diff --git a/.config/config.toml b/.config/config.toml index 268de0b..71e2769 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -15,8 +15,9 @@ # General settings # ---------------------------------------------------------------------------- -frame_rate = 60 # DEPRECATED: this option is no longer used +frame_rate = 60 # DEPRECATED: this option is no longer used tick_rate = 50 +default_channel = "files" [ui] # Whether to use nerd font icons in the UI diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70779bd..32fcda2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ jobs: test: name: Test Suite runs-on: ubuntu-latest + env: + RUST_BACKTRACE: 1 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -18,7 +20,7 @@ jobs: uses: dtolnay/rust-toolchain@nightly - uses: Swatinem/rust-cache@v2 - name: Install fd - run: sudo apt-get install -y fd-find + run: sudo apt install -y fd-find && sudo ln -s $(which fdfind) /usr/bin/fd - name: Run tests run: cargo test --locked --all-features --workspace -- --nocapture diff --git a/cable/unix-channels.toml b/cable/unix-channels.toml index 75c008b..c0c3a3c 100644 --- a/cable/unix-channels.toml +++ b/cable/unix-channels.toml @@ -4,6 +4,13 @@ name = "files" source_command = "fd -t f" preview_command = ":files:" +# Text +[[cable_channel]] +name = "text" +source_command = "rg . --no-heading --line-number" +preview_command = "bat -n --color=always {0} -H {1}" +preview_delimiter = ":" + # Directories [[cable_channel]] name = "dirs" diff --git a/television/cable.rs b/television/cable.rs index fcb4a28..71d35ff 100644 --- a/television/cable.rs +++ b/television/cable.rs @@ -10,9 +10,9 @@ use crate::config::get_config_dir; /// Just a proxy struct to deserialize prototypes #[derive(Debug, serde::Deserialize, Default)] -struct ChannelPrototypes { +pub struct ChannelPrototypes { #[serde(rename = "cable_channel")] - prototypes: Vec, + pub prototypes: Vec, } const CABLE_FILE_NAME_SUFFIX: &str = "channels"; @@ -85,6 +85,10 @@ pub fn load_cable_channels() -> Result { ); debug!("Loaded cable channels: {:?}", prototypes); + if prototypes.is_empty() { + error!("No cable channels found"); + return Err(anyhow::anyhow!("No cable channels found")); + } let mut cable_channels = FxHashMap::default(); for prototype in prototypes { diff --git a/television/channels/cable.rs b/television/channels/cable.rs index 423ea52..7731627 100644 --- a/television/channels/cable.rs +++ b/television/channels/cable.rs @@ -13,11 +13,14 @@ use regex::Regex; use rustc_hash::{FxBuildHasher, FxHashSet}; use tracing::debug; -use crate::channels::entry::{Entry, PreviewCommand, PreviewType}; use crate::channels::OnAir; use crate::matcher::Matcher; use crate::matcher::{config::Config, injector::Injector}; use crate::utils::command::shell_command; +use crate::{ + cable::ChannelPrototypes, + channels::entry::{Entry, PreviewCommand, PreviewType}, +}; #[derive(Debug, Clone, PartialEq)] pub enum PreviewKind { @@ -39,10 +42,10 @@ pub struct Channel { impl Default for Channel { fn default() -> Self { Self::new( - "Files", + "files", "find . -type f", false, - Some(PreviewCommand::new("bat -n --color=always {}", ":")), + Some(PreviewCommand::new("cat {}", ":")), ) } } @@ -290,7 +293,7 @@ impl Display for CableChannelPrototype { } } -#[derive(Debug, serde::Deserialize, Default)] +#[derive(Debug, serde::Deserialize)] pub struct CableChannels(pub FxHashMap); impl Deref for CableChannels { @@ -300,3 +303,25 @@ impl Deref for CableChannels { &self.0 } } + +#[cfg(unix)] +const DEFAULT_CABLE_CHANNELS_FILE: &str = + include_str!("../../cable/unix-channels.toml"); +#[cfg(not(unix))] +const DEFAULT_CABLE_CHANNELS_FILE: &str = + include_str!("../../cable/windows-channels.toml"); + +impl Default for CableChannels { + /// Fallback to the default cable channels specification (the template file + /// included in the repo). + fn default() -> Self { + let pts = + toml::from_str::(DEFAULT_CABLE_CHANNELS_FILE) + .expect("Unable to parse default cable channels"); + let mut channels = FxHashMap::default(); + for prototype in pts.prototypes { + channels.insert(prototype.name.clone(), prototype); + } + CableChannels(channels) + } +} diff --git a/television/cli/args.rs b/television/cli/args.rs index 29a2543..a319b4a 100644 --- a/television/cli/args.rs +++ b/television/cli/args.rs @@ -9,13 +9,8 @@ pub struct Cli { /// A list of the available channels can be displayed using the /// `list-channels` command. The channel can also be changed from within /// the application. - #[arg( - value_enum, - default_value = "files", - index = 1, - verbatim_doc_comment - )] - pub channel: String, + #[arg(value_enum, index = 1, verbatim_doc_comment)] + pub channel: Option, /// A preview command to use with the stdin channel. /// diff --git a/television/cli/mod.rs b/television/cli/mod.rs index 6a31276..a9f1f8b 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -4,10 +4,10 @@ use std::path::Path; use anyhow::{anyhow, Result}; use tracing::debug; -use crate::channels::cable::{parse_preview_kind, PreviewKind}; +use crate::channels::cable::{parse_preview_kind, CableChannels, PreviewKind}; use crate::channels::{cable::CableChannelPrototype, entry::PreviewCommand}; use crate::cli::args::{Cli, Command}; -use crate::config::KeyBindings; +use crate::config::{KeyBindings, DEFAULT_CHANNEL}; use crate::{ cable, config::{get_config_dir, get_data_dir}, @@ -86,22 +86,37 @@ impl From for PostProcessedCli { let channel: CableChannelPrototype; let working_directory: Option; - match parse_channel(&cli.channel) { - Ok(p) => { - channel = p; - working_directory = cli.working_directory; - } - Err(_) => { - // if the path is provided as first argument and it exists, use it as the working - // directory and default to the files channel - if cli.working_directory.is_none() - && Path::new(&cli.channel).exists() - { - channel = CableChannelPrototype::default(); - working_directory = Some(cli.channel.clone()); - } else { - unknown_channel_exit(&cli.channel); - unreachable!(); + let cable_channels = cable::load_cable_channels().unwrap_or_default(); + if cli.channel.is_none() { + channel = cable_channels + .get(DEFAULT_CHANNEL) + .expect("Default channel not found in cable channels") + .clone(); + working_directory = cli.working_directory; + } else { + let cli_channel = cli.channel.as_ref().unwrap().to_owned(); + match parse_channel(&cli_channel, &cable_channels) { + Ok(p) => { + channel = p; + working_directory = cli.working_directory; + } + Err(_) => { + // if the path is provided as first argument and it exists, use it as the working + // directory and default to the files channel + if cli.working_directory.is_none() + && Path::new(&cli_channel).exists() + { + channel = cable_channels + .get(DEFAULT_CHANNEL) + .expect( + "Default channel not found in cable channels", + ) + .clone(); + working_directory = Some(cli.channel.unwrap().clone()); + } else { + unknown_channel_exit(&cli.channel.unwrap()); + unreachable!(); + } } } } @@ -157,15 +172,17 @@ fn parse_keybindings_literal( toml::from_str(&toml_definition).map_err(|e| anyhow!(e)) } -pub fn parse_channel(channel: &str) -> Result { - let cable_channels = cable::load_cable_channels().unwrap_or_default(); +pub fn parse_channel( + channel: &str, + cable_channels: &CableChannels, +) -> Result { // try to parse the channel as a cable channel match cable_channels .iter() .find(|(k, _)| k.to_lowercase() == channel) { Some((_, v)) => Ok(v.clone()), - None => Err(anyhow!("Unknown channel: {channel}")), + None => Err(anyhow!("The following channel wasn't found among cable channels: {channel}")), } } @@ -201,13 +218,18 @@ pub fn list_channels() { pub fn guess_channel_from_prompt( prompt: &str, command_mapping: &FxHashMap, - fallback_channel: CableChannelPrototype, + fallback_channel: &str, + cable_channels: &CableChannels, ) -> Result { debug!("Guessing channel from prompt: {}", prompt); // git checkout -qf // --- -------- --- <--------- + let fallback = cable_channels + .get(fallback_channel) + .expect("Fallback channel not found in cable channels") + .clone(); if prompt.trim().is_empty() { - return Ok(fallback_channel); + return Ok(fallback); } let rev_prompt_words = prompt.split_whitespace().rev(); let mut stack = Vec::new(); @@ -221,7 +243,7 @@ pub fn guess_channel_from_prompt( for word in rev_prompt_words.clone() { // if the stack is empty, we have a match if stack.is_empty() { - return parse_channel(channel); + return parse_channel(channel, cable_channels); } // if the word matches the top of the stack, pop it if stack.last() == Some(&word) { @@ -230,14 +252,14 @@ pub fn guess_channel_from_prompt( } // if the stack is empty, we have a match if stack.is_empty() { - return parse_channel(channel); + return parse_channel(channel, cable_channels); } // reset the stack stack.clear(); } debug!("No match found, falling back to default channel"); - Ok(fallback_channel) + Ok(fallback) } const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION"); @@ -285,7 +307,7 @@ mod tests { #[allow(clippy::float_cmp)] fn test_from_cli() { let cli = Cli { - channel: "files".to_string(), + channel: Some("files".to_string()), preview: Some("bat -n --color=always {}".to_string()), delimiter: ":".to_string(), working_directory: Some("/home/user".to_string()), @@ -317,7 +339,7 @@ mod tests { #[allow(clippy::float_cmp)] fn test_from_cli_no_args() { let cli = Cli { - channel: ".".to_string(), + channel: Some(".".to_string()), delimiter: ":".to_string(), ..Default::default() }; @@ -338,7 +360,7 @@ mod tests { #[test] fn test_builtin_previewer_files() { let cli = Cli { - channel: "files".to_string(), + channel: Some("files".to_string()), preview: Some(":files:".to_string()), delimiter: ":".to_string(), ..Default::default() @@ -355,7 +377,7 @@ mod tests { #[test] fn test_builtin_previewer_env() { let cli = Cli { - channel: "files".to_string(), + channel: Some("files".to_string()), preview: Some(":env_var:".to_string()), delimiter: ":".to_string(), ..Default::default() @@ -372,7 +394,7 @@ mod tests { #[test] fn test_custom_keybindings() { let cli = Cli { - channel: "files".to_string(), + channel: Some("files".to_string()), preview: Some(":env_var:".to_string()), delimiter: ":".to_string(), keybindings: Some( @@ -395,15 +417,16 @@ mod tests { } /// Returns a tuple containing a command mapping and a fallback channel. - fn guess_channel_from_prompt_setup( - ) -> (FxHashMap, CableChannelPrototype) { + fn guess_channel_from_prompt_setup<'a>( + ) -> (FxHashMap, &'a str, CableChannels) { let mut command_mapping = FxHashMap::default(); command_mapping.insert("vim".to_string(), "files".to_string()); command_mapping.insert("export".to_string(), "env".to_string()); ( command_mapping, - CableChannelPrototype::new("env", "", false, None, None), + "env", + cable::load_cable_channels().unwrap_or_default(), ) } @@ -411,11 +434,16 @@ mod tests { fn test_guess_channel_from_prompt_present() { let prompt = "vim -d file1"; - let (command_mapping, fallback) = guess_channel_from_prompt_setup(); + let (command_mapping, fallback, channels) = + guess_channel_from_prompt_setup(); - let channel = - guess_channel_from_prompt(prompt, &command_mapping, fallback) - .unwrap(); + let channel = guess_channel_from_prompt( + prompt, + &command_mapping, + fallback, + &channels, + ) + .unwrap(); assert_eq!(channel.name, "files"); } @@ -424,31 +452,35 @@ mod tests { fn test_guess_channel_from_prompt_fallback() { let prompt = "git checkout "; - let (command_mapping, fallback) = guess_channel_from_prompt_setup(); + let (command_mapping, fallback, channels) = + guess_channel_from_prompt_setup(); let channel = guess_channel_from_prompt( prompt, &command_mapping, - fallback.clone(), + fallback, + &channels, ) .unwrap(); - assert_eq!(channel, fallback); + assert_eq!(channel.name, fallback); } #[test] fn test_guess_channel_from_prompt_empty() { let prompt = ""; - let (command_mapping, fallback) = guess_channel_from_prompt_setup(); + let (command_mapping, fallback, channels) = + guess_channel_from_prompt_setup(); let channel = guess_channel_from_prompt( prompt, &command_mapping, - fallback.clone(), + fallback, + &channels, ) .unwrap(); - assert_eq!(channel, fallback); + assert_eq!(channel.name, fallback); } } diff --git a/television/config/mod.rs b/television/config/mod.rs index 429ab90..25907ba 100644 --- a/television/config/mod.rs +++ b/television/config/mod.rs @@ -36,6 +36,15 @@ pub struct AppConfig { pub frame_rate: f64, #[serde(default = "default_tick_rate")] pub tick_rate: f64, + /// The default channel to use when no channel is specified + #[serde(default = "default_channel")] + pub default_channel: String, +} + +pub const DEFAULT_CHANNEL: &str = "files"; + +fn default_channel() -> String { + DEFAULT_CHANNEL.to_string() } impl Hash for AppConfig { diff --git a/television/main.rs b/television/main.rs index 79a7ed2..88c2ca0 100644 --- a/television/main.rs +++ b/television/main.rs @@ -5,8 +5,8 @@ use std::process::exit; use anyhow::Result; use clap::Parser; -use television::channels::cable::PreviewKind; -use television::cli::parse_channel; +use television::cable; +use television::channels::cable::{CableChannels, PreviewKind}; use television::utils::clipboard::CLIPBOARD; use tracing::{debug, error, info}; @@ -43,6 +43,9 @@ async fn main() -> Result<()> { debug!("Loading configuration..."); let mut config = Config::new(&ConfigEnv::init()?)?; + debug!("Loading cable channels..."); + let cable_channels = cable::load_cable_channels().unwrap_or_default(); + // optionally handle subcommands debug!("Handling subcommands..."); args.command @@ -58,8 +61,12 @@ async fn main() -> Result<()> { // determine the channel to use based on the CLI arguments and configuration debug!("Determining channel..."); - let channel = - determine_channel(args.clone(), &config, is_readable_stdin())?; + let channel = determine_channel( + args.clone(), + &config, + is_readable_stdin(), + &cable_channels, + )?; CLIPBOARD.with(<_>::default); @@ -146,6 +153,7 @@ pub fn determine_channel( args: PostProcessedCli, config: &Config, readable_stdin: bool, + cable_channels: &CableChannels, ) -> Result { if readable_stdin { debug!("Using stdin channel"); @@ -163,7 +171,8 @@ pub fn determine_channel( let channel = guess_channel_from_prompt( &prompt, &config.shell_integration.commands, - parse_channel(&config.shell_integration.fallback_channel)?, + &config.shell_integration.fallback_channel, + cable_channels, )?; debug!("Using guessed channel: {:?}", channel); Ok(TelevisionChannel::Cable(channel.into())) @@ -176,7 +185,9 @@ pub fn determine_channel( #[cfg(test)] mod tests { use rustc_hash::FxHashMap; - use television::channels::cable::CableChannelPrototype; + use television::{ + cable::load_cable_channels, channels::cable::CableChannelPrototype, + }; use super::*; @@ -185,9 +196,13 @@ mod tests { config: &Config, readable_stdin: bool, expected_channel: &TelevisionChannel, + cable_channels: Option, ) { + let channels: CableChannels = cable_channels + .unwrap_or_else(|| load_cable_channels().unwrap_or_default()); let channel = - determine_channel(args.clone(), config, readable_stdin).unwrap(); + determine_channel(args.clone(), config, readable_stdin, &channels) + .unwrap(); assert_eq!( channel.name(), @@ -212,6 +227,7 @@ mod tests { &config, true, &TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)), + None, ); } @@ -242,7 +258,13 @@ mod tests { }; config.shell_integration.merge_triggers(); - assert_is_correct_channel(&args, &config, false, &expected_channel); + assert_is_correct_channel( + &args, + &config, + false, + &expected_channel, + None, + ); } #[tokio::test] @@ -262,6 +284,7 @@ mod tests { CableChannelPrototype::new("dirs", "", false, None, None) .into(), ), + None, ); }