Skip to content

Commit f961f19

Browse files
committed
Docs: Add cross-cutting patterns to architecture.md
Agents say it'd help them a lot.
1 parent 0fcdb13 commit f961f19

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

docs/architecture.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,140 @@ All under `apps/desktop/src-tauri/src/`.
7373
File data lives in Rust (`LISTING_CACHE`). Frontend fetches visible ranges on-demand via IPC (`getFileRange`).
7474
This avoids serializing 50k+ entries. Virtual scrolling renders only ~50 visible items.
7575

76+
### Navigation lifecycle
77+
78+
User navigates → old listing cleaned up → new listing started → events stream back → UI updates.
79+
80+
**Three navigation types, same cleanup/load sequence:**
81+
82+
| Type | Entry point | Who moves history? | Timing |
83+
|------|------------|--------------------|--------|
84+
| **Enter on folder** | `FilePane.handleNavigate``loadDirectory` | `DualPaneExplorer.applyPathChange` pushes history AFTER `listing-complete` | History push on success only |
85+
| **Back/forward** | `DualPaneExplorer.handleNavigationAction``setPanePath` → FilePane `$effect``loadDirectory` | `updatePaneAfterHistoryNavigation` moves history BEFORE load | Optimistic — if path is gone, error handler resolves upward |
86+
| **Volume switch** | `VolumeBreadcrumb.onVolumeChange``FilePane.loadDirectory` + `DualPaneExplorer.handleVolumeChange` | Pushed immediately in `handleVolumeChange` | Optimistic — `determineNavigationPath` may correct to a better path in background |
87+
88+
**Old listing cleanup** (in `FilePane.loadDirectory`, every navigation):
89+
90+
1. `++loadGeneration` — invalidates all in-flight events
91+
2. `cancelListing(oldId)` → sets `AtomicBool` in Rust → background task stops within ~100ms
92+
3. `listDirectoryEnd(oldId)` → stops file watcher, removes from `LISTING_CACHE`
93+
4. Unlisten all 6 event listeners
94+
5. Generate new `listingId` (frontend `crypto.randomUUID()`), subscribe new listeners, call `listDirectoryStart`
95+
96+
**Volume switch specifics**: `handleVolumeChange` saves the old volume's `lastUsedPath` immediately (no debounce),
97+
then runs `determineNavigationPath` in background (each check has 500ms timeout). A `volumeChangeGeneration` counter
98+
guards against stale corrections if the user switches again.
99+
100+
### Listing lifecycle
101+
102+
```
103+
Frontend (FilePane) Rust backend (streaming.rs)
104+
| |
105+
|-- loadDirectory(path) |
106+
| listingId = randomUUID() |
107+
| listDirectoryStart(...) ------------>| spawn tokio task → spawn_blocking
108+
|<-- { listingId, status: Loading } |
109+
| |-- emit listing-opening
110+
| |-- volume.list_directory_with_progress()
111+
|<-- listing-progress (every 200ms) -----| (on separate OS thread, polls cancel every 100ms)
112+
|<-- listing-read-complete --------------|-- sort + enrich + cache insert (atomic with cancel check)
113+
| |-- start file watcher
114+
|<-- listing-complete ------------------|
115+
| handleListingComplete() |
116+
| onPathChange → push history |
117+
| |
118+
|== ACTIVE: getFileRange on demand =====>|== LISTING_CACHE serves ranges
119+
|<= directory-diff events ===============|== file watcher detects changes
120+
| |
121+
|== CLEANUP (next nav or destroy) =======|
122+
|-- cancelListing(id) ----------------->|-- AtomicBool → task exits
123+
|-- listDirectoryEnd(id) -------------->|-- stop watcher, remove from cache
124+
```
125+
126+
Multiple listings coexist (two panes, rapid navigation). Each keyed by unique `listingId` in all global state:
127+
`LISTING_CACHE`, `STREAMING_STATE`, `WATCHER_MANAGER`. Events carry `listingId`; listeners filter by it.
128+
129+
### Concurrency guards
130+
131+
| Guard | Defined in | Incremented by | Checked by | Prevents |
132+
|-------|-----------|---------------|------------|----------|
133+
| `loadGeneration` | `FilePane` (per-instance) | `loadDirectory()`, `adoptListing()` | All 6 listing event handlers; post-`listDirectoryStart` staleness check | Stale listing events from previous navigation applied to current |
134+
| `listingId` match | `FilePane` (per-instance) | Set to new UUID each `loadDirectory()` | Every event handler (belt-and-suspenders with `loadGeneration`) | Wrong listing's events applied to a different navigation |
135+
| `volumeChangeGeneration` | `DualPaneExplorer` (singleton) | `handleVolumeChange()` | `determineNavigationPath` callback | Stale path corrections applied after user switched volumes again |
136+
| `cacheGeneration` | `FilePane` → prop to `FullList`/`BriefList` | `refreshView()`, `adoptListing()`, `directory-diff` handler | Virtual scroll `$effect` via `shouldResetCache()` | Stale frontend scroll cache after sort/filter/watcher changes |
137+
| `AtomicBool` (Rust) | `StreamingListingState` per listing | `cancel_listing()` IPC | Per-entry during read, every 100ms poll, at cache-insert (under write lock) | Cancelled listing inserting into cache or continuing I/O |
138+
139+
### Cancellation patterns
140+
141+
Three architectural patterns used consistently:
142+
143+
1. **`AtomicBool` flag (Rust)**: Every long-running backend task (listing, copy/move/delete, scan, search, indexing,
144+
AI download) uses an `AtomicBool` checked at iteration boundaries. Stored in global `HashMap` keyed by operation ID.
145+
2. **Generation counter (TypeScript)**: Incremented on each new request. Stale responses silently discarded.
146+
Lightweight, no backend coordination needed. Used for `loadGeneration` and `currentFetchId` (viewer).
147+
3. **Tauri event feedback**: Backend emits terminal events (`listing-cancelled`, `write-cancelled`, etc.) after
148+
cancellation completes. For rollback operations, the frontend waits for this event before closing the dialog.
149+
150+
| Operation | User trigger | Frontend | Backend | Coordinated? |
151+
|-----------|-------------|----------|---------|-------------|
152+
| Directory listing | ESC / new navigation | `loadGeneration++` + `cancelListing` IPC | `AtomicBool` per-entry, 100ms poll | Yes |
153+
| File copy/move | Cancel button in dialog | `cancelWriteOperation` IPC | `AtomicBool` per-file; `CopyTransaction.rollback()` if requested | Yes (waits for `write-cancelled` on rollback) |
154+
| File delete/trash | Cancel button in dialog | `cancelWriteOperation` IPC | `AtomicBool` per-file; **no rollback** (already deleted) | Yes, but irreversible |
155+
| Scan preview | Dialog close | `cancelScanPreview` IPC | `AtomicBool` per-entry | Yes |
156+
| File viewer search | New query / ESC / close | `viewerSearchCancel` IPC | `AtomicBool` in search loop | Yes |
157+
| Drive indexing | App shutdown / volume unmount | `stopDriveIndex` IPC | `AtomicBool` on jwalk walker; incomplete scan detected on next startup | Yes |
158+
| AI model download | Cancel button | `cancelAiDownload` IPC | Flag checked per HTTP chunk; partial file kept for resume | Yes |
159+
| Viewer line fetch | Rapid scroll | `currentFetchId++` (discard stale) | None — backend serves all | No, frontend-only |
160+
| Inline rename | ESC / navigate away | `rename.cancel()` resets state | None — purely frontend until submit | No, frontend-only |
161+
162+
**Known gap**: On stuck network mounts, the OS `read_dir` syscall blocks the I/O thread. The `AtomicBool` check runs
163+
between entries, not during the syscall. Mitigation: I/O runs on a separate OS thread; the main task polls via
164+
`mpsc::channel` every 100ms and can respond to cancellation without waiting for the syscall.
165+
166+
### Volume mount/unmount chain
167+
168+
```
169+
Detection:
170+
macOS: FSEvents on /Volumes (non-recursive)
171+
Linux: inotify on /proc/mounts + /run/user/<uid>/gvfs/
172+
MTP: nusb USB hotplug stream (separate system, own events)
173+
174+
→ State diff against KNOWN_VOLUMES (implicit debounce — multiple FSEvents, one diff)
175+
176+
→ Rust processing:
177+
Mount: register LocalPosixVolume with VolumeManager, emit "volume-mounted"
178+
Unmount: unregister from VolumeManager, emit "volume-unmounted"
179+
180+
→ Frontend listeners (both fire independently):
181+
VolumeBreadcrumb: clear space cache, reload volume list (dropdown refresh)
182+
DualPaneExplorer: mount → refresh list; unmount → handleVolumeUnmount()
183+
```
184+
185+
**`handleVolumeUnmount`**: Hard redirect — any pane on the unmounted volume switches to `~` on root volume.
186+
No parent-walking (entire volume is gone). Both panes checked independently. State persisted immediately.
187+
188+
**Safety nets against races**: FilePane's 2-second `dirExistsPollInterval` also detects missing paths. If the
189+
volume root itself is gone, it defers to the unmount handler (avoids double-navigation). If only a subdirectory is
190+
gone but the volume exists, it resolves upward via `resolveValidPath`.
191+
192+
**MTP differs**: Detection via USB hotplug (not filesystem). Three events: `mtp-device-detected`, `mtp-device-removed`,
193+
`mtp-device-connected`. VolumeManager registration happens on explicit `connect()`, not on detection.
194+
SMB shares on macOS appear under `/Volumes` and use the same path as local drives.
195+
196+
### Error recovery
197+
198+
| Scenario | Detection | User sees | Recovery | Cleanup |
199+
|----------|----------|-----------|----------|---------|
200+
| **Path deleted** | `listing-error` + `pathExists` check; watcher `directory-deleted`; 2s poll | Brief spinner → auto-navigates to parent | `resolveValidPath`: walk parents → `~``/` (each step 1s frontend + 2s Rust timeout) | `cancelListing` + `listDirectoryEnd` |
201+
| **Permission denied** | Rust `PermissionDenied``listing-error` | `PermissionDeniedPane` with OS-specific fix instructions | None (manual fix required) | `listingId` cleared, no cache/watcher |
202+
| **Network slow/dead** | Frontend timeouts (500ms/1s); Rust timeout (2s); ESC cancel | "Opening folder..." → progress → "Press ESC to cancel" | ESC navigates back; timeouts cause graceful fallback | `AtomicBool` cancellation |
203+
| **Mid-stream I/O error** | Rust error through channel → `listing-error` | Spinner → auto-navigates to parent | Same as "path deleted" | No partial cache (listing is atomic — all or nothing) |
204+
| **Volume unmounted** | `volume-unmounted` Tauri event (dedicated handler) | Pane switches to home directory | Hard switch to root volume + `~` | Full pane state overwrite + persist |
205+
| **MTP disconnect** | `mtp-device-removed` event | Falls back to default volume | `handleMtpFatalError` → root volume + `~` | Same as volume unmount |
206+
207+
**Per-entry permission errors** (single unreadable file in a readable dir) don't fail the listing — they appear as
208+
zero-permission entries with fallback metadata.
209+
76210
### Persistence
77211

78212
- **App status** (`app-status.json`): ephemeral state — paths, focused pane, view modes, last-used paths per volume
@@ -83,6 +217,18 @@ This avoids serializing 50k+ entries. Virtual scrolling renders only ~50 visible
83217

84218
Philosophy: status is "where you are" (ephemeral), settings are "how you like it" (preferences).
85219

220+
**Persistence timing** (what's at risk on crash):
221+
222+
| State | Timing | Crash loss |
223+
|-------|--------|------------|
224+
| Pane paths, focused pane, view mode, sort | Debounced 200ms (`saveAppStatus`) | Up to 200ms of changes |
225+
| Tab state (paths, sort, viewMode, pinned) | **Immediate** (no debounce) | None — tabs are the reliable source of truth |
226+
| `lastUsedPath` per volume | **Immediate** (no debounce) | None |
227+
| Settings v2 | Debounced 500ms; explicit flush on Settings window close | Up to 500ms if main window crashes |
228+
| Shortcuts | **Immediate** (changes are rare user actions) | None |
229+
| License | **Immediate** (Rust `autoSave`) | None |
230+
| Window size/position | Debounced 500ms on resize; immediate on normal close | Size since last resize settled |
231+
86232
### macOS specifics
87233

88234
- **Full Disk Access**: checked via `~/Library/Mail` readability (<5ms). Prompt on first launch.

0 commit comments

Comments
 (0)