From 0ce8285754b5f8272cad50643ea5bca224442c95 Mon Sep 17 00:00:00 2001 From: Denis Stoyanov Date: Thu, 18 Dec 2025 03:53:45 +0200 Subject: [PATCH] Added grid view mode --- .gitignore | 3 +- cardinal/src-tauri/Cargo.lock | 78 +++ cardinal/src-tauri/Cargo.toml | 1 + cardinal/src-tauri/src/background.rs | 22 +- cardinal/src-tauri/src/commands.rs | 64 ++- cardinal/src-tauri/src/lib.rs | 12 +- cardinal/src/App.css | 460 ++++++++++++++++-- cardinal/src/App.tsx | 174 ++++++- cardinal/src/components/FileGridItem.tsx | 214 ++++++++ cardinal/src/components/FileRow.tsx | 22 +- cardinal/src/components/FileRowRenderer.tsx | 6 +- cardinal/src/components/FilesTabContent.tsx | 119 ++++- .../components/MiddleEllipsisHighlight.tsx | 15 +- .../src/components/PreferencesOverlay.tsx | 48 ++ cardinal/src/components/SearchBar.tsx | 25 + cardinal/src/components/StatusBar.tsx | 28 +- cardinal/src/components/VirtualGrid.tsx | 356 ++++++++++++++ cardinal/src/components/VirtualList.tsx | 20 +- cardinal/src/constants/index.ts | 9 + .../src/hooks/__tests__/useSelection.test.ts | 1 + cardinal/src/hooks/useContextMenu.ts | 24 +- cardinal/src/hooks/useDataLoader.ts | 49 +- cardinal/src/hooks/useIconViewport.ts | 10 +- cardinal/src/hooks/useQuickLook.ts | 6 +- cardinal/src/hooks/useSelection.ts | 13 +- cardinal/src/i18n/resources/en-US.json | 10 + cardinal/src/types/ipc.ts | 4 + cardinal/src/types/search.ts | 4 + cardinal/src/utils/selection.ts | 1 - cardinal/src/utils/viewPreferences.ts | 38 ++ fs-icon/src/lib.rs | 154 ++++-- fs-icon/tests/additional.rs | 4 +- 32 files changed, 1796 insertions(+), 198 deletions(-) create mode 100644 cardinal/src/components/FileGridItem.tsx create mode 100644 cardinal/src/components/VirtualGrid.tsx create mode 100644 cardinal/src/utils/viewPreferences.ts diff --git a/.gitignore b/.gitignore index fd531785..07215772 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target .DS_Store -.vscode \ No newline at end of file +.vscode +.idea \ No newline at end of file diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index f2a356ab..b11c158c 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -415,6 +415,7 @@ dependencies = [ "tauri-plugin-window-state", "tracing", "tracing-subscriber", + "trash", ] [[package]] @@ -4861,6 +4862,24 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trash" +version = "5.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b93a14fcf658568eb11b3ac4cb406822e916e2c55cdebc421beeb0bd7c94d8" +dependencies = [ + "chrono", + "libc", + "log", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", +] + [[package]] name = "tray-icon" version = "0.21.2" @@ -4986,6 +5005,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -5319,6 +5344,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5350,6 +5385,18 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -5411,6 +5458,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "windows-implement" version = "0.58.0" @@ -5444,6 +5502,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "windows-interface" version = "0.58.0" @@ -5488,6 +5557,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml index d12c4a4e..e5d87dcd 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ tauri-plugin-prevent-default = "4" tauri-plugin-macos-permissions = "2.3.0" tauri-plugin-window-state = "2" once_cell = { version = "1.20", features = ["parking_lot"] } +trash = "5.2" cardinal-sdk.path = "../../cardinal-sdk" search-cache.path = "../../search-cache" diff --git a/cardinal/src-tauri/src/background.rs b/cardinal/src-tauri/src/background.rs index 7c326e9d..52059dde 100644 --- a/cardinal/src-tauri/src/background.rs +++ b/cardinal/src-tauri/src/background.rs @@ -33,6 +33,8 @@ pub struct StatusBarUpdate { pub struct IconPayload { pub slab_index: SlabIndex, pub icon: String, + pub width: f64, + pub height: f64, } pub struct BackgroundLoopChannels { @@ -41,7 +43,7 @@ pub struct BackgroundLoopChannels { pub search_rx: Receiver, pub result_tx: Sender>, pub node_info_rx: Receiver, - pub icon_viewport_rx: Receiver<(u64, Vec)>, + pub icon_viewport_rx: Receiver<(u64, Vec, f64)>, pub rescan_rx: Receiver<()>, pub icon_update_tx: Sender, } @@ -160,7 +162,7 @@ pub fn run_background_event_loop( let _ = response_tx.send(node_info_results); } recv(icon_viewport_rx) -> update => { - let (_request_id, viewport) = update.expect("Icon viewport channel closed"); + let (_request_id, viewport, icon_size) = update.expect("Icon viewport channel closed"); let nodes = cache.expand_file_nodes(&viewport); let icon_jobs: Vec<_> = viewport @@ -180,11 +182,17 @@ pub fn run_background_event_loop( .for_each(|(slab_index, path)| { let icon_update_tx = icon_update_tx.clone(); spawn(move || { - if let Some(icon) = fs_icon::icon_of_path_ql(&path).map(|data| format!( - "data:image/png;base64,{}", - general_purpose::STANDARD.encode(&data) - )) { - let _ = icon_update_tx.send(IconPayload { slab_index, icon }); + if let Some((data, width, height)) = fs_icon::icon_of_path(&path, icon_size) { + let icon = format!( + "data:image/png;base64,{}", + general_purpose::STANDARD.encode(&data) + ); + let _ = icon_update_tx.send(IconPayload { + slab_index, + icon, + width, + height, + }); } }); }); diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 1c266aee..a07ae680 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -15,7 +15,7 @@ use parking_lot::Mutex; use search_cache::{SearchOptions, SearchOutcome, SearchResultNode, SlabIndex, SlabNodeMetadata}; use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; -use std::process::Command; +use std::{fs, path::PathBuf, process::Command}; use tauri::{AppHandle, Manager, State}; use tracing::{error, info, warn}; @@ -57,7 +57,7 @@ pub struct SearchState { node_info_tx: Sender, - icon_viewport_tx: Sender<(u64, Vec)>, + icon_viewport_tx: Sender<(u64, Vec, f64)>, rescan_tx: Sender<()>, sorted_view_cache: Mutex>, update_window_state_tx: Sender<()>, @@ -68,7 +68,7 @@ impl SearchState { search_tx: Sender, result_rx: Receiver>, node_info_tx: Sender, - icon_viewport_tx: Sender<(u64, Vec)>, + icon_viewport_tx: Sender<(u64, Vec, f64)>, rescan_tx: Sender<()>, update_window_state_tx: Sender<()>, ) -> Self { @@ -131,6 +131,8 @@ pub struct NodeInfo { pub path: String, pub metadata: Option, pub icon: Option, + pub icon_width: Option, + pub icon_height: Option, } #[derive(Serialize, Default)] @@ -248,20 +250,28 @@ pub fn get_nodes_info( .into_iter() .map(|SearchResultNode { path, metadata }| { let path = path.to_string_lossy().into_owned(); - let icon = if include_icons { - fs_icon::icon_of_path_ns(&path).map(|data| { - format!( - "data:image/png;base64,{}", - general_purpose::STANDARD.encode(data) + let (icon, icon_width, icon_height) = if include_icons { + if let Some((data, width, height)) = fs_icon::icon_of_path_ns(&path, 512.0) { + ( + Some(format!( + "data:image/png;base64,{}", + general_purpose::STANDARD.encode(data) + )), + Some(width), + Some(height), ) - }) + } else { + (None, None, None) + } } else { - None + (None, None, None) }; NodeInfo { path, icon, metadata: metadata.as_ref().map(NodeInfoMetadata::from_metadata), + icon_width, + icon_height, } }) .collect() @@ -291,8 +301,13 @@ pub fn get_sorted_view( } #[tauri::command(async)] -pub fn update_icon_viewport(id: u64, viewport: Vec, state: State<'_, SearchState>) { - if let Err(e) = state.icon_viewport_tx.send((id, viewport)) { +pub fn update_icon_viewport( + id: u64, + viewport: Vec, + icon_size: f64, + state: State<'_, SearchState>, +) { + if let Err(e) = state.icon_viewport_tx.send((id, viewport, icon_size)) { error!("Failed to send icon viewport update: {e:?}"); } } @@ -370,3 +385,28 @@ pub async fn toggle_main_window(app: AppHandle) { warn!("Toggle requested but main window is unavailable"); } } +#[tauri::command] +pub async fn trash_paths(paths: Vec) -> Result<(), String> { + let path_bufs: Vec = paths.into_iter().map(PathBuf::from).collect(); + trash::delete_all(path_bufs).map_err(|e| { + error!("Failed to trash paths: {e}"); + e.to_string() + }) +} + +#[tauri::command] +pub async fn delete_paths(paths: Vec) -> Result<(), String> { + for path in paths { + let p = PathBuf::from(path); + if p.is_dir() { + if let Err(e) = fs::remove_dir_all(&p) { + error!("Failed to delete directory {p:?}: {e}"); + return Err(e.to_string()); + } + } else if let Err(e) = fs::remove_file(&p) { + error!("Failed to delete file {p:?}: {e}"); + return Err(e.to_string()); + } + } + Ok(()) +} diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 6cbd1d62..8baaf6d0 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -12,10 +12,10 @@ use background::{ }; use cardinal_sdk::EventWatcher; use commands::{ - NodeInfoRequest, SearchJob, SearchState, activate_main_window, close_quicklook, get_app_status, - get_nodes_info, get_sorted_view, hide_main_window, open_in_finder, open_path, search, - start_logic, toggle_main_window, toggle_quicklook, trigger_rescan, update_icon_viewport, - update_quicklook, + NodeInfoRequest, SearchJob, SearchState, activate_main_window, close_quicklook, delete_paths, + get_app_status, get_nodes_info, get_sorted_view, hide_main_window, open_in_finder, open_path, + search, start_logic, toggle_main_window, toggle_quicklook, trash_paths, trigger_rescan, + update_icon_viewport, update_quicklook, }; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded, unbounded}; use lifecycle::{ @@ -52,7 +52,7 @@ pub fn run() -> Result<()> { let (search_tx, search_rx) = unbounded::(); let (result_tx, result_rx) = unbounded::>(); let (node_info_tx, node_info_rx) = unbounded::(); - let (icon_viewport_tx, icon_viewport_rx) = unbounded::<(u64, Vec)>(); + let (icon_viewport_tx, icon_viewport_rx) = unbounded::<(u64, Vec, f64)>(); let (rescan_tx, rescan_rx) = unbounded::<()>(); let (icon_update_tx, icon_update_rx) = unbounded::(); let (update_window_state_tx, update_window_state_rx) = bounded::<()>(1); @@ -128,6 +128,8 @@ pub fn run() -> Result<()> { hide_main_window, activate_main_window, toggle_main_window, + trash_paths, + delete_paths, ]) .build(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/cardinal/src/App.css b/cardinal/src/App.css index b96d20bf..3f5b9718 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -48,12 +48,17 @@ -webkit-text-size-adjust: 100%; /* Layout tokens */ - --row-height: 24px; /* Keep in sync with the virtual list rowHeight. */ - --col-min-width: 30px; /* Minimum column width. */ - --col-max-width: 10000px; /* Maximum column width. */ + --row-height: 24px; + /* Keep in sync with the virtual list rowHeight. */ + --col-min-width: 30px; + /* Minimum column width. */ + --col-max-width: 10000px; + /* Maximum column width. */ --container-padding: 10px; - --virtual-scrollbar-width: 14px; /* Virtual scrollbar width, consistent with JS SCROLLBAR_WIDTH. */ - --virtual-scrollbar-thumb-min: 24px; /* Shared JS/CSS scrollbar-thumb minimum height (overrideable). */ + --virtual-scrollbar-width: 14px; + /* Virtual scrollbar width, consistent with JS SCROLLBAR_WIDTH. */ + --virtual-scrollbar-thumb-min: 24px; + /* Shared JS/CSS scrollbar-thumb minimum height (overrideable). */ /* Horizontal padding for header & data cells */ --cell-hpad: 0.4rem; } @@ -110,29 +115,31 @@ main, .container { margin: 0; display: grid; - grid-template-rows: auto 1fr auto; /* search bar | scroll area | status bar */ + grid-template-rows: auto 1fr auto; + /* search bar | scroll area | status bar */ grid-template-columns: 100%; - text-align: center; height: 100vh; } /* === Search Bar === */ .search-container { - margin: var(--container-padding) var(--container-padding) 0; - padding: 0; - width: calc(100% - var(--container-padding) * 2); + padding: 8px 16px; + background-color: var(--header-bg); + border-bottom: 1px solid var(--border-color); + width: 100%; } .search-bar { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.35rem 0.45rem; + gap: 0; + padding: 0 0.45rem; border-radius: 12px; border: 1px solid rgba(var(--color-accent-rgb), 0.12); background-color: var(--color-elevated-bg); width: 100%; box-sizing: border-box; + position: relative; } #search-input { @@ -287,7 +294,8 @@ button:active:not(:disabled) { position: relative; display: flex; flex-direction: column; - min-height: 0; /* Allow content to shrink without stretching the grid row. */ + min-height: 0; + /* Allow content to shrink without stretching the grid row. */ /* Allow absolutely positioned vertical scrollbars inside. */ --columns-total: calc( var(--w-filename) + var(--w-path) + var(--w-size) + var(--w-modified) + var(--w-created) @@ -295,13 +303,15 @@ button:active:not(:disabled) { } .scroll-area { - overflow: hidden; /* Hide the outer scrollbar while letting children scroll. */ + overflow: hidden; + /* Hide the outer scrollbar while letting children scroll. */ height: 100%; flex: 1; display: flex; flex-direction: column; - min-height: 0; /* Prevent children from stretching the container. */ - padding: 0 0 0 var(--container-padding); + min-height: 0; + /* Prevent children from stretching the container. */ + padding: 0 12px; } .events-panel-wrapper { @@ -339,9 +349,9 @@ button:active:not(:disabled) { /* Events grid layout - similar to files grid */ .columns-events { display: grid; - grid-template-columns: - var(--w-event-time) var(--w-event-flags) var(--w-event-name) - var(--w-event-path); + grid-template-columns: var(--w-event-time) var(--w-event-flags) var(--w-event-name) var( + --w-event-path + ); align-items: center; width: var(--columns-events-total); min-width: var(--columns-events-total); @@ -432,12 +442,15 @@ button:active:not(:disabled) { .header-row-container { overflow: hidden; /* Disable header scrolling; rely on the grid-provided scroll position. */ - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE and Edge */ } .header-row-container::-webkit-scrollbar { - display: none; /* Chrome, Safari, Opera */ + display: none; + /* Chrome, Safari, Opera */ } .header-row { @@ -627,8 +640,10 @@ button:active:not(:disabled) { .virtual-list { height: 100%; flex: 1; - overflow: hidden; /* Hide overflow on the outer wrapper. */ - position: relative; /* Allow absolutely positioned children. */ + overflow: hidden; + /* Hide overflow on the outer wrapper. */ + position: relative; + /* Allow absolutely positioned children. */ width: 100%; } @@ -685,6 +700,7 @@ button:active:not(:disabled) { opacity: 0; transform: scale(0.98); } + to { opacity: 1; transform: scale(1); @@ -713,10 +729,13 @@ button:active:not(:disabled) { /* === Rows & Cells === */ .row { /* Remove horizontal padding to avoid width overflow (columns + padding). */ - padding: 5px 0; /* Previously 5px 10px. */ + padding: 5px 0; + /* Previously 5px 10px. */ box-sizing: border-box; - white-space: nowrap; /* Prevent text wrapping. */ - overflow: visible; /* Keep column content visible during horizontal scroll. */ + white-space: nowrap; + /* Prevent text wrapping. */ + overflow: visible; + /* Keep column content visible during horizontal scroll. */ background-color: var(--row-even); border-radius: 6px; } @@ -851,6 +870,7 @@ button:active:not(:disabled) { 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } @@ -1022,17 +1042,16 @@ button:active:not(:disabled) { } /* === Merged: StatusBar.css === */ + .status-bar { - height: 30px; - background: var(--color-elevated-bg); - border-top: 1px solid var(--color-border); - display: flex; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - justify-content: space-between; - padding: 0 12px; - font-size: 12px; - color: var(--color-text); - font-family: var(--font-sans); + padding: 4px 16px; + background-color: var(--status-bar-bg); + border-top: 1px solid var(--border-color); + font-size: 11px; + height: 28px; } .status-section { @@ -1043,9 +1062,40 @@ button:active:not(:disabled) { } .status-left { + display: flex; + align-items: center; + gap: 16px; + justify-self: start; +} + +.status-center { + display: flex; + align-items: center; + justify-content: center; +} + +.status-right { display: flex; align-items: center; gap: 12px; + justify-self: end; +} + +.grid-size-slider-container { + display: flex; + align-items: center; + padding: 0; +} + +.grid-size-slider { + width: 100px; + height: 4px; + cursor: pointer; +} + +.grid-size-slider:disabled { + opacity: 0.4; + cursor: not-allowed; } .status-left > .status-section:first-of-type { @@ -1427,13 +1477,15 @@ button:active:not(:disabled) { } .state-title { - font-size: 1.125rem; /* 18px */ + font-size: 1.125rem; + /* 18px */ font-weight: 500; color: var(--color-text); } .state-message { - font-size: 0.875rem; /* 14px */ + font-size: 0.875rem; + /* 14px */ color: var(--color-muted); } @@ -1612,6 +1664,56 @@ button:active:not(:disabled) { position: relative; } +.segmented-control { + display: flex; + background: var(--color-elevated-bg); + border: 1px solid var(--color-elevated-border, var(--color-border)); + border-radius: 8px; + overflow: hidden; +} + +.segmented-control__button { + padding: 6px 12px; + border: none; + background: transparent; + color: var(--color-text); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: + background 0.2s ease, + color 0.2s ease; +} + +.segmented-control__button:hover { + background: rgba(var(--color-text-rgb), 0.05); +} + +.segmented-control__button.is-active { + background: var(--color-accent); + color: white; +} + +.preferences-control--slider { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + width: auto; +} + +.preferences-slider-value { + font-variant-numeric: tabular-nums; + font-size: 0.9em; + color: var(--color-muted); + min-width: 3em; + text-align: right; +} + +.preferences-control .grid-size-slider { + width: 100px; +} + .preferences-switch__track::after { content: ''; position: absolute; @@ -1682,15 +1784,18 @@ button:active:not(:disabled) { width: var(--virtual-scrollbar-width); position: relative; flex-shrink: 0; - padding: 0 3px; /* Create gutter spacing for the track. */ + padding: 0 3px; + /* Create gutter spacing for the track. */ box-sizing: border-box; display: flex; user-select: none; -webkit-user-select: none; contain: layout paint; transition: opacity 0.25s ease; - opacity: 1; /* Always visible. */ - pointer-events: auto; /* Always interactive. */ + opacity: 1; + /* Always visible. */ + pointer-events: auto; + /* Always interactive. */ } .virtual-scrollbar-track { @@ -1705,7 +1810,8 @@ button:active:not(:disabled) { left: 0; top: 0; width: 100%; - min-height: 24px; /* Match row height so the thumb is easy to grab. */ + min-height: 24px; + /* Match row height so the thumb is easy to grab. */ background: rgba(0, 0, 0, 0.35); background-clip: padding-box; border-radius: 7px; @@ -1737,3 +1843,273 @@ button:active:not(:disabled) { overflow-x: auto; overflow-y: hidden; } + +.scroll-area { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + flex: 1; +} + +.flex-fill { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* === Grid View === */ +.virtual-grid { + display: flex; + height: 100%; + flex: 1; + overflow: hidden; + position: relative; + width: 100%; +} + +.virtual-grid-viewport { + position: relative; + height: 100%; + flex: 1; + overflow: hidden; + contain: strict; +} + +.grid-item { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: flex-start !important; + padding: 12px 0 8px 0; + /* Balanced top padding, 0 side padding, 8px bottom */ + cursor: default; + border-radius: 8px; + border-radius: 8px; + user-select: none; + text-align: center; + width: var(--grid-width, 100px); +} + +.grid-item-selected { + background-color: transparent; + color: inherit; + z-index: 10; + --grid-sel-bg: var(--color-accent); + --grid-sel-icon-bg: rgba(var(--color-accent-rgb), 0.15); +} + +[data-window-focused='false'] .grid-item-selected { + --grid-sel-bg: #99a2ad; + /* macOS-like inactive grey */ + --grid-sel-icon-bg: rgba(0, 0, 0, 0.08); +} + +.grid-item-selected .grid-item-icon { + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.15)); +} + +.grid-item-selected .grid-item-icon-selection { + background-color: var(--grid-sel-icon-bg); + border-radius: 4px; +} + +.grid-item-icon { + min-width: 100%; + min-height: 100%; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15)); + /* No transition to prevent jitter */ +} + +.grid-item-icon-placeholder { + background-color: var(--color-border); + border-radius: 8px; + opacity: 0.3; +} + +.grid-item-icon-container { + /* Flexible container */ + width: 100%; + height: auto; + min-height: var(--icon-size, 64px); + display: flex; + align-items: flex-end; + /* Bottom align like Finder */ + justify-content: center; + margin-bottom: 4px; + position: relative; +} + +.grid-item-icon-selection { + display: flex; + align-items: center; + justify-content: center; + /* Add padding to simulate macOS spacing around the icon inside selection */ + padding: 4px; + box-sizing: content-box; + /* Width/Height apply to content (icon), padding is extra */ +} + +[data-window-focused='false'] .grid-item-selected .grid-item-name-text { + background-color: #d1d1d1; + /* Inactive selection grey */ + color: var(--color-text); +} + +.grid-item-name { + font-size: 11px; + line-height: 1.3em; + max-height: 3.9em; + /* Massive headroom for 2 lines + descenders + highlights */ + max-width: 100%; + padding: 2px 2px 8px 2px; + /* Reduced top and bottom padding */ + /* Large bottom padding for descenders */ + word-break: break-word; + overflow-wrap: anywhere; + overflow: hidden; + text-align: center; +} + +.grid-item-name-text { + padding: 1px 6px; + border-radius: 4px; + transition: + background-color 0.1s ease, + color 0.1s ease; + display: inline; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + white-space: pre-wrap; + font-weight: 400; + -webkit-font-smoothing: subpixel-antialiased; +} + +.grid-item-selected .grid-item-name-text { + background-color: var(--grid-sel-bg); + color: #fff; +} + +.grid-item-meta { + font-size: 10px; + color: #888; + margin-top: -8px; + /* Pull closer to the name */ + padding-bottom: 8px; + min-height: 18px; + /* Reserve space to prevent layout jump */ + pointer-events: none; +} + +.grid-item-selected .grid-item-meta { + color: rgba(0, 0, 0, 0.6); + /* Dark text visible on light blue selection background */ +} + +[data-theme='dark'] .grid-item-meta { + color: #aaa; +} + +.selection-marquee { + position: absolute; + background-color: rgba(0, 100, 225, 0.1); + border: 0.5px solid rgba(0, 100, 225, 0.45); + border-radius: 0; + pointer-events: none; + z-index: 1000; +} + +/* === Grid Size Slider === */ +.grid-size-slider-container { + display: flex; + align-items: center; + margin-left: 12px; + padding-left: 12px; + border-left: 1px solid var(--color-border); + height: 20px; +} + +.grid-size-slider { + width: 80px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +[data-theme='dark'] .grid-size-slider { + background: rgba(255, 255, 255, 0.2); +} + +.grid-size-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + cursor: pointer; +} + +[data-theme='dark'] .grid-size-slider::-webkit-slider-thumb { + background: #ccc; + border-color: rgba(255, 255, 255, 0.2); +} + +/* === View Mode Toggle === */ +.view-mode-toggle { + display: flex; + align-items: center; + background-color: rgba(0, 0, 0, 0.05); + padding: 2px; + border-radius: 6px; + margin-left: 8px; +} + +.view-mode-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--color-muted); + border-radius: 4px; + cursor: pointer; + box-shadow: none; + transition: all 0.1s ease; +} + +.view-mode-button:hover { + background-color: rgba(0, 0, 0, 0.05); + color: var(--color-text); + border-color: transparent; +} + +.view-mode-button.active { + background-color: #fff; + color: var(--color-accent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +[data-theme='dark'] .view-mode-toggle { + background-color: rgba(255, 255, 255, 0.1); +} + +[data-theme='dark'] .view-mode-button.active { + background-color: rgba(255, 255, 255, 0.15); + color: #fff; +} diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index 48b91d72..873683f9 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -24,8 +24,9 @@ import { useRemoteSort } from './hooks/useRemoteSort'; import { useSelection } from './hooks/useSelection'; import { useQuickLook } from './hooks/useQuickLook'; import { useSearchHistory } from './hooks/useSearchHistory'; -import { ROW_HEIGHT, OVERSCAN_ROW_COUNT } from './constants'; +import { ROW_HEIGHT, OVERSCAN_ROW_COUNT, DEFAULT_ICON_SIZE, type ViewMode } from './constants'; import type { VirtualListHandle } from './components/VirtualList'; +import type { VirtualGridHandle } from './components/VirtualGrid'; import FSEventsPanel from './components/FSEventsPanel'; import type { FSEventsPanelHandle } from './components/FSEventsPanel'; import { invoke } from '@tauri-apps/api/core'; @@ -40,6 +41,13 @@ import { openResultPath } from './utils/openResultPath'; import { useStableEvent } from './hooks/useStableEvent'; import { getStoredTrayIconEnabled, persistTrayIconEnabled } from './trayIconPreference'; import { setTrayEnabled } from './tray'; +import { startNativeFileDrag } from './utils/drag'; +import { + getStoredViewMode, + getStoredIconSize, + persistViewMode, + persistIconSize, +} from './utils/viewPreferences'; type ActiveTab = StatusTabKey; @@ -101,9 +109,35 @@ function App() { return document.hasFocus(); }); const [isSearchFocused, setIsSearchFocused] = useState(false); + const [viewMode, setViewModeState] = useState(() => getStoredViewMode()); + const [iconSize, setIconSizeState] = useState(() => getStoredIconSize()); + + // Default settings state (persisted) + const [defaultViewMode, setDefaultViewMode] = useState(() => getStoredViewMode()); + const [defaultIconSize, setDefaultIconSize] = useState(() => getStoredIconSize()); + + // Session-only updates (for main window controls) - do not persist. + const handleSessionViewModeChange = useCallback((mode: ViewMode) => { + setViewModeState(mode); + }, []); + + const handleSessionIconSizeChange = useCallback((size: number) => { + setIconSizeState(size); + }, []); + + // Default preference updates (for Preferences overlay) - persist to storage. + const handleDefaultViewModeChange = useCallback((mode: ViewMode) => { + setDefaultViewMode(mode); + persistViewMode(mode); + }, []); + + const handleDefaultIconSizeChange = useCallback((size: number) => { + setDefaultIconSize(size); + persistIconSize(size); + }, []); const eventsPanelRef = useRef(null); const headerRef = useRef(null); - const virtualListRef = useRef(null); + const virtualListRef = useRef(null); const searchInputRef = useRef(null); const isMountedRef = useRef(false); const keyboardStateRef = useRef<{ activeTab: ActiveTab; activePath: string | null }>({ @@ -145,8 +179,9 @@ function App() { selectedIndicesRef, activeRowIndex, selectedPaths, - handleRowSelect, + handleRowSelect: onRowSelectInternal, selectSingleRow, + bulkSelect, clearSelection, moveSelection, } = useSelection(displayedResults, displayedResultsVersion, virtualListRef); @@ -161,10 +196,39 @@ function App() { }); const triggerQuickLook = useStableEvent(toggleQuickLook); + const handleTrash = useCallback( + async (path?: string) => { + const paths = path ? [path] : selectedPaths; + if (!paths.length) return; + try { + await invoke('trash_paths', { paths }); + queueSearch(currentQuery); // Refresh current search + } catch (error) { + console.error('Failed to trash paths', error); + } + }, + [selectedPaths, currentQuery, queueSearch], + ); + + const handleDelete = useCallback( + async (path?: string) => { + const paths = path ? [path] : selectedPaths; + if (!paths.length) return; + // TODO: Add confirmation dialog if needed + try { + await invoke('delete_paths', { paths }); + queueSearch(currentQuery); // Refresh current search + } catch (error) { + console.error('Failed to delete paths', error); + } + }, + [selectedPaths, currentQuery, queueSearch], + ); + const { showContextMenu: showFilesContextMenu, showHeaderContextMenu: showFilesHeaderContextMenu, - } = useContextMenu(autoFitColumns, toggleQuickLook); + } = useContextMenu(autoFitColumns, toggleQuickLook, handleTrash, handleDelete); const { showContextMenu: showEventsContextMenu, @@ -216,6 +280,25 @@ function App() { }); }, []); const focusSearchInputStable = useStableEvent(focusSearchInput); + const handleDragStart = useStableEvent((path: string, itemIsSelected: boolean, icon?: string) => { + const list = virtualListRef.current; + if (!list) return; + + let paths: string[] = []; + if (itemIsSelected) { + selectedIndicesRef.current.forEach((idx) => { + const item = list.getItem?.(idx); + if (item?.path) paths.push(item.path); + }); + } + + if (paths.length === 0) { + paths = [path]; + } + + void startNativeFileDrag({ paths, icon }); + }); + const handleMetaShortcut = useStableEvent( (event: KeyboardEvent, currentTab: ActiveTab, currentPath: string | null) => { const key = event.key.toLowerCase(); @@ -271,13 +354,35 @@ function App() { return true; } - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (event.key === 'Backspace') { + if (event.metaKey && selectedIndicesRef.current.length > 0) { + event.preventDefault(); + if (event.altKey) { + void handleDelete(); + } else { + void handleTrash(); + } + return true; + } + } + + if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { if (event.altKey || event.ctrlKey || event.metaKey) { return true; } event.preventDefault(); - const delta = event.key === 'ArrowDown' ? 1 : -1; - navigateSelection(delta, { extend: event.shiftKey }); + + const columnsCount = virtualListRef.current?.getColumnsCount?.() ?? 1; + let delta = 0; + + if (event.key === 'ArrowDown') delta = columnsCount; + else if (event.key === 'ArrowUp') delta = -columnsCount; + else if (event.key === 'ArrowLeft') delta = -1; + else if (event.key === 'ArrowRight') delta = 1; + + if (delta !== 0) { + navigateSelection(delta, { extend: event.shiftKey }); + } return true; } @@ -520,16 +625,26 @@ function App() { const selectedIndexSet = useMemo(() => new Set(selectedIndices), [selectedIndices]); - const handleRowContextMenu = useCallback( + const handleRowContextMenu = useStableEvent( (event: ReactMouseEvent, path: string, rowIndex: number) => { - if (!selectedIndexSet.has(rowIndex)) { + const isSelected = selectedIndicesRef.current.includes(rowIndex); + if (!isSelected) { selectSingleRow(rowIndex); } if (path) { showFilesContextMenu(event, path); } }, - [selectedIndexSet, selectSingleRow, showFilesContextMenu], + ); + + const handleRowOpen = useStableEvent((path: string) => { + openResultPath(path); + }); + + const handleRowSelect = useStableEvent( + (rowIndex: number, options: { isShift: boolean; isMeta: boolean; isCtrl: boolean }) => { + onRowSelectInternal(rowIndex, options); + }, ); const renderRow = useCallback( @@ -551,23 +666,16 @@ function App() { item={item} style={rowStyle} isSelected={selectedIndexSet.has(rowIndex)} - selectedPaths={selectedPaths} + onDragStart={handleDragStart} caseInsensitive={!caseSensitive} highlightTerms={highlightTerms} - onContextMenu={(event, contextPath) => handleRowContextMenu(event, contextPath, rowIndex)} + onContextMenu={handleRowContextMenu} onSelect={handleRowSelect} - onOpen={openResultPath} + onOpen={handleRowOpen} /> ); }, - [ - handleRowContextMenu, - handleRowSelect, - highlightTerms, - caseSensitive, - selectedIndexSet, - selectedPaths, - ], + [handleRowContextMenu, handleRowSelect, highlightTerms, caseSensitive, selectedIndexSet], ); const displayState: DisplayState = (() => { @@ -660,6 +768,8 @@ function App() { caseSensitiveLabel={caseSensitiveLabel} onFocus={handleSearchFocus} onBlur={handleSearchBlur} + viewMode={viewMode} + onToggleViewMode={handleSessionViewModeChange} />
{activeTab === 'events' ? ( @@ -680,12 +790,23 @@ function App() { displayState={displayState} searchErrorMessage={searchErrorMessage} currentQuery={currentQuery} - virtualListRef={virtualListRef} + virtualListRef={virtualListRef as any} + viewMode={viewMode} + iconSize={iconSize} results={displayedResults} rowHeight={ROW_HEIGHT} overscan={OVERSCAN_ROW_COUNT} renderRow={renderRow} onScrollSync={handleHorizontalSync} + onSelect={handleRowSelect} + onBulkSelect={bulkSelect} + onContextMenu={handleRowContextMenu} + onOpen={handleRowOpen} + onDragStart={handleDragStart} + caseInsensitive={!caseSensitive} + highlightTerms={highlightTerms} + selectedIndices={selectedIndices} + selectedPaths={selectedPaths} sortState={sortState} onSortToggle={handleSortToggle} sortDisabled={sortButtonsDisabled} @@ -701,8 +822,11 @@ function App() { searchDurationMs={durationMs} resultCount={resultCount} activeTab={activeTab} - onTabChange={handleTabChange} + onTabChange={setActiveTab} onRequestRescan={requestRescan} + viewMode={viewMode} + iconSize={iconSize} + onIconSizeChange={handleSessionIconSizeChange} /> {showFullDiskAccessOverlay && ( , path: string, rowIndex: number) => void; + onOpen?: (path: string) => void; + onSelect: ( + rowIndex: number, + options: { isShift: boolean; isMeta: boolean; isCtrl: boolean }, + ) => void; + isSelected?: boolean; + onDragStart?: (path: string, itemIsSelected: boolean, icon?: string) => void; + caseInsensitive?: boolean; + highlightTerms?: readonly string[]; + iconSize: number; + actualItemWidth: number; +}; + +export const FileGridItem = memo(function FileGridItem({ + item, + rowIndex, + style, + onContextMenu, + onOpen, + onSelect, + isSelected = false, + onDragStart, + caseInsensitive, + highlightTerms, + iconSize, + actualItemWidth, +}: FileGridItemProps): React.JSX.Element | null { + const pendingSelectRef = useRef<{ + isShift: boolean; + isMeta: boolean; + isCtrl: boolean; + } | null>(null); + + if (!item) { + return null; + } + + const path = item.path; + let filename = ''; + + if (path) { + if (path === '/') { + filename = '/'; + } else { + const parts = path.split(/[\\/]/); + filename = parts.pop() || ''; + } + } + + const metadata = item.metadata; + const sizeBytes = metadata?.size ?? item.size; + const isDirectory = metadata?.type === 1; + const sizeText = !isDirectory ? formatKB(sizeBytes) : null; + + const handleContextMenu = (e: ReactMouseEvent) => { + e.preventDefault(); + if (onContextMenu) { + onContextMenu(e, path ?? '', rowIndex); + } + }; + + const handleMouseDown = (e: ReactMouseEvent) => { + if (e.button !== 0) { + return; + } + + const options = { + isShift: e.shiftKey, + isMeta: e.metaKey, + isCtrl: e.ctrlKey, + }; + + const hasModifier = options.isShift || options.isMeta || options.isCtrl; + if (!isSelected || hasModifier) { + onSelect(rowIndex, options); + pendingSelectRef.current = null; + return; + } + + pendingSelectRef.current = options; + }; + + const handleMouseUp = (e: ReactMouseEvent) => { + if (e.button !== 0) { + return; + } + + const pending = pendingSelectRef.current; + if (!pending) { + return; + } + + pendingSelectRef.current = null; + onSelect(rowIndex, pending); + }; + + const handleDoubleClick = (e: ReactMouseEvent) => { + e.preventDefault(); + if (path && onOpen) { + onOpen(path); + } + }; + + const handleDragStart = useCallback( + (e: React.DragEvent) => { + if (!path || !onDragStart) { + return; + } + + pendingSelectRef.current = null; + + const dataTransfer = e.dataTransfer; + if (dataTransfer) { + dataTransfer.effectAllowed = 'copy'; + // Note: System-level multi-drag will be handled by the native side via onDragStart. + // We set dummy text data to satisfy web-standard DND if necessary. + dataTransfer.setData('text/plain', path); + } + + onDragStart(path, isSelected, item.icon); + }, + [isSelected, item.icon, path, onDragStart], + ); + + const itemClassName = ['grid-item', isSelected ? 'grid-item-selected' : ''] + .filter(Boolean) + .join(' '); + + const highlightedParts = React.useMemo(() => { + if (!filename) return []; + const parts = splitTextWithHighlights(filename, highlightTerms, { caseInsensitive }); + // Width of the text area is the actual column width minus local padding + const textAreaWidth = actualItemWidth - 16; + // 9.5px is an extreme buffer for 11px font width to strictly ensure 2 lines + const charsPerLine = Math.floor(textAreaWidth / 9.5); + const maxChars = charsPerLine * 2; + return applyMiddleEllipsis(parts, maxChars); + }, [filename, highlightTerms, caseInsensitive, actualItemWidth]); + + // Use actual dimensions from backend, but ensure they're at least as large as iconSize + // This prevents tiny selection boxes while respecting aspect ratios + const reportedWidth = item.iconWidth ? item.iconWidth / 2 : iconSize; + const reportedHeight = item.iconHeight ? item.iconHeight / 2 : iconSize; + + // Selection box should be at least as large as the display size + const boxWidth = Math.max(reportedWidth, iconSize); + const boxHeight = Math.max(reportedHeight, iconSize); + return ( +
+
+
+ {item.icon ? ( + icon + ) : ( + +
+
+ + {highlightedParts.map((part, index) => + part.isHighlight ? ( + {part.text} + ) : ( + {part.text} + ), + )} + +
+ {sizeText && ( +
+ {sizeText} +
+ )} +
+ ); +}); + +FileGridItem.displayName = 'FileGridItem'; diff --git a/cardinal/src/components/FileRow.tsx b/cardinal/src/components/FileRow.tsx index f3be4813..a1b14f50 100644 --- a/cardinal/src/components/FileRow.tsx +++ b/cardinal/src/components/FileRow.tsx @@ -3,7 +3,6 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; import { MiddleEllipsisHighlight } from './MiddleEllipsisHighlight'; import { formatKB, formatTimestamp } from '../utils/format'; import type { SearchResultItem } from '../types/search'; -import { startNativeFileDrag } from '../utils/drag'; type FileRowProps = { item?: SearchResultItem; @@ -16,7 +15,7 @@ type FileRowProps = { options: { isShift: boolean; isMeta: boolean; isCtrl: boolean }, ) => void; isSelected?: boolean; - selectedPathsForDrag?: string[]; + onDragStart?: (path: string, itemIsSelected: boolean, icon?: string) => void; caseInsensitive?: boolean; highlightTerms?: readonly string[]; }; @@ -29,7 +28,7 @@ export const FileRow = memo(function FileRow({ onOpen, onSelect, isSelected = false, - selectedPathsForDrag = [], + onDragStart, caseInsensitive, highlightTerms, }: FileRowProps): React.JSX.Element | null { @@ -117,26 +116,21 @@ export const FileRow = memo(function FileRow({ const handleDragStart = useCallback( (e: DragEvent) => { - if (!path) { + if (!path || !onDragStart) { return; } pendingSelectRef.current = null; const dataTransfer = e.dataTransfer; - if (!dataTransfer) { - return; + if (dataTransfer) { + dataTransfer.effectAllowed = 'copy'; + dataTransfer.setData('text/plain', path); } - const isDraggingSelected = isSelected && selectedPathsForDrag.length > 0; - const pathsToDrag = - isDraggingSelected && selectedPathsForDrag.length > 0 ? selectedPathsForDrag : [path]; - - dataTransfer.effectAllowed = 'copy'; - dataTransfer.setData('text/plain', pathsToDrag.join('\n')); - void startNativeFileDrag({ paths: pathsToDrag, icon: item.icon }); + onDragStart(path, isSelected, item.icon); }, - [isSelected, item.icon, path, selectedPathsForDrag], + [isSelected, item.icon, path, onDragStart], ); const rowClassName = [ diff --git a/cardinal/src/components/FileRowRenderer.tsx b/cardinal/src/components/FileRowRenderer.tsx index 5ea176b1..ae6956b4 100644 --- a/cardinal/src/components/FileRowRenderer.tsx +++ b/cardinal/src/components/FileRowRenderer.tsx @@ -8,7 +8,7 @@ type FileRowRendererProps = { item: SearchResultItem; style: CSSProperties; isSelected: boolean; - selectedPaths: string[]; + onDragStart: (path: string, itemIsSelected: boolean, icon?: string) => void; caseInsensitive: boolean; highlightTerms: readonly string[]; onContextMenu: (event: ReactMouseEvent, path: string, rowIndex: number) => void; @@ -24,7 +24,7 @@ export const FileRowRenderer = memo(function FileRowRenderer({ item, style, isSelected, - selectedPaths, + onDragStart, caseInsensitive, highlightTerms, onContextMenu, @@ -40,7 +40,7 @@ export const FileRowRenderer = memo(function FileRowRenderer({ onSelect={onSelect} onOpen={onOpen} isSelected={isSelected} - selectedPathsForDrag={selectedPaths} + onDragStart={onDragStart} caseInsensitive={caseInsensitive} highlightTerms={highlightTerms} /> diff --git a/cardinal/src/components/FilesTabContent.tsx b/cardinal/src/components/FilesTabContent.tsx index d62558c3..c4f5b791 100644 --- a/cardinal/src/components/FilesTabContent.tsx +++ b/cardinal/src/components/FilesTabContent.tsx @@ -9,6 +9,10 @@ import type { VirtualListHandle } from './VirtualList'; import type { SearchResultItem } from '../types/search'; import type { SlabIndex } from '../types/slab'; import type { SortKey, SortState } from '../types/sort'; +import type { ViewMode } from '../constants'; +import { type VirtualGridHandle, VirtualGrid } from './VirtualGrid'; +import { FileGridItem } from './FileGridItem'; +import { GRID_SPACING_X, GRID_SPACING_Y } from '../constants'; type FilesTabContentProps = { headerRef: React.RefObject; @@ -17,7 +21,9 @@ type FilesTabContentProps = { displayState: DisplayState; searchErrorMessage: string | null; currentQuery: string; - virtualListRef: React.RefObject; + virtualListRef: React.RefObject; + viewMode: ViewMode; + iconSize: number; results: SlabIndex[]; rowHeight: number; overscan: number; @@ -32,6 +38,18 @@ type FilesTabContentProps = { sortDisabled?: boolean; sortIndicatorMode?: 'triangle' | 'circle'; sortDisabledTooltip?: string | null; + onSelect: ( + rowIndex: number, + options: { isShift: boolean; isMeta: boolean; isCtrl: boolean }, + ) => void; + onBulkSelect?: (indices: number[]) => void; + onContextMenu: (event: ReactMouseEvent, path: string, rowIndex: number) => void; + onOpen: (path: string) => void; + onDragStart: (path: string, itemIsSelected: boolean, icon?: string) => void; + caseInsensitive?: boolean; + highlightTerms?: readonly string[]; + selectedIndices: number[]; + selectedPaths: string[]; }; export function FilesTabContent({ @@ -42,6 +60,8 @@ export function FilesTabContent({ searchErrorMessage, currentQuery, virtualListRef, + viewMode, + iconSize, results, rowHeight, overscan, @@ -52,25 +72,85 @@ export function FilesTabContent({ sortDisabled = false, sortIndicatorMode = 'triangle', sortDisabledTooltip, + onSelect, + onBulkSelect, + onContextMenu, + onOpen, + onDragStart, + caseInsensitive, + highlightTerms, + selectedIndices, + selectedPaths, }: FilesTabContentProps): React.JSX.Element { + const selectedIndexSet = React.useMemo(() => new Set(selectedIndices), [selectedIndices]); + + const renderGridItem = React.useCallback( + ( + rowIndex: number, + item: SearchResultItem | undefined, + style: CSSProperties, + actualItemWidth: number, + ) => { + if (!item) { + return ( +
+ ); + } + + return ( + + ); + }, + [ + caseInsensitive, + highlightTerms, + onContextMenu, + onOpen, + onSelect, + selectedIndexSet, + iconSize, + onDragStart, + ], + ); + return (
- + {viewMode === 'list' && ( + + )}
{displayState !== 'results' ? ( - ) : ( + ) : viewMode === 'list' ? ( } results={results} rowHeight={rowHeight} overscan={overscan} @@ -78,6 +158,19 @@ export function FilesTabContent({ onScrollSync={onScrollSync} className="virtual-list" /> + ) : ( + } + results={results} + renderItem={renderGridItem} + itemWidth={iconSize + GRID_SPACING_X} + itemHeight={iconSize + GRID_SPACING_Y} + onBulkSelect={onBulkSelect} + onSelect={onSelect} + overscanRows={overscan} + className="virtual-grid" + iconSize={iconSize} + /> )}
diff --git a/cardinal/src/components/MiddleEllipsisHighlight.tsx b/cardinal/src/components/MiddleEllipsisHighlight.tsx index 447bb003..fd3205a6 100644 --- a/cardinal/src/components/MiddleEllipsisHighlight.tsx +++ b/cardinal/src/components/MiddleEllipsisHighlight.tsx @@ -76,7 +76,10 @@ export function splitTextWithHighlights( return parts; } -function applyMiddleEllipsis(parts: HighlightSegment[], maxChars: number): HighlightSegment[] { +export function applyMiddleEllipsis( + parts: HighlightSegment[], + maxChars: number, +): HighlightSegment[] { if (maxChars <= 2) { return [{ text: '…', isHighlight: false }]; } @@ -128,6 +131,16 @@ function applyMiddleEllipsis(parts: HighlightSegment[], maxChars: number): Highl } } + // Trim the parts adjacent to the ellipsis to avoid awkward spacing + if (leftParts.length > 0) { + const lastPart = leftParts[leftParts.length - 1]; + leftParts[leftParts.length - 1] = { ...lastPart, text: lastPart.text.trimEnd() }; + } + if (rightParts.length > 0) { + const firstPart = rightParts[0]; + rightParts[0] = { ...firstPart, text: firstPart.text.trimStart() }; + } + return [...leftParts, { text: '…', isHighlight: false }, ...rightParts]; } diff --git a/cardinal/src/components/PreferencesOverlay.tsx b/cardinal/src/components/PreferencesOverlay.tsx index c4bea09e..e6eb3b3a 100644 --- a/cardinal/src/components/PreferencesOverlay.tsx +++ b/cardinal/src/components/PreferencesOverlay.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import ThemeSwitcher from './ThemeSwitcher'; import LanguageSwitcher from './LanguageSwitcher'; +import { ViewMode, MIN_ICON_SIZE, MAX_ICON_SIZE } from '../constants'; type PreferencesOverlayProps = { open: boolean; @@ -10,6 +11,10 @@ type PreferencesOverlayProps = { onSortThresholdChange: (value: number) => void; trayIconEnabled: boolean; onTrayIconEnabledChange: (enabled: boolean) => void; + defaultView: ViewMode; + onDefaultViewChange: (mode: ViewMode) => void; + defaultIconSize: number; + onDefaultIconSizeChange: (size: number) => void; }; export function PreferencesOverlay({ @@ -19,6 +24,10 @@ export function PreferencesOverlay({ onSortThresholdChange, trayIconEnabled, onTrayIconEnabledChange, + defaultView, + onDefaultViewChange, + defaultIconSize, + onDefaultIconSizeChange, }: PreferencesOverlayProps): React.JSX.Element | null { const { t } = useTranslation(); const [thresholdInput, setThresholdInput] = useState(() => sortThreshold.toString()); @@ -128,6 +137,45 @@ export function PreferencesOverlay({
+
+

{t('preferences.defaultView.label')}

+
+
+ + +
+
+
+ {defaultView === 'grid' && ( +
+

{t('preferences.defaultIconSize.label')}

+
+ {defaultIconSize}px + onDefaultIconSizeChange(parseInt(e.target.value, 10))} + className="grid-size-slider" + aria-label={t('preferences.defaultIconSize.label')} + /> +
+
+ )}

{t('preferences.sortingLimit.label')}

diff --git a/cardinal/src/components/SearchBar.tsx b/cardinal/src/components/SearchBar.tsx index cb9af6af..c22f04d5 100644 --- a/cardinal/src/components/SearchBar.tsx +++ b/cardinal/src/components/SearchBar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { ChangeEvent, FocusEventHandler } from 'react'; +import { ViewMode } from '../constants'; type SearchBarProps = { inputRef: React.RefObject; @@ -12,6 +13,8 @@ type SearchBarProps = { caseSensitiveLabel: string; onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; + viewMode: ViewMode; + onToggleViewMode: (mode: ViewMode) => void; }; export function SearchBar({ @@ -25,6 +28,8 @@ export function SearchBar({ caseSensitiveLabel, onFocus, onBlur, + viewMode, + onToggleViewMode, }: SearchBarProps): React.JSX.Element { return (
@@ -56,6 +61,26 @@ export function SearchBar({ {caseSensitiveLabel} +
+ + +
diff --git a/cardinal/src/components/StatusBar.tsx b/cardinal/src/components/StatusBar.tsx index 645ffe54..36a01300 100644 --- a/cardinal/src/components/StatusBar.tsx +++ b/cardinal/src/components/StatusBar.tsx @@ -4,6 +4,8 @@ import type { AppLifecycleStatus } from '../types/ipc'; import { useTranslation } from 'react-i18next'; import { OPEN_PREFERENCES_EVENT } from '../constants/appEvents'; +import { ViewMode, MIN_ICON_SIZE, MAX_ICON_SIZE } from '../constants'; + export type StatusTabKey = 'files' | 'events'; type StatusBarProps = { @@ -15,6 +17,9 @@ type StatusBarProps = { activeTab?: StatusTabKey; onTabChange?: (tab: StatusTabKey) => void; onRequestRescan?: () => void; + viewMode: ViewMode; + iconSize: number; + onIconSizeChange: (size: number) => void; }; const TABS: StatusTabKey[] = ['files', 'events']; @@ -34,6 +39,9 @@ const StatusBar = ({ activeTab = 'files', onTabChange, onRequestRescan, + viewMode, + iconSize, + onIconSizeChange, }: StatusBarProps): React.JSX.Element => { const { t } = useTranslation(); const tabsRef = useRef(null); @@ -188,7 +196,7 @@ const StatusBar = ({
-
+
{t('statusBar.searchLabel')} @@ -196,6 +204,24 @@ const StatusBar = ({
+ +
+
+ onIconSizeChange(parseInt((e.target as HTMLInputElement).value, 10))} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="grid-size-slider" + title="Icon Size" + disabled={viewMode === 'list'} + /> +
+
); }; diff --git a/cardinal/src/components/VirtualGrid.tsx b/cardinal/src/components/VirtualGrid.tsx new file mode 100644 index 00000000..fe98dc6a --- /dev/null +++ b/cardinal/src/components/VirtualGrid.tsx @@ -0,0 +1,356 @@ +import React, { + useRef, + useState, + useCallback, + useMemo, + useLayoutEffect, + useEffect, + forwardRef, + useImperativeHandle, +} from 'react'; +import type { CSSProperties, UIEvent as ReactUIEvent } from 'react'; +import Scrollbar from './Scrollbar'; +import { useDataLoader } from '../hooks/useDataLoader'; +import type { SearchResultItem } from '../types/search'; +import type { SlabIndex } from '../types/slab'; +import { useIconViewport } from '../hooks/useIconViewport'; +import { CONTAINER_PADDING } from '../constants'; + +export type VirtualGridHandle = { + scrollToTop: () => void; + scrollToRow: (rowIndex: number, align?: 'nearest' | 'start' | 'end' | 'center') => void; + ensureRangeLoaded: (startIndex: number, endIndex: number) => Promise | void; + getItem: (index: number) => SearchResultItem | undefined; + getColumnsCount: () => number; +}; + +type VirtualGridProps = { + results?: SlabIndex[]; + overscanRows?: number; + renderItem: ( + index: number, + item: SearchResultItem | undefined, + style: CSSProperties, + actualItemWidth: number, + ) => React.ReactNode; + itemWidth: number; + itemHeight: number; + onBulkSelect?: (indices: number[]) => void; + onSelect?: ( + rowIndex: number, + options: { isShift: boolean; isMeta: boolean; isCtrl: boolean }, + ) => void; + className?: string; + iconSize: number; +}; + +export const VirtualGrid = forwardRef(function VirtualGrid( + { + results = [], + overscanRows = 6, + renderItem, + itemWidth, + itemHeight, + onBulkSelect, + onSelect, + className = '', + iconSize, + }, + ref, +) { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + const [viewportWidth, setViewportWidth] = useState(0); + const [marqueeStart, setMarqueeStart] = useState<{ x: number; y: number } | null>(null); + const [marqueeEnd, setMarqueeEnd] = useState<{ x: number; y: number } | null>(null); + + const rowCount = results.length; + const { cache, ensureRangeLoaded } = useDataLoader(results); + + const gridPadding = 12; // Matches .scroll-area padding in App.css + const availableWidth = Math.max(0, viewportWidth - 2 * gridPadding); + const columnsCount = Math.max(1, Math.floor(availableWidth / itemWidth)); + const actualItemWidth = availableWidth / columnsCount; + const leftOffset = gridPadding; + + const rowsCountTotal = Math.ceil(rowCount / columnsCount); + const totalHeight = rowsCountTotal * itemHeight; + + const maxScrollTop = Math.max(0, totalHeight - viewportHeight); + + const startRow = Math.max(0, Math.floor(scrollTop / itemHeight) - overscanRows); + const endRow = Math.min( + rowsCountTotal - 1, + Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscanRows - 1, + ); + + const startIndex = startRow * columnsCount; + const endIndex = Math.min(rowCount - 1, (endRow + 1) * columnsCount - 1); + + useIconViewport({ results, start: startIndex, end: endIndex, iconSize }); + + const updateScrollAndRange = useCallback( + (updater: (value: number) => number) => { + setScrollTop((prev) => { + const nextValue = updater(prev); + const clamped = Math.max(0, Math.min(nextValue, maxScrollTop)); + return prev === clamped ? prev : clamped; + }); + }, + [maxScrollTop], + ); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + updateScrollAndRange((prev) => prev + e.deltaY); + if (marqueeStart && marqueeEnd) { + setMarqueeEnd((prev) => (prev ? { ...prev, y: prev.y + e.deltaY } : null)); + } + }, + [updateScrollAndRange, marqueeStart, marqueeEnd], + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) return; + + // Check if we clicked on an item or empty space + const target = e.target as HTMLElement; + const isItemInside = target.closest('.grid-item'); + + if (isItemInside) return; + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top + scrollTop; + + setMarqueeStart({ x, y }); + setMarqueeEnd({ x, y }); + + if (!e.metaKey && !e.ctrlKey && !e.shiftKey) { + onBulkSelect?.([]); + } + }, + [scrollTop, onBulkSelect], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!marqueeStart) return; + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top + scrollTop; + + setMarqueeEnd({ x, y }); + + // Calculate selected indices + const x1 = Math.min(marqueeStart.x, x); + const y1 = Math.min(marqueeStart.y, y); + const x2 = Math.max(marqueeStart.x, x); + const y2 = Math.max(marqueeStart.y, y); + + const startCol = Math.max(0, Math.floor((x1 - leftOffset) / actualItemWidth)); + const endCol = Math.min( + columnsCount - 1, + Math.floor((x2 - 1 - leftOffset) / actualItemWidth), + ); + const startRow = Math.max(0, Math.floor(y1 / itemHeight)); + const endRow = Math.min(rowsCountTotal - 1, Math.floor((y2 - 1) / itemHeight)); + + const selectedIndices: number[] = []; + for (let r = startRow; r <= endRow; r++) { + for (let c = startCol; c <= endCol; c++) { + if (c < 0) continue; + const index = r * columnsCount + c; + if (index < rowCount) { + selectedIndices.push(index); + } + } + } + + onBulkSelect?.(selectedIndices); + }, + [ + marqueeStart, + scrollTop, + rowCount, + rowsCountTotal, + columnsCount, + actualItemWidth, + itemHeight, + leftOffset, + onBulkSelect, + ], + ); + + const handleMouseUp = useCallback(() => { + setMarqueeStart(null); + setMarqueeEnd(null); + }, []); + + useEffect(() => { + if (marqueeStart) { + window.addEventListener('mouseup', handleMouseUp); + return () => window.removeEventListener('mouseup', handleMouseUp); + } + }, [marqueeStart, handleMouseUp]); + + useEffect(() => { + if (endIndex >= startIndex) ensureRangeLoaded(startIndex, endIndex); + }, [startIndex, endIndex, ensureRangeLoaded, results]); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + const updateViewport = () => { + setViewportHeight(container.clientHeight); + setViewportWidth(container.clientWidth); + }; + const resizeObserver = new ResizeObserver(updateViewport); + resizeObserver.observe(container); + updateViewport(); + return () => resizeObserver.disconnect(); + }, []); + + useEffect(() => { + setScrollTop((prev) => { + const clamped = Math.max(0, Math.min(prev, maxScrollTop)); + return clamped === prev ? prev : clamped; + }); + }, [maxScrollTop]); + + const scrollToRow = useCallback( + (rowIndex: number, align: 'nearest' | 'start' | 'end' | 'center' = 'nearest') => { + if (!Number.isFinite(rowIndex) || rowCount === 0) return; + + const targetRow = Math.floor(rowIndex / columnsCount); + const rowTop = targetRow * itemHeight; + const rowBottom = rowTop + itemHeight; + + updateScrollAndRange((prev) => { + if (viewportHeight <= 0) return rowTop; + + const viewportTop = prev; + const viewportBottom = viewportTop + viewportHeight; + + switch (align) { + case 'start': + return rowTop; + case 'end': + return rowBottom - viewportHeight; + case 'center': + return rowTop - Math.max(0, (viewportHeight - itemHeight) / 2); + case 'nearest': + default: { + const tolerance = 40; // More forgiving tolerance to prevent jitter + if (rowTop < viewportTop - tolerance) return rowTop; + if (rowBottom > viewportBottom + tolerance) return rowBottom - viewportHeight; + return prev; + } + } + }); + }, + [rowCount, columnsCount, viewportHeight, itemHeight, updateScrollAndRange], + ); + + useImperativeHandle( + ref, + () => ({ + scrollToTop: () => updateScrollAndRange(() => 0), + scrollToRow, + ensureRangeLoaded, + getItem: (index: number) => cache.get(index), + getColumnsCount: () => columnsCount, + }), + [updateScrollAndRange, scrollToRow, ensureRangeLoaded, cache, columnsCount], + ); + + const renderedItems = useMemo(() => { + if (endRow < startRow) return null; + + const items = []; + for (let r = startRow; r <= endRow; r++) { + for (let c = 0; c < columnsCount; c++) { + const index = r * columnsCount + c; + if (index >= rowCount) break; + + const item = cache.get(index); + const style: CSSProperties = { + position: 'absolute', + top: r * itemHeight, + left: leftOffset + c * actualItemWidth, + width: actualItemWidth, + height: itemHeight, + }; + + items.push(renderItem(index, item, style, actualItemWidth)); + } + } + return items; + }, [ + startRow, + endRow, + columnsCount, + rowCount, + cache, + renderItem, + actualItemWidth, + itemHeight, + leftOffset, + ]); + + return ( +
+
+
+ {renderedItems} +
+ {marqueeStart && marqueeEnd && ( +
+ )} +
+ +
+ ); +}); + +VirtualGrid.displayName = 'VirtualGrid'; + +export default VirtualGrid; diff --git a/cardinal/src/components/VirtualList.tsx b/cardinal/src/components/VirtualList.tsx index 1221041b..4875b78c 100644 --- a/cardinal/src/components/VirtualList.tsx +++ b/cardinal/src/components/VirtualList.tsx @@ -21,6 +21,7 @@ export type VirtualListHandle = { scrollToRow: (rowIndex: number, align?: 'nearest' | 'start' | 'end' | 'center') => void; ensureRangeLoaded: (startIndex: number, endIndex: number) => Promise | void; getItem: (index: number) => SearchResultItem | undefined; + getColumnsCount: () => number; }; type VirtualListProps = { @@ -68,7 +69,7 @@ export const VirtualList = forwardRef(funct rowCount && viewportHeight ? Math.min(rowCount - 1, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan - 1) : -1; - useIconViewport({ results: resultsList, start, end }); + useIconViewport({ results: resultsList, start, end, iconSize: 32 }); // Clamp scroll updates so callers cannot push the viewport outside legal bounds const updateScrollAndRange = useCallback( @@ -185,6 +186,7 @@ export const VirtualList = forwardRef(funct scrollToRow, ensureRangeLoaded, getItem: getItemAt, + getColumnsCount: () => 1, }), [updateScrollAndRange, scrollToRow, ensureRangeLoaded, getItemAt], ); @@ -194,7 +196,7 @@ export const VirtualList = forwardRef(funct const renderedItems = useMemo(() => { if (end < start) return null; - const baseTop = start * rowHeight - scrollTop; + const baseTop = start * rowHeight; return Array.from({ length: end - start + 1 }, (_, i) => { const rowIndex = start + i; const item = cache.get(rowIndex); @@ -206,7 +208,7 @@ export const VirtualList = forwardRef(funct right: 0, }); }); - }, [start, end, scrollTop, rowHeight, cache, renderRow]); + }, [start, end, rowHeight, cache, renderRow]); // ----- render ----- return ( @@ -218,7 +220,17 @@ export const VirtualList = forwardRef(funct aria-rowcount={rowCount} >
-
{renderedItems}
+
+ {renderedItems} +
{}, scrollToRow: () => {}, + getColumnsCount: () => 1, ensureRangeLoaded: () => {}, getItem: (index: number) => ({ path: `item-${index}` }) as SearchResultItem, ...overrides, diff --git a/cardinal/src/hooks/useContextMenu.ts b/cardinal/src/hooks/useContextMenu.ts index 31de9848..e528cad9 100644 --- a/cardinal/src/hooks/useContextMenu.ts +++ b/cardinal/src/hooks/useContextMenu.ts @@ -14,6 +14,8 @@ type UseContextMenuResult = { export function useContextMenu( autoFitColumns: (() => void) | null = null, onQuickLookRequest?: () => void | Promise, + onTrashRequest?: (path: string) => void | Promise, + onDeleteRequest?: (path: string) => void | Promise, ): UseContextMenuResult { const { t } = useTranslation(); @@ -62,6 +64,26 @@ export function useContextMenu( } }, }, + { + id: 'context_menu.trash', + text: t('contextMenu.moveToTrash'), + accelerator: 'Cmd+Backspace', + action: () => { + if (onTrashRequest) { + void onTrashRequest(path); + } + }, + }, + { + id: 'context_menu.delete_permanent', + text: t('contextMenu.deletePermanently'), + accelerator: 'Opt+Cmd+Backspace', + action: () => { + if (onDeleteRequest) { + void onDeleteRequest(path); + } + }, + }, ]; if (onQuickLookRequest) { @@ -79,7 +101,7 @@ export function useContextMenu( return items; }, - [onQuickLookRequest, t], + [onQuickLookRequest, onTrashRequest, onDeleteRequest, t], ); const buildHeaderMenuItems = useCallback((): MenuItemOptions[] => { diff --git a/cardinal/src/hooks/useDataLoader.ts b/cardinal/src/hooks/useDataLoader.ts index 73f6933b..4ea25113 100644 --- a/cardinal/src/hooks/useDataLoader.ts +++ b/cardinal/src/hooks/useDataLoader.ts @@ -10,7 +10,7 @@ import type { IconUpdatePayload, IconUpdateWirePayload } from '../types/ipc'; type IconUpdateEventPayload = readonly IconUpdateWirePayload[] | null | undefined; export type DataLoaderCache = Map; -type IconOverrideValue = string | null; +type IconOverrideValue = { icon: string | null; width?: number; height?: number } | null; const normalizeIcon = (icon: string | null | undefined): string | undefined => icon ?? undefined; @@ -23,6 +23,8 @@ const fromNodeInfo = (node: NodeInfoResponse): SearchResultItem => { mtime: node.mtime ?? metadata?.mtime, ctime: node.ctime ?? metadata?.ctime, icon: normalizeIcon(node.icon), + iconWidth: node.iconWidth ?? undefined, + iconHeight: node.iconHeight ?? undefined, }; return base; }; @@ -74,6 +76,8 @@ export function useDataLoader(results: SlabIndex[]) { normalized.push({ slabIndex: toSlabIndex(update.slabIndex), icon: update.icon, + width: update.width, + height: update.height, }); } }); @@ -89,20 +93,34 @@ export function useDataLoader(results: SlabIndex[]) { const index = indexMapRef.current.get(update.slabIndex); if (index === undefined) return; - const overrideValue: IconOverrideValue = update.icon ?? null; + const overrideValue: IconOverrideValue = { + icon: update.icon ?? null, + width: update.width, + height: update.height, + }; iconOverridesRef.current.set(index, overrideValue); const current = prev.get(index); if (!current) return; - const nextIcon = normalizeIcon(overrideValue); - if (current.icon === nextIcon) return; + const nextIcon = normalizeIcon(overrideValue.icon); + if ( + current.icon === nextIcon && + current.iconWidth === overrideValue.width && + current.iconHeight === overrideValue.height + ) + return; if (nextCache === null) { nextCache = new Map(prev); } - nextCache.set(index, { ...current, icon: nextIcon }); + nextCache.set(index, { + ...current, + icon: nextIcon, + iconWidth: overrideValue.width, + iconHeight: overrideValue.height, + }); }); if (nextCache === null) { @@ -158,13 +176,28 @@ export function useDataLoader(results: SlabIndex[]) { const override = hasOverride ? iconOverridesRef.current.get(originalIndex) : undefined; const preferredIcon = hasOverride - ? normalizeIcon(override) + ? normalizeIcon(override?.icon) : (existing?.icon ?? normalizedItem.icon); + const preferredWidth = hasOverride + ? override?.width + : (existing?.iconWidth ?? normalizedItem.iconWidth); + + const preferredHeight = hasOverride + ? override?.height + : (existing?.iconHeight ?? normalizedItem.iconHeight); + const mergedItem = - preferredIcon === normalizedItem.icon + preferredIcon === normalizedItem.icon && + preferredWidth === normalizedItem.iconWidth && + preferredHeight === normalizedItem.iconHeight ? normalizedItem - : { ...normalizedItem, icon: preferredIcon }; + : { + ...normalizedItem, + icon: preferredIcon, + iconWidth: preferredWidth, + iconHeight: preferredHeight, + }; if (nextCache === null) { nextCache = new Map(prev); diff --git a/cardinal/src/hooks/useIconViewport.ts b/cardinal/src/hooks/useIconViewport.ts index 8b610e47..abff7d93 100644 --- a/cardinal/src/hooks/useIconViewport.ts +++ b/cardinal/src/hooks/useIconViewport.ts @@ -6,10 +6,11 @@ type UseIconViewportProps = { results: SlabIndex[]; start: number; end: number; + iconSize: number; }; // Deduplicates and throttles icon viewport updates to the backend. -export function useIconViewport({ results, start, end }: UseIconViewportProps) { +export function useIconViewport({ results, start, end, iconSize }: UseIconViewportProps) { const requestIdRef = useRef(0); const lastRangeRef = useRef<{ start: number; end: number } | null>(null); const pendingRef = useRef(null); @@ -21,8 +22,9 @@ export function useIconViewport({ results, start, end }: UseIconViewportProps) { if (!viewport) return; pendingRef.current = null; requestIdRef.current += 1; - void invoke('update_icon_viewport', { id: requestIdRef.current, viewport }); - }, []); + requestIdRef.current += 1; + void invoke('update_icon_viewport', { id: requestIdRef.current, viewport, iconSize }); + }, [iconSize]); const scheduleIconViewport = useCallback( (viewport: SlabIndex[]) => { @@ -60,7 +62,7 @@ export function useIconViewport({ results, start, end }: UseIconViewportProps) { lastRangeRef.current = { start: clampedStart, end: clampedEnd }; scheduleIconViewport(results.slice(clampedStart, clampedEnd + 1)); - }, [results, start, end, scheduleIconViewport]); + }, [results, start, end, iconSize, scheduleIconViewport]); useEffect( () => () => { diff --git a/cardinal/src/hooks/useQuickLook.ts b/cardinal/src/hooks/useQuickLook.ts index daf6a703..7869c043 100644 --- a/cardinal/src/hooks/useQuickLook.ts +++ b/cardinal/src/hooks/useQuickLook.ts @@ -82,8 +82,10 @@ export const useQuickLook = ({ getPaths }: UseQuickLookConfig) => { const buildItem = (path: string): QuickLookItemPayload => { const selector = `[data-row-path="${escapePathForSelector(path)}"]`; const row = document.querySelector(selector); - const anchor = row?.querySelector('.file-icon, .file-icon-placeholder'); - const iconImage = row?.querySelector('img.file-icon'); + const anchor = row?.querySelector( + '.file-icon, .file-icon-placeholder, .grid-item-icon, .grid-item-icon-placeholder', + ); + const iconImage = row?.querySelector('img.file-icon, img.grid-item-icon'); if (!row || !anchor || !iconImage) { return { path }; } diff --git a/cardinal/src/hooks/useSelection.ts b/cardinal/src/hooks/useSelection.ts index 5297732f..2a678443 100644 --- a/cardinal/src/hooks/useSelection.ts +++ b/cardinal/src/hooks/useSelection.ts @@ -18,8 +18,9 @@ export type SelectionController = { selectedPaths: string[]; handleRowSelect: (rowIndex: number, options: RowSelectOptions) => void; selectSingleRow: (rowIndex: number) => void; + bulkSelect: (indices: number[]) => void; clearSelection: () => void; - moveSelection: (delta: 1 | -1, options?: { extend?: boolean }) => void; + moveSelection: (delta: number, options?: { extend?: boolean }) => void; }; /** @@ -103,6 +104,13 @@ export const useSelection = ( setShiftAnchorIndex(rowIndex); }, []); + const bulkSelect = useCallback((indices: number[]) => { + setSelectedIndices(indices); + if (indices.length > 0) { + setActiveRowIndex(indices[indices.length - 1]); + } + }, []); + const clearSelection = useCallback(() => { setSelectedIndices([]); setActiveRowIndex(null); @@ -110,7 +118,7 @@ export const useSelection = ( }, []); const moveSelection = useCallback( - (delta: 1 | -1, options?: { extend?: boolean }) => { + (delta: number, options?: { extend?: boolean }) => { if (displayedResults.length === 0) { return; } @@ -179,6 +187,7 @@ export const useSelection = ( selectedPaths, handleRowSelect, selectSingleRow, + bulkSelect, clearSelection, moveSelection, }; diff --git a/cardinal/src/i18n/resources/en-US.json b/cardinal/src/i18n/resources/en-US.json index 504a83a4..560b920b 100644 --- a/cardinal/src/i18n/resources/en-US.json +++ b/cardinal/src/i18n/resources/en-US.json @@ -42,6 +42,8 @@ "revealInFinder": "Reveal in Finder", "copyPath": "Copy Path", "copyFilename": "Copy Filename", + "moveToTrash": "Move to Trash", + "deletePermanently": "Delete Permanently", "resetColumnWidths": "Reset Column Widths" }, "statusBar": { @@ -123,6 +125,14 @@ "sortingLimit": { "label": "Sorting limit" }, + "defaultView": { + "label": "Default view", + "list": "List", + "grid": "Grid" + }, + "defaultIconSize": { + "label": "Default icon size" + }, "close": "Close" }, "language": { diff --git a/cardinal/src/types/ipc.ts b/cardinal/src/types/ipc.ts index 5b828e53..bbf08cde 100644 --- a/cardinal/src/types/ipc.ts +++ b/cardinal/src/types/ipc.ts @@ -8,11 +8,15 @@ export type StatusBarUpdatePayload = { export type IconUpdateWirePayload = { slabIndex: number; icon?: string; + width?: number; + height?: number; }; export type IconUpdatePayload = { slabIndex: SlabIndex; icon?: string; + width?: number; + height?: number; }; export type RecentEventPayload = { diff --git a/cardinal/src/types/search.ts b/cardinal/src/types/search.ts index 3b3d3a7e..feb24159 100644 --- a/cardinal/src/types/search.ts +++ b/cardinal/src/types/search.ts @@ -12,11 +12,15 @@ export type SearchResultItem = Readonly<{ mtime?: number; ctime?: number; icon?: string; + iconWidth?: number; + iconHeight?: number; }>; export type NodeInfoResponse = Readonly<{ path: string; icon?: string | null; + iconWidth?: number | null; + iconHeight?: number | null; metadata?: SearchResultMetadata | null; size?: number | null; mtime?: number | null; diff --git a/cardinal/src/utils/selection.ts b/cardinal/src/utils/selection.ts index c749ca0b..76966e2d 100644 --- a/cardinal/src/utils/selection.ts +++ b/cardinal/src/utils/selection.ts @@ -1,4 +1,3 @@ -import type { SearchResultItem } from '../types/search'; import type { SlabIndex } from '../types/slab'; import type { VirtualListHandle } from '../components/VirtualList'; diff --git a/cardinal/src/utils/viewPreferences.ts b/cardinal/src/utils/viewPreferences.ts new file mode 100644 index 00000000..36987adb --- /dev/null +++ b/cardinal/src/utils/viewPreferences.ts @@ -0,0 +1,38 @@ +import { ViewMode, DEFAULT_ICON_SIZE } from '../constants'; + +const VIEW_MODE_STORAGE_KEY = 'cardinal.viewMode'; +const ICON_SIZE_STORAGE_KEY = 'cardinal.gridIconSize'; + +export const getStoredViewMode = (): ViewMode => { + if (typeof window === 'undefined') return 'list'; + const stored = window.localStorage.getItem(VIEW_MODE_STORAGE_KEY); + return stored === 'list' || stored === 'grid' ? stored : 'list'; +}; + +export const persistViewMode = (mode: ViewMode): void => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, mode); + } catch { + // Ignore storage failures. + } +}; + +export const getStoredIconSize = (): number => { + if (typeof window === 'undefined') return DEFAULT_ICON_SIZE; + const stored = window.localStorage.getItem(ICON_SIZE_STORAGE_KEY); + if (stored) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed)) return parsed; + } + return DEFAULT_ICON_SIZE; +}; + +export const persistIconSize = (size: number): void => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(ICON_SIZE_STORAGE_KEY, String(size)); + } catch { + // Ignore storage failures. + } +}; diff --git a/fs-icon/src/lib.rs b/fs-icon/src/lib.rs index 1d12ebbc..6395394a 100644 --- a/fs-icon/src/lib.rs +++ b/fs-icon/src/lib.rs @@ -3,7 +3,7 @@ use crossbeam_channel::bounded; use objc2::{AnyThread, rc::Retained}; use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage, NSWorkspace}; use objc2_core_foundation::{CFNumber, CFString, CFURL, Type}; -use objc2_foundation::{NSData, NSDictionary, NSError, NSSize, NSString, NSURL}; +use objc2_foundation::{NSDictionary, NSError, NSSize, NSString, NSURL}; use objc2_image_io::{CGImageSource, kCGImagePropertyPixelHeight, kCGImagePropertyPixelWidth}; use objc2_quick_look_thumbnailing::{ QLThumbnailGenerationRequest, QLThumbnailGenerationRequestRepresentationTypes, @@ -23,30 +23,40 @@ pub fn scale_with_aspect_ratio( (width * ratio, height * ratio) } -pub fn icon_of_path(path: &str) -> Option> { - if let Some(data) = icon_of_path_ql(path) { - return Some(data); +pub fn icon_of_path(path: &str, requested_size: f64) -> Option<(Vec, f64, f64)> { + // Try QuickLook for images first (optimized path with aspect ratio) + if let Some(result) = icon_of_path_ql(path, requested_size) { + return Some(result); } - icon_of_path_ns(path) + // Try generic QuickLook for PDFs and other file types + if let Some(result) = icon_of_path_ql_generic(path, requested_size) { + return Some(result); + } + // Fallback to NSWorkspace icon + icon_of_path_ns(path, requested_size) } // https://stackoverflow.com/questions/73062803/resizing-nsimage-keeping-aspect-ratio-reducing-the-image-size-while-trying-to-sc -pub fn icon_of_path_ns(path: &str) -> Option> { - objc2::rc::autoreleasepool(|_| -> Option> { +// https://stackoverflow.com/questions/73062803/resizing-nsimage-keeping-aspect-ratio-reducing-the-image-size-while-trying-to-sc +pub fn icon_of_path_ns(path: &str, requested_size: f64) -> Option<(Vec, f64, f64)> { + objc2::rc::autoreleasepool(|_| -> Option<(Vec, f64, f64)> { let path_ns = NSString::from_str(path); let image = NSWorkspace::sharedWorkspace().iconForFile(&path_ns); - let png_data: Retained = (|| -> Option<_> { + const PADDING_COMPENSATION: f64 = 2.0; + let effective_size = requested_size * PADDING_COMPENSATION; + + let (png_data, _width, _height) = (|| -> Option<_> { unsafe { // https://stackoverflow.com/questions/66270656/macos-determine-real-size-of-icon-returned-from-iconforfile-method for image in image.representations().iter() { let size = image.size(); - if size.width > 31.0 - && size.height > 31.0 - && size.width < 33.0 - && size.height < 33.0 + if size.width > (effective_size - 1.0) + && size.height > (effective_size - 1.0) + && size.width < (effective_size + 1.0) + && size.height < (effective_size + 1.0) { - // println!("representation: {}x{}", size.width, size.height); + // println!(\"representation: {}x{}\", size.width, size.height); let new_image = NSImage::imageWithSize_flipped_drawingHandler( NSSize::new(size.width, size.height), false, @@ -55,20 +65,20 @@ pub fn icon_of_path_ns(path: &str) -> Option> { true.into() }), ); - return NSBitmapImageRep::imageRepWithData( - &*new_image.TIFFRepresentation()?, - )? - .representationUsingType_properties( - NSBitmapImageFileType::PNG, - &NSDictionary::new(), - ); + let data = + NSBitmapImageRep::imageRepWithData(&*new_image.TIFFRepresentation()?)? + .representationUsingType_properties( + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + )?; + return Some((Retained::into_raw(data), size.width, size.height)); } } } // zoom in and you will see that the small icon in Finder is 32x32, here we keep it at 64x64 for better visibility let (new_width, new_height) = { - let width = 32.0; - let height = 32.0; + let width = effective_size; + let height = effective_size; // keep aspect ratio let old_width = image.size().width; let old_height = image.size().height; @@ -83,14 +93,27 @@ pub fn icon_of_path_ns(path: &str) -> Option> { true.into() }), ); - NSBitmapImageRep::imageRepWithData(&*new_image.TIFFRepresentation()?)? + let data = NSBitmapImageRep::imageRepWithData(&*new_image.TIFFRepresentation()?)? .representationUsingType_properties( - NSBitmapImageFileType::PNG, - &NSDictionary::new(), - ) + NSBitmapImageFileType::PNG, + &NSDictionary::new(), + )?; + Some((Retained::into_raw(data), new_width, new_height)) } })()?; - Some(png_data.to_vec()) + + let png_data = unsafe { Retained::from_raw(png_data)? }; + + let width = { + let rep = NSBitmapImageRep::imageRepWithData(&png_data)?; + rep.pixelsWide() as f64 + }; + let height = { + let rep = NSBitmapImageRep::imageRepWithData(&png_data)?; + rep.pixelsHigh() as f64 + }; + + Some((png_data.to_vec(), width, height)) }) } @@ -116,16 +139,14 @@ pub fn image_dimension(image_path: &str) -> Option<(f64, f64)> { }) } -pub fn icon_of_path_ql(path: &str) -> Option> { - // We only get QLThumbnail for image, get NSWorkspace icon for other file types. - // Therefore we just error out when image_dimension is not found. - let (width, height) = image_dimension(path)?; - objc2::rc::autoreleasepool(|_| -> Option> { - const THUMBNAIL_SIZE: f64 = 64.0; - const THUMBNAIL_SCALE: f64 = 1.0; - let (width, height) = - scale_with_aspect_ratio(width, height, THUMBNAIL_SIZE, THUMBNAIL_SIZE); - // use a slightly larger thumbnail size with 0.5 scale +fn generate_ql_thumbnail( + path: &str, + width: f64, + height: f64, + representation_type: QLThumbnailGenerationRequestRepresentationTypes, +) -> Option<(Vec, f64, f64)> { + objc2::rc::autoreleasepool(|_| -> Option<(Vec, f64, f64)> { + const THUMBNAIL_SCALE: f64 = 2.0; // Retina scaling let path_url = NSURL::fileURLWithPath(&NSString::from_str(path)); let generator = unsafe { QLThumbnailGenerator::sharedGenerator() }; { @@ -137,33 +158,61 @@ pub fn icon_of_path_ql(path: &str) -> Option> { &path_url, NSSize::new(width, height), THUMBNAIL_SCALE, - QLThumbnailGenerationRequestRepresentationTypes::LowQualityThumbnail, + representation_type, ); + request.setIconMode(true); generator.generateBestRepresentationForRequest_completionHandler( &request, &RcBlock::new( move |result: *mut QLThumbnailRepresentation, _error: *mut NSError| { let _ = tx.send(result.as_ref().and_then(|result| { - Some( - NSBitmapImageRep::imageRepWithData( - &*result.NSImage().TIFFRepresentation()?, - )? + let image = result.NSImage(); + let tiff = image.TIFFRepresentation()?; + let bitmap = NSBitmapImageRep::imageRepWithData(&tiff)?; + let data = bitmap .representationUsingType_properties( NSBitmapImageFileType::PNG, &NSDictionary::new(), )? - .to_vec(), - ) + .to_vec(); + + Some((data, bitmap.pixelsWide() as f64, bitmap.pixelsHigh() as f64)) })); }, ), ) }; - rx.recv().ok().flatten() + let (data, actual_width, actual_height) = rx.recv().ok().flatten()?; + Some((data, actual_width, actual_height)) } }) } +/// Generate QuickLook thumbnail for any file type (PDFs, documents, etc.) +/// This function doesn't require image dimensions and works for all QuickLook-supported formats +pub fn icon_of_path_ql_generic(path: &str, requested_size: f64) -> Option<(Vec, f64, f64)> { + generate_ql_thumbnail( + path, + requested_size, + requested_size, + QLThumbnailGenerationRequestRepresentationTypes::Thumbnail, + ) +} + +pub fn icon_of_path_ql(path: &str, requested_size: f64) -> Option<(Vec, f64, f64)> { + // We only get QLThumbnail for image, get NSWorkspace icon for other file types. + // Therefore we just error out when image_dimension is not found. + let (width, height) = image_dimension(path)?; + let (width, height) = scale_with_aspect_ratio(width, height, requested_size, requested_size); + generate_ql_thumbnail( + path, + width, + height, + QLThumbnailGenerationRequestRepresentationTypes::Icon + | QLThumbnailGenerationRequestRepresentationTypes::Thumbnail, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -175,13 +224,13 @@ mod tests { .unwrap() .to_string_lossy() .into_owned(); - let data = icon_of_path_ns(&pwd).unwrap(); + let (data, _, _) = icon_of_path_ns(&pwd, 32.0).unwrap(); std::fs::write("/tmp/icon.png", data).unwrap(); } #[test] fn test_icon_of_path_ql_normal() { - let data = icon_of_path_ql("../cardinal/mac-icon_1024x1024.png").unwrap(); + let (data, _, _) = icon_of_path_ql("../cardinal/mac-icon_1024x1024.png", 64.0).unwrap(); std::fs::write("/tmp/icon_ql.png", data).unwrap(); } @@ -192,7 +241,7 @@ mod tests { .unwrap() .to_string_lossy() .into_owned(); - icon_of_path_ql(&pwd).expect("should fail for non-image file"); + icon_of_path_ql(&pwd, 64.0).expect("should fail for non-image file"); } #[test] @@ -244,8 +293,8 @@ mod tests { .into_owned(); loop { for _ in 0..10000 { - let _data = icon_of_path_ns(&pwd).unwrap(); - let _data = icon_of_path_ql(&pwd).unwrap(); + let _ = icon_of_path_ns(&pwd, 64.0).unwrap(); + let _ = icon_of_path_ql(&pwd, 64.0).unwrap(); } std::thread::sleep(std::time::Duration::from_secs(1)); } @@ -271,11 +320,12 @@ mod tests { let path_str = path.to_string_lossy().into_owned(); let start_ns = Instant::now(); - let icon_ns = icon_of_path_ns(&path_str).expect("NSWorkspace icon lookup failed"); + let (icon_ns, _, _) = + icon_of_path_ns(&path_str, 64.0).expect("NSWorkspace icon lookup failed"); let ns_elapsed = start_ns.elapsed(); let start_ql = Instant::now(); - let Some(icon_ql) = icon_of_path_ql(&path_str) else { + let Some((icon_ql, _, _)) = icon_of_path_ql(&path_str, 64.0) else { println!("QuickLook thumbnail generation failed for path {path_str}"); continue; }; diff --git a/fs-icon/tests/additional.rs b/fs-icon/tests/additional.rs index 53700d9b..79f2ea1f 100644 --- a/fs-icon/tests/additional.rs +++ b/fs-icon/tests/additional.rs @@ -25,6 +25,6 @@ fn scale_zero_width_graceful() { fn icon_of_path_fallback_for_non_image() { // Non-image path should still return some data via NSWorkspace fallback. let cwd = std::env::current_dir().unwrap(); - let data = icon_of_path(cwd.to_str().unwrap()).expect("fallback icon should exist"); - assert!(!data.is_empty()); + let data = icon_of_path(cwd.to_str().unwrap(), 64.0).expect("fallback icon should exist"); + assert!(!data.0.is_empty()); }