diff --git a/crates/tui/src/tui/file_picker.rs b/crates/tui/src/tui/file_picker.rs index c74d1b69..a4f81c67 100644 --- a/crates/tui/src/tui/file_picker.rs +++ b/crates/tui/src/tui/file_picker.rs @@ -434,6 +434,44 @@ fn collect_candidates(root: &Path) -> Vec { break; } } + + // Whitelist AI-tool dot-directories so they're discoverable even when + // gitignored. Walk each one separately with gitignore disabled. + for dir in [".deepseek", ".cursor", ".claude", ".agents"] { + let dot_dir = root.join(dir); + if !dot_dir.is_dir() { + continue; + } + let mut dot_builder = WalkBuilder::new(&dot_dir); + dot_builder + .hidden(true) + .follow_links(false) + .git_ignore(false) + .ignore(false) + .max_depth(Some(WALK_DEPTH.saturating_sub(1))); + for entry in dot_builder.build().flatten() { + // Exclude machine-generated bulk (e.g. .deepseek/snapshots/). + if entry.path().starts_with(root.join(".deepseek/snapshots")) { + continue; + } + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap_or(path); + if rel.as_os_str().is_empty() { + continue; + } + let display = path_to_workspace_string(rel); + if !display.is_empty() { + out.push(display); + } + if out.len() >= MAX_CANDIDATES { + break; + } + } + } + out.sort(); out } diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index 21eb94d5..2688b0d3 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -93,11 +93,7 @@ impl Workspace { fn build_file_index(&self) -> HashMap> { let mut index: HashMap> = HashMap::new(); - let mut builder = WalkBuilder::new(&self.root); - builder.hidden(true).follow_links(false).max_depth(Some(6)); - // Honor `.deepseekignore` in addition to the defaults the `ignore` crate - // already respects (`.gitignore`, `.git/info/exclude`, `.ignore`). - let _ = builder.add_custom_ignore_filename(".deepseekignore"); + let builder = discovery_walk_builder(&self.root, Some(6)); for entry in builder.build().flatten() { if entry @@ -111,6 +107,37 @@ impl Workspace { .push(entry.path().to_path_buf()); } } + + // Also index AI-tool dot-directories with gitignore disabled. + for dir_name in DISCOVERY_ALWAYS_DIRS { + let dot_dir = self.root.join(dir_name); + if !dot_dir.is_dir() { + continue; + } + let mut dot_builder = WalkBuilder::new(&dot_dir); + dot_builder + .hidden(true) + .follow_links(false) + .git_ignore(false) + .ignore(false) + .max_depth(Some(5)); + for entry in dot_builder.build().flatten() { + // Exclude machine-generated bulk (e.g. .deepseek/snapshots/). + if path_is_excluded_from_discovery(&self.root, entry.path()) { + continue; + } + if entry + .file_type() + .is_some_and(|ft| ft.is_file() || ft.is_dir()) + { + let name = entry.file_name().to_string_lossy().to_lowercase(); + index + .entry(name) + .or_default() + .push(entry.path().to_path_buf()); + } + } + } index } @@ -180,6 +207,111 @@ impl Workspace { /// monorepos. const COMPLETIONS_WALK_DEPTH: usize = 6; +/// Directories that must remain discoverable for `@`-mention completion and +/// fuzzy file resolution even when excluded by `.gitignore`. AI-tool +/// convention directories (`.deepseek/`, `.cursor/`, `.claude/`, `.agents/`) +/// are routinely gitignored, but users need to `@`-mention files inside them. +const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"]; + +/// Subdirectories under `DISCOVERY_ALWAYS_DIRS` that must NOT be indexed +/// even when the parent dir is walked with gitignore disabled. These are +/// large, machine-generated, or sensitive paths that would blow up the +/// walker (e.g. `.deepseek/snapshots/` — the snapshot side repo that +/// #1112 caps at 500 MB; indexing it would trigger the same OOM/hang +/// the cap was built to prevent). +const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] = &[".deepseek/snapshots"]; + +/// Check whether a path resolved against `walk_root` falls inside any +/// `DISCOVERY_EXCLUDED_SUBDIRS` entry. Used to keep the snapshot side +/// repo (`.deepseek/snapshots/`) out of the completion/index walk. +fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool { + for excluded in DISCOVERY_EXCLUDED_SUBDIRS { + if path.starts_with(walk_root.join(excluded)) { + return true; + } + } + false +} + +/// Configure a `WalkBuilder` for workspace discovery: hidden files, no +/// symlink following, depth-limited, custom `.deepseekignore` honored, +/// and gitignore overrides for AI-tool dot-directories so `@`-completion +/// finds them even when they're gitignored. +fn discovery_walk_builder(root: &Path, max_depth: Option) -> WalkBuilder { + let mut builder = WalkBuilder::new(root); + builder.hidden(true).follow_links(false); + if let Some(depth) = max_depth { + builder.max_depth(Some(depth)); + } + let _ = builder.add_custom_ignore_filename(".deepseekignore"); + builder +} + +/// Walk the AI-tool dot-directories (`.deepseek/`, `.cursor/`, `.claude/`, +/// `.agents/`) with gitignore disabled so their contents are discoverable +/// even when the project's `.gitignore` / `.ignore` excludes them. +#[allow(clippy::too_many_arguments)] +fn walk_always_discoverable_dirs( + walk_root: &Path, + display_root: &Path, + needle: &str, + limit: usize, + prefix_hits: &mut Vec, + substring_hits: &mut Vec, + seen: &mut HashSet, + max_depth: Option, +) { + for dir_name in DISCOVERY_ALWAYS_DIRS { + let dot_dir = walk_root.join(dir_name); + if !dot_dir.is_dir() { + continue; + } + let mut builder = WalkBuilder::new(&dot_dir); + builder + .hidden(true) + .follow_links(false) + .git_ignore(false) + .ignore(false); + if let Some(depth) = max_depth { + builder.max_depth(Some(depth.saturating_sub(1))); + } + for entry in builder.build().flatten() { + if prefix_hits.len() + substring_hits.len() >= limit { + break; + } + let path = entry.path(); + // Exclude machine-generated bulk (e.g. .deepseek/snapshots/) + // even though gitignore is disabled for this walk. + if path_is_excluded_from_discovery(walk_root, path) { + continue; + } + let Ok(rel) = path.strip_prefix(display_root) else { + continue; + }; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if rel_str.is_empty() { + continue; + } + let abs = path.to_path_buf(); + if !seen.insert(abs) { + continue; + } + let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); + let candidate = if is_dir { + format!("{rel_str}/") + } else { + rel_str.clone() + }; + let lower = candidate.to_lowercase(); + if needle.is_empty() || lower.starts_with(needle) { + prefix_hits.push(candidate); + } else if lower.contains(needle) { + substring_hits.push(candidate); + } + } + } +} + #[allow(clippy::too_many_arguments)] fn walk_for_completions( walk_root: &Path, @@ -190,12 +322,7 @@ fn walk_for_completions( substring_hits: &mut Vec, seen: &mut HashSet, ) { - let mut builder = WalkBuilder::new(walk_root); - builder - .hidden(true) - .follow_links(false) - .max_depth(Some(COMPLETIONS_WALK_DEPTH)); - let _ = builder.add_custom_ignore_filename(".deepseekignore"); + let builder = discovery_walk_builder(walk_root, Some(COMPLETIONS_WALK_DEPTH)); for entry in builder.build().flatten() { if prefix_hits.len() + substring_hits.len() >= limit { @@ -228,6 +355,19 @@ fn walk_for_completions( substring_hits.push(candidate); } } + + // Also walk the AI-tool dot-directories with gitignore disabled so + // `.deepseek/`, `.cursor/`, etc. are always discoverable. + walk_always_discoverable_dirs( + walk_root, + display_root, + needle, + limit, + prefix_hits, + substring_hits, + seen, + Some(COMPLETIONS_WALK_DEPTH), + ); } impl Clone for Workspace { @@ -1195,4 +1335,114 @@ mod tests { // Index was populated exactly once (subsequent lookups reuse it). assert!(ws.file_index.get().is_some()); } + + /// Regression: `@`-mention completion must discover files inside + /// `.deepseek/`, `.cursor/`, `.claude/`, `.agents/` even when + /// those directories are excluded by `.gitignore` (or `.ignore`). + /// The `discovery_walk_builder` override un-ignores them. + #[test] + fn completions_discovers_files_inside_gitignored_dot_dirs() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // `.ignore` works even outside a git repo; use it to simulate + // a project that gitignores its AI-tool dot-directories. + std::fs::write( + root.join(".ignore"), + ".deepseek/\n.cursor/\n.claude/\n.agents/\n", + ) + .unwrap(); + + // Create files inside each dot-dir. + std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap(); + std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap(); + std::fs::create_dir_all(root.join(".cursor/commands")).unwrap(); + std::fs::write(root.join(".cursor/commands/run.md"), "run cmd").unwrap(); + std::fs::create_dir_all(root.join(".claude/commands")).unwrap(); + std::fs::write(root.join(".claude/commands/test.md"), "test cmd").unwrap(); + std::fs::create_dir_all(root.join(".agents/skills/example")).unwrap(); + std::fs::write( + root.join(".agents/skills/example/SKILL.md"), + "name: example\n", + ) + .unwrap(); + + let ws = Workspace::with_cwd(root.to_path_buf(), None); + + // Completions should find entries inside the dot-dirs. + { + let entries = ws.completions("build", 16); + assert!( + entries.iter().any(|e| e.contains("build.md")), + "expected build.md in completions although .deepseek/ is ignored; got: {entries:?}" + ); + } + { + let entries = ws.completions("run", 16); + assert!( + entries.iter().any(|e| e.contains("run.md")), + "expected run.md from .cursor/; got: {entries:?}" + ); + } + { + let entries = ws.completions("test", 16); + assert!( + entries.iter().any(|e| e.contains("test.md")), + "expected test.md from .claude/; got: {entries:?}" + ); + } + + // Fuzzy resolution should also work. + let f = ws.resolve("build.md").unwrap(); + assert!(f.ends_with("build.md")); + let f2 = ws.resolve("SKILL.md").unwrap(); + assert!(f2.ends_with("SKILL.md")); + } + + /// Regression: the dot-dir walk must NOT index `.deepseek/snapshots/`, + /// which is the snapshot side repo that can grow to hundreds of GB. + /// Indexing it would re-create the same OOM/hang that #1112 was built + /// to prevent. + #[test] + fn dot_dir_walk_excludes_snapshot_side_repo() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + // Create a snapshot-like directory tree. + std::fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects")) + .unwrap(); + std::fs::write( + root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects/snapshot.pack"), + b"fake pack data", + ) + .unwrap(); + // Also create a legitimate file in .deepseek/ that should be found. + std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap(); + std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap(); + + let ws = Workspace::with_cwd(root.to_path_buf(), None); + + // Searching for "build" must find build.md. + let entries = ws.completions("build", 16); + assert!( + entries.iter().any(|e| e.contains("build.md")), + "build.md must still be found; got: {entries:?}" + ); + // Searching for "snapshot" must NOT return snapshot files. + let snap_entries = ws.completions("snapshot", 16); + assert!( + !snap_entries.iter().any(|e| e.contains("snapshot")), + "snapshot files must NOT appear in completions; got: {snap_entries:?}" + ); + + // Fuzzy index must also exclude snapshots. + let f = ws.resolve("build.md").unwrap(); + assert!(f.ends_with("build.md")); + // snapshot.pack should NOT resolve. + let result = ws.resolve("snapshot.pack"); + assert!( + result.is_err(), + "snapshot.pack must not resolve via fuzzy index" + ); + } }