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 new file mode 100644 index 000000000..43a79884f --- /dev/null +++ b/crates/project/src/ignored_entries.rs @@ -0,0 +1,197 @@ +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(GitIgnoreRules::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( + GitIgnoreRules::without_global_excludes(root), + root, + directory, + ) +} + +fn collect_ignored_entry_names( + mut rules: GitIgnoreRules, + 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\n[z-a]\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..4a6e77bfb 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,5 +1,7 @@ pub mod file_mutations; pub mod ide_recents; +pub mod ignore_rules; +pub mod ignored_entries; pub mod local_fs; pub mod path_guard; pub mod shell_file_mutations; 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 aa6c3d557..000000000 --- a/crates/project/src/watcher/ignore_rules.rs +++ /dev/null @@ -1,321 +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>, -} - -impl IgnoreRules { - pub fn new(root: impl Into) -> Self { - Self { - root: root.into(), - per_directory: HashMap::new(), - repository: None, - } - } - - /// 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, checking the ignore files of every - /// directory from the deepest one up to the root, then `.git/info/exclude` and the global - /// excludes file. - pub fn is_ignored(&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); - 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_path_or_any_parents(&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, _) = GitignoreBuilder::new(&self.root).build_global(); - - 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 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 86a35b5b9..e9085cfe3 100644 --- a/crates/project/src/watcher/mod.rs +++ b/crates/project/src/watcher/mod.rs @@ -3,14 +3,15 @@ //! debounce window. mod batch; -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}"); + } + } +} 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..97454396e --- /dev/null +++ b/src/features/file-system/api/ignored-entries.ts @@ -0,0 +1,65 @@ +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"; + +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; + } +} + +interface IgnorableEntry { + name: string; + ignored?: boolean; +} + +export function markIgnoredEntries( + entries: T[], + ignoredNames: ReadonlySet, +): T[] { + 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..1158f325f 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,16 @@ 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. 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 getWorkspaceResourceProvider(path).readDirectory(path, workspaceRoot); + return markIgnoredEntriesIn( + path, + workspaceRoot, + getWorkspaceResourceProvider(path).readDirectory(path, workspaceRoot), + ); } 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 9f90f2960..8b84c607f 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 { @@ -1226,12 +1244,13 @@ const createFileSystemStore = (workspaceId: string): StoreApi { @@ -1995,8 +2015,8 @@ const createFileSystemStore = (workspaceId: string): StoreApi { state.files = addFileToTree(state.files, directoryPath, newFile); @@ -2232,12 +2248,11 @@ const createFileSystemStore = (workspaceId: string): StoreApi { state.files = addFileToTree(state.files, parentPath, newFolder); @@ -2258,7 +2273,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..4aa0cefab 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 }, @@ -360,6 +376,25 @@ describe("remote and WSL workspaces", () => { 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/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/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"], }, { 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([]);