Skip to content

Commit b0ccff5

Browse files
committed
Bugfix: Google Drive panes refresh live after rename, create, and delete
Renaming (or creating/deleting) a file in a Google Drive "My Drive" pane left the pane showing the old listing until the user navigated away and back. Local drives, SMB, iCloud, and Dropbox all refreshed fine. Root cause: Google Drive for desktop exposes "My Drive" as a **symlink** (`…/CloudStorage/GoogleDrive-…/My Drive` → `~/My Drive`), whereas iCloud and Dropbox mount real directories. The pane's FSEvents watcher is rooted at the symlinked listing dir, and FSEvents resolves the symlink and reports events under the real target (`~/My Drive/…`). `rebase_event_path` only firmlink-normalized the paths (`/tmp` → `/private/tmp`, …), so the real-target event path never matched the symlink-form listing path and every event was dropped — no `directory-diff`, no refresh. The indexing watcher was unaffected (it's volume-root-rooted and matches by inode), which is why the change still showed up after a re-navigate. Fix: `handle_directory_change_incremental` now `canonicalize`s the watched dir once per debounce batch and `rebase_event_path` matches the event's parent against that symlink-resolved form too, still rebasing onto the listing's own path space so cache lookups and diff indices stay consistent. The canonicalize is one `realpath` per batch, the same syscall class as the per-event stats already there. Verified: FSEvents probe confirmed a symlink-rooted stream reports events under the real target; `realpath` on the live Drive path yields `~/My Drive`, matching what FSEvents reports. Red/green unit test `test_rebase_event_path_resolves_symlinked_watch_root`.
1 parent cc6681c commit b0ccff5

3 files changed

Lines changed: 75 additions & 18 deletions

File tree

apps/desktop/src-tauri/src/file_system/CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ via `set_tags` / `toggle_color` behind the `toggle_tags` command).
2929
FSEvents reports canonical paths (`/private/tmp/…`) while `LISTING_CACHE` holds the user-navigated form (`/tmp/…`).
3030
The incremental handler compares the firmlink-normalized forms (`indexing::firmlinks::normalize_path`) and rebases
3131
matching event paths onto the listing's directory. A raw `path.parent() == dir_path` comparison silently dropped every
32-
event for listings under `/tmp`, `/var`, and `/etc`, so the pane never updated until the user re-navigated.
32+
event for listings under `/tmp`, `/var`, and `/etc`, so the pane never updated until the user re-navigated. FSEvents
33+
also resolves a **symlinked watch root** and reports events under the real target, so the handler also matches against
34+
the `canonicalize`d watch dir. This bit Google Drive, whose `My Drive` is a symlink to `~/My Drive`, so rename/create/
35+
delete never refreshed the pane; iCloud and Dropbox mount real directories and hit the firmlink path instead.
3336
- **`cloud_actions.rs` is iCloud Drive only.** The `NSFileProviderManager` host-side methods look cross-provider but are
3437
reserved for the app that *bundles* the File Provider extension, so third-party apps get
3538
`NSFileProviderErrorProviderNotFound`. The `FileManager` ubiquity APIs route through iCloud's path and accept any URL

apps/desktop/src-tauri/src/file_system/watcher.rs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -176,22 +176,31 @@ pub fn stop_watching(listing_id: &str) {
176176
/// Maps an FSEvents/inotify path to the watched listing's path space, returning the
177177
/// rebased path when the event is for a direct child of the watched directory.
178178
///
179-
/// On macOS, FSEvents reports canonical paths (`/private/tmp/…`) while the listing
180-
/// cache holds the user-navigated form (`/tmp/…`). A raw parent comparison silently
181-
/// drops every event for listings under `/tmp`, `/var`, or `/etc` — the pane then
182-
/// never updates until the user re-navigates. The comparison runs on the
183-
/// symlink/firmlink-normalized forms (`firmlinks::normalize_path`, the same
184-
/// canonicalization the index uses), and the returned path is rebased onto
185-
/// `dir_path` so cache lookups (`has_entry`) and diff entries stay consistent with
186-
/// the listing's own paths.
187-
pub(super) fn rebase_event_path(event_path: &Path, dir_path: &Path) -> Option<PathBuf> {
179+
/// Two path-form mismatches make a raw `parent == dir_path` comparison silently drop
180+
/// events, leaving the pane stale until the user re-navigates:
181+
///
182+
/// 1. **Firmlinks / well-known `/private` symlinks.** FSEvents reports canonical paths
183+
/// (`/private/tmp/…`) while the listing cache holds the user-navigated form
184+
/// (`/tmp/…`). `firmlinks::normalize_path` (the same canonicalization the index
185+
/// uses) aligns both.
186+
/// 2. **A symlinked watch root.** Google Drive exposes "My Drive" as a symlink
187+
/// (`…/CloudStorage/GoogleDrive-…/My Drive` → `~/My Drive`), and FSEvents resolves
188+
/// the watched symlink and reports events under the real target. `canonical_dir` is
189+
/// `dir_path` with its symlinks resolved (see the caller); matching against it
190+
/// catches this case. iCloud and Dropbox mount real directories, so they hit the
191+
/// firmlink path above; only Google Drive needs this branch.
192+
///
193+
/// The returned path is always rebased onto `dir_path` so cache lookups (`has_entry`)
194+
/// and diff entries stay consistent with the listing's own path space.
195+
pub(super) fn rebase_event_path(event_path: &Path, dir_path: &Path, canonical_dir: &Path) -> Option<PathBuf> {
188196
let parent = event_path.parent()?;
189197
if parent == dir_path {
190198
return Some(event_path.to_path_buf());
191199
}
192200
let parent_normalized = crate::indexing::firmlinks::normalize_path(&parent.to_string_lossy());
193201
let dir_normalized = crate::indexing::firmlinks::normalize_path(&dir_path.to_string_lossy());
194-
if parent_normalized == dir_normalized {
202+
let canonical_normalized = crate::indexing::firmlinks::normalize_path(&canonical_dir.to_string_lossy());
203+
if parent_normalized == dir_normalized || parent_normalized == canonical_normalized {
195204
event_path.file_name().map(|name| dir_path.join(name))
196205
} else {
197206
None
@@ -221,6 +230,13 @@ fn handle_directory_change_incremental(listing_id: &str, events: Vec<DebouncedEv
221230
return;
222231
};
223232

233+
// Resolve the watched dir's symlinks once per batch, so events FSEvents reports
234+
// under a symlinked root's real target (Google Drive's "My Drive" → `~/My Drive`)
235+
// still match. This is a `realpath` syscall like the per-event `get_single_entry`
236+
// stats below, so it adds no new blocking class here; falls back to `dir_path` if
237+
// the dir vanished mid-batch (the re-read path handles a deleted watch root).
238+
let canonical_dir = std::fs::canonicalize(&dir_path).unwrap_or_else(|_| dir_path.clone());
239+
224240
// Collect unique direct-child paths, skipping access events. Event paths are
225241
// rebased into the listing's path space (see `rebase_event_path`).
226242
let mut unique_paths: HashSet<PathBuf> = HashSet::new();
@@ -229,7 +245,7 @@ fn handle_directory_change_incremental(listing_id: &str, events: Vec<DebouncedEv
229245
continue;
230246
}
231247
for path in &event.paths {
232-
if let Some(rebased) = rebase_event_path(path, &dir_path) {
248+
if let Some(rebased) = rebase_event_path(path, &dir_path, &canonical_dir) {
233249
unique_paths.insert(rebased);
234250
}
235251
}

apps/desktop/src-tauri/src/file_system/watcher_test.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ fn make_entry_in(name: &str, dir: &str, size: Option<u64>) -> FileEntry {
3131

3232
#[test]
3333
fn test_rebase_event_path_exact_parent_match() {
34+
let dir = Path::new("/Users/jane/docs");
3435
assert_eq!(
35-
rebase_event_path(Path::new("/Users/jane/docs/file.txt"), Path::new("/Users/jane/docs")),
36+
rebase_event_path(Path::new("/Users/jane/docs/file.txt"), dir, dir),
3637
Some(PathBuf::from("/Users/jane/docs/file.txt"))
3738
);
3839
}
@@ -42,32 +43,69 @@ fn test_rebase_event_path_exact_parent_match() {
4243
fn test_rebase_event_path_resolves_private_symlink() {
4344
// Pre-fix, FSEvents' canonical /private/tmp/... paths never matched a listing
4445
// watched as /tmp/..., so the pane silently never updated.
46+
let dir = Path::new("/tmp/work");
4547
assert_eq!(
46-
rebase_event_path(Path::new("/private/tmp/work/new-dir"), Path::new("/tmp/work")),
48+
rebase_event_path(Path::new("/private/tmp/work/new-dir"), dir, dir),
4749
Some(PathBuf::from("/tmp/work/new-dir")),
4850
"canonical event path must rebase into the listing's /tmp path space"
4951
);
5052
// And the inverse orientation (listing opened via the canonical path)
53+
let dir = Path::new("/private/tmp/work");
5154
assert_eq!(
52-
rebase_event_path(Path::new("/tmp/work/new-dir"), Path::new("/private/tmp/work")),
55+
rebase_event_path(Path::new("/tmp/work/new-dir"), dir, dir),
5356
Some(PathBuf::from("/private/tmp/work/new-dir"))
5457
);
5558
}
5659

60+
#[test]
61+
fn test_rebase_event_path_resolves_symlinked_watch_root() {
62+
// Google Drive exposes "My Drive" as a symlink (…/CloudStorage/GoogleDrive-…/My Drive
63+
// → ~/My Drive). FSEvents resolves the watched symlink and reports events under the
64+
// real target, which never matched the listing's symlink-form path, so the pane
65+
// silently never updated on rename/create/delete. `canonical_dir` (the symlink-
66+
// resolved watch root) closes the gap; the rebase still lands in the listing's own
67+
// path space.
68+
let listing_dir = Path::new("/Users/jane/Library/CloudStorage/GoogleDrive-jane/My Drive");
69+
let canonical_dir = Path::new("/Users/jane/My Drive");
70+
assert_eq!(
71+
rebase_event_path(Path::new("/Users/jane/My Drive/photo.jpg"), listing_dir, canonical_dir),
72+
Some(PathBuf::from(
73+
"/Users/jane/Library/CloudStorage/GoogleDrive-jane/My Drive/photo.jpg"
74+
)),
75+
"event under the symlink-resolved target must rebase into the listing's own path space"
76+
);
77+
// A sibling of the real target, outside the watched dir, stays rejected.
78+
assert_eq!(
79+
rebase_event_path(Path::new("/Users/jane/Other/photo.jpg"), listing_dir, canonical_dir),
80+
None
81+
);
82+
}
83+
5784
#[test]
5885
fn test_rebase_event_path_rejects_non_children() {
5986
// Different directory
6087
assert_eq!(
61-
rebase_event_path(Path::new("/private/tmp/other/file"), Path::new("/tmp/work")),
88+
rebase_event_path(
89+
Path::new("/private/tmp/other/file"),
90+
Path::new("/tmp/work"),
91+
Path::new("/tmp/work")
92+
),
6293
None
6394
);
6495
// Deeper descendant (watcher is non-recursive; only direct children count)
6596
assert_eq!(
66-
rebase_event_path(Path::new("/private/tmp/work/sub/file"), Path::new("/tmp/work")),
97+
rebase_event_path(
98+
Path::new("/private/tmp/work/sub/file"),
99+
Path::new("/tmp/work"),
100+
Path::new("/tmp/work")
101+
),
67102
None
68103
);
69104
// Prefix-similar but distinct dir name must not match (/tmpdir is not /tmp)
70-
assert_eq!(rebase_event_path(Path::new("/tmpdir/file"), Path::new("/tmp")), None);
105+
assert_eq!(
106+
rebase_event_path(Path::new("/tmpdir/file"), Path::new("/tmp"), Path::new("/tmp")),
107+
None
108+
);
71109
}
72110

73111
#[test]

0 commit comments

Comments
 (0)