fix(tui): find files in deeply nested directories via @ and Ctrl+P (#2488)

`ignore`'s `max_depth(Some(6))` excludes files inside a 6-level-deep
directory (they sit at component depth 7), so @-mention completion and the
Ctrl+P picker silently missed them. Raise the default walk depth to 10
(covers conventionally nested Java/.NET/web trees) and make the Ctrl+P
picker honor the configurable `mention_walk_depth` — including `0` for an
unlimited walk — so it matches @-mention behavior and the existing
"set mention_walk_depth 0 to search deeper" guidance.

The walk stays bounded by `.gitignore` and `MAX_CANDIDATES`. Adds a
regression test covering depth-6 miss, default reach, and unlimited.

https://claude.ai/code/session_01MQrnh6wHfrEYN5BBdMarC1
This commit is contained in:
Claude
2026-06-03 01:08:42 +00:00
parent 5249723e18
commit 478bae451a
4 changed files with 86 additions and 19 deletions
+2 -2
View File
@@ -335,7 +335,7 @@ impl Default for Settings {
bracketed_paste: true,
paste_burst_detection: true,
mention_menu_limit: 128,
mention_walk_depth: 6,
mention_walk_depth: 10,
mention_menu_behavior: "fuzzy".to_string(),
show_thinking: true,
show_tool_details: true,
@@ -1300,7 +1300,7 @@ mod tests {
fn mention_completion_caps_are_configurable() {
let mut settings = Settings::default();
assert_eq!(settings.mention_menu_limit, 128);
assert_eq!(settings.mention_walk_depth, 6);
assert_eq!(settings.mention_walk_depth, 10);
assert_eq!(settings.mention_menu_behavior, "fuzzy");
settings
+70 -10
View File
@@ -1,7 +1,8 @@
//! Fuzzy file-picker modal (Ctrl+P).
//!
//! Opens an overlay populated with workspace-relative paths discovered by a
//! single-pass `WalkBuilder` walk (depth 6, hidden=true, follow_links=false,
//! single-pass `WalkBuilder` walk (depth from `mention_walk_depth`, default
//! 10, `0` = unlimited; hidden=true, follow_links=false,
//! `.gitignore` honored). Subsequent keystrokes filter the cached candidate
//! list in memory using a small subsequence + first-letter-bonus scorer — no
//! per-keystroke disk traversal.
@@ -31,8 +32,10 @@ use crate::workspace_discovery::{DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_di
/// equivalent overlay.
const MAX_CANDIDATES: usize = 20_000;
/// Walk depth for the initial scan. Mirrors the `Workspace` fuzzy index.
const WALK_DEPTH: usize = 6;
/// Default walk depth for the initial scan when no explicit depth is supplied.
/// Mirrors the `Workspace` fuzzy index default (`DEFAULT_COMPLETIONS_WALK_DEPTH`).
/// `mention_walk_depth = 0` overrides this with an unlimited walk.
const WALK_DEPTH: usize = 10;
/// Visible candidate rows in the overlay.
const VISIBLE_ROWS: usize = 14;
@@ -121,9 +124,26 @@ pub struct FilePickerView {
}
impl FilePickerView {
/// Build a picker with working-set relevance hints.
/// Build a picker with working-set relevance hints, using the default
/// walk depth ([`WALK_DEPTH`]).
pub fn new_with_relevance(workspace_root: &Path, relevance: FilePickerRelevance) -> Self {
let candidates = collect_candidates(workspace_root);
Self::new_with_relevance_and_depth(workspace_root, relevance, WALK_DEPTH)
}
/// Build a picker with working-set relevance hints and an explicit walk
/// depth. A depth of `0` disables the depth limit so files in deeply
/// nested workspaces (>= 6 levels) remain discoverable (#2488).
pub fn new_with_relevance_and_depth(
workspace_root: &Path,
relevance: FilePickerRelevance,
walk_depth: usize,
) -> Self {
let max_depth = if walk_depth == 0 {
None
} else {
Some(walk_depth)
};
let candidates = collect_candidates(workspace_root, max_depth);
let mut view = Self {
candidates,
relevance,
@@ -406,13 +426,15 @@ fn truncate_path(path: &str, max: usize) -> String {
format!("{truncated}")
}
/// Single-pass walk that collects workspace-relative paths.
fn collect_candidates(root: &Path) -> Vec<String> {
/// Single-pass walk that collects workspace-relative paths. `max_depth` of
/// `None` walks the whole tree (still bounded by `MAX_CANDIDATES` and
/// `.gitignore`); `Some(n)` caps the recursion at `n` levels.
fn collect_candidates(root: &Path, max_depth: Option<usize>) -> Vec<String> {
let mut builder = WalkBuilder::new(root);
builder
.hidden(true)
.follow_links(false)
.max_depth(Some(WALK_DEPTH))
.max_depth(max_depth)
.git_ignore(true)
.git_exclude(true)
.git_global(true);
@@ -449,7 +471,7 @@ fn collect_candidates(root: &Path) -> Vec<String> {
.follow_links(false)
.git_ignore(false)
.ignore(false)
.max_depth(Some(WALK_DEPTH.saturating_sub(1)));
.max_depth(max_depth.map(|d| d.saturating_sub(1)));
for entry in dot_builder.build().flatten() {
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
if path_is_excluded_from_discovery(root, entry.path()) {
@@ -735,6 +757,44 @@ mod tests {
);
}
#[test]
fn picker_finds_deeply_nested_files_within_walk_depth() {
// #2488: a file inside a 6-level-deep directory sits at component depth
// 7 and was excluded by the old depth-6 cap. The default depth (10) now
// reaches it, and `0` (unlimited) reaches arbitrarily deep files.
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let nested = root.join("a/b/c/d/e/f");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("deep.rs"), "deep").unwrap();
let deeper = root.join("a/b/c/d/e/f/g/h/i/j/k");
fs::create_dir_all(&deeper).unwrap();
fs::write(deeper.join("very_deep.rs"), "deeper").unwrap();
// The old default (6) misses the depth-7 file — the reported bug.
let shallow = collect_candidates(root, Some(6));
assert!(
!shallow.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
"depth-6 cap should miss the depth-7 file: {shallow:?}"
);
// The new default reaches files inside a 6-level-deep directory.
let default = collect_candidates(root, Some(WALK_DEPTH));
assert!(
default.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
"default walk depth should reach depth-7 files: {default:?}"
);
// Unlimited (mention_walk_depth = 0) reaches arbitrarily deep files.
let unlimited = collect_candidates(root, None);
assert!(
unlimited
.iter()
.any(|p| p == "a/b/c/d/e/f/g/h/i/j/k/very_deep.rs"),
"unlimited walk should reach very deep files: {unlimited:?}"
);
}
#[test]
fn picker_skips_generated_worktree_bulk_inside_unignored_dot_dirs() {
let dir = TempDir::new().expect("tempdir");
@@ -760,7 +820,7 @@ mod tests {
)
.unwrap();
let candidates = collect_candidates(root);
let candidates = collect_candidates(root, Some(WALK_DEPTH));
assert!(candidates.iter().any(|path| path == "src/main.rs"));
assert!(
+9 -4
View File
@@ -28,10 +28,15 @@ use crate::tui::file_picker::FilePickerView;
/// per-session relevance ranks (modified, @-mentioned, tool-touched).
pub(super) fn open_file_picker(app: &mut App) {
let relevance = build_relevance(app);
app.view_stack.push(FilePickerView::new_with_relevance(
&app.workspace,
relevance,
));
// Honor the configured `mention_walk_depth` (0 = unlimited) so the picker
// and `@`-mention completion agree, and files in deeply nested trees stay
// discoverable (#2488).
app.view_stack
.push(FilePickerView::new_with_relevance_and_depth(
&app.workspace,
relevance,
app.mention_walk_depth,
));
}
pub(super) fn build_relevance(app: &App) -> FilePickerRelevance {
+5 -3
View File
@@ -362,9 +362,11 @@ fn browser_completion_dir_part(dir_part: &str) -> Option<PathBuf> {
}
/// Default directory depth walked when surfacing file-mention completions.
/// Mirrors the existing `project_tree` cutoff and keeps Tab snappy in deep
/// monorepos unless the user opts into a deeper walk.
pub const DEFAULT_COMPLETIONS_WALK_DEPTH: usize = 6;
/// Set high enough that conventionally nested source trees (Java/.NET/web
/// projects routinely reach 7-9 levels) stay reachable, while a `0` override
/// removes the limit entirely. Keeps Tab snappy in deep monorepos via the
/// `.gitignore`-aware walk and per-keypress candidate caps (#2488).
pub const DEFAULT_COMPLETIONS_WALK_DEPTH: usize = 10;
fn normalize_completion_walk_depth(depth: usize) -> Option<usize> {
if depth == 0 { None } else { Some(depth) }