Skip to content

Commit c660d6f

Browse files
committed
Favorites: make the switcher's Favorites section a user-editable store (Phase 1, backend)
Replaces the hardcoded four favorites with a user-owned, editable list, addressing GitHub issue #27 ("can't figure out how to change the favorite list"). This is Phase 1 (backend + IPC contract); the frontend affordances land in Phase 2. - New `favorites/` module owning an ordered `favorites.json` store of `{ id, path, name }`: - Pure, unit-tested core (`add`/`remove`/`rename`/`reorder`) plus disk I/O, an in-memory cache, and seed-once. - `id` is a random UUID minted on add, stable across renames and re-adds (never derived from the path). - Seed-once via file presence: an absent file seeds the platform defaults (macOS: `/Applications`, `~/Desktop`, `~/Documents`, `~/Downloads`; Linux: Home + the three folders); a present file (even an emptied list) is read verbatim and never re-seeded. A corrupt/version-mismatched file quarantines to `.broken` and reads as an empty initialized store, so a stray edit can't silently re-seed over a cleared list. - `add` dedups by normalized path (a re-add moves the entry to the end, keeping its id). - Data dir resolved without an `AppHandle` (mirrors `install_id.rs`) so the sync, handle-free read path can reach it. - Rewired `get_favorites()` (macOS `volumes/` + Linux `volumes_linux/` twins) to read the store and map each entry to a `LocationInfo` with `category: Favorite`. The macOS FDA-pending TCC skip now applies to ANY user-added protected path via `tcc_paths::is_potentially_tcc_restricted`, not just the old hardcoded three. - New IPC commands (`add_favorite`, `remove_favorite`, `rename_favorite`, `reorder_favorites`): thin async pass-throughs under a 5s write timeout that persist then re-emit `volumes-changed`, so both panes' switchers refresh live. Listing keeps riding `list_volumes` / `volumes-changed`; no `list_favorites`. Registered in the runtime handler and the specta collector; `bindings.ts` regenerated. - Docs: new `favorites/CLAUDE.md` + `DETAILS.md`, architecture map line, updated `volumes`/`volumes_linux`/`commands` CLAUDE.md favorite lines, spec index entry, and the Phase 1 IPC contract appended to the plan for the Phase 2 agent. Tests: 20 store tests (CRUD, seed-once-on-absence, no-reseed-when-present, empty-stays-empty, dedup, reorder, rename, persistence round-trip, corrupt/version-mismatch recovery). `pnpm check rust` and `--fast` green.
1 parent 3d6b745 commit c660d6f

18 files changed

Lines changed: 1163 additions & 69 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ immediately to business-logic modules. No significant logic lives here.
1515
| `mtp.rs` | MTP devices | Full MTP command surface (connect, disconnect, list, download, upload, delete, rename, move, scan) |
1616
| `network.rs` | SMB/network shares | Discovery, share listing, keychain, mounting, direct-connection upgrade, in-place reconnect (`reconnect_smb_volume`: backend single-flighted via `Volume::attempt_reconnect`; `reconnect_smb_volume_with_credentials`: the "Sign in" path after an auth-failure reconnect give-up, via `Volume::reconnect_with_credentials`), per-volume disconnect (`disconnect_smb_volume`: macOS shells out to `diskutil unmount`, Linux drops the smb2 session). Borrow Finder's saved password (macOS): `system_has_saved_smb_password` (prompt-free probe — does the login keychain hold a password Finder saved for this server?, drives the "Use saved password" offer) and `upgrade_to_smb_volume_using_saved_password` (consent-gated read via `secrets::system_keychain_smb` → direct smb2 → copies the password into Cmdr's own store so future reconnects are silent → `CredentialsNeeded` fallback if absent/denied). User-initiated only. Lazy-startup hooks: `ensure_network_discovery_started` (idempotent: kicks off mDNS + manual-server load + smb-mount upgrade on first user network action) and `set_network_enabled` (live-applies the `network.enabled` toggle: stops mDNS and clears discovered hosts when off). Upgrade business logic (address resolution, credential lookup, smb2 connection) lives in `network::smb_upgrade`; commands here are thin wrappers. |
1717
| `eject.rs` | Volume eject | `eject_volume(volume_id)`: dispatches by kind. MTP → `mtp::connection_manager().disconnect`; SMB → `diskutil unmount` (FSEvents handles smb2 teardown via `on_unmount`); physical/DMG → `diskutil eject`. Pure `decide_eject_action` (unit-tested) keeps the dispatch logic separate from the impure shell-out. Guards against ejecting a volume with an in-flight write op (`busy_volume_ids().contains(...)` → error) so a transfer can't be truncated. `get_busy_volume_ids()`: bootstrap for the picker's busy set (see `write_operations/DETAILS.md` § "Busy-volumes set"). |
18+
| `favorites.rs` | User-editable favorites | `add_favorite`, `remove_favorite`, `rename_favorite`, `reorder_favorites`. Thin pass-throughs over `crate::favorites::store`; each persists `favorites.json` (5s write timeout) then re-emits `volumes-changed`. No `list_favorites` (listing rides `list_volumes` / `volumes-changed`). See `favorites/CLAUDE.md`. |
1819
| `font_metrics.rs` | Font metrics cache | `store_font_metrics`, `has_font_metrics` |
1920
| `icons.rs` | File icons | `get_icons`, `get_custom_folder_icon_ids` (visible-range custom-folder detection), `refresh_directory_icons`, cache clear |
2021
| `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). |
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//! IPC commands for user-editable favorites.
2+
//!
3+
//! Thin pass-throughs over the `favorites::store` module. Each mutation persists `favorites.json`
4+
//! (a filesystem write, so it runs on the blocking pool with a timeout) and then re-emits
5+
//! `volumes-changed` so both panes' switchers refresh live (subscribe-don't-poll). Listing rides
6+
//! the existing `list_volumes` / `volumes-changed` path, so there's no `list_favorites` command.
7+
8+
use tokio::time::Duration;
9+
10+
use crate::commands::util::{IpcError, blocking_result_with_timeout};
11+
use crate::favorites::store;
12+
13+
/// 5s matches the write timeout other persisting commands use. The store write is local-only, but a
14+
/// hung data-dir mount must never freeze the IPC thread.
15+
const PERSIST_TIMEOUT: Duration = Duration::from_secs(5);
16+
17+
/// Adds a favorite for `path`, deduping by normalized path. When `name` is omitted, the label
18+
/// defaults to the path's file name.
19+
#[tauri::command]
20+
#[specta::specta]
21+
pub async fn add_favorite(path: String, name: Option<String>) -> Result<(), IpcError> {
22+
blocking_result_with_timeout(PERSIST_TIMEOUT, move || {
23+
store::add(&path, name);
24+
Ok(())
25+
})
26+
.await?;
27+
crate::volume_broadcast::emit_volumes_changed();
28+
Ok(())
29+
}
30+
31+
/// Removes a favorite by id. No-op when the id isn't present.
32+
#[tauri::command]
33+
#[specta::specta]
34+
pub async fn remove_favorite(id: String) -> Result<(), IpcError> {
35+
blocking_result_with_timeout(PERSIST_TIMEOUT, move || {
36+
store::remove(&id);
37+
Ok(())
38+
})
39+
.await?;
40+
crate::volume_broadcast::emit_volumes_changed();
41+
Ok(())
42+
}
43+
44+
/// Renames a favorite by id. No-op when the id isn't present.
45+
#[tauri::command]
46+
#[specta::specta]
47+
pub async fn rename_favorite(id: String, name: String) -> Result<(), IpcError> {
48+
blocking_result_with_timeout(PERSIST_TIMEOUT, move || {
49+
store::rename(&id, &name);
50+
Ok(())
51+
})
52+
.await?;
53+
crate::volume_broadcast::emit_volumes_changed();
54+
Ok(())
55+
}
56+
57+
/// Reorders the favorites to match `ordered_ids`. Unknown ids are ignored; favorites missing from
58+
/// the list are appended in their current order, so a stale order never drops an entry.
59+
#[tauri::command]
60+
#[specta::specta]
61+
pub async fn reorder_favorites(ordered_ids: Vec<String>) -> Result<(), IpcError> {
62+
blocking_result_with_timeout(PERSIST_TIMEOUT, move || {
63+
store::reorder(&ordered_ids);
64+
Ok(())
65+
})
66+
.await?;
67+
crate::volume_broadcast::emit_volumes_changed();
68+
Ok(())
69+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod e2e;
99
#[cfg(any(target_os = "macos", target_os = "linux"))]
1010
pub mod eject;
1111
pub mod error_reporter;
12+
pub mod favorites;
1213
pub mod feedback;
1314
pub mod file_system;
1415
pub mod file_viewer;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Favorites (backend)
2+
3+
User-editable favorites: the ordered `favorites.json` store that backs the volume switcher's
4+
"Favorites" section. The store is the single source of truth and replaces the previously hardcoded
5+
four favorites. Full depth in [`DETAILS.md`](DETAILS.md).
6+
7+
## Module map
8+
9+
- `store.rs`: the `favorites.json` store. Pure core (`add`/`remove`/`rename`/`reorder` on a `Vec`,
10+
unit-tested) plus disk I/O, the in-memory cache, and seed-once. Public API: `list`, `add`,
11+
`remove`, `rename`, `reorder`, and the `Favorite { id, path, name }` type.
12+
- IPC lives in `commands/favorites.rs` (not here): thin `add_favorite` / `remove_favorite` /
13+
`rename_favorite` / `reorder_favorites` pass-throughs. There's no `list_favorites`; listing rides
14+
`list_volumes` / `volumes-changed`.
15+
16+
## Must-knows
17+
18+
- **Seed-once via file presence.** File ABSENT means first launch: seed the platform defaults and
19+
write them. File PRESENT (even an empty list) means already-initialized: read verbatim, NEVER
20+
re-seed. `read_store_from_path` returns `Option` for exactly this: `None` = absent (seed), `Some`
21+
= present (don't). A corrupt/version-mismatched file quarantines to `.broken` and reads as
22+
`Some(empty)`, NOT `None`, so a stray hand-edit can't silently re-seed over a user who'd cleared
23+
their list. Don't "simplify" the `Option` to a plain `Vec`: it would erase the absent-vs-empty
24+
distinction the whole contract rests on.
25+
- **`id` is a random UUID minted on add, never derived from `path`.** Paths repeat across renames
26+
and re-adds, so the id must outlive the path. The switcher's `LocationInfo.id` is `format!("fav-{id}")`.
27+
- **Data dir is resolved WITHOUT an `AppHandle`** (mirrors `install_id.rs`: `CMDR_DATA_DIR` else the
28+
OS default for `BUNDLE_ID`). Load-bearing: `get_favorites()` (the read path, in `volumes/mod.rs`
29+
and `volumes_linux/mod.rs`) is sync and `AppHandle`-free, so `store::list()` must stay no-arg. Keep
30+
`BUNDLE_ID` in sync with `tauri.conf.json`.
31+
- **FDA-pending skip on the read side.** `volumes::get_favorites` must NOT stat a TCC-protected path
32+
while the FDA gate is pending (even `Path::exists()` trips a TCC popup). It skips the existence
33+
check for paths where `restricted_paths::tcc_paths::is_potentially_tcc_restricted(path)` is true.
34+
This now applies to ANY user-added path, not just the old hardcoded three. Linux has no TCC, so its
35+
twin existence-checks everything.
36+
- **Mutations re-emit `volumes-changed`.** Every command calls `volume_broadcast::emit_volumes_changed()`
37+
after persisting, so both panes' switchers update live. Don't add a polling path.
38+
- **Lock-poison + log compliance.** Uses `IgnorePoison::lock_ignore_poison()` (not `.lock().unwrap()`)
39+
and `log::{info,warn}!` with `target: "favorites::store"` (no `println!`). The disk lock is never
40+
held across an `.await`.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Favorites (backend) details
2+
3+
User-editable favorites. The volume switcher's "Favorites" section is fully user-owned: add, remove,
4+
rename, reorder. This module owns the ordered `favorites.json` store; the IPC layer
5+
(`commands/favorites.rs`) is a thin pass-through. Read [`CLAUDE.md`](CLAUDE.md) first for the
6+
must-knows.
7+
8+
## What it replaces
9+
10+
Favorites used to be a hardcoded `Vec<LocationInfo>` of four folders, computed fresh on every
11+
`get_favorites()` call with no write path. Now `get_favorites()` (both the macOS `volumes/mod.rs` and
12+
the Linux `volumes_linux/mod.rs` twins) reads `favorites::store::list()` and maps each entry to a
13+
`LocationInfo` with `category: Favorite`. The frontend already tells favorites apart from volumes via
14+
`category`, so no extra "user-removable" flag is needed: every favorite is now a user favorite.
15+
16+
## On-disk shape
17+
18+
`favorites.json` in the app data dir:
19+
20+
```json
21+
{
22+
"_schemaVersion": 1,
23+
"favorites": [
24+
{ "id": "9f1c…", "path": "/Applications", "name": "Applications" },
25+
{ "id": "a83e…", "path": "/Users/me/Desktop", "name": "Desktop" }
26+
]
27+
}
28+
```
29+
30+
- `id`: a random UUID minted on add, never derived from `path`. The switcher exposes it as
31+
`LocationInfo.id = "fav-<id>"`.
32+
- `path`: the absolute filesystem path.
33+
- `name`: the display label. Defaults to the path's file name on add; the user can override via
34+
rename.
35+
36+
Order in the array is the display order.
37+
38+
## Seed-once contract
39+
40+
`favorites.json` existing means "already initialized." Four states:
41+
42+
- **File absent** (`read_store_from_path` returns `None`): first launch. Seed the platform defaults
43+
and write them. This is the only path that ever writes defaults.
44+
- **File present, non-empty**: read verbatim.
45+
- **File present, empty list**: the user cleared every favorite. Read verbatim (stays empty). Never
46+
re-seed.
47+
- **File present, corrupt or wrong `_schemaVersion`**: quarantine to `<name>.broken`, then read as
48+
`Some(empty)` (NOT `None`). A broken file is "initialized but unreadable," so we must not re-seed
49+
the defaults over a user who had intentionally cleared their list.
50+
51+
Existing beta users (data dir present, no `favorites.json` yet) hit the absent branch on the first
52+
launch after the update and get the platform defaults seeded, so there's no regression from the old
53+
hardcoded behavior.
54+
55+
Platform defaults (`default_favorites`, platform-native per `design-principles.md`):
56+
57+
- macOS: `/Applications`, `~/Desktop`, `~/Documents`, `~/Downloads` (the previous hardcoded four).
58+
- Linux: Home, `~/Desktop`, `~/Documents`, `~/Downloads` (matching the old `volumes_linux`
59+
favorites).
60+
61+
## Operations (pure core)
62+
63+
All in `store.rs`, unit-tested without disk or an `AppHandle`:
64+
65+
- `add(path, name?)`: dedups by normalized path. A re-add moves the existing entry to the end and
66+
keeps its id (applying a `name` override if given). A fresh add appends with a UUID id and a label
67+
defaulting to the path's file name. Move-to-end (not move-to-top) because favorites are a curated,
68+
ordered list, not a recency stack: a re-add shouldn't reshuffle the user's deliberate ordering more
69+
than necessary.
70+
- `remove(id)`: drops by id. No-op if absent.
71+
- `rename(id, name)`: updates the label by id. No-op if absent.
72+
- `reorder(ordered_ids)`: reorders to match the given id list. Unknown ids are ignored; favorites
73+
whose ids are missing from the list are appended in their current relative order, so a partial or
74+
stale order from the frontend never drops an entry.
75+
76+
`normalize_for_dedup` strips a single trailing `/` (but keeps root `/`). Case-sensitivity is a known
77+
limitation, same as `go_to_path/history.rs`: on case-insensitive APFS `/Users/x/Foo` and
78+
`/Users/x/foo` compare unequal (worst case: a duplicate-looking row). We don't `canonicalize()` (it
79+
would resolve symlinks and require the path to exist).
80+
81+
## Persistence and concurrency
82+
83+
- In-memory cache: `OnceLock<Mutex<Option<FavoritesStore>>>`. `None` until first access loads (and
84+
lazily seeds) from disk.
85+
- `DISK_LOCK` (a separate `Mutex<()>`) serializes the read-modify-write cycle so concurrent commands
86+
can't clobber each other. `load_or_seed` re-checks the cache under the disk lock so two concurrent
87+
first-access callers can't both seed.
88+
- Atomic writes via `config::durable_write_json` (write-tmp + fsync + rename + parent-dir fsync),
89+
the same data-loss-class discipline the rest of the app uses.
90+
- The disk lock is never held across an `.await`; the in-memory guard is always dropped before any
91+
`fs` call. (The commands themselves run the store calls inside `spawn_blocking`, so even the
92+
synchronous store API never blocks the IPC thread.)
93+
94+
## IPC contract (`commands/favorites.rs`)
95+
96+
Thin async pass-throughs, each `blocking_result_with_timeout` (5s, the write tier) since the store
97+
write touches the filesystem. After persisting, each re-emits `volumes-changed` via
98+
`volume_broadcast::emit_volumes_changed()` so both panes' switchers refresh live
99+
(subscribe-don't-poll). Listing rides the existing `list_volumes` / `volumes-changed` path, so
100+
there's no `list_favorites` command.
101+
102+
- `add_favorite(path: String, name: Option<String>) -> Result<(), IpcError>`
103+
- `remove_favorite(id: String) -> Result<(), IpcError>`
104+
- `rename_favorite(id: String, name: String) -> Result<(), IpcError>`
105+
- `reorder_favorites(ordered_ids: Vec<String>) -> Result<(), IpcError>`
106+
107+
Registered in both `ipc.rs` (runtime `generate_handler`) and `ipc_collectors.rs` (specta types).
108+
109+
## FDA-pending skip (macOS)
110+
111+
`volumes::get_favorites` must not stat a TCC-protected path while the FDA gate is pending: even
112+
`Path::exists()` trips a macOS TCC popup for the protected-folder service once the bundle is
113+
registered with tccd, which is exactly the onboarding-flood the FDA modal exists to prevent. The read
114+
maps each favorite, skipping the existence check when the FDA gate is pending AND
115+
`restricted_paths::tcc_paths::is_potentially_tcc_restricted(path)` is true (and assuming such a
116+
protected favorite exists). Non-protected paths are still checked (for example `/Applications` can be
117+
absent on slim systems). This now applies to ANY user-added path, not just the old hardcoded three.
118+
Linux has no TCC, so its twin existence-checks everything and there's no gate.
119+
120+
## Local-filesystem paths only (v1)
121+
122+
Favorites are local filesystem paths for now. Network and MTP favorites are deferred (mount-state
123+
complexity). The store doesn't enforce this; the add surfaces in the frontend gate it.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//! User-editable favorites.
2+
//!
3+
//! Owns the ordered `favorites.json` store that backs the volume switcher's "Favorites" section.
4+
//! The store is the single source of truth; it replaces the previously hardcoded four favorites.
5+
//! `volumes::get_favorites` (macOS) and `volumes_linux::get_favorites` (Linux) read this store and
6+
//! map each entry to a `LocationInfo` with `category: Favorite`. Mutations go through the IPC
7+
//! commands in `commands/favorites.rs`, which re-emit `volumes-changed` so both panes' switchers
8+
//! update live.
9+
//!
10+
//! See `favorites/CLAUDE.md` for the seed-once contract and the FDA-pending skip.
11+
12+
pub mod store;

0 commit comments

Comments
 (0)