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 <[email protected]>
This commit is contained in:
arookieofcanda-rookie-of-C-language authored and GitHub committed 2026-10-30 19:28:35 +08:00
1 parent ce981e4fd6
commit b983e3d0b2
4 files changed
+111 -4

No files matched your search

+52
View File
@@ -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)]
+55 -3
View File
@@ -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));
+3 -1
View File
@@ -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 { .. })));
+1
View File
@@ -56,6 +56,7 @@ class OverviewLane {
u.docChanged ||
u.geometryChanged ||
u.heightChanged ||
u.viewportChanged ||
u.selectionSet ||
u.startState.facet(scrollbarMarks) !== u.state.facet(scrollbarMarks)
)