Skip to content

Commit 2d0bc98

Browse files
committed
SMB/MTP: Listing updates after mutations and external changes
Unified change notification system for all volume types. Fixes "listing doesn't update after create/delete/rename" on SMB and MTP shares. - Add `notify_directory_changed` unified entry point with `DirectoryChange` enum (Added/Removed/Modified/Renamed/FullRefresh) for incremental listing cache patches - Add `notify_mutation` on `Volume` trait — called after each successful mutation, triggers instant listing update via the volume's own protocol (smb2/MTP), not `std::fs` - Add smb2 background watcher (dedicated connection, `CHANGE_NOTIFY` long-poll) for external changes with 200ms debounce and 50-event threshold - Fix `handle_directory_change` to use `Volume::list_directory` instead of raw `std::fs::read_dir` — works for all volume types now - Move sequence counter from `WatchedDirectory` to `CachedListing` so `directory-diff` events emit for non-FSEvents volumes - Add `volume_id` field to `SmbVolume` and `MtpVolume` for notification routing - Add `find_listings_for_path_on_volume` with volume_id filtering - Fix rename having no listing notification on any volume type (pre-existing bug) - Run watcher event processing via `spawn_blocking` to avoid `block_on`-from-async panics - `SmbVolume::supports_local_fs_access` returns `false` (skips legacy `std::fs` synthetic diff path)
1 parent 156a30e commit 2d0bc98

17 files changed

Lines changed: 1674 additions & 110 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ immediately to business-logic modules. No significant logic lives here.
1616
| `network.rs` | SMB/network shares | Discovery, share listing, keychain, mounting. |
1717
| `font_metrics.rs` | Font metrics cache | `store_font_metrics`, `has_font_metrics` |
1818
| `icons.rs` | File icons | `get_icons`, `refresh_directory_icons`, cache clear |
19-
| `rename.rs` | Rename / trash | `move_to_trash` (delegates to `write_operations::trash::move_to_trash_sync`), `check_rename_permission`, `check_rename_validity`, `rename_file` |
19+
| `rename.rs` | Rename / trash | `move_to_trash` (delegates to `write_operations::trash::move_to_trash_sync`), `check_rename_permission`, `check_rename_validity`, `rename_file`. `rename_file` calls `notify_mutation` after success to update the listing cache (both local and volume-aware paths). |
2020
| `file_viewer.rs` | File viewer | Session lifecycle, line search, word wrap, menu state |
2121
| `ui.rs` | UI / menu | Context menu, Finder reveal, clipboard, Quick Look, Get Info, view mode, `set_menu_context` (enables/disables file-scoped menu items based on window focus) |
2222
| `settings.rs` | Settings | Port availability check, watcher debounce setting, menu accelerator updates |

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

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,7 @@ fn should_emit_synthetic_diff(volume_id: Option<&str>) -> bool {
873873
fn emit_synthetic_entry_diff(app: &tauri::AppHandle, entry_path: &Path, parent_path: &Path) {
874874
use crate::file_system::listing::reading::get_single_entry;
875875
use crate::file_system::listing::{find_listings_for_path, insert_entry_sorted};
876-
use crate::file_system::watcher::{DiffChange, DirectoryDiff, WATCHER_MANAGER};
876+
use crate::file_system::watcher::{DiffChange, DirectoryDiff};
877877
use tauri::Emitter;
878878

879879
// 1. Construct a FileEntry for the new entry
@@ -901,18 +901,9 @@ fn emit_synthetic_entry_diff(app: &tauri::AppHandle, entry_path: &Path, parent_p
901901
continue; // Already exists or listing gone
902902
};
903903

904-
// Increment sequence in WATCHER_MANAGER (after LISTING_CACHE lock is released)
905-
let sequence = {
906-
let mut manager = match WATCHER_MANAGER.write() {
907-
Ok(m) => m,
908-
Err(_) => continue,
909-
};
910-
let watch = match manager.watches.get_mut(&listing_id) {
911-
Some(w) => w,
912-
None => continue,
913-
};
914-
watch.sequence += 1;
915-
watch.sequence
904+
// Increment sequence on CachedListing (after LISTING_CACHE write lock is released)
905+
let Some(sequence) = crate::file_system::listing::increment_sequence(&listing_id) else {
906+
continue;
916907
};
917908

918909
let diff = DirectoryDiff {

apps/desktop/src-tauri/src/commands/rename.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ pub async fn rename_file(from: String, to: String, force: bool, volume_id: Optio
106106
let volume_id_str = volume_id.unwrap_or_else(|| "root".to_string());
107107

108108
if volume_id_str != "root" {
109-
// Volume-aware rename (MTP and other non-local volumes)
109+
// Volume-aware rename (MTP, SMB, and other non-local volumes).
110+
// The volume's `rename` method calls `notify_mutation` internally,
111+
// so the listing cache is updated automatically.
110112
let volume = crate::file_system::get_volume_manager()
111113
.get(&volume_id_str)
112114
.ok_or_else(|| IpcError::from_err(format!("Volume '{}' not found", volume_id_str)))?;
@@ -131,6 +133,9 @@ pub async fn rename_file(from: String, to: String, force: bool, volume_id: Optio
131133
let from_path = PathBuf::from(&from_expanded);
132134
let to_path = PathBuf::from(&to_expanded);
133135

136+
let from_for_notify = from_path.clone();
137+
let to_for_notify = to_path.clone();
138+
134139
tokio::time::timeout(
135140
Duration::from_secs(5),
136141
tokio::task::spawn_blocking(move || {
@@ -143,7 +148,51 @@ pub async fn rename_file(from: String, to: String, force: bool, volume_id: Optio
143148
.await
144149
.map_err(|_| IpcError::timeout())?
145150
.map_err(|e| IpcError::from_err(format!("Task failed: {}", e)))?
146-
.map_err(IpcError::from_err)
151+
.map_err(IpcError::from_err)?;
152+
153+
// Notify listing cache about the rename (fixes pre-existing bug where
154+
// rename had no listing notification on any volume type).
155+
notify_rename_in_listing(&volume_id_str, &from_for_notify, &to_for_notify);
156+
157+
Ok(())
158+
}
159+
}
160+
161+
/// Notifies the listing cache about a rename via the volume's `notify_mutation`.
162+
fn notify_rename_in_listing(volume_id: &str, from: &Path, to: &Path) {
163+
use crate::file_system::volume::MutationEvent;
164+
165+
let volume = match crate::file_system::get_volume_manager().get(volume_id) {
166+
Some(v) => v,
167+
None => return,
168+
};
169+
170+
if let (Some(from_parent), Some(from_name), Some(to_name)) = (from.parent(), from.file_name(), to.file_name()) {
171+
if from.parent() == to.parent() {
172+
// Same-directory rename
173+
volume.notify_mutation(
174+
volume_id,
175+
from_parent,
176+
MutationEvent::Renamed {
177+
from: from_name.to_string_lossy().to_string(),
178+
to: to_name.to_string_lossy().to_string(),
179+
},
180+
);
181+
} else {
182+
// Cross-directory move
183+
volume.notify_mutation(
184+
volume_id,
185+
from_parent,
186+
MutationEvent::Deleted(from_name.to_string_lossy().to_string()),
187+
);
188+
if let Some(to_parent) = to.parent() {
189+
volume.notify_mutation(
190+
volume_id,
191+
to_parent,
192+
MutationEvent::Created(to_name.to_string_lossy().to_string()),
193+
);
194+
}
195+
}
147196
}
148197
}
149198

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Frontend Backend
4646

4747
**LISTING_CACHE**: Global `RwLock<HashMap<String, CachedListing>>`
4848
**Key**: `listing_id` (UUID per navigation)
49-
**Value**: `CachedListing { volume_id, path, entries, sort_by, sort_order, directory_sort_mode }`
49+
**Value**: `CachedListing { volume_id, path, entries, sort_by, sort_order, directory_sort_mode, sequence }`
5050

5151
**Lifecycle**:
5252
1. `list_directory_start_streaming()` receives listing ID from frontend, spawns task
@@ -84,6 +84,9 @@ Frontend Backend
8484
**Decision**: Font metrics in Rust binary cache, not frontend canvas measurement
8585
**Why**: Measuring 50k filenames in JS is slow. Rust precomputes metrics for system fonts, stores in `.bin` cache. `calculate_max_width()` is a hash lookup.
8686

87+
**Decision**: Sequence counter lives on `CachedListing`, not on `WatchedDirectory`
88+
**Why**: SMB and MTP volumes don't use FSEvents (`supports_watching() == false`), so they never get a `WatchedDirectory` entry. With the sequence on the watcher, `increment_sequence` returned `None` and `directory-diff` events were never emitted for those volumes. Moving the `AtomicU64` to `CachedListing` makes it work for all volume types. The FSEvents watcher path also uses this same counter now.
89+
8790
**Decision**: File watcher starts AFTER listing complete
8891
**Why**: Watcher diffs rely on cached entries. Starting before cache is populated would miss initial state.
8992

@@ -105,12 +108,26 @@ Frontend Backend
105108

106109
Used by the watcher's incremental path and synthetic mkdir to patch listings without full re-reads:
107110
- `find_listings_for_path(path)` — returns all listing IDs whose directory matches the given path (multiple panes/tabs may show the same directory)
111+
- `find_listings_for_path_on_volume(volume_id, path)` — same, but also filters by volume ID. Prevents false matches when two volumes serve overlapping paths.
108112
- `insert_entry_sorted(listing_id, entry)` — inserts an entry in sorted position, returns the insertion index
109113
- `remove_entry_by_path(listing_id, path)` — removes an entry by its file path, returns the removed index and entry
110114
- `update_entry_sorted(listing_id, entry)` — updates an existing entry (remove + re-insert if sort position changed), returns `ModifyResult`
111115
- `has_entry(listing_id, path)` — checks if a path exists in the cached listing (used to classify watcher events as add vs modify)
112116
- `get_listing_path(listing_id)` — returns the directory path for a listing (used to filter watcher events to direct children)
113117

118+
### Change notification API (caching.rs)
119+
120+
`notify_directory_changed(volume_id, parent_path, change)` — unified entry point for notifying the listing system that a directory changed on a volume. Accepts a `DirectoryChange` enum:
121+
- `Added(FileEntry)` — single entry added, patches cache via `insert_entry_sorted`
122+
- `Removed(String)` — single entry removed by name, patches cache via `remove_entry_by_path`
123+
- `Modified(FileEntry)` — single entry modified, patches cache via `update_entry_sorted`
124+
- `Renamed { old_name, new_entry }` — same-dir rename, remove old + insert new
125+
- `FullRefresh` — re-reads directory via Volume trait, computes diff against cache
126+
127+
All variants enrich entries with index data and emit `directory-diff` events. Natural deduplication: `insert_entry_sorted` returns `None` for duplicates, `remove_entry_by_path` returns `None` if already removed.
128+
129+
**Callers**: `Volume::notify_mutation()` (called after each successful create/delete/rename on all volume types) and the `rename_file` command (for local filesystem renames). The old `emit_synthetic_entry_diff` remains as a legacy fallback for `create_file`/`create_directory` on volumes where `supports_local_fs_access()` returns `true`.
130+
114131
**Gotcha**: Background task runs to completion even if cancelled on frontend
115132
**Why**: `loadGeneration` discards stale results, but Rust keeps iterating. Mitigation: `AtomicBool` checked per-entry stops early.
116133

0 commit comments

Comments
 (0)