diff --git a/.gitignore b/.gitignore index fd531785..2aab5d18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target .DS_Store -.vscode \ No newline at end of file +.vscode +ignored/ diff --git a/Cargo.lock b/Cargo.lock index 6af61aba..ff10bb9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,16 @@ dependencies = [ "objc2", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -414,11 +424,13 @@ name = "fswalk" version = "0.1.0" dependencies = [ "enumn", + "ignore", "memchr", "rayon", "serde", "serde_repr", "tempdir", + "tracing", ] [[package]] @@ -439,6 +451,19 @@ dependencies = [ "wasip2", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hash32" version = "0.2.1" @@ -505,6 +530,22 @@ dependencies = [ "cc", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -1195,6 +1236,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1562,6 +1612,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "was" version = "0.1.0" @@ -1642,6 +1702,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/cardinal-sdk/src/event.rs b/cardinal-sdk/src/event.rs index e17f5a6f..df68e1f5 100644 --- a/cardinal-sdk/src/event.rs +++ b/cardinal-sdk/src/event.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct FsEvent { /// The path of this event. pub path: PathBuf, diff --git a/cardinal-sdk/src/event_stream.rs b/cardinal-sdk/src/event_stream.rs index 1c8905fd..c5f4f5aa 100644 --- a/cardinal-sdk/src/event_stream.rs +++ b/cardinal-sdk/src/event_stream.rs @@ -176,7 +176,9 @@ impl EventWatcher { since_event_id, latency, Box::new(move |events| { - let _ = sender.send(events); + if !events.is_empty() { + let _ = sender.send(events); + } }), ); let dev = stream.dev(); diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index 99a28a31..4661bd43 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -53,6 +53,28 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -91,6 +113,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -120,6 +153,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.5.0" @@ -322,6 +366,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -401,6 +455,7 @@ dependencies = [ "once_cell", "parking_lot", "rayon", + "rfd", "search-cache", "search-cancel", "serde", @@ -875,6 +930,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 +962,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" @@ -1195,10 +1265,12 @@ name = "fswalk" version = "0.1.0" dependencies = [ "enumn", + "ignore", "memchr", "rayon", "serde", "serde_repr", + "tracing", ] [[package]] @@ -1561,6 +1633,19 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1928,6 +2013,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3151,6 +3252,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -3330,6 +3437,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.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3350,6 +3467,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.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3368,6 +3495,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3516,6 +3652,30 @@ 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", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3616,6 +3776,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" @@ -4986,6 +5152,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 +5343,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -6117,6 +6349,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 9c5dbfc4..92a757eb 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ tauri-plugin-prevent-default = "4" tauri-plugin-macos-permissions = "2.3.0" tauri-plugin-window-state = "2" once_cell = { version = "1.20", features = ["parking_lot"] } +rfd = "0.15" cardinal-sdk.path = "../../cardinal-sdk" search-cache.path = "../../search-cache" diff --git a/cardinal/src-tauri/capabilities/default.json b/cardinal/src-tauri/capabilities/default.json index ce8f857a..948467f8 100644 --- a/cardinal/src-tauri/capabilities/default.json +++ b/cardinal/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:app:allow-default-window-icon", "core:window:allow-set-focus", + "core:webview:allow-set-webview-zoom", "opener:default", "macos-permissions:default", "drag:default", diff --git a/cardinal/src-tauri/src/background.rs b/cardinal/src-tauri/src/background.rs index d8d90031..ec7d2a09 100644 --- a/cardinal/src-tauri/src/background.rs +++ b/cardinal/src-tauri/src/background.rs @@ -208,7 +208,9 @@ fn handle_event_watcher_events( history_ready: &mut bool, processed_events: &mut usize, ) { - *processed_events += events.len(); + let visible_events = cache.filter_runtime_events(&events); + + *processed_events += visible_events.len(); emit_status_bar_update( app_handle, @@ -217,8 +219,8 @@ fn handle_event_watcher_events( cache.rescan_count() as usize, ); - let mut snapshots = Vec::with_capacity(events.len()); - for event in events.iter() { + let mut snapshots = Vec::with_capacity(visible_events.len()); + for event in visible_events.iter() { if event.flag == EventFlag::HistoryDone { *history_ready = true; update_app_state(app_handle, AppLifecycleState::Ready); diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 4fcd2a12..1e383a8d 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -22,7 +22,7 @@ use parking_lot::Mutex; use search_cache::{SearchOptions, SearchOutcome, SearchResultNode, SlabIndex, SlabNodeMetadata}; use search_cancel::CancellationToken; use serde::{Deserialize, Serialize}; -use std::{cell::LazyCell, process::Command}; +use std::{cell::LazyCell, fs::OpenOptions, io::Write, process::Command}; use tauri::{ActivationPolicy, AppHandle, State}; use tracing::{error, info, warn}; @@ -143,12 +143,7 @@ impl SearchState { } } -/// Normalizes user-provided path input into an absolute path string. -/// -/// Expands a leading `~` component using the current `HOME` directory and rejects -/// non-absolute paths (including relative paths and unsupported `~user` forms). -/// Returns `Some` absolute path string when valid, otherwise `None`. -fn normalize_path_input(raw: &str) -> Option { +fn expand_path_input(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { return None; @@ -168,12 +163,89 @@ fn normalize_path_input(raw: &str) -> Option { } } - let resolved = expanded.into_string(); - if resolved.starts_with('/') { - Some(resolved) + Some(expanded.into_string()) +} + +/// Normalizes user-provided path input into an absolute path string. +/// +/// Expands a leading `~` component using the current `HOME` directory and rejects +/// non-absolute paths (including relative paths and unsupported `~user` forms). +/// Returns `Some` absolute path string when valid, otherwise `None`. +fn normalize_path_input(raw: &str) -> Option { + let resolved = expand_path_input(raw)?; + resolved.starts_with('/').then_some(resolved) +} + +/// Normalizes ignore entries. +/// +/// Keeps raw entries so matching follows gitignore semantics exactly, +/// including empty lines and comments. A leading `~` is expanded so +/// existing home-relative ignore entries keep matching. +fn normalize_ignore_path_input(raw: &str) -> String { + if (raw == "~" || raw.starts_with("~/")) + && let Some(expanded) = expand_path_input(raw) + { + return expanded; + } + + raw.to_string() +} + +fn strip_multiline_ignore_comments(ignore_paths: Vec) -> Vec { + let mut normalized = Vec::with_capacity(ignore_paths.len()); + let mut in_block_comment = false; + + for path in ignore_paths { + let trimmed = path.trim(); + + if in_block_comment { + if trimmed == "*/" { + in_block_comment = false; + } + continue; + } + + if trimmed == "/*" { + in_block_comment = true; + continue; + } + + normalized.push(path); + } + + normalized +} + +/// Re-anchor legacy absolute ignore entries beneath the watch root so they keep +/// matching after ignore evaluation switched to watch-root-relative gitignore semantics. +fn rewrite_ignore_path_for_watch_root(raw: &str, watch_root: &str) -> String { + if watch_root == "/" { + return raw.to_string(); + } + + let (prefix, pattern) = if let Some(pattern) = raw.strip_prefix('!') { + ("!", pattern) } else { - None + ("", raw) + }; + + if !pattern.starts_with('/') { + return raw.to_string(); } + + let rewritten = if pattern == watch_root || pattern == format!("{watch_root}/") { + "/*".to_string() + } else if let Some(stripped) = pattern.strip_prefix(watch_root) { + if stripped.starts_with('/') { + stripped.to_string() + } else { + return raw.to_string(); + } + } else { + return raw.to_string(); + }; + + format!("{prefix}{rewritten}") } pub(crate) fn normalize_watch_config( @@ -183,15 +255,9 @@ pub(crate) fn normalize_watch_config( ) -> Option<(String, Vec)> { let watch_root = normalize_path_input(watch_root) .or_else(|| fallback_watch_root.and_then(normalize_path_input))?; - let mut ignore_paths = ignore_paths + let mut ignore_paths = strip_multiline_ignore_comments(ignore_paths) .into_iter() - .filter_map(|path| { - let normalized = normalize_path_input(&path); - if normalized.is_none() { - warn!("Ignoring invalid ignore path: {path:?}"); - } - normalized - }) + .map(|path| normalize_ignore_path_input(&path)) .collect::>(); if !ignore_paths .iter() @@ -199,6 +265,9 @@ pub(crate) fn normalize_watch_config( { ignore_paths.push(DEFAULT_SYSTEM_IGNORE_PATH.to_string()); } + for path in &mut ignore_paths { + *path = rewrite_ignore_path_for_watch_root(path, &watch_root); + } Some((watch_root, ignore_paths)) } @@ -420,6 +489,95 @@ pub async fn open_path(path: String) { } } +#[tauri::command] +pub async fn prompt_save_listed_files_tsv( + app: AppHandle, + default_filename: String, +) -> Result, String> { + let (response_tx, response_rx) = bounded::, String>>(1); + + app.run_on_main_thread(move || { + let result = prompt_save_listed_files_tsv_impl(default_filename); + if response_tx.send(result).is_err() { + error!("Failed to send prompt_save_listed_files_tsv response"); + } + }) + .map_err(|e| format!("Failed to dispatch prompt save dialog on main thread: {e:?}"))?; + + response_rx + .recv() + .map_err(|e| format!("Failed to receive prompt_save_listed_files_tsv response: {e:?}"))? +} + +fn normalize_export_default_filename(default_filename: String) -> String { + let fallback_filename = "cardinal-word-list.tsv".to_string(); + let trimmed = default_filename.trim(); + if trimmed.is_empty() { + fallback_filename + } else { + trimmed.to_string() + } +} + +fn prompt_save_listed_files_tsv_impl(default_filename: String) -> Result, String> { + let default_filename = normalize_export_default_filename(default_filename); + + info!( + "Opening listed-files TSV save dialog with default filename: {}", + default_filename + ); + + let dialog = rfd::FileDialog::new() + .add_filter("Tab-separated values", &["tsv"]) + .set_file_name(&default_filename); + let Some(mut path) = dialog.save_file() else { + info!("Listed-files TSV save dialog canceled by user"); + return Ok(None); + }; + + if path.extension().is_none() { + path.set_extension("tsv"); + } + + let path = path.to_string_lossy().into_owned(); + Ok(Some(path)) +} + +#[tauri::command] +pub async fn write_listed_files_tsv(path: String, content: String) -> Result<(), String> { + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&path) + .map_err(|e| format!("Failed to open TSV file for writing {path}: {e}"))?; + file.write_all(content.as_bytes()) + .map_err(|e| format!("Failed to write TSV file to {path}: {e}"))?; + info!("Saved listed-files TSV to {}", path); + Ok(()) +} + +#[tauri::command] +pub async fn append_listed_files_tsv_chunk(path: String, content: String) -> Result<(), String> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|e| format!("Failed to open TSV file for appending {path}: {e}"))?; + file.write_all(content.as_bytes()) + .map_err(|e| format!("Failed appending TSV chunk to {path}: {e}"))?; + Ok(()) +} + +#[tauri::command] +pub async fn remove_listed_files_tsv(path: String) -> Result<(), String> { + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(format!("Failed to remove TSV file {path}: {err}")), + } +} + #[tauri::command] pub async fn start_logic(watch_root: String, ignore_paths: Vec) { if let Some(sender) = LOGIC_START.get() { @@ -542,4 +700,189 @@ mod tests { assert_eq!(normalize_path_input("~someone"), None); assert_eq!(normalize_path_input("~someone/Documents"), None); } + + #[test] + fn normalize_ignore_accepts_absolute_paths_and_globs() { + assert_eq!( + normalize_ignore_path_input("/tmp/cache"), + "/tmp/cache".to_string() + ); + assert_eq!( + normalize_ignore_path_input("**/node_modules/**"), + "**/node_modules/**".to_string() + ); + assert_eq!( + normalize_ignore_path_input("build/*.tmp"), + "build/*.tmp".to_string() + ); + assert_eq!(normalize_ignore_path_input(".cache"), ".cache".to_string()); + assert_eq!( + normalize_ignore_path_input("Library/Biome/"), + "Library/Biome/".to_string() + ); + } + + #[test] + fn normalize_ignore_expands_home_prefixed_paths() { + let Ok(home) = std::env::var("HOME") else { + return; + }; + + assert_eq!(normalize_ignore_path_input("~"), home.clone()); + assert_eq!( + normalize_ignore_path_input("~/Library/Caches"), + format!("{home}/Library/Caches") + ); + } + + #[test] + fn normalize_ignore_keeps_gitignore_syntax_verbatim() { + assert_eq!( + normalize_ignore_path_input("!/important.pyc"), + "!/important.pyc".to_string() + ); + assert_eq!( + normalize_ignore_path_input("# comment"), + "# comment".to_string() + ); + assert_eq!( + normalize_ignore_path_input("relative/path"), + "relative/path".to_string() + ); + assert_eq!( + normalize_ignore_path_input("~someone/**"), + "~someone/**".to_string() + ); + assert_eq!( + normalize_ignore_path_input("../relative/path"), + "../relative/path".to_string() + ); + } + + #[test] + fn normalize_ignore_keeps_empty_and_whitespace_entries() { + assert_eq!(normalize_ignore_path_input(""), String::new()); + assert_eq!(normalize_ignore_path_input(" "), " ".to_string()); + } + + #[test] + fn strip_multiline_ignore_comments_skips_wrapped_lines_and_markers() { + assert_eq!( + strip_multiline_ignore_comments(vec![ + "/tmp/keep".to_string(), + "/*".to_string(), + "/tmp/commented".to_string(), + "# still commented".to_string(), + "*/".to_string(), + "/tmp/after".to_string(), + ]), + vec!["/tmp/keep".to_string(), "/tmp/after".to_string()] + ); + } + + #[test] + fn strip_multiline_ignore_comments_comments_until_end_when_unclosed() { + assert_eq!( + strip_multiline_ignore_comments(vec![ + "/tmp/keep".to_string(), + " /* ".to_string(), + "/tmp/commented".to_string(), + ]), + vec!["/tmp/keep".to_string()] + ); + } + + #[test] + fn normalize_watch_config_keeps_globs_and_adds_default_ignore_path() { + let Some((watch_root, ignore_paths)) = normalize_watch_config( + "/", + vec![ + "**/node_modules/**".to_string(), + String::new(), + "relative/path".to_string(), + "/tmp/cache".to_string(), + ], + None, + ) else { + panic!("watch config should be valid"); + }; + + assert_eq!(watch_root, "/"); + assert!(ignore_paths.contains(&"**/node_modules/**".to_string())); + assert!(ignore_paths.contains(&String::new())); + assert!(ignore_paths.contains(&"relative/path".to_string())); + assert!(ignore_paths.contains(&"/tmp/cache".to_string())); + assert!(ignore_paths.contains(&DEFAULT_SYSTEM_IGNORE_PATH.to_string())); + } + + #[test] + fn normalize_watch_config_drops_multiline_commented_ignore_entries() { + let Some((watch_root, ignore_paths)) = normalize_watch_config( + "/", + vec![ + "/tmp/keep".to_string(), + "/*".to_string(), + "/tmp/commented".to_string(), + "/tmp/also-commented".to_string(), + "*/".to_string(), + "/tmp/after".to_string(), + ], + None, + ) else { + panic!("watch config should be valid"); + }; + + assert_eq!(watch_root, "/"); + assert!(ignore_paths.contains(&"/tmp/keep".to_string())); + assert!(ignore_paths.contains(&"/tmp/after".to_string())); + assert!(!ignore_paths.contains(&"/*".to_string())); + assert!(!ignore_paths.contains(&"*/".to_string())); + assert!(!ignore_paths.contains(&"/tmp/commented".to_string())); + assert!(!ignore_paths.contains(&"/tmp/also-commented".to_string())); + } + + #[test] + fn normalize_watch_config_rewrites_absolute_ignore_entries_under_non_root_watch_root() { + let Some((watch_root, ignore_paths)) = normalize_watch_config( + "/Users/me", + vec![ + "/Users/me/Library/Caches/".to_string(), + "!/Users/me/project/keep.txt".to_string(), + "/Users/other/cache/".to_string(), + ], + None, + ) else { + panic!("watch config should be valid"); + }; + + assert_eq!(watch_root, "/Users/me"); + assert!(ignore_paths.contains(&"/Library/Caches/".to_string())); + assert!(ignore_paths.contains(&"!/project/keep.txt".to_string())); + assert!(ignore_paths.contains(&"/Users/other/cache/".to_string())); + assert!(ignore_paths.contains(&DEFAULT_SYSTEM_IGNORE_PATH.to_string())); + } + + #[test] + fn normalize_watch_config_rewrites_watch_root_self_ignore_to_root_contents() { + let Some((watch_root, ignore_paths)) = + normalize_watch_config("/Users/me", vec!["/Users/me/".to_string()], None) + else { + panic!("watch config should be valid"); + }; + + assert_eq!(watch_root, "/Users/me"); + assert!(ignore_paths.contains(&"/*".to_string())); + } + + #[test] + fn normalize_watch_config_rewrites_default_ignore_under_non_root_watch_root() { + let Some((watch_root, ignore_paths)) = normalize_watch_config("/System", vec![], None) + else { + panic!("watch config should be valid"); + }; + + assert_eq!(watch_root, "/System"); + assert!(ignore_paths.contains(&"/Volumes/".to_string())); + assert!(!ignore_paths.contains(&DEFAULT_SYSTEM_IGNORE_PATH.to_string())); + } } diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index d7b34c60..0384ecb4 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -14,10 +14,12 @@ use background::{ use cardinal_sdk::EventWatcher; use commands::{ NodeInfoRequest, SearchJob, SearchState, WatchConfigUpdate, activate_main_window, - close_quicklook, copy_files_to_clipboard, get_app_status, get_nodes_info, get_sorted_view, - hide_main_window, normalize_watch_config, open_in_finder, open_path, search, + append_listed_files_tsv_chunk, close_quicklook, copy_files_to_clipboard, get_app_status, + get_nodes_info, get_sorted_view, hide_main_window, normalize_watch_config, open_in_finder, + open_path, prompt_save_listed_files_tsv, remove_listed_files_tsv, search, set_tray_activation_policy, set_watch_config, start_logic, toggle_main_window, toggle_quicklook, trigger_rescan, update_icon_viewport, update_quicklook, + write_listed_files_tsv, }; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded, unbounded}; use lifecycle::{ @@ -38,7 +40,7 @@ use window_controls::{activate_window, hide_window}; static DB_PATH: OnceCell = OnceCell::new(); pub(crate) static LOGIC_START: OnceCell> = OnceCell::new(); -pub(crate) const DEFAULT_SYSTEM_IGNORE_PATH: &str = "/System/Volumes/Data"; +pub(crate) const DEFAULT_SYSTEM_IGNORE_PATH: &str = "/System/Volumes/"; const FSE_LATENCY_SECS: f64 = 0.1; #[derive(Debug, Clone)] @@ -132,6 +134,10 @@ pub fn run() -> Result<()> { set_watch_config, open_in_finder, open_path, + prompt_save_listed_files_tsv, + write_listed_files_tsv, + append_listed_files_tsv_chunk, + remove_listed_files_tsv, toggle_quicklook, close_quicklook, update_quicklook, diff --git a/cardinal/src-tauri/tauri.conf.json b/cardinal/src-tauri/tauri.conf.json index b3f61591..d4bd52d5 100644 --- a/cardinal/src-tauri/tauri.conf.json +++ b/cardinal/src-tauri/tauri.conf.json @@ -14,7 +14,8 @@ { "title": "Cardinal", "width": 1200, - "height": 800 + "height": 800, + "zoomHotkeysEnabled": true } ], "security": { diff --git a/cardinal/src/App.css b/cardinal/src/App.css index cbde9aa9..1aa6070a 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -1528,7 +1528,7 @@ button:active:not(:disabled) { .preferences-card { width: 100%; - max-width: 480px; + max-width: 580px; max-height: calc(100vh - 96px); background: var(--color-elevated-bg, var(--color-bg)); color: var(--color-text); @@ -1555,11 +1555,13 @@ button:active:not(:disabled) { display: flex; flex-direction: column; flex: 1; - overflow-y: scroll; + width: 100%; + box-sizing: border-box; + overflow-y: auto; overflow-x: hidden; - padding-right: 6px; + padding-right: 0px; min-height: 0; - scrollbar-gutter: stable; + scrollbar-gutter: auto; scrollbar-width: auto; } @@ -1656,7 +1658,7 @@ button:active:not(:disabled) { .preferences-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; - align-items: center; + align-items: start; gap: 16px; padding: 5px 0; } @@ -1667,14 +1669,66 @@ button:active:not(:disabled) { white-space: nowrap; } +.preferences-help-text { + margin-top: 4px; + width: 100%; + color: var(--color-text-secondary); + font-size: 1rem; + line-height: 1.35; + white-space: normal; +} + +.preferences-help-panel { + width: 100%; + margin-top: 8px; + padding: 10px 12px; + border: 1px solid var(--color-elevated-border, var(--color-border)); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); +} + +.preferences-help-list { + margin: 8px 0 0; + padding-left: 18px; +} + +.preferences-help-list .preferences-help-text { + margin-top: 6px; +} + .preferences-row__details { min-width: 0; } +.preferences-row--stacked { + grid-template-columns: minmax(0, 1fr); +} + +.preferences-row__details--inline { + display: flex; + align-items: center; + gap: 8px; +} + +.preferences-row__details--inline .preferences-label { + margin: 0; +} + .preferences-control { text-align: right; } +.preferences-control--full-width { + width: 100%; +} + +.preferences-control--stack { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; +} + .preferences-field { border: 1px solid var(--color-elevated-border, var(--color-border)); border-radius: 8px; @@ -1700,10 +1754,10 @@ button:active:not(:disabled) { } .preferences-textarea { - width: 240px; - min-height: 88px; + width: 100%; + min-height: 230px; padding: 8px 10px; - font-size: 0.9rem; + font-size: 1rem; line-height: 1.4; resize: vertical; } @@ -1712,6 +1766,60 @@ button:active:not(:disabled) { margin-bottom: 0; } +.preferences-ignore-reset { + border: 1px solid var(--color-elevated-border, var(--color-border)); + background: transparent; + color: var(--color-text-secondary); + border-radius: 999px; + padding: 4px 6px; + font-size: 0.82rem; + cursor: pointer; + transition: + border-color 0.2s ease, + color 0.2s ease, + background-color 0.2s ease; +} + +.preferences-ignore-reset:hover { + border-color: var(--color-accent); + color: var(--color-text); + background: rgba(var(--color-accent-rgb), 0.08); +} + +.preferences-ignore-reset:focus-visible { + outline: 2px solid rgba(var(--color-accent-rgb), 0.35); + outline-offset: 2px; +} + +.preferences-ignore-info { + border: 1px solid var(--color-elevated-border, var(--color-border)); + background: transparent; + color: var(--color-text-secondary); + border-radius: 999px; + width: 24px; + height: 24px; + padding: 0; + font-size: 0.82rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: + border-color 0.2s ease, + color 0.2s ease, + background-color 0.2s ease; +} + +.preferences-ignore-info:hover { + border-color: var(--color-accent); + color: var(--color-text); + background: rgba(var(--color-accent-rgb), 0.08); +} + +.preferences-ignore-info:focus-visible { + outline: 2px solid rgba(var(--color-accent-rgb), 0.35); + outline-offset: 2px; +} + .preferences-field:focus { outline: 2px solid rgba(var(--color-accent-rgb), 0.35); border-color: var(--color-accent); diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index f98ccbe2..584f7701 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -1,6 +1,7 @@ import { useRef, useCallback, useEffect, useMemo } from 'react'; import type { ChangeEvent, CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; import './App.css'; +import { invoke } from '@tauri-apps/api/core'; import { FileRow } from './components/FileRow'; import { SearchBar } from './components/SearchBar'; import { FilesTabContent } from './components/FilesTabContent'; @@ -30,6 +31,14 @@ import { useAppPreferences } from './hooks/useAppPreferences'; import { useAppWindowListeners } from './hooks/useAppWindowListeners'; import { useFilesTabEffects } from './hooks/useFilesTabEffects'; import { useFilesTabState } from './hooks/useFilesTabState'; +import { + buildListedFilesTsvHeader, + buildListedFilesTsvRows, + createListedFilesTsvFilename, +} from './utils/exportListedFilesTsv'; +import type { NodeInfoResponse } from './types/search'; + +const EXPORT_BATCH_SIZE = 1000; function App() { const { @@ -246,6 +255,66 @@ function App() { [showEventsContextMenu], ); + const handleExportListedFilesTsv = useCallback(async () => { + if (activeTab !== 'files' || displayedResults.length === 0) { + return; + } + + let savePath: string | null = null; + try { + savePath = await invoke('prompt_save_listed_files_tsv', { + defaultFilename: createListedFilesTsvFilename(currentQuery), + }); + if (!savePath) { + return; + } + + const labels = { + filename: t('columns.filename'), + path: t('columns.path'), + size: t('columns.size'), + modified: t('columns.modified'), + created: t('columns.created'), + } as const; + + const header = buildListedFilesTsvHeader(labels); + await invoke('write_listed_files_tsv', { + path: savePath, + content: `${header}\n`, + }); + + for (let start = 0; start < displayedResults.length; start += EXPORT_BATCH_SIZE) { + const batch = displayedResults.slice(start, start + EXPORT_BATCH_SIZE); + const fetched = await invoke('get_nodes_info', { + results: batch, + includeIcons: false, + }); + const rowsChunk = buildListedFilesTsvRows(fetched); + if (!rowsChunk) { + continue; + } + const isLastBatch = start + EXPORT_BATCH_SIZE >= displayedResults.length; + const chunkContent = isLastBatch ? rowsChunk : `${rowsChunk}\n`; + await invoke('append_listed_files_tsv_chunk', { + path: savePath, + content: chunkContent, + }); + } + } catch (error) { + console.error('Failed to export listed files as TSV', error); + if (savePath) { + try { + await invoke('remove_listed_files_tsv', { path: savePath }); + } catch (cleanupError) { + console.error('Failed to clean up partial TSV export', cleanupError); + } + } + if (typeof window !== 'undefined') { + window.alert(t('exportListedFiles.failed')); + } + } + }, [activeTab, currentQuery, displayedResults, t]); + const renderRow = useCallback( (rowIndex: number, item: SearchResultItem | undefined, rowStyle: CSSProperties) => { if (!item) { @@ -391,6 +460,8 @@ function App() { onTabChange={onTabChange} onRequestRescan={requestRescan} rescanErrorCount={rescanErrors} + canExportListedFiles={activeTab === 'files' && displayedResults.length > 0} + onRequestExportListedFilesTsv={handleExportListedFilesTsv} /> ({ @@ -9,6 +9,11 @@ const mocks = vi.hoisted(() => ({ showEventsContextMenu: vi.fn(), selectSingleRow: vi.fn(), useContextMenuMock: vi.fn(), + invoke: vi.fn(), + buildListedFilesTsvHeader: vi.fn(), + buildListedFilesTsvRows: vi.fn(), + createListedFilesTsvFilename: vi.fn(), + alert: vi.fn(), })); const testState = vi.hoisted(() => ({ @@ -27,6 +32,10 @@ vi.mock('react-i18next', () => ({ }), })); +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => mocks.invoke(...args), +})); + vi.mock('../components/SearchBar', () => ({ SearchBar: ({ inputRef }: { inputRef: React.Ref }) => ( @@ -79,7 +88,17 @@ vi.mock('../components/PreferencesOverlay', () => ({ })); vi.mock('../components/StatusBar', () => ({ - default: () => null, + default: ({ + onRequestExportListedFilesTsv, + }: { + onRequestExportListedFilesTsv?: () => Promise | void; + }) => ( + + + + {isIgnorePathsHelpOpen ? ( +
+

{t('ignorePaths.helpIntro')}

+
    +
  • {t('ignorePaths.helpComment')}
  • +
  • {t('ignorePaths.helpSingleStar')}
  • +
  • {t('ignorePaths.helpDoubleStar')}
  • +
  • {t('ignorePaths.helpAnchoring')}
  • +
+
+ ) : null} -
+