From e23a4c0add539ee94d816eeba83ba5e2fa8af85b Mon Sep 17 00:00:00 2001 From: samhandsome Date: Sat, 9 May 2026 13:28:25 +0800 Subject: [PATCH 1/2] fix(tui): complete explicit hidden file mentions Allow @-mentions to complete and resolve explicitly addressed hidden or ignored paths without adding project-specific command discovery rules. --- crates/tui/src/working_set.rs | 171 ++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index 2688b0d3..ab67a348 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -138,6 +138,20 @@ impl Workspace { } } } + + // Beyond the curated dot-dir whitelist above, also index any explicit + // hidden/ignored path the user might `@`-mention (e.g. a project's + // own `.generated/specs/`). `local_reference_paths` walks with + // gitignore disabled but still honors `.deepseekignore`. + for path in local_reference_paths(&self.root, LOCAL_REFERENCE_SCAN_LIMIT) { + let Some(name) = path + .file_name() + .map(|name| name.to_string_lossy().to_lowercase()) + else { + continue; + }; + index.entry(name).or_default().push(path); + } index } @@ -183,6 +197,15 @@ impl Workspace { &mut substring_hits, &mut seen, ); + add_local_reference_completions( + cwd, + cwd, + &needle, + limit, + &mut prefix_hits, + &mut substring_hits, + &mut seen, + ); } walk_for_completions( &self.root, @@ -193,6 +216,15 @@ impl Workspace { &mut substring_hits, &mut seen, ); + add_local_reference_completions( + &self.root, + &self.root, + &needle, + limit, + &mut prefix_hits, + &mut substring_hits, + &mut seen, + ); prefix_hits.sort(); substring_hits.sort(); @@ -370,6 +402,101 @@ fn walk_for_completions( ); } +const LOCAL_REFERENCE_SCAN_LIMIT: usize = 4096; + +#[allow(clippy::too_many_arguments)] +fn add_local_reference_completions( + root: &Path, + display_root: &Path, + needle: &str, + limit: usize, + prefix_hits: &mut Vec, + substring_hits: &mut Vec, + seen: &mut HashSet, +) { + if !should_try_local_reference_completion(needle) { + return; + } + + for path in local_reference_paths(root, LOCAL_REFERENCE_SCAN_LIMIT) { + if prefix_hits.len() + substring_hits.len() >= limit { + break; + } + let Ok(rel) = path.strip_prefix(display_root) else { + continue; + }; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if rel_str.is_empty() || !seen.insert(path.clone()) { + continue; + } + let lower = rel_str.to_lowercase(); + if needle.is_empty() || lower.starts_with(needle) { + prefix_hits.push(rel_str); + } else if lower.contains(needle) { + substring_hits.push(rel_str); + } + } +} + +fn should_try_local_reference_completion(needle: &str) -> bool { + !needle.is_empty() && (needle.starts_with('.') || needle.contains('/') || needle.contains('\\')) +} + +fn local_reference_paths(root: &Path, limit: usize) -> Vec { + let mut out = Vec::new(); + collect_local_reference_paths(root, 0, limit, &mut out); + out +} + +fn collect_local_reference_paths(root: &Path, depth: usize, limit: usize, out: &mut Vec) { + if depth > COMPLETIONS_WALK_DEPTH || out.len() >= limit { + return; + } + + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(_) => return, + }; + + for entry in entries.flatten() { + if out.len() >= limit { + break; + } + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + if should_skip_local_reference_dir(&path) { + continue; + } + out.push(path.clone()); + collect_local_reference_paths(&path, depth + 1, limit, out); + } else if file_type.is_file() { + out.push(path); + } + } +} + +fn should_skip_local_reference_dir(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + matches!( + name, + ".git" + | "target" + | "node_modules" + | ".venv" + | "venv" + | "env" + | "dist" + | "build" + | "__pycache__" + | ".ruff_cache" + ) +} + impl Clone for Workspace { fn clone(&self) -> Self { // Don't carry the cached file_index — clones get a fresh OnceLock so @@ -1318,6 +1445,50 @@ mod tests { ); } + #[test] + fn workspace_completions_surface_explicit_hidden_and_ignored_paths() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join(".gitignore"), ".deepseek/\n.generated/\n").unwrap(); + let deepseek_commands = tmp.path().join(".deepseek").join("commands"); + let generated_specs = tmp.path().join(".generated").join("specs"); + std::fs::create_dir_all(&deepseek_commands).unwrap(); + std::fs::create_dir_all(&generated_specs).unwrap(); + std::fs::write(deepseek_commands.join("start-task.md"), "start").unwrap(); + std::fs::write(generated_specs.join("device-layout.md"), "layout").unwrap(); + + let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(tmp.path().to_path_buf())); + + let start_entries = ws.completions(".deepseek/commands", 16); + assert!( + start_entries + .iter() + .any(|e| e == ".deepseek/commands/start-task.md"), + "expected explicitly addressed hidden command file in completions: {start_entries:?}", + ); + + let generated_entries = ws.completions(".generated/specs", 16); + assert!( + generated_entries + .iter() + .any(|e| e == ".generated/specs/device-layout.md"), + "expected explicitly addressed ignored user folder in completions: {generated_entries:?}", + ); + } + + #[test] + fn fuzzy_index_resolves_hidden_and_ignored_files() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join(".gitignore"), ".generated/\n").unwrap(); + let generated_specs = tmp.path().join(".generated").join("specs"); + std::fs::create_dir_all(&generated_specs).unwrap(); + std::fs::write(generated_specs.join("device-layout.md"), "layout").unwrap(); + + let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None); + let resolved = ws.resolve("device-layout.md").unwrap(); + + assert!(resolved.ends_with(".generated/specs/device-layout.md")); + } + #[test] fn fuzzy_index_finds_files_and_directories() { let tmp = TempDir::new().unwrap(); From b1fb8f334be5386d377b3eb4d517e2b138db5b21 Mon Sep 17 00:00:00 2001 From: samhandsome Date: Sat, 9 May 2026 14:03:15 +0800 Subject: [PATCH 2/2] fix(tui): honor deepseekignore in mention fallback --- crates/tui/src/working_set.rs | 71 +++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index ab67a348..efbff139 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -444,38 +444,33 @@ fn should_try_local_reference_completion(needle: &str) -> bool { fn local_reference_paths(root: &Path, limit: usize) -> Vec { let mut out = Vec::new(); - collect_local_reference_paths(root, 0, limit, &mut out); - out -} + let mut builder = WalkBuilder::new(root); + builder + .hidden(false) + .follow_links(false) + .max_depth(Some(COMPLETIONS_WALK_DEPTH)) + .git_ignore(false) + .git_global(false) + .git_exclude(false); + let _ = builder.add_custom_ignore_filename(".deepseekignore"); + builder.filter_entry(|entry| !should_skip_local_reference_dir(entry.path())); -fn collect_local_reference_paths(root: &Path, depth: usize, limit: usize, out: &mut Vec) { - if depth > COMPLETIONS_WALK_DEPTH || out.len() >= limit { - return; - } - - let entries = match std::fs::read_dir(root) { - Ok(entries) => entries, - Err(_) => return, - }; - - for entry in entries.flatten() { + for entry in builder.build().flatten() { if out.len() >= limit { break; } let path = entry.path(); - let Ok(file_type) = entry.file_type() else { + if path == root { continue; - }; - if file_type.is_dir() { - if should_skip_local_reference_dir(&path) { - continue; - } - out.push(path.clone()); - collect_local_reference_paths(&path, depth + 1, limit, out); - } else if file_type.is_file() { - out.push(path); + } + if entry + .file_type() + .is_some_and(|ft| ft.is_file() || ft.is_dir()) + { + out.push(path.to_path_buf()); } } + out } fn should_skip_local_reference_dir(path: &Path) -> bool { @@ -1449,12 +1444,18 @@ mod tests { fn workspace_completions_surface_explicit_hidden_and_ignored_paths() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join(".gitignore"), ".deepseek/\n.generated/\n").unwrap(); + std::fs::write( + tmp.path().join(".deepseekignore"), + ".generated/specs/secrets.env\n", + ) + .unwrap(); let deepseek_commands = tmp.path().join(".deepseek").join("commands"); let generated_specs = tmp.path().join(".generated").join("specs"); std::fs::create_dir_all(&deepseek_commands).unwrap(); std::fs::create_dir_all(&generated_specs).unwrap(); std::fs::write(deepseek_commands.join("start-task.md"), "start").unwrap(); std::fs::write(generated_specs.join("device-layout.md"), "layout").unwrap(); + std::fs::write(generated_specs.join("secrets.env"), "secret").unwrap(); let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(tmp.path().to_path_buf())); @@ -1473,20 +1474,40 @@ mod tests { .any(|e| e == ".generated/specs/device-layout.md"), "expected explicitly addressed ignored user folder in completions: {generated_entries:?}", ); + assert!( + !generated_entries + .iter() + .any(|e| e == ".generated/specs/secrets.env"), + ".deepseekignore entries must not be reintroduced by local fallback: {generated_entries:?}", + ); } #[test] - fn fuzzy_index_resolves_hidden_and_ignored_files() { + fn fuzzy_index_resolves_hidden_and_ignored_files_except_deepseekignored() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join(".gitignore"), ".generated/\n").unwrap(); + std::fs::write( + tmp.path().join(".deepseekignore"), + ".generated/specs/secrets.env\n", + ) + .unwrap(); let generated_specs = tmp.path().join(".generated").join("specs"); std::fs::create_dir_all(&generated_specs).unwrap(); std::fs::write(generated_specs.join("device-layout.md"), "layout").unwrap(); + std::fs::write(generated_specs.join("secrets.env"), "secret").unwrap(); let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None); let resolved = ws.resolve("device-layout.md").unwrap(); assert!(resolved.ends_with(".generated/specs/device-layout.md")); + assert!( + ws.resolve("secrets.env").is_err(), + "basename fuzzy resolution must honor .deepseekignore" + ); + assert!( + ws.resolve(".generated/specs/secrets.env").is_ok(), + "exact user-specified paths should still resolve" + ); } #[test]