diff --git a/crates/vite_select/src/interactive.rs b/crates/vite_select/src/interactive.rs index 45e2c16f..aa6acf5b 100644 --- a/crates/vite_select/src/interactive.rs +++ b/crates/vite_select/src/interactive.rs @@ -489,7 +489,7 @@ pub fn run( header: Option<&str>, page_size: usize, mut after_render: impl FnMut(&RenderState<'_>), -) -> anyhow::Result<()> { +) -> anyhow::Result { if items.is_empty() { anyhow::bail!("No tasks available"); } @@ -516,7 +516,7 @@ pub fn run( } KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { cleanup(&mut out, &state)?; - std::process::exit(130); + return Ok(super::SelectResult::Cancelled); } KeyCode::Enter => { let Some(idx) = state.selected_item_index() else { @@ -524,7 +524,7 @@ pub fn run( }; *selected_index = idx; cleanup(&mut out, &state)?; - return Ok(()); + return Ok(super::SelectResult::Selected); } KeyCode::Up => { state.move_up(); diff --git a/crates/vite_select/src/lib.rs b/crates/vite_select/src/lib.rs index 228d737b..4317ddfc 100644 --- a/crates/vite_select/src/lib.rs +++ b/crates/vite_select/src/lib.rs @@ -50,16 +50,26 @@ pub struct SelectParams<'a> { pub page_size: usize, } +/// Result of an interactive selection. +pub enum SelectResult { + /// The user selected an item. + Selected, + /// The user cancelled the selection (e.g. Ctrl+C). + Cancelled, +} + /// Show a task selection list. /// /// In [`Mode::Interactive`], enters a terminal UI with fuzzy search and /// keyboard navigation. `after_render` is called after every render with the /// current visible state (useful for emitting test milestones). On Enter, /// `*selected_index` is set to the chosen item's index in the original -/// `items` slice. +/// `items` slice. Returns [`SelectResult::Cancelled`] if the user presses +/// Ctrl+C. /// /// In [`Mode::NonInteractive`], renders the list once to `writer` and -/// returns. `page_size` and `after_render` are ignored. +/// returns [`SelectResult::Selected`]. `page_size` and `after_render` are +/// ignored. /// /// # Errors /// @@ -69,7 +79,7 @@ pub fn select_list( params: &SelectParams<'_>, mode: Mode<'_>, after_render: impl FnMut(&RenderState<'_>), -) -> anyhow::Result<()> { +) -> anyhow::Result { match mode { Mode::Interactive { selected_index } => interactive::run( params.items, @@ -79,7 +89,10 @@ pub fn select_list( params.page_size, after_render, ), - Mode::NonInteractive => non_interactive(writer, params.items, params.query, params.header), + Mode::NonInteractive => { + non_interactive(writer, params.items, params.query, params.header)?; + Ok(SelectResult::Selected) + } } } diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index c378794f..3588faa5 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -449,7 +449,7 @@ impl<'a> Session<'a> { page_size: 12, }; - vite_select::select_list(&mut stdout, ¶ms, mode, |state| { + let select_result = vite_select::select_list(&mut stdout, ¶ms, mode, |state| { use std::io::Write; let milestone_name = vite_str::format!("task-select:{}:{}", state.query, state.selected_index); @@ -459,6 +459,10 @@ impl<'a> Session<'a> { let _ = out.flush(); })?; + if matches!(select_result, vite_select::SelectResult::Cancelled) { + return Err(SessionError::EarlyExit(ExitStatus(130))); + } + let Some(selected_index) = selected_index else { // Non-interactive, the list was printed. return Err(SessionError::EarlyExit(if not_found_name.is_some() { diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml index 77d6d154..9f9562da 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml @@ -203,6 +203,17 @@ steps = [ name = "typo in task script fails without list" steps = ["vt run run-typo-task"] +# Interactive: Ctrl+C cancels selection and exits with code 130 +[[e2e]] +name = "interactive ctrl-c cancels" +cwd = "packages/app" +steps = [ + { command = "vt run", interactions = [ + { "expect-milestone" = "task-select::0" }, + { "write-key" = "ctrl-c" }, + ] }, +] + # --verbose without task: not bare, errors with "no task specifier provided" [[e2e]] name = "verbose without task errors" diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive ctrl-c cancels.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive ctrl-c cancels.snap new file mode 100644 index 00000000..e9513666 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive ctrl-c cancels.snap @@ -0,0 +1,24 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/app +--- +[130]> vt run +@ expect-milestone: task-select::0 +Select a task (↑/↓, Enter to run, type to search): + + › build echo build app + lint echo lint app + test echo test app + lib (packages/lib) + build echo build lib + lint echo lint lib + test echo test lib + typecheck echo typecheck lib + task-select-test (workspace root) + check echo check root + clean echo clean root + deploy echo deploy root + (…5 more) +@ write-key: ctrl-c diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 36d4d168..b1d800da 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -122,13 +122,14 @@ struct WriteKeyInteraction { } #[derive(serde::Deserialize, Debug, Clone, Copy)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "kebab-case")] enum WriteKey { Up, Down, Enter, Escape, Backspace, + CtrlC, } impl WriteKey { @@ -139,6 +140,7 @@ impl WriteKey { Self::Enter => "enter", Self::Escape => "escape", Self::Backspace => "backspace", + Self::CtrlC => "ctrl-c", } } @@ -149,6 +151,7 @@ impl WriteKey { Self::Enter => b"\r", Self::Escape => b"\x1b", Self::Backspace => b"\x7f", + Self::CtrlC => b"\x03", } } }