From b983e3d0b20e8c843ba5704e89007c5edfe1f737 Mon Sep 17 00:00:00 2001 From: arookieofc Date: Fri, 30 Oct 2026 19:28:35 +0800 Subject: [PATCH] fix(vfs): report excluded and ignored directories themselves; keep lane marks aligned while scrolling (#412) The file watcher dropped events for a directory that matched an exclude or .gitignore rule together with its contents, so a target/ or node_modules/ created by a build never appeared in the Project view. The directory's own create/remove now passes through (it shows as excluded/ignored) while its contents stay filtered. Scrollbar lane marks also recompute on viewport changes, since unmeasured line heights shift as more of the document is measured. Refs #383 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-vfs/src/rules.rs | 52 +++++++++++++++++++++++++++++++++ crates/ide-vfs/src/watch.rs | 58 +++++++++++++++++++++++++++++++++++-- crates/ide-vfs/tests/vfs.rs | 4 ++- src/editor/core/cm/lanes.ts | 1 + 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/ide-vfs/src/rules.rs b/crates/ide-vfs/src/rules.rs index b118b15bf..59a45cbd8 100644 --- a/crates/ide-vfs/src/rules.rs +++ b/crates/ide-vfs/src/rules.rs @@ -224,6 +224,58 @@ impl GitignoreStack { } false } + + /// Like [`is_ignored`], but only checks whether `path` itself matches — + /// ignored ancestors do not count. Lets a directory like `target/` (itself + /// ignored) still emit its own create/remove so the tree can show it + /// appearing marked as ignored, while files inside it stay filtered out. + pub fn is_ignored_self(&self, path: &Path, is_dir: bool) -> bool { + if let Some(parent) = path.parent() { + let nested = self + .nested + .lock() + .entry(parent.to_path_buf()) + .or_insert_with(|| { + find_repo_root(parent) + .filter(|root| self.repo_root.as_ref() != Some(root)) + .map(|_| Arc::new(Self::new(parent))) + }) + .clone(); + if let Some(nested) = nested { + return nested.is_ignored_self(path, is_dir); + } + } + if self.repo_root.as_deref() == Some(path) { + return false; + } + for dir in path.ancestors().skip(1) { + if let Some(m) = self.matcher_for(dir) + && let Ok(rel) = path.strip_prefix(dir) + { + match m.matched(rel, is_dir) { + Match::Ignore(_) => return true, + Match::Whitelist(_) => return false, + Match::None => {} + } + } + if self.repo_root.as_deref() == Some(dir) { + break; + } + } + if let Some(root) = &self.repo_root + && let Ok(rel) = path.strip_prefix(root) + && !rel.as_os_str().is_empty() + { + for gi in self.repo_level.read().iter() { + match gi.matched(rel, is_dir) { + Match::Ignore(_) => return true, + Match::Whitelist(_) => return false, + Match::None => {} + } + } + } + false + } } #[cfg(test)] diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index b2d3bc2ad..ccc2dfb5c 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -540,11 +540,21 @@ impl EventFilter { (c, _) => c, }; if let Ok(rel) = path.strip_prefix(&self.root) { - if self.excludes.read().is_excluded(rel) { + let is_dir = meta.as_ref().is_some_and(|m| m.is_dir()); + // Directories that match an exclude / gitignore rule themselves + // still report their own create / remove / modify — the tree + // needs to see `target/` or `node_modules/` appear (marked + // excluded/ignored) instead of silently swallowing the events + // `client install` produces. + // A removed path has no metadata to tell file from directory; + // let it through on the off chance it is the excluded directory + // itself — reporting one extra file removal is harmless. + let self_excluded = (is_dir || change == Change::Removed) + && (self.excludes.read().is_match(rel) || self.gitignore.is_ignored_self(&path, true)); + if !self_excluded && self.excludes.read().is_excluded(rel) { continue; } - let is_dir = meta.as_ref().is_some_and(|m| m.is_dir()); - if self.gitignore.is_ignored(&path, is_dir) { + if !self_excluded && self.gitignore.is_ignored(&path, is_dir) { continue; } } @@ -621,6 +631,48 @@ mod tests { assert!(batch.removed.is_empty()); } + /// `client install` produces an excluded / ignored directory wholesale: + /// the directory's own create passes through so the tree shows it appear + /// (marked excluded/ignored), while everything inside stays filtered out. + #[test] + fn excluded_and_ignored_directories_report_themselves_but_not_contents() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::write(root.join(".gitignore"), "dist\n*.log\n").unwrap(); + std::fs::create_dir_all(root.join("target/debug")).unwrap(); + std::fs::create_dir_all(root.join("dist/assets")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + for f in ["target/debug/out.o", "dist/app.js", "src/main.rs", "debug.log"] { + std::fs::write(root.join(f), "x").unwrap(); + } + let filter = EventFilter::new(root.clone(), &["**/target".into()]); + let create_dir = EventKind::Create(CreateKind::Folder); + let create_file = EventKind::Create(CreateKind::File); + + // The ignored / excluded directory itself is reported. + let batch = filter.batch(&[ev(create_dir, vec![root.join("target")]), ev(create_dir, vec![root.join("dist")])]); + assert_eq!(batch.created, vec![root.join("dist"), root.join("target")]); + + // Everything inside the ignored / excluded directories stays filtered, + // as do files matching ignore patterns at the project root. + let batch = filter.batch(&[ + ev(create_file, vec![root.join("target/debug/out.o")]), + ev(create_file, vec![root.join("dist/app.js")]), + ev(create_file, vec![root.join("debug.log")]), + ]); + assert!(batch.is_empty(), "{batch:?}"); + + // Sibling files in unignored directories still come through. + let batch = filter.batch(&[ev(create_file, vec![root.join("src/main.rs")])]); + assert_eq!(batch.created, vec![root.join("src/main.rs")]); + + // Removing the ignored / excluded directory reports the directory. + std::fs::remove_dir_all(root.join("target")).unwrap(); + let batch = filter.batch(&[ev(EventKind::Remove(RemoveKind::Folder), vec![root.join("target")])]); + assert_eq!(batch.removed, vec![root.join("target")]); + } + #[test] fn git_internal_paths() { let g = |s: &str| EventFilter::git_internal(Path::new(s)); diff --git a/crates/ide-vfs/tests/vfs.rs b/crates/ide-vfs/tests/vfs.rs index 029b8c13f..267bff2e5 100644 --- a/crates/ide-vfs/tests/vfs.rs +++ b/crates/ide-vfs/tests/vfs.rs @@ -311,7 +311,9 @@ fn watcher_reports_changes() { ); let paths = all(&batches.lock()); assert!(!paths.contains(&r.join("noise.log"))); - assert!(!paths.iter().any(|p| p.starts_with(r.join("target")))); + // `target/` itself is reported so the tree sees the excluded directory + // appear; its contents still stay filtered out. + assert!(!paths.iter().any(|p| p.starts_with(r.join("target")) && *p != r.join("target"))); assert!(!paths.contains(&r.join(".git/objects/aa"))); assert!(sunk.lock().iter().any(|e| matches!(e, IdeEvent::FsChanged { .. }))); diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts index a26e4cc66..cb876831c 100644 --- a/src/editor/core/cm/lanes.ts +++ b/src/editor/core/cm/lanes.ts @@ -56,6 +56,7 @@ class OverviewLane { u.docChanged || u.geometryChanged || u.heightChanged || + u.viewportChanged || u.selectionSet || u.startState.facet(scrollbarMarks) !== u.state.facet(scrollbarMarks) )