You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: apps/desktop/src-tauri/capabilities/CLAUDE.md
+19Lines changed: 19 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -12,3 +12,22 @@ rejected at runtime with a "not allowed" error.
12
12
13
13
Check the [Tauri permissions reference](https://tauri.app/security/permissions/)
14
14
for available permission identifiers.
15
+
16
+
## Key decisions
17
+
18
+
**Decision**: One capability file per window type, not one global file.
19
+
**Why**: Tauri's capability system is the security boundary between webview code and native APIs. Each window type (main, settings, viewer) has a different trust level. The main window needs filesystem access, drag-and-drop, clipboard, and the updater. The settings window only needs event dispatch and theme control. The viewer only needs window management. Splitting by window prevents privilege escalation -- a compromised viewer webview can't invoke filesystem operations.
20
+
21
+
**Decision**: `viewer-*` wildcard pattern for viewer window capabilities.
22
+
**Why**: Viewer windows are created dynamically with labels like `viewer-0`, `viewer-1`, etc. A wildcard `"viewer-*"` in the `windows` array covers all of them without needing to pre-register each label. This is a Tauri feature specifically for dynamic window creation.
23
+
24
+
**Decision**: `desktop.json` is separate from `default.json` despite overlapping window targets.
25
+
**Why**: `desktop.json` holds desktop-platform-specific permissions (`window-state:default` for remembering window size/position). It's scoped to `["macOS", "windows", "linux"]` platforms. Keeping it separate from `default.json` makes the platform-specific vs. universal split clear and avoids accidentally applying desktop-only permissions to future non-desktop targets.
26
+
27
+
## Gotchas
28
+
29
+
**Gotcha**: Missing permissions fail silently at runtime.
30
+
**Why**: Tauri doesn't crash or warn visibly when a webview calls an API it lacks permission for. The call just returns a generic "not allowed" error. If a new Tauri API call (e.g., `setFocus`, `setTitle`) is added to a window's frontend code, the corresponding permission must be added here or it will silently fail. Check the browser console for "not allowed" errors.
31
+
32
+
**Gotcha**: `opener:allow-open-path` needs explicit glob patterns for hidden files.
33
+
**Why**: The default `opener:allow-open-path` permission doesn't match dotfiles. The `"**/*"` glob excludes files starting with `.`, so a separate `"**/.*"` pattern is required. Without it, opening hidden files from the file manager would silently fail.
4. Update `DEFAULT_MODEL_ID` if it should be the new default.
62
62
63
+
## Key decisions
64
+
65
+
**Decision**: Global `Mutex<Option<ManagerState>>` singleton instead of Tauri managed state.
66
+
**Why**: AI state needs to be accessed from both Tauri commands and internal init/shutdown paths. Tauri managed state requires an `AppHandle` to access, but `shutdown()` is called from the quit handler where threading constraints make it simpler to use a plain global. The `Option` allows lazy init — `None` until `init()` runs.
67
+
68
+
**Decision**: Two separate install flags (`installed` + `model_download_complete`) rather than a single boolean.
69
+
**Why**: The download can be interrupted (crash, cancel, network loss). A partial 2 GB file on disk looks "installed" but is corrupt. `model_download_complete` is only set after file-size verification passes. This prevents launching llama-server with a truncated model, which would crash silently or produce garbage.
70
+
71
+
**Decision**: Dev gate via `use_real_ai()` that returns `false` in debug builds unless `CMDR_REAL_AI=1`.
72
+
**Why**: AI features spawn a child process, download multi-GB files, and consume GPU resources. Enabling this by default in dev would make every `cargo run` slow and resource-heavy. The env var opt-in keeps the dev loop fast while still allowing manual AI testing.
73
+
74
+
**Decision**: Port discovery via `bind(:0)` then pass to llama-server, instead of letting llama-server pick its own port.
75
+
**Why**: llama-server doesn't have a reliable way to report its chosen port back to the parent. Binding port 0, reading the OS-assigned port, closing the listener, then passing it to llama-server avoids the tiny race window while keeping the architecture simple. The 100ms startup delay before the health check loop makes collisions practically impossible.
76
+
77
+
**Decision**: Cancellation via `Fn() -> bool` parameter rather than `Arc<AtomicBool>`.
78
+
**Why**: `download_file` lives in a separate module from the manager's cancel state. Passing a closure (`is_cancel_requested`) decouples the download logic from the global `MANAGER` mutex — the download module doesn't need to know about `ManagerState` at all.
79
+
80
+
**Decision**: `SIGTERM` then 5s wait then `SIGKILL` for process shutdown.
81
+
**Why**: llama-server may be mid-inference holding GPU memory. `SIGTERM` gives it a chance to release resources cleanly. The 5s timeout prevents hanging on app quit if the server is stuck.
82
+
83
+
**Decision**: Suggestion sanitization strips bullets, markdown, numbering, and deduplicates case-insensitively.
84
+
**Why**: Small LLMs (3B params) inconsistently follow formatting instructions. The same model that returns clean `docs\ntests\n` on one prompt may return `1. **Docs**\n2. tests` on the next. Aggressive sanitization makes the output reliable regardless of LLM mood.
85
+
86
+
## Gotchas
87
+
88
+
**Gotcha**: `tauri::async_runtime::spawn` is used in `init()` instead of `tokio::spawn`.
89
+
**Why**: `init()` runs during Tauri setup before the tokio runtime is fully available. `tauri::async_runtime::spawn` uses Tauri's own runtime which is always ready at that point.
90
+
91
+
**Gotcha**: `get_folder_suggestions` returns `Ok(Vec::new())` on LLM errors, not `Err`.
92
+
**Why**: AI suggestions are a nice-to-have enhancement. Propagating errors would force the frontend to show error UI for a non-critical feature. Returning empty gracefully hides the failure — the user just sees no suggestions, same as if AI were not installed.
|`sync_status.rs`| Cloud sync status |`get_sync_status` — macOS delegates to `file_system::sync_status`; non-macOS returns empty map via `#[cfg]` on the function itself (not the module). |
25
25
26
+
## Key decisions
27
+
28
+
**Decision**: One commands file per domain, with no business logic in commands.
29
+
**Why**: Tauri command functions are the IPC boundary -- they handle argument deserialization, state extraction, and error mapping. Mixing business logic here makes it untestable (Tauri commands need a running app to invoke). Keeping commands as thin pass-throughs means the real logic lives in subsystem modules that can be unit-tested independently.
30
+
31
+
**Decision**: Platform gating at the module level in `mod.rs`, not inside individual functions.
32
+
**Why**: Entire command surfaces (MTP, network, volumes) are platform-specific. Gating at the module level means the compiler excludes unused code entirely rather than compiling stub functions. This also prevents accidentally calling an unsupported command -- if the module doesn't exist on that platform, the Tauri command isn't registered at all.
33
+
34
+
**Decision**: `blocking_with_timeout` for filesystem calls that may touch network mounts.
35
+
**Why**: `spawn_blocking` alone doesn't protect against hung NFS/SMB mounts where a simple `path.exists()` can block indefinitely. The timeout wrapper (2s default) returns a fallback value instead of freezing the async runtime. This is specifically for the listing pipeline where latency matters more than correctness -- a false "doesn't exist" is better than a frozen UI.
36
+
37
+
**Decision**: No `commands/ai.rs` file -- AI commands register directly from `ai::manager` and `ai::suggestions`.
38
+
**Why**: The AI subsystem has its own complex lifecycle (model loading, suggestion pipelines). Adding a thin wrapper in `commands/` would just be boilerplate forwarding. Registering directly keeps the AI command surface co-located with its implementation, which changes frequently.
39
+
26
40
## Key patterns and gotchas
27
41
28
42
-**No business logic here.** If you find yourself adding branching or data transformation, move it to the relevant subsystem module.
Copy file name to clipboardExpand all lines: apps/desktop/src-tauri/src/file_system/volume/CLAUDE.md
+31Lines changed: 31 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -52,6 +52,37 @@ Optional methods default to `Err(VolumeError::NotSupported)` or `false`, so new
52
52
53
53
Both `VolumeManager` and parts of `Volume` are gated with `#[allow(dead_code)]` pending Phase 2/4 integration into `operations.rs` and `lib.rs`. `LocalPosixVolume` is already wired into the indexing subsystem.
54
54
55
+
## Key decisions
56
+
57
+
**Decision**: Trait with optional methods defaulting to `NotSupported`/`false`
58
+
**Why**: New volume types (SMB, S3, FTP) will have vastly different capability sets. Forcing every implementor to stub out every method would be noisy and error-prone. Defaults let new backends start with just `list_directory` + `get_metadata` and opt in to capabilities incrementally. The alternative — a capabilities bitfield — would require runtime checks everywhere and couldn't express return-type differences.
59
+
60
+
**Decision**: `VolumeScanner` and `VolumeWatcher` are separate sub-traits, not part of `Volume`
61
+
**Why**: Scanning and watching have their own lifetimes, threading models, and state (handles, channels). Folding them into `Volume` would force every volume to carry scanner/watcher state even if it never indexes. Returning `Option<Box<dyn VolumeScanner>>` keeps the core trait lightweight.
62
+
63
+
**Decision**: `VolumeManager` uses `RwLock<HashMap>` (not `DashMap` or `Mutex`)
64
+
**Why**: Volume registration/unregistration is rare (mount/unmount events); reads are frequent (every file operation resolves a volume). `RwLock` gives concurrent read access without pulling in an extra dependency. `DashMap` would work but is heavier than needed for a registry that rarely exceeds ~10 entries.
65
+
66
+
**Decision**: `MtpVolume` bridges sync `Volume` trait to async MTP calls via `Handle::block_on`
67
+
**Why**: The `Volume` trait is synchronous because local filesystem ops are blocking and shouldn't touch the async executor. MTP operations are inherently async (USB bulk transfers), so `block_on` bridges the gap. This is safe because MTP methods are always called from `spawn_blocking` contexts (separate OS thread pool), avoiding nested-runtime panics.
68
+
69
+
**Decision**: `VolumeError` stores `String` messages, not the original `std::io::Error`
70
+
**Why**: `std::io::Error` is not `Clone`, but `VolumeError` needs to be `Clone` for ergonomic error propagation across thread boundaries and for serialization to the frontend. Storing the formatted message loses the original error type but keeps the information that matters for user-facing error messages.
71
+
72
+
**Decision**: `LocalPosixVolume` uses `symlink_metadata` for `exists()` instead of `Path::exists()`
73
+
**Why**: `Path::exists()` follows symlinks — a dangling symlink returns `false`, which would make the volume claim a file doesn't exist when it visibly does in a directory listing. `symlink_metadata` detects the symlink itself, matching what the user sees.
74
+
75
+
## Gotchas
76
+
77
+
**Gotcha**: `write_from_stream` in `MtpVolume` must collect all chunks *before* entering `block_on`
78
+
**Why**: `MtpReadStream::next_chunk()` itself calls `block_on` internally to read from the async download stream. If `write_from_stream` entered `block_on` first and then called `next_chunk` inside it, you'd get a nested `block_on` panic. The workaround is to eagerly materialize all chunks into a `Vec<Bytes>`, then do one `block_on` for the upload.
79
+
80
+
**Gotcha**: `LocalPosixVolume::resolve` has a three-way branch for absolute paths
81
+
**Why**: The frontend sometimes sends full absolute paths (like `/Users/alice/Documents`), not paths relative to the volume root. If the volume root is `/Users/alice/Dropbox`, the resolve logic must detect whether the absolute path is already inside the root (pass through), whether the root is `/` (pass through), or neither (strip leading `/` and join). Getting this wrong silently serves the wrong directory.
**Why**: MTP has no single-file stat call — you must list the parent directory and search for the entry by name. The listing pipeline doesn't use `get_metadata` during normal browsing (it gets metadata from `list_directory` results), so implementing it would add an expensive round-trip for a code path that's currently unused.
85
+
55
86
## Testing
56
87
57
88
-`in_memory_test.rs` — unit tests for `InMemoryVolume` (CRUD, sorting, concurrency, stress 50k entries)
Copy file name to clipboardExpand all lines: apps/desktop/src-tauri/src/file_viewer/CLAUDE.md
+20Lines changed: 20 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -34,6 +34,26 @@ if file_size < 1MB {
34
34
-`viewer_setup_menu(label)` — builds viewer menu with word wrap item
35
35
-`viewer_set_word_wrap(label, checked)` — syncs menu state
36
36
37
+
## Key decisions
38
+
39
+
**Decision**: Three-backend architecture (FullLoad / ByteSeek / LineIndex) instead of one general-purpose backend.
40
+
**Why**: The core constraint is that opening a file must feel instant regardless of size. FullLoad is simplest and gives perfect random access, but loading a 1 GB file into memory is unacceptable. ByteSeek opens any file in O(1) time but can't seek by line number (only by byte offset or fraction). LineIndex gives O(1) line seeking but requires a full file scan first. The three-tier approach gives instant open (ByteSeek), then upgrades to precise line navigation (LineIndex) once the background scan finishes.
41
+
42
+
**Decision**: ByteSeek-to-LineIndex upgrade happens in a background thread with a 5-second timeout.
43
+
**Why**: On fast SSDs, indexing a 1 GB file takes ~2 seconds and the upgrade is seamless. But on slow disks or network drives, indexing could take minutes. The 5s timeout prevents the indexer from hammering a slow volume indefinitely. If it times out, the session stays in ByteSeek mode — the user can still scroll (via fraction seeking) and search, they just don't get exact line numbers.
44
+
45
+
**Decision**: Search always uses a fresh `ByteSeekBackend` instance in a separate thread, even when the session uses `LineIndex`.
46
+
**Why**: Search is a streaming full-file scan regardless of backend — the line index doesn't help find text matches. Using `ByteSeekBackend` for search keeps the search thread independent of the session's primary backend, avoiding lock contention. Opening a fresh file handle also means search doesn't interfere with the user scrolling in the main session.
47
+
48
+
**Decision**: `SearchMatch.column` and `.length` use UTF-16 code units instead of byte or char offsets.
49
+
**Why**: The frontend is JavaScript, where `String.prototype.length` and `String.prototype.substring()` count UTF-16 code units. If the backend returned byte offsets or Unicode scalar offsets, the frontend would need to convert on every match highlight, which is error-prone for text with emoji or CJK characters. Matching the JS string model eliminates an entire class of off-by-one bugs in the highlight rendering.
50
+
51
+
**Decision**: Sparse checkpoints every 256 lines instead of indexing every line.
52
+
**Why**: Indexing every line in a 100M-line file would need ~800 MB of offset data (8 bytes each). At 256-line intervals, the same file needs ~3 MB. The trade-off is that seeking to a specific line requires reading forward up to 255 lines from the nearest checkpoint, which takes <1ms on any modern disk — well within the 16ms frame budget for 60fps scrolling.
53
+
54
+
**Decision**: Session map (`VIEWER_SESSIONS`) is a global `LazyLock<Mutex<HashMap>>` rather than Tauri managed state.
55
+
**Why**: Same reasoning as the AI manager — viewer sessions need to be accessed from background threads (search, indexing) that don't have an `AppHandle`. A global makes the session cache accessible from any context without threading an `AppHandle` through every call chain.
56
+
37
57
## Gotchas
38
58
39
59
-**VIEWER_SESSIONS is unbounded** — grows with each `viewer_open`. Must call `viewer_close` when window closes (not automatic).
-`calculate_text_width` is `#[allow(dead_code)]` — it's part of the public API kept for future use; `calculate_max_width` is the primary call site.
35
35
-`init_font_metrics` is idempotent — safe to call multiple times; it just overwrites the cache entry.
36
36
37
+
## Key decisions
38
+
39
+
**Decision**: Frontend measures character widths via Canvas API and ships them to Rust over IPC, rather than Rust measuring fonts directly.
40
+
**Why**: Rust has no access to the system's font rendering stack. The browser's Canvas API uses the exact same font rasterizer the user sees, so the measurements match pixel-perfectly. Any Rust-side font library would need to load font files, handle system font resolution, and might produce slightly different widths than what the browser actually renders.
41
+
42
+
**Decision**: Binary format (bincode2) on disk instead of JSON.
43
+
**Why**: A full Latin character set produces ~4,000 code-point-to-width entries. As JSON that's ~100 KB with key quoting overhead. Bincode compresses this to ~26 KB and deserializes in microseconds vs. milliseconds for JSON parsing. Since this file is only read by Rust (never human-edited), readability doesn't matter.
44
+
45
+
**Decision**: `RwLock` for the metrics cache instead of `Mutex`.
46
+
**Why**: `calculate_max_width` is called on every Brief mode render for every visible column. Multiple Tauri command threads may need to read metrics concurrently. `RwLock` allows unlimited parallel reads; a `Mutex` would serialize all column width calculations, adding latency to directory listing renders.
47
+
48
+
**Decision**: Average-width fallback for unmeasured code points instead of returning an error or zero.
49
+
**Why**: The frontend only measures a known character set (typically Latin + common symbols). Filenames can contain any Unicode — emoji, CJK, Arabic. Returning zero would collapse unknown characters to invisible width, breaking column alignment. The average width is a reasonable approximation that keeps columns roughly sized even for scripts the frontend didn't explicitly measure.
50
+
51
+
## Gotchas
52
+
53
+
**Gotcha**: If the frontend's `getCurrentFontId()` format changes, `calculate_max_width` silently returns `None`.
54
+
**Why**: The cache key is a string like `"system-400-12"` that must match exactly between frontend and backend. There's no validation — a mismatch just means the key isn't found in the cache. The frontend handles the `None` by falling back to its own width estimation.
0 commit comments