Skip to content

Commit bc3dc85

Browse files
committed
Add file metadata display
- Add SelectionInfo.svelte showing selected file's name, size, and date - Size displays with digit-triad coloring (kB→yellow, MB→orange, GB→red, TB→purple) - Filenames middle-truncate responsively ("file-with-long-na…ext") - Date tooltip shows created, opened, moved ("added"), and modified dates - Size tooltip shows human-readable format (e.g., "1.5 GB") Rust: - Add `addedAt` and `openedAt` fields to `FileEntry` - New `macos_metadata.rs` using NSURL for macOS-specific dates - Add `objc2` and `objc2-foundation` dependencies Edge cases handled: - ".." entry shows current directory's modified date Testing: - Add `ResizeObserver` mock to `test-setup.ts` for `jsdom` - Add `setupFiles` to `vitest.config.ts`
1 parent 869cdfb commit bc3dc85

13 files changed

Lines changed: 433 additions & 0 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 2 additions & 0 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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ core-services = "1.0.0"
4343
icns = "0.3.1"
4444
plist = "1.8.0"
4545
urlencoding = "2.1.3"
46+
objc2 = "0.6"
47+
objc2-foundation = { version = "0.3", features = ["NSURL", "NSString", "NSDictionary", "NSDate", "NSArray", "NSValue", "NSError"] }
4648

4749
[dev-dependencies]
4850
criterion = { version = "0.8.1", features = ["html_reports"] }
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//! macOS-specific file metadata retrieval using NSURL resource values.
2+
//!
3+
//! Provides access to metadata not available through standard `std::fs`:
4+
//! - `added_at`: When the file was added to its current directory (moved/copied)
5+
//! - `opened_at`: When the file was last opened
6+
7+
use std::path::Path;
8+
9+
use objc2::rc::Retained;
10+
use objc2_foundation::{NSDate, NSString, NSURL};
11+
12+
/// Extended macOS metadata for a file.
13+
pub struct MacOSMetadata {
14+
/// Unix timestamp: when the file was added to its current directory
15+
pub added_at: Option<u64>,
16+
/// Unix timestamp: when the file was last opened
17+
pub opened_at: Option<u64>,
18+
}
19+
20+
/// Retrieves macOS-specific metadata for a file using NSURL resource values.
21+
///
22+
/// Returns `None` values for individual fields if they are unavailable on the volume
23+
/// or if any error occurs during retrieval.
24+
pub fn get_macos_metadata(path: &Path) -> MacOSMetadata {
25+
// Helper to convert NSDate to Unix timestamp
26+
fn nsdate_to_unix(date: Option<Retained<NSDate>>) -> Option<u64> {
27+
date.and_then(|d| {
28+
// NSDate timeIntervalSince1970 returns seconds since Unix epoch as f64
29+
let interval = d.timeIntervalSince1970();
30+
if interval >= 0.0 { Some(interval as u64) } else { None }
31+
})
32+
}
33+
34+
// Convert path to NSString
35+
let path_str = match path.to_str() {
36+
Some(s) => s,
37+
None => {
38+
return MacOSMetadata {
39+
added_at: None,
40+
opened_at: None,
41+
};
42+
}
43+
};
44+
45+
let ns_path = NSString::from_str(path_str);
46+
47+
// Create NSURL from file path
48+
let url = NSURL::fileURLWithPath(&ns_path);
49+
50+
// Fetch added_at (NSURLAddedToDirectoryDateKey)
51+
let added_at = {
52+
let key = NSString::from_str("NSURLAddedToDirectoryDateKey");
53+
let mut value: Option<Retained<objc2::runtime::AnyObject>> = None;
54+
let success = unsafe { url.getResourceValue_forKey_error(&mut value, &key) };
55+
if success.is_ok() {
56+
// Cast AnyObject to NSDate if it's a date
57+
value.and_then(|obj| {
58+
// Downcast to NSDate - this is safe because we know the key returns NSDate
59+
let retained = obj.downcast::<NSDate>().ok();
60+
nsdate_to_unix(retained)
61+
})
62+
} else {
63+
None
64+
}
65+
};
66+
67+
// Fetch opened_at (NSURLContentAccessDateKey)
68+
let opened_at = {
69+
let key = NSString::from_str("NSURLContentAccessDateKey");
70+
let mut value: Option<Retained<objc2::runtime::AnyObject>> = None;
71+
let success = unsafe { url.getResourceValue_forKey_error(&mut value, &key) };
72+
if success.is_ok() {
73+
value.and_then(|obj| {
74+
let retained = obj.downcast::<NSDate>().ok();
75+
nsdate_to_unix(retained)
76+
})
77+
} else {
78+
None
79+
}
80+
};
81+
82+
MacOSMetadata { added_at, opened_at }
83+
}

src-tauri/src/file_system/mock_provider.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ impl MockFileSystemProvider {
3030
size: Some(1024 * (i as u64)),
3131
modified_at: Some(1640000000 + i as u64),
3232
created_at: Some(1639000000 + i as u64),
33+
added_at: Some(1638000000 + i as u64),
34+
opened_at: Some(1641000000 + i as u64),
3335
permissions: 0o644,
3436
owner: "testuser".to_string(),
3537
group: "staff".to_string(),

src-tauri/src/file_system/mock_provider_test.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ fn test_mock_provider_returns_entries() {
1414
size: Some(1024),
1515
modified_at: Some(1640000000),
1616
created_at: Some(1639000000),
17+
added_at: Some(1638000000),
18+
opened_at: Some(1641000000),
1719
permissions: 0o644,
1820
owner: "testuser".to_string(),
1921
group: "staff".to_string(),
@@ -27,6 +29,8 @@ fn test_mock_provider_returns_entries() {
2729
size: None,
2830
modified_at: Some(1640000000),
2931
created_at: Some(1639000000),
32+
added_at: Some(1638000000),
33+
opened_at: None,
3034
permissions: 0o755,
3135
owner: "testuser".to_string(),
3236
group: "staff".to_string(),

src-tauri/src/file_system/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! File system module - operations, watchers, and providers.
22
3+
#[cfg(target_os = "macos")]
4+
mod macos_metadata;
35
#[cfg(test)]
46
mod mock_provider;
57
mod operations;

src-tauri/src/file_system/operations.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ pub struct FileEntry {
9494
pub size: Option<u64>,
9595
pub modified_at: Option<u64>,
9696
pub created_at: Option<u64>,
97+
/// When the file was added to its current directory (macOS only)
98+
pub added_at: Option<u64>,
99+
/// When the file was last opened (macOS only)
100+
pub opened_at: Option<u64>,
97101
pub permissions: u32,
98102
pub owner: String,
99103
pub group: String,
@@ -170,6 +174,15 @@ pub fn list_directory(path: &Path) -> Result<Vec<FileEntry>, std::io::Error> {
170174
owner_lookup_time += owner_start.elapsed();
171175

172176
let create_start = std::time::Instant::now();
177+
// Get macOS-specific metadata (added_at, opened_at)
178+
#[cfg(target_os = "macos")]
179+
let (added_at, opened_at) = {
180+
let macos_meta = super::macos_metadata::get_macos_metadata(&entry.path());
181+
(macos_meta.added_at, macos_meta.opened_at)
182+
};
183+
#[cfg(not(target_os = "macos"))]
184+
let (added_at, opened_at) = (None, None);
185+
173186
entries.push(FileEntry {
174187
name: name.clone(),
175188
path: entry.path().to_string_lossy().to_string(),
@@ -178,6 +191,8 @@ pub fn list_directory(path: &Path) -> Result<Vec<FileEntry>, std::io::Error> {
178191
size: if metadata.is_file() { Some(metadata.len()) } else { None },
179192
modified_at: modified,
180193
created_at: created,
194+
added_at,
195+
opened_at,
181196
permissions: metadata.permissions().mode(),
182197
owner,
183198
group,
@@ -196,6 +211,8 @@ pub fn list_directory(path: &Path) -> Result<Vec<FileEntry>, std::io::Error> {
196211
size: None,
197212
modified_at: None,
198213
created_at: None,
214+
added_at: None,
215+
opened_at: None,
199216
permissions: 0,
200217
owner: String::new(),
201218
group: String::new(),

src/app.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
/* === Semantic Colors === */
3131
--color-error: #d32f2f;
3232

33+
/* === Size tier colors (20% offset toward target color) === */
34+
--color-size-kb: color-mix(in srgb, var(--color-text-secondary) 70%, #f0c000);
35+
--color-size-mb: color-mix(in srgb, var(--color-text-secondary) 70%, #ff8c00);
36+
--color-size-gb: color-mix(in srgb, var(--color-text-secondary) 70%, #ff4444);
37+
--color-size-tb: color-mix(in srgb, var(--color-text-secondary) 70%, #aa44ff);
38+
3339
/* === Spacing === */
3440
--spacing-xxs: 2px;
3541
--spacing-xs: 4px;

src/lib/file-explorer/FilePane.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { FileEntry } from './types'
44
import { openFile } from '$lib/tauri-commands'
55
import FileList from './FileList.svelte'
6+
import SelectionInfo from './SelectionInfo.svelte'
67
78
/** Chunk size for loading large directories */
89
const CHUNK_SIZE = 5000
@@ -30,6 +31,8 @@
3031
let error = $state<string | null>(null)
3132
let selectedIndex = $state(0)
3233
let fileListRef: FileList | undefined = $state()
34+
/** Metadata for the current directory (used for ".." entry in SelectionInfo) */
35+
const currentDirModifiedAt = $state<number | undefined>(undefined)
3336
3437
// Track the current load operation to cancel outdated ones
3538
let loadGeneration = 0
@@ -48,6 +51,9 @@
4851
return filterFiles(allFilesRaw, showHiddenFiles)
4952
})
5053
54+
// Currently selected entry for SelectionInfo (must be after files declaration)
55+
const selectedEntry = $derived(files[selectedIndex] ?? null)
56+
5157
// Create ".." entry for parent navigation
5258
function createParentEntry(path: string): FileEntry | null {
5359
if (path === '/') return null
@@ -310,6 +316,7 @@
310316
{/if}
311317
{/if}
312318
</div>
319+
<SelectionInfo entry={selectedEntry} {currentDirModifiedAt} />
313320
</div>
314321

315322
<style>

0 commit comments

Comments
 (0)