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.
This commit is contained in:
l0ng-ai
2026-10-31 03:21:59 +08:00
committed by GitHub
parent 00108a669e
commit fcc36d67e7
7 changed files with 58 additions and 7 deletions
+4
View File
@@ -21,6 +21,10 @@ pub enum IdeEvent {
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
dirs: Vec<crate::IpcPath>,
},
/// The backend changed a document (reload from disk, formatting, refactoring).
/// The UI applies `changes` on top of `base_version`, yielding `version`.
DocPatched {
+8 -1
View File
@@ -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<PathBuf>,
/// 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<PathBuf>,
}
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()
}
}
+13 -5
View File
@@ -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")])]);
+2
View File
@@ -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`.
+10 -1
View File
@@ -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());
+15
View File
@@ -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);
+6
View File
@@ -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();