Merge branch 'main' into aiden/shell-completion

This commit is contained in:
Alex Pasmantier 2025-07-25 21:56:34 +02:00 committed by GitHub
commit faf984c72f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 154 additions and 10 deletions

View File

@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use serde_with::{OneOrMany, serde_as}; use serde_with::{OneOrMany, serde_as};
use std::fmt::Display; use std::fmt::Display;
use crate::event::Key;
/// The different actions that can be performed by the application. /// The different actions that can be performed by the application.
#[derive( #[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord,
@ -47,6 +49,8 @@ pub enum Action {
ConfirmSelection, ConfirmSelection,
/// Select the entry currently under the cursor and exit the application. /// Select the entry currently under the cursor and exit the application.
SelectAndExit, SelectAndExit,
/// Confirm selection using one of the `expect` keys.
Expect(Key),
/// Select the next entry in the currently focused list. /// Select the next entry in the currently focused list.
SelectNextEntry, SelectNextEntry,
/// Select the previous entry in the currently focused list. /// Select the previous entry in the currently focused list.
@ -93,8 +97,6 @@ pub enum Action {
#[serde(skip)] #[serde(skip)]
NoOp, NoOp,
// Channel actions // Channel actions
/// FIXME: clean this up
ToggleSendToChannel,
/// Toggle between different source commands. /// Toggle between different source commands.
CycleSources, CycleSources,
/// Reload the current source command. /// Reload the current source command.
@ -326,6 +328,7 @@ impl Display for Action {
Action::ToggleSelectionUp => write!(f, "toggle_selection_up"), Action::ToggleSelectionUp => write!(f, "toggle_selection_up"),
Action::ConfirmSelection => write!(f, "confirm_selection"), Action::ConfirmSelection => write!(f, "confirm_selection"),
Action::SelectAndExit => write!(f, "select_and_exit"), Action::SelectAndExit => write!(f, "select_and_exit"),
Action::Expect(_) => write!(f, "expect"),
Action::SelectNextEntry => write!(f, "select_next_entry"), Action::SelectNextEntry => write!(f, "select_next_entry"),
Action::SelectPrevEntry => write!(f, "select_prev_entry"), Action::SelectPrevEntry => write!(f, "select_prev_entry"),
Action::SelectNextPage => write!(f, "select_next_page"), Action::SelectNextPage => write!(f, "select_next_page"),
@ -352,7 +355,6 @@ impl Display for Action {
Action::TogglePreview => write!(f, "toggle_preview"), Action::TogglePreview => write!(f, "toggle_preview"),
Action::Error(_) => write!(f, "error"), Action::Error(_) => write!(f, "error"),
Action::NoOp => write!(f, "no_op"), Action::NoOp => write!(f, "no_op"),
Action::ToggleSendToChannel => write!(f, "toggle_send_to_channel"),
Action::CycleSources => write!(f, "cycle_sources"), Action::CycleSources => write!(f, "cycle_sources"),
Action::ReloadSource => write!(f, "reload_source"), Action::ReloadSource => write!(f, "reload_source"),
Action::SwitchToChannel(_) => write!(f, "switch_to_channel"), Action::SwitchToChannel(_) => write!(f, "switch_to_channel"),
@ -411,6 +413,7 @@ impl Action {
Action::ToggleSelectionUp => "Toggle selection up", Action::ToggleSelectionUp => "Toggle selection up",
Action::ConfirmSelection => "Select entry", Action::ConfirmSelection => "Select entry",
Action::SelectAndExit => "Select and exit", Action::SelectAndExit => "Select and exit",
Action::Expect(_) => "Expect key",
// Navigation actions // Navigation actions
Action::SelectNextEntry => "Navigate down", Action::SelectNextEntry => "Navigate down",
@ -445,7 +448,6 @@ impl Action {
Action::NoOp => "No operation", Action::NoOp => "No operation",
// Channel actions // Channel actions
Action::ToggleSendToChannel => "Toggle send to channel",
Action::CycleSources => "Cycle sources", Action::CycleSources => "Cycle sources",
Action::ReloadSource => "Reload source", Action::ReloadSource => "Reload source",
Action::SwitchToChannel(_) => "Switch to channel", Action::SwitchToChannel(_) => "Switch to channel",

View File

@ -144,6 +144,7 @@ pub struct App {
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum ActionOutcome { pub enum ActionOutcome {
Entries(FxHashSet<Entry>), Entries(FxHashSet<Entry>),
EntriesWithExpect(FxHashSet<Entry>, Key),
Input(String), Input(String),
None, None,
} }
@ -152,6 +153,7 @@ pub enum ActionOutcome {
#[derive(Debug)] #[derive(Debug)]
pub struct AppOutput { pub struct AppOutput {
pub selected_entries: Option<FxHashSet<Entry>>, pub selected_entries: Option<FxHashSet<Entry>>,
pub expect_key: Option<Key>,
} }
impl AppOutput { impl AppOutput {
@ -159,14 +161,21 @@ impl AppOutput {
match action_outcome { match action_outcome {
ActionOutcome::Entries(entries) => Self { ActionOutcome::Entries(entries) => Self {
selected_entries: Some(entries), selected_entries: Some(entries),
expect_key: None,
},
ActionOutcome::EntriesWithExpect(entries, expect_key) => Self {
selected_entries: Some(entries),
expect_key: Some(expect_key),
}, },
ActionOutcome::Input(input) => Self { ActionOutcome::Input(input) => Self {
selected_entries: Some(FxHashSet::from_iter([Entry::new( selected_entries: Some(FxHashSet::from_iter([Entry::new(
input, input,
)])), )])),
expect_key: None,
}, },
ActionOutcome::None => Self { ActionOutcome::None => Self {
selected_entries: None, selected_entries: None,
expect_key: None,
}, },
} }
} }
@ -603,6 +612,30 @@ impl App {
self.television.current_pattern.clone(), self.television.current_pattern.clone(),
)); ));
} }
Action::Expect(k) => {
self.should_quit = true;
if !self.render_tx.is_closed() {
self.render_tx.send(RenderingTask::Quit)?;
}
if let Some(entries) =
self.television.get_selected_entries()
{
// Add current query to history
let query =
self.television.current_pattern.clone();
self.history.add_entry(
query,
self.television.current_channel(),
)?;
return Ok(ActionOutcome::EntriesWithExpect(
entries, k,
));
}
return Ok(ActionOutcome::Input(
self.television.current_pattern.clone(),
));
}
Action::ClearScreen => { Action::ClearScreen => {
self.render_tx.send(RenderingTask::ClearScreen)?; self.render_tx.send(RenderingTask::ClearScreen)?;
} }

View File

@ -121,6 +121,17 @@ pub struct Cli {
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)] #[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
pub keybindings: Option<String>, pub keybindings: Option<String>,
/// Keys that can be used to confirm the current selection in addition to the default ones
/// (typically `enter`).
///
/// When this is set, confirming the selection will first output an extra line with the key
/// that was used to confirm the selection before outputting the selected entry.
///
/// Example: `tv --expect='ctrl-q'` will output `ctr-q\n<selected_entry>` when `ctrl-q` is
/// pressed to confirm the selection.
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub expect: Option<String>,
/// Input text to pass to the channel to prefill the prompt. /// Input text to pass to the channel to prefill the prompt.
/// ///
/// This flag works identically in both channel mode and ad-hoc mode. /// This flag works identically in both channel mode and ad-hoc mode.

View File

@ -1,12 +1,15 @@
use crate::{ use crate::{
action::{Action, Actions},
cable::{self, Cable}, cable::{self, Cable},
channels::prototypes::{ChannelPrototype, Template}, channels::prototypes::{ChannelPrototype, Template},
cli::args::{Cli, Command}, cli::args::{Cli, Command},
config::{ config::{
DEFAULT_PREVIEW_SIZE, KeyBindings, get_config_dir, get_data_dir, DEFAULT_PREVIEW_SIZE, KeyBindings, get_config_dir, get_data_dir,
merge_bindings,
ui::{BorderType, Padding}, ui::{BorderType, Padding},
}, },
errors::cli_parsing_error_exit, errors::cli_parsing_error_exit,
event::Key,
screen::layout::Orientation, screen::layout::Orientation,
utils::paths::expand_tilde, utils::paths::expand_tilde,
}; };
@ -15,7 +18,10 @@ use clap::CommandFactory;
use clap::error::ErrorKind; use clap::error::ErrorKind;
use colored::Colorize; use colored::Colorize;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use std::path::{Path, PathBuf}; use std::{
path::{Path, PathBuf},
str::FromStr,
};
use tracing::debug; use tracing::debug;
pub mod args; pub mod args;
@ -221,11 +227,27 @@ impl Default for PostProcessedCli {
/// This prevents creating broken ad-hoc channels that reference non-existent commands. /// This prevents creating broken ad-hoc channels that reference non-existent commands.
pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli { pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
// Parse literal keybindings passed through the CLI // Parse literal keybindings passed through the CLI
let keybindings = cli.keybindings.as_ref().map(|kb| { let mut keybindings = cli.keybindings.as_ref().map(|kb| {
parse_keybindings_literal(kb, CLI_KEYBINDINGS_DELIMITER) parse_keybindings_literal(kb, CLI_KEYBINDINGS_DELIMITER)
.unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string())) .unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string()))
}); });
// if `--expect` is used, parse and add to the keybindings
if let Some(expect) = &cli.expect {
let expect_bindings = parse_expect_bindings(expect)
.unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string()));
keybindings = match keybindings {
Some(kb) => {
// Merge expect bindings into existing keybindings
Some(merge_bindings(kb, &expect_bindings))
}
None => {
// Initialize keybindings with expect bindings
Some(expect_bindings)
}
}
}
// Parse preview overrides if provided // Parse preview overrides if provided
let preview_command_override = let preview_command_override =
cli.preview_command.as_ref().map(|preview_cmd| { cli.preview_command.as_ref().map(|preview_cmd| {
@ -610,6 +632,36 @@ pub fn guess_channel_from_prompt(
fallback fallback
} }
/// Parses the `expect` keybindings from the CLI into a `KeyBindings` struct that can be
/// merged with other keybindings.
///
/// The `expect` keybindings are expected to be in the format:
/// ```ignore
/// "ctrl-q;esc"
/// ```
/// And will produce a `KeyBindings` struct with the following entries:
/// ```ignore
/// {
/// Key::Ctrl('q') => Actions::single(Action::Expect(Key::Ctrl('q'))),
/// Key::Esc => Actions::single(Action::Expect(Key::Esc)),
/// }
/// ```
fn parse_expect_bindings(expect: &str) -> Result<KeyBindings> {
let mut bindings = KeyBindings::default();
for s in expect.split(CLI_KEYBINDINGS_DELIMITER) {
let s = s.trim();
if s.is_empty() {
continue;
}
let key = Key::from_str(s).map_err(|e| {
anyhow!("Invalid key in expect bindings: '{}'. Error: {}", s, e)
})?;
let action = Action::Expect(key);
bindings.insert(key, Actions::single(action));
}
Ok(bindings)
}
const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION"); const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION");
pub fn version() -> String { pub fn version() -> String {
@ -796,4 +848,19 @@ mod tests {
validate_adhoc_mode_constraints(&cli, true); validate_adhoc_mode_constraints(&cli, true);
} }
#[test]
fn test_parse_expect_keybindings() {
let expect = "ctrl-q;esc";
let bindings = parse_expect_bindings(expect).unwrap();
let mut expected = KeyBindings::default();
expected.insert(
Key::Ctrl('q'),
Actions::single(Action::Expect(Key::Ctrl('q'))),
);
expected.insert(Key::Esc, Actions::single(Action::Expect(Key::Esc)));
assert_eq!(bindings, expected);
}
} }

View File

@ -113,6 +113,9 @@ async fn main() -> Result<()> {
info!("App output: {:?}", output); info!("App output: {:?}", output);
let stdout_handle = stdout().lock(); let stdout_handle = stdout().lock();
let mut bufwriter = BufWriter::new(stdout_handle); let mut bufwriter = BufWriter::new(stdout_handle);
if let Some(key) = output.expect_key {
writeln!(bufwriter, "{}", key)?;
}
if let Some(entries) = output.selected_entries { if let Some(entries) = output.selected_entries {
for entry in &entries { for entry in &entries {
writeln!( writeln!(

View File

@ -111,7 +111,7 @@ fn is_action_relevant_for_mode(action: &Action, mode: Mode) -> bool {
| Action::WatchTimer | Action::WatchTimer
| Action::SelectEntryAtPosition(_, _) | Action::SelectEntryAtPosition(_, _)
| Action::MouseClickAt(_, _) | Action::MouseClickAt(_, _)
| Action::ToggleSendToChannel | Action::Expect(_)
| Action::SelectAndExit => false, | Action::SelectAndExit => false,
} }
} }

View File

@ -158,3 +158,31 @@ fn test_watch_and_take_1_fast_conflict_errors() {
// CLI should exit with error message // CLI should exit with error message
tester.assert_raw_output_contains("cannot be used with"); tester.assert_raw_output_contains("cannot be used with");
} }
/// Tests that --expect works as intended.
#[test]
fn test_expect_with_selection() {
let mut tester = PtyTester::new();
// This should auto-select "UNIQUE16CHARID" and exit with it
let cmd = tv_local_config_and_cable_with_args(&[
"files",
"--expect",
"ctrl-c",
"--input",
"Cargo.toml",
]);
let mut child = tester.spawn_command_tui(cmd);
tester.send(&ctrl('c'));
let out = tester.read_raw_output();
assert!(
out.contains("Ctrl-c\r\nCargo.toml"),
"Expected output to contain 'Ctrl-c\\r\\nCargo.toml' but got: '{:?}'",
out
);
PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY * 2);
}

View File

@ -172,7 +172,7 @@ impl PtyTester {
match child.try_wait() { match child.try_wait() {
Ok(Some(_)) => { Ok(Some(_)) => {
panic!( panic!(
"Child process exited prematurely with output:\n{}", "Child process exited prematurely with output:\n{:?}",
self.read_raw_output() self.read_raw_output()
); );
} }
@ -287,7 +287,7 @@ impl PtyTester {
let frame = self.get_tui_frame(); let frame = self.get_tui_frame();
assert!( assert!(
!frame.contains(expected), !frame.contains(expected),
"Expected output to not contain\n'{}'\nbut got:\n{}", "Expected output to not contain\n'{}'\nbut got:\n'{}'",
expected, expected,
frame frame
); );
@ -297,7 +297,7 @@ impl PtyTester {
let output = self.read_raw_output(); let output = self.read_raw_output();
assert!( assert!(
output.contains(expected), output.contains(expected),
"Expected output to contain '{}', but got:\n{}", "Expected output to contain '{}', but got:\n{:?}",
expected, expected,
output output
); );