From fcc36d67e79f26bec6ae25b492fce88d3b4a30a2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:21:59 +0800 Subject: [PATCH] fix(project-tree): refresh expanded excluded folders when files change The watcher drops changes inside excluded and ignored folders so the index and version control don't react to build output. That also kept an expanded `target` from showing files a build added or removed. Those folders are now reported in their own event, which only the Project view handles, and only for folders it has loaded. Changes to existing files aren't reported, since the listing stays the same. --- crates/ide-core/src/event.rs | 4 ++++ crates/ide-vfs/src/lib.rs | 9 ++++++++- crates/ide-vfs/src/watch.rs | 18 +++++++++++++----- src/bindings.ts | 2 ++ src/features/project-tree/index.ts | 11 ++++++++++- src/features/project-tree/store.test.ts | 15 +++++++++++++++ src/features/project-tree/store.ts | 6 ++++++ 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 1684b27a..5e517ea1 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -21,6 +21,10 @@ pub enum IdeEvent { modified: Vec, removed: Vec, }, + /// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. + ExcludedDirsChanged { + dirs: Vec, + }, /// The backend changed a document (reload from disk, formatting, refactoring). /// The UI applies `changes` on top of `base_version`, yielding `version`. DocPatched { diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index 708bc954..e30b93c5 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -310,11 +310,18 @@ pub struct FsBatch { /// refresh as a worktree edit and schedule another `vcs_status` (which /// writes `.git/index` again — an idle refresh loop). pub vcs: Vec, + /// Folders holding changes filtered out as excluded or ignored. Only a view already + /// showing such a folder cares, so they don't count as worktree changes. + pub excluded_dirs: Vec, } impl FsBatch { pub fn is_empty(&self) -> bool { - self.created.is_empty() && self.modified.is_empty() && self.removed.is_empty() && self.vcs.is_empty() + self.created.is_empty() + && self.modified.is_empty() + && self.removed.is_empty() + && self.vcs.is_empty() + && self.excluded_dirs.is_empty() } } diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index ccc2dfb5..44dbce5b 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -87,6 +87,9 @@ impl Watcher { removed: batch.removed.iter().map(Into::into).collect(), }); } + if !batch.excluded_dirs.is_empty() { + sink.emit(IdeEvent::ExcludedDirsChanged { dirs: batch.excluded_dirs.iter().map(Into::into).collect() }); + } on_change(batch); }; let inner = match mode { @@ -520,6 +523,7 @@ impl EventFilter { } let mut batch = FsBatch::default(); + let mut excluded_dirs = BTreeSet::new(); for (path, change) in changes { if change == Change::Vcs { batch.vcs.push(path); @@ -551,10 +555,12 @@ impl EventFilter { // 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; - } - if !self_excluded && self.gitignore.is_ignored(&path, is_dir) { + if !self_excluded && (self.excludes.read().is_excluded(rel) || self.gitignore.is_ignored(&path, is_dir)) + { + // Only a file appearing or going changes what the folder lists. + if change != Change::Modified { + excluded_dirs.extend(path.parent().map(Path::to_path_buf)); + } continue; } } @@ -564,6 +570,7 @@ impl EventFilter { _ => batch.modified.push(path), } } + batch.excluded_dirs = excluded_dirs.into_iter().collect(); (batch, rule_dirs.into_iter().collect()) } } @@ -661,7 +668,8 @@ mod tests { ev(create_file, vec![root.join("dist/app.js")]), ev(create_file, vec![root.join("debug.log")]), ]); - assert!(batch.is_empty(), "{batch:?}"); + assert!(batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty(), "{batch:?}"); + assert_eq!(batch.excluded_dirs, vec![root.clone(), root.join("dist"), root.join("target/debug")]); // Sibling files in unignored directories still come through. let batch = filter.batch(&[ev(create_file, vec![root.join("src/main.rs")])]); diff --git a/src/bindings.ts b/src/bindings.ts index b62486f8..ae60c794 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3379,6 +3379,8 @@ export type IdeEvent = { type: "windowGroupsChanged" } | /** Files or directories changed on disk (after debouncing, ignore rules applied). */ { type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } | +/** Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. */ +{ type: "excludedDirsChanged"; dirs: IpcPath[] } | /** * The backend changed a document (reload from disk, formatting, refactoring). * The UI applies `changes` on top of `base_version`, yielding `version`. diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index 27ce39ac..e7b2b352 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -22,7 +22,15 @@ import { ProjectHeaderActions, useProjectHeaderMenu, useProjectTitleMenu } from import { loadHomeDir } from "./home"; import { selectedNodes } from "./operations"; import { ProjectView } from "./ProjectView"; -import { applyFsChange, initProjectTree, reloadAll, setChangedFiles, setOpenFiles, useProjectTree } from "./store"; +import { + applyFsChange, + initProjectTree, + reloadAll, + reloadLoadedDirs, + setChangedFiles, + setOpenFiles, + useProjectTree, +} from "./store"; export function activate(): () => void { loadHomeDir(); @@ -138,6 +146,7 @@ export function activate(): () => void { .map((n) => n.path), ), onIdeEvent("fsChanged", (e) => void applyFsChange(e)), + onIdeEvent("excludedDirsChanged", (e) => void reloadLoadedDirs(e.dirs)), ]; return () => { disposers.reverse().forEach((d) => d()); diff --git a/src/features/project-tree/store.test.ts b/src/features/project-tree/store.test.ts index 6c2fd5d7..032924c3 100644 --- a/src/features/project-tree/store.test.ts +++ b/src/features/project-tree/store.test.ts @@ -3,6 +3,7 @@ import { MemoryFs, setProjectFsForTests } from "./fs"; import { visibleInfo } from "./model"; import { applyFsChange, + reloadLoadedDirs, currentModel, expandAll, initProjectTree, @@ -75,6 +76,20 @@ describe("lazy loading", () => { }); }); +describe("refresh of excluded folders", () => { + it("reloads only the folders the tree has loaded", async () => { + await loadDir(ROOT); + await loadDir("/p/docs"); + const spy = vi.spyOn(fs, "readDir"); + + await reloadLoadedDirs(["/p/docs", "/p/target/never-opened"]); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("/p/docs"); + spy.mockRestore(); + }); +}); + describe("refresh on fsChanged", () => { it("reloads affected loaded folders and keeps expansion and selection", async () => { await loadDir(ROOT); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 11366c41..a94279e3 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -451,6 +451,12 @@ export async function applyFsChange(event: { created: string[]; modified: string await Promise.all(plan.reload.map((p) => loadDir(p, true))); } +/** Re-reads the folders among `dirs` the tree has loaded; the rest stay unread. */ +export async function reloadLoadedDirs(dirs: readonly string[]) { + const { listings } = get(); + await Promise.all(dirs.filter((p) => listings.has(p)).map((p) => loadDir(p, true))); +} + /** Re-reads every loaded folder (View › Reload from Disk). */ export async function reloadAll() { const { listings } = get();