fix(macos): detect untracked file deletion via Finder as remove event

Finder moves files to Trash (rename) instead of deleting them, which
may be reported as `Modify(Name)` rather than `Remove` by the file
watcher. This caused untracked files to lose their red color without
being removed from the tree. Fix by checking disk existence in the
event batch emitter and promoting the kind to "remove" when a changed
path no longer exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-13 22:11:18 +08:00
parent 268b33d2ee
commit 273a8cd7ff

View File

@@ -2466,16 +2466,6 @@ impl WatchEventBatch {
!self.overflowed && self.changed_paths.is_empty() !self.overflowed && self.changed_paths.is_empty()
} }
fn kind(&self) -> &'static str {
if self.has_remove {
"remove"
} else if self.has_create {
"create"
} else {
"modify"
}
}
fn ingest_event(&mut self, root_canonical: &Path, event: notify::Event) { fn ingest_event(&mut self, root_canonical: &Path, event: notify::Event) {
if !should_emit_watch_event(&event.kind) { if !should_emit_watch_event(&event.kind) {
return; return;
@@ -2521,7 +2511,7 @@ impl WatchEventBatch {
} }
} }
fn emit(&self, emitter: &EventEmitter, root_display: &str) { fn emit(&self, emitter: &EventEmitter, root_display: &str, root_canonical: &Path) {
if self.is_empty() { if self.is_empty() {
return; return;
} }
@@ -2534,6 +2524,24 @@ impl WatchEventBatch {
paths paths
}; };
// On macOS, Finder trash (move-to-trash) may be reported as a rename
// (`Modify(Name)`) instead of `Remove`, so `has_remove` is never set.
// Detect this by checking whether any changed path no longer exists on
// disk and promote the event kind to "remove" accordingly.
let has_missing_path = !self.has_remove
&& !self.overflowed
&& self
.changed_paths
.iter()
.any(|p| !root_canonical.join(p).exists());
let kind = if self.has_remove || has_missing_path {
"remove"
} else if self.has_create {
"create"
} else {
"modify"
};
let payload = FileTreeChangedEvent { let payload = FileTreeChangedEvent {
root_path: root_display.to_string(), root_path: root_display.to_string(),
refresh_git_status: if self.overflowed { refresh_git_status: if self.overflowed {
@@ -2542,7 +2550,7 @@ impl WatchEventBatch {
should_refresh_git_status_for_paths(root_display, &changed_paths) should_refresh_git_status_for_paths(root_display, &changed_paths)
}, },
changed_paths, changed_paths,
kind: self.kind().to_string(), kind: kind.to_string(),
full_reload: self.overflowed, full_reload: self.overflowed,
}; };
@@ -2585,21 +2593,21 @@ fn run_file_watch_event_loop(
}; };
if should_flush { if should_flush {
batch.emit(&emitter, &root_display); batch.emit(&emitter, &root_display, &root_canonical);
batch.clear(); batch.clear();
batch_started_at = None; batch_started_at = None;
} }
} }
Err(mpsc::RecvTimeoutError::Timeout) => { Err(mpsc::RecvTimeoutError::Timeout) => {
if !batch.is_empty() { if !batch.is_empty() {
batch.emit(&emitter, &root_display); batch.emit(&emitter, &root_display, &root_canonical);
batch.clear(); batch.clear();
batch_started_at = None; batch_started_at = None;
} }
} }
Err(mpsc::RecvTimeoutError::Disconnected) => { Err(mpsc::RecvTimeoutError::Disconnected) => {
if !batch.is_empty() { if !batch.is_empty() {
batch.emit(&emitter, &root_display); batch.emit(&emitter, &root_display, &root_canonical);
} }
break; break;
} }