Skip to content

Commit 0cc4755

Browse files
authored
feat: task list in vp run (#148)
# Interactive task selector for `vp run` https://github.com/user-attachments/assets/b70fe000-b890-4d04-9786-c76455b28780 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 stdin). ## Interactive mode (TTY) - Fuzzy-searchable list powered by `nucleo-matcher` - Arrow keys to navigate, Enter to select, Esc to cancel - Scrollable when the list exceeds the viewport (page size = 8) - Typo pre-filled as initial search query with "Task not found" header - Selected task runs with original flags (`-r`, `-t`, extra args) preserved - Command shown in cyan, matching the execution summary style ## Non-interactive mode (piped stdin) - Plain text list: `taskName: command` - "Did you mean" suggestions for typos (fuzzy-matched) - Exits with failure status for typo case ## Key implementation details - **`vite_select` crate** — standalone UI widget with crossterm rendering, fuzzy matching, `after_render` callback for test milestones - **`RunFlags`** — extracted as `Copy` struct with `#[clap(flatten)]` so flags survive `RunCommand` consumption - **`RecursiveTaskNotFound`** error variant — recursive queries (`-r`) now error on unmatched task names instead of silently returning an empty graph - **Nested task safety** — `task_not_found_name()` returns `None` when the call stack is non-empty, so errors from nested tasks (e.g. `vp run nonexistent` inside a task script) propagate as-is instead of triggering the selector ## E2E tests 12 test cases across `task-select` and `task-list` fixtures covering: - Non-interactive list, did-you-mean, recursive did-you-mean - Interactive select, search-then-select, typo pre-fill, cancel - Scroll through long list (12 tasks, page size 8) - Flag preservation (`-r`, `-t`) - Nested task error propagation (typo in task script fails without showing selector)
1 parent 7311b74 commit 0cc4755

File tree

65 files changed

+2044
-288
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+2044
-288
lines changed

.typos.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@ PUNICODE = "PUNICODE"
66
extend-exclude = [
77
"crates/fspy_detours_sys/detours",
88
"crates/fspy_detours_sys/src/generated_bindings.rs",
9+
# Intentional typos for testing fuzzy matching and "did you mean" suggestions
10+
"crates/vite_select/src/fuzzy.rs",
11+
"crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select",
912
]

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ Test fixtures and snapshots:
4848
- If a feature can't work on a platform, it shouldn't be added
4949

5050
2. **Windows Cross-Testing from macOS**:
51+
`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.
5152
```bash
52-
# Test on Windows (aarch64) from macOS via cross-compilation
5353
cargo xtest --builder cargo-xwin --target aarch64-pc-windows-msvc -p <package> --test <test>
5454

5555
# Examples:

Cargo.lock

Lines changed: 56 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,11 @@ memmap2 = "0.9.7"
8585
monostate = "1.0.2"
8686
nix = { version = "0.30.1", features = ["dir"] }
8787
ntapi = "0.4.1"
88+
nucleo-matcher = "0.3.1"
8889
once_cell = "1.19"
8990
os_str_bytes = "7.1.1"
9091
ouroboros = "0.18.5"
91-
owo-colors = "4.1.0"
92+
owo-colors = { version = "4.1.0", features = ["supports-colors"] }
9293
passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false }
9394
pathdiff = "0.2.3"
9495
petgraph = "0.8.2"
@@ -135,6 +136,7 @@ vec1 = "1.12.1"
135136
vite_glob = { path = "crates/vite_glob" }
136137
vite_graph_ser = { path = "crates/vite_graph_ser" }
137138
vite_path = { path = "crates/vite_path" }
139+
vite_select = { path = "crates/vite_select" }
138140
vite_shell = { path = "crates/vite_shell" }
139141
vite_str = { path = "crates/vite_str" }
140142
vite_task = { path = "crates/vite_task" }

crates/vite_select/Cargo.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "vite_select"
3+
version = "0.0.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
publish = false
8+
rust-version.workspace = true
9+
10+
[lints]
11+
workspace = true
12+
13+
[dependencies]
14+
anyhow = { workspace = true }
15+
crossterm = { workspace = true }
16+
nucleo-matcher = { workspace = true }
17+
owo-colors = { workspace = true }
18+
vite_str = { path = "../vite_str" }
19+
20+
[dev-dependencies]
21+
assert2 = { workspace = true }

crates/vite_select/src/fuzzy.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
use nucleo_matcher::{
2+
Matcher,
3+
pattern::{AtomKind, CaseMatching, Normalization, Pattern},
4+
};
5+
6+
/// Fuzzy-match `query` against a list of strings.
7+
///
8+
/// Returns original indices sorted by score descending (best match first).
9+
/// When `query` is empty, returns all indices in their original order.
10+
#[must_use]
11+
pub fn fuzzy_match(query: &str, items: &[&str]) -> Vec<usize> {
12+
if query.is_empty() {
13+
return (0..items.len()).collect();
14+
}
15+
16+
let pattern = Pattern::new(query, CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy);
17+
let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT);
18+
19+
let mut scored: Vec<(usize, u32)> = items
20+
.iter()
21+
.enumerate()
22+
.filter_map(|(idx, item)| {
23+
pattern
24+
.score(nucleo_matcher::Utf32Str::Ascii(item.as_bytes()), &mut matcher)
25+
.map(|score| (idx, score))
26+
})
27+
.collect();
28+
29+
scored.sort_by(|a, b| b.1.cmp(&a.1));
30+
scored.into_iter().map(|(idx, _)| idx).collect()
31+
}
32+
33+
#[cfg(test)]
34+
mod tests {
35+
use assert2::{assert, check};
36+
37+
use super::*;
38+
39+
const TASK_NAMES: &[&str] =
40+
&["build", "lint", "test", "app#build", "app#lint", "app#test", "lib#build"];
41+
42+
#[test]
43+
fn exact_match_scores_highest() {
44+
let results = fuzzy_match("build", TASK_NAMES);
45+
assert!(!results.is_empty());
46+
// "build" should be the highest-scoring match
47+
check!(TASK_NAMES[results[0]] == "build");
48+
}
49+
50+
#[test]
51+
fn typo_matches_similar() {
52+
let results = fuzzy_match("buid", TASK_NAMES);
53+
assert!(!results.is_empty());
54+
// Should match "build" and "app#build" and "lib#build" but not "lint" or "test"
55+
let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect();
56+
check!(matched_names.contains(&"build"));
57+
for name in &matched_names {
58+
check!(!name.contains("lint"));
59+
check!(!name.contains("test"));
60+
}
61+
}
62+
63+
#[test]
64+
fn empty_query_returns_all() {
65+
let results = fuzzy_match("", TASK_NAMES);
66+
check!(results.len() == TASK_NAMES.len());
67+
// Indices should be in original order
68+
for (pos, &idx) in results.iter().enumerate() {
69+
check!(idx == pos);
70+
}
71+
}
72+
73+
#[test]
74+
fn completely_unrelated_query_returns_nothing() {
75+
let results = fuzzy_match("zzzzz", TASK_NAMES);
76+
check!(results.is_empty());
77+
}
78+
79+
#[test]
80+
fn package_qualified_match() {
81+
let results = fuzzy_match("app#build", TASK_NAMES);
82+
assert!(!results.is_empty());
83+
check!(TASK_NAMES[results[0]] == "app#build");
84+
}
85+
86+
#[test]
87+
fn lint_matches_lint_tasks() {
88+
let results = fuzzy_match("lint", TASK_NAMES);
89+
assert!(!results.is_empty());
90+
let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect();
91+
check!(matched_names.contains(&"lint"));
92+
check!(matched_names.contains(&"app#lint"));
93+
}
94+
95+
#[test]
96+
fn score_ordering_exact_before_fuzzy() {
97+
let results = fuzzy_match("build", TASK_NAMES);
98+
assert!(results.len() >= 2);
99+
// Exact "build" should appear before "app#build" (higher score = earlier position)
100+
let build_pos = results.iter().position(|&i| TASK_NAMES[i] == "build").unwrap();
101+
let app_build_pos = results.iter().position(|&i| TASK_NAMES[i] == "app#build").unwrap();
102+
check!(build_pos <= app_build_pos);
103+
}
104+
}

0 commit comments

Comments
 (0)