From b40f41f2a3d7ae16d1ce7b605f98e72d18bff916 Mon Sep 17 00:00:00 2001 From: branchseer Date: Tue, 10 Feb 2026 15:49:12 +0800 Subject: [PATCH 01/11] refactor: simplify command handling by introducing to_synthetic_plan_request method --- crates/vite_task/src/cli/mod.rs | 9 +- crates/vite_task/src/session/mod.rs | 118 ++++++++++++++---- .../fixtures/task-list/package.json | 4 + .../task-list/packages/app/package.json | 10 ++ .../task-list/packages/app/vite-task.json | 9 ++ .../task-list/packages/lib/package.json | 6 + .../task-list/packages/lib/vite-task.json | 5 + .../fixtures/task-list/pnpm-workspace.yaml | 2 + .../fixtures/task-list/snapshots.toml | 18 +++ .../list tasks from package dir.snap | 21 ++++ .../list tasks from workspace root.snap | 21 ++++ .../task-list/snapshots/vp run in script.snap | 36 ++++++ .../fixtures/task-list/vite-task.json | 10 ++ crates/vite_task_graph/src/display.rs | 22 ++++ crates/vite_task_plan/src/error.rs | 10 ++ 15 files changed, 277 insertions(+), 24 deletions(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/vite-task.json diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 5e40ff69..5926155d 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -15,8 +15,8 @@ pub enum CacheSubcommand { /// Arguments for the `run` subcommand. #[derive(Debug, clap::Args)] pub struct RunCommand { - /// `packageName#taskName` or `taskName`. - pub task_specifier: TaskSpecifier, + /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. + pub task_specifier: Option, /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. #[clap(default_value = "false", short, long)] @@ -49,6 +49,9 @@ pub enum Command { #[derive(thiserror::Error, Debug)] pub enum CLITaskQueryError { + #[error("no task specifier provided")] + MissingTaskSpecifier, + #[error("--recursive and --transitive cannot be used together")] RecursiveTransitiveConflict, @@ -70,6 +73,8 @@ impl RunCommand { let Self { task_specifier, recursive, transitive, ignore_depends_on, additional_args } = self; + let task_specifier = task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; + let include_explicit_deps = !ignore_depends_on; let query_kind = if recursive { diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 48badea9..6b0876a3 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -98,13 +98,18 @@ impl vite_task_plan::PlanRequestParser for PlanRequestParser<'_> { match self.command_handler.handle_command(command).await? { HandledCommand::Synthesized(synthetic) => Ok(Some(PlanRequest::Synthetic(synthetic))), HandledCommand::ViteTaskCommand(cli_command) => match cli_command { - Command::Cache { .. } => Ok(Some(PlanRequest::Synthetic(SyntheticPlanRequest { - program: Arc::from(OsStr::new(command.program.as_str())), - args: Arc::clone(&command.args), - cache_config: UserCacheConfig::disabled(), - envs: Arc::clone(&command.envs), - }))), - Command::Run(run_command) => Ok(Some(run_command.into_plan_request(&command.cwd)?)), + Command::Cache { .. } => Ok(Some(PlanRequest::Synthetic( + command.to_synthetic_plan_request(UserCacheConfig::disabled()), + ))), + Command::Run(run_command) => match run_command.into_plan_request(&command.cwd) { + Ok(plan_request) => Ok(Some(plan_request)), + Err(crate::cli::CLITaskQueryError::MissingTaskSpecifier) => { + Ok(Some(PlanRequest::Synthetic( + command.to_synthetic_plan_request(UserCacheConfig::disabled()), + ))) + } + Err(err) => Err(err.into()), + }, }, HandledCommand::Verbatim => Ok(None), } @@ -223,13 +228,19 @@ impl<'a> Session<'a> { Command::Cache { ref subcmd } => self.handle_cache_command(subcmd), Command::Run(run_command) => { let cwd = Arc::clone(&self.cwd); - let plan = self.plan_from_cli(cwd, run_command).await?; - let reporter = LabeledReporter::new(std::io::stdout(), self.workspace_path()); - Ok(self - .execute(plan, Box::new(reporter)) - .await - .err() - .unwrap_or(ExitStatus::SUCCESS)) + match self.plan_from_cli(cwd, run_command).await { + Ok(plan) => { + let reporter = + LabeledReporter::new(std::io::stdout(), self.workspace_path()); + Ok(self + .execute(plan, Box::new(reporter)) + .await + .err() + .unwrap_or(ExitStatus::SUCCESS)) + } + Err(err) if err.is_missing_task_specifier() => self.print_task_list().await, + Err(err) => Err(err.into()), + } } } } @@ -245,6 +256,63 @@ impl<'a> Session<'a> { } } + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] + async fn print_task_list(&mut self) -> anyhow::Result { + use std::io::Write; + + let cwd = Arc::clone(&self.cwd); + let task_graph = self.ensure_task_graph_loaded().await?; + let mut entries = task_graph.list_tasks(); + entries.sort_unstable_by(|a, b| { + a.task_display + .package_name + .cmp(&b.task_display.package_name) + .then_with(|| a.task_display.task_name.cmp(&b.task_display.task_name)) + }); + + // Find the most specific package containing the CWD (longest matching path) + let current_package_path = entries + .iter() + .map(|e| &e.task_display.package_path) + .filter(|p| cwd.as_path().starts_with(p.as_path())) + .max_by_key(|p| p.as_path().as_os_str().len()); + + let (current, others): (Vec<_>, Vec<_>) = entries + .iter() + .partition(|e| current_package_path == Some(&e.task_display.package_path)); + + let mut stdout = std::io::stdout().lock(); + + if !current.is_empty() { + let package_name = ¤t[0].task_display.package_name; + if package_name.is_empty() { + writeln!(stdout, "Tasks in the current package")?; + } else { + writeln!(stdout, "Tasks in the current package ({package_name})")?; + } + for entry in ¤t { + writeln!(stdout, " {}", entry.task_display.task_name)?; + writeln!(stdout, " {}", entry.command)?; + } + } + + if !others.is_empty() { + if !current.is_empty() { + writeln!(stdout)?; + } + writeln!(stdout, "Tasks in other packages")?; + for entry in &others { + writeln!(stdout, " {}", entry.task_display)?; + writeln!(stdout, " {}", entry.command)?; + } + } + + Ok(ExitStatus::SUCCESS) + } + /// Lazily initializes and returns the execution cache. /// The cache is only created when first accessed to avoid `SQLite` race conditions /// when multiple processes start simultaneously. @@ -323,15 +391,21 @@ impl<'a> Session<'a> { cwd: Arc, command: RunCommand, ) -> Result { - let plan_request = command.into_plan_request(&cwd).map_err(|error| { - TaskPlanErrorKind::ParsePlanRequestError { - error: error.into(), - program: Str::from("vp"), - args: Arc::default(), - cwd: Arc::clone(&cwd), + let plan_request = match command.into_plan_request(&cwd) { + Ok(plan_request) => plan_request, + Err(crate::cli::CLITaskQueryError::MissingTaskSpecifier) => { + return Err(TaskPlanErrorKind::MissingTaskSpecifier.with_empty_call_stack()); + } + Err(error) => { + return Err(TaskPlanErrorKind::ParsePlanRequestError { + error: error.into(), + program: Str::from("vp"), + args: Arc::default(), + cwd: Arc::clone(&cwd), + } + .with_empty_call_stack()); } - .with_empty_call_stack() - })?; + }; let plan = ExecutionPlan::plan( plan_request, &self.workspace_path, diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/package.json new file mode 100644 index 00000000..8858e597 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/package.json @@ -0,0 +1,4 @@ +{ + "name": "task-list-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/package.json new file mode 100644 index 00000000..45aeb51c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "app", + "scripts": { + "build": "echo build app", + "test": "echo test app" + }, + "dependencies": { + "lib": "workspace:*" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/vite-task.json new file mode 100644 index 00000000..2814f38e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/vite-task.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "build": {}, + "test": {}, + "lint": { + "command": "echo lint app" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/package.json new file mode 100644 index 00000000..5a2cc4d3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/package.json @@ -0,0 +1,6 @@ +{ + "name": "lib", + "scripts": { + "build": "echo build lib" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/vite-task.json new file mode 100644 index 00000000..355525a7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/vite-task.json @@ -0,0 +1,5 @@ +{ + "tasks": { + "build": {} + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml new file mode 100644 index 00000000..b69e78b2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml @@ -0,0 +1,18 @@ +[[e2e]] +name = "list tasks from package dir" +cwd = "packages/app" +steps = [ + "vp run", +] + +[[e2e]] +name = "list tasks from workspace root" +steps = [ + "vp run", +] + +[[e2e]] +name = "vp run in script" +steps = [ + "vp run list-tasks", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap new file mode 100644 index 00000000..719da9ea --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap @@ -0,0 +1,21 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list +--- +> vp run +Tasks in the current package (app) + build + echo build app + lint + echo lint app + test + echo test app + +Tasks in other packages + lib#build + echo build lib + task-list-test#hello + echo hello from root + task-list-test#list-tasks + vp run diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap new file mode 100644 index 00000000..a864b107 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap @@ -0,0 +1,21 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list +--- +> vp run +Tasks in the current package (task-list-test) + hello + echo hello from root + list-tasks + vp run + +Tasks in other packages + app#build + echo build app + app#lint + echo lint app + app#test + echo test app + lib#build + echo build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap new file mode 100644 index 00000000..8db581cc --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap @@ -0,0 +1,36 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list +--- +> vp run list-tasks +$ vp run ⊘ cache disabled: no cache config +Tasks in the current package (task-list-test) + hello + echo hello from root + list-tasks + vp run + +Tasks in other packages + app#build + echo build app + app#lint + echo lint app + app#test + echo test app + lib#build + echo build lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] task-list-test#list-tasks: $ vp run ✓ + → Cache disabled in task configuration +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/vite-task.json new file mode 100644 index 00000000..1e2ed69c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/vite-task.json @@ -0,0 +1,10 @@ +{ + "tasks": { + "hello": { + "command": "echo hello from root" + }, + "list-tasks": { + "command": "vp run" + } + } +} diff --git a/crates/vite_task_graph/src/display.rs b/crates/vite_task_graph/src/display.rs index 3e60b5a1..fbc06dd2 100644 --- a/crates/vite_task_graph/src/display.rs +++ b/crates/vite_task_graph/src/display.rs @@ -27,10 +27,32 @@ impl Display for TaskDisplay { } } +/// A task with its display info and command, for listing purposes. +#[derive(Debug)] +pub struct TaskListEntry { + pub task_display: TaskDisplay, + pub command: Str, +} + impl IndexedTaskGraph { /// Get human-readable display for a task node. #[must_use] pub fn display_task(&self, task_index: TaskNodeIndex) -> TaskDisplay { self.task_graph()[task_index].task_display.clone() } + + /// Returns all tasks as a flat list. + #[must_use] + pub fn list_tasks(&self) -> Vec { + self.task_graph() + .node_indices() + .map(|idx| { + let node = &self.task_graph()[idx]; + TaskListEntry { + task_display: node.task_display.clone(), + command: node.resolved_config.command.clone(), + } + }) + .collect() + } } diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 35212f8e..989d9763 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -134,6 +134,9 @@ pub enum TaskPlanErrorKind { #[error("Failed to resolve environment variables")] ResolveEnvError(#[source] ResolveEnvError), + + #[error("No task specifier provided for 'run' command")] + MissingTaskSpecifier, } #[derive(Debug, thiserror::Error)] @@ -161,6 +164,13 @@ impl TaskPlanErrorKind { } } +impl Error { + #[must_use] + pub const fn is_missing_task_specifier(&self) -> bool { + matches!(self.kind, TaskPlanErrorKind::MissingTaskSpecifier) + } +} + #[expect( clippy::result_large_err, reason = "Error wraps TaskPlanErrorKind with call stack for diagnostics" From 0baf59b2e53db3c167b5070c92e2c69a512569fe Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Feb 2026 22:41:49 +0800 Subject: [PATCH 02/11] feat: interactive task selector for `vp run` with fuzzy search When `vp run` is called without a task or with a typo, show an interactive fuzzy-searchable selector (TTY) or a plain task list (piped). - Add vite_select crate with crossterm-based interactive widget and nucleo-matcher fuzzy search - Extract RunFlags from RunCommand for flag preservation across selection - Detect interactive vs non-interactive mode via stdin/stdout is_terminal - Show 'did you mean' suggestions for typos (including -r/--recursive) - Add RecursiveTaskNotFound error for recursive queries with no matches - Only intercept task-not-found at top level (non-empty call stack propagates as-is, fixing nested task error hanging) - Style list items as 'taskName: command' with cyan command color - E2E tests: list, did-you-mean, interactive select, search, scroll, cancel, flag preservation, nested task error propagation --- Cargo.lock | 23 ++ Cargo.toml | 2 + crates/vite_select/Cargo.toml | 20 ++ crates/vite_select/src/fuzzy.rs | 104 +++++++ crates/vite_select/src/interactive.rs | 277 ++++++++++++++++++ crates/vite_select/src/lib.rs | 87 ++++++ crates/vite_task/Cargo.toml | 2 + crates/vite_task/src/cli/mod.rs | 29 +- crates/vite_task/src/lib.rs | 2 +- crates/vite_task/src/session/mod.rs | 173 ++++++++--- .../fixtures/task-list/snapshots.toml | 4 +- .../list tasks from package dir.snap | 24 +- .../list tasks from workspace root.snap | 24 +- .../task-list/snapshots/vp run in script.snap | 22 +- .../fixtures/task-select/package.json | 4 + .../task-select/packages/app/package.json | 7 + .../task-select/packages/app/vite-task.json | 13 + .../task-select/packages/lib/package.json | 4 + .../task-select/packages/lib/vite-task.json | 16 + .../fixtures/task-select/pnpm-workspace.yaml | 2 + .../fixtures/task-select/snapshots.toml | 85 ++++++ .../snapshots/interactive cancel.snap | 17 ++ .../interactive scroll long list.snap | 71 +++++ .../interactive search then select.snap | 38 +++ .../snapshots/interactive select task.snap | 45 +++ .../interactive select with recursive.snap | 39 +++ ...ctive select with typo and transitive.snap | 33 +++ .../interactive select with typo.snap | 27 ++ ...teractive did you mean with recursive.snap | 8 + .../non-interactive did you mean.snap | 8 + .../snapshots/non-interactive list tasks.snap | 17 ++ ...ypo in task script fails without list.snap | 11 + .../fixtures/task-select/vite-task.json | 19 ++ .../vite_task_bin/tests/e2e_snapshots/main.rs | 6 + crates/vite_task_graph/src/query/mod.rs | 16 + crates/vite_task_plan/src/error.rs | 22 ++ 36 files changed, 1207 insertions(+), 94 deletions(-) create mode 100644 crates/vite_select/Cargo.toml create mode 100644 crates/vite_select/src/fuzzy.rs create mode 100644 crates/vite_select/src/interactive.rs create mode 100644 crates/vite_select/src/lib.rs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json diff --git a/Cargo.lock b/Cargo.lock index a253c5c6..48182c98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2009,6 +2009,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" @@ -3785,6 +3795,17 @@ dependencies = [ "vite_str", ] +[[package]] +name = "vite_select" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert2", + "crossterm", + "nucleo-matcher", + "vite_str", +] + [[package]] name = "vite_shell" version = "0.0.0" @@ -3825,6 +3846,7 @@ dependencies = [ "once_cell", "owo-colors", "petgraph", + "pty_terminal_test_client", "rayon", "rusqlite", "rustc-hash", @@ -3836,6 +3858,7 @@ dependencies = [ "twox-hash", "vite_glob", "vite_path", + "vite_select", "vite_str", "vite_task_graph", "vite_task_plan", diff --git a/Cargo.toml b/Cargo.toml index 65153612..f0ac0615 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,7 @@ memmap2 = "0.9.7" monostate = "1.0.2" nix = { version = "0.30.1", features = ["dir"] } ntapi = "0.4.1" +nucleo-matcher = "0.3.1" once_cell = "1.19" os_str_bytes = "7.1.1" ouroboros = "0.18.5" @@ -135,6 +136,7 @@ vec1 = "1.12.1" vite_glob = { path = "crates/vite_glob" } vite_graph_ser = { path = "crates/vite_graph_ser" } vite_path = { path = "crates/vite_path" } +vite_select = { path = "crates/vite_select" } vite_shell = { path = "crates/vite_shell" } vite_str = { path = "crates/vite_str" } vite_task = { path = "crates/vite_task" } diff --git a/crates/vite_select/Cargo.toml b/crates/vite_select/Cargo.toml new file mode 100644 index 00000000..6a344ae0 --- /dev/null +++ b/crates/vite_select/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "vite_select" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +crossterm = { workspace = true } +nucleo-matcher = { workspace = true } +vite_str = { path = "../vite_str" } + +[dev-dependencies] +assert2 = { workspace = true } diff --git a/crates/vite_select/src/fuzzy.rs b/crates/vite_select/src/fuzzy.rs new file mode 100644 index 00000000..4fa4f4dc --- /dev/null +++ b/crates/vite_select/src/fuzzy.rs @@ -0,0 +1,104 @@ +use nucleo_matcher::{ + Matcher, + pattern::{AtomKind, CaseMatching, Normalization, Pattern}, +}; + +/// Fuzzy-match `query` against a list of strings. +/// +/// Returns original indices sorted by score descending (best match first). +/// When `query` is empty, returns all indices in their original order. +#[must_use] +pub fn fuzzy_match(query: &str, items: &[&str]) -> Vec { + if query.is_empty() { + return (0..items.len()).collect(); + } + + let pattern = Pattern::new(query, CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy); + let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT); + + let mut scored: Vec<(usize, u32)> = items + .iter() + .enumerate() + .filter_map(|(idx, item)| { + pattern + .score(nucleo_matcher::Utf32Str::Ascii(item.as_bytes()), &mut matcher) + .map(|score| (idx, score)) + }) + .collect(); + + scored.sort_by(|a, b| b.1.cmp(&a.1)); + scored.into_iter().map(|(idx, _)| idx).collect() +} + +#[cfg(test)] +mod tests { + use assert2::{assert, check}; + + use super::*; + + const TASK_NAMES: &[&str] = + &["build", "lint", "test", "app#build", "app#lint", "app#test", "lib#build"]; + + #[test] + fn exact_match_scores_highest() { + let results = fuzzy_match("build", TASK_NAMES); + assert!(!results.is_empty()); + // "build" should be the highest-scoring match + check!(TASK_NAMES[results[0]] == "build"); + } + + #[test] + fn typo_matches_similar() { + let results = fuzzy_match("buid", TASK_NAMES); + assert!(!results.is_empty()); + // Should match "build" and "app#build" and "lib#build" but not "lint" or "test" + let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect(); + check!(matched_names.contains(&"build")); + for name in &matched_names { + check!(!name.contains("lint")); + check!(!name.contains("test")); + } + } + + #[test] + fn empty_query_returns_all() { + let results = fuzzy_match("", TASK_NAMES); + check!(results.len() == TASK_NAMES.len()); + // Indices should be in original order + for (pos, &idx) in results.iter().enumerate() { + check!(idx == pos); + } + } + + #[test] + fn completely_unrelated_query_returns_nothing() { + let results = fuzzy_match("zzzzz", TASK_NAMES); + check!(results.is_empty()); + } + + #[test] + fn package_qualified_match() { + let results = fuzzy_match("app#build", TASK_NAMES); + assert!(!results.is_empty()); + check!(TASK_NAMES[results[0]] == "app#build"); + } + + #[test] + fn lint_matches_lint_tasks() { + let results = fuzzy_match("lint", TASK_NAMES); + assert!(!results.is_empty()); + let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect(); + check!(matched_names.contains(&"lint")); + check!(matched_names.contains(&"app#lint")); + } + + #[test] + fn score_ordering_exact_before_fuzzy() { + let results = fuzzy_match("build", TASK_NAMES); + assert!(results.len() >= 2); + // Exact "build" should appear before "app#build" (higher score = earlier position) + let build_pos = results.iter().position(|&i| TASK_NAMES[i] == "build").unwrap(); + let app_build_pos = results.iter().position(|&i| TASK_NAMES[i] == "app#build").unwrap(); + check!(build_pos <= app_build_pos); + } +} diff --git a/crates/vite_select/src/interactive.rs b/crates/vite_select/src/interactive.rs new file mode 100644 index 00000000..c691dada --- /dev/null +++ b/crates/vite_select/src/interactive.rs @@ -0,0 +1,277 @@ +use std::io::{Write, stdout}; + +use crossterm::{ + cursor::{self, MoveToColumn}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor}, + terminal::{self, Clear, ClearType}, +}; + +use crate::{RenderState, SelectItem, SelectResult, fuzzy::fuzzy_match}; + +struct RawModeGuard; + +impl RawModeGuard { + fn enable() -> anyhow::Result { + terminal::enable_raw_mode()?; + Ok(Self) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + } +} + +struct State<'a> { + items: &'a [SelectItem], + /// Indices into `items` that match the current query, in score order. + filtered: Vec, + #[expect( + clippy::disallowed_types, + reason = "crossterm key events push chars one at a time; String is natural here" + )] + query: String, + /// Index into `filtered`. + selected: usize, + /// First visible row in the filtered list (scroll offset). + scroll_offset: usize, + page_size: usize, + /// Number of lines rendered in the last frame (for clearing). + rendered_lines: usize, +} + +impl<'a> State<'a> { + fn new(items: &'a [SelectItem], initial_query: Option<&str>, page_size: usize) -> Self { + let query = initial_query.unwrap_or_default().to_owned(); + let mut state = Self { + items, + filtered: Vec::new(), + query, + selected: 0, + scroll_offset: 0, + page_size, + rendered_lines: 0, + }; + state.refilter(); + state + } + + fn refilter(&mut self) { + let labels: Vec<&str> = self.items.iter().map(|i| i.label.as_str()).collect(); + self.filtered = fuzzy_match(&self.query, &labels); + self.selected = 0; + self.scroll_offset = 0; + } + + const fn move_up(&mut self) { + if self.selected > 0 { + self.selected -= 1; + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } + } + } + + const fn move_down(&mut self) { + if !self.filtered.is_empty() && self.selected < self.filtered.len() - 1 { + self.selected += 1; + if self.selected >= self.scroll_offset + self.page_size { + self.scroll_offset = self.selected + 1 - self.page_size; + } + } + } + + fn selected_original_index(&self) -> Option { + self.filtered.get(self.selected).copied() + } + + fn visible_range(&self) -> std::ops::Range { + let end = (self.scroll_offset + self.page_size).min(self.filtered.len()); + self.scroll_offset..end + } + + const fn hidden_count(&self) -> usize { + self.filtered.len().saturating_sub(self.scroll_offset + self.page_size) + } +} + +fn render( + stdout: &mut impl Write, + state: &mut State<'_>, + header: Option<&str>, +) -> anyhow::Result<()> { + // Move cursor up to clear previous render + if state.rendered_lines > 0 { + let move_up = u16::try_from(state.rendered_lines) + .expect("rendered_lines fits in u16: at most header + page_size + footer lines"); + crossterm::execute!( + stdout, + cursor::MoveUp(move_up), + MoveToColumn(0), + Clear(ClearType::FromCursorDown), + )?; + } + + let mut lines = 0u16; + + // Header (error message) + if let Some(header) = header { + crossterm::execute!(stdout, Print(header), Print("\r\n"))?; + lines += 1; + } + + // Prompt line + crossterm::execute!( + stdout, + SetAttribute(Attribute::Bold), + Print("Search task"), + SetAttribute(Attribute::Reset), + Print(" ("), + Print("\u{2191}/\u{2193} to move, enter to select"), + Print("): "), + Print(&state.query), + Print("\r\n"), + )?; + lines += 1; + + // Items + let visible = state.visible_range(); + + for vi in visible { + let item_idx = state.filtered[vi]; + let item = &state.items[item_idx]; + let is_selected = vi == state.selected; + + if is_selected { + crossterm::execute!( + stdout, + SetAttribute(Attribute::Bold), + Print("> "), + Print(item.label.as_str()), + Print(": "), + SetForegroundColor(Color::Cyan), + Print(item.description.as_str()), + ResetColor, + SetAttribute(Attribute::Reset), + Print("\r\n"), + )?; + } else { + crossterm::execute!( + stdout, + Print(" "), + Print(item.label.as_str()), + Print(": "), + SetForegroundColor(Color::Cyan), + Print(item.description.as_str()), + ResetColor, + Print("\r\n"), + )?; + } + lines += 1; + } + + // Footer: hidden items count + let hidden = state.hidden_count(); + if hidden > 0 { + crossterm::execute!( + stdout, + Print(vite_str::format!(" (\u{2026}{hidden} more)")), + Print("\r\n"), + )?; + lines += 1; + } + + // Empty state + if state.filtered.is_empty() { + crossterm::execute!(stdout, Print(" No matching tasks.\r\n"))?; + lines += 1; + } + + stdout.flush()?; + state.rendered_lines = lines as usize; + Ok(()) +} + +pub fn run( + items: &[SelectItem], + initial_query: Option<&str>, + header: Option<&str>, + page_size: usize, + mut after_render: impl FnMut(&RenderState<'_>), +) -> anyhow::Result> { + if items.is_empty() { + anyhow::bail!("No tasks available"); + } + + let _guard = RawModeGuard::enable()?; + // Hide cursor while the widget is active + let mut out = stdout(); + crossterm::execute!(out, cursor::Hide)?; + + let mut state = State::new(items, initial_query, page_size); + + // Initial render + render(&mut out, &mut state, header)?; + after_render(&RenderState { query: &state.query, selected_index: state.selected }); + + loop { + let ev = event::read()?; + match ev { + Event::Key(KeyEvent { code, modifiers, kind: KeyEventKind::Press, .. }) => match code { + KeyCode::Esc => { + cleanup(&mut out, &state)?; + return Ok(None); + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + cleanup(&mut out, &state)?; + return Ok(None); + } + KeyCode::Enter => { + let result = state + .selected_original_index() + .map(|idx| SelectResult { original_index: idx }); + cleanup(&mut out, &state)?; + return Ok(result); + } + KeyCode::Up => { + state.move_up(); + } + KeyCode::Down => { + state.move_down(); + } + KeyCode::Char(c) => { + state.query.push(c); + state.refilter(); + } + KeyCode::Backspace => { + state.query.pop(); + state.refilter(); + } + _ => continue, + }, + _ => continue, + } + + render(&mut out, &mut state, header)?; + after_render(&RenderState { query: &state.query, selected_index: state.selected }); + } +} + +/// Clear the widget output and restore cursor. +fn cleanup(stdout: &mut impl Write, state: &State<'_>) -> anyhow::Result<()> { + if state.rendered_lines > 0 { + let move_up = u16::try_from(state.rendered_lines) + .expect("rendered_lines fits in u16: at most header + page_size + footer lines"); + crossterm::execute!( + stdout, + cursor::MoveUp(move_up), + MoveToColumn(0), + Clear(ClearType::FromCursorDown), + )?; + } + crossterm::execute!(stdout, cursor::Show)?; + stdout.flush()?; + Ok(()) +} diff --git a/crates/vite_select/src/lib.rs b/crates/vite_select/src/lib.rs new file mode 100644 index 00000000..f996220d --- /dev/null +++ b/crates/vite_select/src/lib.rs @@ -0,0 +1,87 @@ +mod fuzzy; +mod interactive; + +use std::io::Write; + +pub use fuzzy::fuzzy_match; +use vite_str::Str; + +/// An item in the selection list. +pub struct SelectItem { + /// Display label, e.g. `"build"` or `"app#build"`. + pub label: Str, + /// Description shown next to the label, e.g. `"echo build app"`. + pub description: Str, +} + +/// Snapshot of the selector's visible state, passed to `after_render`. +pub struct RenderState<'a> { + /// Current search text (empty if no filter typed yet). + pub query: &'a str, + /// Index of the highlighted item in the **filtered** list. + pub selected_index: usize, +} + +/// Result returned when the user confirms a selection. +pub struct SelectResult { + /// Index into the *original* `items` slice. + pub original_index: usize, +} + +/// Show an interactive fuzzy-searchable select list. +/// +/// `after_render` is called after every render with the current visible state +/// (useful for emitting test milestones). +/// +/// Returns `Ok(None)` if the user cancels (Esc / Ctrl-C). +/// +/// # Errors +/// +/// Returns an error if terminal I/O fails. +pub fn interactive_select( + items: &[SelectItem], + initial_query: Option<&str>, + header: Option<&str>, + page_size: usize, + after_render: impl FnMut(&RenderState<'_>), +) -> anyhow::Result> { + interactive::run(items, initial_query, header, page_size, after_render) +} + +/// Print a list of items to `writer` (non-interactive). +/// +/// When `query` is `Some(q)`, only items matching the fuzzy filter are printed. +/// When `query` is `None`, all items are printed. +/// +/// `header` is printed above the list (e.g. an error message). +/// +/// # Errors +/// +/// Returns an error if writing fails. +pub fn print_select_list( + writer: &mut impl Write, + items: &[SelectItem], + query: Option<&str>, + header: Option<&str>, +) -> anyhow::Result<()> { + if let Some(header) = header { + writeln!(writer, "{header}")?; + } + + let labels: Vec<&str> = items.iter().map(|item| item.label.as_str()).collect(); + + let indices: Vec = + query.map_or_else(|| (0..items.len()).collect(), |q| fuzzy_match(q, &labels)); + + if indices.is_empty() { + writeln!(writer, " No matching tasks found.")?; + return Ok(()); + } + + for &idx in &indices { + let item = &items[idx]; + writeln!(writer, " {}: {}", item.label, item.description)?; + } + + Ok(()) +} diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 9192f1e5..d485a683 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -24,6 +24,7 @@ futures-util = { workspace = true } once_cell = { workspace = true } owo-colors = { workspace = true } petgraph = { workspace = true } +pty_terminal_test_client = { workspace = true } rayon = { workspace = true } rusqlite = { workspace = true, features = ["bundled"] } rustc-hash = { workspace = true } @@ -35,6 +36,7 @@ tracing = { workspace = true } twox-hash = { workspace = true } vite_glob = { workspace = true } vite_path = { workspace = true } +vite_select = { workspace = true } vite_str = { workspace = true } vite_task_graph = { workspace = true } vite_task_plan = { workspace = true } diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 5926155d..45bc76d5 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -12,12 +12,12 @@ pub enum CacheSubcommand { Clean, } -/// Arguments for the `run` subcommand. -#[derive(Debug, clap::Args)] -pub struct RunCommand { - /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. - pub task_specifier: Option, - +/// Flags that control how a `run` command selects tasks. +/// +/// Extracted as a separate struct so they can be cheaply `Copy`-ed +/// before `RunCommand` is consumed. +#[derive(Debug, Clone, Copy, clap::Args)] +pub struct RunFlags { /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. #[clap(default_value = "false", short, long)] pub recursive: bool, @@ -29,6 +29,16 @@ pub struct RunCommand { /// Do not run dependencies specified in `dependsOn` fields. #[clap(default_value = "false", long)] pub ignore_depends_on: bool, +} + +/// Arguments for the `run` subcommand. +#[derive(Debug, clap::Args)] +pub struct RunCommand { + /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. + pub task_specifier: Option, + + #[clap(flatten)] + pub flags: RunFlags, /// Additional arguments to pass to the tasks #[clap(trailing_var_arg = true, allow_hyphen_values = true)] @@ -70,8 +80,11 @@ impl RunCommand { self, cwd: &Arc, ) -> Result { - let Self { task_specifier, recursive, transitive, ignore_depends_on, additional_args } = - self; + let Self { + task_specifier, + flags: RunFlags { recursive, transitive, ignore_depends_on }, + additional_args, + } = self; let task_specifier = task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index bf391971..41e7c04b 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -4,7 +4,7 @@ mod maybe_str; pub mod session; // Public exports for vite_task_bin -pub use cli::{CacheSubcommand, Command, RunCommand}; +pub use cli::{CacheSubcommand, Command, RunCommand, RunFlags}; pub use session::{CommandHandler, ExitStatus, HandledCommand, Session, SessionCallbacks}; pub use vite_task_graph::{ config::{ diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 6b0876a3..d4868275 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -4,7 +4,7 @@ mod execute; pub(crate) mod reporter; // Re-export types that are part of the public API -use std::{ffi::OsStr, fmt::Debug, sync::Arc}; +use std::{ffi::OsStr, fmt::Debug, io::IsTerminal, sync::Arc}; use cache::ExecutionCache; pub use cache::{CacheMiss, FingerprintMismatch}; @@ -14,9 +14,10 @@ pub use reporter::ExitStatus; use reporter::LabeledReporter; use rustc_hash::FxHashMap; use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_select::SelectItem; use vite_str::Str; use vite_task_graph::{ - IndexedTaskGraph, TaskGraph, TaskGraphLoadError, config::user::UserCacheConfig, + IndexedTaskGraph, TaskGraph, TaskGraphLoadError, TaskSpecifier, config::user::UserCacheConfig, loader::UserConfigLoader, }; use vite_task_plan::{ @@ -26,7 +27,7 @@ use vite_task_plan::{ }; use vite_workspace::{WorkspaceRoot, find_workspace_root}; -use crate::cli::{CacheSubcommand, Command, RunCommand}; +use crate::cli::{CacheSubcommand, Command, RunCommand, RunFlags}; #[derive(Debug)] enum LazyTaskGraph<'a> { @@ -228,6 +229,13 @@ impl<'a> Session<'a> { Command::Cache { ref subcmd } => self.handle_cache_command(subcmd), Command::Run(run_command) => { let cwd = Arc::clone(&self.cwd); + let is_interactive = + std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + + // Copy flags before consuming run_command + let flags = run_command.flags; + let additional_args = run_command.additional_args.clone(); + match self.plan_from_cli(cwd, run_command).await { Ok(plan) => { let reporter = @@ -238,8 +246,23 @@ impl<'a> Session<'a> { .err() .unwrap_or(ExitStatus::SUCCESS)) } - Err(err) if err.is_missing_task_specifier() => self.print_task_list().await, - Err(err) => Err(err.into()), + Err(err) if err.is_missing_task_specifier() => { + self.handle_no_task(is_interactive, None, flags, additional_args).await + } + Err(err) => { + if let Some(task_name) = err.task_not_found_name() { + let task_name = task_name.to_owned(); + self.handle_no_task( + is_interactive, + Some(&task_name), + flags, + additional_args, + ) + .await + } else { + Err(err.into()) + } + } } } } @@ -256,13 +279,25 @@ impl<'a> Session<'a> { } } + /// Handle the case where no task was specified or a task name was not found. + /// + /// In interactive mode, shows a fuzzy-searchable selection list. + /// In non-interactive mode, prints the task list or "did you mean" suggestions. #[expect( clippy::future_not_send, reason = "session is single-threaded, futures do not need to be Send" )] - async fn print_task_list(&mut self) -> anyhow::Result { - use std::io::Write; - + #[expect( + clippy::large_futures, + reason = "interactive select future is large but only awaited once" + )] + async fn handle_no_task( + &mut self, + is_interactive: bool, + not_found_name: Option<&str>, + flags: RunFlags, + additional_args: Vec, + ) -> anyhow::Result { let cwd = Arc::clone(&self.cwd); let task_graph = self.ensure_task_graph_loaded().await?; let mut entries = task_graph.list_tasks(); @@ -278,39 +313,111 @@ impl<'a> Session<'a> { .iter() .map(|e| &e.task_display.package_path) .filter(|p| cwd.as_path().starts_with(p.as_path())) - .max_by_key(|p| p.as_path().as_os_str().len()); + .max_by_key(|p| p.as_path().as_os_str().len()) + .cloned(); + // Sort: current package tasks first, then others let (current, others): (Vec<_>, Vec<_>) = entries .iter() - .partition(|e| current_package_path == Some(&e.task_display.package_path)); + .partition(|e| current_package_path.as_ref() == Some(&e.task_display.package_path)); - let mut stdout = std::io::stdout().lock(); + // Build the items list: current package tasks first (unqualified name), + // then other packages (qualified with package#task). + let select_items: Vec = current + .iter() + .map(|entry| SelectItem { + label: entry.task_display.task_name.clone(), + description: entry.command.clone(), + }) + .chain(others.iter().map(|entry| SelectItem { + label: vite_str::format!("{}", entry.task_display), + description: entry.command.clone(), + })) + .collect(); - if !current.is_empty() { - let package_name = ¤t[0].task_display.package_name; - if package_name.is_empty() { - writeln!(stdout, "Tasks in the current package")?; - } else { - writeln!(stdout, "Tasks in the current package ({package_name})")?; - } - for entry in ¤t { - writeln!(stdout, " {}", entry.task_display.task_name)?; - writeln!(stdout, " {}", entry.command)?; - } + let header = not_found_name.map(|name| vite_str::format!("Task \"{name}\" not found.")); + let header_str = header.as_deref(); + + if is_interactive { + self.interactive_task_select( + &select_items, + not_found_name, + header_str, + flags, + additional_args, + ) + .await + } else { + Self::non_interactive_task_list(&select_items, not_found_name, header_str) } + } - if !others.is_empty() { - if !current.is_empty() { - writeln!(stdout)?; - } - writeln!(stdout, "Tasks in other packages")?; - for entry in &others { - writeln!(stdout, " {}", entry.task_display)?; - writeln!(stdout, " {}", entry.command)?; - } - } + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] + #[expect( + clippy::large_futures, + reason = "execution plan future is large but only awaited once" + )] + async fn interactive_task_select( + &mut self, + items: &[SelectItem], + not_found_name: Option<&str>, + header: Option<&str>, + flags: RunFlags, + additional_args: Vec, + ) -> anyhow::Result { + let selection = + vite_select::interactive_select(items, not_found_name, header, 8, |state| { + use std::io::Write; + let milestone_name = + vite_str::format!("task-select:{}:{}", state.query, state.selected_index); + let milestone_bytes = pty_terminal_test_client::encoded_milestone(&milestone_name); + let mut out = std::io::stdout(); + let _ = out.write_all(&milestone_bytes); + let _ = out.flush(); + })?; + + let Some(result) = selection else { + return Ok(ExitStatus::SUCCESS); + }; + + let selected_label = &items[result.original_index].label; + + // Parse the selected label back into a TaskSpecifier and re-run + let task_specifier = TaskSpecifier::parse_raw(selected_label); - Ok(ExitStatus::SUCCESS) + let run_command = + RunCommand { task_specifier: Some(task_specifier), flags, additional_args }; + + let cwd = Arc::clone(&self.cwd); + let plan = self.plan_from_cli(cwd, run_command).await?; + let reporter = LabeledReporter::new(std::io::stdout(), self.workspace_path()); + Ok(self.execute(plan, Box::new(reporter)).await.err().unwrap_or(ExitStatus::SUCCESS)) + } + + fn non_interactive_task_list( + items: &[SelectItem], + not_found_name: Option<&str>, + header: Option<&str>, + ) -> anyhow::Result { + let mut stdout = std::io::stdout().lock(); + + // For the "did you mean" case, add suffix to header + let did_you_mean_header = not_found_name + .map(|name| vite_str::format!("Task \"{name}\" not found. Did you mean:")); + let effective_header = + if not_found_name.is_some() { did_you_mean_header.as_deref() } else { header }; + + vite_select::print_select_list(&mut stdout, items, not_found_name, effective_header)?; + + if not_found_name.is_some() { + // Non-interactive typo case should exit with failure + Ok(ExitStatus::FAILURE) + } else { + Ok(ExitStatus::SUCCESS) + } } /// Lazily initializes and returns the execution cache. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml index b69e78b2..446672c4 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml @@ -2,13 +2,13 @@ name = "list tasks from package dir" cwd = "packages/app" steps = [ - "vp run", + "echo '' | vp run", ] [[e2e]] name = "list tasks from workspace root" steps = [ - "vp run", + "echo '' | vp run", ] [[e2e]] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap index 719da9ea..40607a14 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap @@ -1,21 +1,11 @@ --- source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs -input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list --- -> vp run -Tasks in the current package (app) - build - echo build app - lint - echo lint app - test - echo test app - -Tasks in other packages - lib#build - echo build lib - task-list-test#hello - echo hello from root - task-list-test#list-tasks - vp run +> echo '' | vp run + build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + task-list-test#hello: echo hello from root + task-list-test#list-tasks: vp run diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap index a864b107..000a9748 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap @@ -1,21 +1,11 @@ --- source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs -input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list --- -> vp run -Tasks in the current package (task-list-test) - hello - echo hello from root - list-tasks - vp run - -Tasks in other packages - app#build - echo build app - app#lint - echo lint app - app#test - echo test app - lib#build - echo build lib +> echo '' | vp run + hello: echo hello from root + list-tasks: vp run + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + lib#build: echo build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap index 8db581cc..f4210397 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap @@ -1,25 +1,15 @@ --- source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs -input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list --- > vp run list-tasks $ vp run ⊘ cache disabled: no cache config -Tasks in the current package (task-list-test) - hello - echo hello from root - list-tasks - vp run - -Tasks in other packages - app#build - echo build app - app#lint - echo lint app - app#test - echo test app - lib#build - echo build lib + hello: echo hello from root + list-tasks: vp run + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + lib#build: echo build lib ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/package.json new file mode 100644 index 00000000..b3ea6388 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/package.json @@ -0,0 +1,4 @@ +{ + "name": "task-select-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/package.json new file mode 100644 index 00000000..7b19151f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "private": true, + "dependencies": { + "lib": "workspace:*" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/vite-task.json new file mode 100644 index 00000000..9f842f77 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/vite-task.json @@ -0,0 +1,13 @@ +{ + "tasks": { + "build": { + "command": "echo build app" + }, + "lint": { + "command": "echo lint app" + }, + "test": { + "command": "echo test app" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/package.json new file mode 100644 index 00000000..42510612 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "lib", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/vite-task.json new file mode 100644 index 00000000..93d1597c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/vite-task.json @@ -0,0 +1,16 @@ +{ + "tasks": { + "build": { + "command": "echo build lib" + }, + "lint": { + "command": "echo lint lib" + }, + "test": { + "command": "echo test lib" + }, + "typecheck": { + "command": "echo typecheck lib" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* 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 new file mode 100644 index 00000000..47f195d3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml @@ -0,0 +1,85 @@ +# Non-interactive: list all tasks (piped stdin forces non-interactive mode) +[[e2e]] +name = "non-interactive list tasks" +steps = [ + "echo '' | vp run", +] + +# Non-interactive: typo triggers "did you mean" +[[e2e]] +name = "non-interactive did you mean" +steps = [ + "echo '' | vp run buid", +] + +# Non-interactive: typo with -r flag +[[e2e]] +name = "non-interactive did you mean with recursive" +steps = [ + "echo '' | vp run -r buid", +] + +# Interactive: navigate down and select second task +[[e2e]] +name = "interactive select task" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::1" }, { "write-key" = "enter" }] }, +] + +# Interactive: typo pre-filled in search +[[e2e]] +name = "interactive select with typo" +cwd = "packages/app" +steps = [ + { command = "vp run buid", interactions = [{ "expect-milestone" = "task-select:buid:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: type to search then select +[[e2e]] +name = "interactive search then select" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "lin" }, { "expect-milestone" = "task-select:lin:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: cancel with escape +[[e2e]] +name = "interactive cancel" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "escape" }] }, +] + +# Interactive: -r flag preserved +[[e2e]] +name = "interactive select with recursive" +cwd = "packages/app" +steps = [ + { command = "vp run -r", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, +] + +# Interactive: -t flag + typo +[[e2e]] +name = "interactive select with typo and transitive" +cwd = "packages/app" +steps = [ + { command = "vp run -t buid", interactions = [{ "expect-milestone" = "task-select:buid:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: scroll down past visible page, then select a task beyond the initial viewport +[[e2e]] +name = "interactive scroll long list" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, # Navigate down to index 8 (past page_size=8, triggering scroll) + { "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "expect-milestone" = "task-select::8" }, # Scroll back up to the top + { "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "expect-milestone" = "task-select::0" },{ "write-key" = "enter" },] }, +] + +# Typo inside a task script should fail with an error, NOT show a list +[[e2e]] +name = "typo in task script fails without list" +steps = [ + "vp run run-typo-task", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap new file mode 100644 index 00000000..5bcbcec1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write-key: escape diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap new file mode 100644 index 00000000..21579933 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap @@ -0,0 +1,71 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ expect-milestone: task-select::8 +Search task (↑/↓ to move, enter to select): + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root +> task-select-test#format: echo format root + (…3 more) +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write-key: enter +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap new file mode 100644 index 00000000..94e8fc90 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap @@ -0,0 +1,38 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write: lin +@ expect-milestone: task-select:lin:0 +Search task (↑/↓ to move, enter to select): lin +> lint: echo lint app + lib#lint: echo lint lib +@ write-key: enter +~/packages/app$ echo lint app ⊘ cache disabled: built-in command +lint app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#lint: ~/packages/app$ echo lint app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap new file mode 100644 index 00000000..889b2b0c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap @@ -0,0 +1,45 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write-key: down +@ expect-milestone: task-select::1 +Search task (↑/↓ to move, enter to select): + build: echo build app +> lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write-key: enter +~/packages/app$ echo lint app ⊘ cache disabled: built-in command +lint app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#lint: ~/packages/app$ echo lint app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap new file mode 100644 index 00000000..25f8829e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap @@ -0,0 +1,39 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run -r +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write-key: enter +~/packages/lib$ echo build lib ⊘ cache disabled: built-in command +build lib + +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 2 tasks • 0 cache hits • 0 cache misses • 2 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#build: ~/packages/lib$ echo build lib ✓ + → Cache disabled for built-in command + ······················································· + [2] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap new file mode 100644 index 00000000..75d72250 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap @@ -0,0 +1,33 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run -t buid +@ expect-milestone: task-select:buid:0 +Task "buid" not found. +Search task (↑/↓ to move, enter to select): buid +> build: echo build app + lib#build: echo build lib +@ write-key: enter +~/packages/lib$ echo build lib ⊘ cache disabled: built-in command +build lib + +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 2 tasks • 0 cache hits • 0 cache misses • 2 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#build: ~/packages/lib$ echo build lib ✓ + → Cache disabled for built-in command + ······················································· + [2] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap new file mode 100644 index 00000000..6d8e0d76 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap @@ -0,0 +1,27 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run buid +@ expect-milestone: task-select:buid:0 +Task "buid" not found. +Search task (↑/↓ to move, enter to select): buid +> build: echo build app + lib#build: echo build lib +@ write-key: enter +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap new file mode 100644 index 00000000..f8940b84 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> echo '' | vp run -r buid +Task "buid" not found. Did you mean: + app#build: echo build app + lib#build: echo build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean.snap new file mode 100644 index 00000000..e6ecb83d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> echo '' | vp run buid +Task "buid" not found. Did you mean: + app#build: echo build app + lib#build: echo build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap new file mode 100644 index 00000000..d0f308f5 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> echo '' | vp run + check: echo check root + format: echo format root + hello: echo hello from root + run-typo-task: vp run nonexistent-xyz + validate: echo validate root + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap new file mode 100644 index 00000000..cb58fd0d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> vp run run-typo-task +Error: Failed to plan execution, task call stack: task-select-test#run-typo-task + +Caused by: + 0: Failed to query tasks from task graph + 1: Failed to look up task from specifier: nonexistent-xyz + 2: Task 'nonexistent-xyz' not found in package task-select-test diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json new file mode 100644 index 00000000..54879f75 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json @@ -0,0 +1,19 @@ +{ + "tasks": { + "check": { + "command": "echo check root" + }, + "format": { + "command": "echo format root" + }, + "hello": { + "command": "echo hello from root" + }, + "run-typo-task": { + "command": "vp run nonexistent-xyz" + }, + "validate": { + "command": "echo validate root" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 3581c239..07e65543 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -161,6 +161,8 @@ enum WriteKey { Up, Down, Enter, + Escape, + Backspace, } impl WriteKey { @@ -169,6 +171,8 @@ impl WriteKey { Self::Up => "up", Self::Down => "down", Self::Enter => "enter", + Self::Escape => "escape", + Self::Backspace => "backspace", } } @@ -177,6 +181,8 @@ impl WriteKey { Self::Up => b"\x1b[A", Self::Down => b"\x1b[B", Self::Enter => b"\r", + Self::Escape => b"\x1b", + Self::Backspace => b"\x7f", } } } diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index c96dddd4..d52b2e3d 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -61,6 +61,9 @@ pub enum TaskQueryError { #[source] lookup_error: SpecifierLookupError, }, + + #[error("No packages have a task named '{task_name}'")] + RecursiveTaskNotFound { task_name: Str }, } impl IndexedTaskGraph { @@ -135,11 +138,24 @@ impl IndexedTaskGraph { } TaskQueryKind::Recursive { task_names } => { // Add all tasks matching the names across all packages + let mut matched_names = FxHashSet::<&str>::with_capacity_and_hasher( + task_names.len(), + rustc_hash::FxBuildHasher, + ); for task_index in self.task_graph.node_indices() { let current_task_name = self.task_graph[task_index].task_display.task_name.as_str(); if task_names.contains(current_task_name) { execution_graph.add_node(task_index); + matched_names.insert(current_task_name); + } + } + // Return an error if any requested task name was not found in any package + for task_name in &task_names { + if !matched_names.contains(task_name.as_str()) { + return Err(TaskQueryError::RecursiveTaskNotFound { + task_name: task_name.clone(), + }); } } } diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 989d9763..36df150d 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -169,6 +169,28 @@ impl Error { pub const fn is_missing_task_specifier(&self) -> bool { matches!(self.kind, TaskPlanErrorKind::MissingTaskSpecifier) } + + /// If this error represents a top-level task-not-found lookup failure, + /// returns the task name that the user typed. + /// + /// Returns `None` if the error occurred in a nested task (non-empty call stack), + /// since nested task errors should propagate as-is rather than triggering + /// interactive task selection. + #[must_use] + pub fn task_not_found_name(&self) -> Option<&str> { + if !self.task_call_stack.is_empty() { + return None; + } + match &self.kind { + TaskPlanErrorKind::TaskQueryError( + vite_task_graph::query::TaskQueryError::SpecifierLookupError { specifier, .. }, + ) => Some(specifier.task_name.as_str()), + TaskPlanErrorKind::TaskQueryError( + vite_task_graph::query::TaskQueryError::RecursiveTaskNotFound { task_name }, + ) => Some(task_name.as_str()), + _ => None, + } + } } #[expect( From cde7a141c5250d9ce1e4145ca97de2ffb069c850 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 08:40:14 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20CI=20failures=20=E2=80=94=20add=20?= =?UTF-8?q?'buid'=20to=20typos=20allowlist=20and=20fix=20trailing=20space?= =?UTF-8?q?=20for=20Windows=20ConPTY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .typos.toml | 1 + crates/vite_select/src/interactive.rs | 37 +++++++++++++------ .../snapshots/interactive cancel.snap | 2 +- .../interactive scroll long list.snap | 6 +-- .../interactive search then select.snap | 2 +- .../snapshots/interactive select task.snap | 4 +- .../interactive select with recursive.snap | 2 +- 7 files changed, 35 insertions(+), 19 deletions(-) diff --git a/.typos.toml b/.typos.toml index 9b8271d7..8ecd5b94 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,6 +1,7 @@ [default.extend-words] ratatui = "ratatui" PUNICODE = "PUNICODE" +buid = "buid" [files] extend-exclude = [ diff --git a/crates/vite_select/src/interactive.rs b/crates/vite_select/src/interactive.rs index c691dada..d127fd1d 100644 --- a/crates/vite_select/src/interactive.rs +++ b/crates/vite_select/src/interactive.rs @@ -123,17 +123,32 @@ fn render( } // Prompt line - crossterm::execute!( - stdout, - SetAttribute(Attribute::Bold), - Print("Search task"), - SetAttribute(Attribute::Reset), - Print(" ("), - Print("\u{2191}/\u{2193} to move, enter to select"), - Print("): "), - Print(&state.query), - Print("\r\n"), - )?; + // Print ": " separator before query only when query is non-empty, + // to avoid a trailing space that Windows ConPTY would strip. + if state.query.is_empty() { + crossterm::execute!( + stdout, + SetAttribute(Attribute::Bold), + Print("Search task"), + SetAttribute(Attribute::Reset), + Print(" ("), + Print("\u{2191}/\u{2193} to move, enter to select"), + Print("):"), + Print("\r\n"), + )?; + } else { + crossterm::execute!( + stdout, + SetAttribute(Attribute::Bold), + Print("Search task"), + SetAttribute(Attribute::Reset), + Print(" ("), + Print("\u{2191}/\u{2193} to move, enter to select"), + Print("): "), + Print(&state.query), + Print("\r\n"), + )?; + } lines += 1; // Items diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap index 5bcbcec1..4d5a31c7 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap @@ -4,7 +4,7 @@ expression: e2e_outputs --- > vp run @ expect-milestone: task-select::0 -Search task (↑/↓ to move, enter to select): +Search task (↑/↓ to move, enter to select): > build: echo build app lint: echo lint app test: echo test app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap index 21579933..8c004eca 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap @@ -4,7 +4,7 @@ expression: e2e_outputs --- > vp run @ expect-milestone: task-select::0 -Search task (↑/↓ to move, enter to select): +Search task (↑/↓ to move, enter to select): > build: echo build app lint: echo lint app test: echo test app @@ -23,7 +23,7 @@ Search task (↑/↓ to move, enter to select): @ write-key: down @ write-key: down @ expect-milestone: task-select::8 -Search task (↑/↓ to move, enter to select): +Search task (↑/↓ to move, enter to select): lint: echo lint app test: echo test app lib#build: echo build lib @@ -42,7 +42,7 @@ Search task (↑/↓ to move, enter to select): @ write-key: up @ write-key: up @ expect-milestone: task-select::0 -Search task (↑/↓ to move, enter to select): +Search task (↑/↓ to move, enter to select): > build: echo build app lint: echo lint app test: echo test app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap index 94e8fc90..dbba2b40 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap @@ -4,7 +4,7 @@ expression: e2e_outputs --- > vp run @ expect-milestone: task-select::0 -Search task (↑/↓ to move, enter to select): +Search task (↑/↓ to move, enter to select): > build: echo build app lint: echo lint app test: echo test app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap index 889b2b0c..41c5d91e 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap @@ -4,7 +4,7 @@ expression: e2e_outputs --- > vp run @ expect-milestone: task-select::0 -Search task (↑/↓ to move, enter to select): +Search task (↑/↓ to move, enter to select): > build: echo build app lint: echo lint app test: echo test app @@ -16,7 +16,7 @@ Search task (↑/↓ to move, enter to select): (…4 more) @ write-key: down @ expect-milestone: task-select::1 -Search task (↑/↓ to move, enter to select): +Search task (↑/↓ to move, enter to select): build: echo build app > lint: echo lint app test: echo test app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap index 25f8829e..8021e1b9 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap @@ -4,7 +4,7 @@ expression: e2e_outputs --- > vp run -r @ expect-milestone: task-select::0 -Search task (↑/↓ to move, enter to select): +Search task (↑/↓ to move, enter to select): > build: echo build app lint: echo lint app test: echo test app From 068b9f480eb9aacdd8c7734e5cd978ec21145fe7 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 08:51:34 +0800 Subject: [PATCH 04/11] fix: exclude typo test files from spellchecker instead of global allowlist --- .typos.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.typos.toml b/.typos.toml index 8ecd5b94..f671e6db 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,10 +1,12 @@ [default.extend-words] ratatui = "ratatui" PUNICODE = "PUNICODE" -buid = "buid" [files] extend-exclude = [ "crates/fspy_detours_sys/detours", "crates/fspy_detours_sys/src/generated_bindings.rs", + # Intentional typos for testing fuzzy matching and "did you mean" suggestions + "crates/vite_select/src/fuzzy.rs", + "crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select", ] From f2bbadc4aef7e71d88dea1a5ed4351aaf7baab07 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 10:00:52 +0800 Subject: [PATCH 05/11] refactor: unify interactive and non-interactive list rendering in vite_select - Replace interactive_select() + print_select_list() with single select_list() - Add Mode enum: Interactive { selected_index: &mut usize } / NonInteractive - Remove SelectResult; selected index written directly via Mode reference - Shared render_items() with RenderParams used by both modes - Switch from crossterm styling to owo-colors with if_supports_color - Esc now clears search query instead of cancelling selection - Ctrl+C restores terminal and exits with code 130 --- Cargo.lock | 34 ++- Cargo.toml | 2 +- crates/vite_select/Cargo.toml | 1 + crates/vite_select/src/interactive.rs | 213 ++++++++++-------- crates/vite_select/src/lib.rs | 87 +++---- crates/vite_task/src/session/mod.rs | 30 ++- .../fixtures/task-select/snapshots.toml | 6 +- .../snapshots/interactive cancel.snap | 17 -- .../interactive escape clears query.snap | 50 ++++ 9 files changed, 275 insertions(+), 165 deletions(-) delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap diff --git a/Cargo.lock b/Cargo.lock index 48182c98..a250a0c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1499,6 +1499,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1576,6 +1582,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -2207,6 +2224,10 @@ name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] [[package]] name = "parking_lot" @@ -3192,6 +3213,16 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + [[package]] name = "supports-color" version = "3.0.2" @@ -3803,6 +3834,7 @@ dependencies = [ "assert2", "crossterm", "nucleo-matcher", + "owo-colors", "vite_str", ] @@ -3936,7 +3968,7 @@ dependencies = [ "serde_json", "sha2", "shell-escape", - "supports-color", + "supports-color 3.0.2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index f0ac0615..d9fcb3f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,7 +89,7 @@ nucleo-matcher = "0.3.1" once_cell = "1.19" os_str_bytes = "7.1.1" ouroboros = "0.18.5" -owo-colors = "4.1.0" +owo-colors = { version = "4.1.0", features = ["supports-colors"] } passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false } pathdiff = "0.2.3" petgraph = "0.8.2" diff --git a/crates/vite_select/Cargo.toml b/crates/vite_select/Cargo.toml index 6a344ae0..bc859128 100644 --- a/crates/vite_select/Cargo.toml +++ b/crates/vite_select/Cargo.toml @@ -14,6 +14,7 @@ workspace = true anyhow = { workspace = true } crossterm = { workspace = true } nucleo-matcher = { workspace = true } +owo-colors = { workspace = true } vite_str = { path = "../vite_str" } [dev-dependencies] diff --git a/crates/vite_select/src/interactive.rs b/crates/vite_select/src/interactive.rs index d127fd1d..9be88d1c 100644 --- a/crates/vite_select/src/interactive.rs +++ b/crates/vite_select/src/interactive.rs @@ -3,11 +3,12 @@ use std::io::{Write, stdout}; use crossterm::{ cursor::{self, MoveToColumn}, event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, - style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor}, + style::{Attribute, SetAttribute}, terminal::{self, Clear, ClearType}, }; +use owo_colors::{OwoColorize, Stream}; -use crate::{RenderState, SelectItem, SelectResult, fuzzy::fuzzy_match}; +use crate::{RenderState, SelectItem, fuzzy::fuzzy_match}; struct RawModeGuard; @@ -97,125 +98,148 @@ impl<'a> State<'a> { } } -fn render( - stdout: &mut impl Write, - state: &mut State<'_>, - header: Option<&str>, -) -> anyhow::Result<()> { - // Move cursor up to clear previous render - if state.rendered_lines > 0 { - let move_up = u16::try_from(state.rendered_lines) - .expect("rendered_lines fits in u16: at most header + page_size + footer lines"); - crossterm::execute!( - stdout, - cursor::MoveUp(move_up), - MoveToColumn(0), - Clear(ClearType::FromCursorDown), - )?; - } +/// Parameters for rendering a task list. +pub struct RenderParams<'a> { + pub items: &'a [SelectItem], + pub filtered: &'a [usize], + /// Index into `filtered` of the highlighted item, or `None` for non-interactive. + pub selected_in_filtered: Option, + /// Which slice of `filtered` to display. + pub visible_range: std::ops::Range, + /// Number of items beyond the visible range. + pub hidden_count: usize, + pub header: Option<&'a str>, + /// Current search text. `Some` enables the prompt line (interactive only). + pub query: Option<&'a str>, + /// `"\r\n"` for raw mode, `"\n"` for normal. + pub line_ending: &'a str, +} - let mut lines = 0u16; +/// Render the item list. Shared rendering logic used by both interactive +/// and non-interactive modes (via [`crate::non_interactive`]). +/// +/// Returns the number of lines written. +pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyhow::Result { + let RenderParams { + items, + filtered, + selected_in_filtered, + visible_range, + hidden_count, + header, + query, + line_ending, + } = params; - // Header (error message) + let mut lines = 0usize; + + // Header (e.g. error message) if let Some(header) = header { - crossterm::execute!(stdout, Print(header), Print("\r\n"))?; + write!(writer, "{header}{line_ending}")?; lines += 1; } - // Prompt line - // Print ": " separator before query only when query is non-empty, - // to avoid a trailing space that Windows ConPTY would strip. - if state.query.is_empty() { - crossterm::execute!( - stdout, - SetAttribute(Attribute::Bold), - Print("Search task"), - SetAttribute(Attribute::Reset), - Print(" ("), - Print("\u{2191}/\u{2193} to move, enter to select"), - Print("):"), - Print("\r\n"), - )?; - } else { - crossterm::execute!( - stdout, - SetAttribute(Attribute::Bold), - Print("Search task"), - SetAttribute(Attribute::Reset), - Print(" ("), - Print("\u{2191}/\u{2193} to move, enter to select"), - Print("): "), - Print(&state.query), - Print("\r\n"), - )?; + // Prompt line (interactive only) + if let Some(q) = query { + let bold = SetAttribute(Attribute::Bold); + let reset = SetAttribute(Attribute::Reset); + // Print ": " separator before query only when query is non-empty, + // to avoid a trailing space that Windows ConPTY would strip. + if q.is_empty() { + write!( + writer, + "{bold}Search task{reset} (\u{2191}/\u{2193} to move, enter to select):{line_ending}", + )?; + } else { + write!( + writer, + "{bold}Search task{reset} (\u{2191}/\u{2193} to move, enter to select): {q}{line_ending}", + )?; + } + lines += 1; } - lines += 1; // Items - let visible = state.visible_range(); - - for vi in visible { - let item_idx = state.filtered[vi]; - let item = &state.items[item_idx]; - let is_selected = vi == state.selected; + for vi in visible_range.clone() { + let item_idx = filtered[vi]; + let item = &items[item_idx]; + let is_selected = *selected_in_filtered == Some(vi); + let desc_str = item.description.as_str(); + let desc = desc_str.if_supports_color(Stream::Stdout, |s| s.cyan()); if is_selected { - crossterm::execute!( - stdout, - SetAttribute(Attribute::Bold), - Print("> "), - Print(item.label.as_str()), - Print(": "), - SetForegroundColor(Color::Cyan), - Print(item.description.as_str()), - ResetColor, - SetAttribute(Attribute::Reset), - Print("\r\n"), + write!( + writer, + "{bold}> {label}: {desc}{reset}{line_ending}", + bold = SetAttribute(Attribute::Bold), + label = item.label, + reset = SetAttribute(Attribute::Reset), )?; } else { - crossterm::execute!( - stdout, - Print(" "), - Print(item.label.as_str()), - Print(": "), - SetForegroundColor(Color::Cyan), - Print(item.description.as_str()), - ResetColor, - Print("\r\n"), - )?; + write!(writer, " {}: {desc}{line_ending}", item.label)?; } lines += 1; } // Footer: hidden items count - let hidden = state.hidden_count(); - if hidden > 0 { - crossterm::execute!( - stdout, - Print(vite_str::format!(" (\u{2026}{hidden} more)")), - Print("\r\n"), - )?; + if *hidden_count > 0 { + write!(writer, " (\u{2026}{hidden_count} more){line_ending}")?; lines += 1; } // Empty state - if state.filtered.is_empty() { - crossterm::execute!(stdout, Print(" No matching tasks.\r\n"))?; + if filtered.is_empty() { + write!(writer, " No matching tasks.{line_ending}")?; lines += 1; } - stdout.flush()?; - state.rendered_lines = lines as usize; + writer.flush()?; + Ok(lines) +} + +fn render( + stdout: &mut impl Write, + state: &mut State<'_>, + header: Option<&str>, +) -> anyhow::Result<()> { + // Move cursor up to clear previous render + if state.rendered_lines > 0 { + let move_up = u16::try_from(state.rendered_lines) + .expect("rendered_lines fits in u16: at most header + page_size + footer lines"); + crossterm::execute!( + stdout, + cursor::MoveUp(move_up), + MoveToColumn(0), + Clear(ClearType::FromCursorDown), + )?; + } + + let lines = render_items( + stdout, + &RenderParams { + items: state.items, + filtered: &state.filtered, + selected_in_filtered: Some(state.selected), + visible_range: state.visible_range(), + hidden_count: state.hidden_count(), + header, + query: Some(&state.query), + line_ending: "\r\n", + }, + )?; + + state.rendered_lines = lines; Ok(()) } pub fn run( items: &[SelectItem], initial_query: Option<&str>, + selected_index: &mut usize, 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"); } @@ -236,19 +260,20 @@ pub fn run( match ev { Event::Key(KeyEvent { code, modifiers, kind: KeyEventKind::Press, .. }) => match code { KeyCode::Esc => { - cleanup(&mut out, &state)?; - return Ok(None); + // Clear the search query and reset the filter + state.query.clear(); + state.refilter(); } KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { cleanup(&mut out, &state)?; - return Ok(None); + std::process::exit(130); } KeyCode::Enter => { - let result = state - .selected_original_index() - .map(|idx| SelectResult { original_index: idx }); + if let Some(idx) = state.selected_original_index() { + *selected_index = idx; + } cleanup(&mut out, &state)?; - return Ok(result); + return Ok(()); } KeyCode::Up => { state.move_up(); diff --git a/crates/vite_select/src/lib.rs b/crates/vite_select/src/lib.rs index f996220d..fa68e656 100644 --- a/crates/vite_select/src/lib.rs +++ b/crates/vite_select/src/lib.rs @@ -4,6 +4,7 @@ mod interactive; use std::io::Write; pub use fuzzy::fuzzy_match; +use interactive::{RenderParams, render_items}; use vite_str::Str; /// An item in the selection list. @@ -14,6 +15,17 @@ pub struct SelectItem { pub description: Str, } +/// Selection mode. +pub enum Mode<'a> { + /// Interactive terminal UI with fuzzy search, keyboard navigation, and selection. + /// + /// On Enter, `*selected_index` is set to the index of the chosen item + /// in the original `items` slice. + Interactive { selected_index: &'a mut usize }, + /// Non-interactive: renders the list once and returns. + NonInteractive, +} + /// Snapshot of the selector's visible state, passed to `after_render`. pub struct RenderState<'a> { /// Current search text (empty if no filter typed yet). @@ -22,66 +34,61 @@ pub struct RenderState<'a> { pub selected_index: usize, } -/// Result returned when the user confirms a selection. -pub struct SelectResult { - /// Index into the *original* `items` slice. - pub original_index: usize, -} - -/// Show an interactive fuzzy-searchable select list. +/// Show a task selection list. /// -/// `after_render` is called after every render with the current visible state -/// (useful for emitting test milestones). +/// 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. /// -/// Returns `Ok(None)` if the user cancels (Esc / Ctrl-C). +/// In [`Mode::NonInteractive`], renders the list once to `writer` and +/// returns. `page_size` and `after_render` are ignored. /// /// # Errors /// /// Returns an error if terminal I/O fails. -pub fn interactive_select( +pub fn select_list( + writer: &mut impl Write, items: &[SelectItem], - initial_query: Option<&str>, + query: Option<&str>, + mode: Mode<'_>, header: Option<&str>, page_size: usize, after_render: impl FnMut(&RenderState<'_>), -) -> anyhow::Result> { - interactive::run(items, initial_query, header, page_size, after_render) +) -> anyhow::Result<()> { + match mode { + Mode::Interactive { selected_index } => { + interactive::run(items, query, selected_index, header, page_size, after_render) + } + Mode::NonInteractive => non_interactive(writer, items, query, header), + } } -/// Print a list of items to `writer` (non-interactive). -/// -/// When `query` is `Some(q)`, only items matching the fuzzy filter are printed. -/// When `query` is `None`, all items are printed. -/// -/// `header` is printed above the list (e.g. an error message). -/// -/// # Errors -/// -/// Returns an error if writing fails. -pub fn print_select_list( +fn non_interactive( writer: &mut impl Write, items: &[SelectItem], query: Option<&str>, header: Option<&str>, ) -> anyhow::Result<()> { - if let Some(header) = header { - writeln!(writer, "{header}")?; - } - let labels: Vec<&str> = items.iter().map(|item| item.label.as_str()).collect(); - - let indices: Vec = + let filtered: Vec = query.map_or_else(|| (0..items.len()).collect(), |q| fuzzy_match(q, &labels)); + let len = filtered.len(); - if indices.is_empty() { - writeln!(writer, " No matching tasks found.")?; - return Ok(()); - } - - for &idx in &indices { - let item = &items[idx]; - writeln!(writer, " {}: {}", item.label, item.description)?; - } + render_items( + writer, + &RenderParams { + items, + filtered: &filtered, + selected_in_filtered: None, + visible_range: 0..len, + hidden_count: 0, + header, + query: None, + line_ending: "\n", + }, + )?; Ok(()) } diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index d4868275..5bec445e 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -368,8 +368,15 @@ impl<'a> Session<'a> { flags: RunFlags, additional_args: Vec, ) -> anyhow::Result { - let selection = - vite_select::interactive_select(items, not_found_name, header, 8, |state| { + let mut selected_index = 0usize; + vite_select::select_list( + &mut std::io::stdout(), + items, + not_found_name, + vite_select::Mode::Interactive { selected_index: &mut selected_index }, + header, + 8, + |state| { use std::io::Write; let milestone_name = vite_str::format!("task-select:{}:{}", state.query, state.selected_index); @@ -377,13 +384,10 @@ impl<'a> Session<'a> { let mut out = std::io::stdout(); let _ = out.write_all(&milestone_bytes); let _ = out.flush(); - })?; - - let Some(result) = selection else { - return Ok(ExitStatus::SUCCESS); - }; + }, + )?; - let selected_label = &items[result.original_index].label; + let selected_label = &items[selected_index].label; // Parse the selected label back into a TaskSpecifier and re-run let task_specifier = TaskSpecifier::parse_raw(selected_label); @@ -410,7 +414,15 @@ impl<'a> Session<'a> { let effective_header = if not_found_name.is_some() { did_you_mean_header.as_deref() } else { header }; - vite_select::print_select_list(&mut stdout, items, not_found_name, effective_header)?; + vite_select::select_list( + &mut stdout, + items, + not_found_name, + vite_select::Mode::NonInteractive, + effective_header, + 0, + |_| {}, + )?; if not_found_name.is_some() { // Non-interactive typo case should exit with failure 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 47f195d3..0c01fe19 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 @@ -43,12 +43,12 @@ steps = [ { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "lin" }, { "expect-milestone" = "task-select:lin:0" }, { "write-key" = "enter" }] }, ] -# Interactive: cancel with escape +# Interactive: escape clears query and resets filter [[e2e]] -name = "interactive cancel" +name = "interactive escape clears query" cwd = "packages/app" steps = [ - { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "escape" }] }, + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "lin" }, { "expect-milestone" = "task-select:lin:0" }, { "write-key" = "escape" }, { "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, ] # Interactive: -r flag preserved diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap deleted file mode 100644 index 4d5a31c7..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive cancel.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: crates/vite_task_bin/tests/e2e_snapshots/main.rs -expression: e2e_outputs ---- -> vp run -@ expect-milestone: task-select::0 -Search task (↑/↓ to move, enter to select): -> build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - (…4 more) -@ write-key: escape diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap new file mode 100644 index 00000000..bada2db2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap @@ -0,0 +1,50 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write: lin +@ expect-milestone: task-select:lin:0 +Search task (↑/↓ to move, enter to select): lin +> lint: echo lint app + lib#lint: echo lint lib +@ write-key: escape +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write-key: enter +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ From e700cd33d81fef5ebc8a915db840a57042541f3e Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 10:04:22 +0800 Subject: [PATCH 06/11] refactor: unify interactive and non-interactive task selection into single handle_no_task method --- crates/vite_task/src/session/mod.rs | 99 +++++++++-------------------- 1 file changed, 29 insertions(+), 70 deletions(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 5bec445e..683e583c 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -335,46 +335,30 @@ impl<'a> Session<'a> { })) .collect(); - let header = not_found_name.map(|name| vite_str::format!("Task \"{name}\" not found.")); - let header_str = header.as_deref(); - - if is_interactive { - self.interactive_task_select( - &select_items, - not_found_name, - header_str, - flags, - additional_args, - ) - .await - } else { - Self::non_interactive_task_list(&select_items, not_found_name, header_str) - } - } + // Build header: interactive says "not found.", non-interactive "did you mean:" suffix + let header = not_found_name.map(|name| { + if is_interactive { + vite_str::format!("Task \"{name}\" not found.") + } else { + vite_str::format!("Task \"{name}\" not found. Did you mean:") + } + }); - #[expect( - clippy::future_not_send, - reason = "session is single-threaded, futures do not need to be Send" - )] - #[expect( - clippy::large_futures, - reason = "execution plan future is large but only awaited once" - )] - async fn interactive_task_select( - &mut self, - items: &[SelectItem], - not_found_name: Option<&str>, - header: Option<&str>, - flags: RunFlags, - additional_args: Vec, - ) -> anyhow::Result { + // Build mode-dependent params and call select_list once let mut selected_index = 0usize; + let mut stdout = std::io::stdout(); + let mode = if is_interactive { + vite_select::Mode::Interactive { selected_index: &mut selected_index } + } else { + vite_select::Mode::NonInteractive + }; + vite_select::select_list( - &mut std::io::stdout(), - items, + &mut stdout, + &select_items, not_found_name, - vite_select::Mode::Interactive { selected_index: &mut selected_index }, - header, + mode, + header.as_deref(), 8, |state| { use std::io::Write; @@ -387,11 +371,17 @@ impl<'a> Session<'a> { }, )?; - let selected_label = &items[selected_index].label; + if !is_interactive { + return if not_found_name.is_some() { + Ok(ExitStatus::FAILURE) + } else { + Ok(ExitStatus::SUCCESS) + }; + } - // Parse the selected label back into a TaskSpecifier and re-run + // Interactive: run the selected task + let selected_label = &select_items[selected_index].label; let task_specifier = TaskSpecifier::parse_raw(selected_label); - let run_command = RunCommand { task_specifier: Some(task_specifier), flags, additional_args }; @@ -401,37 +391,6 @@ impl<'a> Session<'a> { Ok(self.execute(plan, Box::new(reporter)).await.err().unwrap_or(ExitStatus::SUCCESS)) } - fn non_interactive_task_list( - items: &[SelectItem], - not_found_name: Option<&str>, - header: Option<&str>, - ) -> anyhow::Result { - let mut stdout = std::io::stdout().lock(); - - // For the "did you mean" case, add suffix to header - let did_you_mean_header = not_found_name - .map(|name| vite_str::format!("Task \"{name}\" not found. Did you mean:")); - let effective_header = - if not_found_name.is_some() { did_you_mean_header.as_deref() } else { header }; - - vite_select::select_list( - &mut stdout, - items, - not_found_name, - vite_select::Mode::NonInteractive, - effective_header, - 0, - |_| {}, - )?; - - if not_found_name.is_some() { - // Non-interactive typo case should exit with failure - Ok(ExitStatus::FAILURE) - } else { - Ok(ExitStatus::SUCCESS) - } - } - /// Lazily initializes and returns the execution cache. /// The cache is only created when first accessed to avoid `SQLite` race conditions /// when multiple processes start simultaneously. From 8a1a73f718d87b6e8c28a4392668d4ee3d3cc256 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 10:09:01 +0800 Subject: [PATCH 07/11] refactor: use Option for selected_index to distinguish interactive vs non-interactive mode --- crates/vite_task/src/session/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 683e583c..1ff19cc9 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -345,10 +345,10 @@ impl<'a> Session<'a> { }); // Build mode-dependent params and call select_list once - let mut selected_index = 0usize; + let mut selected_index = if is_interactive { Some(0) } else { None }; let mut stdout = std::io::stdout(); - let mode = if is_interactive { - vite_select::Mode::Interactive { selected_index: &mut selected_index } + let mode = if let Some(selected_index) = selected_index.as_mut() { + vite_select::Mode::Interactive { selected_index } } else { vite_select::Mode::NonInteractive }; @@ -371,13 +371,14 @@ impl<'a> Session<'a> { }, )?; - if !is_interactive { + let Some(selected_index) = selected_index else { + // Non-interactive: if no task was found, return failure. Otherwise, print the list and return return if not_found_name.is_some() { Ok(ExitStatus::FAILURE) } else { Ok(ExitStatus::SUCCESS) }; - } + }; // Interactive: run the selected task let selected_label = &select_items[selected_index].label; From f437b0a4cc1afa3b261329971a482a1728e1965e Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 10:38:25 +0800 Subject: [PATCH 08/11] feat: add before_render hook to select_list and use nearest-package detection for current-package prioritization --- crates/vite_select/src/interactive.rs | 5 ++ crates/vite_select/src/lib.rs | 35 ++++++++--- crates/vite_task/src/session/mod.rs | 62 +++++++++---------- .../fixtures/task-select/snapshots.toml | 40 ++++++++++++ ...interactive search other package task.snap | 37 +++++++++++ ...earch preserves rating within package.snap | 45 ++++++++++++++ ...active search with hash skips reorder.snap | 40 ++++++++++++ .../interactive select task from lib.snap | 33 ++++++++++ .../non-interactive list tasks from lib.snap | 17 +++++ crates/vite_task_graph/src/lib.rs | 8 +++ 10 files changed, 283 insertions(+), 39 deletions(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap diff --git a/crates/vite_select/src/interactive.rs b/crates/vite_select/src/interactive.rs index 9be88d1c..09b92968 100644 --- a/crates/vite_select/src/interactive.rs +++ b/crates/vite_select/src/interactive.rs @@ -238,6 +238,7 @@ pub fn run( selected_index: &mut usize, header: Option<&str>, page_size: usize, + mut before_render: impl FnMut(&mut Vec, &str), mut after_render: impl FnMut(&RenderState<'_>), ) -> anyhow::Result<()> { if items.is_empty() { @@ -250,6 +251,7 @@ pub fn run( crossterm::execute!(out, cursor::Hide)?; let mut state = State::new(items, initial_query, page_size); + before_render(&mut state.filtered, &state.query); // Initial render render(&mut out, &mut state, header)?; @@ -263,6 +265,7 @@ pub fn run( // Clear the search query and reset the filter state.query.clear(); state.refilter(); + before_render(&mut state.filtered, &state.query); } KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { cleanup(&mut out, &state)?; @@ -284,10 +287,12 @@ pub fn run( KeyCode::Char(c) => { state.query.push(c); state.refilter(); + before_render(&mut state.filtered, &state.query); } KeyCode::Backspace => { state.query.pop(); state.refilter(); + before_render(&mut state.filtered, &state.query); } _ => continue, }, diff --git a/crates/vite_select/src/lib.rs b/crates/vite_select/src/lib.rs index fa68e656..419dd5d7 100644 --- a/crates/vite_select/src/lib.rs +++ b/crates/vite_select/src/lib.rs @@ -34,6 +34,17 @@ pub struct RenderState<'a> { pub selected_index: usize, } +/// Parameters for [`select_list`]. +pub struct SelectParams<'a> { + pub items: &'a [SelectItem], + /// Initial search query (pre-filled in interactive, used as filter in non-interactive). + pub query: Option<&'a str>, + /// Header line rendered above the list (e.g. an error message). + pub header: Option<&'a str>, + /// Max visible rows (interactive only). + pub page_size: usize, +} + /// Show a task selection list. /// /// In [`Mode::Interactive`], enters a terminal UI with fuzzy search and @@ -50,18 +61,24 @@ pub struct RenderState<'a> { /// Returns an error if terminal I/O fails. pub fn select_list( writer: &mut impl Write, - items: &[SelectItem], - query: Option<&str>, + params: &SelectParams<'_>, mode: Mode<'_>, - header: Option<&str>, - page_size: usize, + before_render: impl FnMut(&mut Vec, &str), after_render: impl FnMut(&RenderState<'_>), ) -> anyhow::Result<()> { match mode { - Mode::Interactive { selected_index } => { - interactive::run(items, query, selected_index, header, page_size, after_render) + Mode::Interactive { selected_index } => interactive::run( + params.items, + params.query, + selected_index, + params.header, + params.page_size, + before_render, + after_render, + ), + Mode::NonInteractive => { + non_interactive(writer, params.items, params.query, params.header, before_render) } - Mode::NonInteractive => non_interactive(writer, items, query, header), } } @@ -70,10 +87,12 @@ fn non_interactive( items: &[SelectItem], query: Option<&str>, header: Option<&str>, + mut before_render: impl FnMut(&mut Vec, &str), ) -> anyhow::Result<()> { let labels: Vec<&str> = items.iter().map(|item| item.label.as_str()).collect(); - let filtered: Vec = + let mut filtered: Vec = query.map_or_else(|| (0..items.len()).collect(), |q| fuzzy_match(q, &labels)); + before_render(&mut filtered, query.unwrap_or_default()); let len = filtered.len(); render_items( diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 1ff19cc9..7d8afb06 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -300,6 +300,7 @@ impl<'a> Session<'a> { ) -> anyhow::Result { let cwd = Arc::clone(&self.cwd); let task_graph = self.ensure_task_graph_loaded().await?; + let current_package_path = task_graph.get_package_path_from_cwd(&cwd).cloned(); let mut entries = task_graph.list_tasks(); entries.sort_unstable_by(|a, b| { a.task_display @@ -308,31 +309,19 @@ impl<'a> Session<'a> { .then_with(|| a.task_display.task_name.cmp(&b.task_display.task_name)) }); - // Find the most specific package containing the CWD (longest matching path) - let current_package_path = entries + // Build items: current package tasks use unqualified names (no '#'), + // other packages use qualified "package#task" names. + let select_items: Vec = entries .iter() - .map(|e| &e.task_display.package_path) - .filter(|p| cwd.as_path().starts_with(p.as_path())) - .max_by_key(|p| p.as_path().as_os_str().len()) - .cloned(); - - // Sort: current package tasks first, then others - let (current, others): (Vec<_>, Vec<_>) = entries - .iter() - .partition(|e| current_package_path.as_ref() == Some(&e.task_display.package_path)); - - // Build the items list: current package tasks first (unqualified name), - // then other packages (qualified with package#task). - let select_items: Vec = current - .iter() - .map(|entry| SelectItem { - label: entry.task_display.task_name.clone(), - description: entry.command.clone(), + .map(|entry| { + let label = + if current_package_path.as_ref() == Some(&entry.task_display.package_path) { + entry.task_display.task_name.clone() + } else { + vite_str::format!("{}", entry.task_display) + }; + SelectItem { label, description: entry.command.clone() } }) - .chain(others.iter().map(|entry| SelectItem { - label: vite_str::format!("{}", entry.task_display), - description: entry.command.clone(), - })) .collect(); // Build header: interactive says "not found.", non-interactive "did you mean:" suffix @@ -347,19 +336,30 @@ impl<'a> Session<'a> { // Build mode-dependent params and call select_list once let mut selected_index = if is_interactive { Some(0) } else { None }; let mut stdout = std::io::stdout(); - let mode = if let Some(selected_index) = selected_index.as_mut() { - vite_select::Mode::Interactive { selected_index } - } else { - vite_select::Mode::NonInteractive + let mode = + selected_index.as_mut().map_or(vite_select::Mode::NonInteractive, |selected_index| { + vite_select::Mode::Interactive { selected_index } + }); + + let params = vite_select::SelectParams { + items: &select_items, + query: not_found_name, + header: header.as_deref(), + page_size: 8, }; vite_select::select_list( &mut stdout, - &select_items, - not_found_name, + ¶ms, mode, - header.as_deref(), - 8, + |filtered, query| { + // When the query doesn't contain '#', move current-package tasks (those + // without '#' in their label) to the top. `sort_by_key` is a stable sort, + // so the fuzzy rating order is preserved within each group. + if !query.contains('#') { + filtered.sort_by_key(|&idx| select_items[idx].label.contains('#')); + } + }, |state| { use std::io::Write; let milestone_name = 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 0c01fe19..fc9c0672 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 @@ -77,6 +77,46 @@ steps = [ { "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "expect-milestone" = "task-select::0" },{ "write-key" = "enter" },] }, ] +# Non-interactive: list tasks from lib package (lib tasks first, unqualified) +[[e2e]] +name = "non-interactive list tasks from lib" +cwd = "packages/lib" +steps = [ + "echo '' | vp run", +] + +# Interactive: select from lib package (first item is lib's task) +[[e2e]] +name = "interactive select task from lib" +cwd = "packages/lib" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, +] + +# Interactive: search for a task that only exists in another package +[[e2e]] +name = "interactive search other package task" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "typec" }, { "expect-milestone" = "task-select:typec:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: '#' in query skips current-package reordering +[[e2e]] +name = "interactive search with hash skips reorder" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "lib#" }, { "expect-milestone" = "task-select:lib#:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: multiple current-package matches preserve fuzzy rating order +[[e2e]] +name = "interactive search preserves rating within package" +cwd = "packages/lib" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "t" }, { "expect-milestone" = "task-select:t:0" }, { "write-key" = "enter" }] }, +] + # Typo inside a task script should fail with an error, NOT show a list [[e2e]] name = "typo in task script fails without list" diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap new file mode 100644 index 00000000..6f17805e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap @@ -0,0 +1,37 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write: typec +@ expect-milestone: task-select:typec:0 +Search task (↑/↓ to move, enter to select): typec +> lib#typecheck: echo typecheck lib +@ write-key: enter +~/packages/lib$ echo typecheck lib ⊘ cache disabled: built-in command +typecheck lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#typecheck: ~/packages/lib$ echo typecheck lib ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap new file mode 100644 index 00000000..239d1924 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap @@ -0,0 +1,45 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + task-select-test#check: echo check root + (…4 more) +@ write: t +@ expect-milestone: task-select:t:0 +Search task (↑/↓ to move, enter to select): t +> test: echo test lib + typecheck: echo typecheck lib + lint: echo lint lib + task-select-test#check: echo check root + task-select-test#format: echo format root + task-select-test#hello: echo hello from root + task-select-test#run-typo-task: vp run nonexistent-xyz + task-select-test#validate: echo validate root + (…2 more) +@ write-key: enter +~/packages/lib$ echo test lib ⊘ cache disabled: built-in command +test lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#test: ~/packages/lib$ echo test lib ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap new file mode 100644 index 00000000..f19fd1ac --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap @@ -0,0 +1,40 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + (…4 more) +@ write: lib# +@ expect-milestone: task-select:lib#:0 +Search task (↑/↓ to move, enter to select): lib# +> lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib +@ write-key: enter +~/packages/lib$ echo build lib ⊘ cache disabled: built-in command +build lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#build: ~/packages/lib$ echo build lib ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap new file mode 100644 index 00000000..478724e1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap @@ -0,0 +1,33 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + task-select-test#check: echo check root + (…4 more) +@ write-key: enter +~/packages/lib$ echo build lib ⊘ cache disabled: built-in command +build lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#build: ~/packages/lib$ echo build lib ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap new file mode 100644 index 00000000..51441e76 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> echo '' | vp run + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + task-select-test#check: echo check root + task-select-test#format: echo format root + task-select-test#hello: echo hello from root + task-select-test#run-typo-task: vp run nonexistent-xyz + task-select-test#validate: echo validate root diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index f578b73a..1c8698fe 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -506,4 +506,12 @@ impl IndexedTaskGraph { pub fn get_package_path_for_task(&self, task_index: TaskNodeIndex) -> &Arc { &self.task_graph[task_index].task_display.package_path } + + /// Get the package path for a given current working directory by traversing up the directory + /// tree to find the nearest package. + #[must_use] + pub fn get_package_path_from_cwd(&self, cwd: &AbsolutePath) -> Option<&Arc> { + let index = self.indexed_package_graph.get_package_index_from_cwd(cwd)?; + Some(self.get_package_path(index)) + } } From 0fb47d2a60d99ad7328697b95e9ea5b7de068392 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 10:53:42 +0800 Subject: [PATCH 09/11] refactor: detect empty plan instead of erroring in query_tasks when no tasks match --- crates/vite_task/src/session/mod.rs | 13 ++++++++++++- crates/vite_task_graph/src/query/mod.rs | 16 ---------------- crates/vite_task_plan/src/error.rs | 3 --- crates/vite_task_plan/src/lib.rs | 9 +++++++++ 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 7d8afb06..6ac4096b 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -232,11 +232,22 @@ impl<'a> Session<'a> { let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); - // Copy flags before consuming run_command + // Save task name and flags before consuming run_command + let task_name = run_command.task_specifier.as_ref().map(|s| s.task_name.clone()); let flags = run_command.flags; let additional_args = run_command.additional_args.clone(); match self.plan_from_cli(cwd, run_command).await { + Ok(plan) if plan.is_empty() => { + // No tasks matched the query — show task selector / "did you mean" + self.handle_no_task( + is_interactive, + task_name.as_deref(), + flags, + additional_args, + ) + .await + } Ok(plan) => { let reporter = LabeledReporter::new(std::io::stdout(), self.workspace_path()); diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index d52b2e3d..c96dddd4 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -61,9 +61,6 @@ pub enum TaskQueryError { #[source] lookup_error: SpecifierLookupError, }, - - #[error("No packages have a task named '{task_name}'")] - RecursiveTaskNotFound { task_name: Str }, } impl IndexedTaskGraph { @@ -138,24 +135,11 @@ impl IndexedTaskGraph { } TaskQueryKind::Recursive { task_names } => { // Add all tasks matching the names across all packages - let mut matched_names = FxHashSet::<&str>::with_capacity_and_hasher( - task_names.len(), - rustc_hash::FxBuildHasher, - ); for task_index in self.task_graph.node_indices() { let current_task_name = self.task_graph[task_index].task_display.task_name.as_str(); if task_names.contains(current_task_name) { execution_graph.add_node(task_index); - matched_names.insert(current_task_name); - } - } - // Return an error if any requested task name was not found in any package - for task_name in &task_names { - if !matched_names.contains(task_name.as_str()) { - return Err(TaskQueryError::RecursiveTaskNotFound { - task_name: task_name.clone(), - }); } } } diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 36df150d..e29faceb 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -185,9 +185,6 @@ impl Error { TaskPlanErrorKind::TaskQueryError( vite_task_graph::query::TaskQueryError::SpecifierLookupError { specifier, .. }, ) => Some(specifier.task_name.as_str()), - TaskPlanErrorKind::TaskQueryError( - vite_task_graph::query::TaskQueryError::RecursiveTaskNotFound { task_name }, - ) => Some(task_name.as_str()), _ => None, } } diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 8a383f7d..b1221cc7 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -192,6 +192,15 @@ impl ExecutionPlan { &self.root_node } + /// Returns `true` if the plan contains no tasks to execute. + #[must_use] + pub fn is_empty(&self) -> bool { + match &self.root_node { + ExecutionItemKind::Expanded(graph) => graph.node_count() == 0, + ExecutionItemKind::Leaf(_) => false, + } + } + /// Plan an execution from a plan request. /// /// # Errors From e2dfe1a9dbb006c32dc8b3bdf625409d8b3e09a5 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 11:12:10 +0800 Subject: [PATCH 10/11] feat: truncate long descriptions in interactive task select to terminal width Prevents line wrapping that breaks cursor-based clearing when navigating the interactive task list. Descriptions exceeding terminal width are truncated with an ellipsis. Non-interactive (piped) output is unaffected. Moves the long-cmd test into a dedicated task-select-truncate fixture so it does not appear in every task-select snapshot. --- CLAUDE.md | 2 +- crates/vite_select/src/interactive.rs | 145 +++++++++++++++++- crates/vite_select/src/lib.rs | 1 + .../task-select-truncate/package.json | 4 + .../packages/app/package.json | 4 + .../packages/app/vite-task.json | 16 ++ .../task-select-truncate/pnpm-workspace.yaml | 2 + .../task-select-truncate/snapshots.toml | 7 + .../interactive long command truncated.snap | 56 +++++++ .../task-select-truncate/vite-task.json | 3 + 10 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/vite-task.json diff --git a/CLAUDE.md b/CLAUDE.md index 086b3e59..993281ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,8 +48,8 @@ Test fixtures and snapshots: - If a feature can't work on a platform, it shouldn't be added 2. **Windows Cross-Testing from macOS**: + `cargo xtest` cross-compiles the test binary and runs it on a real remote Windows environment (not emulation). The filesystem is bridged so the test can access local fixture files. ```bash - # Test on Windows (aarch64) from macOS via cross-compilation cargo xtest --builder cargo-xwin --target aarch64-pc-windows-msvc -p --test # Examples: diff --git a/crates/vite_select/src/interactive.rs b/crates/vite_select/src/interactive.rs index 09b92968..b7a78e7f 100644 --- a/crates/vite_select/src/interactive.rs +++ b/crates/vite_select/src/interactive.rs @@ -113,6 +113,10 @@ pub struct RenderParams<'a> { pub query: Option<&'a str>, /// `"\r\n"` for raw mode, `"\n"` for normal. pub line_ending: &'a str, + /// Maximum visible width per line. Descriptions are truncated to prevent + /// line wrapping, which would break cursor-based clearing in interactive mode. + /// Use `usize::MAX` to disable truncation (non-interactive / piped output). + pub max_line_width: usize, } /// Render the item list. Shared rendering logic used by both interactive @@ -129,6 +133,7 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho header, query, line_ending, + max_line_width: _, } = params; let mut lines = 0usize; @@ -164,8 +169,24 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho let item_idx = filtered[vi]; let item = &items[item_idx]; let is_selected = *selected_in_filtered == Some(vi); + + // Truncate description to prevent line wrapping. + // Line layout: prefix (2: "> " or " ") + label + ": " (2) + description + let prefix_and_label_width = 2 + item.label.chars().count() + 2; + let max_desc_chars = params.max_line_width.saturating_sub(prefix_and_label_width); let desc_str = item.description.as_str(); - let desc = desc_str.if_supports_color(Stream::Stdout, |s| s.cyan()); + let desc_char_count = desc_str.chars().count(); + let truncated; + let display_desc = if desc_char_count > max_desc_chars { + let take = max_desc_chars.saturating_sub(1); // room for "…" + #[expect(clippy::disallowed_types, reason = "intermediate collect for char truncation")] + let prefix: std::string::String = desc_str.chars().take(take).collect(); + truncated = vite_str::format!("{prefix}\u{2026}"); + truncated.as_str() + } else { + desc_str + }; + let desc = display_desc.if_supports_color(Stream::Stdout, |s| s.cyan()); if is_selected { write!( @@ -214,6 +235,9 @@ fn render( )?; } + // Query terminal width on each render to handle resize + let max_line_width = terminal::size().map_or(80, |(w, _)| w as usize); + let lines = render_items( stdout, &RenderParams { @@ -225,6 +249,7 @@ fn render( header, query: Some(&state.query), line_ending: "\r\n", + max_line_width, }, )?; @@ -320,3 +345,121 @@ fn cleanup(stdout: &mut impl Write, state: &State<'_>) -> anyhow::Result<()> { stdout.flush()?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_items(items: &[(&str, &str)]) -> Vec { + items + .iter() + .map(|(label, desc)| SelectItem { label: (*label).into(), description: (*desc).into() }) + .collect() + } + + /// Strip ANSI escape sequences from output for easier assertions. + #[expect(clippy::disallowed_types, reason = "test helper building arbitrary output string")] + fn strip_ansi(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + // Skip until we hit a letter (end of escape sequence) + for c in chars.by_ref() { + if c.is_ascii_alphabetic() { + break; + } + } + } else { + result.push(c); + } + } + result + } + + #[expect(clippy::disallowed_types, reason = "test helper building arbitrary output string")] + fn render_to_string(items: &[SelectItem], max_line_width: usize) -> String { + let filtered: Vec = (0..items.len()).collect(); + let len = filtered.len(); + let mut buf = Vec::new(); + render_items( + &mut buf, + &RenderParams { + items, + filtered: &filtered, + selected_in_filtered: Some(0), + visible_range: 0..len, + hidden_count: 0, + header: None, + query: None, + line_ending: "\n", + max_line_width, + }, + ) + .unwrap(); + strip_ansi(&String::from_utf8(buf).unwrap()) + } + + #[test] + fn truncates_long_description() { + let items = make_items(&[("build", "a]really long command that exceeds the width limit")]); + // " build: a really long..." = 2 + 5 + 2 + desc + // max_line_width = 30 => max_desc = 30 - 9 = 21 chars + let output = render_to_string(&items, 30); + let line = output.lines().next().unwrap(); + // "> " (2) + "build" (5) + ": " (2) + desc (21) = 30 + assert!( + line.chars().count() <= 30, + "line should be at most 30 chars, got {}: {line:?}", + line.chars().count() + ); + assert!(line.contains('\u{2026}'), "truncated line should contain ellipsis: {line:?}"); + } + + #[test] + fn does_not_truncate_short_description() { + let items = make_items(&[("build", "echo ok")]); + let output = render_to_string(&items, 80); + let line = output.lines().next().unwrap(); + assert!(!line.contains('\u{2026}'), "short line should not be truncated: {line:?}"); + assert!(line.contains("echo ok"), "full description should appear: {line:?}"); + } + + #[test] + fn max_line_width_max_disables_truncation() { + let long_desc = "x".repeat(500); + let items = make_items(&[("build", &long_desc)]); + let output = render_to_string(&items, usize::MAX); + let line = output.lines().next().unwrap(); + assert!(!line.contains('\u{2026}'), "usize::MAX should disable truncation: {line:?}"); + assert!(line.contains(&long_desc), "full 500-char description should appear"); + } + + #[test] + fn each_line_fits_within_max_width() { + let items = make_items(&[ + ("build", "tsc -p tsconfig.build.json && echo done"), + ("lint", "oxlint --fix"), + ("test", "vitest run --reporter=verbose --coverage"), + ]); + let max_width = 40; + let output = render_to_string(&items, max_width); + for line in output.lines() { + assert!( + line.chars().count() <= max_width, + "line exceeds max width {max_width}: ({}) {line:?}", + line.chars().count() + ); + } + } + + #[test] + fn truncation_preserves_label() { + let items = make_items(&[("my-task", "very long description here")]); + // " my-task: very..." => prefix(2) + label(7) + sep(2) + desc + // max_line_width = 20 => max_desc = 20 - 11 = 9 chars + let output = render_to_string(&items, 20); + let line = output.lines().next().unwrap(); + assert!(line.contains("my-task"), "label should always be preserved: {line:?}"); + } +} diff --git a/crates/vite_select/src/lib.rs b/crates/vite_select/src/lib.rs index 419dd5d7..a2bac06b 100644 --- a/crates/vite_select/src/lib.rs +++ b/crates/vite_select/src/lib.rs @@ -106,6 +106,7 @@ fn non_interactive( header, query: None, line_ending: "\n", + max_line_width: usize::MAX, }, )?; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/package.json new file mode 100644 index 00000000..33d52e30 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/package.json @@ -0,0 +1,4 @@ +{ + "name": "task-select-truncate-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/package.json new file mode 100644 index 00000000..0a2d4152 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "app", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/vite-task.json new file mode 100644 index 00000000..4eddc58c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/vite-task.json @@ -0,0 +1,16 @@ +{ + "tasks": { + "build": { + "command": "echo build app" + }, + "lint": { + "command": "echo lint app" + }, + "long-cmd": { + "command": "echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "test": { + "command": "echo test app" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots.toml new file mode 100644 index 00000000..751f2c1f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots.toml @@ -0,0 +1,7 @@ +# Interactive: long commands are truncated to terminal width (no line wrapping) +[[e2e]] +name = "interactive long command truncated" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::1" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::2" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::3" }, { "write-key" = "up" }, { "expect-milestone" = "task-select::2" }, { "write-key" = "enter" }] }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap new file mode 100644 index 00000000..c63b3ce7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap @@ -0,0 +1,56 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… + test: echo test app +@ write-key: down +@ expect-milestone: task-select::1 +Search task (↑/↓ to move, enter to select): + build: echo build app +> lint: echo lint app + long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… + test: echo test app +@ write-key: down +@ expect-milestone: task-select::2 +Search task (↑/↓ to move, enter to select): + build: echo build app + lint: echo lint app +> long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… + test: echo test app +@ write-key: down +@ expect-milestone: task-select::3 +Search task (↑/↓ to move, enter to select): + build: echo build app + lint: echo lint app + long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… +> test: echo test app +@ write-key: up +@ expect-milestone: task-select::2 +Search task (↑/↓ to move, enter to select): + build: echo build app + lint: echo lint app +> long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… + test: echo test app +@ write-key: enter +~/packages/app$ echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ⊘ cache disabled: built-in command +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#long-cmd: ~/packages/app$ echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/vite-task.json new file mode 100644 index 00000000..90faa728 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/vite-task.json @@ -0,0 +1,3 @@ +{ + "tasks": {} +} From 3a98682f976b6a63a954946d4d6a30c7c5864b78 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 13 Feb 2026 11:50:59 +0800 Subject: [PATCH 11/11] refactor: remove interact subcommand and increase task select page size to 12 Remove the vp interact subcommand and interactions-no-vp test fixture as they are no longer needed. Remove crossterm and pty_terminal_test_client dependencies from vite_task_bin that were only used by the interact command. Increase interactive task select page_size from 8 to 12. Add extra tasks to the task-select fixture to ensure paging behavior is still tested. --- Cargo.lock | 2 - crates/vite_task/src/session/mod.rs | 2 +- crates/vite_task_bin/Cargo.toml | 2 - crates/vite_task_bin/src/lib.rs | 2 - crates/vite_task_bin/src/main.rs | 159 +----------------- .../fixtures/interactions-no-vp/package.json | 4 - .../interactions-no-vp/snapshots.toml | 6 - .../snapshots/interactions without vp.snap | 77 --------- .../interactive escape clears query.snap | 12 +- .../interactive scroll long list.snap | 18 +- ...interactive search other package task.snap | 6 +- ...earch preserves rating within package.snap | 12 +- .../interactive search then select.snap | 6 +- ...active search with hash skips reorder.snap | 6 +- .../interactive select task from lib.snap | 6 +- .../snapshots/interactive select task.snap | 12 +- .../interactive select with recursive.snap | 6 +- .../non-interactive list tasks from lib.snap | 3 + .../snapshots/non-interactive list tasks.snap | 3 + .../fixtures/task-select/vite-task.json | 9 + 20 files changed, 87 insertions(+), 266 deletions(-) delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap diff --git a/Cargo.lock b/Cargo.lock index a250a0c2..cab5bbb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3906,13 +3906,11 @@ dependencies = [ "clap", "cow-utils", "cp_r", - "crossterm", "insta", "jsonc-parser", "pathdiff", "pty_terminal", "pty_terminal_test", - "pty_terminal_test_client", "regex", "rustc-hash", "serde", diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 6ac4096b..7975a100 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -356,7 +356,7 @@ impl<'a> Session<'a> { items: &select_items, query: not_found_name, header: header.as_deref(), - page_size: 8, + page_size: 12, }; vite_select::select_list( diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index 6163a3c4..f98cc7cc 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -14,9 +14,7 @@ path = "src/main.rs" anyhow = { workspace = true } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } -crossterm = { workspace = true } jsonc-parser = { workspace = true } -pty_terminal_test_client = { workspace = true } rustc-hash = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index 20a7ed91..392a6df4 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -84,7 +84,6 @@ pub enum Args { name: Str, value: Str, }, - Interact, #[command(flatten)] Task(Command), } @@ -131,7 +130,6 @@ impl vite_task::CommandHandler for CommandHandler { envs: Arc::new(envs), })) } - Args::Interact => Ok(HandledCommand::Verbatim), Args::Task(cli_command) => Ok(HandledCommand::ViteTaskCommand(cli_command)), } } diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index 9cd4f95f..4b9a5250 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -1,8 +1,4 @@ -use std::{ - io::{IsTerminal, Read, Write}, - process::ExitCode, - sync::Arc, -}; +use std::{process::ExitCode, sync::Arc}; use clap::Parser; use vite_str::Str; @@ -25,7 +21,6 @@ async fn run() -> anyhow::Result { let mut owned_callbacks = OwnedSessionCallbacks::default(); let session = Session::init(owned_callbacks.as_callbacks())?; match args { - Args::Interact => run_interact(), Args::Task(command) => { #[expect(clippy::large_futures, reason = "session.main produces a large future")] { @@ -67,155 +62,3 @@ async fn run() -> anyhow::Result { } } } - -fn write_line(stdout: &mut impl Write, line: &[u8]) -> anyhow::Result<()> { - stdout.write_all(line)?; - stdout.write_all(b"\r\n")?; - stdout.flush()?; - Ok(()) -} - -fn write_milestone(stdout: &mut impl Write, name: &str) -> anyhow::Result<()> { - stdout.write_all(&pty_terminal_test_client::encoded_milestone(name))?; - stdout.flush()?; - Ok(()) -} - -struct RawModeGuard { - enabled: bool, -} - -impl RawModeGuard { - fn new(enabled: bool) -> anyhow::Result { - if enabled { - crossterm::terminal::enable_raw_mode()?; - } - Ok(Self { enabled }) - } - - fn disable(&mut self) -> anyhow::Result<()> { - if self.enabled { - crossterm::terminal::disable_raw_mode()?; - self.enabled = false; - } - Ok(()) - } -} - -impl Drop for RawModeGuard { - fn drop(&mut self) { - if self.enabled { - let _ = crossterm::terminal::disable_raw_mode(); - } - } -} - -fn run_interact() -> anyhow::Result { - let stdin_is_tty = std::io::stdin().is_terminal(); - let enable_raw_mode = if cfg!(windows) { true } else { stdin_is_tty }; - let mut raw_mode = RawModeGuard::new(enable_raw_mode)?; - - let mut stdin = std::io::stdin(); - let mut stdout = std::io::stdout(); - let mut text_buffer = Vec::::new(); - let mut ansi_escape_pending = false; - let mut ansi_csi_pending = false; - let mut windows_extended_key_pending = false; - - write_line(&mut stdout, b"START")?; - write_milestone(&mut stdout, "ready")?; - - loop { - let mut byte = [0u8; 1]; - let read_count = stdin.read(&mut byte)?; - if read_count == 0 { - break; - } - - let byte = byte[0]; - if ansi_escape_pending { - ansi_escape_pending = false; - - if byte == b'[' || byte == b'O' { - ansi_csi_pending = true; - continue; - } - } - - if ansi_csi_pending { - ansi_csi_pending = false; - - if byte == b'A' { - write_milestone(&mut stdout, "after-up")?; - continue; - } - - if byte == b'B' { - write_milestone(&mut stdout, "after-down")?; - continue; - } - } - - if windows_extended_key_pending { - windows_extended_key_pending = false; - - if byte == 72 { - write_milestone(&mut stdout, "after-up")?; - continue; - } - - if byte == 80 { - write_milestone(&mut stdout, "after-down")?; - continue; - } - } - - if byte == 0x1b { - ansi_escape_pending = true; - continue; - } - - if byte == 0x00 || byte == 0xe0 { - windows_extended_key_pending = true; - continue; - } - - if byte == b'\r' { - if text_buffer.is_empty() { - write_line(&mut stdout, b"KEY:ENTER")?; - raw_mode.disable()?; - write_line(&mut stdout, b"DONE")?; - write_milestone(&mut stdout, "after-enter")?; - return Ok(ExitStatus::SUCCESS); - } - - stdout.write_all(b"LINE:")?; - stdout.write_all(&text_buffer)?; - stdout.write_all(b"\r\n")?; - stdout.flush()?; - text_buffer.clear(); - write_milestone(&mut stdout, "after-line")?; - continue; - } - - if byte == b'\n' { - if !text_buffer.is_empty() { - stdout.write_all(b"LINE:")?; - stdout.write_all(&text_buffer)?; - stdout.write_all(b"\r\n")?; - stdout.flush()?; - text_buffer.clear(); - write_milestone(&mut stdout, "after-line")?; - } - continue; - } - - text_buffer.push(byte); - stdout.write_all(b"CHAR:")?; - stdout.write_all(&[byte])?; - stdout.write_all(b"\r\n")?; - stdout.flush()?; - } - - Ok(ExitStatus::SUCCESS) -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json deleted file mode 100644 index cdef0840..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "interactions-no-vp", - "private": true -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml deleted file mode 100644 index 22fd775a..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml +++ /dev/null @@ -1,6 +0,0 @@ -[[e2e]] -name = "interactions without vp" -steps = [ - { command = "vp interact", interactions = [{ "expect-milestone" = "ready" }, { "write" = "hello" }, { "write-line" = "hello" }, { "expect-milestone" = "after-line" }, { "write-key" = "up" }, { "write-key" = "down" }, { "write" = "x" }, { "write-key" = "enter" }, { "expect-milestone" = "after-line" }, { "write-key" = "enter" }, { "expect-milestone" = "after-enter" }] }, - "echo -n | node -e \"console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))\"", -] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap deleted file mode 100644 index 91a74e97..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap +++ /dev/null @@ -1,77 +0,0 @@ ---- -source: crates/vite_task_bin/tests/e2e_snapshots/main.rs -expression: e2e_outputs ---- -> vp interact -@ expect-milestone: ready -START -@ write: hello -@ write-line: hello -@ expect-milestone: after-line -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -@ write-key: up -@ write-key: down -@ write: x -@ write-key: enter -@ expect-milestone: after-line -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -CHAR:x -LINE:x -@ write-key: enter -@ expect-milestone: after-enter -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -CHAR:x -LINE:x -KEY:ENTER -DONE -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -CHAR:x -LINE:x -KEY:ENTER -DONE -> echo -n | node -e "console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))" -PIPE_STDIN_IS_TTY:false diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap index bada2db2..6d50d728 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write: lin @ expect-milestone: task-select:lin:0 Search task (↑/↓ to move, enter to select): lin @@ -30,7 +34,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write-key: enter ~/packages/app$ echo build app ⊘ cache disabled: built-in command build app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap index 8c004eca..fbd4a69a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write-key: down @ write-key: down @ write-key: down @@ -24,6 +28,7 @@ Search task (↑/↓ to move, enter to select): @ write-key: down @ expect-milestone: task-select::8 Search task (↑/↓ to move, enter to select): + build: echo build app lint: echo lint app test: echo test app lib#build: echo build lib @@ -31,7 +36,10 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root -> task-select-test#format: echo format root +> task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root (…3 more) @ write-key: up @ write-key: up @@ -51,7 +59,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write-key: enter ~/packages/app$ echo build app ⊘ cache disabled: built-in command build app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap index 6f17805e..50258c2d 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write: typec @ expect-milestone: task-select:typec:0 Search task (↑/↓ to move, enter to select): typec diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap index 239d1924..b844dac0 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): app#lint: echo lint app app#test: echo test app task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write: t @ expect-milestone: task-select:t:0 Search task (↑/↓ to move, enter to select): t @@ -21,11 +25,15 @@ Search task (↑/↓ to move, enter to select): t typecheck: echo typecheck lib lint: echo lint lib task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root task-select-test#format: echo format root task-select-test#hello: echo hello from root task-select-test#run-typo-task: vp run nonexistent-xyz task-select-test#validate: echo validate root - (…2 more) + app#test: echo test app + (…1 more) @ write-key: enter ~/packages/lib$ echo test lib ⊘ cache disabled: built-in command test lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap index dbba2b40..6e3ac272 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write: lin @ expect-milestone: task-select:lin:0 Search task (↑/↓ to move, enter to select): lin diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap index f19fd1ac..881383e8 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write: lib# @ expect-milestone: task-select:lib#:0 Search task (↑/↓ to move, enter to select): lib# diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap index 478724e1..b95f84e9 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): app#lint: echo lint app app#test: echo test app task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write-key: enter ~/packages/lib$ echo build lib ⊘ cache disabled: built-in command build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap index 41c5d91e..0d770bd0 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write-key: down @ expect-milestone: task-select::1 Search task (↑/↓ to move, enter to select): @@ -25,7 +29,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write-key: enter ~/packages/app$ echo lint app ⊘ cache disabled: built-in command lint app diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap index 8021e1b9..e9f81aef 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap @@ -13,7 +13,11 @@ Search task (↑/↓ to move, enter to select): lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root - (…4 more) + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) @ write-key: enter ~/packages/lib$ echo build lib ⊘ cache disabled: built-in command build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap index 51441e76..da25a4b6 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap @@ -11,6 +11,9 @@ expression: e2e_outputs app#lint: echo lint app app#test: echo test app task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root task-select-test#format: echo format root task-select-test#hello: echo hello from root task-select-test#run-typo-task: vp run nonexistent-xyz diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap index d0f308f5..7d6e097d 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap @@ -4,6 +4,9 @@ expression: e2e_outputs --- > echo '' | vp run check: echo check root + clean: echo clean root + deploy: echo deploy root + docs: echo docs root format: echo format root hello: echo hello from root run-typo-task: vp run nonexistent-xyz diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json index 54879f75..4ef32234 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json @@ -3,6 +3,15 @@ "check": { "command": "echo check root" }, + "clean": { + "command": "echo clean root" + }, + "deploy": { + "command": "echo deploy root" + }, + "docs": { + "command": "echo docs root" + }, "format": { "command": "echo format root" },