diff --git a/man/tv.1 b/man/tv.1 index c0e2ed9..92a8356 100644 --- a/man/tv.1 +++ b/man/tv.1 @@ -1,10 +1,10 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH television 1 "television 0.11.0" +.TH television 1 "television 0.11.5" .SH NAME television \- A cross\-platform, fast and extensible general purpose fuzzy finder TUI. .SH SYNOPSIS -\fBtelevision\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-\-no\-preview\fR] [\fB\-\-delimiter\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-f\fR|\fB\-\-frame\-rate\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-\-passthrough\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR] +\fBtelevision\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-\-no\-preview\fR] [\fB\-\-delimiter\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-f\fR|\fB\-\-frame\-rate\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-select\-1\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR] .SH DESCRIPTION A cross\-platform, fast and extensible general purpose fuzzy finder TUI. .SH OPTIONS @@ -53,13 +53,6 @@ expressions using the configuration file formalism. Example: `tv \-\-keybindings=\*(Aqquit="esc";select_next_entry=["down","ctrl\-j"]\*(Aq` .TP -\fB\-\-passthrough\-keybindings\fR=\fISTRING\fR -Passthrough keybindings (comma separated, e.g. "q,ctrl\-w,ctrl\-t") - -These keybindings will trigger selection of the current entry and be -passed through to stdout along with the entry to be handled by the -parent process. -.TP \fB\-i\fR, \fB\-\-input\fR=\fISTRING\fR Input text to pass to the channel to prefill the prompt. @@ -73,6 +66,17 @@ This can be used to automatically select a channel based on the input prompt by using the `shell_integration` mapping in the configuration file. .TP +\fB\-\-select\-1\fR +Automatically select and output the first entry if there is only one +entry. + +Note that most channels stream entries asynchronously which means that +knowing if there\*(Aqs only one entry will require waiting for the channel +to finish loading first. + +For most channels and workloads this shouldn\*(Aqt be a problem since the +loading times are usually very short and will go unnoticed by the user. +.TP \fB\-h\fR, \fB\-\-help\fR Print help (see a summary with \*(Aq\-h\*(Aq) .TP @@ -104,6 +108,6 @@ Initializes shell completion ("tv init zsh") television\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION -v0.11.0 +v0.11.5 .SH AUTHORS Alexandre Pasmantier diff --git a/television/app.rs b/television/app.rs index b3613e1..f1f8909 100644 --- a/television/app.rs +++ b/television/app.rs @@ -5,7 +5,7 @@ use tokio::sync::mpsc; use tracing::{debug, trace}; use crate::channels::entry::{Entry, PreviewType}; -use crate::channels::TelevisionChannel; +use crate::channels::{OnAir, TelevisionChannel}; use crate::config::Config; use crate::keymap::Keymap; use crate::render::UiState; @@ -53,10 +53,13 @@ pub struct App { ui_state_tx: mpsc::UnboundedSender, /// Render task handle render_task: Option>>, + /// Whether the application should automatically select the first entry if there is only one + /// entry available. + select_1: bool, } /// The outcome of an action. -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum ActionOutcome { Entries(FxHashSet), Input(String), @@ -96,6 +99,7 @@ impl App { channel: TelevisionChannel, config: Config, input: Option, + select_1: bool, ) -> Self { let (action_tx, action_rx) = mpsc::unbounded_channel(); let (render_tx, render_rx) = mpsc::unbounded_channel(); @@ -124,6 +128,7 @@ impl App { ui_state_rx, ui_state_tx, render_task: None, + select_1, } } @@ -146,6 +151,7 @@ impl App { is_output_tty: bool, headless: bool, ) -> Result { + // Event loop if !headless { debug!("Starting backend event loop"); let event_loop = EventLoop::new(self.tick_rate, true); @@ -169,11 +175,13 @@ impl App { .expect("Unable to send init render action."); } - // event handling loop + // Main loop debug!("Starting event handling loop"); let action_tx = self.action_tx.clone(); let mut event_buf = Vec::with_capacity(EVENT_BUF_SIZE); let mut action_buf = Vec::with_capacity(ACTION_BUF_SIZE); + let mut action_outcome; + loop { // handle event and convert to action if self @@ -192,7 +200,18 @@ impl App { } } // It's important that this shouldn't block if no actions are available - let action_outcome = self.handle_actions(&mut action_buf).await?; + action_outcome = self.handle_actions(&mut action_buf).await?; + + // If `self.select_1` is true, the channel is not running, and there is + // only one entry available, automatically select the first entry. + if self.select_1 + && !self.television.channel.running() + && self.television.channel.total_count() == 1 + { + if let Some(outcome) = self.maybe_select_1() { + action_outcome = outcome; + } + } if self.should_quit { // send a termination signal to the event loop @@ -349,4 +368,52 @@ impl App { } Ok(ActionOutcome::None) } + + /// Maybe select the first entry if there is only one entry available. + fn maybe_select_1(&mut self) -> Option { + debug!("Automatically selecting the first entry"); + if let Some(unique_entry) = + self.television.results_picker.entries.first() + { + self.should_quit = true; + + if !self.render_tx.is_closed() { + let _ = self.render_tx.send(RenderingTask::Quit); + } + + return Some(ActionOutcome::Entries(FxHashSet::from_iter([ + unique_entry.clone(), + ]))); + } + None + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::channels::stdin::Channel as StdinChannel; + + #[test] + fn test_maybe_select_1() { + let mut app = App::new( + TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)), + Config::default(), + None, + true, + ); + app.television + .results_picker + .entries + .push(Entry::new("test".to_string(), PreviewType::None)); + let outcome = app.maybe_select_1(); + assert!(outcome.is_some()); + assert_eq!( + outcome.unwrap(), + ActionOutcome::Entries(FxHashSet::from_iter([Entry::new( + "test".to_string(), + PreviewType::None + )])) + ); + } } diff --git a/television/channels/alias.rs b/television/channels/alias.rs index 9ebbf7f..fed01d0 100644 --- a/television/channels/alias.rs +++ b/television/channels/alias.rs @@ -26,6 +26,7 @@ pub struct Channel { matcher: Matcher, file_icon: FileIcon, selected_entries: FxHashSet, + crawl_handle: tokio::task::JoinHandle<()>, } const NUM_THREADS: usize = 1; @@ -44,6 +45,7 @@ fn get_raw_aliases(shell: &str) -> Vec { .arg("alias") .output() .expect("failed to execute process"); + let aliases = String::from_utf8_lossy(&output.stdout); aliases.lines().map(ToString::to_string).collect() } @@ -52,12 +54,13 @@ impl Channel { pub fn new() -> Self { let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS)); let injector = matcher.injector(); - tokio::spawn(load_aliases(injector)); + let crawl_handle = tokio::spawn(load_aliases(injector)); Self { matcher, file_icon: FileIcon::from(FILE_ICON_STR), selected_entries: HashSet::with_hasher(FxBuildHasher), + crawl_handle, } } } @@ -136,7 +139,7 @@ impl OnAir for Channel { } fn running(&self) -> bool { - self.matcher.status.running + self.matcher.status.running || !self.crawl_handle.is_finished() } fn shutdown(&self) {} diff --git a/television/channels/cable.rs b/television/channels/cable.rs index 4aade4f..839be19 100644 --- a/television/channels/cable.rs +++ b/television/channels/cable.rs @@ -33,6 +33,7 @@ pub struct Channel { entries_command: String, preview_kind: PreviewKind, selected_entries: FxHashSet, + crawl_handle: tokio::task::JoinHandle<()>, } impl Default for Channel { @@ -82,7 +83,10 @@ impl Channel { ) -> Self { let matcher = Matcher::new(Config::default()); let injector = matcher.injector(); - tokio::spawn(load_candidates(entries_command.to_string(), injector)); + let crawl_handle = tokio::spawn(load_candidates( + entries_command.to_string(), + injector, + )); let preview_kind = match preview_command { Some(command) => { parse_preview_kind(&command).unwrap_or_else(|_| { @@ -98,6 +102,7 @@ impl Channel { preview_kind, name: name.to_string(), selected_entries: HashSet::with_hasher(FxBuildHasher), + crawl_handle, } } } @@ -209,7 +214,7 @@ impl OnAir for Channel { } fn running(&self) -> bool { - self.matcher.status.running + self.matcher.status.running || !self.crawl_handle.is_finished() } fn shutdown(&self) {} diff --git a/television/channels/dirs.rs b/television/channels/dirs.rs index 2e39a4b..6756302 100644 --- a/television/channels/dirs.rs +++ b/television/channels/dirs.rs @@ -136,7 +136,7 @@ impl OnAir for Channel { } fn running(&self) -> bool { - self.matcher.status.running + self.matcher.status.running || !self.crawl_handle.is_finished() } fn shutdown(&self) { diff --git a/television/channels/files.rs b/television/channels/files.rs index 5d77ce3..7361272 100644 --- a/television/channels/files.rs +++ b/television/channels/files.rs @@ -142,7 +142,7 @@ impl OnAir for Channel { } fn running(&self) -> bool { - self.matcher.status.running + self.matcher.status.running || !self.crawl_handle.is_finished() } fn shutdown(&self) { diff --git a/television/channels/git_repos.rs b/television/channels/git_repos.rs index d459730..0511aef 100644 --- a/television/channels/git_repos.rs +++ b/television/channels/git_repos.rs @@ -106,7 +106,7 @@ impl OnAir for Channel { } fn running(&self) -> bool { - self.matcher.status.running + self.matcher.status.running || !self.crawl_handle.is_finished() } fn shutdown(&self) { diff --git a/television/channels/stdin.rs b/television/channels/stdin.rs index 38958e1..7b32b25 100644 --- a/television/channels/stdin.rs +++ b/television/channels/stdin.rs @@ -15,6 +15,7 @@ pub struct Channel { matcher: Matcher, preview_type: PreviewType, selected_entries: FxHashSet, + instream_handle: std::thread::JoinHandle<()>, } impl Channel { @@ -22,12 +23,13 @@ impl Channel { let matcher = Matcher::new(Config::default()); let injector = matcher.injector(); - spawn(move || stream_from_stdin(&injector)); + let instream_handle = spawn(move || stream_from_stdin(&injector)); Self { matcher, preview_type, selected_entries: HashSet::with_hasher(FxBuildHasher), + instream_handle, } } } @@ -38,6 +40,33 @@ impl Default for Channel { } } +impl From for Channel +where + E: AsRef>, +{ + fn from(entries: E) -> Self { + let matcher = Matcher::new(Config::default()); + let injector = matcher.injector(); + + let entries = entries.as_ref().clone(); + + let instream_handle = spawn(move || { + for entry in entries { + injector.push(entry.clone(), |e, cols| { + cols[0] = e.to_string().into(); + }); + } + }); + + Self { + matcher, + preview_type: PreviewType::default(), + selected_entries: HashSet::with_hasher(FxBuildHasher), + instream_handle, + } + } +} + const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); fn stream_from_stdin(injector: &Injector) { @@ -116,7 +145,7 @@ impl OnAir for Channel { } fn running(&self) -> bool { - self.matcher.status.running + self.matcher.status.running || !self.instream_handle.is_finished() } fn shutdown(&self) {} diff --git a/television/channels/text.rs b/television/channels/text.rs index 6dd2e93..0159479 100644 --- a/television/channels/text.rs +++ b/television/channels/text.rs @@ -239,7 +239,7 @@ impl OnAir for Channel { } fn running(&self) -> bool { - self.matcher.status.running + self.matcher.status.running || !self.crawl_handle.is_finished() } fn shutdown(&self) { diff --git a/television/cli/args.rs b/television/cli/args.rs index f014fb1..5206fa5 100644 --- a/television/cli/args.rs +++ b/television/cli/args.rs @@ -90,6 +90,18 @@ pub struct Cli { #[arg(long, value_name = "STRING", verbatim_doc_comment)] pub autocomplete_prompt: Option, + /// Automatically select and output the first entry if there is only one + /// entry. + /// + /// Note that most channels stream entries asynchronously which means that + /// knowing if there's only one entry will require waiting for the channel + /// to finish loading first. + /// + /// For most channels and workloads this shouldn't be a problem since the + /// loading times are usually very short and will go unnoticed by the user. + #[arg(long, default_value = "false", verbatim_doc_comment)] + pub select_1: bool, + #[command(subcommand)] pub command: Option, } diff --git a/television/cli/mod.rs b/television/cli/mod.rs index 02044f7..0eb1edd 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -29,6 +29,7 @@ pub struct PostProcessedCli { pub working_directory: Option, pub autocomplete_prompt: Option, pub keybindings: Option, + pub select_1: bool, } impl Default for PostProcessedCli { @@ -44,6 +45,7 @@ impl Default for PostProcessedCli { working_directory: None, autocomplete_prompt: None, keybindings: None, + select_1: false, } } } @@ -108,6 +110,7 @@ impl From for PostProcessedCli { working_directory, autocomplete_prompt: cli.autocomplete_prompt, keybindings, + select_1: cli.select_1, } } } @@ -322,6 +325,7 @@ mod tests { command: None, working_directory: Some("/home/user".to_string()), autocomplete_prompt: None, + select_1: false, }; let post_processed_cli: PostProcessedCli = cli.into(); @@ -360,6 +364,7 @@ mod tests { command: None, working_directory: None, autocomplete_prompt: None, + select_1: false, }; let post_processed_cli: PostProcessedCli = cli.into(); @@ -389,6 +394,7 @@ mod tests { command: None, working_directory: None, autocomplete_prompt: None, + select_1: false, }; let post_processed_cli: PostProcessedCli = cli.into(); @@ -413,6 +419,7 @@ mod tests { command: None, working_directory: None, autocomplete_prompt: None, + select_1: false, }; let post_processed_cli: PostProcessedCli = cli.into(); @@ -440,6 +447,7 @@ mod tests { command: None, working_directory: None, autocomplete_prompt: None, + select_1: false, }; let post_processed_cli: PostProcessedCli = cli.into(); diff --git a/television/main.rs b/television/main.rs index 8a9eb5c..1c02fdd 100644 --- a/television/main.rs +++ b/television/main.rs @@ -65,8 +65,9 @@ async fn main() -> Result<()> { CLIPBOARD.with(<_>::default); debug!("Creating application..."); - let mut app = App::new(channel, config, args.input); + let mut app = App::new(channel, config, args.input, args.select_1); stdout().flush()?; + debug!("Running application..."); let output = app.run(stdout().is_terminal(), false).await?; info!("App output: {:?}", output); let stdout_handle = stdout().lock(); diff --git a/television/matcher/mod.rs b/television/matcher/mod.rs index 6093ca5..778f0cc 100644 --- a/television/matcher/mod.rs +++ b/television/matcher/mod.rs @@ -84,7 +84,7 @@ where /// This can be used at any time to push items into the fuzzy matcher. /// /// # Example - /// ``` + /// ```ignore /// use television::matcher::{config::Config, Matcher}; /// /// let config = Config::default(); @@ -134,7 +134,7 @@ where /// indices of the matched characters. /// /// # Example - /// ``` + /// ```ignore /// use television::matcher::{config::Config, Matcher}; /// /// let config = Config::default(); @@ -190,7 +190,7 @@ where /// Get a single matched item. /// /// # Example - /// ``` + /// ```ignore /// use television::matcher::{config::Config, Matcher}; /// /// let config = Config::default(); diff --git a/tests/app.rs b/tests/app.rs index ed9d81e..f087215 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -9,7 +9,7 @@ use tokio::{task::JoinHandle, time::timeout}; /// Default timeout for tests. /// /// This is kept quite high to avoid flakiness in CI. -const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); +const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); /// Sets up an app with a file channel and default config. /// @@ -18,21 +18,28 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); /// /// The app is started in a separate task and can be interacted with by sending /// actions to the action channel. -fn setup_app() -> ( +fn setup_app( + channel: Option, + select_1: bool, +) -> ( JoinHandle, tokio::sync::mpsc::UnboundedSender, ) { - let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("target_dir"); - std::env::set_current_dir(&target_dir).unwrap(); - let channel = TelevisionChannel::Files( - television::channels::files::Channel::new(vec![target_dir]), - ); - let config = default_config_from_file().unwrap(); + let chan: TelevisionChannel = channel.unwrap_or_else(|| { + let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("target_dir"); + std::env::set_current_dir(&target_dir).unwrap(); + TelevisionChannel::Files(television::channels::files::Channel::new( + vec![target_dir], + )) + }); + let mut config = default_config_from_file().unwrap(); + // this speeds up the tests + config.application.tick_rate = 100.0; let input = None; - let mut app = App::new(channel, config, input); + let mut app = App::new(chan, config, input, select_1); // retrieve the app's action channel handle in order to send a quit action let tx = app.action_tx.clone(); @@ -41,29 +48,29 @@ fn setup_app() -> ( let f = tokio::spawn(async move { app.run_headless().await.unwrap() }); // let the app spin up - std::thread::sleep(Duration::from_millis(200)); + std::thread::sleep(Duration::from_millis(100)); (f, tx) } #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_does_quit() { - let (f, tx) = setup_app(); + let (f, tx) = setup_app(None, false); // send a quit action to the app tx.send(Action::Quit).unwrap(); // assert that the app quits within a default timeout - std::thread::sleep(DEFAULT_TIMEOUT / 4); + std::thread::sleep(DEFAULT_TIMEOUT); assert!(f.is_finished()); } #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_starts_normally() { - let (f, _) = setup_app(); + let (f, _) = setup_app(None, false); // assert that the app is still running after the default timeout - std::thread::sleep(DEFAULT_TIMEOUT / 4); + std::thread::sleep(DEFAULT_TIMEOUT); assert!(!f.is_finished()); f.abort(); @@ -71,7 +78,7 @@ async fn test_app_starts_normally() { #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_basic_search() { - let (f, tx) = setup_app(); + let (f, tx) = setup_app(None, false); // send actions to the app for c in "file1".chars() { @@ -100,7 +107,7 @@ async fn test_app_basic_search() { #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_basic_search_multiselect() { - let (f, tx) = setup_app(); + let (f, tx) = setup_app(None, false); // send actions to the app for c in "file".chars() { @@ -132,3 +139,56 @@ async fn test_app_basic_search_multiselect() { HashSet::from([&"file1.txt".to_string(), &"file2.txt".to_string()]) ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_app_exits_when_select_1_and_only_one_result() { + let channel = + TelevisionChannel::Stdin(television::channels::stdin::Channel::from( + vec!["file1.txt".to_string()], + )); + let (f, tx) = setup_app(Some(channel), true); + + // tick a few times to get the results + for _ in 0..=10 { + tx.send(Action::Tick).unwrap(); + } + + // check the output with a timeout + // Note: we don't need to send a confirm action here, as the app should + // exit automatically when there's only one result + let output = timeout(DEFAULT_TIMEOUT, f) + .await + .expect("app did not finish within the default timeout") + .unwrap(); + + assert!(output.selected_entries.is_some()); + assert_eq!( + &output + .selected_entries + .unwrap() + .drain() + .next() + .unwrap() + .name, + "file1.txt" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() { + let channel = + TelevisionChannel::Stdin(television::channels::stdin::Channel::from( + vec!["file1.txt".to_string(), "file2.txt".to_string()], + )); + let (f, tx) = setup_app(Some(channel), true); + + // tick a few times to get the results + for _ in 0..=10 { + tx.send(Action::Tick).unwrap(); + } + + // check that the app is still running after the default timeout + let output = timeout(DEFAULT_TIMEOUT, f).await; + + assert!(output.is_err()); +}