Skip to content

Commit 1dd439d

Browse files
committed
Icons: Real system-folder icons (Downloads, etc.)
Phase 1 / Tier B of per-folder icons: special system folders now show their real macOS icon instead of the generic folder glyph. - New `icons::special_folders`: classifies a folder by its well-known canonical path (resolved once via `dirs`) into a bounded `special:{name}` key. The set: home, Downloads, Desktop, Documents, Movies, Music, Pictures, Public, plus macOS-only Applications and Trash. Detection is a lexical-path `HashMap` lookup with no disk I/O (no `canonicalize` — would block on a dead mount) and no NSWorkspace/TCC, so it's safe per entry. - `get_icon_id` now takes the entry's full `path` and routes real special folders to `special:{name}`; detection is by path, not name, so a folder merely named "Downloads" elsewhere stays `dir`. Symlinks keep `symlink-dir`. - `icons::get_icons` fetches each uncached `special:*` from the folder's REAL path through the 8 MB-stack `fetch_path_icons` thread (the real folder can be iCloud-synced and descend into `fileproviderd`), then caches under the bounded key. FDA gate + timeout unchanged. - `special:*` keys are bounded, so they persist to localStorage (FE) and are uncapped (Rust); cleared with the other appearance-tinted keys on theme/accent change. - The FE prefetch path already batches visible `iconId`s through `get_icons`, and `FileIcon.svelte` falls back to `dir` while pending/gated, so no per-component wiring was needed. - Converted `icons.rs` to `icons/mod.rs` + `icons/special_folders.rs`; added `icons/CLAUDE.md` documenting the icon-id tier scheme. - Tests: special-folder classifier + `get_icon_id` routing (Rust), `special:` persistence + no-LRU-eviction (FE).
1 parent b039ffe commit 1dd439d

9 files changed

Lines changed: 464 additions & 16 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ pub fn clear_extension_icon_cache() {
7373
icons::clear_extension_icon_cache();
7474
}
7575

76-
/// Clears cached directory icons (`dir`, `symlink-dir`, `path:*`).
76+
/// Clears cached directory icons (`dir`, `symlink-dir`, `path:*`, `special:*`).
7777
/// Called when the system theme or accent color changes.
7878
#[tauri::command]
7979
#[specta::specta]

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@ NSURL resource-value lookups, FileProvider queries, and similar Objective-C APIs
3434
system daemons. These can consume deep stack frames through FileProvider override chains (iCloud, Dropbox, etc.),
3535
exceeding rayon's default 2 MB worker stack. Use dedicated OS threads with an explicit stack size (8 MB) instead. This
3636
also prevents I/O-bound XPC calls from starving rayon's pool, which should be reserved for CPU-bound work.
37-
See `sync_status.rs` for the pattern. `icons.rs::fetch_path_icons` follows it too: per-folder NSWorkspace icon lookups
38-
on real user folders can descend into `fileproviderd` for iCloud/Dropbox folders, so the `path:`-keyed branch runs on
39-
8 MB threads while the extension branch (sample temp paths, never cloud) stays on rayon.
37+
See `sync_status.rs` for the pattern. `icons::fetch_path_icons` follows it too: per-folder NSWorkspace icon lookups on
38+
real user folders can descend into `fileproviderd` for iCloud/Dropbox folders, so the `path:`-keyed branch runs on 8 MB
39+
threads while the extension branch (sample temp paths, never cloud) stays on rayon. `special:*` (special-system-folder)
40+
icons fetch from the folder's REAL path too (Downloads/Desktop can be iCloud-synced), so `icons::get_icons` routes them
41+
through the same `fetch_path_icons` 8 MB path, not the generic per-id loop.
4042

4143
**Never `tokio::spawn` from the notify-rs debouncer callback.** The callback runs on the notify-rs internal thread
4244
which has no Tokio runtime context, so `tokio::spawn` panics with "there is no reactor running". Use

apps/desktop/src-tauri/src/file_system/listing/metadata.rs

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,20 @@ pub(crate) fn get_group_name(gid: u32) -> String {
4545
name
4646
}
4747

48-
/// Generates icon ID based on file type and extension.
49-
pub(crate) fn get_icon_id(is_dir: bool, is_symlink: bool, name: &str) -> String {
48+
/// Generates icon ID based on file type, extension, and (for directories) the
49+
/// folder's well-known path.
50+
///
51+
/// `path` is the entry's full path. It's used to detect the finite set of
52+
/// special system folders (Downloads, Applications, the home folder, …) by
53+
/// canonical path — NOT by name, since any folder can be named "Downloads". A
54+
/// real special folder gets a bounded `special:{name}` key (Tier B); every other
55+
/// directory keeps the shared `dir` icon (Tier A). Detection is a cheap path
56+
/// comparison (no NSWorkspace, no TCC), so it's safe to run per entry.
57+
///
58+
/// Symlinks (even to a special location) keep their `symlink-dir` icon: the link
59+
/// badge is the salient signal, and following a symlink to classify it would
60+
/// cost a syscall per entry.
61+
pub(crate) fn get_icon_id(is_dir: bool, is_symlink: bool, name: &str, path: &str) -> String {
5062
if is_symlink {
5163
// Distinguish symlinks to directories vs files
5264
return if is_dir {
@@ -56,6 +68,9 @@ pub(crate) fn get_icon_id(is_dir: bool, is_symlink: bool, name: &str) -> String
5668
};
5769
}
5870
if is_dir {
71+
if let Some(special_id) = crate::icons::special_folders::icon_id_for_path(Path::new(path)) {
72+
return special_id;
73+
}
5974
return "dir".to_string();
6075
}
6176
// Extract extension
@@ -140,7 +155,7 @@ impl FileEntry {
140155
/// Creates a `FileEntry` with the four essential fields set and everything else defaulted.
141156
pub(crate) fn new(name: String, path: String, is_dir: bool, is_symlink: bool) -> Self {
142157
Self {
143-
icon_id: get_icon_id(is_dir, is_symlink, &name),
158+
icon_id: get_icon_id(is_dir, is_symlink, &name, &path),
144159
name,
145160
path,
146161
is_directory: is_dir,
@@ -168,6 +183,55 @@ impl FileEntry {
168183
}
169184
}
170185

186+
#[cfg(test)]
187+
mod icon_id_tests {
188+
use super::*;
189+
190+
#[test]
191+
fn plain_directory_gets_the_generic_dir_icon() {
192+
let home = dirs::home_dir().expect("home_dir resolves");
193+
let project = home.join("Projects").join("foo");
194+
assert_eq!(get_icon_id(true, false, "foo", &project.to_string_lossy()), "dir");
195+
}
196+
197+
#[test]
198+
fn real_downloads_folder_gets_the_special_key() {
199+
let downloads = dirs::download_dir().expect("download_dir resolves");
200+
assert_eq!(
201+
get_icon_id(true, false, "Downloads", &downloads.to_string_lossy()),
202+
"special:downloads"
203+
);
204+
}
205+
206+
#[test]
207+
fn a_folder_merely_named_downloads_elsewhere_stays_generic() {
208+
let home = dirs::home_dir().expect("home_dir resolves");
209+
let fake = home.join("Projects").join("Downloads");
210+
assert_eq!(get_icon_id(true, false, "Downloads", &fake.to_string_lossy()), "dir");
211+
}
212+
213+
#[test]
214+
fn a_symlink_to_a_special_path_keeps_the_symlink_dir_icon() {
215+
// Symlinks keep the link badge; we don't promote them to `special:*`.
216+
let downloads = dirs::download_dir().expect("download_dir resolves");
217+
assert_eq!(
218+
get_icon_id(true, true, "Downloads", &downloads.to_string_lossy()),
219+
"symlink-dir"
220+
);
221+
}
222+
223+
#[test]
224+
fn files_are_unaffected_by_special_folder_detection() {
225+
// Even a file sitting at a path that string-matches a special folder must
226+
// route by extension, never to `special:*`.
227+
let downloads = dirs::download_dir().expect("download_dir resolves");
228+
assert_eq!(
229+
get_icon_id(false, false, "notes.txt", &downloads.to_string_lossy()),
230+
"ext:txt"
231+
);
232+
}
233+
}
234+
171235
/// Extended metadata for a single file (macOS-specific fields).
172236
#[derive(Debug, Clone, Serialize, Deserialize)]
173237
#[serde(rename_all = "camelCase")]
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Icons module
2+
3+
OS icon retrieval and caching for the file list. Entries carry only an `iconId`; the frontend batches the unique ids
4+
for visible rows and fetches each once via `get_icons`, so 50k files never transmit 50k icon blobs.
5+
6+
This is the Rust `src/icons/` module. (`src-tauri/icons/`, a sibling at the crate root, holds the app *bundle* icons —
7+
unrelated.)
8+
9+
## Icon-id scheme
10+
11+
`get_icon_id` (in `file_system/listing/metadata.rs`) assigns each entry an id; `get_icons` resolves it to a base64 WebP
12+
data URL. The id namespace, by tier:
13+
14+
| Tier | Id | Assigned to | Fetched from |
15+
| --- | --- | --- | --- |
16+
| A | `dir` / `symlink-dir` | every plain folder (~99%) | the home dir (sample) |
17+
| A | `ext:{x}` / `file` / `symlink*` | files | a per-extension temp sample / `/etc/hosts` |
18+
| B | `special:{name}` | the finite special **system** folders | the folder's REAL path (8 MB thread) |
19+
| C | `path:{dir}` / `pkg:{dir}` | per-path icons (volumes, packages, custom-icon folders) | the real path (8 MB thread) |
20+
|| `git:{branch,tag,commit,fork}` | git-portal virtual entries | rendered by the FE via Lucide, never here |
21+
22+
`dir` / `ext:*` / `file` / `symlink*` / `special:*` are inherently **bounded**, so they're uncapped and persist to
23+
localStorage. `path:*` / `pkg:*` are **unbounded** (grow with folders visited), so they're LRU-capped (`PATH_KEY_CAP`)
24+
and never persisted. See `clear_directory_icon_cache` for which keys a theme/accent change drops (`dir`, `symlink-dir`,
25+
`path:*`, `special:*` — all appearance-tinted by macOS).
26+
27+
## Tier B — special system folders (`special_folders.rs`)
28+
29+
The finite set: Downloads, Desktop, Documents, Movies, Music, Pictures, Public, the home folder, plus (macOS only)
30+
Applications and the Trash. Detected by **canonical path**, NOT by name — a folder merely *named* "Downloads" under
31+
`~/Projects/` is not the real one and stays `dir`. The set of real paths is resolved once at startup via the `dirs`
32+
crate (`/Applications` and `~/.Trash` are hardcoded; `dirs` has no entry for them). `classify` is a lexical-path
33+
`HashMap` lookup with **no disk I/O** (no `canonicalize` — it would block on a dead mount), so it's cheap enough to run
34+
per entry during listing.
35+
36+
`get_icons` re-keys each uncached `special:*` id to its real path, fetches via the 8 MB `fetch_path_icons` thread (the
37+
real folder can be iCloud-synced and descend into `fileproviderd`; see `file_system/CLAUDE.md` § Gotchas), then caches
38+
under the bounded `special:{name}` key. The FE renders the fetched icon and falls back to the generic `dir` glyph while
39+
the fetch is pending, FDA-gated, or timed out — the feature is purely additive.
40+
41+
Symlinks to a special location keep `symlink-dir` (the link badge is the salient signal; following the link to classify
42+
would cost a syscall per entry).
43+
44+
## Threading + FDA
45+
46+
Per-path / per-special NSWorkspace fetches run on dedicated 8 MB-stack OS threads (`fetch_path_icons`), never rayon —
47+
real folders can be cloud folders whose icon lookup descends through deep FileProvider XPC chains that overflow rayon's
48+
2 MB worker stack. The extension branch (sample temp paths, never cloud) stays on rayon. All fetches are FDA-gated in
49+
`commands/icons.rs` (NSWorkspace touches TCC services); the FE re-requests after the gate clears. Linux skips
50+
NSWorkspace entirely and resolves via the XDG theme lookup, so `special:*` degrades to the generic folder icon there.
Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
//! Benchmarked on M1 Mac: 10 files→3.7ms, 50→8ms, 100→12.8ms, 200→21ms.
55
//! Custom thread counts showed no improvement, so we use auto-detect.
66
7+
pub mod special_folders;
8+
79
use crate::config::ICON_SIZE;
810
use crate::ignore_poison::RwLockIgnorePoison;
911
use base64::Engine;
@@ -103,13 +105,17 @@ pub fn clear_extension_icon_cache() {
103105
ICON_CACHE.write_ignore_poison().retain(|key| !key.starts_with("ext:"));
104106
}
105107

106-
/// Clears all cached icons for directory entries (`dir`, `symlink-dir`, `path:*`).
107-
/// Called when the system theme or accent color changes, since macOS folder icons
108-
/// are tinted by the current appearance.
108+
/// Clears all cached icons for directory entries (`dir`, `symlink-dir`,
109+
/// `path:*`, `special:*`). Called when the system theme or accent color changes,
110+
/// since macOS folder icons (including the special-folder glyphs) are tinted by
111+
/// the current appearance.
109112
pub fn clear_directory_icon_cache() {
110-
ICON_CACHE
111-
.write_ignore_poison()
112-
.retain(|key| key != "dir" && key != "symlink-dir" && !key.starts_with(PATH_KEY_PREFIX));
113+
ICON_CACHE.write_ignore_poison().retain(|key| {
114+
key != "dir"
115+
&& key != "symlink-dir"
116+
&& !key.starts_with(PATH_KEY_PREFIX)
117+
&& !key.starts_with(special_folders::SPECIAL_KEY_PREFIX)
118+
});
113119
}
114120

115121
/// Converts an image to a base64 WebP data URL.
@@ -192,12 +198,47 @@ fn get_sample_path_for_icon_id(icon_id: &str) -> Option<PathBuf> {
192198
pub fn get_icons(icon_ids: Vec<String>, use_app_icons_for_documents: bool) -> HashMap<String, String> {
193199
let mut result = HashMap::new();
194200

201+
// Special system folders (`special:downloads`, …) fetch their icon from the
202+
// folder's REAL path, which can be a cloud-synced location (Desktop &
203+
// Documents iCloud sync) whose NSWorkspace lookup descends into
204+
// `fileproviderd`. Route them through the dedicated 8 MB-stack fetch (same as
205+
// the `path:` branch), NOT the generic per-id loop below, which runs on the
206+
// calling thread. The result stays keyed by `special:{name}` (bounded), not
207+
// by the real path.
208+
let mut remaining = Vec::with_capacity(icon_ids.len());
209+
let mut special_to_fetch: Vec<(String, String)> = Vec::new();
195210
for icon_id in icon_ids {
196-
// Check cache first
197211
if let Some(cached) = get_cached_icon(&icon_id) {
198212
result.insert(icon_id, cached);
199213
continue;
200214
}
215+
if icon_id.starts_with(special_folders::SPECIAL_KEY_PREFIX) {
216+
if let Some(real_path) = special_folders::real_path_for_icon_id(&icon_id) {
217+
special_to_fetch.push((icon_id, real_path.to_string_lossy().into_owned()));
218+
}
219+
// Unknown special name (no resolved standard location): skip; the
220+
// frontend keeps the `dir` fallback.
221+
continue;
222+
}
223+
remaining.push(icon_id);
224+
}
225+
226+
if !special_to_fetch.is_empty() {
227+
let paths: Vec<String> = special_to_fetch.iter().map(|(_, path)| path.clone()).collect();
228+
let fetched = fetch_path_icons(paths);
229+
// `fetch_path_icons` returns `(path:{real_path}, data_url)` in input
230+
// order; re-key each back to its `special:{name}` id and cache under the
231+
// bounded key.
232+
for ((special_id, _), (_, data_url)) in special_to_fetch.into_iter().zip(fetched) {
233+
if let Some(url) = data_url {
234+
cache_icon(special_id.clone(), url.clone());
235+
result.insert(special_id, url);
236+
}
237+
}
238+
}
239+
240+
for icon_id in remaining {
241+
// Cache was already checked above for this batch.
201242

202243
// macOS: drain autoreleased ObjC objects per iteration
203244
// (fetch_fresh_extension_icon and fetch_icon_for_path call ObjC APIs)

0 commit comments

Comments
 (0)