Skip to content

Commit 0b9f943

Browse files
committed
File explorer: Jump to file by typing
- Type letters/digits in a focused pane to jump the cursor to the best fuzzy match in the current directory. A bottom-right `Jump: …` chip shows the live buffer; ESC, navigation keys, rename, context menu, drag start, pane/tab switch, directory change, and re-sort all clear it. - Backend: new `find_first_fuzzy_match` Tauri command in `commands/file_system/listing.rs` backed by `nucleo-matcher`. Pure `fuzzy_jump::find_first_match` runs over the cached listing's visible-space sequence so the returned index lines up with `get_file_at` / `get_file_range`. - Frontend: per-pane reactive state factory `type-to-jump-state.svelte.ts` with asymmetric timers (1 s buffer reset → stale, 5 s indicator hide), generation-counter race protection, and a `dispose()` for clean tab-close teardown. `TypeToJumpIndicator.svelte` carries `role="status"` + `aria-live="polite"` + `prefers-reduced-motion` handling. - Keyboard intercept in `DualPaneExplorer.svelte` for letters/digits with no modifiers, falling through cleanly for shortcuts and reset keys. Skips when an input/textarea has focus or when a pane is in rename mode. - New `fileExplorer.typeToJump.resetDelay` setting under **Settings > Advanced** (300–3000 ms, default 1000), live-applied through the existing reactive-settings flow. - MCP `typeToJump` surface on `PaneState` ({ `buffer`, `indicatorVisible`, `indicatorStale`, `lastMatchedName` }) so MCP-driven E2E tests can drive and assert this feature without poking the DOM. - Tests: 11 Rust unit tests (incl. regression for visible-space indexing with hidden files before the match), 12 Svelte factory tests, 4 tier-3 a11y tests, 4 Playwright golden paths. - Docs: `Type-to-jump` subsection in `file-explorer/CLAUDE.md`, module bullet in `listing/CLAUDE.md`, command-list entry in `commands/CLAUDE.md`, MCP-surface note in `mcp/CLAUDE.md`, Advanced-setting note in `settings/CLAUDE.md`. - `FilePane.svelte` grew ~450 lines past its file-length allowlist entry. Per `.claude/rules/file-length-allowlist.md`, left as a warning to surface follow-up extraction.
1 parent 10e7e8b commit 0b9f943

35 files changed

Lines changed: 1821 additions & 20 deletions

Cargo.lock

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

apps/desktop/src-tauri/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ genai = "=0.6.0-beta.19"
126126
# v1 line is for Tauri 1, v2 RC line is the active branch for Tauri 2.
127127
tauri-specta = { version = "=2.0.0-rc.24", features = ["derive", "typescript"] }
128128
specta = { version = "=2.0.0-rc.24", features = ["derive", "serde_json"] }
129+
# Fuzzy matcher for the in-directory "type to jump" feature. Helix's matcher, also used
130+
# by Zellij and several TUI projects — fast (microseconds per match), smart-case, good
131+
# scoring. Pinned at 0.3.1 (published 2024-02-20, well over a month old). MPL-2.0;
132+
# already in the `deny.toml` allowlist. See `file_system/listing/fuzzy_jump.rs` for usage.
133+
nucleo-matcher = "0.3.1"
129134

130135
[target.'cfg(unix)'.dependencies]
131136
libc = "0.2"

apps/desktop/src-tauri/src/commands/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ immediately to business-logic modules. No significant logic lives here.
99
|------|--------|-------|
1010
| `mod.rs` | Re-exports | `mtp`, `network` gated behind `#[cfg(any(target_os = "macos", target_os = "linux"))]`; `volumes` behind `#[cfg(target_os = "macos")]`; `volumes_linux` behind `#[cfg(target_os = "linux")]` |
1111
| `util.rs` | Shared helpers | `TimedOut<T>`, `IpcError`, `blocking_with_timeout`, `blocking_with_timeout_flag`, `blocking_result_with_timeout`. See "Timeout-aware return types" below. |
12-
| `file_system/` | File listing & writes | Directory module split by operation type. `mod.rs` has `expand_tilde()`, re-exports, and tests. `listing.rs`: streaming + virtual-scroll listing API, path queries, benchmarking. `write_ops.rs`: create, copy, move, delete, trash, scan preview, conflict resolution, synthetic diff helpers. `volume_copy.rs`: cross-volume copy/move/scan, `SourceItemInput`. `drag.rs`: native drag, self-drag overlay. `e2e_support.rs`: feature-gated E2E/debug commands. |
12+
| `file_system/` | File listing & writes | Directory module split by operation type. `mod.rs` has `expand_tilde()`, re-exports, and tests. `listing.rs`: streaming + virtual-scroll listing API, path queries, `find_first_fuzzy_match` (type-to-jump), benchmarking. `write_ops.rs`: create, copy, move, delete, trash, scan preview, conflict resolution, synthetic diff helpers. `volume_copy.rs`: cross-volume copy/move/scan, `SourceItemInput`. `drag.rs`: native drag, self-drag overlay. `e2e_support.rs`: feature-gated E2E/debug commands. |
1313
| `volumes.rs` | Volume management (macOS) | `list_volumes`, `get_default_volume_id`, `get_volume_space`, `resolve_path_volume` (statfs-based, no volume enumeration) |
1414
| `volumes_linux.rs` | Volume management (Linux) | Same interface as `volumes.rs`, delegates to `volumes_linux` module |
1515
| `mtp.rs` | MTP devices | Full MTP command surface (connect, disconnect, list, download, upload, delete, rename, move, scan) |

apps/desktop/src-tauri/src/commands/file_system/listing.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ use crate::file_system::get_paths_at_indices as ops_get_paths_at_indices;
55
use crate::file_system::{
66
DirectorySortMode, FileEntry, ListingStartResult, ListingStats, ResortResult, SortColumn, SortOrder,
77
StreamingListingStartResult, cancel_listing as ops_cancel_listing, find_file_index as ops_find_file_index,
8-
find_file_indices as ops_find_file_indices, get_file_at as ops_get_file_at, get_file_range as ops_get_file_range,
9-
get_listing_stats as ops_get_listing_stats, get_max_filename_width as ops_get_max_filename_width,
10-
get_total_count as ops_get_total_count, get_volume_manager, list_directory_end as ops_list_directory_end,
11-
list_directory_start_streaming as ops_list_directory_start_streaming,
8+
find_file_indices as ops_find_file_indices,
9+
fuzzy_find_first_match_in_listing as ops_fuzzy_find_first_match_in_listing, get_file_at as ops_get_file_at,
10+
get_file_range as ops_get_file_range, get_listing_stats as ops_get_listing_stats,
11+
get_max_filename_width as ops_get_max_filename_width, get_total_count as ops_get_total_count, get_volume_manager,
12+
list_directory_end as ops_list_directory_end, list_directory_start_streaming as ops_list_directory_start_streaming,
1213
list_directory_start_with_volume as ops_list_directory_start_with_volume,
1314
refresh_listing_index_sizes as ops_refresh_listing_index_sizes, resort_listing as ops_resort_listing,
1415
};
@@ -239,6 +240,21 @@ pub fn find_file_indices(
239240
ops_find_file_indices(&listing_id, &names, include_hidden)
240241
}
241242

243+
/// Returns the backend index of the highest-scoring fuzzy match for `query` in
244+
/// the cached listing, or `None` when nothing matches. Powers the type-to-jump
245+
/// feature in `FilePane.svelte`. Hidden entries are skipped when `include_hidden`
246+
/// is false. The frontend adjusts for the synthetic `..` parent offset before
247+
/// setting the cursor (the parent entry is never in `LISTING_CACHE`).
248+
#[tauri::command]
249+
#[specta::specta]
250+
pub async fn find_first_fuzzy_match(
251+
listing_id: String,
252+
query: String,
253+
include_hidden: bool,
254+
) -> Result<Option<usize>, IpcError> {
255+
ops_fuzzy_find_first_match_in_listing(&listing_id, &query, include_hidden).map_err(IpcError::from_err)
256+
}
257+
242258
#[tauri::command]
243259
#[specta::specta]
244260
pub fn get_file_at(listing_id: String, index: usize, include_hidden: bool) -> Result<Option<FileEntry>, String> {

apps/desktop/src-tauri/src/file_system/listing/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Backend directory reading, caching, sorting, and streaming for the file explorer
1313
- **caching.rs**`LISTING_CACHE` global state, `CachedListing` struct, cache helpers for incremental updates
1414
- **sorting.rs**`SortColumn`, `SortOrder`, `sort_entries()`
1515
- **metadata.rs**`FileEntry` struct, macOS extended metadata. `FileEntry` has `physical_size: Option<u64>` (populated from `st_blocks * 512`) and `recursive_physical_size: Option<u64>` (populated from drive index)
16+
- **fuzzy_jump.rs**`find_first_match()` pure function powering the in-directory type-to-jump feature (Tauri command `find_first_fuzzy_match` in `commands/file_system/listing.rs`). Uses `nucleo-matcher` for smart-case fuzzy scoring; ties resolve to the lower index. The `..` parent entry is not in the cache (frontend prepends it), so no special-casing. Returns a **visible-space** index — counted over the same `visible_entries(...)` sequence as `get_file_at` / `get_file_range`, so the frontend can use the result as a cursor index directly (plus the `+1` parent-entry offset when `hasParent`). Logs each call to `target: "type_to_jump"`.
1617

1718
### Data flow
1819

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
//! Type-to-jump: highest-scoring fuzzy filename match within a cached listing.
2+
//!
3+
//! Powers the in-directory navigation feature where the user types a few characters
4+
//! in a focused file pane and the cursor jumps to the best-matching entry.
5+
//!
6+
//! ## Crate choice — why `nucleo-matcher`
7+
//!
8+
//! Picked `nucleo-matcher = "0.3.1"` (Helix editor's matcher, also used by Zellij).
9+
//! Pros: microsecond-scale per-match cost, smart-case behavior (lowercase query =
10+
//! case-insensitive, uppercase letter in the query opts into case-sensitive matching
11+
//! for that character), Unicode normalization, MIT-style scoring that prefers prefix /
12+
//! word-boundary matches. Pinned at 0.3.1 (published 2024-02-20, comfortably older
13+
//! than the 1-month minimum). License is MPL-2.0, which is allowed by `deny.toml`.
14+
//! The crate is small (~3 kLOC) and has no async runtime / heavy transitive deps.
15+
//!
16+
//! `sublime_fuzzy` (MIT) was the documented fallback if `nucleo-matcher` failed
17+
//! license / `cargo deny` review. That didn't happen, so we shipped nucleo-matcher.
18+
//!
19+
//! ## Why a separate module
20+
//!
21+
//! `find_first_match` is a pure function over `&[FileEntry]` — no `LISTING_CACHE`
22+
//! lock, no `tokio`. That makes it trivial to unit-test against in-memory fixtures
23+
//! and keeps the Tauri command layer (`commands/file_system/listing.rs`) a thin
24+
//! pass-through that just grabs the read lock and delegates here.
25+
26+
use std::time::Instant;
27+
28+
use nucleo_matcher::{
29+
Config, Matcher, Utf32Str,
30+
pattern::{CaseMatching, Normalization, Pattern},
31+
};
32+
33+
use crate::file_system::listing::caching::LISTING_CACHE;
34+
use crate::file_system::listing::metadata::FileEntry;
35+
36+
/// Returns the **visible-space** index of the highest-scoring fuzzy match for `query`,
37+
/// or `None` if no entry matches.
38+
///
39+
/// Rules:
40+
/// - When `include_hidden` is `false`, dotfiles (`name.starts_with('.')`) are skipped.
41+
/// - The match runs against the whole filename (including extension) — fuzzy scoring
42+
/// already rewards prefix and word-boundary matches, so we don't split on the dot.
43+
/// - Smart-case: an all-lowercase query matches case-insensitively; any uppercase
44+
/// character makes that character case-sensitive (delegated to nucleo-matcher).
45+
/// - Ties (equal score) resolve to the lower index, which matches the listing's
46+
/// active sort order.
47+
/// - Empty query → `None`. Empty listing → `None`.
48+
/// - The synthetic `..` parent entry is **not** in `LISTING_CACHE` (it's prepended
49+
/// by the frontend), so there's no special case for it here.
50+
///
51+
/// ## Index space
52+
///
53+
/// The returned index counts entries in the **visible** sequence — the same
54+
/// sequence `operations::get_file_at` / `get_file_range` produce when called
55+
/// with the same `include_hidden` flag. When `include_hidden` is `false` and
56+
/// the entries vec contains hidden files before the match, the returned index
57+
/// will be **smaller** than the absolute vec position. The frontend uses this
58+
/// directly as a cursor index (plus the `+1` parent-entry offset when
59+
/// `hasParent`), so the indexing space must line up with `getFileAt` /
60+
/// `getFileRange`.
61+
pub fn find_first_match(entries: &[FileEntry], query: &str, include_hidden: bool) -> Option<usize> {
62+
if query.is_empty() || entries.is_empty() {
63+
return None;
64+
}
65+
66+
let mut matcher = Matcher::new(Config::DEFAULT);
67+
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
68+
69+
let mut best: Option<(usize, u32)> = None;
70+
let mut haystack_buf: Vec<char> = Vec::new();
71+
72+
// Iterate the visible sequence directly so the returned index matches the
73+
// cursor space used by `getFileAt` / `getFileRange` (which iterate via
74+
// `visible_entries(...).nth(index)` in `operations.rs`).
75+
let visible = entries.iter().filter(|e| include_hidden || !e.name.starts_with('.'));
76+
77+
for (visible_idx, entry) in visible.enumerate() {
78+
let haystack = Utf32Str::new(&entry.name, &mut haystack_buf);
79+
let Some(score) = pattern.score(haystack, &mut matcher) else {
80+
continue;
81+
};
82+
83+
// Strictly greater so ties resolve to the lower index (the first match wins).
84+
match best {
85+
Some((_, best_score)) if score <= best_score => {}
86+
_ => best = Some((visible_idx, score)),
87+
}
88+
}
89+
90+
best.map(|(idx, _)| idx)
91+
}
92+
93+
/// Convenience wrapper that grabs the `LISTING_CACHE` read lock, runs
94+
/// `find_first_match`, and emits a single `type_to_jump` debug log line with
95+
/// the per-call timing. The Tauri command in `commands::file_system::listing`
96+
/// is a thin async pass-through over this.
97+
pub fn fuzzy_find_first_match_in_listing(
98+
listing_id: &str,
99+
query: &str,
100+
include_hidden: bool,
101+
) -> Result<Option<usize>, String> {
102+
let started = Instant::now();
103+
let cache = LISTING_CACHE
104+
.read()
105+
.map_err(|_| "Failed to acquire cache lock".to_string())?;
106+
107+
let listing = cache
108+
.get(listing_id)
109+
.ok_or_else(|| format!("Listing not found: {}", listing_id))?;
110+
111+
let result = find_first_match(&listing.entries, query, include_hidden);
112+
let elapsed_us = started.elapsed().as_micros();
113+
log::debug!(
114+
target: "type_to_jump",
115+
"listing_id={} query_len={} include_hidden={} result_index={:?} elapsed_us={}",
116+
listing_id,
117+
query.chars().count(),
118+
include_hidden,
119+
result,
120+
elapsed_us,
121+
);
122+
Ok(result)
123+
}
124+
125+
#[cfg(test)]
126+
mod tests {
127+
use super::*;
128+
use crate::file_system::listing::metadata::FileEntry;
129+
130+
fn entry(name: &str) -> FileEntry {
131+
FileEntry::new(name.to_string(), format!("/{}", name), false, false)
132+
}
133+
134+
#[test]
135+
fn empty_listing_returns_none() {
136+
let entries: Vec<FileEntry> = Vec::new();
137+
assert_eq!(find_first_match(&entries, "abc", true), None);
138+
}
139+
140+
#[test]
141+
fn empty_query_returns_none() {
142+
let entries = vec![entry("README.md"), entry("AGENTS.md")];
143+
assert_eq!(find_first_match(&entries, "", true), None);
144+
}
145+
146+
#[test]
147+
fn no_matches_returns_none() {
148+
let entries = vec![entry("README.md"), entry("AGENTS.md")];
149+
// "xyz" shares no characters with either name.
150+
assert_eq!(find_first_match(&entries, "xyz", true), None);
151+
}
152+
153+
#[test]
154+
fn single_match_returns_its_index() {
155+
let entries = vec![entry("README.md"), entry("AGENTS.md"), entry("Cargo.toml")];
156+
// Only "Cargo.toml" contains the subsequence "crg" / "cargo".
157+
let idx = find_first_match(&entries, "cargo", true).expect("should match");
158+
assert_eq!(idx, 2);
159+
}
160+
161+
#[test]
162+
fn multiple_matches_pick_highest_scored() {
163+
// "tests" fuzzy-matches both — but "tests.js" is the better (prefix) match
164+
// than "my_tests_helper.rs" so it should win.
165+
let entries = vec![entry("my_tests_helper.rs"), entry("tests.js"), entry("other.txt")];
166+
let idx = find_first_match(&entries, "tests", true).expect("should match");
167+
assert_eq!(idx, 1, "prefix match 'tests.js' should outscore 'my_tests_helper.rs'");
168+
}
169+
170+
#[test]
171+
fn ties_resolve_to_lower_index() {
172+
// Two identical names → identical scores → lower index wins.
173+
let entries = vec![entry("hello.txt"), entry("hello.txt")];
174+
let idx = find_first_match(&entries, "hello", true).expect("should match");
175+
assert_eq!(idx, 0);
176+
}
177+
178+
#[test]
179+
fn hidden_entry_excluded_when_include_hidden_false() {
180+
let entries = vec![entry(".env"), entry("env_setup.sh")];
181+
// With hidden excluded, only "env_setup.sh" is a candidate. The dotfile
182+
// is invisible, so "env_setup.sh" sits at visible-index 0.
183+
let idx = find_first_match(&entries, "env", false).expect("should match");
184+
assert_eq!(idx, 0);
185+
}
186+
187+
#[test]
188+
fn hidden_entry_included_when_include_hidden_true() {
189+
// Deterministic case: two clearly distinct names. The only entry that can
190+
// match "alpha" is ".alpha.txt" — "zeta.bin" shares no characters with the
191+
// query. The match must be found AND must land at the dotfile's visible
192+
// index (0 when hidden is on, since the dotfile is then visible).
193+
let entries = vec![entry(".alpha.txt"), entry("zeta.bin")];
194+
let idx = find_first_match(&entries, "alpha", true).expect("should match");
195+
assert_eq!(
196+
idx, 0,
197+
"hidden '.alpha.txt' must be considered when include_hidden=true"
198+
);
199+
}
200+
201+
/// Regression test for the visible-space indexing contract.
202+
///
203+
/// Before this fix, `find_first_match` returned the absolute index into the
204+
/// `entries` vec. With a hidden file sitting before the match in the vec,
205+
/// the frontend (which uses the index in the visible sequence — same as
206+
/// `get_file_at` / `get_file_range`) landed one row too far down per
207+
/// skipped dotfile. This test exercises exactly that scenario.
208+
#[test]
209+
fn returns_visible_space_index_when_hidden_precedes_match() {
210+
// Vec layout: [hidden, hidden, target, other]
211+
// Absolute indices: 0 1 2 3
212+
// Visible indices: - - 0 1
213+
let entries = vec![
214+
entry(".hidden_a"),
215+
entry(".hidden_b"),
216+
entry("target.txt"),
217+
entry("other.bin"),
218+
];
219+
220+
let idx = find_first_match(&entries, "target", false).expect("should match");
221+
// The visible-space index of "target.txt" is 0, not the absolute 2.
222+
assert_eq!(
223+
idx, 0,
224+
"must return visible-space index (0), not absolute vec index (2), so the frontend cursor doesn't skip rows"
225+
);
226+
227+
// Sanity check: with include_hidden=true, the same match lands at
228+
// visible-index 2 because the two dotfiles are now visible too.
229+
let idx_with_hidden = find_first_match(&entries, "target", true).expect("should match");
230+
assert_eq!(idx_with_hidden, 2);
231+
}
232+
233+
#[test]
234+
fn case_insensitive_with_lowercase_query() {
235+
// Lowercase query → smart case → matches against UPPERCASE filename.
236+
let entries = vec![entry("README.md"), entry("TESTS.txt"), entry("other.bin")];
237+
let idx = find_first_match(&entries, "tes", true).expect("should match");
238+
assert_eq!(idx, 1);
239+
}
240+
241+
#[test]
242+
fn unicode_filename_is_matchable() {
243+
// Nucleo normalizes Unicode (Normalization::Smart). Typing the ASCII form
244+
// should still find the accented filename. We document the observed behavior
245+
// here rather than asserting a strict score — what matters is "some match
246+
// is found and it's the Résumé entry, not the unrelated one".
247+
let entries = vec![entry("notes.txt"), entry("Résumé.pdf"), entry("photo.jpg")];
248+
let idx = find_first_match(&entries, "resume", true).expect("should match");
249+
assert_eq!(idx, 1, "ASCII 'resume' should fold into 'Résumé.pdf'");
250+
}
251+
}

apps/desktop/src-tauri/src/file_system/listing/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Directory listing module - reading, operations, caching, metadata, sorting, streaming.
22
33
pub(crate) mod caching;
4+
pub(crate) mod fuzzy_jump;
45
pub(crate) mod metadata;
56
pub(crate) mod operations;
67
pub(crate) mod reading;
@@ -9,6 +10,7 @@ pub(crate) mod streaming;
910

1011
// Re-export types for backwards compatibility (they were originally defined in operations.rs)
1112
// These re-exports make the types available both externally and locally in this module
13+
pub use fuzzy_jump::fuzzy_find_first_match_in_listing;
1214
pub use metadata::{ExtendedMetadata, FileEntry};
1315
pub use operations::{
1416
ListingStartResult, ListingStats, ResortResult, find_file_index, find_file_indices, get_file_at, get_file_range,

apps/desktop/src-tauri/src/file_system/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ use std::sync::{Arc, LazyLock};
3030
pub use listing::ExtendedMetadata;
3131
pub use listing::{
3232
DirectorySortMode, FileEntry, ListingStartResult, ListingStats, ResortResult, SortColumn, SortOrder,
33-
StreamingListingStartResult, cancel_listing, find_file_index, find_file_indices, get_file_at, get_file_range,
34-
get_listing_stats, get_max_filename_width, get_total_count, list_directory_end, list_directory_start_streaming,
35-
list_directory_start_with_volume, refresh_listing_index_sizes, resort_listing,
33+
StreamingListingStartResult, cancel_listing, find_file_index, find_file_indices, fuzzy_find_first_match_in_listing,
34+
get_file_at, get_file_range, get_listing_stats, get_max_filename_width, get_total_count, list_directory_end,
35+
list_directory_start_streaming, list_directory_start_with_volume, refresh_listing_index_sizes, resort_listing,
3636
};
3737
// Batch accessors (used by drag, clipboard, and transfer dialogs)
3838
pub use listing::{get_files_at_indices, get_paths_at_indices};

0 commit comments

Comments
 (0)