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();