From 8322b4bdad0f0ed3456ea623102939ad8fde498f Mon Sep 17 00:00:00 2001 From: Denis Stoyanov Date: Thu, 18 Dec 2025 03:53:45 +0200 Subject: [PATCH] Added grid view and advanced search dialog --- .gitignore | 3 +- cardinal/package-lock.json | 10 + cardinal/package.json | 1 + cardinal/src-tauri/Cargo.lock | 291 ++++++++- cardinal/src-tauri/Cargo.toml | 3 + cardinal/src-tauri/capabilities/default.json | 1 + cardinal/src-tauri/src/background.rs | 2 +- cardinal/src-tauri/src/commands.rs | 29 +- cardinal/src-tauri/src/lib.rs | 14 +- cardinal/src-tauri/src/query_parser.rs | 115 ++++ cardinal/src/App.css | 591 ++++++++++++++++-- cardinal/src/App.tsx | 174 +++++- cardinal/src/components/FileGridItem.tsx | 188 ++++++ cardinal/src/components/FileRow.tsx | 21 +- cardinal/src/components/FileRowRenderer.tsx | 6 +- cardinal/src/components/FilesTabContent.tsx | 118 +++- .../components/MiddleEllipsisHighlight.tsx | 15 +- .../src/components/PreferencesOverlay.tsx | 48 ++ cardinal/src/components/QueryBuilder.css | 403 ++++++++++++ cardinal/src/components/QueryBuilder.tsx | 545 ++++++++++++++++ cardinal/src/components/SearchBar.tsx | 76 +++ cardinal/src/components/StatusBar.tsx | 28 +- cardinal/src/components/VirtualGrid.tsx | 354 +++++++++++ cardinal/src/components/VirtualList.tsx | 18 +- cardinal/src/constants/index.ts | 6 + .../src/hooks/__tests__/useSelection.test.ts | 1 + cardinal/src/hooks/useContextMenu.ts | 24 +- cardinal/src/hooks/useQuickLook.ts | 6 +- cardinal/src/hooks/useSelection.ts | 13 +- cardinal/src/i18n/resources/en-US.json | 65 ++ cardinal/src/utils/queryParser.ts | 224 +++++++ cardinal/src/utils/viewPreferences.ts | 38 ++ fs-icon/src/lib.rs | 81 ++- fs-icon/tests/additional.rs | 2 +- 34 files changed, 3374 insertions(+), 140 deletions(-) create mode 100644 cardinal/src-tauri/src/query_parser.rs create mode 100644 cardinal/src/components/FileGridItem.tsx create mode 100644 cardinal/src/components/QueryBuilder.css create mode 100644 cardinal/src/components/QueryBuilder.tsx create mode 100644 cardinal/src/components/VirtualGrid.tsx create mode 100644 cardinal/src/utils/queryParser.ts 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/package-lock.json b/cardinal/package-lock.json index a94ba9d5..ab536d15 100644 --- a/cardinal/package-lock.json +++ b/cardinal/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-window-state": "^2.4.1", @@ -1575,6 +1576,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.4.2.tgz", + "integrity": "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-global-shortcut": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz", diff --git a/cardinal/package.json b/cardinal/package.json index 2b9128fc..8b052f61 100644 --- a/cardinal/package.json +++ b/cardinal/package.json @@ -17,6 +17,7 @@ "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-window-state": "^2.4.1", diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index f2a356ab..7ec1eb89 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -53,6 +53,27 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -391,6 +412,7 @@ dependencies = [ "base64 0.22.1", "camino", "cardinal-sdk", + "cardinal-syntax", "crossbeam-channel", "fs-icon", "fswalk", @@ -407,6 +429,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-drag", "tauri-plugin-global-shortcut", "tauri-plugin-macos-permissions", @@ -415,6 +438,7 @@ dependencies = [ "tauri-plugin-window-state", "tracing", "tracing-subscriber", + "trash", ] [[package]] @@ -875,6 +899,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -898,6 +931,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -3119,7 +3158,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.12.1", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3281,6 +3320,15 @@ dependencies = [ name = "query-segmentation" version = "0.1.0" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3330,6 +3378,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3350,6 +3408,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3368,6 +3436,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3516,6 +3593,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3616,6 +3718,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4312,6 +4420,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "url", +] + [[package]] name = "tauri-plugin-drag" version = "2.1.0" @@ -4327,6 +4453,28 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "tauri-plugin-fs" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "toml 0.9.8", + "url", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.3.1" @@ -4642,7 +4790,9 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tracing", "windows-sys 0.61.2", ] @@ -4861,6 +5011,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 +5154,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" @@ -5171,6 +5345,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -5319,6 +5553,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 +5594,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 +5667,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 +5711,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 +5766,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" @@ -5969,6 +6256,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -6117,6 +6405,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.14", "zvariant_derive", "zvariant_utils", diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml index d12c4a4e..7724270f 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-drag = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" crossbeam-channel = "0.5.15" @@ -39,8 +40,10 @@ 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" +cardinal-syntax.path = "../../cardinal-syntax" search-cache.path = "../../search-cache" fswalk.path = "../../fswalk" fs-icon.path = "../../fs-icon" diff --git a/cardinal/src-tauri/capabilities/default.json b/cardinal/src-tauri/capabilities/default.json index ce8f857a..a3015482 100644 --- a/cardinal/src-tauri/capabilities/default.json +++ b/cardinal/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "opener:default", "macos-permissions:default", "drag:default", + "dialog:default", "global-shortcut:allow-is-registered", "global-shortcut:allow-register", "global-shortcut:allow-unregister" diff --git a/cardinal/src-tauri/src/background.rs b/cardinal/src-tauri/src/background.rs index 7c326e9d..16dcf068 100644 --- a/cardinal/src-tauri/src/background.rs +++ b/cardinal/src-tauri/src/background.rs @@ -180,7 +180,7 @@ 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!( + if let Some(icon) = fs_icon::icon_of_path(&path, 512.0).map(|data| format!( "data:image/png;base64,{}", general_purpose::STANDARD.encode(&data) )) { diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 1c266aee..6de0a1b0 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}; @@ -249,7 +249,7 @@ pub fn get_nodes_info( .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| { + fs_icon::icon_of_path_ns(&path, 512.0).map(|data| { format!( "data:image/png;base64,{}", general_purpose::STANDARD.encode(data) @@ -370,3 +370,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..2707abe6 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod background; mod commands; mod lifecycle; +mod query_parser; mod quicklook; mod search_activity; mod sort; @@ -12,16 +13,17 @@ 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::{ APP_QUIT, AppLifecycleState, EXIT_REQUESTED, emit_app_state, load_app_state, update_app_state, }; use once_cell::sync::OnceCell; +use query_parser::parse_search_query; use search_cache::{SearchCache, SearchOutcome, SlabIndex, WalkData}; use std::{ path::{Path, PathBuf}, @@ -73,6 +75,7 @@ pub fn run() -> Result<()> { .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_window_state::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) .on_window_event(move |window, event| { if window.label() != "main" { return; @@ -128,6 +131,9 @@ pub fn run() -> Result<()> { hide_main_window, activate_main_window, toggle_main_window, + trash_paths, + delete_paths, + parse_search_query, ]) .build(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/cardinal/src-tauri/src/query_parser.rs b/cardinal/src-tauri/src/query_parser.rs new file mode 100644 index 00000000..e2937549 --- /dev/null +++ b/cardinal/src-tauri/src/query_parser.rs @@ -0,0 +1,115 @@ +use cardinal_syntax::{Expr, FilterKind, Term, parse_query}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ParsedExpr { + Empty, + Term { term: ParsedTerm }, + Not { inner: Box }, + And { parts: Vec }, + Or { parts: Vec }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ParsedTerm { + Word { + text: String, + }, + Phrase { + text: String, + }, + Regex { + pattern: String, + }, + Filter { + kind: String, + argument: Option, + }, +} + +impl From for ParsedExpr { + fn from(expr: Expr) -> Self { + match expr { + Expr::Empty => ParsedExpr::Empty, + Expr::Term(term) => ParsedExpr::Term { term: term.into() }, + Expr::Not(inner) => ParsedExpr::Not { + inner: Box::new((*inner).into()), + }, + Expr::And(parts) => ParsedExpr::And { + parts: parts.into_iter().map(Into::into).collect(), + }, + Expr::Or(parts) => ParsedExpr::Or { + parts: parts.into_iter().map(Into::into).collect(), + }, + } + } +} + +impl From for ParsedTerm { + fn from(term: Term) -> Self { + match term { + Term::Word(text) => ParsedTerm::Word { text }, + Term::Phrase(text) => ParsedTerm::Phrase { text }, + Term::Regex(pattern) => ParsedTerm::Regex { pattern }, + Term::Filter(filter) => { + let kind = filter_kind_to_string(&filter.kind); + let argument = filter.argument.map(|arg| arg.raw); + ParsedTerm::Filter { kind, argument } + } + } + } +} + +fn filter_kind_to_string(kind: &FilterKind) -> String { + match kind { + FilterKind::File => "file".to_string(), + FilterKind::Folder => "folder".to_string(), + FilterKind::Ext => "ext".to_string(), + FilterKind::Type => "type".to_string(), + FilterKind::Audio => "audio".to_string(), + FilterKind::Video => "video".to_string(), + FilterKind::Doc => "doc".to_string(), + FilterKind::Exe => "exe".to_string(), + FilterKind::Size => "size".to_string(), + FilterKind::DateModified => "dm".to_string(), + FilterKind::DateCreated => "dc".to_string(), + FilterKind::DateAccessed => "da".to_string(), + FilterKind::DateRun => "dr".to_string(), + FilterKind::Parent => "parent".to_string(), + FilterKind::InFolder => "infolder".to_string(), + FilterKind::NoSubfolders => "nosubfolders".to_string(), + FilterKind::Child => "child".to_string(), + FilterKind::Attribute => "attrib".to_string(), + FilterKind::AttributeDuplicate => "attribdupe".to_string(), + FilterKind::DateModifiedDuplicate => "dmdupe".to_string(), + FilterKind::Duplicate => "dupe".to_string(), + FilterKind::NamePartDuplicate => "namepartdupe".to_string(), + FilterKind::SizeDuplicate => "sizedupe".to_string(), + FilterKind::Artist => "artist".to_string(), + FilterKind::Album => "album".to_string(), + FilterKind::Title => "title".to_string(), + FilterKind::Genre => "genre".to_string(), + FilterKind::Year => "year".to_string(), + FilterKind::Track => "track".to_string(), + FilterKind::Comment => "comment".to_string(), + FilterKind::Width => "width".to_string(), + FilterKind::Height => "height".to_string(), + FilterKind::Dimensions => "dimensions".to_string(), + FilterKind::Orientation => "orientation".to_string(), + FilterKind::BitDepth => "bitdepth".to_string(), + FilterKind::CaseSensitive => "case".to_string(), + FilterKind::Tag => "tag".to_string(), + FilterKind::Content => "content".to_string(), + FilterKind::NoWholeFilename => "nowholefilename".to_string(), + FilterKind::Custom(name) => name.clone(), + } +} + +#[tauri::command] +pub async fn parse_search_query(query: String) -> Result { + parse_query(&query) + .map(|parsed| parsed.expr.into()) + .map_err(|e: cardinal_syntax::ParseError| e.to_string()) +} diff --git a/cardinal/src/App.css b/cardinal/src/App.css index b96d20bf..b79d21cd 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,199 @@ 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-help-trigger-container { + display: flex; + align-items: center; + padding: 2px; + background-color: rgba(0, 0, 0, 0.05); + border-radius: 6px; + margin-right: 8px; +} + +.search-help-trigger { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 24px; + border: none; + background: transparent; + color: var(--color-muted); + cursor: pointer; + border-radius: 4px; + transition: all 0.1s ease; + flex-shrink: 0; + padding: 0; +} + +.search-help-trigger:hover { + background-color: rgba(0, 0, 0, 0.05); + color: var(--color-text); +} + +.search-help-trigger.active { + background-color: #fff; + color: var(--color-accent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +[data-theme='dark'] .search-help-trigger-container { + background-color: rgba(255, 255, 255, 0.1); +} + +[data-theme='dark'] .search-help-trigger.active { + background-color: rgba(255, 255, 255, 0.15); + color: #fff; +} + +.search-help-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + width: 380px; + max-height: 500px; + background-color: var(--color-elevated-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + backdrop-filter: blur(16px); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; + animation: popoverFadeIn 0.2s ease-out; +} + +@keyframes popoverFadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.search-help-header { + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(var(--color-accent-rgb), 0.08); + font-weight: 600; + font-size: 13px; + color: var(--color-text); + background-color: rgba(var(--color-accent-rgb), 0.03); +} + +.search-help-close { + background: transparent; + border: none; + font-size: 18px; + color: var(--color-muted); + cursor: pointer; + padding: 0 4px; +} + +.search-help-content { + padding: 16px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.search-help-category-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); + margin-bottom: 8px; + font-weight: 700; +} + +.search-help-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.search-help-item { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 8px 12px; + background-color: rgba(var(--color-accent-rgb), 0.03); + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.search-help-item:hover { + background-color: rgba(var(--color-accent-rgb), 0.08); + border-color: rgba(var(--color-accent-rgb), 0.15); + transform: translateY(-1px); +} + +.search-help-item-label { + font-size: 12px; + font-weight: 500; + margin-bottom: 4px; +} + +.search-help-item-syntax { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-accent); + background: transparent; + padding: 0; +} + +.search-help-footer { + padding: 12px 16px; + border-top: 1px solid rgba(var(--color-accent-rgb), 0.08); + background-color: rgba(var(--color-accent-rgb), 0.02); +} + +.search-help-full-doc-link { + font-size: 12px; + color: var(--color-accent); + text-decoration: none; +} + +.search-help-full-doc-link:hover { + text-decoration: underline; } #search-input { @@ -287,7 +462,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 +471,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 +517,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 +610,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 +808,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 +868,7 @@ button:active:not(:disabled) { opacity: 0; transform: scale(0.98); } + to { opacity: 1; transform: scale(1); @@ -713,10 +897,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 +1038,7 @@ button:active:not(:disabled) { 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } @@ -1022,17 +1210,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 +1230,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 +1645,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 +1832,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 +1952,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 +1978,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 +2011,236 @@ 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 2px 4px rgba(0, 0, 0, 0.1)) brightness(0.9); +} + +.grid-item-selected .grid-item-icon-container { + background-color: var(--grid-sel-icon-bg); + border-radius: 10px; + /* Slightly more rounded like macOS Finder folders */ +} + +.grid-item-icon { + width: var(--icon-size, 64px); + height: var(--icon-size, 64px); + object-fit: contain; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); + transition: filter 0.1s ease; +} + +.grid-item-icon-placeholder { + background-color: var(--color-border); + border-radius: 8px; + opacity: 0.3; +} + +.grid-item-icon-container { + /* Create frame: container is larger than icon */ + width: calc(var(--icon-size, 64px) + 20px); + height: calc(var(--icon-size, 64px) + 20px); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; + position: relative; + transition: background-color 0.1s ease; +} + +[data-window-focused='false'] .grid-item-selected .grid-item-name-text { + background-color: var(--color-border); + color: var(--color-text); +} + +.grid-item-name { + font-size: 11px; + line-height: 1.6em; + max-height: 4.5em; + /* Massive headroom for 2 lines + descenders + highlights */ + max-width: 100%; + padding: 4px 2px 12px 2px; + /* Large bottom padding for descenders */ + word-break: break-word; + overflow-wrap: anywhere; + overflow: hidden; + text-align: center; +} + +.grid-item-name-text { + padding: 2px 5px; + border-radius: 3px; + 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; +} + +.grid-item-selected .grid-item-name-text { + background-color: var(--grid-sel-bg); + color: #fff; +} + +.selection-marquee { + position: absolute; + background-color: rgba(var(--color-accent-rgb), 0.15); + border: 1px solid var(--color-accent); + border-radius: 2px; + 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 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]); + + return ( +
+
+ {item.icon ? ( + icon + ) : ( + +
+ + {highlightedParts.map((part, index) => + part.isHighlight ? ( + {part.text} + ) : ( + {part.text} + ), + )} + +
+
+ ); +}); + +FileGridItem.displayName = 'FileGridItem'; diff --git a/cardinal/src/components/FileRow.tsx b/cardinal/src/components/FileRow.tsx index f3be4813..984c7392 100644 --- a/cardinal/src/components/FileRow.tsx +++ b/cardinal/src/components/FileRow.tsx @@ -16,7 +16,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 +29,7 @@ export const FileRow = memo(function FileRow({ onOpen, onSelect, isSelected = false, - selectedPathsForDrag = [], + onDragStart, caseInsensitive, highlightTerms, }: FileRowProps): React.JSX.Element | null { @@ -117,26 +117,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..ae3f1006 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 { VirtualGrid, type VirtualGridHandle } from './VirtualGrid'; +import { FileGridItem } from './FileGridItem'; +import { startNativeFileDrag } from '../utils/drag'; 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,18 @@ export function FilesTabContent({ onScrollSync={onScrollSync} className="virtual-list" /> + ) : ( + } + results={results} + renderItem={renderGridItem} + itemWidth={iconSize + 40} + itemHeight={iconSize + 100} + onBulkSelect={onBulkSelect} + onSelect={onSelect} + overscanRows={overscan} + className="virtual-grid" + /> )}
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/QueryBuilder.css b/cardinal/src/components/QueryBuilder.css new file mode 100644 index 00000000..69f23e0d --- /dev/null +++ b/cardinal/src/components/QueryBuilder.css @@ -0,0 +1,403 @@ +.query-builder-overlay { + position: fixed; + inset: 0; + z-index: 2100; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease; +} + +.query-builder-modal { + width: 650px; + background-color: var(--color-elevated-bg); + border: 1px solid var(--color-elevated-border); + border-radius: 12px; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + max-height: 85vh; + overflow: hidden; + animation: scaleIn 0.2s ease; +} + +.query-builder-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border); + background-color: rgba(var(--color-bg-rgb), 0.5); +} + +.query-builder-title-group { + display: flex; + align-items: center; + gap: 16px; +} + +.match-type-toggle { + display: flex; + background-color: var(--color-bg); + padding: 2px; + border-radius: 6px; + border: 1px solid var(--color-border); +} + +.match-type-btn { + background: transparent; + border: none; + padding: 4px 12px; + font-size: 0.8rem; + font-weight: 500; + color: var(--color-muted); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.match-type-btn:hover { + color: var(--color-text); +} + +.match-type-btn.active { + background-color: var(--color-accent); + color: white; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.match-type-btn.active.is-not { + background-color: #ff4d4f; + border-color: #ff4d4f; +} + +.query-builder-title { + font-weight: 600; + font-size: 1.1rem; + color: var(--color-header); +} + +.query-builder-folder-btn { + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-muted); + cursor: pointer; + transition: all 0.2s; +} + +.query-builder-folder-btn:hover { + border-color: var(--color-accent); + color: var(--color-accent); + background-color: rgba(var(--color-accent-rgb), 0.05); +} + +.query-builder-close { + background: none; + border: none; + font-size: 1.5rem; + line-height: 1; + color: var(--color-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s; +} + +.query-builder-close:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--color-text); +} + +.query-builder-content { + padding: 20px; + max-height: 60vh; + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; +} + +.query-builder-warning { + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 16px; + color: #856404; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.query-rule-row { + display: grid; + grid-template-columns: 80px 140px 1fr auto; + gap: 12px; + align-items: center; + animation: slideIn 0.2s ease; +} + +.query-builder-exclude-btn { + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-muted); + border-radius: 6px; + padding: 0; + height: 36px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.query-builder-exclude-btn:hover { + border-color: var(--color-text); + color: var(--color-text); +} + +.query-builder-exclude-btn.is-active { + background-color: rgba(255, 77, 79, 0.15); + border-color: #ff4d4f; + color: #ff4d4f; +} + +.query-builder-select, +.query-builder-input { + height: 36px; + padding: 0 10px; + border: 1px solid var(--color-border); + border-radius: 6px; + background-color: var(--color-bg); + color: var(--color-text); + font-size: 0.95rem; + width: 100%; +} + +.query-builder-select.field-select { + font-weight: 500; +} + +.query-builder-select:focus, +.query-builder-input:focus { + outline: 2px solid rgba(var(--color-accent-rgb), 0.25); + border-color: var(--color-accent); +} + +.query-builder-remove-btn { + background: none; + border: none; + color: var(--color-muted); + font-size: 1.4rem; + cursor: pointer; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.query-builder-remove-btn:hover { + background-color: rgba(255, 0, 0, 0.1); + color: #ff4d4f; +} + +.query-builder-remove-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.query-builder-add-btn { + align-self: flex-start; + background: none; + border: 1px dashed var(--color-border); + color: var(--color-accent); + padding: 8px 16px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.query-builder-add-btn:hover { + background-color: rgba(var(--color-accent-rgb), 0.1); + border-color: var(--color-accent); +} + +.query-builder-footer { + padding: 16px 20px; + border-top: 1px solid var(--color-border); + background-color: rgba(var(--color-bg-rgb), 0.5); + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +/* === Recursive Group Styles === */ +.query-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.query-group:not(.root-group) { + margin-left: 20px; + padding-left: 16px; + border-left: 2px solid var(--color-border); + position: relative; +} + +/* Optional: connection line for nesting */ +.query-group:not(.root-group)::before { + content: ''; + position: absolute; + left: -2px; + top: 0; + width: 12px; + height: 18px; + /* half of header height approx */ + border-left: 2px solid var(--color-border); + border-bottom: 2px solid var(--color-border); + border-bottom-left-radius: 8px; + display: none; + /* simple border might be cleaner */ +} + +.query-group-header { + display: flex; + align-items: center; + gap: 12px; +} + +.query-group-items { + display: flex; + flex-direction: column; + gap: 12px; +} + +.query-group-item-wrapper { + /* No special styles needed yet */ + display: block; +} + +.query-group-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.query-group-remove-btn { + background: none; + border: none; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-muted); + border-radius: 4px; + cursor: pointer; + font-size: 1.2rem; + transition: all 0.2s; +} + +.query-group-remove-btn:hover { + background-color: rgba(255, 0, 0, 0.1); + color: #ff4d4f; +} + +/* Override padding for root group content if needed */ +/* Override padding for root group content if needed */ +.query-builder-content > .query-group.root-group { + padding: 0; +} + +.query-preview { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + overflow: hidden; +} + +.query-preview-label { + font-size: 0.9rem; + color: var(--color-muted); + white-space: nowrap; +} + +.query-preview-code { + font-family: 'SF Mono', monospace; + font-size: 0.9rem; + background-color: rgba(0, 0, 0, 0.05); + padding: 4px 8px; + border-radius: 6px; + color: var(--color-header); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.query-builder-apply-btn { + background-color: var(--color-accent); + color: white; + border: none; + padding: 8px 24px; + border-radius: 6px; + font-weight: 500; + font-size: 0.95rem; + cursor: pointer; + transition: opacity 0.2s; +} + +.query-builder-apply-btn:hover { + opacity: 0.9; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +[data-theme='dark'] .query-preview-code { + background-color: rgba(255, 255, 255, 0.1); +} diff --git a/cardinal/src/components/QueryBuilder.tsx b/cardinal/src/components/QueryBuilder.tsx new file mode 100644 index 00000000..5f6ac718 --- /dev/null +++ b/cardinal/src/components/QueryBuilder.tsx @@ -0,0 +1,545 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { open } from '@tauri-apps/plugin-dialog'; +import { parseQueryToBuilder } from '../utils/queryParser'; +import './QueryBuilder.css'; + +export type SearchField = + | 'name' + | 'ext' + | 'type' + | 'size' + | 'dm' + | 'dc' + | 'parent' + | 'infolder' + | 'nosubfolders' + | 'content' + | 'regex' + | 'tag' + | 'kind'; + +export type SearchOperator = 'contains' | 'is' | 'gt' | 'lt'; + +export interface FilterRule { + id: string; + type: 'rule'; + field: SearchField; + operator: SearchOperator; + value: string; + exclude: boolean; +} + +export interface RuleGroup { + id: string; + type: 'group'; + combinator: 'and' | 'or'; + items: SearchItem[]; + exclude?: boolean; +} + +export type SearchItem = FilterRule | RuleGroup; + +type QueryBuilderProps = { + onApplyQuery: (query: string) => void; + onClose: () => void; + initialQuery?: string; +}; + +const INITIAL_ROOT: RuleGroup = { + id: 'root', + type: 'group', + combinator: 'and', + items: [], + exclude: false, +}; + +export function QueryBuilder({ + onApplyQuery, + onClose, + initialQuery, +}: QueryBuilderProps): React.JSX.Element { + const { t } = useTranslation(); + const [rootGroup, setRootGroup] = useState(INITIAL_ROOT); + const [parseWarning, setParseWarning] = useState(null); + + // Parse initial query on mount + useEffect(() => { + if (initialQuery && initialQuery.trim()) { + parseQueryToBuilder(initialQuery) + .then((parsed) => { + if (parsed) { + setRootGroup(parsed); + setParseWarning(null); + } else { + setParseWarning( + t('queryBuilder.parseWarning', 'Could not parse query. Starting with empty builder.'), + ); + } + }) + .catch((err) => { + console.error('Parse error:', err); + setParseWarning(t('queryBuilder.parseError', 'Failed to parse query.')); + }); + } + }, [initialQuery, t]); + + // Recursive query generation + const processGroup = useCallback((group: RuleGroup): string => { + const parts = group.items + .map((item) => { + if (item.type === 'group') { + const groupQuery = processGroup(item); + return groupQuery ? `(${groupQuery})` : ''; + } else { + return generateRuleString(item); + } + }) + .filter((part) => part !== ''); + + if (parts.length === 0) return ''; + + let result = ''; + if (group.combinator === 'and') { + result = parts.join(' '); + } else { + // OR logic: (A) | (B) + result = parts + .map((p) => { + if (p.startsWith('(') && p.endsWith(')')) return p; + if (p.includes(' ')) return `(${p})`; + return p; + }) + .join(' | '); + } + + // Handle Group Negation: !(...) + if (group.exclude) { + return `!(${result})`; + } + + return result; + }, []); + + const generateRuleString = (rule: FilterRule): string => { + const { field, value, exclude } = rule; + if (!value) return ''; + + const cleanValue = value.trim(); + const prefix = exclude ? '!' : ''; + let term = ''; + + switch (field) { + case 'name': + return exclude ? `!${cleanValue}` : cleanValue; + case 'ext': + term = `${prefix}ext:${cleanValue}`; + break; + case 'type': + term = `${prefix}type:${cleanValue}`; + break; + case 'size': + term = `${prefix}size:${cleanValue}`; + break; + case 'dm': + term = `${prefix}dm:${cleanValue}`; + break; + case 'dc': + term = `${prefix}dc:${cleanValue}`; + break; + case 'parent': + term = `${prefix}parent:${cleanValue}`; + break; + case 'infolder': + term = `${prefix}infolder:${cleanValue}`; + break; + case 'nosubfolders': + term = `${prefix}nosubfolders:${cleanValue}`; + break; + case 'content': + term = `${prefix}content:${cleanValue}`; + break; + case 'regex': + term = `${prefix}regex:${cleanValue}`; + break; + case 'tag': + term = `${prefix}tag:${cleanValue}`; + break; + case 'kind': + term = `${prefix}${cleanValue}:`; + break; + default: + term = ''; + } + return term; + }; + + const handleApply = () => { + const query = processGroup(rootGroup); + onApplyQuery(query); + onClose(); + }; + + const updateRoot = (newRoot: RuleGroup) => { + setRootGroup(newRoot); + }; + + return ( +
+
e.stopPropagation()}> +
+ {t('queryBuilder.title', 'Advanced Search')} + +
+ +
+ {parseWarning &&
⚠️ {parseWarning}
} + {}} // Root cannot be removed + /> +
+ +
+
+ + {t('queryBuilder.preview', 'Query Preview')}:{' '} + + {processGroup(rootGroup) || '...'} +
+ +
+
+
+ ); +} + +interface GroupProps { + group: RuleGroup; + onChange: (g: RuleGroup) => void; + onRemove: () => void; + isRoot?: boolean; +} + +function Group({ group, onChange, onRemove, isRoot = false }: GroupProps) { + const { t } = useTranslation(); + + const updateCombinator = (combinator: 'and' | 'or') => { + onChange({ ...group, combinator }); + }; + + const toggleExclude = () => { + onChange({ ...group, exclude: !group.exclude }); + }; + + const addItem = (type: 'rule' | 'group') => { + const newItem: SearchItem = + type === 'rule' + ? { + id: crypto.randomUUID(), + type: 'rule', + field: 'name', + operator: 'contains', + value: '', + exclude: false, + } + : { + id: crypto.randomUUID(), + type: 'group', + combinator: 'and', + items: [], // Start empty as requested + exclude: false, + }; + onChange({ ...group, items: [...group.items, newItem] }); + }; + + const updateItem = (id: string, newItem: SearchItem) => { + onChange({ + ...group, + items: group.items.map((i) => (i.id === id ? newItem : i)), + }); + }; + + const removeItem = (id: string) => { + onChange({ + ...group, + items: group.items.filter((i) => i.id !== id), + }); + }; + + return ( +
+
+
+ {/* Integrated NOT toggle */} + + + + +
+ + {!isRoot && ( + + )} +
+ +
+ {group.items.map((item) => ( +
+ {item.type === 'group' ? ( + updateItem(item.id, g)} + onRemove={() => removeItem(item.id)} + /> + ) : ( + updateItem(item.id, r)} + onRemove={() => removeItem(item.id)} + canRemove={group.items.length > 0} // Always allow remove in updated logic + /> + )} + {/* Connecting line or logic label could go here */} +
+ ))} +
+ +
+ + +
+
+ ); +} + +interface RuleProps { + rule: FilterRule; + onChange: (r: FilterRule) => void; + onRemove: () => void; + canRemove: boolean; +} + +function Rule({ rule, onChange, onRemove }: RuleProps) { + const { t } = useTranslation(); + + const updateRule = (updates: Partial) => { + onChange({ ...rule, ...updates }); + }; + + return ( +
+ {/* Exclude Toggle */} + + + {/* Field Selector */} + + + {/* Operator / Value Inputs based on Field */} + updateRule({ value: val })} /> + + {/* Remove Button */} + +
+ ); +} + +function RuleValueInput({ rule, onChange }: { rule: FilterRule; onChange: (val: string) => void }) { + const { t } = useTranslation(); + + const handleSelectFolder = async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + }); + if (selected) { + const path = Array.isArray(selected) ? selected[0] : selected; + if (path) { + onChange(path); + } + } + } catch (error) { + console.error('Failed to open folder picker:', error); + } + }; + + if (rule.field === 'kind') { + return ( + + ); + } + + if (rule.field === 'type') { + return ( + + ); + } + + if (rule.field === 'dm' || rule.field === 'dc') { + return ( + + ); + } + + if (rule.field === 'size') { + return ( +
+ +
+ ); + } + + const placeholders: Record = { + name: t('queryBuilder.inputPlaceholder', 'Enter value...'), + ext: 'jpg;png', + parent: '/Users/demo/Documents', + infolder: '/Users/demo/Projects', + regex: '^Report.*2024$', + tag: 'Important', + content: 'TODO', + }; + + const isPathField = rule.field === 'parent' || rule.field === 'infolder'; + + return ( +
+ onChange(e.target.value)} + placeholder={placeholders[rule.field] || placeholders.name} + style={{ flex: 1, width: 'auto' }} + /> + {isPathField && ( + + )} +
+ ); +} diff --git a/cardinal/src/components/SearchBar.tsx b/cardinal/src/components/SearchBar.tsx index cb9af6af..d31a3950 100644 --- a/cardinal/src/components/SearchBar.tsx +++ b/cardinal/src/components/SearchBar.tsx @@ -1,5 +1,7 @@ import React from 'react'; import type { ChangeEvent, FocusEventHandler } from 'react'; +import { ViewMode } from '../constants'; +import { QueryBuilder } from './QueryBuilder'; type SearchBarProps = { inputRef: React.RefObject; @@ -12,6 +14,8 @@ type SearchBarProps = { caseSensitiveLabel: string; onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; + viewMode: ViewMode; + onToggleViewMode: (mode: ViewMode) => void; }; export function SearchBar({ @@ -25,10 +29,62 @@ export function SearchBar({ caseSensitiveLabel, onFocus, onBlur, + viewMode, + onToggleViewMode, }: SearchBarProps): React.JSX.Element { + const [isBuilderOpen, setIsBuilderOpen] = React.useState(false); + + const handleApplyQuery = React.useCallback( + (query: string) => { + // Create a synthetic event to trigger the main onChange handler + // We replace the entire query with the built one + const event = { + target: { value: query }, + } as React.ChangeEvent; + + onChange(event); + + const input = inputRef.current; + if (input) { + requestAnimationFrame(() => { + input.focus(); + }); + } + }, + [inputRef, onChange], + ); + return (
+
+ + {isBuilderOpen && ( + setIsBuilderOpen(false)} + initialQuery={value} + /> + )} +
{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..91d9ca67 --- /dev/null +++ b/cardinal/src/components/VirtualGrid.tsx @@ -0,0 +1,354 @@ +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; +}; + +export const VirtualGrid = forwardRef(function VirtualGrid( + { + results = [], + overscanRows = 6, + renderItem, + itemWidth, + itemHeight, + onBulkSelect, + onSelect, + className = '', + }, + 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 }); + + 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..191ebea0 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 = { @@ -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/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..9a18de2d 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": { @@ -138,6 +148,61 @@ "dark": "Dark" } }, + "queryBuilder": { + "title": "Advanced Search", + "addRule": "Add Rule", + "removeRule": "Remove rule", + "exclude": "Exclude", + "not": "NOT", + "preview": "Preview", + "search": "Search", + "inputPlaceholder": "Enter value...", + "selectType": "Select type...", + "selectDate": "Select date...", + "selectSize": "Select size...", + "field": { + "name": "Name", + "ext": "Extension", + "type": "Type", + "size": "Size", + "dm": "Date Modified", + "dc": "Date Created", + "tag": "Tag", + "content": "Content", + "regex": "Regex", + "parent": "Parent Folder", + "infolder": "In Folder", + "nosubfolders": "No Subfolders" + }, + "type": { + "image": "Image", + "video": "Video", + "audio": "Audio", + "doc": "Document", + "code": "Code", + "archive": "Archive", + "exe": "Executable" + }, + "date": { + "today": "Today", + "yesterday": "Yesterday", + "thisweek": "This Week", + "pastweek": "Past Week", + "thismonth": "This Month", + "pastmonth": "Past Month", + "thisyear": "This Year", + "pastyear": "Past Year" + }, + "size": { + "empty": "Empty (0kb)", + "small": "Small", + "medium": "Medium", + "large": "Large", + "gt1mb": "> 1MB", + "gt100mb": "> 100MB", + "gt1gb": "> 1GB" + } + }, "tray": { "quit": "Quit Cardinal" } diff --git a/cardinal/src/utils/queryParser.ts b/cardinal/src/utils/queryParser.ts new file mode 100644 index 00000000..70f376e9 --- /dev/null +++ b/cardinal/src/utils/queryParser.ts @@ -0,0 +1,224 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { FilterRule, RuleGroup, SearchField } from '../components/QueryBuilder'; + +// Types matching Rust serialization +interface ParsedExpr { + type: 'empty' | 'term' | 'not' | 'and' | 'or'; + term?: ParsedTerm; + inner?: ParsedExpr; + parts?: ParsedExpr[]; +} + +interface ParsedTerm { + type: 'word' | 'phrase' | 'regex' | 'filter'; + text?: string; + pattern?: string; + kind?: string; + argument?: string | null; +} + +export async function parseQueryToBuilder(queryText: string): Promise { + if (!queryText.trim()) { + return { + id: 'root', + type: 'group', + combinator: 'and', + items: [], + exclude: false, + }; + } + + try { + const parsed = await invoke('parse_search_query', { query: queryText }); + console.log('Parsed query:', queryText); + console.log('Raw parsed:', JSON.stringify(parsed, null, 2)); + const result = exprToGroup(parsed, true); + console.log('Converted to builder:', result); + return result; + } catch (error) { + console.error('Failed to parse query:', error); + return null; + } +} + +function exprToGroup(expr: ParsedExpr, isRoot = false): RuleGroup { + const id = isRoot ? 'root' : crypto.randomUUID(); + + switch (expr.type) { + case 'empty': + return { + id, + type: 'group', + combinator: 'and', + items: [], + exclude: false, + }; + + case 'term': + if (!expr.term) { + return { + id, + type: 'group', + combinator: 'and', + items: [], + exclude: false, + }; + } + const rule = termToRule(expr.term); + return { + id, + type: 'group', + combinator: 'and', + items: rule ? [rule] : [], + exclude: false, + }; + + case 'not': + if (!expr.inner) { + return { + id, + type: 'group', + combinator: 'and', + items: [], + exclude: false, + }; + } + // If NOT wraps a single term, convert to excluded rule + if (expr.inner.type === 'term' && expr.inner.term) { + const rule = termToRule(expr.inner.term); + if (rule) { + rule.exclude = true; + return { + id, + type: 'group', + combinator: 'and', + items: [rule], + exclude: false, + }; + } + } + // Otherwise, negate the entire group + const innerGroup = exprToGroup(expr.inner); + innerGroup.exclude = true; + return isRoot + ? innerGroup + : { + id, + type: 'group', + combinator: 'and', + items: [innerGroup], + exclude: false, + }; + + case 'and': + return { + id, + type: 'group', + combinator: 'and', + items: (expr.parts || []) + .map((part) => { + if (part.type === 'term' && part.term) { + const rule = termToRule(part.term); + return rule || exprToGroup(part); + } + return exprToGroup(part); + }) + .filter(Boolean), + exclude: false, + }; + + case 'or': + return { + id, + type: 'group', + combinator: 'or', + items: (expr.parts || []) + .map((part) => { + if (part.type === 'term' && part.term) { + const rule = termToRule(part.term); + return rule || exprToGroup(part); + } + return exprToGroup(part); + }) + .filter(Boolean), + exclude: false, + }; + + default: + return { + id, + type: 'group', + combinator: 'and', + items: [], + exclude: false, + }; + } +} + +function termToRule(term: ParsedTerm): FilterRule | null { + const id = crypto.randomUUID(); + + switch (term.type) { + case 'word': + case 'phrase': + return { + id, + type: 'rule', + field: 'name', + operator: 'contains', + value: term.text || '', + exclude: false, + }; + + case 'filter': + if (!term.kind) return null; + + // Map filter kind to SearchField + const fieldMap: Record = { + file: 'kind', + folder: 'kind', + ext: 'ext', + type: 'type', + size: 'size', + dm: 'dm', + dc: 'dc', + parent: 'parent', + infolder: 'infolder', + nosubfolders: 'nosubfolders', + content: 'content', + tag: 'tag', + regex: 'regex', + }; + + const field = fieldMap[term.kind]; + if (!field) return null; + + // Special handling for file/folder + let value = term.argument || ''; + if (term.kind === 'file' || term.kind === 'folder') { + value = term.kind; + } + + return { + id, + type: 'rule', + field, + operator: 'contains', + value, + exclude: false, + }; + + case 'regex': + return { + id, + type: 'rule', + field: 'regex', + operator: 'contains', + value: term.pattern || '', + exclude: false, + }; + + default: + return null; + } +} 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..fc48a2d4 100644 --- a/fs-icon/src/lib.rs +++ b/fs-icon/src/lib.rs @@ -23,15 +23,21 @@ 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) { +pub fn icon_of_path(path: &str, requested_size: f64) -> Option> { + // Try QuickLook for images first (optimized path with aspect ratio) + if let Some(data) = icon_of_path_ql(path, requested_size) { return Some(data); } - icon_of_path_ns(path) + // Try generic QuickLook for PDFs and other file types + if let Some(data) = icon_of_path_ql_generic(path, requested_size) { + return Some(data); + } + // 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> { +pub fn icon_of_path_ns(path: &str, requested_size: f64) -> Option> { objc2::rc::autoreleasepool(|_| -> Option> { let path_ns = NSString::from_str(path); let image = NSWorkspace::sharedWorkspace().iconForFile(&path_ns); @@ -41,10 +47,10 @@ pub fn icon_of_path_ns(path: &str) -> Option> { // 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 > (requested_size - 1.0) + && size.height > (requested_size - 1.0) + && size.width < (requested_size + 1.0) + && size.height < (requested_size + 1.0) { // println!("representation: {}x{}", size.width, size.height); let new_image = NSImage::imageWithSize_flipped_drawingHandler( @@ -67,8 +73,8 @@ pub fn icon_of_path_ns(path: &str) -> Option> { } // 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 = requested_size; + let height = requested_size; // keep aspect ratio let old_width = image.size().width; let old_height = image.size().height; @@ -116,16 +122,15 @@ 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)?; +/// Internal helper to generate QuickLook thumbnails +fn generate_ql_thumbnail( + path: &str, + width: f64, + height: f64, + representation_type: QLThumbnailGenerationRequestRepresentationTypes, +) -> Option> { 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 let path_url = NSURL::fileURLWithPath(&NSString::from_str(path)); let generator = unsafe { QLThumbnailGenerator::sharedGenerator() }; { @@ -137,7 +142,7 @@ pub fn icon_of_path_ql(path: &str) -> Option> { &path_url, NSSize::new(width, height), THUMBNAIL_SCALE, - QLThumbnailGenerationRequestRepresentationTypes::LowQualityThumbnail, + representation_type, ); generator.generateBestRepresentationForRequest_completionHandler( &request, @@ -164,6 +169,30 @@ pub fn icon_of_path_ql(path: &str) -> Option> { }) } +/// 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> { + generate_ql_thumbnail( + path, + requested_size, + requested_size, + QLThumbnailGenerationRequestRepresentationTypes::Thumbnail, + ) +} + +pub fn icon_of_path_ql(path: &str, requested_size: f64) -> 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)?; + let (width, height) = scale_with_aspect_ratio(width, height, requested_size, requested_size); + generate_ql_thumbnail( + path, + width, + height, + QLThumbnailGenerationRequestRepresentationTypes::LowQualityThumbnail, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -175,13 +204,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 +221,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 +273,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 _data = icon_of_path_ns(&pwd, 64.0).unwrap(); + let _data = icon_of_path_ql(&pwd, 64.0).unwrap(); } std::thread::sleep(std::time::Duration::from_secs(1)); } @@ -271,11 +300,11 @@ 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..5e014d9f 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"); + let data = icon_of_path(cwd.to_str().unwrap(), 64.0).expect("fallback icon should exist"); assert!(!data.is_empty()); }