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

View File

@ -144,6 +144,7 @@ pub struct App {
#[derive(Debug, PartialEq)]
pub enum ActionOutcome {
Entries(FxHashSet<Entry>),
EntriesWithExpect(FxHashSet<Entry>, Key),
Input(String),
None,
}
@ -152,6 +153,7 @@ pub enum ActionOutcome {
#[derive(Debug)]
pub struct AppOutput {
pub selected_entries: Option<FxHashSet<Entry>>,
pub expect_key: Option<Key>,
}
impl AppOutput {
@ -159,14 +161,21 @@ impl AppOutput {
match action_outcome {
ActionOutcome::Entries(entries) => Self {
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 {
selected_entries: Some(FxHashSet::from_iter([Entry::new(
input,
)])),
expect_key: None,
},
ActionOutcome::None => Self {
selected_entries: None,
expect_key: None,
},
}
}
@ -603,6 +612,30 @@ impl App {
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 => {
self.render_tx.send(RenderingTask::ClearScreen)?;
}

View File

@ -121,6 +121,17 @@ pub struct Cli {
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
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.
///
/// This flag works identically in both channel mode and ad-hoc mode.

View File

@ -1,12 +1,15 @@
use crate::{
action::{Action, Actions},
cable::{self, Cable},
channels::prototypes::{ChannelPrototype, Template},
cli::args::{Cli, Command},
config::{
DEFAULT_PREVIEW_SIZE, KeyBindings, get_config_dir, get_data_dir,
merge_bindings,
ui::{BorderType, Padding},
},
errors::cli_parsing_error_exit,
event::Key,
screen::layout::Orientation,
utils::paths::expand_tilde,
};
@ -15,7 +18,10 @@ use clap::CommandFactory;
use clap::error::ErrorKind;
use colored::Colorize;
use rustc_hash::FxHashMap;
use std::path::{Path, PathBuf};
use std::{
path::{Path, PathBuf},
str::FromStr,
};
use tracing::debug;
pub mod args;
@ -221,11 +227,27 @@ impl Default for PostProcessedCli {
/// This prevents creating broken ad-hoc channels that reference non-existent commands.
pub fn post_process(cli: Cli, readable_stdin: bool) -> PostProcessedCli {
// 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)
.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
let preview_command_override =
cli.preview_command.as_ref().map(|preview_cmd| {
@ -610,6 +632,36 @@ pub fn guess_channel_from_prompt(
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");
pub fn version() -> String {
@ -796,4 +848,19 @@ mod tests {
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);
let stdout_handle = stdout().lock();
let mut bufwriter = BufWriter::new(stdout_handle);
if let Some(key) = output.expect_key {
writeln!(bufwriter, "{}", key)?;
}
if let Some(entries) = output.selected_entries {
for entry in &entries {
writeln!(

View File

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

View File

@ -158,3 +158,31 @@ fn test_watch_and_take_1_fast_conflict_errors() {
// CLI should exit with error message
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() {
Ok(Some(_)) => {
panic!(
"Child process exited prematurely with output:\n{}",
"Child process exited prematurely with output:\n{:?}",
self.read_raw_output()
);
}
@ -287,7 +287,7 @@ impl PtyTester {
let frame = self.get_tui_frame();
assert!(
!frame.contains(expected),
"Expected output to not contain\n'{}'\nbut got:\n{}",
"Expected output to not contain\n'{}'\nbut got:\n'{}'",
expected,
frame
);
@ -297,7 +297,7 @@ impl PtyTester {
let output = self.read_raw_output();
assert!(
output.contains(expected),
"Expected output to contain '{}', but got:\n{}",
"Expected output to contain '{}', but got:\n{:?}",
expected,
output
);