From 3c0f700b0919f4bdd14d9a91ade5504030a6b2a3 Mon Sep 17 00:00:00 2001 From: Konv Suu Date: Thu, 12 Nov 2026 02:28:33 +0800 Subject: [PATCH 1/3] Mark ignored file tree entries from the Rust ignore rules The file tree matched .gitignore files in TypeScript, re-normalizing paths with regexes for every row and every ancestor, so expanding a folder of 3,000 files spent about 300 ms in gitignore checks. It also only read the .gitignore files of folders that were already loaded. Listing a local or WSL folder for the tree now asks Rust which entries the workspace's ignore files exclude, reusing the file watcher's rules, and the tree reads that flag. WSL reads the rules through the \\wsl.localhost share without the global excludes file. The tree now also honors .ignore files, .git/info/exclude and the global excludes file, like the watcher. A change to an ignore file re-lists the loaded folders below it. The shared rules no longer let a /**/ pattern match the files next to it, and a !pattern can no longer bring back a path inside an excluded directory, which matches git. --- crates/project/src/ignored_entries.rs | 193 ++++++++++ crates/project/src/lib.rs | 1 + crates/project/src/watcher/ignore_rules.rs | 64 +++- crates/project/src/watcher/mod.rs | 2 +- src-tauri/src/commands/project/fs.rs | 15 + src-tauri/src/commands/project/wsl.rs | 23 ++ src-tauri/src/main.rs | 2 + src/bindings/commands.ts | 8 + .../components/file-explorer-tree.tsx | 93 +---- .../lib/visible-file-tree-rows.ts | 16 +- .../services/file-tree-gitignore.ts | 329 ------------------ .../tests/file-explorer-tree.test.tsx | 17 +- .../tests/file-tree-gitignore.test.ts | 230 ------------ .../tests/visible-file-tree-rows.test.ts | 28 +- .../file-system/api/file-system-api.ts | 6 +- .../file-system/api/ignored-entries.ts | 61 ++++ .../controllers/directory-contents.ts | 19 +- .../services/file-watcher-listener.ts | 28 +- .../services/ignore-rule-changes.ts | 23 ++ .../services/workspace-resource-provider.ts | 24 +- .../file-system/stores/file-system.store.ts | 69 ++-- .../file-system/tests/file-operations.test.ts | 18 +- .../tests/file-system-store.test.ts | 45 +++ .../file-system-workspace-lifecycle.test.ts | 16 + .../tests/file-watcher-listener.test.ts | 53 ++- .../tests/platform-text-writes.test.ts | 4 +- .../tests/workspace-resource-provider.test.ts | 71 ++++ .../web-workbench/services/web-backend.ts | 20 ++ .../web-workbench/tests/web-backend.test.ts | 17 + 29 files changed, 754 insertions(+), 741 deletions(-) create mode 100644 crates/project/src/ignored_entries.rs delete mode 100644 src/features/file-explorer/services/file-tree-gitignore.ts delete mode 100644 src/features/file-explorer/tests/file-tree-gitignore.test.ts create mode 100644 src/features/file-system/api/ignored-entries.ts create mode 100644 src/features/file-system/services/ignore-rule-changes.ts diff --git a/crates/project/src/ignored_entries.rs b/crates/project/src/ignored_entries.rs new file mode 100644 index 000000000..083068ea7 --- /dev/null +++ b/crates/project/src/ignored_entries.rs @@ -0,0 +1,193 @@ +use crate::watcher::ignore_rules::IgnoreRules; +use std::{fs, path::Path}; + +/// Names of the entries of `directory` that the ignore files of `root` exclude: `.gitignore` and +/// `.ignore` files from `directory` up to `root`, `.git/info/exclude`, and the global excludes +/// file. Empty when `directory` is outside `root`. +pub fn ignored_entry_names(root: &Path, directory: &Path) -> Result, String> { + collect_ignored_entry_names(IgnoreRules::new(root), root, directory) +} + +/// Like [`ignored_entry_names`] without the global excludes file, for a root on another system +/// whose git config this process cannot read. +pub fn ignored_entry_names_without_global_excludes( + root: &Path, + directory: &Path, +) -> Result, String> { + collect_ignored_entry_names(IgnoreRules::without_global_excludes(root), root, directory) +} + +fn collect_ignored_entry_names( + mut rules: IgnoreRules, + root: &Path, + directory: &Path, +) -> Result, String> { + let Ok(relative_directory) = directory.strip_prefix(root) else { + return Ok(Vec::new()); + }; + + let entries = fs::read_dir(directory) + .map_err(|error| format!("Failed to read {}: {error}", directory.display()))?; + + let mut names = Vec::new(); + for entry in entries.flatten() { + let file_name = entry.file_name(); + // Git never applies ignore rules to its own directory. + if file_name == ".git" { + continue; + } + let Some(name) = file_name.to_str() else { + continue; + }; + // Git does not follow symlinks, so a link to a directory is matched as a file. + let is_dir = entry.file_type().is_ok_and(|file_type| file_type.is_dir()); + if rules.is_ignored(&relative_directory.join(name), is_dir) { + names.push(name.to_string()); + } + } + names.sort(); + Ok(names) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn tree(files: &[(&str, &str)]) -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + for (path, content) in files { + let path = temp.path().join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + if path.to_string_lossy().ends_with('/') { + fs::create_dir_all(&path).unwrap(); + } else { + fs::write(&path, content).unwrap(); + } + } + temp + } + + fn ignored(root: &Path, directory: &str) -> Vec { + ignored_entry_names_without_global_excludes(root, &root.join(directory)).unwrap() + } + + #[test] + fn applies_nested_ignore_files_relative_to_their_directory() { + let temp = tree(&[ + (".gitignore", "target/\n*.tmp\n"), + ("target/", ""), + ("src/file.tmp", ""), + ("src/main.ts", ""), + ("subprojects/.gitignore", "/**/\npackagecache\n"), + ("subprojects/zerocopy-0.8.48/", ""), + ("subprojects/packagecache", ""), + ("subprojects/notes.md", ""), + ("zerocopy-0.8.48/", ""), + ]); + let root = temp.path(); + + assert_eq!(ignored(root, ""), vec!["target"]); + assert_eq!(ignored(root, "src"), vec!["file.tmp"]); + assert_eq!( + ignored(root, "subprojects"), + vec!["packagecache", "zerocopy-0.8.48"] + ); + } + + #[test] + fn lets_deeper_rules_unignore_unless_a_parent_directory_is_ignored() { + let temp = tree(&[ + (".gitignore", "*.log\nbuild/\n"), + ("logs/.gitignore", "!keep.log\n"), + ("logs/error.log", ""), + ("logs/keep.log", ""), + ("build/.gitignore", "!keep.log\n"), + ("build/keep.log", ""), + ]); + let root = temp.path(); + + assert_eq!(ignored(root, "logs"), vec!["error.log"]); + assert_eq!(ignored(root, "build"), vec![".gitignore", "keep.log"]); + } + + #[test] + fn handles_anchored_and_directory_only_patterns() { + let temp = tree(&[ + ("sub/.gitignore", "/cache\nbuild/\n"), + ("sub/cache", ""), + ("sub/build/", ""), + ("sub/build.txt", ""), + ("sub/deep/cache", ""), + ]); + let root = temp.path(); + + assert_eq!(ignored(root, "sub"), vec!["build", "cache"]); + assert!(ignored(root, "sub/deep").is_empty()); + } + + #[test] + fn keeps_valid_rules_around_a_malformed_line() { + let temp = tree(&[ + (".gitignore", "valid.out\nbad[\n\\#literal-hash\n"), + ("valid.out", ""), + ("#literal-hash", ""), + ("kept.txt", ""), + ]); + + assert_eq!(ignored(temp.path(), ""), vec!["#literal-hash", "valid.out"]); + } + + #[test] + fn never_reports_the_git_directory() { + let temp = tree(&[ + (".gitignore", ".git\n*\n!.gitignore\n"), + (".git/HEAD", ""), + ("file.txt", ""), + ]); + + assert_eq!(ignored(temp.path(), ""), vec!["file.txt"]); + } + + #[cfg(unix)] + #[test] + fn treats_a_symlink_to_a_directory_as_a_file_like_git() { + let temp = tree(&[(".gitignore", "build/\n"), ("real/", "")]); + std::os::unix::fs::symlink(temp.path().join("real"), temp.path().join("build")).unwrap(); + + assert!(ignored(temp.path(), "").is_empty()); + } + + #[test] + fn reads_info_exclude_and_ignore_files() { + let temp = tree(&[ + (".git/info/exclude", "local-notes.md\n"), + (".ignore", "*.snap\n"), + ("local-notes.md", ""), + ("view.snap", ""), + ("view.tsx", ""), + ]); + + assert_eq!( + ignored(temp.path(), ""), + vec!["local-notes.md", "view.snap"] + ); + } + + #[test] + fn returns_nothing_for_a_directory_outside_the_root() { + let temp = tree(&[(".gitignore", "*.log\n"), ("app/debug.log", "")]); + let other = tree(&[("debug.log", "")]); + + assert!( + ignored_entry_names_without_global_excludes(&temp.path().join("app"), other.path()) + .unwrap() + .is_empty() + ); + assert!( + ignored_entry_names(&PathBuf::from(temp.path()).join("app"), other.path()) + .unwrap() + .is_empty() + ); + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index a3319236b..81e4e4773 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,5 +1,6 @@ pub mod file_mutations; pub mod ide_recents; +pub mod ignored_entries; pub mod local_fs; pub mod path_guard; pub mod shell_file_mutations; diff --git a/crates/project/src/watcher/ignore_rules.rs b/crates/project/src/watcher/ignore_rules.rs index aa6c3d557..aa3383535 100644 --- a/crates/project/src/watcher/ignore_rules.rs +++ b/crates/project/src/watcher/ignore_rules.rs @@ -119,6 +119,7 @@ pub struct IgnoreRules { root: PathBuf, per_directory: HashMap>>, repository: Option>, + global_excludes: bool, } impl IgnoreRules { @@ -127,6 +128,16 @@ impl IgnoreRules { root: root.into(), per_directory: HashMap::new(), repository: None, + global_excludes: true, + } + } + + /// Rules for a root whose global excludes file is not the one this process's git config + /// names, such as a WSL distribution reached from Windows. + pub fn without_global_excludes(root: impl Into) -> Self { + Self { + global_excludes: false, + ..Self::new(root) } } @@ -136,16 +147,35 @@ impl IgnoreRules { self.repository = None; } - /// Whether `relative` (relative to the root) is ignored, checking the ignore files of every - /// directory from the deepest one up to the root, then `.git/info/exclude` and the global - /// excludes file. + /// Whether `relative` (relative to the root) is ignored: it or one of its parent directories + /// is excluded by the ignore files of the directories above it (the deepest one deciding), + /// then by `.git/info/exclude` and the global excludes file. pub fn is_ignored(&mut self, relative: &Path, is_dir: bool) -> bool { + let parents: Vec = relative + .ancestors() + .skip(1) + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf) + .collect(); + // Git never descends into an excluded directory, so a `!pattern` below it cannot bring a + // path back. + for parent in parents.iter().rev() { + if self.is_excluded(parent, true) { + return true; + } + } + self.is_excluded(relative, is_dir) + } + + /// Matches `relative` itself, never its parents: `Gitignore::matched_path_or_any_parents` + /// also tests the matcher's own directory as an empty path, which `/**/` matches. + fn is_excluded(&mut self, relative: &Path, is_dir: bool) -> bool { let absolute = self.root.join(relative); let mut directory = relative.parent().map(Path::to_path_buf); while let Some(current) = directory { if let Some(matcher) = self.matcher_for(¤t) { - let matched = matcher.matched_path_or_any_parents(&absolute, is_dir); + let matched = matcher.matched(&absolute, is_dir); if matched.is_ignore() { return true; } @@ -158,7 +188,7 @@ impl IgnoreRules { let repository = self.repository_matchers(); for matcher in [&repository.0, &repository.1] { - let matched = matcher.matched_path_or_any_parents(&absolute, is_dir); + let matched = matcher.matched(&absolute, is_dir); if matched.is_ignore() { return true; } @@ -217,7 +247,11 @@ impl IgnoreRules { ); } let exclude = exclude.build().unwrap_or_else(|_| Gitignore::empty()); - let (global, _) = GitignoreBuilder::new(&self.root).build_global(); + let global = if self.global_excludes { + GitignoreBuilder::new(&self.root).build_global().0 + } else { + Gitignore::empty() + }; let matchers = Arc::new((exclude, global)); self.repository = Some(matchers.clone()); @@ -309,6 +343,24 @@ mod tests { assert!(rules.is_ignored(Path::new("data.watchtest"), false)); } + #[test] + fn matches_globstar_directories_and_excluded_parents_like_git() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join("build")).unwrap(); + std::fs::create_dir_all(root.join("sub/nested")).unwrap(); + std::fs::write(root.join(".gitignore"), "build/\n").unwrap(); + std::fs::write(root.join("build/.gitignore"), "!keep.log\n").unwrap(); + std::fs::write(root.join("sub/.gitignore"), "/**/\n").unwrap(); + + let mut rules = IgnoreRules::without_global_excludes(root); + assert!(rules.is_ignored(Path::new("build/keep.log"), false)); + assert!(rules.is_ignored(Path::new("sub/nested"), true)); + assert!(rules.is_ignored(Path::new("sub/nested/file.rs"), false)); + assert!(!rules.is_ignored(Path::new("sub/notes.md"), false)); + assert!(!rules.is_ignored(Path::new("sub/.gitignore"), false)); + } + #[test] fn bounds_the_directory_cache() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/project/src/watcher/mod.rs b/crates/project/src/watcher/mod.rs index 86a35b5b9..ddc906a95 100644 --- a/crates/project/src/watcher/mod.rs +++ b/crates/project/src/watcher/mod.rs @@ -3,7 +3,7 @@ //! debounce window. mod batch; -mod ignore_rules; +pub(crate) mod ignore_rules; mod types; use anyhow::{Context, Result, bail}; diff --git a/src-tauri/src/commands/project/fs.rs b/src-tauri/src/commands/project/fs.rs index 351a6978d..06e82d647 100644 --- a/src-tauri/src/commands/project/fs.rs +++ b/src-tauri/src/commands/project/fs.rs @@ -35,6 +35,21 @@ pub async fn get_local_directory_size(path: String) -> Result { .map_err(|error| format!("Directory size task failed: {error}"))? } +#[command] +#[specta::specta] +pub async fn local_ignored_entry_names( + directory: String, + workspace_root: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let directory = resolve_local_path(&directory)?; + let root = resolve_local_path(&workspace_root)?; + athas_project::ignored_entries::ignored_entry_names(&root, &directory) + }) + .await + .map_err(|error| format!("Ignored entries task failed: {error}"))? +} + #[command] #[specta::specta] pub async fn read_local_file(path: String) -> Result { diff --git a/src-tauri/src/commands/project/wsl.rs b/src-tauri/src/commands/project/wsl.rs index cad4a91a4..585d11eb3 100644 --- a/src-tauri/src/commands/project/wsl.rs +++ b/src-tauri/src/commands/project/wsl.rs @@ -1,5 +1,6 @@ use super::fs::FileBytes; use athas_wsl::{WslDistribution, WslFileEntry, WslSymlinkInfo}; +use std::path::Path; use tauri::command; #[command] @@ -20,6 +21,28 @@ pub fn wsl_read_directory(distro: String, path: String) -> Result Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let flavor = athas_wsl::resolve_unc_flavor(&distro); + let directory = athas_wsl::wsl_path_to_windows_unc(&distro, &directory, flavor); + let root = athas_wsl::wsl_path_to_windows_unc(&distro, &workspace_root, flavor); + athas_project::ignored_entries::ignored_entry_names_without_global_excludes( + Path::new(&root), + Path::new(&directory), + ) + }) + .await + .map_err(|error| format!("Ignored entries task failed: {error}"))? +} + #[command] #[specta::specta] pub fn wsl_read_file(distro: String, file_path: String) -> Result { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5b9ecce0f..ae54373c6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -167,6 +167,7 @@ pub(crate) fn specta_builder() -> tauri_specta::Builder { write_local_file, delete_local_file_checked, get_local_directory_size, + local_ignored_entry_names, allow_asset_path::, open_file_external, toggle_quick_look, @@ -393,6 +394,7 @@ pub(crate) fn specta_builder() -> tauri_specta::Builder { wsl_list_distributions, wsl_get_home_dir, wsl_read_directory, + wsl_ignored_entry_names, wsl_read_file, wsl_read_file_bytes, wsl_write_file, diff --git a/src/bindings/commands.ts b/src/bindings/commands.ts index 39bc06722..dd9e79049 100644 --- a/src/bindings/commands.ts +++ b/src/bindings/commands.ts @@ -32,6 +32,8 @@ export const commands = { __TAURI_INVOKE("delete_local_file_checked", { path, expectedContent }), getLocalDirectorySize: (path: string) => __TAURI_INVOKE("get_local_directory_size", { path }), + localIgnoredEntryNames: (directory: string, workspaceRoot: string) => + __TAURI_INVOKE("local_ignored_entry_names", { directory, workspaceRoot }), /** * Allow `path` to be served through the asset protocol. Directories are * allowed recursively; files are allowed individually. @@ -764,6 +766,12 @@ export const commands = { wslGetHomeDir: (distro: string) => __TAURI_INVOKE("wsl_get_home_dir", { distro }), wslReadDirectory: (distro: string, path: string) => __TAURI_INVOKE("wsl_read_directory", { distro, path }), + /** + * Reads the ignore files through the distribution's `\\wsl.localhost` share. The global + * excludes file is skipped: the one this process would find belongs to the Windows user. + */ + wslIgnoredEntryNames: (distro: string, directory: string, workspaceRoot: string) => + __TAURI_INVOKE("wsl_ignored_entry_names", { distro, directory, workspaceRoot }), wslReadFile: (distro: string, filePath: string) => __TAURI_INVOKE("wsl_read_file", { distro, filePath }), wslReadFileBytes: (distro: string, filePath: string) => diff --git a/src/features/file-explorer/components/file-explorer-tree.tsx b/src/features/file-explorer/components/file-explorer-tree.tsx index 85eb5500e..4bbade888 100644 --- a/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/src/features/file-explorer/components/file-explorer-tree.tsx @@ -47,18 +47,9 @@ import { type FileTreeGitStatusDecoration, type FileTreeGitStatusLookup, } from "@/features/file-explorer/lib/file-tree-git-status"; -import { - collectGitIgnoreFileReferences, - getCachedFileTreeGitIgnoreRules, - invalidateFileTreeGitIgnoreCache, - isPathGitIgnoredByFileTreeRules, - readFileTreeGitIgnoreContents, - subscribeToFileTreeGitIgnoreCacheInvalidation, - type FileTreeGitIgnoreRules, -} from "@/features/file-explorer/services/file-tree-gitignore"; import { fileOpenBenchmark } from "@/features/editor/services/file-open-benchmark"; import { findFileInTree } from "@/features/file-system/services/file-tree-utils"; -import { readDirectory, readFile } from "@/features/file-system/api/file-system-api"; +import { readWorkspaceDirectoryEntries } from "@/features/file-system/services/workspace-resource-provider"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import type { FileEntry } from "@/features/file-system/types/app.types"; import { useFffSearch } from "@/features/file-search/hooks/use-fff-search"; @@ -278,8 +269,6 @@ function FileExplorerTreeComponent({ const wasTreeSearchActiveRef = useRef(false); const documentRef = useRef(document); - const [gitIgnoreRules, setGitIgnoreRules] = useState(null); - const [gitIgnoreCacheVersion, setGitIgnoreCacheVersion] = useState(0); const workspaceGitStatus = useGitStore((state) => state.workspaceGitStatus); const currentWorkspaceRepoPath = useGitStore((state) => state.currentWorkspaceRepoPath); @@ -398,70 +387,11 @@ function FileExplorerTreeComponent({ [getWorkspaceRootForPath, userIgnore], ); - // Every tree update produces a new references array, but the ignore files only need reading - // again when the set of them changes or one of them is edited. - const collectedGitIgnoreFileReferences = useMemo( - () => collectGitIgnoreFileReferences(files, rootFolderPath), - [files, rootFolderPath], - ); - const gitIgnoreFileReferencesKey = collectedGitIgnoreFileReferences - .map((reference) => reference.path) - .join("\n"); - const gitIgnoreFileReferencesRef = useRef(collectedGitIgnoreFileReferences); - gitIgnoreFileReferencesRef.current = collectedGitIgnoreFileReferences; - - useEffect( - () => - subscribeToFileTreeGitIgnoreCacheInvalidation((path) => { - if (!rootFolderPath || (path && !pathStartsWithRoot(path, rootFolderPath))) return; - setGitIgnoreCacheVersion((version) => version + 1); - }), - [rootFolderPath], - ); - - useEffect(() => { - invalidateFileTreeGitIgnoreCache(); - }, [rootFolderPath]); - - useEffect(() => { - let cancelled = false; - const gitIgnoreFileReferences = gitIgnoreFileReferencesRef.current; - - const loadGitignore = async () => { - if (!rootFolderPath) { - setGitIgnoreRules(null); - return; - } - - const ignoreFiles = await readFileTreeGitIgnoreContents(gitIgnoreFileReferences, readFile); - - if (!cancelled) { - setGitIgnoreRules(getCachedFileTreeGitIgnoreRules(rootFolderPath, ignoreFiles)); - } - }; - - loadGitignore(); - - return () => { - cancelled = true; - }; - }, [gitIgnoreCacheVersion, gitIgnoreFileReferencesKey, rootFolderPath]); - const gitStatus = currentWorkspaceRepoPath && currentWorkspaceRepoPath === rootFolderPath ? workspaceGitStatus : null; - const isGitIgnored = useCallback( - (fullPath: string, isDir: boolean): boolean => { - if (!gitIgnoreRules || !rootFolderPath) return false; - if (getWorkspaceRootForPath(fullPath) !== rootFolderPath) return false; - - return isPathGitIgnoredByFileTreeRules(gitIgnoreRules, fullPath, isDir); - }, - [getWorkspaceRootForPath, gitIgnoreRules, rootFolderPath], - ); - const gitStatusDecorationLookup = useMemo(() => { const startedAt = performance.now(); if (!gitStatus || !fileTreeSettings.showGitStatusInFileTree) @@ -492,7 +422,6 @@ function FileExplorerTreeComponent({ () => ({ options: { isAlwaysHidden: isAlwaysHiddenFileName, - isGitIgnored, isHiddenName: isHiddenFileTreeName, isUserHidden, showGitignoredFiles: fileTreeSettings.showGitignoredFilesInFileTree, @@ -501,7 +430,6 @@ function FileExplorerTreeComponent({ cache: new WeakMap() as FileTreeFilterCache, }), [ - isGitIgnored, isUserHidden, fileTreeSettings.showGitignoredFilesInFileTree, fileTreeSettings.showHiddenFilesInFileTree, @@ -981,16 +909,14 @@ function FileExplorerTreeComponent({ const currentBatch = stack.slice(stackCursor, batchEnd); stackCursor = batchEnd; const directoryEntries = await Promise.all( - currentBatch.map((currentPath) => readDirectory(currentPath)), + currentBatch.map((currentPath) => + readWorkspaceDirectoryEntries(currentPath, rootFolderPath ?? directoryPath), + ), ); for (const entries of directoryEntries) { - for (const entry of entries as Array<{ - path: string; - is_dir?: boolean; - }>) { - if (!entry.path) continue; - const isDir = !!entry.is_dir; + for (const entry of entries) { + const isDir = entry.isDir; const entryName = getPathBaseName(entry.path); if (isAlwaysHiddenFileName(entryName)) { @@ -1005,10 +931,7 @@ function FileExplorerTreeComponent({ continue; } - if ( - !fileTreeSettings.showGitignoredFilesInFileTree && - isGitIgnored(entry.path, isDir) - ) { + if (!fileTreeSettings.showGitignoredFilesInFileTree && entry.ignored) { continue; } @@ -1028,7 +951,7 @@ function FileExplorerTreeComponent({ }, [ isUserHidden, - isGitIgnored, + rootFolderPath, fileTreeSettings.showGitignoredFilesInFileTree, fileTreeSettings.showHiddenFilesInFileTree, ], diff --git a/src/features/file-explorer/lib/visible-file-tree-rows.ts b/src/features/file-explorer/lib/visible-file-tree-rows.ts index edfc25b74..6aa5b3db4 100644 --- a/src/features/file-explorer/lib/visible-file-tree-rows.ts +++ b/src/features/file-explorer/lib/visible-file-tree-rows.ts @@ -34,7 +34,6 @@ interface FilterFileTreeForFffHitsOptions { interface FilterFileTreeEntriesOptions { isAlwaysHidden: (name: string) => boolean; - isGitIgnored: (path: string, isDir: boolean) => boolean; isHiddenName: (name: string) => boolean; isUserHidden: (path: string, isDir: boolean) => boolean; showGitignoredFiles: boolean; @@ -60,8 +59,6 @@ export function filterFileTreeEntries( const filteredItems: FileEntry[] = []; for (const item of files) { - const ignored = options.isGitIgnored(item.path, item.isDir); - if (options.isAlwaysHidden(item.name) || options.isUserHidden(item.path, item.isDir)) { changed = true; continue; @@ -72,7 +69,7 @@ export function filterFileTreeEntries( continue; } - if (!options.showGitignoredFiles && ignored) { + if (!options.showGitignoredFiles && item.ignored) { changed = true; continue; } @@ -80,16 +77,9 @@ export function filterFileTreeEntries( const filteredChildren = item.children ? filterFileTreeEntries(item.children, options, cache) : undefined; - const childrenChanged = filteredChildren !== item.children; - const ignoredChanged = item.ignored !== ignored && (ignored || item.ignored !== undefined); - - if (childrenChanged || ignoredChanged) { + if (filteredChildren !== item.children) { changed = true; - filteredItems.push({ - ...item, - ignored, - children: filteredChildren, - }); + filteredItems.push({ ...item, children: filteredChildren }); continue; } diff --git a/src/features/file-explorer/services/file-tree-gitignore.ts b/src/features/file-explorer/services/file-tree-gitignore.ts deleted file mode 100644 index 8553f009b..000000000 --- a/src/features/file-explorer/services/file-tree-gitignore.ts +++ /dev/null @@ -1,329 +0,0 @@ -import ignore from "ignore"; -import type { FileEntry } from "@/features/file-system/types/app.types"; -import { - getDirName, - getRelativePath, - joinPath, - normalizePath, - pathStartsWithRoot, - stripTrailingPathSeparators, -} from "@/utils/path-helpers"; - -const GITIGNORE_FILE_NAME = ".gitignore"; - -type IgnoreMatcher = ReturnType; - -interface GitIgnoreFileReference { - path: string; - directoryPath: string; -} - -interface GitIgnoreFileContent extends GitIgnoreFileReference { - content: string; -} - -interface GitIgnoreRuleSet { - directoryPath: string; - matcher: IgnoreMatcher; -} - -type GitIgnoreReader = (path: string) => Promise; -type GitIgnoreCacheListener = (path: string | undefined) => void; -interface GitIgnoreContentCacheEntry { - promise: Promise; -} - -const referenceCache = new WeakMap(); -const contentCache = new Map(); -const matcherCache = new Map(); -const rulesCache = new Map< - string, - { ignoreFiles: readonly GitIgnoreFileContent[]; rules: FileTreeGitIgnoreRules | null } ->(); -const cacheListeners = new Set(); -const MAX_CACHED_GITIGNORE_FILES = 256; -const MAX_CACHED_GITIGNORE_ROOTS = 16; - -export interface FileTreeGitIgnoreRules { - rootFolderPath: string; - ruleSets: GitIgnoreRuleSet[]; -} - -const pathDepth = (path: string): number => - normalizePath(stripTrailingPathSeparators(path)).split("/").filter(Boolean).length; - -const compareIgnoreReferences = ( - left: GitIgnoreFileReference, - right: GitIgnoreFileReference, -): number => { - const depthDelta = pathDepth(left.directoryPath) - pathDepth(right.directoryPath); - if (depthDelta !== 0) return depthDelta; - return normalizePath(left.directoryPath).localeCompare(normalizePath(right.directoryPath)); -}; - -function addGitIgnoreContent(matcher: IgnoreMatcher, content: string): void { - for (const line of content.split(/\r?\n/)) { - try { - matcher.add(line); - } catch { - // Keep the rest of the file usable if a single malformed pattern is present. - } - } -} - -function trimOldestCacheEntries(cache: Map, maxSize: number): void { - while (cache.size > maxSize) { - const oldestKey = cache.keys().next().value; - if (oldestKey === undefined) return; - cache.delete(oldestKey); - } -} - -function getCachedIgnoreMatcher(file: GitIgnoreFileContent): IgnoreMatcher { - const cacheKey = normalizePath(stripTrailingPathSeparators(file.path)); - const cached = matcherCache.get(cacheKey); - if (cached?.content === file.content) { - matcherCache.delete(cacheKey); - matcherCache.set(cacheKey, cached); - return cached.matcher; - } - - const matcher = ignore({ allowRelativePaths: true }); - addGitIgnoreContent(matcher, file.content); - matcherCache.set(cacheKey, { content: file.content, matcher }); - trimOldestCacheEntries(matcherCache, MAX_CACHED_GITIGNORE_FILES); - return matcher; -} - -function collectGitIgnoreReferencesForEntry(entry: FileEntry): GitIgnoreFileReference[] { - const cached = referenceCache.get(entry); - if (cached) return cached; - - const references: GitIgnoreFileReference[] = []; - if (entry.name === GITIGNORE_FILE_NAME && !entry.isDir) { - references.push({ path: entry.path, directoryPath: getDirName(entry.path) }); - } - for (const child of entry.children ?? []) { - references.push(...collectGitIgnoreReferencesForEntry(child)); - } - referenceCache.set(entry, references); - return references; -} - -export function collectGitIgnoreFileReferences( - files: FileEntry[], - rootFolderPath: string | undefined, -): GitIgnoreFileReference[] { - if (!rootFolderPath) return []; - - const references = new Map(); - const addReference = (path: string) => { - if (!pathStartsWithRoot(path, rootFolderPath)) return; - - const normalizedPath = normalizePath(stripTrailingPathSeparators(path)); - references.set(normalizedPath, { - path, - directoryPath: getDirName(path), - }); - }; - - addReference(joinPath(rootFolderPath, GITIGNORE_FILE_NAME)); - - for (const entry of files) { - for (const reference of collectGitIgnoreReferencesForEntry(entry)) { - addReference(reference.path); - } - } - - return [...references.values()].sort(compareIgnoreReferences); -} - -export async function readFileTreeGitIgnoreContents( - references: readonly GitIgnoreFileReference[], - read: GitIgnoreReader, -): Promise { - const ignoreFiles = await Promise.all( - references.map((reference) => { - const cacheKey = normalizePath(stripTrailingPathSeparators(reference.path)); - let entry = contentCache.get(cacheKey); - if (!entry) { - const newEntry: GitIgnoreContentCacheEntry = { promise: Promise.resolve(null) }; - newEntry.promise = read(reference.path) - .then((content) => - contentCache.get(cacheKey) === newEntry ? { ...reference, content } : null, - ) - .catch(() => null); - entry = newEntry; - contentCache.set(cacheKey, newEntry); - trimOldestCacheEntries(contentCache, MAX_CACHED_GITIGNORE_FILES); - } else { - contentCache.delete(cacheKey); - contentCache.set(cacheKey, entry); - } - return entry.promise; - }), - ); - - return ignoreFiles.filter((file): file is GitIgnoreFileContent => file !== null); -} - -export function invalidateFileTreeGitIgnoreCache(path?: string): void { - if (path) { - const cacheKey = normalizePath(stripTrailingPathSeparators(path)); - const pathParts = cacheKey.split("/"); - if (pathParts[pathParts.length - 1] !== GITIGNORE_FILE_NAME) return; - contentCache.delete(cacheKey); - matcherCache.delete(cacheKey); - for (const [rootPath, cached] of rulesCache) { - if (cached.ignoreFiles.some((file) => normalizePath(file.path) === cacheKey)) { - rulesCache.delete(rootPath); - } - } - } else { - contentCache.clear(); - matcherCache.clear(); - rulesCache.clear(); - } - - for (const listener of cacheListeners) { - listener(path); - } -} - -export function subscribeToFileTreeGitIgnoreCacheInvalidation( - listener: GitIgnoreCacheListener, -): () => void { - cacheListeners.add(listener); - return () => cacheListeners.delete(listener); -} - -function hasSameIgnoreFiles( - left: readonly GitIgnoreFileContent[], - right: readonly GitIgnoreFileContent[], -): boolean { - return ( - left.length === right.length && - left.every( - (file, index) => - file.path === right[index]?.path && - file.directoryPath === right[index]?.directoryPath && - file.content === right[index]?.content, - ) - ); -} - -export function createFileTreeGitIgnoreRules( - rootFolderPath: string | undefined, - ignoreFiles: GitIgnoreFileContent[], -): FileTreeGitIgnoreRules | null { - if (!rootFolderPath || ignoreFiles.length === 0) return null; - - const ruleSets = ignoreFiles - .filter((file) => pathStartsWithRoot(file.directoryPath, rootFolderPath)) - .sort(compareIgnoreReferences) - .map((file) => ({ - directoryPath: file.directoryPath, - matcher: getCachedIgnoreMatcher(file), - })); - - if (ruleSets.length === 0) return null; - - return { - rootFolderPath, - ruleSets, - }; -} - -export function getCachedFileTreeGitIgnoreRules( - rootFolderPath: string | undefined, - ignoreFiles: GitIgnoreFileContent[], -): FileTreeGitIgnoreRules | null { - if (!rootFolderPath) return null; - - const cacheKey = normalizePath(stripTrailingPathSeparators(rootFolderPath)); - const cached = rulesCache.get(cacheKey); - if (cached && hasSameIgnoreFiles(cached.ignoreFiles, ignoreFiles)) { - return cached.rules; - } - - const rules = createFileTreeGitIgnoreRules(rootFolderPath, ignoreFiles); - rulesCache.set(cacheKey, { ignoreFiles: [...ignoreFiles], rules }); - trimOldestCacheEntries(rulesCache, MAX_CACHED_GITIGNORE_ROOTS); - return rules; -} - -function toMatcherPath(fullPath: string, directoryPath: string, isDir: boolean): string | null { - if (!pathStartsWithRoot(fullPath, directoryPath)) return null; - - let relative = getRelativePath(fullPath, directoryPath); - if (!relative || relative.trim() === "") return null; - - relative = normalizePath(relative); - if (isDir && !relative.endsWith("/")) { - relative += "/"; - } - - return relative; -} - -function isPathIgnoredByOwnRules( - rules: FileTreeGitIgnoreRules | null, - fullPath: string, - isDir: boolean, -): boolean { - if (!rules || !pathStartsWithRoot(fullPath, rules.rootFolderPath)) return false; - - let rootRelative = getRelativePath(fullPath, rules.rootFolderPath); - if (!rootRelative || rootRelative.trim() === "") return false; - rootRelative = normalizePath(rootRelative); - if (rootRelative === ".git" || rootRelative === ".git/") return false; - - let ignored = false; - - for (const ruleSet of rules.ruleSets) { - const matcherPath = toMatcherPath(fullPath, ruleSet.directoryPath, isDir); - if (!matcherPath) continue; - - const result = ruleSet.matcher.test(matcherPath); - if (result.ignored) { - ignored = true; - } else if (result.unignored) { - ignored = false; - } - } - - return ignored; -} - -function getAncestorDirectoryPaths(fullPath: string, rootFolderPath: string): string[] { - const ancestors: string[] = []; - const normalizedRootPath = normalizePath(stripTrailingPathSeparators(rootFolderPath)); - let currentPath = getDirName(fullPath); - - while (currentPath && pathStartsWithRoot(currentPath, rootFolderPath)) { - if (normalizePath(stripTrailingPathSeparators(currentPath)) === normalizedRootPath) { - break; - } - - ancestors.unshift(currentPath); - currentPath = getDirName(currentPath); - } - - return ancestors; -} - -export function isPathGitIgnoredByFileTreeRules( - rules: FileTreeGitIgnoreRules | null, - fullPath: string, - isDir: boolean, -): boolean { - if (!rules || !pathStartsWithRoot(fullPath, rules.rootFolderPath)) return false; - - for (const ancestorPath of getAncestorDirectoryPaths(fullPath, rules.rootFolderPath)) { - if (isPathIgnoredByOwnRules(rules, ancestorPath, true)) { - return true; - } - } - - return isPathIgnoredByOwnRules(rules, fullPath, isDir); -} diff --git a/src/features/file-explorer/tests/file-explorer-tree.test.tsx b/src/features/file-explorer/tests/file-explorer-tree.test.tsx index 4a3316822..0c18539bc 100644 --- a/src/features/file-explorer/tests/file-explorer-tree.test.tsx +++ b/src/features/file-explorer/tests/file-explorer-tree.test.tsx @@ -25,19 +25,17 @@ const native = vi.hoisted(() => { return { directories: new Map>(), readDirectoryFails: false, - gitignore: "dist/\n", }; }); vi.mock("@/features/file-system/api/file-system-api", async (importOriginal) => ({ ...(await importOriginal()), - readFile: async (path: string) => { - if (path === "/repo/.gitignore") return native.gitignore; - throw new Error("missing"); - }, readDirectory: async (path: string) => { if (native.readDirectoryFails) throw new Error("EACCES"); - return native.directories.get(path) ?? []; + return (native.directories.get(path) ?? []).map((entry) => ({ + name: entry.path.split("/").pop(), + ...entry, + })); }, })); @@ -53,7 +51,7 @@ const file = (path: string, children?: FileEntry[]): FileEntry => ({ const initialTree = (): FileEntry[] => [ file("/repo", [ file("/repo/src", [file("/repo/src/index.ts"), file("/repo/src/util.ts")]), - file("/repo/dist", [file("/repo/dist/bundle.js")]), + { ...file("/repo/dist", [{ ...file("/repo/dist/bundle.js"), ignored: true }]), ignored: true }, file("/repo/.env"), file("/repo/.gitignore"), file("/repo/README.md"), @@ -173,7 +171,6 @@ beforeEach(() => { }); native.directories.clear(); native.readDirectoryFails = false; - native.gitignore = "dist/\n"; useFileClipboardStore.setState({ clipboard: null }); vi.spyOn(commands, "clipboardSet").mockResolvedValue(null as never); Element.prototype.getAnimations = () => []; @@ -521,10 +518,12 @@ describe("file explorer tree", () => { { path: "/repo/.DS_Store" }, { path: "/repo/debug.log" }, { path: "/repo/README.md" }, - { path: "" }, ]); native.directories.set("/repo/src", [{ path: "/repo/src/index.ts" }]); native.directories.set("/repo/dist", [{ path: "/repo/dist/bundle.js" }]); + vi.spyOn(commands, "localIgnoredEntryNames").mockImplementation(async (directory) => + directory === "/repo" ? ["dist"] : [], + ); await render(); await flush(); await openContextMenu("/repo"); diff --git a/src/features/file-explorer/tests/file-tree-gitignore.test.ts b/src/features/file-explorer/tests/file-tree-gitignore.test.ts deleted file mode 100644 index 7d2e21e71..000000000 --- a/src/features/file-explorer/tests/file-tree-gitignore.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { FileEntry } from "@/features/file-system/types/app.types"; -import { - collectGitIgnoreFileReferences, - createFileTreeGitIgnoreRules, - getCachedFileTreeGitIgnoreRules, - invalidateFileTreeGitIgnoreCache, - isPathGitIgnoredByFileTreeRules, - readFileTreeGitIgnoreContents, -} from "../services/file-tree-gitignore"; - -const dir = (name: string, path: string, children?: FileEntry[]): FileEntry => ({ - name, - path, - isDir: true, - children, -}); - -const file = (name: string, path: string): FileEntry => ({ - name, - path, - isDir: false, -}); - -describe("file tree gitignore rules", () => { - beforeEach(() => invalidateFileTreeGitIgnoreCache()); - - it("collects root and nested .gitignore files from the loaded tree", () => { - const references = collectGitIgnoreFileReferences( - [ - file(".gitignore", "/repo/.gitignore"), - dir("subprojects", "/repo/subprojects", [ - file(".gitignore", "/repo/subprojects/.gitignore"), - dir("nested", "/repo/subprojects/nested", [ - file(".gitignore", "/repo/subprojects/nested/.gitignore"), - ]), - ]), - file(".gitignore", "/other/.gitignore"), - ], - "/repo", - ); - - expect(references.map((reference) => reference.path)).toEqual([ - "/repo/.gitignore", - "/repo/subprojects/.gitignore", - "/repo/subprojects/nested/.gitignore", - ]); - }); - - it("applies nested .gitignore files relative to the directory that owns them", () => { - const rules = createFileTreeGitIgnoreRules("/repo", [ - { - path: "/repo/.gitignore", - directoryPath: "/repo", - content: "target/\n*.tmp\n", - }, - { - path: "/repo/subprojects/.gitignore", - directoryPath: "/repo/subprojects", - content: "/**/\npackagecache\nchumsky\n", - }, - ]); - - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/target", true)).toBe(true); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/src/file.tmp", false)).toBe(true); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/subprojects/zerocopy-0.8.48", true)).toBe( - true, - ); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/subprojects/packagecache", true)).toBe( - true, - ); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/zerocopy-0.8.48", true)).toBe(false); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/src/main.ts", false)).toBe(false); - }); - - it("lets lower .gitignore files unignore files ignored by parent rules", () => { - const rules = createFileTreeGitIgnoreRules("/repo", [ - { - path: "/repo/.gitignore", - directoryPath: "/repo", - content: "*.log\n", - }, - { - path: "/repo/logs/.gitignore", - directoryPath: "/repo/logs", - content: "!keep.log\n", - }, - ]); - - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/logs/error.log", false)).toBe(true); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/logs/keep.log", false)).toBe(false); - }); - - it("keeps files ignored when an ancestor directory is ignored", () => { - const rules = createFileTreeGitIgnoreRules("/repo", [ - { - path: "/repo/.gitignore", - directoryPath: "/repo", - content: "logs/\n", - }, - { - path: "/repo/logs/.gitignore", - directoryPath: "/repo/logs", - content: "!keep.log\n", - }, - ]); - - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/logs", true)).toBe(true); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/logs/keep.log", false)).toBe(true); - }); - - it("handles anchored nested patterns and directory-only patterns", () => { - const rules = createFileTreeGitIgnoreRules("/repo", [ - { - path: "/repo/sub/.gitignore", - directoryPath: "/repo/sub", - content: "/cache\nbuild/\n", - }, - ]); - - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/sub/cache", false)).toBe(true); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/sub/deep/cache", false)).toBe(false); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/sub/build", true)).toBe(true); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/sub/build.txt", false)).toBe(false); - }); - - it("supports Windows paths after normalizing matcher input", () => { - const rules = createFileTreeGitIgnoreRules("C:\\repo", [ - { - path: "C:\\repo\\sub\\.gitignore", - directoryPath: "C:\\repo\\sub", - content: "*.gen.ts\n", - }, - ]); - - expect(isPathGitIgnoredByFileTreeRules(rules, "C:\\repo\\sub\\client.gen.ts", false)).toBe( - true, - ); - expect(isPathGitIgnoredByFileTreeRules(rules, "C:\\repo\\other\\client.gen.ts", false)).toBe( - false, - ); - }); - - it("keeps valid rules when a .gitignore contains a malformed line", () => { - const rules = createFileTreeGitIgnoreRules("/repo", [ - { - path: "/repo/.gitignore", - directoryPath: "/repo", - content: "valid.out\nbad\\\n\\#literal-hash\n", - }, - ]); - - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/valid.out", false)).toBe(true); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/#literal-hash", false)).toBe(true); - expect(isPathGitIgnoredByFileTreeRules(rules, "/other/valid.out", false)).toBe(false); - }); - - it("does not treat the repository root or .git directory as ignored", () => { - const rules = createFileTreeGitIgnoreRules("/repo", [ - { - path: "/repo/.gitignore", - directoryPath: "/repo", - content: ".git\n*\n", - }, - ]); - - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo", true)).toBe(false); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/.git", true)).toBe(false); - expect(isPathGitIgnoredByFileTreeRules(rules, "/repo/file.txt", false)).toBe(true); - }); - - it("reuses file contents and parsed rules until the file is invalidated", async () => { - const references = [{ path: "/repo/.gitignore", directoryPath: "/repo" }]; - const read = vi.fn(async () => "dist/\n"); - - const firstContents = await readFileTreeGitIgnoreContents(references, read); - const secondContents = await readFileTreeGitIgnoreContents(references, read); - const firstRules = getCachedFileTreeGitIgnoreRules("/repo", firstContents); - const secondRules = getCachedFileTreeGitIgnoreRules("/repo", secondContents); - - expect(read).toHaveBeenCalledOnce(); - expect(secondRules).toBe(firstRules); - - invalidateFileTreeGitIgnoreCache("/repo/.gitignore"); - const refreshedContents = await readFileTreeGitIgnoreContents(references, read); - const refreshedRules = getCachedFileTreeGitIgnoreRules("/repo", refreshedContents); - - expect(read).toHaveBeenCalledTimes(2); - expect(refreshedRules).not.toBe(firstRules); - }); - - it("does not let an invalidated pending read repopulate the cache", async () => { - const references = [{ path: "/repo/.gitignore", directoryPath: "/repo" }]; - let resolveFirstRead: ((content: string) => void) | undefined; - const read = vi - .fn<(_: string) => Promise>() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirstRead = resolve; - }), - ) - .mockResolvedValueOnce("fresh/\n"); - - const staleRead = readFileTreeGitIgnoreContents(references, read); - invalidateFileTreeGitIgnoreCache("/repo/.gitignore"); - resolveFirstRead?.("stale/\n"); - expect(await staleRead).toEqual([]); - - const freshRead = await readFileTreeGitIgnoreContents(references, read); - expect(freshRead.map((file) => file.content)).toEqual(["fresh/\n"]); - expect(read).toHaveBeenCalledTimes(2); - }); - - it("retries a missing root ignore file after create invalidation", async () => { - const references = [{ path: "/repo/.gitignore", directoryPath: "/repo" }]; - const read = vi - .fn<(_: string) => Promise>() - .mockRejectedValueOnce(new Error("missing")) - .mockResolvedValueOnce("created/\n"); - - expect(await readFileTreeGitIgnoreContents(references, read)).toEqual([]); - expect(await readFileTreeGitIgnoreContents(references, read)).toEqual([]); - expect(read).toHaveBeenCalledOnce(); - - invalidateFileTreeGitIgnoreCache("/repo/.gitignore"); - expect(await readFileTreeGitIgnoreContents(references, read)).toHaveLength(1); - expect(read).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/features/file-explorer/tests/visible-file-tree-rows.test.ts b/src/features/file-explorer/tests/visible-file-tree-rows.test.ts index 63d1014d9..6ccf232f4 100644 --- a/src/features/file-explorer/tests/visible-file-tree-rows.test.ts +++ b/src/features/file-explorer/tests/visible-file-tree-rows.test.ts @@ -359,7 +359,6 @@ describe("collectFileTreeSearchHits", () => { describe("filterFileTreeEntries", () => { const baseOptions = { isAlwaysHidden: () => false, - isGitIgnored: () => false, isHiddenName: () => false, isUserHidden: () => false, showGitignoredFiles: true, @@ -387,28 +386,29 @@ describe("filterFileTreeEntries", () => { expect(result[0]!.children?.[0].children?.[0].children?.[0].children).toEqual([]); }); - test("decorates ignored entries without cloning unaffected siblings", () => { - const sibling = { - name: "package.json", - path: "/root/package.json", + test("keeps ignored entries as they are unless gitignored files are hidden", () => { + const ignoredSibling = { + name: "debug.log", + path: "/root/debug.log", isDir: false, + ignored: true, }; - const files = [{ ...tree[0]!, children: [...(tree[0]!.children ?? []), sibling] }]; - const result = filterFileTreeEntries(files, { - ...baseOptions, - isGitIgnored: (path) => path === sibling.path, - }); + const files = [{ ...tree[0]!, children: [...(tree[0]!.children ?? []), ignoredSibling] }]; - expect(result[0]).not.toBe(files[0]); - expect(result[0]!.children?.[0]).toBe(files[0]!.children?.[0]); - expect(result[0]!.children?.[1]).toEqual({ ...sibling, ignored: true, children: undefined }); + expect(filterFileTreeEntries(files, baseOptions)).toBe(files); + + const hidden = filterFileTreeEntries(files, { ...baseOptions, showGitignoredFiles: false }); + expect(hidden[0]!.children?.map((child) => child.path)).toEqual( + tree[0]!.children?.map((child) => child.path), + ); + expect(hidden[0]!.children?.[0]).toBe(files[0]!.children?.[0]); }); test("filters only the directories that changed when given a cache", () => { const checkedPaths: string[] = []; const options = { ...baseOptions, - isGitIgnored: (path: string) => { + isUserHidden: (path: string) => { checkedPaths.push(path); return false; }, diff --git a/src/features/file-system/api/file-system-api.ts b/src/features/file-system/api/file-system-api.ts index da58ca580..08a09d9b7 100644 --- a/src/features/file-system/api/file-system-api.ts +++ b/src/features/file-system/api/file-system-api.ts @@ -2,7 +2,7 @@ import { homeDir } from "@tauri-apps/api/path"; import { open } from "@tauri-apps/plugin-dialog"; import { commands } from "@/bindings/commands"; import { useLinuxFolderPickerStore } from "@/features/file-system/stores/linux-folder-picker.store"; -import { invalidateFileTreeGitIgnoreCache } from "@/features/file-explorer/services/file-tree-gitignore"; +import { notifyIgnoreRuleFileChanged } from "@/features/file-system/services/ignore-rule-changes"; import { parseWslPath } from "@/features/wsl/services/wsl-path"; import { stripTrailingPathSeparators } from "@/utils/path-helpers"; import { IS_LINUX } from "@/utils/platform"; @@ -65,12 +65,12 @@ export async function writeFile(path: string, content: string): Promise { const wslInfo = parseWslPath(path); if (wslInfo) { await commands.wslWriteFile(wslInfo.distro, wslInfo.linuxPath, content); - invalidateFileTreeGitIgnoreCache(path); + notifyIgnoreRuleFileChanged(path); return; } await commands.writeLocalFile(path, content); - invalidateFileTreeGitIgnoreCache(path); + notifyIgnoreRuleFileChanged(path); } /** diff --git a/src/features/file-system/api/ignored-entries.ts b/src/features/file-system/api/ignored-entries.ts new file mode 100644 index 000000000..6120f544b --- /dev/null +++ b/src/features/file-system/api/ignored-entries.ts @@ -0,0 +1,61 @@ +import { commands } from "@/bindings/commands"; +import { parseRemotePath } from "@/features/remote/services/remote-path"; +import { parseWslPath } from "@/features/wsl/services/wsl-path"; +import { logger } from "@/utils/logger"; +import type { FileEntry } from "../types/app.types"; + +const NO_IGNORED_NAMES: ReadonlySet = new Set(); + +/** + * Names of the entries of a local or WSL directory that the workspace's ignore files exclude. + * A remote directory, or one whose ignore files cannot be read, is listed as if nothing in it + * were ignored. + */ +export async function readIgnoredEntryNames( + directoryPath: string, + workspaceRoot: string, +): Promise> { + if (parseRemotePath(directoryPath)) return NO_IGNORED_NAMES; + try { + const wslDirectory = parseWslPath(directoryPath); + if (!wslDirectory) { + return new Set(await commands.localIgnoredEntryNames(directoryPath, workspaceRoot)); + } + + const wslRoot = parseWslPath(workspaceRoot); + if (wslRoot?.distro !== wslDirectory.distro) return NO_IGNORED_NAMES; + return new Set( + await commands.wslIgnoredEntryNames( + wslDirectory.distro, + wslDirectory.linuxPath, + wslRoot.linuxPath, + ), + ); + } catch (error) { + logger.debug("FileSystem", `Could not read ignore rules for ${directoryPath}`, error); + return NO_IGNORED_NAMES; + } +} + +export function markIgnoredEntries( + entries: FileEntry[], + ignoredNames: ReadonlySet, +): FileEntry[] { + if (ignoredNames.size === 0) return entries; + return entries.map((entry) => + ignoredNames.has(entry.name) ? { ...entry, ignored: true } : entry, + ); +} + +/** Marks the entries of `directoryPath` the file tree shows dimmed or hides as ignored. */ +export async function markIgnoredEntriesIn( + directoryPath: string, + workspaceRoot: string, + entries: Promise, +): Promise { + const [listed, ignoredNames] = await Promise.all([ + entries, + readIgnoredEntryNames(directoryPath, workspaceRoot), + ]); + return markIgnoredEntries(listed, ignoredNames); +} diff --git a/src/features/file-system/controllers/directory-contents.ts b/src/features/file-system/controllers/directory-contents.ts index b2ad34d2c..47aac9767 100644 --- a/src/features/file-system/controllers/directory-contents.ts +++ b/src/features/file-system/controllers/directory-contents.ts @@ -1,4 +1,5 @@ import type { FileEntry } from "../types/app.types"; +import { pathStartsWithRoot } from "@/utils/path-helpers"; import { isPathInsideTreeEntry, sortFileEntries } from "../services/file-tree-utils"; const OS_GENERATED_FILE_PATTERNS: string[] = [ @@ -224,6 +225,7 @@ export function updateDirectoryContents( children: existingChild?.children || undefined, // Refreshes keep links marked as links, so their icon and target tooltip survive. ...(isSymlink ? { isSymlink: true, symlinkTarget: symlinkTarget ?? undefined } : {}), + ...(entry.ignored ? { ignored: true } : {}), }; }; @@ -238,7 +240,7 @@ export function updateDirectoryContents( existingChild.children === nextEntry.children && existingChild.isEditing === undefined && existingChild.isNewItem === undefined && - existingChild.ignored === undefined && + existingChild.ignored === nextEntry.ignored && existingChild.isRenaming === undefined && existingChild.isSymlink === nextEntry.isSymlink && existingChild.symlinkTarget === nextEntry.symlinkTarget @@ -281,3 +283,18 @@ export function updateDirectoryContents( } return "missing"; } + +/** Directories at or under `directoryPath` whose children are loaded in the tree. */ +export function collectLoadedDirectoryPaths(files: FileEntry[], directoryPath: string): string[] { + const paths: string[] = []; + const visit = (entries: FileEntry[]) => { + for (const entry of entries) { + if (!entry.isDir || !entry.children) continue; + const isInside = pathStartsWithRoot(entry.path, directoryPath); + if (isInside) paths.push(entry.path); + if (isInside || pathStartsWithRoot(directoryPath, entry.path)) visit(entry.children); + } + }; + visit(files); + return paths; +} diff --git a/src/features/file-system/services/file-watcher-listener.ts b/src/features/file-system/services/file-watcher-listener.ts index 7b9e14a58..45d3183ae 100644 --- a/src/features/file-system/services/file-watcher-listener.ts +++ b/src/features/file-system/services/file-watcher-listener.ts @@ -7,16 +7,22 @@ import { import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getBufferByPath } from "@/features/editor/stores/buffer-index"; import { emitGitChanged } from "@/features/git/events/git-events"; -import { invalidateFileTreeGitIgnoreCache } from "@/features/file-explorer/services/file-tree-gitignore"; import { showToast } from "@/utils/toast"; import { workspaceRuntimeRegistry } from "@/features/workspace/services/workspace-runtime-registry"; import { readFileContent } from "../api/file-operations"; +import { collectLoadedDirectoryPaths } from "../controllers/directory-contents"; +import type { FileEntry } from "../types/app.types"; import { useFileSystemStore } from "../stores/file-system.store"; import { useFileWatcherStore } from "../stores/file-watcher.store"; import { cancelFileWatcherRefreshes, scheduleFileWatcherRefresh, } from "./file-watcher-refresh-scheduler"; +import { + notifyIgnoreRuleFileChanged, + notifyIgnoreRulesChanged, + subscribeToIgnoreRuleChanges, +} from "./ignore-rule-changes"; import { getDirName } from "@/utils/path-helpers"; import { readBufferText } from "@/features/editor/services/buffer-text"; import { emitAppEvent } from "@/utils/app-events"; @@ -31,6 +37,7 @@ interface FileChangeEvent { let unlistenFileChanged: UnlistenFn | null = null; let unlistenWorkspaceChanges: UnlistenFn | null = null; +let unsubscribeIgnoreRuleChanges: (() => void) | null = null; const EVENT_TYPE_BY_KIND: Record = { created: "opened", @@ -67,6 +74,17 @@ function scheduleDirectoryRefresh(workspaceId: string, directoryPath: string) { }); } +/** Re-lists every loaded directory under `directoryPath`, so their ignored entries are current. */ +function refreshIgnoredEntries(directoryPath: string) { + for (const { workspaceId, store } of workspaceRuntimeRegistry.getExistingStoreEntries<{ + files: FileEntry[]; + }>("file-system")) { + for (const path of collectLoadedDirectoryPaths(store.getState().files, directoryPath)) { + scheduleDirectoryRefresh(workspaceId, path); + } + } +} + /** * Brings an open editor up to date with a file that changed on disk. A clean buffer reloads. A * buffer with unsaved changes is never overwritten: if the disk now matches it (an agent wrote @@ -129,7 +147,7 @@ async function applyFileChange( { path, event_type, agent_write_id }: FileChangeEvent, workspaceId: string, ): Promise { - invalidateFileTreeGitIgnoreCache(path); + notifyIgnoreRuleFileChanged(path); emitAppEvent("file:external-change", { path, agentWriteId: agent_write_id }); @@ -173,8 +191,8 @@ export async function handleWorkspaceFileChanges(batch: WorkspaceFileChanges) { const workspaceId = findWorkspaceForRoot(batch.root); if (batch.rescan) { - invalidateFileTreeGitIgnoreCache(batch.root); scheduleDirectoryRefresh(workspaceId, batch.root); + notifyIgnoreRulesChanged(batch.root); } const outcomes = await Promise.all( @@ -206,6 +224,8 @@ export async function handleWorkspaceFileChanges(batch: WorkspaceFileChanges) { export async function initializeFileWatcherListener() { await cleanupFileWatcherListener(); + unsubscribeIgnoreRuleChanges = subscribeToIgnoreRuleChanges(refreshIgnoredEntries); + // Agent writes still report each file on its own; the project watcher sends batches. unlistenFileChanged = await listen("file-changed", (event) => handleFileChange(event.payload), @@ -218,6 +238,8 @@ export async function initializeFileWatcherListener() { export async function cleanupFileWatcherListener() { cancelFileWatcherRefreshes(); + unsubscribeIgnoreRuleChanges?.(); + unsubscribeIgnoreRuleChanges = null; for (const unlisten of [unlistenFileChanged, unlistenWorkspaceChanges]) { try { diff --git a/src/features/file-system/services/ignore-rule-changes.ts b/src/features/file-system/services/ignore-rule-changes.ts new file mode 100644 index 000000000..18ec5d2a2 --- /dev/null +++ b/src/features/file-system/services/ignore-rule-changes.ts @@ -0,0 +1,23 @@ +import { getBaseName, getDirName } from "@/utils/path-helpers"; + +type IgnoreRuleChangeListener = (directoryPath: string) => void; + +const IGNORE_RULE_FILE_NAMES = new Set([".gitignore", ".ignore"]); +const listeners = new Set(); + +/** Reports that which entries under `directoryPath` are ignored may have changed. */ +export function notifyIgnoreRulesChanged(directoryPath: string) { + for (const listener of listeners) listener(directoryPath); +} + +/** Reports a write, delete, or external change of `path` when it is an ignore file. */ +export function notifyIgnoreRuleFileChanged(path: string) { + if (IGNORE_RULE_FILE_NAMES.has(getBaseName(path, ""))) { + notifyIgnoreRulesChanged(getDirName(path)); + } +} + +export function subscribeToIgnoreRuleChanges(listener: IgnoreRuleChangeListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} diff --git a/src/features/file-system/services/workspace-resource-provider.ts b/src/features/file-system/services/workspace-resource-provider.ts index 2f90267c5..c4c593517 100644 --- a/src/features/file-system/services/workspace-resource-provider.ts +++ b/src/features/file-system/services/workspace-resource-provider.ts @@ -1,9 +1,10 @@ import { readFile as readLocalFileBytes } from "@tauri-apps/plugin-fs"; import { commands } from "@/bindings/commands"; -import { invalidateFileTreeGitIgnoreCache } from "@/features/file-explorer/services/file-tree-gitignore"; import { buildRemoteEntryPath, parseRemotePath } from "@/features/remote/services/remote-path"; import { parseWslPath } from "@/features/wsl/services/wsl-path"; import { readDirectoryContents, readFileContent } from "../api/file-operations"; +import { markIgnoredEntriesIn } from "../api/ignored-entries"; +import { notifyIgnoreRuleFileChanged } from "./ignore-rule-changes"; import { sortFileEntries } from "./file-tree-utils"; import type { FileEntry } from "../types/app.types"; @@ -41,7 +42,7 @@ const localWorkspaceResourceProvider: WorkspaceResourceProvider = { expectedContent === null ? null : await digestText(expectedContent), content, ); - invalidateFileTreeGitIgnoreCache(path); + notifyIgnoreRuleFileChanged(path); return; } const { writeFile } = await import("../api/file-system-api"); @@ -49,7 +50,7 @@ const localWorkspaceResourceProvider: WorkspaceResourceProvider = { }, async deleteText(path, expectedContent) { await commands.deleteLocalFileChecked(path, expectedContent); - invalidateFileTreeGitIgnoreCache(path); + notifyIgnoreRuleFileChanged(path); }, readBytes: readLocalFileBytes, }; @@ -95,7 +96,6 @@ const remoteWorkspaceResourceProvider: WorkspaceResourceProvider = { expectedContent, ); } - invalidateFileTreeGitIgnoreCache(path); }, async deleteText(path, expectedContent) { const remoteInfo = parseRemotePath(path); @@ -105,7 +105,6 @@ const remoteWorkspaceResourceProvider: WorkspaceResourceProvider = { remoteInfo.remotePath, expectedContent, ); - invalidateFileTreeGitIgnoreCache(path); }, async readBytes() { return null; @@ -152,13 +151,13 @@ const wslWorkspaceResourceProvider: WorkspaceResourceProvider = { expectedContent, ); } - invalidateFileTreeGitIgnoreCache(path); + notifyIgnoreRuleFileChanged(path); }, async deleteText(path, expectedContent) { const wslInfo = parseWslPath(path); if (!wslInfo) throw new Error(`Invalid WSL workspace path: ${path}`); await commands.wslDeleteFileChecked(wslInfo.distro, wslInfo.linuxPath, expectedContent); - invalidateFileTreeGitIgnoreCache(path); + notifyIgnoreRuleFileChanged(path); }, async readBytes(path) { const wslInfo = parseWslPath(path); @@ -183,6 +182,15 @@ export function getWorkspaceResourceProvider(path: string): WorkspaceResourcePro return localWorkspaceResourceProvider; } +/** + * Lists a directory for the file tree, with the entries the workspace's ignore files exclude + * marked. Searches and context readers call a provider's `readDirectory` directly instead: they + * apply ignore rules themselves and should not pay for this lookup. + */ export function readWorkspaceDirectoryEntries(path: string, workspaceRoot = path) { - return getWorkspaceResourceProvider(path).readDirectory(path, workspaceRoot); + return markIgnoredEntriesIn( + path, + workspaceRoot, + getWorkspaceResourceProvider(path).readDirectory(path, workspaceRoot), + ); } diff --git a/src/features/file-system/stores/file-system.store.ts b/src/features/file-system/stores/file-system.store.ts index 9f90f2960..21483cb50 100644 --- a/src/features/file-system/stores/file-system.store.ts +++ b/src/features/file-system/stores/file-system.store.ts @@ -14,7 +14,6 @@ import { detectLanguageFromFileName } from "@/features/editor/services/language- import { getBufferByPath } from "@/features/editor/stores/buffer-index"; import { fileOpenBenchmark } from "@/features/editor/services/file-open-benchmark"; import { getLineSlice } from "@/features/editor/services/large-file"; -import { invalidateFileTreeGitIgnoreCache } from "@/features/file-explorer/services/file-tree-gitignore"; import { getAncestorDirectoryPaths } from "@/features/file-explorer/services/file-explorer-tree-utils"; import { useFileTreeStore } from "@/features/file-explorer/stores/file-explorer-tree.store"; import { getGitStatus } from "@/features/git/api/git-status-api"; @@ -70,6 +69,11 @@ import type { FileEntry } from "../types/app.types"; import type { FsActions, FsState } from "../types/interface.types"; import { getFileEntryNameError, parseNewEntryPath } from "../utils/file-entry-name"; import { readDirectoryContents } from "../api/file-operations"; +import { + markIgnoredEntries, + markIgnoredEntriesIn, + readIgnoredEntryNames, +} from "../api/ignored-entries"; import { addFileToTree, findFileInTree, @@ -97,6 +101,10 @@ import { inspectFileOpenResource, readFileOpenText, } from "../services/file-open-resource"; +import { + notifyIgnoreRuleFileChanged, + notifyIgnoreRulesChanged, +} from "../services/ignore-rule-changes"; import { restoreWorkspaceSessionBuffer } from "../services/workspace-session-buffer-restore"; import { restoreWorkspaceSessionFolders } from "../services/workspace-session-folder-restore"; import { prepareWorkspaceClose } from "../services/workspace-close-guard"; @@ -251,6 +259,16 @@ const syncFffWorkspace = async (workspaceId: string): Promise => { } }; +// The watcher drops events for ignored paths, so no later refresh would set this flag. +const markIgnoredIfExcluded = async ( + entry: FileEntry, + parentPath: string, + workspaceRoot: string, +): Promise => { + const ignoredNames = await readIgnoredEntryNames(parentPath, workspaceRoot); + return markIgnoredEntries([entry], ignoredNames)[0]; +}; + const readWorkspaceRootEntry = async (path: string): Promise => { const projectName = getFolderName(path); const entries = await readWorkspaceDirectoryEntries(path); @@ -506,7 +524,7 @@ const openLocalWorkspace = async ( const projectName = getFolderName(path); const readDirectoryStartedAt = performance.now(); logWorkspaceOpenStep("start", "readDirectoryContents", path); - const entries = await readDirectoryContents(path); + const entries = await markIgnoredEntriesIn(path, path, readDirectoryContents(path)); logWorkspaceOpenStep("end", "readDirectoryContents", path, readDirectoryStartedAt); return { @@ -1986,6 +2004,7 @@ const createFileSystemStore = (workspaceId: string): StoreApi { @@ -1995,8 +2014,8 @@ const createFileSystemStore = (workspaceId: string): StoreApi { state.files = addFileToTree(state.files, directoryPath, newFile); @@ -2232,12 +2247,11 @@ const createFileSystemStore = (workspaceId: string): StoreApi { state.files = addFileToTree(state.files, parentPath, newFolder); @@ -2258,7 +2272,7 @@ const createFileSystemStore = (workspaceId: string): StoreApi { diff --git a/src/features/file-system/tests/file-operations.test.ts b/src/features/file-system/tests/file-operations.test.ts index 237baff3b..ca8921a4a 100644 --- a/src/features/file-system/tests/file-operations.test.ts +++ b/src/features/file-system/tests/file-operations.test.ts @@ -2,11 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const readDirectoryMock = vi.fn(); const getSymlinkInfoMock = vi.fn(); -const { localPathExistsMock, writeFileMock } = vi.hoisted(() => ({ +const { invokeMock, localPathExistsMock, writeFileMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), localPathExistsMock: vi.fn(), writeFileMock: vi.fn(), })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + vi.mock("../api/file-system-api", () => ({ createDirectory: vi.fn(), deletePath: vi.fn(), @@ -29,6 +32,19 @@ describe("file operations", () => { beforeEach(() => { readDirectoryMock.mockReset(); getSymlinkInfoMock.mockReset(); + invokeMock.mockReset().mockResolvedValue([]); + }); + + it("lists a directory without reading its ignore rules", async () => { + readDirectoryMock.mockResolvedValue([ + { name: "dist", path: "/workspace/dist", is_dir: true, is_symlink: false }, + ]); + const { readDirectoryContents } = await import("../api/file-operations"); + + await expect(readDirectoryContents("/workspace")).resolves.toEqual([ + { name: "dist", path: "/workspace/dist", isDir: true, children: undefined }, + ]); + expect(invokeMock).not.toHaveBeenCalledWith("local_ignored_entry_names", expect.anything()); }); it("uses readDir symlink metadata instead of probing every entry", async () => { diff --git a/src/features/file-system/tests/file-system-store.test.ts b/src/features/file-system/tests/file-system-store.test.ts index 1050d7d82..aa3a90dcc 100644 --- a/src/features/file-system/tests/file-system-store.test.ts +++ b/src/features/file-system/tests/file-system-store.test.ts @@ -28,6 +28,7 @@ vi.hoisted(() => { const mocks = vi.hoisted(() => ({ readWorkspaceDirectoryEntries: vi.fn(), + readIgnoredEntryNames: vi.fn<() => Promise>>(), mutations: { kind: "local" as const, createFile: vi.fn(), @@ -76,6 +77,10 @@ vi.mock("../services/workspace-resource-provider", async (importOriginal) => ({ ...(await importOriginal()), readWorkspaceDirectoryEntries: mocks.readWorkspaceDirectoryEntries, })); +vi.mock("../api/ignored-entries", async (importOriginal) => ({ + ...(await importOriginal()), + readIgnoredEntryNames: mocks.readIgnoredEntryNames, +})); vi.mock("../services/workspace-entry-mutation-provider", () => ({ getWorkspaceEntryMutationProvider: () => mocks.mutations, })); @@ -142,6 +147,7 @@ beforeEach(() => { vi.spyOn(toast, "success").mockImplementation(() => ""); vi.spyOn(toast, "warning").mockImplementation(() => ""); mocks.readFileOpenText.mockResolvedValue(""); + mocks.readIgnoredEntryNames.mockResolvedValue(new Set()); mocks.ensureWorkspaceFileSearch.mockImplementation(async (paths: string[]) => paths); }); @@ -325,6 +331,26 @@ describe("creating entries", () => { expect(getBufferByPath(buffers().buffers, `${root}/src/utils/math.ts`)).toBeDefined(); }); + it("marks a new file or folder ignored when the ignore files exclude it", async () => { + setWorkspace([dir(`${root}/src`, [])]); + mocks.mutations.createDirectory.mockImplementation( + async (parent: string, name: string) => `${parent}/${name}`, + ); + mocks.mutations.createFile.mockImplementation( + async (parent: string, name: string) => `${parent}/${name}`, + ); + mocks.readIgnoredEntryNames.mockResolvedValue(new Set(["debug.log", "logs"])); + + await fs().createFile(`${root}/src`, "debug.log"); + await fs().createFile(`${root}/src`, "main.ts"); + await fs().createDirectory(`${root}/src`, "logs"); + + expect(mocks.readIgnoredEntryNames).toHaveBeenCalledWith(`${root}/src`, root); + expect(entry(`${root}/src/debug.log`)?.ignored).toBe(true); + expect(entry(`${root}/src/main.ts`)?.ignored).toBeUndefined(); + expect(entry(`${root}/src/logs`)?.ignored).toBe(true); + }); + it.each(["../escape.ts", "src/../../escape.ts", "./a.ts", "a\\b.ts"])( "rejects the unsafe file name %s", async (name) => { @@ -955,4 +981,23 @@ describe("file tree edge cases", () => { expect(entry(`${root}/link`)).toMatchObject({ isSymlink: true, symlinkTarget: "../elsewhere" }); }); + + it("updates which entries are ignored when a folder is refreshed", async () => { + setWorkspace([file(`${root}/debug.log`), file(`${root}/notes.md`)]); + mocks.readWorkspaceDirectoryEntries.mockResolvedValue([ + { ...file(`${root}/debug.log`), ignored: true }, + file(`${root}/notes.md`), + ]); + + await fs().refreshDirectory(root); + expect(entry(`${root}/debug.log`)?.ignored).toBe(true); + expect(entry(`${root}/notes.md`)?.ignored).toBeUndefined(); + + mocks.readWorkspaceDirectoryEntries.mockResolvedValue([ + file(`${root}/debug.log`), + file(`${root}/notes.md`), + ]); + await fs().refreshDirectory(root); + expect(entry(`${root}/debug.log`)?.ignored).toBeUndefined(); + }); }); diff --git a/src/features/file-system/tests/file-system-workspace-lifecycle.test.ts b/src/features/file-system/tests/file-system-workspace-lifecycle.test.ts index ff5c40213..4cbad17a0 100644 --- a/src/features/file-system/tests/file-system-workspace-lifecycle.test.ts +++ b/src/features/file-system/tests/file-system-workspace-lifecycle.test.ts @@ -347,6 +347,22 @@ describe("remote and WSL workspaces", () => { expect(tabs().projectTabs).toEqual([]); }); + it("marks the root entries the workspace's ignore files exclude", async () => { + vi.spyOn(commands, "localIgnoredEntryNames").mockResolvedValue(["README.md"]); + + await fs().handleOpenFolderByPath("/work/app"); + + expect(commands.localIgnoredEntryNames).toHaveBeenCalledWith("/work/app", "/work/app"); + const [rootEntry] = fs().files; + expect( + rootEntry.children?.map(({ name, ignored }) => ({ name, ignored: ignored ?? false })), + ).toEqual([ + { name: ".git", ignored: false }, + { name: "src", ignored: false }, + { name: "README.md", ignored: true }, + ]); + }); + it("opens WSL paths through the WSL reader and records them as recents", async () => { vi.spyOn(commands, "wslReadDirectory").mockResolvedValue([ { name: "project", path: "/home/me/project", is_dir: true }, diff --git a/src/features/file-system/tests/file-watcher-listener.test.ts b/src/features/file-system/tests/file-watcher-listener.test.ts index 145ea1a55..02ef3fe42 100644 --- a/src/features/file-system/tests/file-watcher-listener.test.ts +++ b/src/features/file-system/tests/file-watcher-listener.test.ts @@ -1,6 +1,12 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { handleFileChange, handleWorkspaceFileChanges } from "../services/file-watcher-listener"; +import type { FileEntry } from "../types/app.types"; +import { + cleanupFileWatcherListener, + handleFileChange, + handleWorkspaceFileChanges, + initializeFileWatcherListener, +} from "../services/file-watcher-listener"; const mocks = vi.hoisted(() => ({ buffers: [] as Array>, @@ -13,6 +19,7 @@ const mocks = vi.hoisted(() => ({ pendingSaves: new Set(), refreshedWorkspaces: [] as string[], workspaceRoots: [] as Array<{ workspaceId: string; rootFolderPath: string }>, + loadedFiles: [] as FileEntry[], })); vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); @@ -38,11 +45,13 @@ vi.mock("@/features/workspace/services/workspace-runtime-registry", () => ({ workspaceRuntimeRegistry: { getActiveWorkspaceId: () => "ws", hasWorkspace: () => true, - getExistingStoreEntries: () => - mocks.workspaceRoots.map(({ workspaceId, rootFolderPath }) => ({ - workspaceId, - store: { getState: () => ({ rootFolderPath, workspaceFolders: [] }) }, - })), + getExistingStoreEntries: (key: string) => + key === "file-system" + ? [{ workspaceId: "ws", store: { getState: () => ({ files: mocks.loadedFiles }) } }] + : mocks.workspaceRoots.map(({ workspaceId, rootFolderPath }) => ({ + workspaceId, + store: { getState: () => ({ rootFolderPath, workspaceFolders: [] }) }, + })), }, })); vi.mock("../api/file-operations", () => ({ @@ -85,6 +94,7 @@ describe("file watcher listener", () => { mocks.pendingSaves.clear(); mocks.refreshedWorkspaces = []; mocks.workspaceRoots = []; + mocks.loadedFiles = []; for (const mock of [ mocks.reloadBufferFromDisk, mocks.markBufferDirty, @@ -278,6 +288,37 @@ describe("file watcher listener", () => { ); }); + it("re-lists the loaded folders under a changed ignore file", async () => { + const folder = (path: string, children?: FileEntry[]): FileEntry => ({ + name: path.slice(path.lastIndexOf("/") + 1), + path, + isDir: true, + children, + }); + mocks.loadedFiles = [ + folder("/repo", [ + folder("/repo/web", [folder("/repo/web/src", []), folder("/repo/web/dist")]), + folder("/repo/docs", []), + ]), + ]; + + await initializeFileWatcherListener(); + try { + await deliver({ + root: "/repo", + changes: [{ path: "/repo/web/.gitignore", kind: "modified", is_dir: false }], + git_changed: false, + rescan: false, + }); + } finally { + await cleanupFileWatcherListener(); + } + + expect(mocks.refreshDirectory).toHaveBeenCalledTimes(2); + expect(mocks.refreshDirectory).toHaveBeenCalledWith("/repo/web"); + expect(mocks.refreshDirectory).toHaveBeenCalledWith("/repo/web/src"); + }); + it("re-reads the whole root after a rescan", async () => { await deliver({ root: "/repo", changes: [], git_changed: false, rescan: true }); diff --git a/src/features/file-system/tests/platform-text-writes.test.ts b/src/features/file-system/tests/platform-text-writes.test.ts index f6704b6f7..a897a004b 100644 --- a/src/features/file-system/tests/platform-text-writes.test.ts +++ b/src/features/file-system/tests/platform-text-writes.test.ts @@ -11,8 +11,8 @@ vi.mock("@tauri-apps/plugin-fs", () => ({ remove: vi.fn(), writeTextFile: mocks.writeTextFile, })); -vi.mock("@/features/file-explorer/services/file-tree-gitignore", () => ({ - invalidateFileTreeGitIgnoreCache: mocks.invalidate, +vi.mock("@/features/file-system/services/ignore-rule-changes", () => ({ + notifyIgnoreRuleFileChanged: mocks.invalidate, })); describe("platform text writes", () => { diff --git a/src/features/file-system/tests/workspace-resource-provider.test.ts b/src/features/file-system/tests/workspace-resource-provider.test.ts index e1b2fbd46..c7af181bd 100644 --- a/src/features/file-system/tests/workspace-resource-provider.test.ts +++ b/src/features/file-system/tests/workspace-resource-provider.test.ts @@ -35,6 +35,52 @@ describe("workspace resource provider", () => { expect(readDirectoryContents).toHaveBeenCalledWith("/workspace", "/workspace"); }); + it("marks the local entries the workspace's ignore files exclude", async () => { + readDirectoryContents.mockResolvedValue([ + { name: "dist", path: "/workspace/web/dist", isDir: true }, + { name: "src", path: "/workspace/web/src", isDir: true }, + ]); + invoke.mockResolvedValue(["dist"]); + + const entries = await readWorkspaceDirectoryEntries("/workspace/web", "/workspace"); + + expect(invoke).toHaveBeenCalledWith("local_ignored_entry_names", { + directory: "/workspace/web", + workspaceRoot: "/workspace", + }); + expect(entries.map(({ name, ignored }) => ({ name, ignored }))).toEqual([ + { name: "dist", ignored: true }, + { name: "src", ignored: undefined }, + ]); + }); + + it("lists a directory whose ignore rules cannot be read", async () => { + readDirectoryContents.mockResolvedValue([ + { name: "dist", path: "/workspace/dist", isDir: true }, + ]); + invoke.mockRejectedValue(new Error("Permission denied")); + + await expect(readWorkspaceDirectoryEntries("/workspace")).resolves.toEqual([ + { name: "dist", path: "/workspace/dist", isDir: true }, + ]); + }); + + it("lists directories for search and context readers without reading ignore rules", async () => { + readDirectoryContents.mockResolvedValue([]); + invoke.mockResolvedValue([]); + + await getWorkspaceResourceProvider("/workspace").readDirectory("/workspace", "/workspace"); + await getWorkspaceResourceProvider("wsl://Ubuntu/home/me/app").readDirectory( + "wsl://Ubuntu/home/me/app", + "wsl://Ubuntu/home/me", + ); + await readWorkspaceDirectoryEntries("remote://connection-1/repo"); + + const commandsCalled = invoke.mock.calls.map(([command]) => command); + expect(commandsCalled).not.toContain("local_ignored_entry_names"); + expect(commandsCalled).not.toContain("wsl_ignored_entry_names"); + }); + it("reads local text and bytes through the existing filesystem adapters", async () => { readFileContent.mockResolvedValue("local text"); readLocalFileBytes.mockResolvedValue(Uint8Array.from([108, 111, 99, 97, 108])); @@ -134,6 +180,31 @@ describe("workspace resource provider", () => { }); }); + it("marks WSL entries the workspace's ignore files exclude", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "wsl_ignored_entry_names") return ["dist"]; + return [ + { name: "dist", path: "wsl://Ubuntu/home/me/app/dist", is_dir: true, size: 0 }, + { name: "src", path: "wsl://Ubuntu/home/me/app/src", is_dir: true, size: 0 }, + ]; + }); + + const entries = await readWorkspaceDirectoryEntries( + "wsl://Ubuntu/home/me/app", + "wsl://Ubuntu/home/me", + ); + + expect(entries.map(({ name, ignored }) => ({ name, ignored }))).toEqual([ + { name: "dist", ignored: true }, + { name: "src", ignored: undefined }, + ]); + expect(invoke).toHaveBeenCalledWith("wsl_ignored_entry_names", { + distro: "Ubuntu", + directory: "/home/me/app", + workspaceRoot: "/home/me", + }); + }); + it("reads WSL text and converts byte responses", async () => { invoke.mockImplementation((command: string) => { if (command === "wsl_read_file") { diff --git a/src/features/web-workbench/services/web-backend.ts b/src/features/web-workbench/services/web-backend.ts index b3defc08c..c73ad0a1a 100644 --- a/src/features/web-workbench/services/web-backend.ts +++ b/src/features/web-workbench/services/web-backend.ts @@ -1,3 +1,4 @@ +import ignore from "ignore"; import type { FffIndexedFile, FffSearchHit, SearchOptions } from "@/bindings/commands"; import { type MemoryFileSystem, @@ -103,6 +104,24 @@ export function createWebBackend({ home, fs, git, appVersion }: WebBackendOption if (current !== expected) throw new Error(`${path} changed on disk. Read it again first.`); } + /** The sample project only has a root `.gitignore`, so that is the one file read here. */ + function ignoredEntryNames(directoryArg: unknown, rootArg: unknown) { + const root = normalizePath(rootArg); + const directory = normalizePath(directoryArg); + const gitignore = `${root}/.gitignore`; + if (!fs.exists(gitignore) || (directory !== root && !directory.startsWith(`${root}/`))) { + return []; + } + const rules = ignore().add(fs.readText(gitignore)); + return fs + .list(directory) + .filter(({ name, isDirectory }) => { + const relative = `${directory}/${name}`.slice(root.length + 1); + return rules.ignores(isDirectory ? `${relative}/` : relative); + }) + .map(({ name }) => name); + } + function indexedFiles(rootPaths: unknown): FffIndexedFile[] { const roots = ((rootPaths as string[] | undefined) ?? [git.root]).map(normalizePath); return roots.flatMap((root) => @@ -224,6 +243,7 @@ export function createWebBackend({ home, fs, git, appVersion }: WebBackendOption return null; }, read_local_file: (args) => toBytes(fs.readText(args?.path)), + local_ignored_entry_names: (args) => ignoredEntryNames(args?.directory, args?.workspaceRoot), write_local_file: (args) => { fs.writeText(args?.path, String(args?.content ?? "")); return null; diff --git a/src/features/web-workbench/tests/web-backend.test.ts b/src/features/web-workbench/tests/web-backend.test.ts index c4fdc067c..dfb69a0f7 100644 --- a/src/features/web-workbench/tests/web-backend.test.ts +++ b/src/features/web-workbench/tests/web-backend.test.ts @@ -37,6 +37,23 @@ describe("web workbench backend", () => { expect(await invoke("plugin:fs|read_dir", { path: `${SAMPLE_ROOT}/.athas/rules` })).toEqual([]); }); + it("reports the sample files its .gitignore excludes", async () => { + const { invoke } = createSample(); + + expect( + await invoke("local_ignored_entry_names", { + directory: SAMPLE_ROOT, + workspaceRoot: SAMPLE_ROOT, + }), + ).toEqual([".env.local"]); + expect( + await invoke("local_ignored_entry_names", { + directory: `${SAMPLE_ROOT}/src`, + workspaceRoot: SAMPLE_ROOT, + }), + ).toEqual([]); + }); + it("lists no WSL distributions, so the project picker opens", async () => { const { invoke } = createSample(); expect(await invoke("wsl_list_distributions")).toEqual([]); From 3623214b3f99263c415edc2798b1be25ff824011 Mon Sep 17 00:00:00 2001 From: Konv Suu Date: Thu, 12 Nov 2026 11:09:34 +0800 Subject: [PATCH 2/3] Share the gitignore matcher between the watcher and AI tools Move the ignore rules out of the file watcher into a project-level module so the watcher, the file tree and the AI workspace tools match paths the same way. AI ignore files now use it too, so a /**/ rule no longer hides every file at the workspace root. --- crates/ai/src/workspace_tools.rs | 42 ++- crates/project/src/ignore_rules.rs | 8 + crates/project/src/ignore_rules/git.rs | 231 +++++++++++++ crates/project/src/ignore_rules/scope.rs | 164 +++++++++ crates/project/src/ignored_entries.rs | 14 +- crates/project/src/lib.rs | 1 + crates/project/src/watcher/batch.rs | 17 +- crates/project/src/watcher/ignore_rules.rs | 373 --------------------- crates/project/src/watcher/mod.rs | 35 +- crates/project/src/watcher/path_class.rs | 138 ++++++++ 10 files changed, 609 insertions(+), 414 deletions(-) create mode 100644 crates/project/src/ignore_rules.rs create mode 100644 crates/project/src/ignore_rules/git.rs create mode 100644 crates/project/src/ignore_rules/scope.rs delete mode 100644 crates/project/src/watcher/ignore_rules.rs create mode 100644 crates/project/src/watcher/path_class.rs diff --git a/crates/ai/src/workspace_tools.rs b/crates/ai/src/workspace_tools.rs index b34ef0b46..b68019247 100644 --- a/crates/ai/src/workspace_tools.rs +++ b/crates/ai/src/workspace_tools.rs @@ -1,3 +1,4 @@ +use athas_project::ignore_rules::{IgnoreScope, ScopeMatch}; use regex::{Regex, RegexBuilder}; use serde::{Deserialize, Serialize}; use std::{ @@ -23,11 +24,14 @@ const MAX_EDITS: usize = 64; const AI_IGNORE_FILES: [&str; 3] = [".athasignore", ".aiignore", ".cursorignore"]; -struct AiIgnore(Vec); +/// The root's AI ignore files, one scope each, so a `!pattern` in one file cannot bring back +/// what another excludes. A file that is linked, too large, unreadable, or holds a line that +/// does not compile fails the load: the rules must never be dropped silently. +struct AiIgnore(Vec); impl AiIgnore { fn load(root: &Path) -> Result { - let mut matchers = Vec::new(); + let mut scopes = Vec::new(); for name in AI_IGNORE_FILES { let path = root.join(name); let metadata = match fs::symlink_metadata(&path) { @@ -40,21 +44,20 @@ impl AiIgnore { "{name} must be a regular text file smaller than 256 KiB." )); } - let mut builder = ignore::gitignore::GitignoreBuilder::new(root); - if let Some(error) = builder.add(&path) { - return Err(format!("Could not read {name}: {error}")); - } - matchers.push(builder.build().map_err(|error| error.to_string())?); + let mut builder = IgnoreScope::builder(root); + builder + .add(&path) + .map_err(|error| format!("Could not read {name}: {error}"))?; + scopes.extend(builder.build()?); } - Ok(Self(matchers)) + Ok(Self(scopes)) } fn excludes(&self, path: &Path, is_dir: bool) -> bool { - self.0.iter().any(|matcher| { - matcher - .matched_path_or_any_parents(path, is_dir) - .is_ignore() - }) + self + .0 + .iter() + .any(|scope| scope.matched_path_or_parents(path, is_dir) == ScopeMatch::Ignore) } } @@ -733,6 +736,19 @@ mod tests { assert!(read_workspace_file(root, "a.ts").is_err()); } + #[test] + fn excludes_only_folders_for_a_globstar_folder_pattern() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_str().unwrap(); + fs::write(dir.path().join(".aiignore"), "/**/\n").unwrap(); + fs::create_dir(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("readme.md"), "top").unwrap(); + fs::write(dir.path().join("src/lib.rs"), "nested").unwrap(); + + assert!(read_workspace_file(root, "readme.md").is_ok()); + assert!(read_workspace_file(root, "src/lib.rs").is_err()); + } + #[cfg(unix)] #[test] fn refuses_symlinked_ignore_files_and_symlinked_search_roots() { diff --git a/crates/project/src/ignore_rules.rs b/crates/project/src/ignore_rules.rs new file mode 100644 index 000000000..f37a01bfd --- /dev/null +++ b/crates/project/src/ignore_rules.rs @@ -0,0 +1,8 @@ +//! Ignore rules in gitignore syntax. [`IgnoreScope`] compiles one directory's rules and matches +//! paths against them, and rule sets such as [`GitIgnoreRules`] decide how their scopes combine. + +mod git; +mod scope; + +pub use git::{GitIgnoreRules, is_ignore_file}; +pub use scope::{IgnoreScope, IgnoreScopeBuilder, ScopeMatch}; diff --git a/crates/project/src/ignore_rules/git.rs b/crates/project/src/ignore_rules/git.rs new file mode 100644 index 000000000..41dd4eb21 --- /dev/null +++ b/crates/project/src/ignore_rules/git.rs @@ -0,0 +1,231 @@ +use super::{IgnoreScope, ScopeMatch}; +use std::{ + collections::HashMap, + ffi::OsStr, + path::{Path, PathBuf}, + sync::Arc, +}; + +/// Ignore files read in every directory, in increasing precedence. +const IGNORE_FILE_NAMES: &[&str] = &[".gitignore", ".ignore"]; + +/// Past this many cached directory matchers the cache starts over, so a long session over a +/// huge tree does not keep one matcher per directory it ever saw. +const MAX_CACHED_DIRS: usize = 4096; + +pub fn is_ignore_file(path: &Path) -> bool { + path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| IGNORE_FILE_NAMES.contains(&name)) +} + +/// The `.gitignore` / `.ignore` rules of one workspace root, read lazily per directory, plus +/// `.git/info/exclude` and the global excludes file. Ignore files that cannot be read, and lines +/// that do not compile, are skipped. +pub struct GitIgnoreRules { + root: PathBuf, + per_directory: HashMap>>, + repository: Option>>, + global_excludes: bool, +} + +impl GitIgnoreRules { + pub fn new(root: impl Into) -> Self { + Self { + root: root.into(), + per_directory: HashMap::new(), + repository: None, + global_excludes: true, + } + } + + /// Rules for a root whose global excludes file is not the one this process's git config + /// names, such as a WSL distribution reached from Windows. + pub fn without_global_excludes(root: impl Into) -> Self { + Self { + global_excludes: false, + ..Self::new(root) + } + } + + /// Forgets every matcher read so far. Called when an ignore file changes. + pub fn invalidate(&mut self) { + self.per_directory.clear(); + self.repository = None; + } + + /// Whether `relative` (relative to the root) is ignored: it or one of its parent directories + /// is excluded by the ignore files of the directories above it (the deepest one deciding), + /// then by `.git/info/exclude` and the global excludes file. + pub fn is_ignored(&mut self, relative: &Path, is_dir: bool) -> bool { + let parents: Vec = relative + .ancestors() + .skip(1) + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf) + .collect(); + // Git never descends into an excluded directory, so a `!pattern` below it cannot bring a + // path back. + for parent in parents.iter().rev() { + if self.is_excluded(parent, true) { + return true; + } + } + self.is_excluded(relative, is_dir) + } + + fn is_excluded(&mut self, relative: &Path, is_dir: bool) -> bool { + let absolute = self.root.join(relative); + let mut directory = relative.parent().map(Path::to_path_buf); + + while let Some(current) = directory { + if let Some(scope) = self.scope_for(¤t) { + match scope.matched(&absolute, is_dir) { + ScopeMatch::Ignore => return true, + ScopeMatch::Allow => return false, + ScopeMatch::None => {} + } + } + directory = current.parent().map(Path::to_path_buf); + } + + for scope in self.repository_scopes().iter() { + match scope.matched(&absolute, is_dir) { + ScopeMatch::Ignore => return true, + ScopeMatch::Allow => return false, + ScopeMatch::None => {} + } + } + false + } + + fn scope_for(&mut self, relative_dir: &Path) -> Option> { + if let Some(cached) = self.per_directory.get(relative_dir) { + return cached.clone(); + } + if self.per_directory.len() >= MAX_CACHED_DIRS { + self.per_directory.clear(); + } + + let directory = self.root.join(relative_dir); + let scope = self + .read_scope( + &directory, + IGNORE_FILE_NAMES.iter().map(|name| directory.join(name)), + ) + .map(Arc::new); + self + .per_directory + .insert(relative_dir.to_path_buf(), scope.clone()); + scope + } + + fn repository_scopes(&mut self) -> Arc> { + if let Some(scopes) = &self.repository { + return scopes.clone(); + } + + let exclude = self.root.join(".git").join("info").join("exclude"); + let mut scopes: Vec = self + .read_scope(&self.root, std::iter::once(exclude)) + .into_iter() + .collect(); + if self.global_excludes { + scopes.extend(IgnoreScope::global_excludes(&self.root)); + } + + let scopes = Arc::new(scopes); + self.repository = Some(scopes.clone()); + scopes + } + + fn read_scope( + &self, + directory: &Path, + paths: impl Iterator, + ) -> Option { + let mut builder = IgnoreScope::builder(directory); + for path in paths.filter(|path| path.is_file()) { + if let Err(error) = builder.add(&path) { + log::debug!("[IgnoreRules] Partially read {}: {error}", path.display()); + } + } + builder.build().ok().flatten() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn respects_nested_ignore_files_and_negations() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join("logs/keep")).unwrap(); + std::fs::create_dir_all(root.join("web/generated")).unwrap(); + std::fs::write(root.join(".gitignore"), "*.log\n/coverage/\n").unwrap(); + std::fs::write(root.join("web/.gitignore"), "generated/\n!important.log\n").unwrap(); + std::fs::write(root.join("web/.ignore"), "*.tmp\n").unwrap(); + + let mut rules = GitIgnoreRules::new(root); + assert!(rules.is_ignored(Path::new("debug.log"), false)); + assert!(rules.is_ignored(Path::new("logs/keep/today.log"), false)); + assert!(rules.is_ignored(Path::new("coverage"), true)); + assert!(rules.is_ignored(Path::new("coverage/lcov.info"), false)); + assert!(rules.is_ignored(Path::new("web/generated/types.ts"), false)); + assert!(rules.is_ignored(Path::new("web/scratch.tmp"), false)); + assert!(!rules.is_ignored(Path::new("web/important.log"), false)); + assert!(!rules.is_ignored(Path::new("src/main.rs"), false)); + assert!(!rules.is_ignored(Path::new("web/src/app.tsx"), false)); + } + + #[test] + fn reads_info_exclude_and_picks_up_rule_changes_after_invalidate() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join(".git/info")).unwrap(); + std::fs::write(root.join(".git/info/exclude"), "local-notes.md\n").unwrap(); + + let mut rules = GitIgnoreRules::new(root); + assert!(rules.is_ignored(Path::new("local-notes.md"), false)); + assert!(!rules.is_ignored(Path::new("data.watchtest"), false)); + + std::fs::write(root.join(".gitignore"), "*.watchtest\n").unwrap(); + assert!( + !rules.is_ignored(Path::new("data.watchtest"), false), + "matchers are cached until invalidated" + ); + rules.invalidate(); + assert!(rules.is_ignored(Path::new("data.watchtest"), false)); + } + + #[test] + fn matches_globstar_directories_and_excluded_parents_like_git() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join("build")).unwrap(); + std::fs::create_dir_all(root.join("sub/nested")).unwrap(); + std::fs::write(root.join(".gitignore"), "build/\n").unwrap(); + std::fs::write(root.join("build/.gitignore"), "!keep.log\n").unwrap(); + std::fs::write(root.join("sub/.gitignore"), "/**/\n").unwrap(); + + let mut rules = GitIgnoreRules::without_global_excludes(root); + assert!(rules.is_ignored(Path::new("build/keep.log"), false)); + assert!(rules.is_ignored(Path::new("sub/nested"), true)); + assert!(rules.is_ignored(Path::new("sub/nested/file.rs"), false)); + assert!(!rules.is_ignored(Path::new("sub/notes.md"), false)); + assert!(!rules.is_ignored(Path::new("sub/.gitignore"), false)); + } + + #[test] + fn bounds_the_directory_cache() { + let temp = tempfile::tempdir().unwrap(); + let mut rules = GitIgnoreRules::new(temp.path()); + for index in 0..(MAX_CACHED_DIRS + 10) { + rules.is_ignored(&PathBuf::from(format!("d{index}/file.rs")), false); + } + assert!(rules.per_directory.len() <= MAX_CACHED_DIRS); + } +} diff --git a/crates/project/src/ignore_rules/scope.rs b/crates/project/src/ignore_rules/scope.rs new file mode 100644 index 000000000..3eadf895f --- /dev/null +++ b/crates/project/src/ignore_rules/scope.rs @@ -0,0 +1,164 @@ +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use std::path::Path; + +/// How an [`IgnoreScope`] answers for one path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScopeMatch { + None, + Ignore, + /// A `!pattern` brings the path back. + Allow, +} + +/// Rules in gitignore syntax, anchored at the directory whose ignore files hold them. +pub struct IgnoreScope { + matcher: Gitignore, +} + +pub struct IgnoreScopeBuilder { + builder: GitignoreBuilder, +} + +impl IgnoreScope { + pub fn builder(directory: &Path) -> IgnoreScopeBuilder { + IgnoreScopeBuilder { + builder: GitignoreBuilder::new(directory), + } + } + + /// The excludes file this machine's git config names (`core.excludesFile`), anchored at + /// `root`. + pub fn global_excludes(root: &Path) -> Option { + let (matcher, error) = GitignoreBuilder::new(root).build_global(); + if let Some(error) = error { + log::debug!("[IgnoreRules] Partially read the global excludes file: {error}"); + } + (!matcher.is_empty()).then_some(Self { matcher }) + } + + /// The decision for `path` itself, never for its parent directories. + pub fn matched(&self, path: &Path, is_dir: bool) -> ScopeMatch { + if !path.starts_with(self.matcher.path()) { + return ScopeMatch::None; + } + let matched = self.matcher.matched(path, is_dir); + if matched.is_ignore() { + ScopeMatch::Ignore + } else if matched.is_whitelist() { + ScopeMatch::Allow + } else { + ScopeMatch::None + } + } + + /// The decision for the nearest of `path` and its parent directories that a rule matches, + /// short of the scope's own directory. `Gitignore::matched_path_or_any_parents` also tests + /// that directory, as an empty path, and a `/**/` rule matches it, which would ignore every + /// file beside the rule. + pub fn matched_path_or_parents(&self, path: &Path, is_dir: bool) -> ScopeMatch { + let directory = self.matcher.path(); + let mut current = path; + let mut current_is_dir = is_dir; + while current != directory && current.starts_with(directory) { + let matched = self.matched(current, current_is_dir); + if matched != ScopeMatch::None { + return matched; + } + let Some(parent) = current.parent() else { + break; + }; + current = parent; + current_is_dir = true; + } + ScopeMatch::None + } +} + +impl IgnoreScopeBuilder { + /// Adds the rules of the ignore file at `file`. Lines that do not compile are reported, and + /// the others still apply. + pub fn add(&mut self, file: &Path) -> Result<(), String> { + match self.builder.add(file) { + Some(error) => Err(error.to_string()), + None => Ok(()), + } + } + + /// `None` when no line holds a rule. + pub fn build(self) -> Result, String> { + let matcher = self.builder.build().map_err(|error| error.to_string())?; + Ok((!matcher.is_empty()).then_some(IgnoreScope { matcher })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn scope(rules: &str) -> (tempfile::TempDir, PathBuf, IgnoreScope) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().to_path_buf(); + std::fs::write(root.join(".gitignore"), rules).unwrap(); + let mut builder = IgnoreScope::builder(&root); + builder.add(&root.join(".gitignore")).unwrap(); + let scope = builder.build().unwrap().unwrap(); + (temp, root, scope) + } + + #[test] + fn matches_a_path_itself_and_reports_negations() { + let (_temp, root, scope) = scope("*.log\n!keep.log\nbuild/\n"); + + assert_eq!( + scope.matched(&root.join("debug.log"), false), + ScopeMatch::Ignore + ); + assert_eq!( + scope.matched(&root.join("keep.log"), false), + ScopeMatch::Allow + ); + assert_eq!(scope.matched(&root.join("build"), true), ScopeMatch::Ignore); + assert_eq!( + scope.matched(&root.join("build/out.js"), false), + ScopeMatch::None + ); + assert_eq!( + scope.matched(Path::new("/elsewhere/debug.log"), false), + ScopeMatch::None + ); + } + + #[test] + fn takes_the_nearest_decision_without_testing_its_own_directory() { + let (_temp, root, globstar) = scope("/**/\n"); + assert_eq!( + globstar.matched_path_or_parents(&root.join("notes.md"), false), + ScopeMatch::None + ); + assert_eq!( + globstar.matched_path_or_parents(&root.join("sub/a.rs"), false), + ScopeMatch::Ignore + ); + + let (_temp, root, nested) = scope("build/\n!build/keep.js\n"); + assert_eq!( + nested.matched_path_or_parents(&root.join("build/out.js"), false), + ScopeMatch::Ignore + ); + assert_eq!( + nested.matched_path_or_parents(&root.join("build/keep.js"), false), + ScopeMatch::Allow + ); + } + + #[test] + fn builds_nothing_from_files_without_rules() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join(".gitignore"); + std::fs::write(&file, "# comment\n\n").unwrap(); + let mut builder = IgnoreScope::builder(temp.path()); + builder.add(&file).unwrap(); + assert!(builder.build().unwrap().is_none()); + } +} diff --git a/crates/project/src/ignored_entries.rs b/crates/project/src/ignored_entries.rs index 083068ea7..43a79884f 100644 --- a/crates/project/src/ignored_entries.rs +++ b/crates/project/src/ignored_entries.rs @@ -1,11 +1,11 @@ -use crate::watcher::ignore_rules::IgnoreRules; +use crate::ignore_rules::GitIgnoreRules; use std::{fs, path::Path}; /// Names of the entries of `directory` that the ignore files of `root` exclude: `.gitignore` and /// `.ignore` files from `directory` up to `root`, `.git/info/exclude`, and the global excludes /// file. Empty when `directory` is outside `root`. pub fn ignored_entry_names(root: &Path, directory: &Path) -> Result, String> { - collect_ignored_entry_names(IgnoreRules::new(root), root, directory) + collect_ignored_entry_names(GitIgnoreRules::new(root), root, directory) } /// Like [`ignored_entry_names`] without the global excludes file, for a root on another system @@ -14,11 +14,15 @@ pub fn ignored_entry_names_without_global_excludes( root: &Path, directory: &Path, ) -> Result, String> { - collect_ignored_entry_names(IgnoreRules::without_global_excludes(root), root, directory) + collect_ignored_entry_names( + GitIgnoreRules::without_global_excludes(root), + root, + directory, + ) } fn collect_ignored_entry_names( - mut rules: IgnoreRules, + mut rules: GitIgnoreRules, root: &Path, directory: &Path, ) -> Result, String> { @@ -129,7 +133,7 @@ mod tests { #[test] fn keeps_valid_rules_around_a_malformed_line() { let temp = tree(&[ - (".gitignore", "valid.out\nbad[\n\\#literal-hash\n"), + (".gitignore", "valid.out\n[z-a]\n\\#literal-hash\n"), ("valid.out", ""), ("#literal-hash", ""), ("kept.txt", ""), diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 81e4e4773..4a6e77bfb 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,5 +1,6 @@ pub mod file_mutations; pub mod ide_recents; +pub mod ignore_rules; pub mod ignored_entries; pub mod local_fs; pub mod path_guard; diff --git a/crates/project/src/watcher/batch.rs b/crates/project/src/watcher/batch.rs index 20663f560..17c31f4b6 100644 --- a/crates/project/src/watcher/batch.rs +++ b/crates/project/src/watcher/batch.rs @@ -1,7 +1,8 @@ use super::{ - ignore_rules::{IgnoreRules, PathClass, classify, is_ignore_file}, + path_class::{PathClass, classify}, types::{WorkspaceFileChange, WorkspaceFileChangeKind, WorkspaceFileChanges}, }; +use crate::ignore_rules::{GitIgnoreRules, is_ignore_file}; use notify::{Event, EventKind, event::ModifyKind}; use std::{ collections::HashMap, @@ -150,7 +151,7 @@ pub fn build_batch( pending: &PendingEvents, root: &Path, canonical_root: &Path, - rules: &mut IgnoreRules, + rules: &mut GitIgnoreRules, max_changes: usize, ) -> WorkspaceFileChanges { let mut batch = WorkspaceFileChanges { @@ -275,7 +276,7 @@ mod tests { } assert_eq!(pending.len(), 2); - let batch = build_batch(&pending, &root, &root, &mut IgnoreRules::new(&root), 100); + let batch = build_batch(&pending, &root, &root, &mut GitIgnoreRules::new(&root), 100); assert!(batch.rescan); } @@ -289,7 +290,7 @@ mod tests { pending.record(&modified(&path)); } - let batch = build_batch(&pending, &root, &root, &mut IgnoreRules::new(&root), 3); + let batch = build_batch(&pending, &root, &root, &mut GitIgnoreRules::new(&root), 3); assert!(batch.rescan); assert!(batch.changes.is_empty()); } @@ -323,7 +324,7 @@ mod tests { pending.record(&modified(&root.join(".git/HEAD"))); pending.record(&modified(Path::new("/somewhere/else.rs"))); - let batch = build_batch(&pending, &root, &root, &mut IgnoreRules::new(&root), 100); + let batch = build_batch(&pending, &root, &root, &mut GitIgnoreRules::new(&root), 100); let path = |p: &PathBuf| p.to_string_lossy().into_owned(); assert_eq!( @@ -353,7 +354,7 @@ mod tests { &pending, spelled, &canonical, - &mut IgnoreRules::new(&canonical), + &mut GitIgnoreRules::new(&canonical), 100, ); @@ -367,7 +368,7 @@ mod tests { #[test] fn a_changed_ignore_file_applies_within_the_same_window() { let (_temp, root) = canonical_tempdir(); - let mut rules = IgnoreRules::new(&root); + let mut rules = GitIgnoreRules::new(&root); let generated = root.join("generated.ts"); std::fs::write(&generated, "x").unwrap(); assert!(!rules.is_ignored(Path::new("generated.ts"), false)); @@ -462,6 +463,6 @@ mod tests { .unwrap(); assert!(!pending.is_empty()); let root = Path::new("/w"); - assert!(build_batch(&pending, root, root, &mut IgnoreRules::new(root), 10).rescan); + assert!(build_batch(&pending, root, root, &mut GitIgnoreRules::new(root), 10).rescan); } } diff --git a/crates/project/src/watcher/ignore_rules.rs b/crates/project/src/watcher/ignore_rules.rs deleted file mode 100644 index aa3383535..000000000 --- a/crates/project/src/watcher/ignore_rules.rs +++ /dev/null @@ -1,373 +0,0 @@ -use ignore::gitignore::{Gitignore, GitignoreBuilder}; -use std::{ - collections::HashMap, - ffi::OsStr, - path::{Component, Path, PathBuf}, - sync::Arc, -}; - -/// Directory names whose contents never reach the frontend, whatever the ignore files say. They -/// are build output, dependency caches, or tool state that churn far faster than anyone reads -/// them in a file tree. -pub const ALWAYS_IGNORED_DIRS: &[&str] = &[ - "node_modules", - "target", - "dist", - "build", - ".next", - ".nuxt", - ".svelte-kit", - ".turbo", - ".parcel-cache", - ".cache", - ".gradle", - ".venv", - "__pycache__", - ".pytest_cache", - ".mypy_cache", -]; - -/// Ignore files read in every directory, in increasing precedence. -const IGNORE_FILE_NAMES: &[&str] = &[".gitignore", ".ignore"]; - -/// Past this many cached directory matchers the cache starts over, so a long session over a -/// huge tree does not keep one matcher per directory it ever saw. -const MAX_CACHED_DIRS: usize = 4096; - -/// Where a path inside a workspace root falls for the watcher. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PathClass { - /// A worktree path the frontend should hear about (subject to the ignore files). - Worktree, - /// A file inside the root `.git` directory whose change can move the Git view. - GitState, - /// Never reported: other `.git` internals, nested repositories, and always-ignored dirs. - Dropped, -} - -pub fn is_ignore_file(path: &Path) -> bool { - path - .file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| IGNORE_FILE_NAMES.contains(&name)) -} - -/// Classifies `relative`, a path relative to the workspace root, before any ignore file is read. -pub fn classify(relative: &Path) -> PathClass { - let mut components = relative - .components() - .filter_map(|component| match component { - Component::Normal(name) => Some(name), - _ => None, - }); - - let Some(first) = components.next() else { - return PathClass::Dropped; - }; - - if first == ".git" { - let rest: PathBuf = components.collect(); - return if is_git_state_path(&rest) { - PathClass::GitState - } else { - PathClass::Dropped - }; - } - - let dropped = std::iter::once(first).chain(components).any(|name| { - name == ".git" - || name - .to_str() - .is_some_and(|name| ALWAYS_IGNORED_DIRS.contains(&name)) - }); - if dropped { - PathClass::Dropped - } else { - PathClass::Worktree - } -} - -/// Whether `inside_git_dir` (relative to `.git`) is state the Git view shows: the checked out -/// commit, the index, refs, and in-progress merge/cherry-pick/revert markers. Objects, hooks and -/// lock files churn during every git command and say nothing on their own. -pub fn is_git_state_path(inside_git_dir: &Path) -> bool { - let Some(name) = inside_git_dir.to_str() else { - return false; - }; - let name = name.replace('\\', "/"); - if name.ends_with(".lock") { - return false; - } - - matches!( - name.as_str(), - "HEAD" - | "index" - | "packed-refs" - | "ORIG_HEAD" - | "FETCH_HEAD" - | "MERGE_HEAD" - | "CHERRY_PICK_HEAD" - | "REVERT_HEAD" - | "REBASE_HEAD" - | "logs/HEAD" - ) || name.starts_with("refs/") -} - -/// The `.gitignore` / `.ignore` rules of one workspace root, read lazily per directory. -pub struct IgnoreRules { - root: PathBuf, - per_directory: HashMap>>, - repository: Option>, - global_excludes: bool, -} - -impl IgnoreRules { - pub fn new(root: impl Into) -> Self { - Self { - root: root.into(), - per_directory: HashMap::new(), - repository: None, - global_excludes: true, - } - } - - /// Rules for a root whose global excludes file is not the one this process's git config - /// names, such as a WSL distribution reached from Windows. - pub fn without_global_excludes(root: impl Into) -> Self { - Self { - global_excludes: false, - ..Self::new(root) - } - } - - /// Forgets every matcher read so far. Called when an ignore file changes. - pub fn invalidate(&mut self) { - self.per_directory.clear(); - self.repository = None; - } - - /// Whether `relative` (relative to the root) is ignored: it or one of its parent directories - /// is excluded by the ignore files of the directories above it (the deepest one deciding), - /// then by `.git/info/exclude` and the global excludes file. - pub fn is_ignored(&mut self, relative: &Path, is_dir: bool) -> bool { - let parents: Vec = relative - .ancestors() - .skip(1) - .filter(|parent| !parent.as_os_str().is_empty()) - .map(Path::to_path_buf) - .collect(); - // Git never descends into an excluded directory, so a `!pattern` below it cannot bring a - // path back. - for parent in parents.iter().rev() { - if self.is_excluded(parent, true) { - return true; - } - } - self.is_excluded(relative, is_dir) - } - - /// Matches `relative` itself, never its parents: `Gitignore::matched_path_or_any_parents` - /// also tests the matcher's own directory as an empty path, which `/**/` matches. - fn is_excluded(&mut self, relative: &Path, is_dir: bool) -> bool { - let absolute = self.root.join(relative); - let mut directory = relative.parent().map(Path::to_path_buf); - - while let Some(current) = directory { - if let Some(matcher) = self.matcher_for(¤t) { - let matched = matcher.matched(&absolute, is_dir); - if matched.is_ignore() { - return true; - } - if matched.is_whitelist() { - return false; - } - } - directory = current.parent().map(Path::to_path_buf); - } - - let repository = self.repository_matchers(); - for matcher in [&repository.0, &repository.1] { - let matched = matcher.matched(&absolute, is_dir); - if matched.is_ignore() { - return true; - } - if matched.is_whitelist() { - return false; - } - } - false - } - - fn matcher_for(&mut self, relative_dir: &Path) -> Option> { - if let Some(cached) = self.per_directory.get(relative_dir) { - return cached.clone(); - } - if self.per_directory.len() >= MAX_CACHED_DIRS { - self.per_directory.clear(); - } - - let directory = self.root.join(relative_dir); - let mut builder = GitignoreBuilder::new(&directory); - let mut found = false; - for name in IGNORE_FILE_NAMES { - let path = directory.join(name); - if path.is_file() { - found = true; - if let Some(error) = builder.add(&path) { - log::debug!("[FileWatcher] Partially read {}: {error}", path.display()); - } - } - } - - let matcher = found - .then(|| builder.build().ok()) - .flatten() - .filter(|matcher| !matcher.is_empty()) - .map(Arc::new); - self - .per_directory - .insert(relative_dir.to_path_buf(), matcher.clone()); - matcher - } - - fn repository_matchers(&mut self) -> Arc<(Gitignore, Gitignore)> { - if let Some(matchers) = &self.repository { - return matchers.clone(); - } - - let mut exclude = GitignoreBuilder::new(&self.root); - let exclude_path = self.root.join(".git").join("info").join("exclude"); - if exclude_path.is_file() - && let Some(error) = exclude.add(&exclude_path) - { - log::debug!( - "[FileWatcher] Partially read {}: {error}", - exclude_path.display() - ); - } - let exclude = exclude.build().unwrap_or_else(|_| Gitignore::empty()); - let global = if self.global_excludes { - GitignoreBuilder::new(&self.root).build_global().0 - } else { - Gitignore::empty() - }; - - let matchers = Arc::new((exclude, global)); - self.repository = Some(matchers.clone()); - matchers - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn classifies_git_state_and_dropped_paths() { - assert_eq!(classify(Path::new("src/main.rs")), PathClass::Worktree); - assert_eq!(classify(Path::new(".gitignore")), PathClass::Worktree); - assert_eq!(classify(Path::new(".github/ci.yml")), PathClass::Worktree); - assert_eq!(classify(Path::new("build.rs")), PathClass::Worktree); - - for path in [ - ".git/HEAD", - ".git/index", - ".git/packed-refs", - ".git/logs/HEAD", - ".git/refs/heads/main", - ".git/refs/remotes/origin/main", - ".git/MERGE_HEAD", - ] { - assert_eq!(classify(Path::new(path)), PathClass::GitState, "{path}"); - } - - for path in [ - "", - ".git", - ".git/objects/ab/cdef", - ".git/index.lock", - ".git/refs/heads/main.lock", - ".git/hooks/pre-commit", - "vendor/lib/.git/HEAD", - "node_modules/react/index.js", - "web/node_modules/.bin/vite", - "target/debug/athas", - "crates/project/target", - "app/dist/index.html", - "pkg/__pycache__/mod.pyc", - ] { - assert_eq!(classify(Path::new(path)), PathClass::Dropped, "{path}"); - } - } - - #[test] - fn respects_nested_ignore_files_and_negations() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - std::fs::create_dir_all(root.join("logs/keep")).unwrap(); - std::fs::create_dir_all(root.join("web/generated")).unwrap(); - std::fs::write(root.join(".gitignore"), "*.log\n/coverage/\n").unwrap(); - std::fs::write(root.join("web/.gitignore"), "generated/\n!important.log\n").unwrap(); - std::fs::write(root.join("web/.ignore"), "*.tmp\n").unwrap(); - - let mut rules = IgnoreRules::new(root); - assert!(rules.is_ignored(Path::new("debug.log"), false)); - assert!(rules.is_ignored(Path::new("logs/keep/today.log"), false)); - assert!(rules.is_ignored(Path::new("coverage"), true)); - assert!(rules.is_ignored(Path::new("coverage/lcov.info"), false)); - assert!(rules.is_ignored(Path::new("web/generated/types.ts"), false)); - assert!(rules.is_ignored(Path::new("web/scratch.tmp"), false)); - assert!(!rules.is_ignored(Path::new("web/important.log"), false)); - assert!(!rules.is_ignored(Path::new("src/main.rs"), false)); - assert!(!rules.is_ignored(Path::new("web/src/app.tsx"), false)); - } - - #[test] - fn reads_info_exclude_and_picks_up_rule_changes_after_invalidate() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - std::fs::create_dir_all(root.join(".git/info")).unwrap(); - std::fs::write(root.join(".git/info/exclude"), "local-notes.md\n").unwrap(); - - let mut rules = IgnoreRules::new(root); - assert!(rules.is_ignored(Path::new("local-notes.md"), false)); - assert!(!rules.is_ignored(Path::new("data.watchtest"), false)); - - std::fs::write(root.join(".gitignore"), "*.watchtest\n").unwrap(); - assert!( - !rules.is_ignored(Path::new("data.watchtest"), false), - "matchers are cached until invalidated" - ); - rules.invalidate(); - assert!(rules.is_ignored(Path::new("data.watchtest"), false)); - } - - #[test] - fn matches_globstar_directories_and_excluded_parents_like_git() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - std::fs::create_dir_all(root.join("build")).unwrap(); - std::fs::create_dir_all(root.join("sub/nested")).unwrap(); - std::fs::write(root.join(".gitignore"), "build/\n").unwrap(); - std::fs::write(root.join("build/.gitignore"), "!keep.log\n").unwrap(); - std::fs::write(root.join("sub/.gitignore"), "/**/\n").unwrap(); - - let mut rules = IgnoreRules::without_global_excludes(root); - assert!(rules.is_ignored(Path::new("build/keep.log"), false)); - assert!(rules.is_ignored(Path::new("sub/nested"), true)); - assert!(rules.is_ignored(Path::new("sub/nested/file.rs"), false)); - assert!(!rules.is_ignored(Path::new("sub/notes.md"), false)); - assert!(!rules.is_ignored(Path::new("sub/.gitignore"), false)); - } - - #[test] - fn bounds_the_directory_cache() { - let temp = tempfile::tempdir().unwrap(); - let mut rules = IgnoreRules::new(temp.path()); - for index in 0..(MAX_CACHED_DIRS + 10) { - rules.is_ignored(&PathBuf::from(format!("d{index}/file.rs")), false); - } - assert!(rules.per_directory.len() <= MAX_CACHED_DIRS); - } -} diff --git a/crates/project/src/watcher/mod.rs b/crates/project/src/watcher/mod.rs index ddc906a95..e9085cfe3 100644 --- a/crates/project/src/watcher/mod.rs +++ b/crates/project/src/watcher/mod.rs @@ -3,14 +3,15 @@ //! debounce window. mod batch; -pub(crate) mod ignore_rules; +mod path_class; mod types; +use crate::ignore_rules::GitIgnoreRules; use anyhow::{Context, Result, bail}; use batch::{PendingEvents, build_batch, collect_window, touches_root}; -pub use ignore_rules::ALWAYS_IGNORED_DIRS; -use ignore_rules::{IgnoreRules, PathClass, classify}; use notify::{Config, EventKindMask, RecommendedWatcher, RecursiveMode, Watcher}; +pub use path_class::ALWAYS_IGNORED_DIRS; +use path_class::{PathClass, classify}; use std::{ collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, @@ -51,7 +52,7 @@ const PER_DIRECTORY_WATCHES: bool = cfg!(target_os = "linux"); struct RootEntry { canonical: PathBuf, subscribers: BTreeSet, - rules: IgnoreRules, + rules: GitIgnoreRules, /// OS-level watches registered for this root. Empty while an enclosing root covers it. os_watches: Vec, } @@ -119,7 +120,7 @@ impl FileWatcher { state.roots.insert( root.to_path_buf(), RootEntry { - rules: IgnoreRules::new(&canonical), + rules: GitIgnoreRules::new(&canonical), canonical, subscribers: BTreeSet::from([subscriber]), os_watches: Vec::new(), @@ -341,7 +342,10 @@ fn watch_new_directory(state: &mut State, root: &Path, directory: &Path) { } } -fn watch_targets(canonical_root: &Path, rules: &mut IgnoreRules) -> Vec<(PathBuf, RecursiveMode)> { +fn watch_targets( + canonical_root: &Path, + rules: &mut GitIgnoreRules, +) -> Vec<(PathBuf, RecursiveMode)> { if !PER_DIRECTORY_WATCHES { return vec![(canonical_root.to_path_buf(), RecursiveMode::Recursive)]; } @@ -350,7 +354,7 @@ fn watch_targets(canonical_root: &Path, rules: &mut IgnoreRules) -> Vec<(PathBuf fn per_directory_targets( canonical_root: &Path, - rules: &mut IgnoreRules, + rules: &mut GitIgnoreRules, ) -> Vec<(PathBuf, RecursiveMode)> { let mut targets: Vec<(PathBuf, RecursiveMode)> = worktree_directories(canonical_root, canonical_root, rules) @@ -386,7 +390,7 @@ fn per_directory_targets( fn worktree_directories( canonical_root: &Path, start: &Path, - rules: &mut IgnoreRules, + rules: &mut GitIgnoreRules, ) -> Vec { let mut directories = Vec::new(); let mut stack = vec![start.to_path_buf()]; @@ -529,13 +533,14 @@ mod tests { } std::fs::write(root.join(".gitignore"), "coverage/\n").unwrap(); - let targets: BTreeSet = per_directory_targets(&root, &mut IgnoreRules::new(&root)) - .into_iter() - .map(|(path, mode)| { - assert_eq!(mode, RecursiveMode::NonRecursive); - path.strip_prefix(&root).unwrap().to_path_buf() - }) - .collect(); + let targets: BTreeSet = + per_directory_targets(&root, &mut GitIgnoreRules::new(&root)) + .into_iter() + .map(|(path, mode)| { + assert_eq!(mode, RecursiveMode::NonRecursive); + path.strip_prefix(&root).unwrap().to_path_buf() + }) + .collect(); let expected: BTreeSet = [ "", diff --git a/crates/project/src/watcher/path_class.rs b/crates/project/src/watcher/path_class.rs new file mode 100644 index 000000000..f643aedbf --- /dev/null +++ b/crates/project/src/watcher/path_class.rs @@ -0,0 +1,138 @@ +use std::path::{Component, Path, PathBuf}; + +/// Directory names whose contents never reach the frontend, whatever the ignore files say. They +/// are build output, dependency caches, or tool state that churn far faster than anyone reads +/// them in a file tree. +pub const ALWAYS_IGNORED_DIRS: &[&str] = &[ + "node_modules", + "target", + "dist", + "build", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".parcel-cache", + ".cache", + ".gradle", + ".venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", +]; + +/// Where a path inside a workspace root falls for the watcher. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathClass { + /// A worktree path the frontend should hear about (subject to the ignore files). + Worktree, + /// A file inside the root `.git` directory whose change can move the Git view. + GitState, + /// Never reported: other `.git` internals, nested repositories, and always-ignored dirs. + Dropped, +} + +/// Classifies `relative`, a path relative to the workspace root, before any ignore file is read. +pub fn classify(relative: &Path) -> PathClass { + let mut components = relative + .components() + .filter_map(|component| match component { + Component::Normal(name) => Some(name), + _ => None, + }); + + let Some(first) = components.next() else { + return PathClass::Dropped; + }; + + if first == ".git" { + let rest: PathBuf = components.collect(); + return if is_git_state_path(&rest) { + PathClass::GitState + } else { + PathClass::Dropped + }; + } + + let dropped = std::iter::once(first).chain(components).any(|name| { + name == ".git" + || name + .to_str() + .is_some_and(|name| ALWAYS_IGNORED_DIRS.contains(&name)) + }); + if dropped { + PathClass::Dropped + } else { + PathClass::Worktree + } +} + +/// Whether `inside_git_dir` (relative to `.git`) is state the Git view shows: the checked out +/// commit, the index, refs, and in-progress merge/cherry-pick/revert markers. Objects, hooks and +/// lock files churn during every git command and say nothing on their own. +pub fn is_git_state_path(inside_git_dir: &Path) -> bool { + let Some(name) = inside_git_dir.to_str() else { + return false; + }; + let name = name.replace('\\', "/"); + if name.ends_with(".lock") { + return false; + } + + matches!( + name.as_str(), + "HEAD" + | "index" + | "packed-refs" + | "ORIG_HEAD" + | "FETCH_HEAD" + | "MERGE_HEAD" + | "CHERRY_PICK_HEAD" + | "REVERT_HEAD" + | "REBASE_HEAD" + | "logs/HEAD" + ) || name.starts_with("refs/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_git_state_and_dropped_paths() { + assert_eq!(classify(Path::new("src/main.rs")), PathClass::Worktree); + assert_eq!(classify(Path::new(".gitignore")), PathClass::Worktree); + assert_eq!(classify(Path::new(".github/ci.yml")), PathClass::Worktree); + assert_eq!(classify(Path::new("build.rs")), PathClass::Worktree); + + for path in [ + ".git/HEAD", + ".git/index", + ".git/packed-refs", + ".git/logs/HEAD", + ".git/refs/heads/main", + ".git/refs/remotes/origin/main", + ".git/MERGE_HEAD", + ] { + assert_eq!(classify(Path::new(path)), PathClass::GitState, "{path}"); + } + + for path in [ + "", + ".git", + ".git/objects/ab/cdef", + ".git/index.lock", + ".git/refs/heads/main.lock", + ".git/hooks/pre-commit", + "vendor/lib/.git/HEAD", + "node_modules/react/index.js", + "web/node_modules/.bin/vite", + "target/debug/athas", + "crates/project/target", + "app/dist/index.html", + "pkg/__pycache__/mod.pyc", + ] { + assert_eq!(classify(Path::new(path)), PathClass::Dropped, "{path}"); + } + } +} From 68e8aa864b5b37ee2f906f977d128ed2315087c2 Mon Sep 17 00:00:00 2001 From: Konv Suu Date: Thu, 12 Nov 2026 11:22:14 +0800 Subject: [PATCH 3/3] Mark ignored entries when a WSL folder opens Opening a WSL folder listed its root without asking which entries the ignore files exclude, so ignored root entries were not dimmed or hidden until a refresh. The listing now goes through markIgnoredEntriesIn, as opening a local folder does. The Respect .gitignore setting now says it also covers .ignore files, .git/info/exclude and the global excludes file, and the note on readWorkspaceDirectoryEntries no longer claims every direct reader applies ignore rules itself. --- .../file-system/api/ignored-entries.ts | 18 +++++++++++------- .../services/workspace-resource-provider.ts | 5 +++-- .../file-system/services/wsl-workspace.ts | 2 ++ .../file-system/stores/file-system.store.ts | 11 ++++++----- .../file-system-workspace-lifecycle.test.ts | 19 +++++++++++++++++++ src/features/settings/config/search-index.ts | 3 ++- 6 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/features/file-system/api/ignored-entries.ts b/src/features/file-system/api/ignored-entries.ts index 6120f544b..97454396e 100644 --- a/src/features/file-system/api/ignored-entries.ts +++ b/src/features/file-system/api/ignored-entries.ts @@ -2,7 +2,6 @@ import { commands } from "@/bindings/commands"; import { parseRemotePath } from "@/features/remote/services/remote-path"; import { parseWslPath } from "@/features/wsl/services/wsl-path"; import { logger } from "@/utils/logger"; -import type { FileEntry } from "../types/app.types"; const NO_IGNORED_NAMES: ReadonlySet = new Set(); @@ -37,10 +36,15 @@ export async function readIgnoredEntryNames( } } -export function markIgnoredEntries( - entries: FileEntry[], +interface IgnorableEntry { + name: string; + ignored?: boolean; +} + +export function markIgnoredEntries( + entries: T[], ignoredNames: ReadonlySet, -): FileEntry[] { +): T[] { if (ignoredNames.size === 0) return entries; return entries.map((entry) => ignoredNames.has(entry.name) ? { ...entry, ignored: true } : entry, @@ -48,11 +52,11 @@ export function markIgnoredEntries( } /** Marks the entries of `directoryPath` the file tree shows dimmed or hides as ignored. */ -export async function markIgnoredEntriesIn( +export async function markIgnoredEntriesIn( directoryPath: string, workspaceRoot: string, - entries: Promise, -): Promise { + entries: Promise, +): Promise { const [listed, ignoredNames] = await Promise.all([ entries, readIgnoredEntryNames(directoryPath, workspaceRoot), diff --git a/src/features/file-system/services/workspace-resource-provider.ts b/src/features/file-system/services/workspace-resource-provider.ts index c4c593517..1158f325f 100644 --- a/src/features/file-system/services/workspace-resource-provider.ts +++ b/src/features/file-system/services/workspace-resource-provider.ts @@ -184,8 +184,9 @@ export function getWorkspaceResourceProvider(path: string): WorkspaceResourcePro /** * Lists a directory for the file tree, with the entries the workspace's ignore files exclude - * marked. Searches and context readers call a provider's `readDirectory` directly instead: they - * apply ignore rules themselves and should not pay for this lookup. + * marked. Other readers, such as search and AI context, call a provider's `readDirectory` + * directly instead: they either do not use the flag or apply their own rules, and should not pay + * for this lookup. */ export function readWorkspaceDirectoryEntries(path: string, workspaceRoot = path) { return markIgnoredEntriesIn( diff --git a/src/features/file-system/services/wsl-workspace.ts b/src/features/file-system/services/wsl-workspace.ts index d58d8f0fc..1349d2bb4 100644 --- a/src/features/file-system/services/wsl-workspace.ts +++ b/src/features/file-system/services/wsl-workspace.ts @@ -8,6 +8,7 @@ interface WslDirectoryEntry { size: number; is_symlink: boolean; target?: string | null; + ignored?: boolean; } interface WslWorkspaceTree { @@ -35,6 +36,7 @@ export function buildWslWorkspaceTree( children: entry.is_dir ? [] : undefined, isSymlink: entry.is_symlink, symlinkTarget: entry.target ?? undefined, + ...(entry.ignored ? { ignored: true } : {}), })); const projectName = getWslProjectName(distro, linuxPath); diff --git a/src/features/file-system/stores/file-system.store.ts b/src/features/file-system/stores/file-system.store.ts index 21483cb50..8b84c607f 100644 --- a/src/features/file-system/stores/file-system.store.ts +++ b/src/features/file-system/stores/file-system.store.ts @@ -1244,12 +1244,13 @@ const createFileSystemStore = (workspaceId: string): StoreApi { expect(useRecentFoldersStore.getState().recentFolders[0]?.path).toBe("wsl://Ubuntu/home/me"); }); + it("marks the WSL root entries the workspace's ignore files exclude", async () => { + vi.spyOn(commands, "wslReadDirectory").mockResolvedValue([ + { name: "dist", path: "/home/me/dist", is_dir: true }, + { name: "src", path: "/home/me/src", is_dir: true }, + ] as never); + vi.spyOn(commands, "wslIgnoredEntryNames").mockResolvedValue(["dist"]); + + await fs().handleOpenFolderByPath("wsl://Ubuntu/home/me"); + + expect(commands.wslIgnoredEntryNames).toHaveBeenCalledWith("Ubuntu", "/home/me", "/home/me"); + const [rootEntry] = fs().files; + expect( + rootEntry.children?.map(({ name, ignored }) => ({ name, ignored: ignored ?? false })), + ).toEqual([ + { name: "dist", ignored: true }, + { name: "src", ignored: false }, + ]); + }); + it("reports WSL read failures", async () => { vi.spyOn(commands, "wslReadDirectory").mockRejectedValue("distro stopped"); diff --git a/src/features/settings/config/search-index.ts b/src/features/settings/config/search-index.ts index fa7f717d8..6fdc395d3 100644 --- a/src/features/settings/config/search-index.ts +++ b/src/features/settings/config/search-index.ts @@ -342,7 +342,8 @@ export const settingsSearchIndex: SettingSearchRecord[] = [ tab: "file-explorer", section: "Display", label: "Respect .gitignore", - description: "Hide files matched by root and nested .gitignore files", + description: + "Hide files matched by .gitignore and .ignore files, .git/info/exclude, and the global excludes file", keywords: ["gitignore", ".gitignore", "ignored", "git", "files", "visibility", "exclude"], }, {