Skip to content

Commit b8c588e

Browse files
committed
Metadata phase 2: add icons
- Add `file_icon_provider` and `image` crates - Get icons from the OS: create `icons.rs` module with icon ID generation and caching - Implement `get_icons` Tauri command to get the icons to the front end - Create frontend icon cache (in localStorage) - Update `FileList.svelte` to use cached icons - Test: All checks pass
1 parent c5724c0 commit b8c588e

13 files changed

Lines changed: 1427 additions & 155 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 1120 additions & 149 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ serde_json = "1"
2727
notify = "8"
2828
dirs = "6"
2929
uzers = "0.12.2"
30+
file_icon_provider = "0.4.0"
31+
image = "0.25.9"
32+
base64 = "0.22.1"
3033

3134
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
3235
tauri-plugin-window-state = "2"

src-tauri/deny.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ allow = [
2424
"Unicode-3.0",
2525
"CC0-1.0",
2626
"AGPL-3.0",
27+
"NCSA", # For libfuzzer-sys (transitive dependency of image crate)
2728
]
2829

2930
[bans]

src-tauri/src/commands/icons.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//! Tauri commands for icon retrieval.
2+
3+
use crate::icons;
4+
use std::collections::HashMap;
5+
6+
/// Gets icon data URLs for the requested icon IDs.
7+
/// Returns a map of icon_id -> base64 WebP data URL.
8+
/// Only fetches icons not already cached; clients should cache returned icons.
9+
#[tauri::command]
10+
pub fn get_icons(icon_ids: Vec<String>) -> HashMap<String, String> {
11+
icons::get_icons(icon_ids)
12+
}

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
//! Tauri commands module.
22
33
pub mod file_system;
4+
pub mod icons;

src-tauri/src/icons.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//! Icon retrieval and caching for file types.
2+
3+
use base64::Engine;
4+
use file_icon_provider::get_file_icon;
5+
use image::{DynamicImage, ImageFormat, imageops::FilterType};
6+
use std::collections::HashMap;
7+
use std::io::Cursor;
8+
use std::path::Path;
9+
use std::sync::RwLock;
10+
11+
/// Icon size in pixels (32x32 for retina display)
12+
const ICON_SIZE: u32 = 32;
13+
14+
/// Cache for generated icons (icon_id -> base64 WebP data URL)
15+
static ICON_CACHE: RwLock<Option<HashMap<String, String>>> = RwLock::new(None);
16+
17+
/// Initializes the icon cache if not already done.
18+
fn ensure_cache() {
19+
let cache = ICON_CACHE.read().unwrap();
20+
if cache.is_some() {
21+
return;
22+
}
23+
drop(cache);
24+
let mut cache = ICON_CACHE.write().unwrap();
25+
if cache.is_none() {
26+
*cache = Some(HashMap::new());
27+
}
28+
}
29+
30+
/// Gets cached icon data URL for the given icon ID, if available.
31+
fn get_cached_icon(icon_id: &str) -> Option<String> {
32+
ensure_cache();
33+
let cache = ICON_CACHE.read().unwrap();
34+
cache.as_ref()?.get(icon_id).cloned()
35+
}
36+
37+
/// Caches an icon data URL.
38+
fn cache_icon(icon_id: String, data_url: String) {
39+
ensure_cache();
40+
let mut cache = ICON_CACHE.write().unwrap();
41+
if let Some(ref mut map) = *cache {
42+
map.insert(icon_id, data_url);
43+
}
44+
}
45+
46+
/// Converts an image to a base64 WebP data URL.
47+
fn image_to_data_url(img: &DynamicImage) -> Option<String> {
48+
// Resize to 32x32
49+
let resized = img.resize_exact(ICON_SIZE, ICON_SIZE, FilterType::Lanczos3);
50+
51+
// Encode as WebP
52+
let mut buffer = Cursor::new(Vec::new());
53+
resized.write_to(&mut buffer, ImageFormat::WebP).ok()?;
54+
55+
// Convert to base64 data URL
56+
let base64 = base64::engine::general_purpose::STANDARD.encode(buffer.into_inner());
57+
Some(format!("data:image/webp;base64,{}", base64))
58+
}
59+
60+
/// Fetches icon for a specific file path.
61+
fn fetch_icon_for_path(path: &Path) -> Option<String> {
62+
// Get icon from OS (size is u16)
63+
let icon = get_file_icon(path, ICON_SIZE as u16).ok()?;
64+
65+
// file_icon_provider returns Icon with width, height, and RGBA pixels
66+
let img = image::RgbaImage::from_raw(icon.width, icon.height, icon.pixels)?;
67+
let dynamic_img = DynamicImage::ImageRgba8(img);
68+
69+
image_to_data_url(&dynamic_img)
70+
}
71+
72+
/// Generates icon ID based on file properties.
73+
/// This is called during list_directory.
74+
pub fn generate_icon_id(is_dir: bool, is_symlink: bool, extension: Option<&str>) -> String {
75+
if is_symlink {
76+
return "symlink".to_string();
77+
}
78+
if is_dir {
79+
return "dir".to_string();
80+
}
81+
match extension {
82+
Some(ext) => format!("ext:{}", ext.to_lowercase()),
83+
None => "file".to_string(),
84+
}
85+
}
86+
87+
/// Gets the sample file path to use for fetching an icon by ID.
88+
/// For extension-based icons, we create a temp file with that extension.
89+
fn get_sample_path_for_icon_id(icon_id: &str) -> Option<std::path::PathBuf> {
90+
if icon_id == "dir" {
91+
// Use home directory as sample directory
92+
return dirs::home_dir();
93+
}
94+
if icon_id == "symlink" {
95+
// Symlinks use a generic file icon
96+
return Some(std::path::PathBuf::from("/tmp"));
97+
}
98+
if icon_id == "file" {
99+
// Generic file with no extension
100+
return Some(std::path::PathBuf::from("/tmp/file"));
101+
}
102+
if let Some(ext) = icon_id.strip_prefix("ext:") {
103+
// Create a fake path with the extension to get the right icon
104+
return Some(std::path::PathBuf::from(format!("/tmp/sample.{}", ext)));
105+
}
106+
None
107+
}
108+
109+
/// Fetches icons for the given icon IDs that are not already cached.
110+
/// Returns a map of icon_id -> data URL.
111+
pub fn get_icons(icon_ids: Vec<String>) -> HashMap<String, String> {
112+
let mut result = HashMap::new();
113+
114+
for icon_id in icon_ids {
115+
// Check cache first
116+
if let Some(cached) = get_cached_icon(&icon_id) {
117+
result.insert(icon_id, cached);
118+
continue;
119+
}
120+
121+
// Not cached, fetch it
122+
if let Some(sample_path) = get_sample_path_for_icon_id(&icon_id)
123+
&& let Some(data_url) = fetch_icon_for_path(&sample_path)
124+
{
125+
cache_icon(icon_id.clone(), data_url.clone());
126+
result.insert(icon_id, data_url);
127+
}
128+
}
129+
130+
result
131+
}
132+
133+
#[cfg(test)]
134+
mod tests {
135+
use super::*;
136+
137+
#[test]
138+
fn test_generate_icon_id_directory() {
139+
assert_eq!(generate_icon_id(true, false, None), "dir");
140+
}
141+
142+
#[test]
143+
fn test_generate_icon_id_symlink() {
144+
assert_eq!(generate_icon_id(false, true, Some("txt")), "symlink");
145+
}
146+
147+
#[test]
148+
fn test_generate_icon_id_extension() {
149+
assert_eq!(generate_icon_id(false, false, Some("PDF")), "ext:pdf");
150+
assert_eq!(generate_icon_id(false, false, Some("jpg")), "ext:jpg");
151+
}
152+
153+
#[test]
154+
fn test_generate_icon_id_no_extension() {
155+
assert_eq!(generate_icon_id(false, false, None), "file");
156+
}
157+
}

src-tauri/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
mod commands;
55
mod file_system;
6+
mod icons;
67
mod menu;
78

89
use menu::{MenuState, SHOW_HIDDEN_FILES_ID};
@@ -58,7 +59,8 @@ pub fn run() {
5859
.invoke_handler(tauri::generate_handler![
5960
greet,
6061
commands::file_system::list_directory_contents,
61-
commands::file_system::path_exists
62+
commands::file_system::path_exists,
63+
commands::icons::get_icons
6264
])
6365
.run(tauri::generate_context!())
6466
.expect("error while running tauri application");

src/lib/file-explorer/DualPaneExplorer.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock('$lib/tauri-commands', () => ({
1818
pathExists: vi.fn().mockResolvedValue(true),
1919
listDirectoryContents: vi.fn().mockResolvedValue([]),
2020
openFile: vi.fn().mockResolvedValue(undefined),
21+
getIcons: vi.fn().mockResolvedValue({}),
2122
}))
2223

2324
// Mock settings-store to avoid Tauri event API dependency in tests

src/lib/file-explorer/FileList.svelte

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import type { FileEntry } from './types'
3+
import { getCachedIcon, prefetchIcons } from '$lib/icon-cache'
34
45
interface Props {
56
files: FileEntry[]
@@ -13,6 +14,31 @@
1314
1415
let listElement: HTMLUListElement | undefined = $state()
1516
17+
// Track which icons we've prefetched to avoid redundant calls (module-level, non-reactive)
18+
// Using a plain Set outside the reactive system since we only add to it
19+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
20+
const prefetchedSet: Set<string> = new Set()
21+
22+
// Prefetch icons when files change
23+
$effect(() => {
24+
const newIconIds = files.map((f) => f.iconId).filter((id) => id && !prefetchedSet.has(id))
25+
if (newIconIds.length > 0) {
26+
// Add to set first to avoid re-fetching during async
27+
newIconIds.forEach((id) => prefetchedSet.add(id))
28+
void prefetchIcons(newIconIds)
29+
}
30+
})
31+
32+
function getIconUrl(file: FileEntry): string | undefined {
33+
return getCachedIcon(file.iconId)
34+
}
35+
36+
function getFallbackEmoji(file: FileEntry): string {
37+
if (file.isSymlink) return '🔗'
38+
if (file.isDirectory) return '📁'
39+
return '📄'
40+
}
41+
1642
function formatName(entry: FileEntry): string {
1743
return entry.name
1844
}
@@ -60,10 +86,10 @@
6086
role="option"
6187
aria-selected={index === selectedIndex}
6288
>
63-
{#if file.isDirectory}
64-
<span class="icon">📁</span>
89+
{#if getIconUrl(file)}
90+
<img class="icon" src={getIconUrl(file)} alt="" width="16" height="16" />
6591
{:else}
66-
<span class="icon">📄</span>
92+
<span class="icon-emoji">{getFallbackEmoji(file)}</span>
6793
{/if}
6894
<span class="name">{formatName(file)}</span>
6995
</li>
@@ -98,8 +124,17 @@
98124
}
99125
100126
.icon {
127+
width: 16px;
128+
height: 16px;
129+
flex-shrink: 0;
130+
object-fit: contain;
131+
}
132+
133+
.icon-emoji {
101134
font-size: var(--font-size-sm);
102135
flex-shrink: 0;
136+
width: 16px;
137+
text-align: center;
103138
}
104139
105140
.name {

src/lib/file-explorer/FileList.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import FileList from './FileList.svelte'
44
import type { FileEntry } from './types'
55
import { createFileEntry } from './test-helpers'
66

7+
// Mock icon-cache to avoid Tauri dependency
8+
vi.mock('$lib/icon-cache', () => ({
9+
getCachedIcon: vi.fn().mockReturnValue(undefined),
10+
prefetchIcons: vi.fn().mockResolvedValue(undefined),
11+
}))
12+
713
describe('FileList', () => {
814
const noop = () => {}
915

@@ -82,7 +88,7 @@ describe('FileList', () => {
8288
props: { files, selectedIndex: 0, onSelect: noop, onNavigate: noop },
8389
})
8490

85-
const icons = target.querySelectorAll('.icon')
91+
const icons = target.querySelectorAll('.icon, .icon-emoji')
8692
expect(icons.length).toBeGreaterThan(0)
8793
})
8894

0 commit comments

Comments
 (0)