feat(cli): add --select-1 cli flag to automatically select unique result (#448)

Adds a `--select-1` flag that enables `tv` to automatically select the
only result and exit when such a case appears.

Fixes #440
This commit is contained in:
Alexandre Pasmantier 2025-04-06 22:00:04 +00:00 committed by GitHub
parent 69c4dcc5c5
commit 4892dc3c3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 235 additions and 46 deletions

View File

@ -1,10 +1,10 @@
.ie \n(.g .ds Aq \(aq .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH television 1 "television 0.11.0" .TH television 1 "television 0.11.5"
.SH NAME .SH NAME
television \- A cross\-platform, fast and extensible general purpose fuzzy finder TUI. television \- A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH SYNOPSIS .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 .SH DESCRIPTION
A cross\-platform, fast and extensible general purpose fuzzy finder TUI. A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
.SH OPTIONS .SH OPTIONS
@ -53,13 +53,6 @@ expressions using the configuration file formalism.
Example: `tv \-\-keybindings=\*(Aqquit="esc";select_next_entry=["down","ctrl\-j"]\*(Aq` Example: `tv \-\-keybindings=\*(Aqquit="esc";select_next_entry=["down","ctrl\-j"]\*(Aq`
.TP .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 \fB\-i\fR, \fB\-\-input\fR=\fISTRING\fR
Input text to pass to the channel to prefill the prompt. 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 prompt by using the `shell_integration` mapping in the configuration
file. file.
.TP .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 \fB\-h\fR, \fB\-\-help\fR
Print help (see a summary with \*(Aq\-h\*(Aq) Print help (see a summary with \*(Aq\-h\*(Aq)
.TP .TP
@ -104,6 +108,6 @@ Initializes shell completion ("tv init zsh")
television\-help(1) television\-help(1)
Print this message or the help of the given subcommand(s) Print this message or the help of the given subcommand(s)
.SH VERSION .SH VERSION
v0.11.0 v0.11.5
.SH AUTHORS .SH AUTHORS
Alexandre Pasmantier <alex.pasmant@gmail.com> Alexandre Pasmantier <alex.pasmant@gmail.com>

View File

@ -5,7 +5,7 @@ use tokio::sync::mpsc;
use tracing::{debug, trace}; use tracing::{debug, trace};
use crate::channels::entry::{Entry, PreviewType}; use crate::channels::entry::{Entry, PreviewType};
use crate::channels::TelevisionChannel; use crate::channels::{OnAir, TelevisionChannel};
use crate::config::Config; use crate::config::Config;
use crate::keymap::Keymap; use crate::keymap::Keymap;
use crate::render::UiState; use crate::render::UiState;
@ -53,10 +53,13 @@ pub struct App {
ui_state_tx: mpsc::UnboundedSender<UiState>, ui_state_tx: mpsc::UnboundedSender<UiState>,
/// Render task handle /// Render task handle
render_task: Option<tokio::task::JoinHandle<Result<()>>>, render_task: Option<tokio::task::JoinHandle<Result<()>>>,
/// Whether the application should automatically select the first entry if there is only one
/// entry available.
select_1: bool,
} }
/// The outcome of an action. /// The outcome of an action.
#[derive(Debug)] #[derive(Debug, PartialEq)]
pub enum ActionOutcome { pub enum ActionOutcome {
Entries(FxHashSet<Entry>), Entries(FxHashSet<Entry>),
Input(String), Input(String),
@ -96,6 +99,7 @@ impl App {
channel: TelevisionChannel, channel: TelevisionChannel,
config: Config, config: Config,
input: Option<String>, input: Option<String>,
select_1: bool,
) -> Self { ) -> Self {
let (action_tx, action_rx) = mpsc::unbounded_channel(); let (action_tx, action_rx) = mpsc::unbounded_channel();
let (render_tx, render_rx) = mpsc::unbounded_channel(); let (render_tx, render_rx) = mpsc::unbounded_channel();
@ -124,6 +128,7 @@ impl App {
ui_state_rx, ui_state_rx,
ui_state_tx, ui_state_tx,
render_task: None, render_task: None,
select_1,
} }
} }
@ -146,6 +151,7 @@ impl App {
is_output_tty: bool, is_output_tty: bool,
headless: bool, headless: bool,
) -> Result<AppOutput> { ) -> Result<AppOutput> {
// Event loop
if !headless { if !headless {
debug!("Starting backend event loop"); debug!("Starting backend event loop");
let event_loop = EventLoop::new(self.tick_rate, true); let event_loop = EventLoop::new(self.tick_rate, true);
@ -169,11 +175,13 @@ impl App {
.expect("Unable to send init render action."); .expect("Unable to send init render action.");
} }
// event handling loop // Main loop
debug!("Starting event handling loop"); debug!("Starting event handling loop");
let action_tx = self.action_tx.clone(); let action_tx = self.action_tx.clone();
let mut event_buf = Vec::with_capacity(EVENT_BUF_SIZE); let mut event_buf = Vec::with_capacity(EVENT_BUF_SIZE);
let mut action_buf = Vec::with_capacity(ACTION_BUF_SIZE); let mut action_buf = Vec::with_capacity(ACTION_BUF_SIZE);
let mut action_outcome;
loop { loop {
// handle event and convert to action // handle event and convert to action
if self if self
@ -192,7 +200,18 @@ impl App {
} }
} }
// It's important that this shouldn't block if no actions are available // 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 { if self.should_quit {
// send a termination signal to the event loop // send a termination signal to the event loop
@ -349,4 +368,52 @@ impl App {
} }
Ok(ActionOutcome::None) Ok(ActionOutcome::None)
} }
/// Maybe select the first entry if there is only one entry available.
fn maybe_select_1(&mut self) -> Option<ActionOutcome> {
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
)]))
);
}
} }

View File

@ -26,6 +26,7 @@ pub struct Channel {
matcher: Matcher<Alias>, matcher: Matcher<Alias>,
file_icon: FileIcon, file_icon: FileIcon,
selected_entries: FxHashSet<Entry>, selected_entries: FxHashSet<Entry>,
crawl_handle: tokio::task::JoinHandle<()>,
} }
const NUM_THREADS: usize = 1; const NUM_THREADS: usize = 1;
@ -44,6 +45,7 @@ fn get_raw_aliases(shell: &str) -> Vec<String> {
.arg("alias") .arg("alias")
.output() .output()
.expect("failed to execute process"); .expect("failed to execute process");
let aliases = String::from_utf8_lossy(&output.stdout); let aliases = String::from_utf8_lossy(&output.stdout);
aliases.lines().map(ToString::to_string).collect() aliases.lines().map(ToString::to_string).collect()
} }
@ -52,12 +54,13 @@ impl Channel {
pub fn new() -> Self { pub fn new() -> Self {
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS)); let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
let injector = matcher.injector(); let injector = matcher.injector();
tokio::spawn(load_aliases(injector)); let crawl_handle = tokio::spawn(load_aliases(injector));
Self { Self {
matcher, matcher,
file_icon: FileIcon::from(FILE_ICON_STR), file_icon: FileIcon::from(FILE_ICON_STR),
selected_entries: HashSet::with_hasher(FxBuildHasher), selected_entries: HashSet::with_hasher(FxBuildHasher),
crawl_handle,
} }
} }
} }
@ -136,7 +139,7 @@ impl OnAir for Channel {
} }
fn running(&self) -> bool { fn running(&self) -> bool {
self.matcher.status.running self.matcher.status.running || !self.crawl_handle.is_finished()
} }
fn shutdown(&self) {} fn shutdown(&self) {}

View File

@ -33,6 +33,7 @@ pub struct Channel {
entries_command: String, entries_command: String,
preview_kind: PreviewKind, preview_kind: PreviewKind,
selected_entries: FxHashSet<Entry>, selected_entries: FxHashSet<Entry>,
crawl_handle: tokio::task::JoinHandle<()>,
} }
impl Default for Channel { impl Default for Channel {
@ -82,7 +83,10 @@ impl Channel {
) -> Self { ) -> Self {
let matcher = Matcher::new(Config::default()); let matcher = Matcher::new(Config::default());
let injector = matcher.injector(); 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 { let preview_kind = match preview_command {
Some(command) => { Some(command) => {
parse_preview_kind(&command).unwrap_or_else(|_| { parse_preview_kind(&command).unwrap_or_else(|_| {
@ -98,6 +102,7 @@ impl Channel {
preview_kind, preview_kind,
name: name.to_string(), name: name.to_string(),
selected_entries: HashSet::with_hasher(FxBuildHasher), selected_entries: HashSet::with_hasher(FxBuildHasher),
crawl_handle,
} }
} }
} }
@ -209,7 +214,7 @@ impl OnAir for Channel {
} }
fn running(&self) -> bool { fn running(&self) -> bool {
self.matcher.status.running self.matcher.status.running || !self.crawl_handle.is_finished()
} }
fn shutdown(&self) {} fn shutdown(&self) {}

View File

@ -136,7 +136,7 @@ impl OnAir for Channel {
} }
fn running(&self) -> bool { fn running(&self) -> bool {
self.matcher.status.running self.matcher.status.running || !self.crawl_handle.is_finished()
} }
fn shutdown(&self) { fn shutdown(&self) {

View File

@ -142,7 +142,7 @@ impl OnAir for Channel {
} }
fn running(&self) -> bool { fn running(&self) -> bool {
self.matcher.status.running self.matcher.status.running || !self.crawl_handle.is_finished()
} }
fn shutdown(&self) { fn shutdown(&self) {

View File

@ -106,7 +106,7 @@ impl OnAir for Channel {
} }
fn running(&self) -> bool { fn running(&self) -> bool {
self.matcher.status.running self.matcher.status.running || !self.crawl_handle.is_finished()
} }
fn shutdown(&self) { fn shutdown(&self) {

View File

@ -15,6 +15,7 @@ pub struct Channel {
matcher: Matcher<String>, matcher: Matcher<String>,
preview_type: PreviewType, preview_type: PreviewType,
selected_entries: FxHashSet<Entry>, selected_entries: FxHashSet<Entry>,
instream_handle: std::thread::JoinHandle<()>,
} }
impl Channel { impl Channel {
@ -22,12 +23,13 @@ impl Channel {
let matcher = Matcher::new(Config::default()); let matcher = Matcher::new(Config::default());
let injector = matcher.injector(); let injector = matcher.injector();
spawn(move || stream_from_stdin(&injector)); let instream_handle = spawn(move || stream_from_stdin(&injector));
Self { Self {
matcher, matcher,
preview_type, preview_type,
selected_entries: HashSet::with_hasher(FxBuildHasher), selected_entries: HashSet::with_hasher(FxBuildHasher),
instream_handle,
} }
} }
} }
@ -38,6 +40,33 @@ impl Default for Channel {
} }
} }
impl<E> From<E> for Channel
where
E: AsRef<Vec<String>>,
{
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); const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn stream_from_stdin(injector: &Injector<String>) { fn stream_from_stdin(injector: &Injector<String>) {
@ -116,7 +145,7 @@ impl OnAir for Channel {
} }
fn running(&self) -> bool { fn running(&self) -> bool {
self.matcher.status.running self.matcher.status.running || !self.instream_handle.is_finished()
} }
fn shutdown(&self) {} fn shutdown(&self) {}

View File

@ -239,7 +239,7 @@ impl OnAir for Channel {
} }
fn running(&self) -> bool { fn running(&self) -> bool {
self.matcher.status.running self.matcher.status.running || !self.crawl_handle.is_finished()
} }
fn shutdown(&self) { fn shutdown(&self) {

View File

@ -90,6 +90,18 @@ pub struct Cli {
#[arg(long, value_name = "STRING", verbatim_doc_comment)] #[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub autocomplete_prompt: Option<String>, pub autocomplete_prompt: Option<String>,
/// 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)] #[command(subcommand)]
pub command: Option<Command>, pub command: Option<Command>,
} }

View File

@ -29,6 +29,7 @@ pub struct PostProcessedCli {
pub working_directory: Option<String>, pub working_directory: Option<String>,
pub autocomplete_prompt: Option<String>, pub autocomplete_prompt: Option<String>,
pub keybindings: Option<KeyBindings>, pub keybindings: Option<KeyBindings>,
pub select_1: bool,
} }
impl Default for PostProcessedCli { impl Default for PostProcessedCli {
@ -44,6 +45,7 @@ impl Default for PostProcessedCli {
working_directory: None, working_directory: None,
autocomplete_prompt: None, autocomplete_prompt: None,
keybindings: None, keybindings: None,
select_1: false,
} }
} }
} }
@ -108,6 +110,7 @@ impl From<Cli> for PostProcessedCli {
working_directory, working_directory,
autocomplete_prompt: cli.autocomplete_prompt, autocomplete_prompt: cli.autocomplete_prompt,
keybindings, keybindings,
select_1: cli.select_1,
} }
} }
} }
@ -322,6 +325,7 @@ mod tests {
command: None, command: None,
working_directory: Some("/home/user".to_string()), working_directory: Some("/home/user".to_string()),
autocomplete_prompt: None, autocomplete_prompt: None,
select_1: false,
}; };
let post_processed_cli: PostProcessedCli = cli.into(); let post_processed_cli: PostProcessedCli = cli.into();
@ -360,6 +364,7 @@ mod tests {
command: None, command: None,
working_directory: None, working_directory: None,
autocomplete_prompt: None, autocomplete_prompt: None,
select_1: false,
}; };
let post_processed_cli: PostProcessedCli = cli.into(); let post_processed_cli: PostProcessedCli = cli.into();
@ -389,6 +394,7 @@ mod tests {
command: None, command: None,
working_directory: None, working_directory: None,
autocomplete_prompt: None, autocomplete_prompt: None,
select_1: false,
}; };
let post_processed_cli: PostProcessedCli = cli.into(); let post_processed_cli: PostProcessedCli = cli.into();
@ -413,6 +419,7 @@ mod tests {
command: None, command: None,
working_directory: None, working_directory: None,
autocomplete_prompt: None, autocomplete_prompt: None,
select_1: false,
}; };
let post_processed_cli: PostProcessedCli = cli.into(); let post_processed_cli: PostProcessedCli = cli.into();
@ -440,6 +447,7 @@ mod tests {
command: None, command: None,
working_directory: None, working_directory: None,
autocomplete_prompt: None, autocomplete_prompt: None,
select_1: false,
}; };
let post_processed_cli: PostProcessedCli = cli.into(); let post_processed_cli: PostProcessedCli = cli.into();

View File

@ -65,8 +65,9 @@ async fn main() -> Result<()> {
CLIPBOARD.with(<_>::default); CLIPBOARD.with(<_>::default);
debug!("Creating application..."); 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()?; stdout().flush()?;
debug!("Running application...");
let output = app.run(stdout().is_terminal(), false).await?; let output = app.run(stdout().is_terminal(), false).await?;
info!("App output: {:?}", output); info!("App output: {:?}", output);
let stdout_handle = stdout().lock(); let stdout_handle = stdout().lock();

View File

@ -84,7 +84,7 @@ where
/// This can be used at any time to push items into the fuzzy matcher. /// This can be used at any time to push items into the fuzzy matcher.
/// ///
/// # Example /// # Example
/// ``` /// ```ignore
/// use television::matcher::{config::Config, Matcher}; /// use television::matcher::{config::Config, Matcher};
/// ///
/// let config = Config::default(); /// let config = Config::default();
@ -134,7 +134,7 @@ where
/// indices of the matched characters. /// indices of the matched characters.
/// ///
/// # Example /// # Example
/// ``` /// ```ignore
/// use television::matcher::{config::Config, Matcher}; /// use television::matcher::{config::Config, Matcher};
/// ///
/// let config = Config::default(); /// let config = Config::default();
@ -190,7 +190,7 @@ where
/// Get a single matched item. /// Get a single matched item.
/// ///
/// # Example /// # Example
/// ``` /// ```ignore
/// use television::matcher::{config::Config, Matcher}; /// use television::matcher::{config::Config, Matcher};
/// ///
/// let config = Config::default(); /// let config = Config::default();

View File

@ -9,7 +9,7 @@ use tokio::{task::JoinHandle, time::timeout};
/// Default timeout for tests. /// Default timeout for tests.
/// ///
/// This is kept quite high to avoid flakiness in CI. /// 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. /// 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 /// The app is started in a separate task and can be interacted with by sending
/// actions to the action channel. /// actions to the action channel.
fn setup_app() -> ( fn setup_app(
channel: Option<TelevisionChannel>,
select_1: bool,
) -> (
JoinHandle<television::app::AppOutput>, JoinHandle<television::app::AppOutput>,
tokio::sync::mpsc::UnboundedSender<Action>, tokio::sync::mpsc::UnboundedSender<Action>,
) { ) {
let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) let chan: TelevisionChannel = channel.unwrap_or_else(|| {
.join("tests") let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target_dir"); .join("tests")
std::env::set_current_dir(&target_dir).unwrap(); .join("target_dir");
let channel = TelevisionChannel::Files( std::env::set_current_dir(&target_dir).unwrap();
television::channels::files::Channel::new(vec![target_dir]), TelevisionChannel::Files(television::channels::files::Channel::new(
); vec![target_dir],
let config = default_config_from_file().unwrap(); ))
});
let mut config = default_config_from_file().unwrap();
// this speeds up the tests
config.application.tick_rate = 100.0;
let input = None; 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 // retrieve the app's action channel handle in order to send a quit action
let tx = app.action_tx.clone(); 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 f = tokio::spawn(async move { app.run_headless().await.unwrap() });
// let the app spin up // let the app spin up
std::thread::sleep(Duration::from_millis(200)); std::thread::sleep(Duration::from_millis(100));
(f, tx) (f, tx)
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 3)] #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_does_quit() { 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 // send a quit action to the app
tx.send(Action::Quit).unwrap(); tx.send(Action::Quit).unwrap();
// assert that the app quits within a default timeout // assert that the app quits within a default timeout
std::thread::sleep(DEFAULT_TIMEOUT / 4); std::thread::sleep(DEFAULT_TIMEOUT);
assert!(f.is_finished()); assert!(f.is_finished());
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 3)] #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_starts_normally() { 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 // 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()); assert!(!f.is_finished());
f.abort(); f.abort();
@ -71,7 +78,7 @@ async fn test_app_starts_normally() {
#[tokio::test(flavor = "multi_thread", worker_threads = 3)] #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_basic_search() { async fn test_app_basic_search() {
let (f, tx) = setup_app(); let (f, tx) = setup_app(None, false);
// send actions to the app // send actions to the app
for c in "file1".chars() { for c in "file1".chars() {
@ -100,7 +107,7 @@ async fn test_app_basic_search() {
#[tokio::test(flavor = "multi_thread", worker_threads = 3)] #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_basic_search_multiselect() { async fn test_app_basic_search_multiselect() {
let (f, tx) = setup_app(); let (f, tx) = setup_app(None, false);
// send actions to the app // send actions to the app
for c in "file".chars() { 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()]) 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());
}