diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e7e9935 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "cmdr": { + "type": "http", + "url": "http://127.0.0.1:9224/mcp" + }, + "tauri": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@hypothesi/tauri-mcp-server" + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc04c6a..5289780 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,6 +126,8 @@ This snippet will likely come handy: } ``` +Or add it via CLI like: + Since the agent shares the context with your IDE/client, enabling the MCP server makes the tools available to the agent automatically. diff --git a/apps/desktop/coverage-allowlist.json b/apps/desktop/coverage-allowlist.json index c0b0fb8..7c1af46 100644 --- a/apps/desktop/coverage-allowlist.json +++ b/apps/desktop/coverage-allowlist.json @@ -10,6 +10,9 @@ "file-explorer/FileIcon.svelte": { "reason": "Simple UI component, display only" }, "file-explorer/FilePane.svelte": { "reason": "Tested in integration.test.ts, complex component" }, "file-explorer/FullList.svelte": { "reason": "Logic tested in full-list-utils.ts, component mounting heavy" }, + "file-explorer/full-list-utils.ts": { + "reason": "measureDateColumnWidth depends on Canvas API for text measurement" + }, "file-explorer/NewFolderDialog.svelte": { "reason": "UI modal, logic tested in new-folder-utils.test.ts" }, "file-explorer/NetworkBrowser.svelte": { "reason": "Network component, needs Tauri integration" }, "file-explorer/PaneResizer.svelte": { "reason": "Mouse drag UI component, difficult to unit test" }, @@ -30,7 +33,40 @@ "network-store.svelte.ts": { "reason": "Depends on Tauri APIs" }, "onboarding/FullDiskAccessPrompt.svelte": { "reason": "UI component" }, "settings-store.ts": { "reason": "Depends on Tauri store APIs" }, + "settings/components/SettingNumberInput.svelte": { "reason": "UI component, logic is simple" }, + "settings/components/SettingRadioGroup.svelte": { "reason": "UI component, logic is simple" }, + "settings/components/SectionSummary.svelte": { "reason": "UI component, simple navigation cards" }, + "settings/format-utils.ts": { "reason": "Simple date formatting, low complexity" }, + "settings/network-settings.ts": { "reason": "Thin wrapper over getSetting, requires mocking settings store" }, + "settings/reactive-settings.svelte.ts": { "reason": "Svelte reactive state, depends on settings store" }, + "settings/settings-applier.ts": { "reason": "Depends on DOM APIs (document.documentElement.style)" }, + "settings/components/SettingRow.svelte": { "reason": "UI component, layout only" }, + "settings/components/SettingSelect.svelte": { "reason": "UI component, logic is simple" }, + "settings/components/SettingSlider.svelte": { "reason": "UI component, logic is simple" }, + "settings/components/SettingSwitch.svelte": { "reason": "UI component, logic is simple" }, + "settings/components/SettingToggleGroup.svelte": { "reason": "UI component, logic is simple" }, + "settings/components/SettingsContent.svelte": { "reason": "UI layout component" }, + "settings/components/SettingsSidebar.svelte": { "reason": "UI component, search logic tested via registry" }, + "settings/sections/AdvancedSection.svelte": { "reason": "UI section, depends on Tauri APIs" }, + "settings/sections/AppearanceSection.svelte": { "reason": "UI section, simple rendering" }, + "settings/sections/FileOperationsSection.svelte": { "reason": "UI section, simple rendering" }, + "settings/sections/KeyboardShortcutsSection.svelte": { + "reason": "UI section, logic tested in shortcuts/*.test.ts" + }, + "settings/sections/LoggingSection.svelte": { "reason": "UI section, simple rendering" }, + "settings/mcp-settings-bridge.ts": { "reason": "MCP bridge, depends on Tauri APIs and events" }, + "settings/sections/McpServerSection.svelte": { "reason": "UI section, depends on Tauri APIs" }, + "settings/sections/NetworkSection.svelte": { "reason": "UI section, simple rendering" }, + "settings/sections/ThemesSection.svelte": { "reason": "UI section, simple rendering" }, + "settings/sections/UpdatesSection.svelte": { "reason": "UI section, simple rendering" }, + "settings/settings-store.ts": { "reason": "Depends on Tauri store APIs" }, + "settings/settings-window.ts": { "reason": "Depends on Tauri window APIs" }, + "shortcuts/conflict-detector.ts": { "reason": "Logic tested via shortcuts.test.ts" }, + "shortcuts/mcp-shortcuts-listener.ts": { "reason": "MCP listener, depends on Tauri events" }, + "shortcuts/keyboard-handler.ts": { "reason": "DOM event handling, tested via integration" }, + "shortcuts/shortcuts-store.ts": { "reason": "Depends on Tauri store APIs" }, "tauri-commands.ts": { "reason": "Tauri command wrappers, tested via integration" }, + "utils/confirm-dialog.ts": { "reason": "Thin wrapper over Tauri dialog API" }, "updater.svelte.ts": { "reason": "Depends on Tauri updater APIs" }, "window-state.ts": { "reason": "Depends on Tauri window APIs" }, "write-operations/CopyDialog.svelte": { "reason": "UI modal, logic tested in copy-dialog-utils.test.ts" }, diff --git a/apps/desktop/knip.json b/apps/desktop/knip.json index 3ff6e4f..32a5198 100644 --- a/apps/desktop/knip.json +++ b/apps/desktop/knip.json @@ -6,7 +6,11 @@ "src/routes/+layout.ts", "src/lib/benchmark.ts", "src/lib/tauri-commands.ts", - "src/lib/licensing-store.svelte.ts" + "src/lib/licensing-store.svelte.ts", + "src/lib/settings/settings-store.ts", + "src/lib/settings/settings-window.ts", + "src/lib/settings/types.ts", + "src/lib/shortcuts/**" ], "ignoreDependencies": [ "@tauri-apps/cli", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index da2afb9..48c3606 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -30,11 +30,13 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@ark-ui/svelte": "^5.15.0", "@crabnebula/tauri-plugin-drag": "^2.1.0", "@leeoniya/ufuzzy": "^1.0.19", "@logtape/logtape": "^2.0.0", "@lottiefiles/dotlottie-svelte": "^0.8.12", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 7854ff1..34a0c07 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -239,6 +239,27 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +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" @@ -1031,6 +1052,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", "tauri-plugin-drag", "tauri-plugin-fs", "tauri-plugin-mcp-bridge", @@ -1609,6 +1631,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2 0.6.2", + "libc", "objc2 0.6.3", ] @@ -1623,6 +1647,15 @@ dependencies = [ "syn 2.0.112", ] +[[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" @@ -5602,6 +5635,31 @@ dependencies = [ "subtle", ] +[[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 0.18.1", + "gobject-sys 0.18.0", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "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 = "rgb" version = "0.8.52" @@ -5799,6 +5857,12 @@ dependencies = [ "syn 2.0.112", ] +[[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" @@ -6826,6 +6890,24 @@ dependencies = [ "thiserror 2.0.17", ] +[[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" @@ -7246,6 +7328,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -7861,6 +7944,7 @@ dependencies = [ "cc", "downcast-rs", "rustix", + "scoped-tls", "smallvec", "wayland-sys", ] @@ -7919,6 +8003,8 @@ version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ + "dlib", + "log", "pkg-config", ] @@ -8862,6 +8948,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -9059,6 +9146,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.14", "zvariant_derive", "zvariant_utils", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 6f68d92..dcfca25 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-mcp-bridge = "0.6" tauri-plugin-store = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" notify = "8" diff --git a/apps/desktop/src-tauri/benches/icon_benchmarks.rs b/apps/desktop/src-tauri/benches/icon_benchmarks.rs index 8a618c2..aaf792c 100644 --- a/apps/desktop/src-tauri/benches/icon_benchmarks.rs +++ b/apps/desktop/src-tauri/benches/icon_benchmarks.rs @@ -46,7 +46,8 @@ fn bench_icon_fetching(c: &mut Criterion) { BenchmarkId::new("refresh_directory", count), &(paths.clone(), extensions.clone()), |b, (dir_paths, exts)| { - b.iter(|| cmdr_lib::icons::refresh_icons_for_directory(dir_paths.clone(), exts.clone())) + // Benchmark with app icons enabled (the more expensive path) + b.iter(|| cmdr_lib::icons::refresh_icons_for_directory(dir_paths.clone(), exts.clone(), true)) }, ); } diff --git a/apps/desktop/src-tauri/capabilities/settings.json b/apps/desktop/src-tauri/capabilities/settings.json new file mode 100644 index 0000000..c1082c0 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/settings.json @@ -0,0 +1,16 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "settings", + "description": "Capability for the settings window", + "windows": [ + "settings" + ], + "permissions": [ + "core:window:allow-close", + "core:event:default", + "core:app:allow-set-app-theme", + "core:webview:allow-internal-toggle-devtools", + "store:default", + "dialog:allow-ask" + ] +} diff --git a/apps/desktop/src-tauri/src/commands/file_system.rs b/apps/desktop/src-tauri/src/commands/file_system.rs index 61b9607..4cb5d86 100644 --- a/apps/desktop/src-tauri/src/commands/file_system.rs +++ b/apps/desktop/src-tauri/src/commands/file_system.rs @@ -392,7 +392,7 @@ pub fn cancel_write_operation(operation_id: String, rollback: bool) { /// the actual copy operation. /// /// # Events emitted -/// * `scan-preview-progress` - Every 100ms with current counts +/// * `scan-preview-progress` - Based on progress_interval_ms setting /// * `scan-preview-complete` - When scanning finishes /// * `scan-preview-error` - On error /// * `scan-preview-cancelled` - If cancelled @@ -402,15 +402,18 @@ pub fn cancel_write_operation(operation_id: String, rollback: bool) { /// * `sources` - List of source file/directory paths. Supports tilde expansion (~). /// * `sort_column` - Column to sort files by. /// * `sort_order` - Sort order (ascending/descending). +/// * `progress_interval_ms` - Progress update interval in milliseconds (default: 500). #[tauri::command] pub fn start_scan_preview( app: tauri::AppHandle, sources: Vec, sort_column: SortColumn, sort_order: SortOrder, + progress_interval_ms: Option, ) -> ScanPreviewStartResult { let sources: Vec = sources.iter().map(|s| PathBuf::from(expand_tilde(s))).collect(); - ops_start_scan_preview(app, sources, sort_column, sort_order) + let progress_interval = progress_interval_ms.unwrap_or(500); + ops_start_scan_preview(app, sources, sort_column, sort_order, progress_interval) } /// Cancels a running scan preview. diff --git a/apps/desktop/src-tauri/src/commands/icons.rs b/apps/desktop/src-tauri/src/commands/icons.rs index 21b73d3..0f63b61 100644 --- a/apps/desktop/src-tauri/src/commands/icons.rs +++ b/apps/desktop/src-tauri/src/commands/icons.rs @@ -6,15 +6,33 @@ use std::collections::HashMap; /// Gets icon data URLs for the requested icon IDs. /// Returns a map of icon_id -> base64 WebP data URL. /// Only fetches icons not already cached; clients should cache returned icons. +/// +/// When `use_app_icons_for_documents` is true and on macOS, extension-based icons +/// are fetched from app bundles (showing the app's icon as fallback). When false, +/// the system's default document icons are used (Finder-style with app badge). #[tauri::command] -pub fn get_icons(icon_ids: Vec) -> HashMap { - icons::get_icons(icon_ids) +pub fn get_icons(icon_ids: Vec, use_app_icons_for_documents: bool) -> HashMap { + icons::get_icons(icon_ids, use_app_icons_for_documents) } /// Refreshes icons for a directory listing. /// Fetches icons in parallel for all directories and extensions. /// Returns all fetched icons (frontend can compare with cache to detect changes). +/// +/// When `use_app_icons_for_documents` is true, falls back to app icons for files without +/// document-specific icons. When false, uses Finder-style document icons. #[tauri::command] -pub fn refresh_directory_icons(directory_paths: Vec, extensions: Vec) -> HashMap { - icons::refresh_icons_for_directory(directory_paths, extensions) +pub fn refresh_directory_icons( + directory_paths: Vec, + extensions: Vec, + use_app_icons_for_documents: bool, +) -> HashMap { + icons::refresh_icons_for_directory(directory_paths, extensions, use_app_icons_for_documents) +} + +/// Clears cached extension icons. +/// Called when the "use app icons for documents" setting changes. +#[tauri::command] +pub fn clear_extension_icon_cache() { + icons::clear_extension_icon_cache(); } diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index d120d4e..942eb78 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod icons; pub mod licensing; #[cfg(target_os = "macos")] pub mod network; +pub mod settings; pub mod sync_status; // Has both macOS and non-macOS implementations pub mod ui; #[cfg(target_os = "macos")] diff --git a/apps/desktop/src-tauri/src/commands/network.rs b/apps/desktop/src-tauri/src/commands/network.rs index e16def1..21bba19 100644 --- a/apps/desktop/src-tauri/src/commands/network.rs +++ b/apps/desktop/src-tauri/src/commands/network.rs @@ -59,7 +59,7 @@ pub async fn resolve_host(host_id: String) -> Option { /// Lists shares available on a network host. /// -/// Returns cached results if available (30 second TTL), otherwise queries the host. +/// Returns cached results if available, otherwise queries the host. /// Attempts guest access first; returns an error if authentication is required. /// /// # Arguments @@ -67,23 +67,52 @@ pub async fn resolve_host(host_id: String) -> Option { /// * `hostname` - Hostname to connect to (for example, "TEST_SERVER.local") /// * `ip_address` - Optional resolved IP address (preferred over hostname for reliability) /// * `port` - SMB port (default 445, but Docker containers may use different ports) +/// * `timeout_ms` - Optional timeout in milliseconds (default: 15000) +/// * `cache_ttl_ms` - Optional cache TTL in milliseconds (default: 30000) #[tauri::command] pub async fn list_shares_on_host( host_id: String, hostname: String, ip_address: Option, port: u16, + timeout_ms: Option, + cache_ttl_ms: Option, ) -> Result { - smb_client::list_shares(&host_id, &hostname, ip_address.as_deref(), port, None).await + smb_client::list_shares( + &host_id, + &hostname, + ip_address.as_deref(), + port, + None, + timeout_ms, + cache_ttl_ms, + ) + .await } /// Prefetches shares for a host (for example, on hover). /// Same as list_shares_on_host but designed for prefetching - errors are silently ignored. /// Returns immediately if shares are already cached. #[tauri::command] -pub async fn prefetch_shares(host_id: String, hostname: String, ip_address: Option, port: u16) { +pub async fn prefetch_shares( + host_id: String, + hostname: String, + ip_address: Option, + port: u16, + timeout_ms: Option, + cache_ttl_ms: Option, +) { // Fire and forget - we don't care about the result for prefetching - let _ = smb_client::list_shares(&host_id, &hostname, ip_address.as_deref(), port, None).await; + let _ = smb_client::list_shares( + &host_id, + &hostname, + ip_address.as_deref(), + port, + None, + timeout_ms, + cache_ttl_ms, + ) + .await; } /// Gets auth mode detected for a host (from cached share list if available). @@ -189,7 +218,13 @@ pub fn delete_smb_credentials(server: String, share: Option) -> Result<( /// * `port` - SMB port /// * `username` - Username for authentication (or None for guest) /// * `password` - Password for authentication (or None for guest) +/// * `timeout_ms` - Optional timeout in milliseconds (default: 15000) +/// * `cache_ttl_ms` - Optional cache TTL in milliseconds (default: 30000) #[tauri::command] +#[allow( + clippy::too_many_arguments, + reason = "Tauri command requires all parameters to be top-level" +)] pub async fn list_shares_with_credentials( host_id: String, hostname: String, @@ -197,6 +232,8 @@ pub async fn list_shares_with_credentials( port: u16, username: Option, password: Option, + timeout_ms: Option, + cache_ttl_ms: Option, ) -> Result { let credentials = match (username, password) { (Some(u), Some(p)) => Some((u, p)), @@ -209,6 +246,8 @@ pub async fn list_shares_with_credentials( ip_address.as_deref(), port, credentials.as_ref().map(|(u, p)| (u.as_str(), p.as_str())), + timeout_ms, + cache_ttl_ms, ) .await } @@ -228,6 +267,7 @@ use crate::network::mount::{self, MountError, MountResult}; /// * `share` - Name of the share to mount /// * `username` - Optional username for authentication /// * `password` - Optional password for authentication +/// * `timeout_ms` - Optional timeout in milliseconds (default: 20000) /// /// # Returns /// * `Ok(MountResult)` - Mount successful, with path to mount point @@ -238,6 +278,7 @@ pub async fn mount_network_share( share: String, username: Option, password: Option, + timeout_ms: Option, ) -> Result { - mount::mount_share(server, share, username, password).await + mount::mount_share(server, share, username, password, timeout_ms).await } diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000..f34febf --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -0,0 +1,131 @@ +//! Settings-related commands. + +use std::net::TcpListener; + +use tauri::{AppHandle, Manager}; + +use crate::file_system::update_debounce_ms; +use crate::menu::{MenuState, frontend_shortcut_to_accelerator, update_view_mode_accelerator}; +#[cfg(target_os = "macos")] +use crate::network::bonjour::update_resolve_timeout; + +/// Check if a port is available for binding. +#[tauri::command] +pub fn check_port_available(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).is_ok() +} + +/// Find an available port starting from the given port. +/// Scans up to 100 ports from the start port. +#[tauri::command] +pub fn find_available_port(start_port: u16) -> Option { + for offset in 0..100 { + let port = start_port.saturating_add(offset); + if check_port_available(port) { + return Some(port); + } + } + None +} + +/// Updates the file watcher debounce duration in milliseconds. +/// This affects newly created watchers; existing watchers keep their original duration. +#[tauri::command] +pub fn update_file_watcher_debounce(debounce_ms: u64) { + update_debounce_ms(debounce_ms); +} + +/// Updates the Bonjour service resolve timeout in milliseconds. +/// This affects future service resolutions; ongoing resolutions keep their original timeout. +#[cfg(target_os = "macos")] +#[tauri::command] +pub fn update_service_resolve_timeout(timeout_ms: u64) { + update_resolve_timeout(timeout_ms); +} + +/// Stub for non-macOS platforms - network discovery is not supported. +#[cfg(not(target_os = "macos"))] +#[tauri::command] +pub fn update_service_resolve_timeout(_timeout_ms: u64) { + // No-op on non-macOS platforms +} + +/// Update menu accelerator for a command. +/// Called from frontend when keyboard shortcuts are changed. +/// Currently supports: view.fullMode, view.briefMode +#[tauri::command] +pub fn update_menu_accelerator(app: AppHandle, command_id: &str, shortcut: &str) -> Result<(), String> { + let menu_state = app.state::>(); + + // Convert frontend shortcut format to Tauri accelerator format + let accelerator = frontend_shortcut_to_accelerator(shortcut); + + match command_id { + "view.fullMode" => { + // Get current checked state before updating + let is_checked = menu_state + .view_mode_full + .lock() + .unwrap() + .as_ref() + .and_then(|item| item.is_checked().ok()) + .unwrap_or(false); + + let new_item = update_view_mode_accelerator(&app, &menu_state, true, accelerator.as_deref(), is_checked) + .map_err(|e| format!("Failed to update Full view accelerator: {e}"))?; + + // Update the reference in MenuState + *menu_state.view_mode_full.lock().unwrap() = Some(new_item); + Ok(()) + } + "view.briefMode" => { + // Get current checked state before updating + let is_checked = menu_state + .view_mode_brief + .lock() + .unwrap() + .as_ref() + .and_then(|item| item.is_checked().ok()) + .unwrap_or(true); + + let new_item = update_view_mode_accelerator(&app, &menu_state, false, accelerator.as_deref(), is_checked) + .map_err(|e| format!("Failed to update Brief view accelerator: {e}"))?; + + // Update the reference in MenuState + *menu_state.view_mode_brief.lock().unwrap() = Some(new_item); + Ok(()) + } + _ => { + // Silently succeed for commands that don't have menu items + // This allows the frontend to call this for all shortcuts without errors + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_check_port_available() { + // Port 0 should let OS pick an available port, so this should succeed + // But we test a high port that's likely free + let result = check_port_available(49999); + // The function should return a valid boolean (either true or false) + // This test verifies the function executes without panic + let _ = result; + } + + #[test] + fn test_find_available_port() { + // Should find some available port + let result = find_available_port(49000); + // On most systems, we should find an available port in the high range + assert!(result.is_some()); + if let Some(port) = result { + assert!(port >= 49000); + assert!(port < 49100); + } + } +} diff --git a/apps/desktop/src-tauri/src/file_system/mod.rs b/apps/desktop/src-tauri/src/file_system/mod.rs index 33ca164..af17d8f 100644 --- a/apps/desktop/src-tauri/src/file_system/mod.rs +++ b/apps/desktop/src-tauri/src/file_system/mod.rs @@ -41,7 +41,7 @@ pub use volume::{InMemoryVolume, LocalPosixVolume, Volume, VolumeError}; #[allow(unused_imports, reason = "Public API re-exports for future use")] pub use volume_manager::VolumeManager; // Watcher management - init_watcher_manager must be called from lib.rs -pub use watcher::init_watcher_manager; +pub use watcher::{init_watcher_manager, update_debounce_ms}; // Re-export write operation types pub use write_operations::{ OperationStatus, OperationSummary, WriteOperationConfig, WriteOperationError, WriteOperationStartResult, diff --git a/apps/desktop/src-tauri/src/file_system/watcher.rs b/apps/desktop/src-tauri/src/file_system/watcher.rs index 33deab1..4720acb 100644 --- a/apps/desktop/src-tauri/src/file_system/watcher.rs +++ b/apps/desktop/src-tauri/src/file_system/watcher.rs @@ -16,8 +16,23 @@ use tauri::{AppHandle, Emitter}; use super::operations::{FileEntry, get_listing_entries, list_directory_core, update_listing_entries}; -/// Debounce duration in milliseconds -const DEBOUNCE_MS: u64 = 200; +/// Default debounce duration in milliseconds (used if not configured) +const DEFAULT_DEBOUNCE_MS: u64 = 200; + +/// Configured debounce duration in milliseconds (set by frontend via update_debounce_ms) +static DEBOUNCE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(DEFAULT_DEBOUNCE_MS); + +/// Updates the file watcher debounce duration. +/// This affects newly started watchers; existing watchers keep their original duration. +pub fn update_debounce_ms(ms: u64) { + DEBOUNCE_MS.store(ms, std::sync::atomic::Ordering::Relaxed); + log::debug!("File watcher debounce updated to {} ms", ms); +} + +/// Gets the current debounce duration in milliseconds. +fn get_debounce_ms() -> u64 { + DEBOUNCE_MS.load(std::sync::atomic::Ordering::Relaxed) +} /// Global watcher manager static WATCHER_MANAGER: LazyLock> = LazyLock::new(|| RwLock::new(WatcherManager::new())); @@ -88,8 +103,9 @@ pub fn start_watching(listing_id: &str, path: &Path) -> Result<(), String> { let listing_for_closure = listing_id_owned.clone(); // Create the debouncer with a callback that handles changes + let debounce_duration = Duration::from_millis(get_debounce_ms()); let mut debouncer = new_debouncer( - Duration::from_millis(DEBOUNCE_MS), + debounce_duration, None, // No tick rate limit move |result: DebounceEventResult| { if let Ok(_events) = result { diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/copy.rs b/apps/desktop/src-tauri/src/file_system/write_operations/copy.rs index 769b85d..6ffd166 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/copy.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/copy.rs @@ -52,6 +52,7 @@ pub(super) fn copy_files_with_progress( operation_id, WriteOperationType::Copy, state.progress_interval, + config.max_conflicts_to_show, )? { return Ok(()); } diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/move_op.rs b/apps/desktop/src-tauri/src/file_system/write_operations/move_op.rs index f430927..afe9bd5 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/move_op.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/move_op.rs @@ -37,6 +37,7 @@ pub(super) fn move_files_with_progress( operation_id, WriteOperationType::Move, state.progress_interval, + config.max_conflicts_to_show, )? { return Ok(()); } diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/scan.rs b/apps/desktop/src-tauri/src/file_system/write_operations/scan.rs index 7c22841..7f2413e 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/scan.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/scan.rs @@ -16,9 +16,9 @@ use super::state::{ WriteOperationState, update_operation_status, }; use super::types::{ - ConflictInfo, MAX_CONFLICTS_IN_RESULT, ScanPreviewCancelledEvent, ScanPreviewCompleteEvent, ScanPreviewErrorEvent, - ScanPreviewProgressEvent, ScanPreviewStartResult, ScanProgressEvent, SortColumn, SortOrder, WriteOperationError, - WriteOperationPhase, WriteOperationType, WriteProgressEvent, + ConflictInfo, ScanPreviewCancelledEvent, ScanPreviewCompleteEvent, ScanPreviewErrorEvent, ScanPreviewProgressEvent, + ScanPreviewStartResult, ScanProgressEvent, SortColumn, SortOrder, WriteOperationError, WriteOperationPhase, + WriteOperationType, WriteProgressEvent, }; // ============================================================================ @@ -32,13 +32,14 @@ pub fn start_scan_preview( sources: Vec, sort_column: SortColumn, sort_order: SortOrder, + progress_interval_ms: u64, ) -> ScanPreviewStartResult { let preview_id = Uuid::new_v4().to_string(); let preview_id_clone = preview_id.clone(); let state = Arc::new(ScanPreviewState { cancelled: AtomicBool::new(false), - progress_interval: Duration::from_millis(100), + progress_interval: Duration::from_millis(progress_interval_ms), }); // Register state @@ -668,6 +669,7 @@ pub(super) fn handle_dry_run( operation_id: &str, operation_type: WriteOperationType, progress_interval: Duration, + max_conflicts_to_show: usize, ) -> Result { use super::types::DryRunResult; use tauri::Emitter; @@ -687,7 +689,7 @@ pub(super) fn handle_dry_run( )?; let conflicts_count = scan_result.conflicts.len(); - let (sampled_conflicts, conflicts_sampled) = sample_conflicts(scan_result.conflicts, MAX_CONFLICTS_IN_RESULT); + let (sampled_conflicts, conflicts_sampled) = sample_conflicts(scan_result.conflicts, max_conflicts_to_show); let result = DryRunResult { operation_id: operation_id.to_string(), diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/types.rs b/apps/desktop/src-tauri/src/file_system/write_operations/types.rs index 16f4716..188a28d 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/types.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/types.rs @@ -181,7 +181,9 @@ pub struct DryRunResult { pub conflicts_sampled: bool, } -/// Maximum number of conflicts to include in DryRunResult +/// Legacy constant, kept for backward compatibility. +/// The actual value is now configurable via WriteOperationConfig.max_conflicts_to_show. +#[allow(dead_code, reason = "Kept for backward compatibility")] pub const MAX_CONFLICTS_IN_RESULT: usize = 200; // ============================================================================ @@ -396,6 +398,9 @@ pub struct WriteOperationConfig { /// Preview scan ID to reuse cached scan results (from start_scan_preview) #[serde(default)] pub preview_id: Option, + /// Maximum number of conflicts to include in DryRunResult (default: 100) + #[serde(default = "default_max_conflicts_to_show")] + pub max_conflicts_to_show: usize, } impl Default for WriteOperationConfig { @@ -408,6 +413,7 @@ impl Default for WriteOperationConfig { sort_column: SortColumn::default(), sort_order: SortOrder::default(), preview_id: None, + max_conflicts_to_show: default_max_conflicts_to_show(), } } } @@ -416,6 +422,10 @@ fn default_progress_interval() -> u64 { 200 } +fn default_max_conflicts_to_show() -> usize { + 100 +} + // ============================================================================ // Scan preview events // ============================================================================ diff --git a/apps/desktop/src-tauri/src/icons.rs b/apps/desktop/src-tauri/src/icons.rs index 22b7fab..bfbdfdb 100644 --- a/apps/desktop/src-tauri/src/icons.rs +++ b/apps/desktop/src-tauri/src/icons.rs @@ -46,6 +46,17 @@ fn cache_icon(icon_id: String, data_url: String) { } } +/// Clears all cached icons for extension-based entries. +/// Called when the "use app icons for documents" setting changes. +pub fn clear_extension_icon_cache() { + ensure_cache(); + let mut cache = ICON_CACHE.write().unwrap(); + if let Some(ref mut map) = *cache { + // Only remove extension-based icons (ext:xxx), keep directory icons + map.retain(|key, _| !key.starts_with("ext:")); + } +} + /// Converts an image to a base64 WebP data URL. fn image_to_data_url(img: &DynamicImage) -> Option { // Resize to configured size @@ -103,8 +114,13 @@ fn get_sample_path_for_icon_id(icon_id: &str) -> Option { } /// Fetches icons for the given icon IDs that are not already cached. +/// +/// When `use_app_icons_for_documents` is true and on macOS, extension-based icons +/// are fetched from app bundles (showing the app's icon as fallback). When false, +/// the system's default document icons are used (Finder-style with app badge). +/// /// Returns a map of icon_id -> data URL. -pub fn get_icons(icon_ids: Vec) -> HashMap { +pub fn get_icons(icon_ids: Vec, use_app_icons_for_documents: bool) -> HashMap { let mut result = HashMap::new(); for icon_id in icon_ids { @@ -115,6 +131,22 @@ pub fn get_icons(icon_ids: Vec) -> HashMap { } // Not cached, fetch it + // For extension-based icons on macOS, use fresh fetch if app icons are enabled + #[cfg(target_os = "macos")] + if use_app_icons_for_documents + && let Some(ext) = icon_id.strip_prefix("ext:") + && let Some(data_url) = fetch_fresh_extension_icon(ext, true) + { + cache_icon(icon_id.clone(), data_url.clone()); + result.insert(icon_id, data_url); + continue; + } + + // Silence unused variable warning when not on macOS + #[cfg(not(target_os = "macos"))] + let _ = use_app_icons_for_documents; + + // Default path: use sample file approach if let Some(sample_path) = get_sample_path_for_icon_id(&icon_id) && let Some(data_url) = fetch_icon_for_path(&sample_path) { @@ -128,16 +160,23 @@ pub fn get_icons(icon_ids: Vec) -> HashMap { /// Fetches a fresh icon for an extension, bypassing any OS cache. /// On macOS, this goes directly to the app bundle. On other platforms, falls back to temp files. -fn fetch_fresh_extension_icon(ext: &str) -> Option { +/// +/// When `use_app_icons_for_documents` is true, falls back to app icons for files without +/// document-specific icons. When false, uses Finder-style document icons. +fn fetch_fresh_extension_icon(ext: &str, use_app_icons_for_documents: bool) -> Option { // On macOS, try to get the icon directly from the default app's bundle // This bypasses the Launch Services icon cache #[cfg(target_os = "macos")] { - if let Some(img) = crate::macos_icons::fetch_fresh_icon_for_extension(ext) { + if let Some(img) = crate::macos_icons::fetch_fresh_icon_for_extension(ext, use_app_icons_for_documents) { return image_to_data_url(&img); } } + // Silence unused variable warning on non-macOS platforms + #[cfg(not(target_os = "macos"))] + let _ = use_app_icons_for_documents; + // Fallback: use temp file approach (works on all platforms, but may use cached icons) let sample_path = std::env::temp_dir().join(format!("cmdr_icon_sample.{}", ext)); if !sample_path.exists() { @@ -154,9 +193,16 @@ fn fetch_fresh_extension_icon(ext: &str) -> Option { /// On macOS, extension icons are fetched directly from app bundles to bypass /// the Launch Services icon cache, ensuring we always show the current association. /// +/// When `use_app_icons_for_documents` is true, falls back to app icons for files without +/// document-specific icons. When false, uses Finder-style document icons. +/// /// Returns only the icons that were successfully fetched, regardless of cache state. /// This allows the frontend to detect changes by comparing with its cached icons. -pub fn refresh_icons_for_directory(directory_paths: Vec, extensions: Vec) -> HashMap { +pub fn refresh_icons_for_directory( + directory_paths: Vec, + extensions: Vec, + use_app_icons_for_documents: bool, +) -> HashMap { let mut result = HashMap::new(); // Fetch extension icons in parallel (uses rayon's global pool) @@ -165,7 +211,7 @@ pub fn refresh_icons_for_directory(directory_paths: Vec, extensions: Vec .par_iter() .map(|ext| { let icon_id = format!("ext:{}", ext.to_lowercase()); - let data_url = fetch_fresh_extension_icon(ext); + let data_url = fetch_fresh_extension_icon(ext, use_app_icons_for_documents); (icon_id, data_url) }) .collect(); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5cd564f..d89779c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -67,9 +67,9 @@ mod stubs; use menu::{ ABOUT_ID, COMMAND_PALETTE_ID, ENTER_LICENSE_KEY_ID, GO_BACK_ID, GO_FORWARD_ID, GO_PARENT_ID, MenuState, - SHOW_HIDDEN_FILES_ID, SORT_ASCENDING_ID, SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID, SORT_BY_MODIFIED_ID, - SORT_BY_NAME_ID, SORT_BY_SIZE_ID, SORT_DESCENDING_ID, SWITCH_PANE_ID, VIEW_MODE_BRIEF_ID, VIEW_MODE_FULL_ID, - ViewMode, + SETTINGS_ID, SHOW_HIDDEN_FILES_ID, SORT_ASCENDING_ID, SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID, + SORT_BY_MODIFIED_ID, SORT_BY_NAME_ID, SORT_BY_SIZE_ID, SORT_DESCENDING_ID, SWITCH_PANE_ID, VIEW_MODE_BRIEF_ID, + VIEW_MODE_FULL_ID, ViewMode, }; use tauri::{Emitter, Manager}; @@ -99,6 +99,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { // Initialize logging - respects RUST_LOG env var (default: info) env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) @@ -150,6 +151,9 @@ pub fn run() { *menu_state.show_hidden_files.lock().unwrap() = Some(menu_items.show_hidden_files); *menu_state.view_mode_full.lock().unwrap() = Some(menu_items.view_mode_full); *menu_state.view_mode_brief.lock().unwrap() = Some(menu_items.view_mode_brief); + *menu_state.view_submenu.lock().unwrap() = Some(menu_items.view_submenu); + *menu_state.view_mode_full_position.lock().unwrap() = menu_items.view_mode_full_position; + *menu_state.view_mode_brief_position.lock().unwrap() = menu_items.view_mode_brief_position; app.manage(menu_state); // Set window title based on license status @@ -162,8 +166,15 @@ pub fn run() { // Initialize pane state store for MCP context tools app.manage(mcp::PaneStateStore::new()); + // Initialize settings state store for MCP settings tools + app.manage(mcp::SettingsStateStore::new()); + // Start MCP server for AI agent integration - let mcp_config = mcp::McpConfig::from_env(); + // Use settings from user preferences, with env vars as override for dev + let mcp_config = mcp::McpConfig::from_settings_and_env( + saved_settings.developer_mcp_enabled, + saved_settings.developer_mcp_port, + ); mcp::start_mcp_server(app.handle().clone(), mcp_config); // Initialize AI manager (starts llama-server if model is installed) @@ -230,6 +241,9 @@ pub fn run() { } else if id == ENTER_LICENSE_KEY_ID { // Emit event to show the license key entry dialog (main window only) let _ = app.emit_to("main", "show-license-key-dialog", ()); + } else if id == SETTINGS_ID { + // Open settings window (emits to main window to handle) + let _ = app.emit_to("main", "open-settings", ()); } else if id == COMMAND_PALETTE_ID { // Emit event to show the command palette (main window only) let _ = app.emit_to("main", "show-command-palette", ()); @@ -306,6 +320,7 @@ pub fn run() { commands::font_metrics::has_font_metrics, commands::icons::get_icons, commands::icons::refresh_directory_icons, + commands::icons::clear_extension_icon_cache, commands::ui::show_file_context_menu, commands::ui::show_main_window, commands::ui::update_menu_context, @@ -319,6 +334,12 @@ pub fn run() { mcp::pane_state::update_left_pane_state, mcp::pane_state::update_right_pane_state, mcp::pane_state::update_focused_pane, + mcp::settings_state::mcp_update_settings_state, + mcp::settings_state::mcp_update_settings_open, + mcp::settings_state::mcp_update_settings_section, + mcp::settings_state::mcp_update_settings_sections, + mcp::settings_state::mcp_update_current_settings, + mcp::settings_state::mcp_update_shortcuts, // Sync status (macOS uses real implementation, others use stub in commands) commands::sync_status::get_sync_status, // Volume commands (platform-specific) @@ -435,8 +456,22 @@ pub fn run() { ai::manager::opt_in_ai, ai::manager::is_ai_opted_out, ai::suggestions::get_folder_suggestions, + // Settings commands + commands::settings::check_port_available, + commands::settings::find_available_port, + commands::settings::update_file_watcher_debounce, + commands::settings::update_service_resolve_timeout, + commands::settings::update_menu_accelerator ]) - .on_window_event(|_window, event| { + .on_window_event(|window, event| { + // When the main window is closed, quit the entire app (including settings/debug/viewer windows) + if let tauri::WindowEvent::CloseRequested { .. } = event + && window.label() == "main" + { + ai::manager::shutdown(); + window.app_handle().exit(0); + } + // Also handle window destruction for cleanup if let tauri::WindowEvent::Destroyed = event { ai::manager::shutdown(); } diff --git a/apps/desktop/src-tauri/src/macos_icons.rs b/apps/desktop/src-tauri/src/macos_icons.rs index c7302a1..909046a 100644 --- a/apps/desktop/src-tauri/src/macos_icons.rs +++ b/apps/desktop/src-tauri/src/macos_icons.rs @@ -80,7 +80,7 @@ fn get_app_url_for_bundle_id(bundle_id: &CFString) -> Option { } /// Reads the app's Info.plist and finds the document icon for the given UTI. -fn get_document_icon_name_from_bundle(app_path: &Path, uti: &str) -> Option { +fn get_document_icon_name_from_bundle(app_path: &Path, uti: &str, use_app_icons_for_documents: bool) -> Option { let plist_path = app_path.join("Contents/Info.plist"); let plist_data = std::fs::read(&plist_path).ok()?; let plist: Value = plist::from_bytes(&plist_data).ok()?; @@ -112,7 +112,7 @@ fn get_document_icon_name_from_bundle(app_path: &Path, uti: &str) -> Option Option { /// Fetches the icon for a file extension directly from the default app's bundle. /// This bypasses the Launch Services icon cache. -pub fn fetch_fresh_icon_for_extension(ext: &str) -> Option { +/// +/// When `use_app_icons_for_documents` is true, falls back to the app's main icon +/// if no document-specific icon is found. When false, returns None to use +/// Finder-style document icons instead. +pub fn fetch_fresh_icon_for_extension(ext: &str, use_app_icons_for_documents: bool) -> Option { // 1. Get UTI for extension let uti = get_uti_for_extension(ext)?; let uti_str = uti.to_string(); @@ -173,7 +177,7 @@ pub fn fetch_fresh_icon_for_extension(ext: &str) -> Option { let app_path = get_app_url_for_bundle_id(&bundle_id)?; // 4. Find the document icon name in the app's Info.plist - let icon_name = get_document_icon_name_from_bundle(&app_path, &uti_str)?; + let icon_name = get_document_icon_name_from_bundle(&app_path, &uti_str, use_app_icons_for_documents)?; // 5. Build the icon path (in Resources folder) // Icon name might or might not have .icns extension @@ -214,8 +218,8 @@ mod tests { #[test] fn test_fetch_fresh_icon() { - // Try a common extension - let icon = fetch_fresh_icon_for_extension("pdf"); + // Try a common extension with app icons enabled + let icon = fetch_fresh_icon_for_extension("pdf", true); // This might fail if no PDF reader is installed, which is fine if let Some(img) = icon { println!("Got PDF icon: {}x{}", img.width(), img.height()); @@ -240,7 +244,7 @@ mod tests { println!("App URL for bundle ID: {:?}", app_url); if let Some(app_path) = app_url { - let icon_name = get_document_icon_name_from_bundle(&app_path, &uti.to_string()); + let icon_name = get_document_icon_name_from_bundle(&app_path, &uti.to_string(), true); println!("Document icon name: {:?}", icon_name); } } diff --git a/apps/desktop/src-tauri/src/mcp/config.rs b/apps/desktop/src-tauri/src/mcp/config.rs index 0b78ab1..c1c3412 100644 --- a/apps/desktop/src-tauri/src/mcp/config.rs +++ b/apps/desktop/src-tauri/src/mcp/config.rs @@ -2,7 +2,8 @@ use std::env; -/// Configuration for the MCP server, read from environment variables. +/// Configuration for the MCP server. +/// Priority: environment variables > user settings > defaults #[derive(Debug, Clone)] pub struct McpConfig { /// Whether the MCP server is enabled @@ -12,15 +13,34 @@ pub struct McpConfig { } impl McpConfig { - /// Load configuration from environment variables. + /// Load configuration from environment variables only (fallback). + /// Use `from_settings_and_env` when settings are available. pub fn from_env() -> Self { + Self::from_settings_and_env(None, None) + } + + /// Load configuration with priority: env vars > user settings > defaults. + /// This allows env vars to override settings (useful for development), + /// while letting user settings work in production. + pub fn from_settings_and_env(setting_enabled: Option, setting_port: Option) -> Self { + // Priority for enabled: + // 1. CMDR_MCP_ENABLED env var (explicit dev override) + // 2. User setting (developer.mcpEnabled) + // 3. Default: enabled in debug builds only let enabled = env::var("CMDR_MCP_ENABLED") .map(|v| v == "true" || v == "1") - .unwrap_or(cfg!(debug_assertions)); // Default: enabled in debug builds + .ok() + .or(setting_enabled) + .unwrap_or(cfg!(debug_assertions)); + // Priority for port: + // 1. CMDR_MCP_PORT env var (explicit dev override) + // 2. User setting (developer.mcpPort) + // 3. Default: 9224 let port = env::var("CMDR_MCP_PORT") .ok() .and_then(|v| v.parse().ok()) + .or(setting_port) .unwrap_or(9224); Self { enabled, port } @@ -38,7 +58,7 @@ mod tests { use super::*; #[test] - fn test_default_values() { + fn test_direct_construction() { let config = McpConfig { enabled: true, port: 9224, @@ -59,4 +79,31 @@ mod tests { let config = McpConfig::default(); assert!(config.port > 0); } + + #[test] + fn test_from_settings_with_no_settings() { + // When no settings are provided, should use defaults + let config = McpConfig::from_settings_and_env(None, None); + assert_eq!(config.port, 9224); + // In debug builds, enabled is true by default + #[cfg(debug_assertions)] + assert!(config.enabled); + } + + #[test] + fn test_from_settings_uses_settings() { + // When settings are provided, should use them (assuming no env vars override) + let config = McpConfig::from_settings_and_env(Some(true), Some(8080)); + // Port should use setting unless env var overrides + // Since we can't control env vars in tests easily, just check structure + assert!(config.port > 0); + } + + #[test] + fn test_from_settings_with_partial_settings() { + // When only port setting is provided + let config = McpConfig::from_settings_and_env(None, Some(9999)); + // Should use default enabled and provided port (unless env vars override) + assert!(config.port > 0); + } } diff --git a/apps/desktop/src-tauri/src/mcp/executor.rs b/apps/desktop/src-tauri/src/mcp/executor.rs index 75b4c18..1759c11 100644 --- a/apps/desktop/src-tauri/src/mcp/executor.rs +++ b/apps/desktop/src-tauri/src/mcp/executor.rs @@ -8,6 +8,7 @@ use tauri::{AppHandle, Emitter, Manager, Runtime}; use super::pane_state::PaneStateStore; use super::protocol::{INTERNAL_ERROR, INVALID_PARAMS}; +use super::settings_state::SettingsStateStore; use crate::commands::ui::{ copy_to_clipboard, get_info, open_in_editor, quick_look, set_view_mode, show_in_finder, toggle_hidden_files, }; @@ -59,6 +60,10 @@ pub fn execute_tool(app: &AppHandle, name: &str, params: &Value) n if n.starts_with("selection_") => execute_selection_command(app, n, params), // Context commands n if n.starts_with("context_") => execute_context_command(app, n), + // Settings commands + n if n.starts_with("settings_") => execute_settings_command(app, n, params), + // Shortcuts commands + n if n.starts_with("shortcuts_") => execute_shortcuts_command(app, n, params), _ => Err(ToolError::invalid_params(format!("Unknown tool: {name}"))), } } @@ -355,6 +360,196 @@ fn execute_context_command(app: &AppHandle, name: &str) -> ToolRe } } +/// Execute a settings command. +/// These manage the Settings window and its values. +fn execute_settings_command(app: &AppHandle, name: &str, params: &Value) -> ToolResult { + match name { + "settings_open" => { + // Emit event to main window to open settings + app.emit_to("main", "open-settings", ()) + .map_err(|e| ToolError::internal(e.to_string()))?; + Ok(json!({"success": true, "message": "Settings window opening"})) + } + "settings_close" => { + // Emit event to settings window to close + app.emit_to("settings", "mcp-settings-close", ()) + .map_err(|e| ToolError::internal(e.to_string()))?; + Ok(json!({"success": true, "message": "Settings window closing"})) + } + "settings_listSections" => { + let store = app + .try_state::() + .ok_or_else(|| ToolError::internal("Settings state not initialized"))?; + + let state = store.get_state(); + Ok(json!({ + "sections": state.sections, + "selectedSection": state.selected_section + })) + } + "settings_selectSection" => { + let section_path = params + .get("sectionPath") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect::>() + }) + .ok_or_else(|| ToolError::invalid_params("Missing 'sectionPath' parameter"))?; + + // Emit event to settings window to select section + app.emit_to( + "settings", + "mcp-settings-select-section", + json!({"sectionPath": section_path}), + ) + .map_err(|e| ToolError::internal(e.to_string()))?; + Ok(json!({"success": true, "selectedSection": section_path})) + } + "settings_listItems" => { + let store = app + .try_state::() + .ok_or_else(|| ToolError::internal("Settings state not initialized"))?; + + let state = store.get_state(); + Ok(json!({ + "selectedSection": state.selected_section, + "settings": state.current_settings + })) + } + "settings_getValue" => { + let setting_id = params + .get("settingId") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::invalid_params("Missing 'settingId' parameter"))?; + + let store = app + .try_state::() + .ok_or_else(|| ToolError::internal("Settings state not initialized"))?; + + let state = store.get_state(); + let setting = state.current_settings.iter().find(|s| s.id == setting_id).cloned(); + + match setting { + Some(s) => Ok(json!({ + "settingId": s.id, + "value": s.value, + "isModified": s.is_modified, + "defaultValue": s.default_value + })), + None => Err(ToolError::invalid_params(format!("Setting not found: {setting_id}"))), + } + } + "settings_setValue" => { + let setting_id = params + .get("settingId") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::invalid_params("Missing 'settingId' parameter"))?; + + let value = params + .get("value") + .ok_or_else(|| ToolError::invalid_params("Missing 'value' parameter"))?; + + // Emit event to settings window to set the value + app.emit_to( + "settings", + "mcp-settings-set-value", + json!({"settingId": setting_id, "value": value}), + ) + .map_err(|e| ToolError::internal(e.to_string()))?; + + Ok(json!({"success": true, "settingId": setting_id, "value": value})) + } + _ => Err(ToolError::invalid_params(format!("Unknown settings command: {name}"))), + } +} + +/// Execute a shortcuts command. +/// These manage keyboard shortcuts configuration. +fn execute_shortcuts_command(app: &AppHandle, name: &str, params: &Value) -> ToolResult { + match name { + "shortcuts_list" => { + let store = app + .try_state::() + .ok_or_else(|| ToolError::internal("Settings state not initialized"))?; + + let state = store.get_state(); + Ok(json!({ + "commands": state.shortcuts + })) + } + "shortcuts_set" => { + let command_id = params + .get("commandId") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::invalid_params("Missing 'commandId' parameter"))?; + + let index = params + .get("index") + .and_then(|v| v.as_i64()) + .ok_or_else(|| ToolError::invalid_params("Missing 'index' parameter"))?; + + let shortcut = params + .get("shortcut") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::invalid_params("Missing 'shortcut' parameter"))?; + + // Emit event to settings window (or main window if settings is closed) + // to set the shortcut + app.emit( + "mcp-shortcuts-set", + json!({"commandId": command_id, "index": index, "shortcut": shortcut}), + ) + .map_err(|e| ToolError::internal(e.to_string()))?; + + Ok(json!({ + "success": true, + "commandId": command_id, + "index": index, + "shortcut": shortcut + })) + } + "shortcuts_remove" => { + let command_id = params + .get("commandId") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::invalid_params("Missing 'commandId' parameter"))?; + + let index = params + .get("index") + .and_then(|v| v.as_i64()) + .ok_or_else(|| ToolError::invalid_params("Missing 'index' parameter"))?; + + // Emit event to remove the shortcut + app.emit("mcp-shortcuts-remove", json!({"commandId": command_id, "index": index})) + .map_err(|e| ToolError::internal(e.to_string()))?; + + Ok(json!({ + "success": true, + "commandId": command_id, + "index": index + })) + } + "shortcuts_reset" => { + let command_id = params + .get("commandId") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::invalid_params("Missing 'commandId' parameter"))?; + + // Emit event to reset the shortcut + app.emit("mcp-shortcuts-reset", json!({"commandId": command_id})) + .map_err(|e| ToolError::internal(e.to_string()))?; + + Ok(json!({ + "success": true, + "commandId": command_id + })) + } + _ => Err(ToolError::invalid_params(format!("Unknown shortcuts command: {name}"))), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src-tauri/src/mcp/mod.rs b/apps/desktop/src-tauri/src/mcp/mod.rs index 97e65cd..84041e8 100644 --- a/apps/desktop/src-tauri/src/mcp/mod.rs +++ b/apps/desktop/src-tauri/src/mcp/mod.rs @@ -9,6 +9,7 @@ pub mod pane_state; mod protocol; mod resources; mod server; +pub mod settings_state; mod tools; #[cfg(test)] @@ -17,3 +18,4 @@ mod tests; pub use config::McpConfig; pub use pane_state::PaneStateStore; pub use server::start_mcp_server; +pub use settings_state::SettingsStateStore; diff --git a/apps/desktop/src-tauri/src/mcp/settings_state.rs b/apps/desktop/src-tauri/src/mcp/settings_state.rs new file mode 100644 index 0000000..6a041ed --- /dev/null +++ b/apps/desktop/src-tauri/src/mcp/settings_state.rs @@ -0,0 +1,190 @@ +//! Settings state storage for MCP settings tools. +//! +//! Stores the current settings state so MCP tools can access it. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::RwLock; +use tauri::AppHandle; +use tauri::Manager; + +/// Represents a setting item with its definition and current value. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingItem { + pub id: String, + pub label: String, + pub description: String, + pub setting_type: String, + pub value: Value, + pub default_value: Value, + pub is_modified: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub constraints: Option, +} + +/// Represents a section in the settings sidebar. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsSection { + pub name: String, + pub path: Vec, + pub subsections: Vec, +} + +/// Represents a command with its shortcuts for the keyboard shortcuts section. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShortcutCommand { + pub id: String, + pub name: String, + pub scope: String, + pub shortcuts: Vec, + pub default_shortcuts: Vec, + pub is_modified: bool, +} + +/// Settings state stored by the frontend. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettingsState { + /// Whether the settings window is open + pub is_open: bool, + /// Currently selected section path + pub selected_section: Vec, + /// All available sections + pub sections: Vec, + /// Settings in the current section + pub current_settings: Vec, + /// All shortcut commands (for keyboard shortcuts section) + pub shortcuts: Vec, +} + +/// Shared state for settings. +#[derive(Debug, Default)] +pub struct SettingsStateStore { + pub state: RwLock, +} + +impl SettingsStateStore { + pub fn new() -> Self { + Self { + state: RwLock::new(SettingsState::default()), + } + } + + pub fn get_state(&self) -> SettingsState { + self.state.read().unwrap().clone() + } + + pub fn set_state(&self, state: SettingsState) { + *self.state.write().unwrap() = state; + } + + pub fn set_is_open(&self, is_open: bool) { + self.state.write().unwrap().is_open = is_open; + } + + pub fn set_selected_section(&self, section: Vec) { + self.state.write().unwrap().selected_section = section; + } + + pub fn set_sections(&self, sections: Vec) { + self.state.write().unwrap().sections = sections; + } + + pub fn set_current_settings(&self, settings: Vec) { + self.state.write().unwrap().current_settings = settings; + } + + pub fn set_shortcuts(&self, shortcuts: Vec) { + self.state.write().unwrap().shortcuts = shortcuts; + } +} + +/// Tauri command to update settings state from frontend. +#[tauri::command] +pub fn mcp_update_settings_state(app: AppHandle, state: SettingsState) { + if let Some(store) = app.try_state::() { + store.set_state(state); + } +} + +/// Tauri command to update settings window open state. +#[tauri::command] +pub fn mcp_update_settings_open(app: AppHandle, is_open: bool) { + if let Some(store) = app.try_state::() { + store.set_is_open(is_open); + } +} + +/// Tauri command to update selected section. +#[tauri::command] +pub fn mcp_update_settings_section(app: AppHandle, section: Vec) { + if let Some(store) = app.try_state::() { + store.set_selected_section(section); + } +} + +/// Tauri command to update available sections. +#[tauri::command] +pub fn mcp_update_settings_sections(app: AppHandle, sections: Vec) { + if let Some(store) = app.try_state::() { + store.set_sections(sections); + } +} + +/// Tauri command to update current settings in the selected section. +#[tauri::command] +pub fn mcp_update_current_settings(app: AppHandle, settings: Vec) { + if let Some(store) = app.try_state::() { + store.set_current_settings(settings); + } +} + +/// Tauri command to update shortcuts list. +#[tauri::command] +pub fn mcp_update_shortcuts(app: AppHandle, shortcuts: Vec) { + if let Some(store) = app.try_state::() { + store.set_shortcuts(shortcuts); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_settings_state_store() { + let store = SettingsStateStore::new(); + + let state = SettingsState { + is_open: true, + selected_section: vec!["General".to_string(), "Appearance".to_string()], + sections: vec![SettingsSection { + name: "General".to_string(), + path: vec!["General".to_string()], + subsections: vec![], + }], + current_settings: vec![], + shortcuts: vec![], + }; + + store.set_state(state); + let retrieved = store.get_state(); + + assert!(retrieved.is_open); + assert_eq!(retrieved.selected_section, vec!["General", "Appearance"]); + assert_eq!(retrieved.sections.len(), 1); + } + + #[test] + fn test_set_is_open() { + let store = SettingsStateStore::new(); + + assert!(!store.get_state().is_open); + + store.set_is_open(true); + assert!(store.get_state().is_open); + } +} diff --git a/apps/desktop/src-tauri/src/mcp/tests.rs b/apps/desktop/src-tauri/src/mcp/tests.rs index 3c68c9c..369109a 100644 --- a/apps/desktop/src-tauri/src/mcp/tests.rs +++ b/apps/desktop/src-tauri/src/mcp/tests.rs @@ -106,11 +106,12 @@ fn test_tool_input_schemas_are_valid() { fn test_total_tool_count() { let tools = get_all_tools(); // 3 app + 3 view + 1 pane + 12 nav + 8 sort + 6 file + 2 volume + 5 selection = 40 + // + 7 settings + 4 shortcuts = 51 // (context tools and volume_list moved to resources) assert_eq!( tools.len(), - 40, - "Expected 40 tools, got {}. Did you add/remove tools?", + 51, + "Expected 51 tools, got {}. Did you add/remove tools?", tools.len() ); } diff --git a/apps/desktop/src-tauri/src/mcp/tools.rs b/apps/desktop/src-tauri/src/mcp/tools.rs index 37f25ae..8ee78ac 100644 --- a/apps/desktop/src-tauri/src/mcp/tools.rs +++ b/apps/desktop/src-tauri/src/mcp/tools.rs @@ -170,6 +170,132 @@ fn get_selection_tools() -> Vec { ] } +/// Get settings window tools. +fn get_settings_tools() -> Vec { + vec![ + Tool::no_params("settings_open", "Open the Settings window"), + Tool::no_params("settings_close", "Close the Settings window"), + Tool::no_params( + "settings_listSections", + "List all available sections in the Settings sidebar", + ), + Tool { + name: "settings_selectSection".to_string(), + description: "Navigate to a settings section by path (e.g., ['General', 'Appearance'])".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "sectionPath": { + "type": "array", + "items": { "type": "string" }, + "description": "Section path as array of strings (e.g., ['General', 'Appearance'])" + } + }, + "required": ["sectionPath"] + }), + }, + Tool::no_params( + "settings_listItems", + "List all setting items in the current section with their IDs and current values", + ), + Tool { + name: "settings_getValue".to_string(), + description: "Get the current value of a specific setting by ID".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "settingId": { + "type": "string", + "description": "The setting ID (e.g., 'appearance.uiDensity')" + } + }, + "required": ["settingId"] + }), + }, + Tool { + name: "settings_setValue".to_string(), + description: "Set a value for a specific setting".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "settingId": { + "type": "string", + "description": "The setting ID (e.g., 'appearance.uiDensity')" + }, + "value": { + "description": "The value to set (type depends on the setting)" + } + }, + "required": ["settingId", "value"] + }), + }, + ] +} + +/// Get keyboard shortcuts tools. +fn get_shortcuts_tools() -> Vec { + vec![ + Tool::no_params( + "shortcuts_list", + "List all commands with their current shortcuts and modification status", + ), + Tool { + name: "shortcuts_set".to_string(), + description: "Set a shortcut for a command at a specific index".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "commandId": { + "type": "string", + "description": "The command ID (e.g., 'nav.open')" + }, + "index": { + "type": "integer", + "description": "Index of the shortcut to set (0-based)" + }, + "shortcut": { + "type": "string", + "description": "The shortcut string (e.g., '⌘O' or 'Ctrl+O')" + } + }, + "required": ["commandId", "index", "shortcut"] + }), + }, + Tool { + name: "shortcuts_remove".to_string(), + description: "Remove a shortcut from a command at a specific index".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "commandId": { + "type": "string", + "description": "The command ID (e.g., 'nav.open')" + }, + "index": { + "type": "integer", + "description": "Index of the shortcut to remove (0-based)" + } + }, + "required": ["commandId", "index"] + }), + }, + Tool { + name: "shortcuts_reset".to_string(), + description: "Reset a command's shortcuts to their default values".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "commandId": { + "type": "string", + "description": "The command ID to reset (e.g., 'nav.open')" + } + }, + "required": ["commandId"] + }), + }, + ] +} + /// Get all available tools. pub fn get_all_tools() -> Vec { let mut tools = Vec::new(); @@ -181,6 +307,8 @@ pub fn get_all_tools() -> Vec { tools.extend(get_file_tools()); tools.extend(get_volume_tools()); tools.extend(get_selection_tools()); + tools.extend(get_settings_tools()); + tools.extend(get_shortcuts_tools()); tools } @@ -209,9 +337,21 @@ mod tests { #[test] fn test_all_tools_count() { let tools = get_all_tools(); - // 3 app + 3 view + 1 pane + 12 nav + 8 sort + 6 file + 2 volume + 5 selection = 40 + // 3 app + 3 view + 1 pane + 12 nav + 8 sort + 6 file + 2 volume + 5 selection + 7 settings + 4 shortcuts = 51 // (context tools and volume_list moved to resources) - assert_eq!(tools.len(), 40); + assert_eq!(tools.len(), 51); + } + + #[test] + fn test_settings_tools_count() { + let tools = get_settings_tools(); + assert_eq!(tools.len(), 7); + } + + #[test] + fn test_shortcuts_tools_count() { + let tools = get_shortcuts_tools(); + assert_eq!(tools.len(), 4); } #[test] diff --git a/apps/desktop/src-tauri/src/menu.rs b/apps/desktop/src-tauri/src/menu.rs index ebcdd1b..2530af1 100644 --- a/apps/desktop/src-tauri/src/menu.rs +++ b/apps/desktop/src-tauri/src/menu.rs @@ -3,7 +3,7 @@ use std::sync::Mutex; use tauri::{ AppHandle, Runtime, - menu::{CheckMenuItem, Menu, Submenu}, + menu::{CheckMenuItem, Menu, MenuItem, MenuItemKind, PredefinedMenuItem, Submenu}, }; /// Menu item IDs for file actions. @@ -51,6 +51,11 @@ pub struct MenuState { pub view_mode_full: Mutex>>, pub view_mode_brief: Mutex>>, pub context: Mutex, + /// Reference to the View submenu for accelerator updates + pub view_submenu: Mutex>>, + /// Positions of items in View submenu (for reinsertion after accelerator updates) + pub view_mode_full_position: Mutex, + pub view_mode_brief_position: Mutex, } impl Default for MenuState { @@ -60,6 +65,9 @@ impl Default for MenuState { view_mode_full: Mutex::new(None), view_mode_brief: Mutex::new(None), context: Mutex::new(MenuContext::default()), + view_submenu: Mutex::new(None), + view_mode_full_position: Mutex::new(0), + view_mode_brief_position: Mutex::new(0), } } } @@ -70,6 +78,12 @@ pub struct MenuItems { pub show_hidden_files: CheckMenuItem, pub view_mode_full: CheckMenuItem, pub view_mode_brief: CheckMenuItem, + /// Reference to View submenu for accelerator updates + pub view_submenu: Submenu, + /// Position of Full view item in View submenu + pub view_mode_full_position: usize, + /// Position of Brief view item in View submenu + pub view_mode_brief_position: usize, } /// View mode type that matches the frontend type. @@ -86,6 +100,9 @@ pub const ABOUT_ID: &str = "about"; /// Menu item ID for Enter License Key. pub const ENTER_LICENSE_KEY_ID: &str = "enter_license_key"; +/// Menu item ID for Settings. +pub const SETTINGS_ID: &str = "settings"; + /// Builds the application menu with default macOS items plus a custom View and File submenu enhancements. pub fn build_menu( app: &AppHandle, @@ -99,16 +116,16 @@ pub fn build_menu( // Replace the default About item with our custom one that emits an event // The app menu is typically the first item for item in menu.items()? { - if let tauri::menu::MenuItemKind::Submenu(submenu) = item { + if let MenuItemKind::Submenu(submenu) = item { let text = submenu.text()?; if text == "cmdr" || text.to_lowercase().contains("cmdr") { // Find and remove the default About item, add our custom one - let about_item = tauri::menu::MenuItem::with_id(app, ABOUT_ID, "About cmdr", true, None::<&str>)?; + let about_item = MenuItem::with_id(app, ABOUT_ID, "About cmdr", true, None::<&str>)?; // Get all items and recreate without the default about let items = submenu.items()?; for (i, sub_item) in items.iter().enumerate() { - if let tauri::menu::MenuItemKind::Predefined(pred) = sub_item { + if let MenuItemKind::Predefined(pred) = sub_item { // Check if this is the About item by position (typically first) if i == 0 { submenu.remove(pred)?; @@ -120,14 +137,16 @@ pub fn build_menu( } else { "Enter license key..." }; - let enter_license_key_item = tauri::menu::MenuItem::with_id( - app, - ENTER_LICENSE_KEY_ID, - license_menu_text, - true, - None::<&str>, - )?; + let enter_license_key_item = + MenuItem::with_id(app, ENTER_LICENSE_KEY_ID, license_menu_text, true, None::<&str>)?; submenu.insert(&enter_license_key_item, 1)?; + + // Add separator and Settings after license key + let separator = PredefinedMenuItem::separator(app)?; + submenu.insert(&separator, 2)?; + let settings_item = + MenuItem::with_id(app, SETTINGS_ID, "Settings...", true, Some("Cmd+,"))?; + submenu.insert(&settings_item, 3)?; break; } } @@ -138,23 +157,20 @@ pub fn build_menu( } // Add File menu items - let open_item = tauri::menu::MenuItem::with_id(app, OPEN_ID, "Open", true, None::<&str>)?; - let edit_item = tauri::menu::MenuItem::with_id(app, EDIT_ID, "Edit", true, Some("F4"))?; - let show_in_finder_item = - tauri::menu::MenuItem::with_id(app, SHOW_IN_FINDER_ID, "Show in Finder", true, Some("Opt+Cmd+O"))?; - let copy_path_item = - tauri::menu::MenuItem::with_id(app, COPY_PATH_ID, "Copy path to clipboard", true, Some("Ctrl+Cmd+C"))?; - let copy_filename_item = - tauri::menu::MenuItem::with_id(app, COPY_FILENAME_ID, "Copy filename", true, None::<&str>)?; - let get_info_item = tauri::menu::MenuItem::with_id(app, GET_INFO_ID, "Get info", true, Some("Cmd+I"))?; - let quick_look_item = tauri::menu::MenuItem::with_id(app, QUICK_LOOK_ID, "Quick look", true, Some("Space"))?; + let open_item = MenuItem::with_id(app, OPEN_ID, "Open", true, None::<&str>)?; + let edit_item = MenuItem::with_id(app, EDIT_ID, "Edit", true, Some("F4"))?; + let show_in_finder_item = MenuItem::with_id(app, SHOW_IN_FINDER_ID, "Show in Finder", true, Some("Opt+Cmd+O"))?; + let copy_path_item = MenuItem::with_id(app, COPY_PATH_ID, "Copy path to clipboard", true, Some("Ctrl+Cmd+C"))?; + let copy_filename_item = MenuItem::with_id(app, COPY_FILENAME_ID, "Copy filename", true, None::<&str>)?; + let get_info_item = MenuItem::with_id(app, GET_INFO_ID, "Get info", true, Some("Cmd+I"))?; + let quick_look_item = MenuItem::with_id(app, QUICK_LOOK_ID, "Quick look", true, Some("Space"))?; // Find the existing File submenu and add our items to it for item in menu.items()? { - if let tauri::menu::MenuItemKind::Submenu(submenu) = item + if let MenuItemKind::Submenu(submenu) = item && submenu.text()? == "File" { - submenu.prepend(&tauri::menu::PredefinedMenuItem::separator(app)?)?; + submenu.prepend(&PredefinedMenuItem::separator(app)?)?; submenu.prepend(&quick_look_item)?; submenu.prepend(&get_info_item)?; submenu.prepend(©_filename_item)?; @@ -197,29 +213,35 @@ pub fn build_menu( // Find the existing View submenu and add our items to it // The default menu on macOS has: App, File, Edit, View, Window, Help - let mut found_view = false; + let mut found_view_submenu: Option> = None; + let mut view_full_pos: usize = 0; + let mut view_brief_pos: usize = 0; + for item in menu.items()? { - if let tauri::menu::MenuItemKind::Submenu(submenu) = item + if let MenuItemKind::Submenu(submenu) = item && submenu.text()? == "View" { // Add separator then our items - submenu.append(&tauri::menu::PredefinedMenuItem::separator(app)?)?; + submenu.append(&PredefinedMenuItem::separator(app)?)?; + + // Track positions of view mode items (after the separator we just added) + let base_count = submenu.items()?.len(); + view_full_pos = base_count; submenu.append(&view_mode_full_item)?; + view_brief_pos = base_count + 1; submenu.append(&view_mode_brief_item)?; - submenu.append(&tauri::menu::PredefinedMenuItem::separator(app)?)?; + + submenu.append(&PredefinedMenuItem::separator(app)?)?; submenu.append(&show_hidden_item)?; // Add Sort by submenu - let sort_by_name = tauri::menu::MenuItem::with_id(app, SORT_BY_NAME_ID, "Name", true, None::<&str>)?; - let sort_by_ext = - tauri::menu::MenuItem::with_id(app, SORT_BY_EXTENSION_ID, "Extension", true, None::<&str>)?; - let sort_by_size = tauri::menu::MenuItem::with_id(app, SORT_BY_SIZE_ID, "Size", true, None::<&str>)?; - let sort_by_modified = - tauri::menu::MenuItem::with_id(app, SORT_BY_MODIFIED_ID, "Date modified", true, None::<&str>)?; - let sort_by_created = - tauri::menu::MenuItem::with_id(app, SORT_BY_CREATED_ID, "Date created", true, None::<&str>)?; - let sort_asc = tauri::menu::MenuItem::with_id(app, SORT_ASCENDING_ID, "Ascending", true, None::<&str>)?; - let sort_desc = tauri::menu::MenuItem::with_id(app, SORT_DESCENDING_ID, "Descending", true, None::<&str>)?; + let sort_by_name = MenuItem::with_id(app, SORT_BY_NAME_ID, "Name", true, None::<&str>)?; + let sort_by_ext = MenuItem::with_id(app, SORT_BY_EXTENSION_ID, "Extension", true, None::<&str>)?; + let sort_by_size = MenuItem::with_id(app, SORT_BY_SIZE_ID, "Size", true, None::<&str>)?; + let sort_by_modified = MenuItem::with_id(app, SORT_BY_MODIFIED_ID, "Date modified", true, None::<&str>)?; + let sort_by_created = MenuItem::with_id(app, SORT_BY_CREATED_ID, "Date created", true, None::<&str>)?; + let sort_asc = MenuItem::with_id(app, SORT_ASCENDING_ID, "Ascending", true, None::<&str>)?; + let sort_desc = MenuItem::with_id(app, SORT_DESCENDING_ID, "Descending", true, None::<&str>)?; let sort_submenu = Submenu::with_items( app, @@ -231,7 +253,7 @@ pub fn build_menu( &sort_by_size, &sort_by_modified, &sort_by_created, - &tauri::menu::PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::separator(app)?, &sort_asc, &sort_desc, ], @@ -239,25 +261,22 @@ pub fn build_menu( submenu.append(&sort_submenu)?; // Add command palette and switch pane after separator - submenu.append(&tauri::menu::PredefinedMenuItem::separator(app)?)?; - let command_palette_item = tauri::menu::MenuItem::with_id( - app, - COMMAND_PALETTE_ID, - "Command palette...", - true, - Some("Cmd+Shift+P"), - )?; + submenu.append(&PredefinedMenuItem::separator(app)?)?; + let command_palette_item = + MenuItem::with_id(app, COMMAND_PALETTE_ID, "Command palette...", true, Some("Cmd+Shift+P"))?; submenu.append(&command_palette_item)?; - let switch_pane_item = - tauri::menu::MenuItem::with_id(app, SWITCH_PANE_ID, "Switch pane", true, Some("Tab"))?; + let switch_pane_item = MenuItem::with_id(app, SWITCH_PANE_ID, "Switch pane", true, Some("Tab"))?; submenu.append(&switch_pane_item)?; - found_view = true; + + found_view_submenu = Some(submenu); break; } } // If View menu wasn't found (unlikely), create one - if !found_view { + let view_submenu = if let Some(submenu) = found_view_submenu { + submenu + } else { let view_menu = Submenu::with_items( app, "View", @@ -265,17 +284,20 @@ pub fn build_menu( &[ &view_mode_full_item, &view_mode_brief_item, - &tauri::menu::PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::separator(app)?, &show_hidden_item, ], )?; + view_full_pos = 0; + view_brief_pos = 1; menu.append(&view_menu)?; - } + view_menu + }; // Create Go menu for navigation - let go_back_item = tauri::menu::MenuItem::with_id(app, GO_BACK_ID, "Back", true, Some("Cmd+["))?; - let go_forward_item = tauri::menu::MenuItem::with_id(app, GO_FORWARD_ID, "Forward", true, Some("Cmd+]"))?; - let go_parent_item = tauri::menu::MenuItem::with_id(app, GO_PARENT_ID, "Parent folder", true, Some("Cmd+Up"))?; + let go_back_item = MenuItem::with_id(app, GO_BACK_ID, "Back", true, Some("Cmd+["))?; + let go_forward_item = MenuItem::with_id(app, GO_FORWARD_ID, "Forward", true, Some("Cmd+]"))?; + let go_parent_item = MenuItem::with_id(app, GO_PARENT_ID, "Parent folder", true, Some("Cmd+Up"))?; let go_menu = Submenu::with_items( app, @@ -284,7 +306,7 @@ pub fn build_menu( &[ &go_back_item, &go_forward_item, - &tauri::menu::PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::separator(app)?, &go_parent_item, ], )?; @@ -294,7 +316,7 @@ pub fn build_menu( let mut inserted = false; let items = menu.items()?; for (i, item) in items.iter().enumerate() { - if let tauri::menu::MenuItemKind::Submenu(submenu) = item + if let MenuItemKind::Submenu(submenu) = item && submenu.text()? == "Window" { menu.insert(&go_menu, i)?; @@ -312,6 +334,9 @@ pub fn build_menu( show_hidden_files: show_hidden_item, view_mode_full: view_mode_full_item, view_mode_brief: view_mode_brief_item, + view_submenu, + view_mode_full_position: view_full_pos, + view_mode_brief_position: view_brief_pos, }) } @@ -323,21 +348,19 @@ pub fn build_context_menu( ) -> tauri::Result> { let menu = Menu::new(app)?; - let open_item = tauri::menu::MenuItem::with_id(app, OPEN_ID, "Open", true, None::<&str>)?; - let edit_item = tauri::menu::MenuItem::with_id(app, EDIT_ID, "Edit", true, Some("F4"))?; - let show_in_finder_item = - tauri::menu::MenuItem::with_id(app, SHOW_IN_FINDER_ID, "Show in Finder", true, Some("Opt+Cmd+O"))?; - let copy_path_item = - tauri::menu::MenuItem::with_id(app, COPY_PATH_ID, "Copy path to clipboard", true, Some("Ctrl+Cmd+C"))?; - let copy_filename_item = tauri::menu::MenuItem::with_id( + let open_item = MenuItem::with_id(app, OPEN_ID, "Open", true, None::<&str>)?; + let edit_item = MenuItem::with_id(app, EDIT_ID, "Edit", true, Some("F4"))?; + let show_in_finder_item = MenuItem::with_id(app, SHOW_IN_FINDER_ID, "Show in Finder", true, Some("Opt+Cmd+O"))?; + let copy_path_item = MenuItem::with_id(app, COPY_PATH_ID, "Copy path to clipboard", true, Some("Ctrl+Cmd+C"))?; + let copy_filename_item = MenuItem::with_id( app, COPY_FILENAME_ID, format!("Copy \"{}\"", filename), true, Some("Cmd+C"), )?; - let get_info_item = tauri::menu::MenuItem::with_id(app, GET_INFO_ID, "Get info", true, Some("Cmd+I"))?; - let quick_look_item = tauri::menu::MenuItem::with_id(app, QUICK_LOOK_ID, "Quick look", true, None::<&str>)?; + let get_info_item = MenuItem::with_id(app, GET_INFO_ID, "Get info", true, Some("Cmd+I"))?; + let quick_look_item = MenuItem::with_id(app, QUICK_LOOK_ID, "Quick look", true, None::<&str>)?; // Add items to menu if !is_directory { @@ -345,12 +368,199 @@ pub fn build_context_menu( menu.append(&edit_item)?; } menu.append(&show_in_finder_item)?; - menu.append(&tauri::menu::PredefinedMenuItem::separator(app)?)?; + menu.append(&PredefinedMenuItem::separator(app)?)?; menu.append(©_filename_item)?; menu.append(©_path_item)?; - menu.append(&tauri::menu::PredefinedMenuItem::separator(app)?)?; + menu.append(&PredefinedMenuItem::separator(app)?)?; menu.append(&get_info_item)?; menu.append(&quick_look_item)?; Ok(menu) } + +/// Convert frontend shortcut format (⌘2) to Tauri accelerator format (Cmd+2). +/// Returns None if the shortcut is empty or invalid. +pub fn frontend_shortcut_to_accelerator(shortcut: &str) -> Option { + if shortcut.is_empty() { + return None; + } + + let mut result = String::new(); + let mut chars = shortcut.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '⌘' => { + if !result.is_empty() { + result.push('+'); + } + result.push_str("Cmd"); + } + 'βŒƒ' => { + if !result.is_empty() { + result.push('+'); + } + result.push_str("Ctrl"); + } + 'βŒ₯' => { + if !result.is_empty() { + result.push('+'); + } + result.push_str("Opt"); + } + '⇧' => { + if !result.is_empty() { + result.push('+'); + } + result.push_str("Shift"); + } + '↑' => { + if !result.is_empty() { + result.push('+'); + } + result.push_str("Up"); + } + '↓' => { + if !result.is_empty() { + result.push('+'); + } + result.push_str("Down"); + } + '←' => { + if !result.is_empty() { + result.push('+'); + } + result.push_str("Left"); + } + 'β†’' => { + if !result.is_empty() { + result.push('+'); + } + result.push_str("Right"); + } + _ => { + // Regular character (letter, number, etc.) + if !result.is_empty() { + result.push('+'); + } + // Handle special key names + let remaining: String = std::iter::once(c).chain(chars.by_ref()).collect(); + if remaining.eq_ignore_ascii_case("enter") { + result.push_str("Enter"); + } else if remaining.eq_ignore_ascii_case("space") { + result.push_str("Space"); + } else if remaining.eq_ignore_ascii_case("tab") { + result.push_str("Tab"); + } else if remaining.eq_ignore_ascii_case("escape") { + result.push_str("Escape"); + } else if remaining.eq_ignore_ascii_case("backspace") { + result.push_str("Backspace"); + } else if remaining.starts_with('F') || remaining.starts_with('f') { + // Function keys like F1, F4 + result.push_str(&remaining.to_uppercase()); + } else if remaining.eq_ignore_ascii_case("pageup") { + result.push_str("PageUp"); + } else if remaining.eq_ignore_ascii_case("pagedown") { + result.push_str("PageDown"); + } else if remaining.eq_ignore_ascii_case("home") { + result.push_str("Home"); + } else if remaining.eq_ignore_ascii_case("end") { + result.push_str("End"); + } else { + // Single character or unknown - use as-is (uppercase for letters) + result.push_str(&remaining.to_uppercase()); + } + break; + } + } + } + + if result.is_empty() { None } else { Some(result) } +} + +/// Update the accelerator for a view mode menu item. +/// Returns the new CheckMenuItem reference if successful. +pub fn update_view_mode_accelerator( + app: &AppHandle, + menu_state: &MenuState, + is_full_mode: bool, + new_accelerator: Option<&str>, + is_checked: bool, +) -> tauri::Result> { + let view_submenu_guard = menu_state.view_submenu.lock().unwrap(); + let view_submenu = view_submenu_guard + .as_ref() + .ok_or_else(|| tauri::Error::InvalidWindowHandle)?; + + let (menu_item_guard, position_guard, menu_id, label) = if is_full_mode { + ( + menu_state.view_mode_full.lock().unwrap(), + menu_state.view_mode_full_position.lock().unwrap(), + VIEW_MODE_FULL_ID, + "Full view", + ) + } else { + ( + menu_state.view_mode_brief.lock().unwrap(), + menu_state.view_mode_brief_position.lock().unwrap(), + VIEW_MODE_BRIEF_ID, + "Brief view", + ) + }; + + let old_item = menu_item_guard + .as_ref() + .ok_or_else(|| tauri::Error::InvalidWindowHandle)?; + let position = *position_guard; + + // Remove the old item + view_submenu.remove(old_item)?; + + // Create new item with new accelerator + let new_item = CheckMenuItem::with_id(app, menu_id, label, true, is_checked, new_accelerator)?; + + // Insert at same position + view_submenu.insert(&new_item, position)?; + + Ok(new_item) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frontend_shortcut_to_accelerator_simple() { + // Basic modifier + key combinations + assert_eq!(frontend_shortcut_to_accelerator("⌘1"), Some("Cmd+1".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("⌘2"), Some("Cmd+2".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("βŒ˜β‡§P"), Some("Cmd+Shift+P".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("βŒ₯⌘O"), Some("Opt+Cmd+O".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("βŒƒβŒ˜C"), Some("Ctrl+Cmd+C".to_string())); + } + + #[test] + fn test_frontend_shortcut_to_accelerator_arrows() { + assert_eq!(frontend_shortcut_to_accelerator("βŒ˜β†‘"), Some("Cmd+Up".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("βŒ˜β†“"), Some("Cmd+Down".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("⌘["), Some("Cmd+[".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("⌘]"), Some("Cmd+]".to_string())); + } + + #[test] + fn test_frontend_shortcut_to_accelerator_special_keys() { + assert_eq!(frontend_shortcut_to_accelerator("Tab"), Some("Tab".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("Enter"), Some("Enter".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("Space"), Some("Space".to_string())); + assert_eq!(frontend_shortcut_to_accelerator("F4"), Some("F4".to_string())); + assert_eq!( + frontend_shortcut_to_accelerator("Backspace"), + Some("Backspace".to_string()) + ); + } + + #[test] + fn test_frontend_shortcut_to_accelerator_empty() { + assert_eq!(frontend_shortcut_to_accelerator(""), None); + } +} diff --git a/apps/desktop/src-tauri/src/network/bonjour.rs b/apps/desktop/src-tauri/src/network/bonjour.rs index 2592fe1..c699c99 100644 --- a/apps/desktop/src-tauri/src/network/bonjour.rs +++ b/apps/desktop/src-tauri/src/network/bonjour.rs @@ -38,8 +38,24 @@ const SMB_SERVICE_TYPE: &str = "_smb._tcp."; const LOCAL_DOMAIN: &str = "local."; /// Default SMB port. const SMB_DEFAULT_PORT: u16 = 445; -/// Timeout for service resolution in seconds. -const RESOLVE_TIMEOUT: f64 = 5.0; +/// Default timeout for service resolution in seconds. +const DEFAULT_RESOLVE_TIMEOUT_MS: u64 = 5000; + +/// Configured resolve timeout in milliseconds (set by frontend via update_resolve_timeout) +static RESOLVE_TIMEOUT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(DEFAULT_RESOLVE_TIMEOUT_MS); + +/// Updates the Bonjour service resolve timeout. +/// This affects future service resolutions; ongoing resolutions keep their original timeout. +pub fn update_resolve_timeout(ms: u64) { + RESOLVE_TIMEOUT_MS.store(ms, std::sync::atomic::Ordering::Relaxed); + debug!("Bonjour resolve timeout updated to {} ms", ms); +} + +/// Gets the current resolve timeout in seconds (for NSNetService.resolveWithTimeout). +fn get_resolve_timeout_seconds() -> f64 { + let ms = RESOLVE_TIMEOUT_MS.load(std::sync::atomic::Ordering::Relaxed); + ms as f64 / 1000.0 +} /// Global Bonjour discovery manager. static BONJOUR_MANAGER: OnceLock>> = OnceLock::new(); @@ -383,7 +399,7 @@ fn start_resolving_service(service: &NSNetService, host_id: &str) { } // Start resolution with timeout - resolve_service.resolveWithTimeout(RESOLVE_TIMEOUT); + resolve_service.resolveWithTimeout(get_resolve_timeout_seconds()); // Store to keep alive manager diff --git a/apps/desktop/src-tauri/src/network/mod.rs b/apps/desktop/src-tauri/src/network/mod.rs index d0cdde6..83edab8 100644 --- a/apps/desktop/src-tauri/src/network/mod.rs +++ b/apps/desktop/src-tauri/src/network/mod.rs @@ -3,7 +3,7 @@ //! Discovers SMB-capable hosts on the local network using Bonjour (mDNS/DNS-SD) //! and enumerates shares using the smb-rs crate. -mod bonjour; +pub mod bonjour; pub mod keychain; pub mod known_shares; pub mod mount; diff --git a/apps/desktop/src-tauri/src/network/mount.rs b/apps/desktop/src-tauri/src/network/mount.rs index 44b5db4..d27b4b0 100644 --- a/apps/desktop/src-tauri/src/network/mount.rs +++ b/apps/desktop/src-tauri/src/network/mount.rs @@ -213,8 +213,8 @@ pub fn mount_share_sync( }) } -/// Mount timeout in seconds -const MOUNT_TIMEOUT_SECS: u64 = 20; +/// Default mount timeout in milliseconds +const DEFAULT_MOUNT_TIMEOUT_MS: u64 = 20_000; /// Async wrapper for mount_share_sync that runs in a blocking task with timeout. pub async fn mount_share( @@ -222,15 +222,17 @@ pub async fn mount_share( share: String, username: Option, password: Option, + timeout_ms: Option, ) -> Result { let server_clone = server.clone(); + let timeout_duration = std::time::Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_MOUNT_TIMEOUT_MS)); // Use timeout to prevent hanging indefinitely let mount_future = tokio::task::spawn_blocking(move || { mount_share_sync(&server, &share, username.as_deref(), password.as_deref()) }); - match tokio::time::timeout(std::time::Duration::from_secs(MOUNT_TIMEOUT_SECS), mount_future).await { + match tokio::time::timeout(timeout_duration, mount_future).await { Ok(Ok(result)) => result, Ok(Err(join_error)) => Err(MountError::ProtocolError { message: format!("Mount task failed: {}", join_error), @@ -238,7 +240,8 @@ pub async fn mount_share( Err(_timeout) => Err(MountError::Timeout { message: format!( "Connection to \"{}\" timed out after {} seconds", - server_clone, MOUNT_TIMEOUT_SECS + server_clone, + timeout_duration.as_secs() ), }), } @@ -280,8 +283,8 @@ mod tests { #[test] fn test_timeout_constant() { - // Verify timeout is reasonable (10-60 seconds) - const { assert!(MOUNT_TIMEOUT_SECS >= 10) }; - const { assert!(MOUNT_TIMEOUT_SECS <= 60) }; + // Verify default timeout is reasonable (10-60 seconds) + const { assert!(DEFAULT_MOUNT_TIMEOUT_MS >= 10_000) }; + const { assert!(DEFAULT_MOUNT_TIMEOUT_MS <= 60_000) }; } } diff --git a/apps/desktop/src-tauri/src/network/smb_client.rs b/apps/desktop/src-tauri/src/network/smb_client.rs index 4f87592..90dd065 100644 --- a/apps/desktop/src-tauri/src/network/smb_client.rs +++ b/apps/desktop/src-tauri/src/network/smb_client.rs @@ -91,11 +91,13 @@ struct CachedShares { expires_at: Instant, } -/// Share cache with 30-second TTL. +/// Share cache with configurable TTL. static SHARE_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); -const CACHE_TTL: Duration = Duration::from_secs(30); -const LIST_SHARES_TIMEOUT: Duration = Duration::from_secs(15); +/// Default cache TTL (30 seconds) - used when no setting is provided. +const DEFAULT_CACHE_TTL_MS: u64 = 30_000; +/// Default list shares timeout (15 seconds) - used when no setting is provided. +const DEFAULT_LIST_SHARES_TIMEOUT_MS: u64 = 15_000; fn get_share_cache() -> &'static Mutex> { SHARE_CACHE.get_or_init(|| Mutex::new(HashMap::new())) @@ -115,18 +117,19 @@ fn get_cached_shares(host_id: &str) -> Option { } } -/// Caches share list for a host. -fn cache_shares(host_id: &str, result: &ShareListResult) { +/// Caches share list for a host with a configurable TTL. +fn cache_shares(host_id: &str, result: &ShareListResult, cache_ttl_ms: u64) { if let Ok(mut cache) = get_share_cache().lock() { // Clean up expired entries while we're here let now = Instant::now(); cache.retain(|_, v| v.expires_at > now); + let ttl = Duration::from_millis(cache_ttl_ms); cache.insert( host_id.to_string(), CachedShares { result: result.clone(), - expires_at: now + CACHE_TTL, + expires_at: now + ttl, }, ); } @@ -160,19 +163,23 @@ pub fn get_cached_shares_auth_mode(host_id: &str) -> Option { /// Lists shares on a network host. /// /// Attempts guest access first, then uses provided credentials if guest fails. -/// Results are cached for 30 seconds. +/// Results are cached for the specified TTL. /// /// # Arguments /// * `host_id` - Unique identifier for the host (used for caching) /// * `hostname` - Hostname to connect to (for example, "TEST_SERVER.local") /// * `ip_address` - Optional resolved IP address (preferred over hostname) /// * `credentials` - Optional (username, password) tuple for authenticated access +/// * `timeout_ms` - Timeout in milliseconds for the operation (default: 15000) +/// * `cache_ttl_ms` - Cache TTL in milliseconds (default: 30000) pub async fn list_shares( host_id: &str, hostname: &str, ip_address: Option<&str>, port: u16, credentials: Option<(&str, &str)>, + timeout_ms: Option, + cache_ttl_ms: Option, ) -> Result { // Only use cache for non-authenticated requests. // When credentials are provided, the user is explicitly authenticating @@ -183,11 +190,15 @@ pub async fn list_shares( return Ok(cached); } + // Use provided timeout or default + let timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_LIST_SHARES_TIMEOUT_MS)); + // Try to list shares - let result = list_shares_uncached(hostname, ip_address, port, credentials).await?; + let result = list_shares_uncached(hostname, ip_address, port, credentials, timeout).await?; - // Cache successful result - cache_shares(host_id, &result); + // Cache successful result with configurable TTL + let ttl = cache_ttl_ms.unwrap_or(DEFAULT_CACHE_TTL_MS); + cache_shares(host_id, &result, ttl); Ok(result) } @@ -200,6 +211,7 @@ async fn list_shares_uncached( ip_address: Option<&str>, port: u16, credentials: Option<(&str, &str)>, + timeout: Duration, ) -> Result { // Debug log the incoming params debug!( @@ -211,7 +223,7 @@ async fn list_shares_uncached( ); // Try smb-rs first - match list_shares_smb_rs(hostname, ip_address, port, credentials).await { + match list_shares_smb_rs(hostname, ip_address, port, credentials, timeout).await { Ok(result) => Ok(result), Err(ShareListError::ProtocolError(ref msg)) => { // Protocol error (likely RPC incompatibility with Samba) @@ -229,6 +241,7 @@ async fn list_shares_smb_rs( ip_address: Option<&str>, port: u16, credentials: Option<(&str, &str)>, + timeout: Duration, ) -> Result { // Create SMB client with unsigned guest access allowed // (some servers like Samba don't require signing for anonymous access) @@ -252,71 +265,81 @@ async fn list_shares_smb_rs( ); // Try guest access first, then authenticated - let (shares, auth_mode) = match try_list_shares_as_guest(&client, server_name, hostname, ip_address, port).await { - Ok(shares) => { - debug!("Guest access succeeded, got {} raw shares", shares.len()); - (shares, AuthMode::GuestAllowed) - } - Err(e) if is_auth_error(&e) => { - debug!("Guest failed with auth error: {}", e); - // Guest failed with auth error - try with credentials if provided - if let Some((user, pass)) = credentials { - debug!("Trying authenticated access with user: {}", user); - - // IMPORTANT: Create a fresh client for authenticated attempt. - // smb-rs reuses connections internally, so if we use the same client, - // the failed guest connection can interfere with the auth attempt. - let mut auth_config = ClientConfig::default(); - auth_config.connection.allow_unsigned_guest_access = false; // Require proper auth - let auth_client = Client::new(auth_config); - - match try_list_shares_authenticated(&auth_client, server_name, hostname, ip_address, port, user, pass) + let (shares, auth_mode) = + match try_list_shares_as_guest(&client, server_name, hostname, ip_address, port, timeout).await { + Ok(shares) => { + debug!("Guest access succeeded, got {} raw shares", shares.len()); + (shares, AuthMode::GuestAllowed) + } + Err(e) if is_auth_error(&e) => { + debug!("Guest failed with auth error: {}", e); + // Guest failed with auth error - try with credentials if provided + if let Some((user, pass)) = credentials { + debug!("Trying authenticated access with user: {}", user); + + // IMPORTANT: Create a fresh client for authenticated attempt. + // smb-rs reuses connections internally, so if we use the same client, + // the failed guest connection can interfere with the auth attempt. + let mut auth_config = ClientConfig::default(); + auth_config.connection.allow_unsigned_guest_access = false; // Require proper auth + let auth_client = Client::new(auth_config); + + match try_list_shares_authenticated( + &auth_client, + server_name, + hostname, + ip_address, + port, + user, + pass, + timeout, + ) .await - { - Ok(shares) if !shares.is_empty() => { - // smb-rs auth worked and returned shares - debug!("Authenticated access succeeded, got {} raw shares", shares.len()); - (shares, AuthMode::CredsRequired) - } - Ok(_) | Err(_) => { - // smb-rs returned 0 shares or failed - fall back to smbutil with auth - // This handles cases where smb-rs internally falls back to guest - debug!("smb-rs auth returned empty or failed, trying smbutil with credentials"); - return match list_shares_smbutil_with_auth(hostname, ip_address, port, user, pass).await { - Ok(result) => { - debug!("smbutil with auth succeeded, got {} shares", result.shares.len()); - Ok(result) - } - Err(e) => { - debug!("smbutil with auth also failed: {:?}", e); - Err(e) - } - }; + { + Ok(shares) if !shares.is_empty() => { + // smb-rs auth worked and returned shares + debug!("Authenticated access succeeded, got {} raw shares", shares.len()); + (shares, AuthMode::CredsRequired) + } + Ok(_) | Err(_) => { + // smb-rs returned 0 shares or failed - fall back to smbutil with auth + // This handles cases where smb-rs internally falls back to guest + debug!("smb-rs auth returned empty or failed, trying smbutil with credentials"); + return match list_shares_smbutil_with_auth(hostname, ip_address, port, user, pass).await { + Ok(result) => { + debug!("smbutil with auth succeeded, got {} shares", result.shares.len()); + Ok(result) + } + Err(e) => { + debug!("smbutil with auth also failed: {:?}", e); + Err(e) + } + }; + } } + } else { + // No explicit credentials provided - try smbutil which uses macOS Keychain + // This allows seamless login when user has previously connected via Finder + debug!("No explicit credentials, trying smbutil with Keychain..."); + return match list_shares_smbutil_authenticated_from_keychain(hostname, ip_address, port).await { + Ok(result) => { + debug!("smbutil with Keychain succeeded, got {} shares", result.shares.len()); + Ok(result) + } + Err(e) => { + debug!("smbutil with Keychain failed: {:?}, requiring manual login", e); + Err(ShareListError::AuthRequired( + "This server requires authentication to list shares".to_string(), + )) + } + }; } - } else { - // No explicit credentials provided - try smbutil which uses macOS Keychain - // This allows seamless login when user has previously connected via Finder - debug!("No explicit credentials, trying smbutil with Keychain..."); - return match list_shares_smbutil_authenticated_from_keychain(hostname, ip_address, port).await { - Ok(result) => { - debug!("smbutil with Keychain succeeded, got {} shares", result.shares.len()); - Ok(result) - } - Err(e) => { - debug!("smbutil with Keychain failed: {:?}, requiring manual login", e); - Err(ShareListError::AuthRequired( - "This server requires authentication to list shares".to_string(), - )) - } - }; } - } - Err(e) => { - debug!("Guest failed with non-auth error: {}", e); - return Err(classify_error(&e)); - } - }; + Err(e) => { + debug!("Guest failed with non-auth error: {}", e); + return Err(classify_error(&e)); + } + }; // Filter to disk shares only let filtered_shares = filter_disk_shares(shares); @@ -658,8 +681,9 @@ async fn try_list_shares_as_guest( hostname: &str, ip_address: Option<&str>, port: u16, + timeout_duration: Duration, ) -> Result, String> { - timeout(LIST_SHARES_TIMEOUT, async { + timeout(timeout_duration, async { let connect_name = establish_smb_connection(client, server_name, hostname, ip_address, port).await?; // Connect to IPC$ with "Guest" user @@ -676,11 +700,12 @@ async fn try_list_shares_as_guest( .map_err(|e| format!("list_shares failed: {}", e)) }) .await - .map_err(|_| format!("Timeout after {}s", LIST_SHARES_TIMEOUT.as_secs()))? + .map_err(|_| format!("Timeout after {}s", timeout_duration.as_secs()))? } /// Attempts to list shares with credentials. /// Connects via IP address when available (preferred), falling back to hostname resolution. +#[allow(clippy::too_many_arguments, reason = "Internal function, parameters are all needed")] async fn try_list_shares_authenticated( client: &Client, server_name: &str, @@ -689,8 +714,9 @@ async fn try_list_shares_authenticated( port: u16, username: &str, password: &str, + timeout_duration: Duration, ) -> Result, String> { - timeout(LIST_SHARES_TIMEOUT, async { + timeout(timeout_duration, async { let connect_name = establish_smb_connection(client, server_name, hostname, ip_address, port).await?; // Connect to IPC$ with credentials @@ -706,7 +732,7 @@ async fn try_list_shares_authenticated( .map_err(|e| format!("list_shares failed: {}", e)) }) .await - .map_err(|_| format!("Timeout after {}s", LIST_SHARES_TIMEOUT.as_secs()))? + .map_err(|_| format!("Timeout after {}s", timeout_duration.as_secs()))? } /// Checks if an error is an authentication error (including signing requirement). @@ -863,7 +889,7 @@ mod tests { auth_mode: AuthMode::GuestAllowed, from_cache: false, }; - cache_shares(host_id, &result); + cache_shares(host_id, &result, DEFAULT_CACHE_TTL_MS); // Should be cached now let cached = get_cached_shares(host_id); diff --git a/apps/desktop/src-tauri/src/settings.rs b/apps/desktop/src-tauri/src/settings.rs deleted file mode 100644 index ba2f263..0000000 --- a/apps/desktop/src-tauri/src/settings.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! User settings persistence. -//! -//! Reads settings from the tauri-plugin-store JSON file. -//! Used to initialize the menu with the correct checked state on startup. - -use serde::Deserialize; -use std::fs; -use std::path::PathBuf; -use tauri::Manager; - -/// User's choice regarding full disk access permission. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum FullDiskAccessChoice { - /// User clicked "Open System Settings" (presumably granted) - Allow, - /// User clicked "Deny" - don't ask again - Deny, - /// First launch, haven't shown prompt yet - #[default] - NotAskedYet, -} - -/// User settings structure, matching the frontend settings-store.ts -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Settings { - pub show_hidden_files: bool, - #[serde(default)] - #[allow(dead_code, reason = "Only used by frontend, backend just persists it")] - pub full_disk_access_choice: FullDiskAccessChoice, -} - -impl Default for Settings { - fn default() -> Self { - Self { - show_hidden_files: true, - full_disk_access_choice: FullDiskAccessChoice::NotAskedYet, - } - } -} - -/// Loads settings from the persistent store file. -/// Returns defaults if the file doesn't exist or can't be parsed. -pub fn load_settings(app: &tauri::AppHandle) -> Settings { - // Get the app data directory (e.g., ~/Library/Application Support/com.veszelovszki.cmdr/) - let Some(data_dir) = app.path().app_data_dir().ok() else { - return Settings::default(); - }; - - let settings_path: PathBuf = data_dir.join("settings.json"); - - // Try to read and parse the settings file - let Ok(contents) = fs::read_to_string(&settings_path) else { - return Settings::default(); - }; - - serde_json::from_str(&contents).unwrap_or_default() -} diff --git a/apps/desktop/src-tauri/src/settings/legacy.rs b/apps/desktop/src-tauri/src/settings/legacy.rs new file mode 100644 index 0000000..8babd43 --- /dev/null +++ b/apps/desktop/src-tauri/src/settings/legacy.rs @@ -0,0 +1,106 @@ +//! Settings loading from tauri-plugin-store JSON file. +//! +//! Reads settings from the settings-v2.json file created by tauri-plugin-store. +//! Used to initialize app state (menu checkboxes, MCP config) on startup. + +use serde::Deserialize; +use std::fs; +use std::path::PathBuf; +use tauri::Manager; + +/// User's choice regarding full disk access permission. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum FullDiskAccessChoice { + /// User clicked "Open System Settings" (presumably granted) + Allow, + /// User clicked "Deny" - don't ask again + Deny, + /// First launch, haven't shown prompt yet + #[default] + NotAskedYet, +} + +/// User settings structure, matching the frontend settings-store.ts +/// Note: Uses serde aliases to support both camelCase (settings-v2.json) and snake_case +#[derive(Debug, Deserialize)] +pub struct Settings { + #[serde(alias = "showHiddenFiles", default = "default_show_hidden")] + pub show_hidden_files: bool, + #[serde(alias = "fullDiskAccessChoice", default)] + #[allow(dead_code, reason = "Only used by frontend, backend just persists it")] + pub full_disk_access_choice: FullDiskAccessChoice, + #[serde(alias = "developer.mcpEnabled", default)] + pub developer_mcp_enabled: Option, + #[serde(alias = "developer.mcpPort", default)] + pub developer_mcp_port: Option, +} + +fn default_show_hidden() -> bool { + true +} + +impl Default for Settings { + fn default() -> Self { + Self { + show_hidden_files: true, + full_disk_access_choice: FullDiskAccessChoice::NotAskedYet, + developer_mcp_enabled: None, + developer_mcp_port: None, + } + } +} + +/// Loads settings from the persistent store file (settings-v2.json). +/// Returns defaults if the file doesn't exist or can't be parsed. +pub fn load_settings(app: &tauri::AppHandle) -> Settings { + // Get the app data directory (e.g., ~/Library/Application Support/com.veszelovszki.cmdr/) + let Some(data_dir) = app.path().app_data_dir().ok() else { + return Settings::default(); + }; + + // Try settings-v2.json first (new format from tauri-plugin-store) + let settings_v2_path: PathBuf = data_dir.join("settings-v2.json"); + if let Ok(contents) = fs::read_to_string(&settings_v2_path) + && let Ok(settings) = parse_settings_v2(&contents) + { + return settings; + } + + // Fall back to legacy settings.json + let settings_path: PathBuf = data_dir.join("settings.json"); + if let Ok(contents) = fs::read_to_string(&settings_path) + && let Ok(settings) = serde_json::from_str(&contents) + { + return settings; + } + + Settings::default() +} + +/// Parse settings-v2.json which uses dot notation for keys (e.g., "developer.mcpEnabled") +fn parse_settings_v2(contents: &str) -> Result { + // tauri-plugin-store uses flat JSON with dot notation keys + let json: serde_json::Value = serde_json::from_str(contents)?; + + let show_hidden_files = json.get("showHiddenFiles").and_then(|v| v.as_bool()).unwrap_or(true); + + let full_disk_access_choice = json + .get("fullDiskAccessChoice") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let developer_mcp_enabled = json.get("developer.mcpEnabled").and_then(|v| v.as_bool()); + + let developer_mcp_port = json + .get("developer.mcpPort") + .and_then(|v| v.as_u64()) + .and_then(|v| u16::try_from(v).ok()); + + Ok(Settings { + show_hidden_files, + full_disk_access_choice, + developer_mcp_enabled, + developer_mcp_port, + }) +} diff --git a/apps/desktop/src-tauri/src/settings/mod.rs b/apps/desktop/src-tauri/src/settings/mod.rs new file mode 100644 index 0000000..8e38d65 --- /dev/null +++ b/apps/desktop/src-tauri/src/settings/mod.rs @@ -0,0 +1,6 @@ +//! Settings module for legacy settings loading. + +mod legacy; + +// Re-export only what's used externally +pub use legacy::load_settings; diff --git a/apps/desktop/src/app.css b/apps/desktop/src/app.css index 7f54429..60e998e 100644 --- a/apps/desktop/src/app.css +++ b/apps/desktop/src/app.css @@ -44,10 +44,14 @@ --color-allow: #2e7d32; --color-error: #d32f2f; --color-warning: #e65100; + --color-warning-bg: rgba(230, 81, 0, 0.1); /* === Selection === */ --color-selection-fg: #c9a227; + /* === Search Highlight === */ + --color-highlight: rgba(255, 213, 0, 0.4); + /* === Size tier colors (20% offset toward target color) === */ --color-size-kb: color-mix(in srgb, var(--color-text-secondary) 70%, #f0c000); --color-size-mb: color-mix(in srgb, var(--color-text-secondary) 70%, #ff8c00); @@ -99,9 +103,13 @@ /* === Semantic Colors === */ --color-error: #f44336; --color-warning: #f5a623; + --color-warning-bg: rgba(245, 166, 35, 0.15); /* === Selection === */ --color-selection-fg: #d4a82a; + + /* === Search Highlight === */ + --color-highlight: rgba(255, 213, 0, 0.35); } } @@ -271,6 +279,18 @@ } } +/* ============================================================================= + Global Focus Styles + ============================================================================= */ + +/* Consistent focus-visible outline for all interactive elements */ +button:focus-visible, +a:focus-visible, +[role='button']:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + /* ============================================================================= Global Styles ============================================================================= */ diff --git a/apps/desktop/src/lib/app-status-store.ts b/apps/desktop/src/lib/app-status-store.ts index 94b91c8..7a710bb 100644 --- a/apps/desktop/src/lib/app-status-store.ts +++ b/apps/desktop/src/lib/app-status-store.ts @@ -296,3 +296,39 @@ export async function savePaletteQuery(query: string): Promise { // Silently fail } } + +// ============================================================================ +// Settings window section persistence +// ============================================================================ + +const DEFAULT_SETTINGS_SECTION = ['General', 'Appearance'] + +/** + * Loads the last viewed settings section. + * Returns default section if not previously saved. + */ +export async function loadLastSettingsSection(): Promise { + try { + const store = await getStore() + const section = await store.get('lastSettingsSection') + if (Array.isArray(section) && section.every((s): s is string => typeof s === 'string')) { + return section + } + return DEFAULT_SETTINGS_SECTION + } catch { + return DEFAULT_SETTINGS_SECTION + } +} + +/** + * Saves the current settings section for next time. + */ +export async function saveLastSettingsSection(section: string[]): Promise { + try { + const store = await getStore() + await store.set('lastSettingsSection', section) + await store.save() + } catch { + // Silently fail + } +} diff --git a/apps/desktop/src/lib/drag-drop.ts b/apps/desktop/src/lib/drag-drop.ts index d9f7349..bc0db19 100644 --- a/apps/desktop/src/lib/drag-drop.ts +++ b/apps/desktop/src/lib/drag-drop.ts @@ -5,9 +5,12 @@ import { startDrag } from '@crabnebula/tauri-plugin-drag' import { tempDir, join } from '@tauri-apps/api/path' import { getCachedIcon } from './icon-cache' import { startSelectionDrag } from './tauri-commands' +import { getSetting } from './settings/settings-store' -/** Minimum distance (in pixels) to trigger drag */ -export const DRAG_THRESHOLD = 5 +/** Gets the drag threshold from settings (minimum distance in pixels to trigger drag) */ +export function getDragThreshold(): number { + return getSetting('advanced.dragThreshold') +} /** Name of the temp icon file */ const TEMP_ICON_FILENAME = 'drag-icon.png' @@ -112,7 +115,7 @@ export function startSelectionDragTracking( const dy = moveEvent.clientY - activeDrag.startY const distance = Math.sqrt(dx * dx + dy * dy) - if (distance >= DRAG_THRESHOLD) { + if (distance >= getDragThreshold()) { // Threshold crossed - trigger the drag const ctx = activeDrag.context const cbs = activeDrag.callbacks diff --git a/apps/desktop/src/lib/file-explorer/BriefList.svelte b/apps/desktop/src/lib/file-explorer/BriefList.svelte index 70973be..03bcb25 100644 --- a/apps/desktop/src/lib/file-explorer/BriefList.svelte +++ b/apps/desktop/src/lib/file-explorer/BriefList.svelte @@ -13,7 +13,11 @@ calculateFetchRange, isRangeCached, shouldResetCache, + refetchIconsForEntries, } from './file-list-utils' + import { getRowHeight } from '$lib/settings/reactive-settings.svelte' + import { getSetting } from '$lib/settings/settings-store' + import { extensionCacheCleared } from '$lib/icon-cache' interface Props { listingId: string @@ -63,8 +67,10 @@ let isFetching = $state(false) // ==== Layout constants ==== - const ROW_HEIGHT = 20 - const BUFFER_COLUMNS = 2 + // Row height is reactive based on UI density setting + const rowHeight = $derived(getRowHeight()) + // Buffer columns is reactive based on settings + const bufferColumns = $derived(getSetting('advanced.virtualizationBufferColumns')) const MIN_COLUMN_WIDTH = 100 // const COLUMN_PADDING = 8 // horizontal padding inside each column (unused for now) @@ -76,7 +82,7 @@ // ==== Column layout calculations ==== // Number of items that fit in one column - const itemsPerColumn = $derived(Math.max(1, Math.floor(containerHeight / ROW_HEIGHT))) + const itemsPerColumn = $derived(Math.max(1, Math.floor(containerHeight / rowHeight))) // For column width: use backend-calculated width if available, otherwise estimate // Backend calculation is based on actual font metrics and considers all filenames @@ -99,7 +105,7 @@ calculateVirtualWindow({ direction: 'horizontal', itemSize: maxFilenameWidth, - bufferSize: BUFFER_COLUMNS, + bufferSize: bufferColumns, containerSize: containerWidth, scrollOffset: scrollLeft, totalItems: totalColumns, @@ -345,6 +351,15 @@ } prevContainerHeight = height }) + + // Re-fetch icons when the extension icon cache is cleared (settings change) + $effect(() => { + void $extensionCacheCleared // Track the store value + // Re-fetch icons for all cached entries + if (cachedEntries.length > 0) { + refetchIconsForEntries(cachedEntries) + } + })
@@ -412,6 +427,7 @@ class="file-entry" class:is-under-cursor={globalIndex === cursorIndex} class:is-selected={selectedIndices.has(globalIndex)} + style="height: {rowHeight}px;" onmousedown={(e: MouseEvent) => { handleMouseDown(e, globalIndex) }} @@ -494,7 +510,7 @@ .file-entry { display: flex; - height: 20px; + /* height is set via inline style for reactivity */ padding: var(--spacing-xxs) var(--spacing-sm); gap: var(--spacing-sm); align-items: center; diff --git a/apps/desktop/src/lib/file-explorer/FilePane.svelte b/apps/desktop/src/lib/file-explorer/FilePane.svelte index 0f34820..0d9a096 100644 --- a/apps/desktop/src/lib/file-explorer/FilePane.svelte +++ b/apps/desktop/src/lib/file-explorer/FilePane.svelte @@ -43,6 +43,7 @@ type PaneFileEntry, } from '$lib/tauri-commands' import type { ViewMode } from '$lib/app-status-store' + import { getMountTimeoutMs } from '$lib/settings/network-settings' import FullList from './FullList.svelte' import BriefList from './BriefList.svelte' import SelectionInfo from './SelectionInfo.svelte' @@ -841,6 +842,7 @@ share.name, credentials?.username ?? null, credentials?.password ?? null, + getMountTimeoutMs(), ) // Navigate to the mounted share diff --git a/apps/desktop/src/lib/file-explorer/FullList.svelte b/apps/desktop/src/lib/file-explorer/FullList.svelte index e21e4fc..abe9601 100644 --- a/apps/desktop/src/lib/file-explorer/FullList.svelte +++ b/apps/desktop/src/lib/file-explorer/FullList.svelte @@ -12,14 +12,21 @@ calculateFetchRange, isRangeCached, shouldResetCache, + refetchIconsForEntries, } from './file-list-utils' - import { formatSizeTriads, formatHumanReadable } from './selection-info-utils' + import { formatSizeTriads } from './selection-info-utils' import { - formatDateShort, getVisibleItemsCount as getVisibleItemsCountUtil, - FULL_LIST_ROW_HEIGHT, - FULL_LIST_BUFFER_SIZE, + getVirtualizationBufferRows, + measureDateColumnWidth, } from './full-list-utils' + import { + getRowHeight, + getIsCompactDensity, + formatDateTime, + formatFileSize, + } from '$lib/settings/reactive-settings.svelte' + import { extensionCacheCleared } from '$lib/icon-cache' interface Props { listingId: string @@ -67,8 +74,16 @@ let isFetching = $state(false) // ==== Virtual scrolling constants ==== - const ROW_HEIGHT = FULL_LIST_ROW_HEIGHT - const BUFFER_SIZE = FULL_LIST_BUFFER_SIZE + // Row height is reactive based on UI density setting + const rowHeight = $derived(getRowHeight()) + // Buffer size is reactive based on settings + const bufferSize = $derived(getVirtualizationBufferRows()) + // UI density for compact mode detection (uses reactive state from reactive-settings) + const isCompact = $derived(getIsCompactDensity()) + + // Dynamic date column width based on measured text width using the actual font. + // Measures multiple sample dates to find the maximum width needed. + const dateColumnWidth = $derived(measureDateColumnWidth(formatDateTime)) // ==== Virtual scrolling state ==== let scrollContainer: HTMLDivElement | undefined = $state() @@ -79,8 +94,8 @@ const virtualWindow = $derived( calculateVirtualWindow({ direction: 'vertical', - itemSize: ROW_HEIGHT, - bufferSize: BUFFER_SIZE, + itemSize: rowHeight, + bufferSize, containerSize: containerHeight, scrollOffset: scrollTop, totalItems: totalCount, @@ -225,7 +240,7 @@ // Exported for parent to call when arrow keys change cursor position export function scrollToIndex(index: number) { if (!scrollContainer) return - const newScrollTop = getScrollToPosition(index, ROW_HEIGHT, scrollTop, containerHeight) + const newScrollTop = getScrollToPosition(index, rowHeight, scrollTop, containerHeight) if (newScrollTop !== undefined) { scrollContainer.scrollTop = newScrollTop } @@ -251,13 +266,22 @@ // Returns the number of visible items (for Page Up/Down navigation) export function getVisibleItemsCount(): number { - return getVisibleItemsCountUtil(containerHeight, ROW_HEIGHT) + return getVisibleItemsCountUtil(containerHeight, rowHeight) } + + // Re-fetch icons when the extension icon cache is cleared (settings change) + $effect(() => { + void $extensionCacheCleared // Track the store value + // Re-fetch icons for all cached entries + if (cachedEntries.length > 0) { + refetchIconsForEntries(cachedEntries) + } + }) -
+
-
+
{ handleMouseDown(e, globalIndex) }} @@ -320,7 +345,7 @@ > {file.name} - + {#if file.isDirectory} <dir> {:else if file.size !== undefined} @@ -329,7 +354,7 @@ {/each} {/if} - {formatDateShort(file.modifiedAt)} + {formatDateTime(file.modifiedAt)}
{/each}
@@ -357,7 +382,7 @@ .header-row { display: grid; - grid-template-columns: 16px 1fr 85px 120px; + /* grid-template-columns set via inline style for dynamic date column width */ gap: var(--spacing-sm); padding: var(--spacing-xxs) var(--spacing-sm); background: var(--color-bg-header); @@ -380,11 +405,16 @@ .file-entry { display: grid; - height: 20px; + /* height and grid-template-columns set via inline style for reactivity */ padding: var(--spacing-xxs) var(--spacing-sm); gap: var(--spacing-sm); align-items: center; - grid-template-columns: 16px 1fr 85px 120px; + } + + /* In compact mode, use symmetric padding to match BriefList alignment */ + .full-list-container.is-compact .file-entry { + padding-top: 0; + padding-bottom: 4px; } .file-entry.is-under-cursor { diff --git a/apps/desktop/src/lib/file-explorer/SelectionInfo.svelte b/apps/desktop/src/lib/file-explorer/SelectionInfo.svelte index 155fca2..8754710 100644 --- a/apps/desktop/src/lib/file-explorer/SelectionInfo.svelte +++ b/apps/desktop/src/lib/file-explorer/SelectionInfo.svelte @@ -1,10 +1,8 @@ @@ -216,7 +224,7 @@ {/each} {/if} - {dateDisplay} + {dateDisplay} {:else if displayMode === 'no-selection'} {noSelectionText} @@ -271,7 +279,7 @@ .date { flex-shrink: 0; - width: 140px; + /* width is set via inline style based on formatted date length */ text-align: right; font-size: calc(var(--font-size-sm) * 0.9); } diff --git a/apps/desktop/src/lib/file-explorer/ShareBrowser.svelte b/apps/desktop/src/lib/file-explorer/ShareBrowser.svelte index da6710e..5e622d4 100644 --- a/apps/desktop/src/lib/file-explorer/ShareBrowser.svelte +++ b/apps/desktop/src/lib/file-explorer/ShareBrowser.svelte @@ -18,6 +18,7 @@ getSmbCredentials, updateKnownShare, } from '$lib/tauri-commands' + import { getNetworkTimeoutMs, getShareCacheTtlMs } from '$lib/settings/network-settings' import NetworkLoginForm from './NetworkLoginForm.svelte' import { handleNavigationShortcut } from './keyboard-shortcuts' @@ -156,6 +157,8 @@ host.port, username, password, + getNetworkTimeoutMs(), + getShareCacheTtlMs(), ) shares = result.shares diff --git a/apps/desktop/src/lib/file-explorer/file-list-utils.test.ts b/apps/desktop/src/lib/file-explorer/file-list-utils.test.ts index 62ff7ce..9f61004 100644 --- a/apps/desktop/src/lib/file-explorer/file-list-utils.test.ts +++ b/apps/desktop/src/lib/file-explorer/file-list-utils.test.ts @@ -10,7 +10,7 @@ import { calculateFetchRange, isRangeCached, shouldResetCache, - PREFETCH_BUFFER, + getPrefetchBufferSize, fetchVisibleRange, } from './file-list-utils' import type { FileEntry } from './types' @@ -22,6 +22,12 @@ vi.mock('$lib/tauri-commands', () => ({ vi.mock('$lib/icon-cache', () => ({ prefetchIcons: vi.fn(), })) +vi.mock('$lib/settings/reactive-settings.svelte', () => ({ + getUseAppIconsForDocuments: vi.fn().mockReturnValue(true), +})) +vi.mock('$lib/settings/settings-store', () => ({ + getSetting: vi.fn().mockReturnValue(200), +})) import { getFileRange } from '$lib/tauri-commands' @@ -199,6 +205,9 @@ describe('getFallbackEmoji', () => { }) describe('calculateFetchRange', () => { + // getPrefetchBufferSize() returns 200 (mocked) + const prefetchBuffer = getPrefetchBufferSize() + it('calculates range without parent entry', () => { const result = calculateFetchRange({ startItem: 150, @@ -206,9 +215,9 @@ describe('calculateFetchRange', () => { hasParent: false, totalCount: 500, }) - // PREFETCH_BUFFER is 200, so buffer is 100 on each side - expect(result.fetchStart).toBe(150 - PREFETCH_BUFFER / 2) // 50 - expect(result.fetchEnd).toBe(160 + PREFETCH_BUFFER / 2) // 260 + // prefetchBuffer is 200, so buffer is 100 on each side + expect(result.fetchStart).toBe(150 - prefetchBuffer / 2) // 50 + expect(result.fetchEnd).toBe(160 + prefetchBuffer / 2) // 260 }) it('calculates range with parent entry', () => { @@ -219,8 +228,8 @@ describe('calculateFetchRange', () => { totalCount: 500, }) // With parent, indices are shifted down by 1 - expect(result.fetchStart).toBe(149 - PREFETCH_BUFFER / 2) // 49 - expect(result.fetchEnd).toBe(159 + PREFETCH_BUFFER / 2) // 259 + expect(result.fetchStart).toBe(149 - prefetchBuffer / 2) // 49 + expect(result.fetchEnd).toBe(159 + prefetchBuffer / 2) // 259 }) it('clamps fetchStart to 0', () => { diff --git a/apps/desktop/src/lib/file-explorer/file-list-utils.ts b/apps/desktop/src/lib/file-explorer/file-list-utils.ts index e9b05cc..78f3fd3 100644 --- a/apps/desktop/src/lib/file-explorer/file-list-utils.ts +++ b/apps/desktop/src/lib/file-explorer/file-list-utils.ts @@ -5,9 +5,13 @@ import type { FileEntry, SyncStatus } from './types' import { getFileRange } from '$lib/tauri-commands' import { prefetchIcons } from '$lib/icon-cache' +import { getUseAppIconsForDocuments } from '$lib/settings/reactive-settings.svelte' +import { getSetting } from '$lib/settings/settings-store' -/** Prefetch buffer - load this many items around visible range */ -export const PREFETCH_BUFFER = 200 +/** Gets the prefetch buffer size from settings (items to load around visible range) */ +export function getPrefetchBufferSize(): number { + return getSetting('advanced.prefetchBufferSize') +} /** Sync status icon paths - returns undefined if no icon should be shown */ export function getSyncIconPath(status: SyncStatus | undefined): string | undefined { @@ -103,8 +107,9 @@ export function calculateFetchRange(params: { } // Add prefetch buffer - const fetchStart = Math.max(0, adjustedStart - PREFETCH_BUFFER / 2) - const fetchEnd = Math.min(hasParent ? totalCount - 1 : totalCount, adjustedEnd + PREFETCH_BUFFER / 2) + const prefetchBuffer = getPrefetchBufferSize() + const fetchStart = Math.max(0, adjustedStart - prefetchBuffer / 2) + const fetchEnd = Math.min(hasParent ? totalCount - 1 : totalCount, adjustedEnd + prefetchBuffer / 2) return { fetchStart, fetchEnd } } @@ -134,7 +139,8 @@ export async function fetchVisibleRange(params: FetchRangeParams): Promise e.iconId).filter((id) => id) - void prefetchIcons(iconIds) + const useAppIcons = getUseAppIconsForDocuments() + void prefetchIcons(iconIds, useAppIcons) // Request sync status for visible paths const paths = entries.map((e) => e.path) @@ -158,3 +164,14 @@ export function shouldResetCache( current.cacheGeneration !== previous.cacheGeneration ) } + +/** + * Re-fetches icons for already-cached entries. + * Called when the extension icon cache is cleared to refresh icons for visible files. + */ +export function refetchIconsForEntries(entries: FileEntry[]): void { + if (entries.length === 0) return + const iconIds = entries.map((e) => e.iconId).filter((id) => id) + const useAppIcons = getUseAppIconsForDocuments() + void prefetchIcons(iconIds, useAppIcons) +} diff --git a/apps/desktop/src/lib/file-explorer/full-list-utils.test.ts b/apps/desktop/src/lib/file-explorer/full-list-utils.test.ts index 43ef6d7..68a2562 100644 --- a/apps/desktop/src/lib/file-explorer/full-list-utils.test.ts +++ b/apps/desktop/src/lib/file-explorer/full-list-utils.test.ts @@ -1,16 +1,26 @@ /** * Tests for full-list-utils.ts */ -import { describe, it, expect } from 'vitest' -import { getVisibleItemsCount, formatDateShort, FULL_LIST_ROW_HEIGHT, FULL_LIST_BUFFER_SIZE } from './full-list-utils' +import { describe, it, expect, vi } from 'vitest' +import { + getVisibleItemsCount, + formatDateShort, + FULL_LIST_ROW_HEIGHT, + getVirtualizationBufferRows, +} from './full-list-utils' + +// Mock the settings store +vi.mock('$lib/settings/settings-store', () => ({ + getSetting: vi.fn().mockReturnValue(20), // Default buffer size +})) describe('constants', () => { it('has expected row height', () => { expect(FULL_LIST_ROW_HEIGHT).toBe(20) }) - it('has expected buffer size', () => { - expect(FULL_LIST_BUFFER_SIZE).toBe(20) + it('has expected buffer size from settings', () => { + expect(getVirtualizationBufferRows()).toBe(20) }) }) diff --git a/apps/desktop/src/lib/file-explorer/full-list-utils.ts b/apps/desktop/src/lib/file-explorer/full-list-utils.ts index 1ad71d8..aa1ac38 100644 --- a/apps/desktop/src/lib/file-explorer/full-list-utils.ts +++ b/apps/desktop/src/lib/file-explorer/full-list-utils.ts @@ -3,9 +3,15 @@ * Extracted for testability. */ +import { getSetting } from '$lib/settings/settings-store' + /** Layout constants for Full mode */ export const FULL_LIST_ROW_HEIGHT = 20 -export const FULL_LIST_BUFFER_SIZE = 20 + +/** Gets the virtualization buffer size from settings (rows above/below visible area) */ +export function getVirtualizationBufferRows(): number { + return getSetting('advanced.virtualizationBufferRows') +} /** Calculates the number of visible items based on container height */ export function getVisibleItemsCount(containerHeight: number, rowHeight: number = FULL_LIST_ROW_HEIGHT): number { @@ -24,3 +30,105 @@ export function formatDateShort(timestamp: number | undefined): string { const mins = pad(date.getMinutes()) return `${String(year)}-${month}-${day} ${hours}:${mins}` } + +// ============================================================================ +// Date Column Width Measurement +// ============================================================================ + +/** + * The date column font specification matching CSS: var(--font-size-xs) = 12px, system font. + * Used for accurate text width measurement via Canvas API. + */ +const DATE_COLUMN_FONT = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif' + +/** Extra padding for the date column width (accounts for rounding and breathing room) */ +const DATE_COLUMN_PADDING = 8 + +/** Minimum date column width to prevent collapsing on very short formats */ +const DATE_COLUMN_MIN_WIDTH = 70 + +/** Cached canvas context for text measurement (reused for performance) */ +let measureCanvas: CanvasRenderingContext2D | null = null + +/** + * Get or create a canvas context for text measurement. + * The canvas is created once and reused for performance. + */ +function getMeasureContext(): CanvasRenderingContext2D | null { + if (!measureCanvas) { + // Check if we're in a browser environment (may be SSR) + if (typeof document === 'undefined') return null + const canvas = document.createElement('canvas') + measureCanvas = canvas.getContext('2d') + if (measureCanvas) { + measureCanvas.font = DATE_COLUMN_FONT + } + } + return measureCanvas +} + +/** + * Measure the pixel width of text using the date column's actual font styling. + * Uses the Canvas API for fast, accurate measurement without DOM manipulation. + * + * @param text Text to measure + * @returns Width in pixels, or 0 if measurement fails + */ +function measureTextWidth(text: string): number { + const ctx = getMeasureContext() + if (!ctx) return 0 + return ctx.measureText(text).width +} + +/** + * Generate sample date strings for width measurement based on the format. + * Tests various digit combinations since different digits have different widths. + * For example, '8' is typically the widest digit in most fonts. + * + * @param formatFn Function that formats a timestamp to a date string + * @returns Array of sample date strings to measure + */ +function generateSampleDateStrings(formatFn: (timestamp: number) => string): string[] { + // Create dates with various digit patterns to find the maximum width. + // We use real dates that produce the desired digit patterns when formatted. + // Note: Month is 0-indexed in Date constructor. + const sampleDates = [ + // Dates that produce various digit patterns + new Date(1111, 10, 11, 11, 11, 11), // "1111-11-11 11:11" pattern + new Date(2022, 11, 22, 22, 22, 22), // "2022-12-22 22:22" pattern + new Date(2028, 7, 28, 18, 28, 28), // Contains many 8s (typically widest) + new Date(2000, 9, 10, 10, 10, 10), // "2000-10-10 10:10" pattern with 0s + new Date(2024, 11, 31, 23, 59, 59), // End of year/day (large numbers) + new Date(2024, 0, 1, 0, 0, 0), // Start of year/day + new Date(2088, 7, 8, 8, 8, 8), // Many 8s for maximum width + new Date(2008, 8, 8, 8, 8, 8), // Another 8-heavy date + ] + + // Convert dates to Unix timestamps (seconds) and format + return sampleDates.map((date) => formatFn(date.getTime() / 1000)) +} + +/** + * Measure the optimal date column width based on the current date/time format. + * Tests multiple sample strings to find the maximum width needed to display + * any possible date without truncation. + * + * @param formatFn Function that formats a timestamp (seconds) to a date string + * @returns Optimal column width in pixels + */ +export function measureDateColumnWidth(formatFn: (timestamp: number) => string): number { + const samples = generateSampleDateStrings(formatFn) + + // Measure each sample and find the maximum width + let maxWidth = 0 + for (const sample of samples) { + const width = measureTextWidth(sample) + if (width > maxWidth) { + maxWidth = width + } + } + + // Add padding and enforce minimum width + // Use Math.ceil to avoid subpixel rendering issues + return Math.max(DATE_COLUMN_MIN_WIDTH, Math.ceil(maxWidth) + DATE_COLUMN_PADDING) +} diff --git a/apps/desktop/src/lib/file-explorer/integration.test.ts b/apps/desktop/src/lib/file-explorer/integration.test.ts index 96129eb..d4f4234 100644 --- a/apps/desktop/src/lib/file-explorer/integration.test.ts +++ b/apps/desktop/src/lib/file-explorer/integration.test.ts @@ -94,6 +94,13 @@ vi.mock('$lib/icon-cache', async () => { } }) +vi.mock('$lib/settings/reactive-settings.svelte', () => ({ + getRowHeight: vi.fn().mockReturnValue(24), + formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'), + formatFileSize: vi.fn().mockReturnValue('1.0 KB'), + getUseAppIconsForDocuments: vi.fn().mockReturnValue(true), +})) + vi.mock('$lib/drag-drop', () => ({ startDragTracking: vi.fn(), })) diff --git a/apps/desktop/src/lib/file-explorer/types.ts b/apps/desktop/src/lib/file-explorer/types.ts index 28fa04e..8a221b3 100644 --- a/apps/desktop/src/lib/file-explorer/types.ts +++ b/apps/desktop/src/lib/file-explorer/types.ts @@ -346,6 +346,8 @@ export interface WriteOperationConfig { sortOrder?: SortOrder /** Preview scan ID to reuse cached scan results (from start_scan_preview) */ previewId?: string | null + /** Maximum number of conflicts to include in DryRunResult (default: 100) */ + maxConflictsToShow?: number } /** Result of starting a write operation. */ diff --git a/apps/desktop/src/lib/file-explorer/view-modes.test.ts b/apps/desktop/src/lib/file-explorer/view-modes.test.ts index dda3a34..3f856c8 100644 --- a/apps/desktop/src/lib/file-explorer/view-modes.test.ts +++ b/apps/desktop/src/lib/file-explorer/view-modes.test.ts @@ -20,6 +20,14 @@ vi.mock('$lib/icon-cache', () => ({ prefetchIcons: vi.fn(), })) +// Mock reactive-settings +vi.mock('$lib/settings/reactive-settings.svelte', () => ({ + getRowHeight: vi.fn().mockReturnValue(24), + formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'), + formatFileSize: vi.fn().mockReturnValue('1.0 KB'), + getUseAppIconsForDocuments: vi.fn().mockReturnValue(true), +})) + // Mock drag-and-drop vi.mock('$lib/drag-and-drop', () => ({ startDragTracking: vi.fn(), diff --git a/apps/desktop/src/lib/icon-cache.ts b/apps/desktop/src/lib/icon-cache.ts index 2cf9280..dbe05f7 100644 --- a/apps/desktop/src/lib/icon-cache.ts +++ b/apps/desktop/src/lib/icon-cache.ts @@ -2,7 +2,11 @@ // Caches icon data URLs by icon ID to avoid redundant Tauri calls import { writable } from 'svelte/store' -import { getIcons, refreshDirectoryIcons as refreshIconsCommand } from './tauri-commands' +import { + getIcons, + refreshDirectoryIcons as refreshIconsCommand, + clearExtensionIconCache as clearExtensionIconCacheCommand, +} from './tauri-commands' const STORAGE_KEY = 'cmdr-icon-cache' @@ -15,6 +19,12 @@ const memoryCache = new Map() */ export const iconCacheVersion = writable(0) +/** + * Reactive counter that increments when extension icon cache is cleared. + * List components subscribe to this to re-fetch icons for visible files. + */ +export const extensionCacheCleared = writable(0) + /** Load persisted cache from localStorage */ function loadFromStorage(): void { try { @@ -52,14 +62,17 @@ if (typeof localStorage !== 'undefined') { * Prefetches icons for the given IDs. * Fetches only those not already cached. * Increments iconCacheVersion when new icons are loaded, triggering re-renders. + * + * @param iconIds - Array of icon IDs to prefetch + * @param useAppIconsForDocuments - Whether to use app icons as fallback for documents */ -export async function prefetchIcons(iconIds: string[]): Promise { +export async function prefetchIcons(iconIds: string[], useAppIconsForDocuments: boolean): Promise { const uncached = iconIds.filter((id) => !memoryCache.has(id)) if (uncached.length === 0) return // Deduplicate const unique = [...new Set(uncached)] - const icons = await getIcons(unique) + const icons = await getIcons(unique, useAppIconsForDocuments) let added = false for (const [id, url] of Object.entries(icons)) { @@ -89,12 +102,19 @@ export function getCachedIcon(iconId: string): string | undefined { * - All unique extensions (for file association changes) * * Updates the cache and triggers re-render if any icons changed. + * @param directoryPaths - Array of directory paths to fetch icons for + * @param extensions - Array of file extensions (without dot) + * @param useAppIconsForDocuments - Whether to use app icons as fallback for documents * @knipignore Used via dynamic import in FilePane.svelte */ -export async function refreshDirectoryIcons(directoryPaths: string[], extensions: string[]): Promise { +export async function refreshDirectoryIcons( + directoryPaths: string[], + extensions: string[], + useAppIconsForDocuments: boolean, +): Promise { if (directoryPaths.length === 0 && extensions.length === 0) return - const icons = await refreshIconsCommand(directoryPaths, extensions) + const icons = await refreshIconsCommand(directoryPaths, extensions, useAppIconsForDocuments) let changed = false for (const [id, url] of Object.entries(icons)) { @@ -110,3 +130,31 @@ export async function refreshDirectoryIcons(directoryPaths: string[], extensions iconCacheVersion.update((v) => v + 1) } } + +/** + * Clears all cached extension icons from both memory and localStorage. + * Called when the "use app icons for documents" setting changes. + * After calling this, extension icons will be re-fetched with the new setting. + */ +export async function clearExtensionIconCache(): Promise { + // Clear backend cache + await clearExtensionIconCacheCommand() + + // Clear frontend cache (extension icons only) + for (const key of memoryCache.keys()) { + if (key.startsWith('ext:')) { + memoryCache.delete(key) + } + } + + // Persist the change + saveToStorage() + + // Notify list components to re-fetch icons for visible files + // This must happen BEFORE incrementing iconCacheVersion so components + // can re-fetch before re-rendering with the cleared cache + extensionCacheCleared.update((v) => v + 1) + + // Trigger reactive update so components re-fetch icons + iconCacheVersion.update((v) => v + 1) +} diff --git a/apps/desktop/src/lib/licensing/LicenseKeyDialog.svelte b/apps/desktop/src/lib/licensing/LicenseKeyDialog.svelte index f75f2f2..217636e 100644 --- a/apps/desktop/src/lib/licensing/LicenseKeyDialog.svelte +++ b/apps/desktop/src/lib/licensing/LicenseKeyDialog.svelte @@ -200,7 +200,6 @@ placeholder="Example: CMDR-ABCD-EFGH-1234" spellcheck="false" autocomplete="off" - autocorrect="off" autocapitalize="off" disabled={isActivating} onkeydown={handleInputKeydown} @@ -290,7 +289,7 @@ width: 100%; padding: 12px 14px; font-size: 14px; - font-family: var(--font-system); + font-family: var(--font-system) sans-serif; background: var(--color-bg-primary); border: 1px solid var(--color-border-primary); border-radius: 8px; diff --git a/apps/desktop/src/lib/logger.ts b/apps/desktop/src/lib/logger.ts index 616bc3c..56a5f2e 100644 --- a/apps/desktop/src/lib/logger.ts +++ b/apps/desktop/src/lib/logger.ts @@ -14,14 +14,17 @@ * Default behavior: * - Dev mode: info+ for all, but specific features can enable debug * - Prod mode: error+ only + * - Verbose logging setting: when enabled, all categories get debug level * - * To enable debug logs for a feature, add it to debugCategories below. + * To enable debug logs for a feature, add it to debugCategories below, + * or enable the "Verbose logging" setting in Developer settings. * * @module logger */ import { configure, getConsoleSink, getLogger as getLogTapeLogger } from '@logtape/logtape' import type { Logger } from '@logtape/logtape' +import { load, type Store } from '@tauri-apps/plugin-store' // Re-export getLogger for convenience export { getLogger } from '@logtape/logtape' @@ -41,33 +44,69 @@ const debugCategories: string[] = [ // 'licensing', // 'copyProgress', // Enable to debug copy operation progress events // 'viewer', // Enable to debug file viewer streaming/caching + 'settings', // Enable to debug settings dialog initialization and persistence + 'reactive-settings', // Enable to debug reactive settings updates + 'shortcuts', // Enable to debug keyboard shortcut persistence ] +// Track if verbose logging is enabled for reconfiguration +let verboseLoggingEnabled = false +let loggerInitialized = false + /** - * Initialize the logging system. Call once at app startup. + * Read the verbose logging setting directly from the store file. + * This is needed because the logger initializes before the full settings system. */ -export async function initLogger(): Promise { - // Build logger configs: base config + debug overrides for specific features +async function getVerboseLoggingSetting(): Promise { + try { + // Use empty defaults since we just want to read existing values + const store: Store = await load('settings-v2.json', { + autoSave: false, + defaults: {}, + }) + const value = await store.get('developer.verboseLogging') + return value === true + } catch { + // Store doesn't exist yet or can't be read - use default + return false + } +} + +/** + * Build and apply logger configuration. + * @param verbose - Whether to enable debug logging for all categories + * @param isReset - Whether this is a reconfiguration (requires reset flag) + */ +async function applyLoggerConfig(verbose: boolean, isReset: boolean): Promise { const loggers: Array<{ category: string | string[] lowestLevel: 'debug' | 'info' | 'warning' | 'error' sinks: string[] - }> = [ - // Base config: info in dev, error in prod - { - category: 'app', - lowestLevel: isDev ? 'info' : 'error', - sinks: ['console'], - }, - ] + }> = [] - // Add debug-level loggers for specific categories - for (const cat of debugCategories) { + if (verbose) { + // Verbose mode: debug for all categories loggers.push({ - category: ['app', cat], + category: 'app', lowestLevel: 'debug', sinks: ['console'], }) + } else { + // Normal mode: info in dev, error in prod + loggers.push({ + category: 'app', + lowestLevel: isDev ? 'info' : 'error', + sinks: ['console'], + }) + + // Add debug-level loggers for specific categories + for (const cat of debugCategories) { + loggers.push({ + category: ['app', cat], + lowestLevel: 'debug', + sinks: ['console'], + }) + } } await configure({ @@ -75,17 +114,57 @@ export async function initLogger(): Promise { console: getConsoleSink(), }, loggers, + reset: isReset, }) +} + +/** + * Initialize the logging system. Call once at app startup. + */ +export async function initLogger(): Promise { + if (loggerInitialized) { + return + } + + // Read verbose logging setting from store (before full settings system is up) + verboseLoggingEnabled = await getVerboseLoggingSetting() + + await applyLoggerConfig(verboseLoggingEnabled, false) + loggerInitialized = true if (isDev) { const log = getLogTapeLogger(['app', 'logger']) - log.info('Logger initialized (dev mode, info+)') - if (debugCategories.length > 0) { - log.info('Debug enabled for: {categories}', { categories: debugCategories.join(', ') }) + if (verboseLoggingEnabled) { + log.info('Logger initialized (verbose mode, debug+ for all)') + } else { + log.info('Logger initialized (dev mode, info+)') + if (debugCategories.length > 0) { + log.info('Debug enabled for: {categories}', { categories: debugCategories.join(', ') }) + } } } } +/** + * Enable or disable verbose logging at runtime. + * Called when the developer.verboseLogging setting changes. + */ +export async function setVerboseLogging(enabled: boolean): Promise { + if (enabled === verboseLoggingEnabled) { + return + } + + verboseLoggingEnabled = enabled + await applyLoggerConfig(enabled, true) + + const log = getLogTapeLogger(['app', 'logger']) + if (enabled) { + log.info('Verbose logging enabled - debug level for all categories') + } else { + log.info('Verbose logging disabled - returning to normal log levels') + } +} + /** * Get a logger for a specific feature. * Categories are hierarchical, for example ['app', 'fileExplorer', 'selection']. diff --git a/apps/desktop/src/lib/network-store.svelte.ts b/apps/desktop/src/lib/network-store.svelte.ts index eaf1778..a06cb38 100644 --- a/apps/desktop/src/lib/network-store.svelte.ts +++ b/apps/desktop/src/lib/network-store.svelte.ts @@ -13,6 +13,7 @@ import { prefetchShares as prefetchSharesCmd, getSmbCredentials, } from '$lib/tauri-commands' +import { getNetworkTimeoutMs, getShareCacheTtlMs } from '$lib/settings/network-settings' import type { UnlistenFn } from '$lib/tauri-commands' import type { NetworkHost, DiscoveryState, ShareListResult, ShareListError } from './file-explorer/types' @@ -87,7 +88,14 @@ function startPrefetchShares(host: NetworkHost) { prefetchingHosts.add(host.id) - void prefetchSharesCmd(host.id, host.hostname, host.ipAddress, host.port) + void prefetchSharesCmd( + host.id, + host.hostname, + host.ipAddress, + host.port, + getNetworkTimeoutMs(), + getShareCacheTtlMs(), + ) .then(() => { // Prefetch succeeded - backend has cached it if (!shareStates.has(host.id)) { @@ -110,7 +118,14 @@ async function fetchSharesSilent(host: NetworkHost): Promise { if (!host.hostname) return try { - const result = await listSharesOnHost(host.id, host.hostname, host.ipAddress, host.port) + const result = await listSharesOnHost( + host.id, + host.hostname, + host.ipAddress, + host.port, + getNetworkTimeoutMs(), + getShareCacheTtlMs(), + ) shareStates.set(host.id, { status: 'loaded', result, fetchedAt: Date.now() }) } catch (error) { const shareError = error as ShareListError @@ -242,16 +257,13 @@ export function isListingShares(hostId: string): boolean { return shareStates.get(hostId)?.status === 'loading' } -/** Share data is considered stale after 30 seconds (matches backend cache TTL). */ -const STALE_THRESHOLD_MS = 30_000 - /** - * Check if share data is stale (older than 30 seconds). + * Check if share data is stale (older than the configured cache TTL). */ export function isShareDataStale(hostId: string): boolean { const state = shareStates.get(hostId) if (!state || state.status === 'loading') return false - return Date.now() - state.fetchedAt > STALE_THRESHOLD_MS + return Date.now() - state.fetchedAt > getShareCacheTtlMs() } /** @@ -267,7 +279,14 @@ export async function fetchShares(host: NetworkHost): Promise { shareStates.set(host.id, { status: 'loading' }) try { - const result = await listSharesOnHost(host.id, host.hostname, host.ipAddress, host.port) + const result = await listSharesOnHost( + host.id, + host.hostname, + host.ipAddress, + host.port, + getNetworkTimeoutMs(), + getShareCacheTtlMs(), + ) shareStates.set(host.id, { status: 'loaded', result, fetchedAt: Date.now() }) return result } catch (error) { diff --git a/apps/desktop/src/lib/settings/components/SectionSummary.svelte b/apps/desktop/src/lib/settings/components/SectionSummary.svelte new file mode 100644 index 0000000..5e0a782 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SectionSummary.svelte @@ -0,0 +1,111 @@ + + +
+

{sectionName}

+ + {#if section && section.subsections.length > 0} +
+ {#each section.subsections as subsection (subsection.name)} + + {/each} +
+ {:else} +

This section has no subsections.

+ {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte b/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte new file mode 100644 index 0000000..8032711 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte @@ -0,0 +1,126 @@ + + +
+ + + βˆ’ + + + + + + + {#if unit} + {unit} + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte b/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte new file mode 100644 index 0000000..0a67596 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte @@ -0,0 +1,140 @@ + + + +
+ {#each options as option (option.value)} + + + + {option.label} + {#if option.description} + {option.description} + {/if} + + + + {/each} + + {#if customContent} +
+ {@render customContent(value)} +
+ {/if} +
+
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingRow.svelte b/apps/desktop/src/lib/settings/components/SettingRow.svelte new file mode 100644 index 0000000..c841826 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingRow.svelte @@ -0,0 +1,168 @@ + + +
+
+
+ {#if modified} + ● + {/if} + + {#if disabled && disabledReason} + {disabledReason} + {/if} + {#if requiresRestart} + Restart required + {/if} +
+
+ {@render children()} +
+
+

{description}

+ +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingSelect.svelte b/apps/desktop/src/lib/settings/components/SettingSelect.svelte new file mode 100644 index 0000000..2e36b49 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingSelect.svelte @@ -0,0 +1,368 @@ + + +
+ {#if showCustomInput} +
+ { + if (e.key === 'Enter') handleCustomSubmit() + }} + placeholder="Enter custom value" + min={definition?.constraints?.customMin} + max={definition?.constraints?.customMax} + {disabled} + /> + +
+ {:else} + + + + + β–Ό + + + + { + if (e.key === 'Escape') { + e.stopPropagation() + } + }} + > + {#each options as option (option.value)} + + + {option.label} + {#if option.description} + β€” {option.description} + {/if} + + βœ“ + + {/each} + {#if allowCustom && isCustomValue} + + Custom: {String(value)} + βœ“ + + {/if} + {#if allowCustom} + + Custom... + + {/if} + + + + + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingSlider.svelte b/apps/desktop/src/lib/settings/components/SettingSlider.svelte new file mode 100644 index 0000000..be956c5 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingSlider.svelte @@ -0,0 +1,226 @@ + + +
+ + + + + + + + {#if sliderStops.length > 0} +
+ {#each sliderStops as stop (stop)} + + {/each} +
+ {/if} +
+
+ + + + + + + + {#if unit} + {unit} + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingSwitch.svelte b/apps/desktop/src/lib/settings/components/SettingSwitch.svelte new file mode 100644 index 0000000..1c754a2 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingSwitch.svelte @@ -0,0 +1,76 @@ + + + + + + + + + + diff --git a/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte b/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte new file mode 100644 index 0000000..7ae386f --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte @@ -0,0 +1,87 @@ + + + + {#each options as option (option.value)} + + {option.label} + + {/each} + + + diff --git a/apps/desktop/src/lib/settings/components/SettingsContent.svelte b/apps/desktop/src/lib/settings/components/SettingsContent.svelte new file mode 100644 index 0000000..f7bf380 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingsContent.svelte @@ -0,0 +1,150 @@ + + +
+ + {#if showSummary} + + {:else} + + {#if shouldShowSection(['General', 'Appearance'])} +
+ +
+ {/if} + + {#if shouldShowSection(['General', 'File operations'])} +
+ +
+ {/if} + + {#if shouldShowSection(['General', 'Updates'])} +
+ +
+ {/if} + + + {#if shouldShowSection(['Network', 'SMB/Network shares'])} +
+ +
+ {/if} + + + {#if shouldShowSection( ['Keyboard shortcuts'], ) || (isTopLevelSection && selectedSection[0] === 'Keyboard shortcuts')} +
+ +
+ {/if} + + {#if shouldShowSection(['Themes']) || (isTopLevelSection && selectedSection[0] === 'Themes')} +
+ +
+ {/if} + + + {#if shouldShowSection(['Developer', 'MCP server'])} +
+ +
+ {/if} + + {#if shouldShowSection(['Developer', 'Logging'])} +
+ +
+ {/if} + + + {#if shouldShowSection(['Advanced']) || (isTopLevelSection && selectedSection[0] === 'Advanced')} +
+ +
+ {/if} + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte b/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte new file mode 100644 index 0000000..3a1acb1 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte @@ -0,0 +1,320 @@ + + + + + diff --git a/apps/desktop/src/lib/settings/format-utils.ts b/apps/desktop/src/lib/settings/format-utils.ts new file mode 100644 index 0000000..4e8ab8f --- /dev/null +++ b/apps/desktop/src/lib/settings/format-utils.ts @@ -0,0 +1,99 @@ +/** + * Formatting utilities for settings-based display. + * These functions are pure and don't need reactive state. + */ + +import type { DateTimeFormat, FileSizeFormat } from './types' + +/** + * Format a timestamp according to the given format. + * @param timestamp Unix timestamp in seconds + * @param format The date/time format to use + * @param customFormat Custom format string (used when format is 'custom') + */ +export function formatDateTimeWithFormat( + timestamp: number | undefined, + format: DateTimeFormat, + customFormat: string, +): string { + if (timestamp === undefined) return '' + + const date = new Date(timestamp * 1000) + + switch (format) { + case 'system': + return date.toLocaleString() + + case 'iso': + return formatIso(date) + + case 'short': + return formatShort(date) + + case 'custom': + return formatCustom(date, customFormat) + + default: + return formatIso(date) + } +} + +function formatIso(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0') + const year = date.getFullYear() + const month = pad(date.getMonth() + 1) + const day = pad(date.getDate()) + const hours = pad(date.getHours()) + const mins = pad(date.getMinutes()) + return `${String(year)}-${month}-${day} ${hours}:${mins}` +} + +function formatShort(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0') + const month = pad(date.getMonth() + 1) + const day = pad(date.getDate()) + const hours = pad(date.getHours()) + const mins = pad(date.getMinutes()) + return `${month}/${day} ${hours}:${mins}` +} + +function formatCustom(date: Date, format: string): string { + const pad = (n: number) => String(n).padStart(2, '0') + return format + .replace('YYYY', String(date.getFullYear())) + .replace('MM', pad(date.getMonth() + 1)) + .replace('DD', pad(date.getDate())) + .replace('HH', pad(date.getHours())) + .replace('mm', pad(date.getMinutes())) + .replace('ss', pad(date.getSeconds())) +} + +// ============================================================================ +// File Size Formatting +// ============================================================================ + +// Binary units (base 1024) - traditional computing units +const binaryUnits = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'] + +// SI units (base 1000) - International System of Units +const siUnits = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB'] + +/** + * Format bytes as human-readable string based on the format setting. + * @param bytes Number of bytes + * @param format 'binary' uses 1024-based (KB/MB/GB), 'si' uses 1000-based (kB/MB/GB) + */ +export function formatFileSizeWithFormat(bytes: number, format: FileSizeFormat): string { + const base = format === 'binary' ? 1024 : 1000 + const units = format === 'binary' ? binaryUnits : siUnits + + let value = bytes + let unitIndex = 0 + while (value >= base && unitIndex < units.length - 1) { + value /= base + unitIndex++ + } + + const valueStr = unitIndex === 0 ? String(value) : value.toFixed(2) + return `${valueStr} ${units[unitIndex]}` +} diff --git a/apps/desktop/src/lib/settings/index.ts b/apps/desktop/src/lib/settings/index.ts new file mode 100644 index 0000000..8bd34f0 --- /dev/null +++ b/apps/desktop/src/lib/settings/index.ts @@ -0,0 +1,63 @@ +/** + * Settings module public API. + */ + +// Types +export type { + DateTimeFormat, + DensityValues, + DurationUnit, + EnumOption, + FileSizeFormat, + NetworkTimeoutMode, + SettingConstraints, + SettingDefinition, + SettingId, + SettingSearchResult, + SettingsValues, + SettingType, + ThemeMode, + UiDensity, +} from './types' + +export { densityMappings, formatDuration, fromMilliseconds, SettingValidationError, toMilliseconds } from './types' + +// Registry +export { + buildSectionTree, + getAdvancedSettings, + getDefaultValue, + getSettingDefinition, + getSettingsInSection, + settingsRegistry, + validateSettingValue, +} from './settings-registry' + +export type { SettingsSection } from './settings-registry' + +// Store +export { + forceSave, + getAllSettings, + getSetting, + initializeSettings, + isModified, + onSettingChange, + onSpecificSettingChange, + resetAllSettings, + resetSetting, + setSetting, +} from './settings-store' + +// Search +export { + clearSearchIndex, + getMatchingSections, + highlightMatches, + searchAdvancedSettings, + searchSettings, + sectionHasMatches, +} from './settings-search' + +// Network settings helpers +export { getMountTimeoutMs, getNetworkTimeoutMs, getShareCacheTtlMs } from './network-settings' diff --git a/apps/desktop/src/lib/settings/mcp-settings-bridge.ts b/apps/desktop/src/lib/settings/mcp-settings-bridge.ts new file mode 100644 index 0000000..de5fd29 --- /dev/null +++ b/apps/desktop/src/lib/settings/mcp-settings-bridge.ts @@ -0,0 +1,263 @@ +/** + * MCP Settings Bridge - syncs settings state with the Rust backend for MCP tools. + */ + +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { buildSectionTree, getSetting, setSetting, settingsRegistry, isModified } from '$lib/settings' +import type { SettingId, SettingsValues } from '$lib/settings' +import { + getEffectiveShortcuts, + getDefaultShortcuts, + isShortcutModified, + setShortcut, + removeShortcut, + resetShortcut, +} from '$lib/shortcuts' +import { commands } from '$lib/commands/command-registry' +import { getAppLogger } from '$lib/logger' + +const log = getAppLogger('mcp-settings') + +interface SettingsSection { + name: string + path: string[] + subsections: SettingsSection[] +} + +interface SettingItem { + id: string + label: string + description: string + settingType: string + value: unknown + defaultValue: unknown + isModified: boolean + constraints?: unknown +} + +interface ShortcutCommand { + id: string + name: string + scope: string + shortcuts: string[] + defaultShortcuts: string[] + isModified: boolean +} + +/** + * Convert the section tree to the format expected by the Rust backend. + */ +function convertSectionTree(sections: ReturnType): SettingsSection[] { + return sections.map((section) => ({ + name: section.name, + path: section.path, + subsections: convertSectionTree(section.subsections), + })) +} + +/** + * Get all settings for the current section. + */ +function getSettingsForSection(sectionPath: string[]): SettingItem[] { + const items: SettingItem[] = [] + + for (const setting of settingsRegistry) { + // Check if this setting belongs to the current section + if ( + sectionPath.length <= setting.section.length && + sectionPath.every((part, i) => setting.section[i] === part) + ) { + const id = setting.id as SettingId + const value = getSetting(id) + items.push({ + id: setting.id, + label: setting.label, + description: setting.description, + settingType: setting.type, + value, + defaultValue: setting.default, + isModified: isModified(id), + constraints: setting.constraints, + }) + } + } + + return items +} + +/** + * Get all shortcut commands. + */ +function getAllShortcuts(): ShortcutCommand[] { + return commands.map((cmd) => ({ + id: cmd.id, + name: cmd.name, + scope: cmd.scope, + shortcuts: getEffectiveShortcuts(cmd.id), + defaultShortcuts: getDefaultShortcuts(cmd.id), + isModified: isShortcutModified(cmd.id), + })) +} + +/** + * Sync the current settings state to the Rust backend. + */ +export async function syncSettingsState(selectedSection: string[]): Promise { + try { + const sectionTree = buildSectionTree() + + // Add special sections that aren't in the registry tree + const sections = convertSectionTree(sectionTree) + sections.push({ + name: 'Keyboard shortcuts', + path: ['Keyboard shortcuts'], + subsections: [], + }) + sections.push({ + name: 'Advanced', + path: ['Advanced'], + subsections: [], + }) + + await invoke('mcp_update_settings_sections', { sections }) + await invoke('mcp_update_settings_section', { section: selectedSection }) + await invoke('mcp_update_current_settings', { settings: getSettingsForSection(selectedSection) }) + await invoke('mcp_update_shortcuts', { shortcuts: getAllShortcuts() }) + + log.debug('Synced settings state to backend') + } catch (error) { + log.error('Failed to sync settings state: {error}', { error }) + } +} + +/** + * Notify the backend that the settings window is open/closed. + */ +export async function notifySettingsWindowOpen(isOpen: boolean): Promise { + try { + await invoke('mcp_update_settings_open', { isOpen }) + log.debug('Notified backend of settings window state: {isOpen}', { isOpen }) + } catch (error) { + log.error('Failed to notify settings window state: {error}', { error }) + } +} + +// Event handlers +let unlistenFns: UnlistenFn[] = [] + +interface McpSelectSectionPayload { + sectionPath: string[] +} + +interface McpSetValuePayload { + settingId: string + value: unknown +} + +interface McpShortcutsSetPayload { + commandId: string + index: number + shortcut: string +} + +interface McpShortcutsRemovePayload { + commandId: string + index: number +} + +interface McpShortcutsResetPayload { + commandId: string +} + +/** + * Set up MCP event listeners for the settings window. + */ +export async function setupMcpEventListeners( + onSectionSelect: (sectionPath: string[]) => void, + onSettingChanged: () => void, +): Promise { + // Listen for close request + const unlistenClose = await listen('mcp-settings-close', () => { + log.debug('MCP requested settings window close') + void getCurrentWindow().close() + }) + unlistenFns.push(unlistenClose) + + // Listen for section selection + const unlistenSection = await listen('mcp-settings-select-section', (event) => { + log.debug('MCP requested section select: {section}', { section: event.payload.sectionPath.join(' > ') }) + onSectionSelect(event.payload.sectionPath) + }) + unlistenFns.push(unlistenSection) + + // Listen for value changes + const unlistenValue = await listen('mcp-settings-set-value', (event) => { + const { settingId, value } = event.payload + log.debug('MCP requested setting change: {settingId} = {value}', { settingId, value }) + + try { + setSetting(settingId as SettingId, value as SettingsValues[SettingId]) + onSettingChanged() + } catch (error) { + log.error('Failed to set setting via MCP: {error}', { error }) + } + }) + unlistenFns.push(unlistenValue) + + // Listen for shortcut set + const unlistenShortcutsSet = await listen('mcp-shortcuts-set', (event) => { + const { commandId, index, shortcut } = event.payload + log.debug('MCP requested shortcut set: {commandId}[{index}] = {shortcut}', { commandId, index, shortcut }) + + try { + setShortcut(commandId, index, shortcut) + onSettingChanged() + } catch (error) { + log.error('Failed to set shortcut via MCP: {error}', { error }) + } + }) + unlistenFns.push(unlistenShortcutsSet) + + // Listen for shortcut remove + const unlistenShortcutsRemove = await listen('mcp-shortcuts-remove', (event) => { + const { commandId, index } = event.payload + log.debug('MCP requested shortcut remove: {commandId}[{index}]', { commandId, index }) + + try { + removeShortcut(commandId, index) + onSettingChanged() + } catch (error) { + log.error('Failed to remove shortcut via MCP: {error}', { error }) + } + }) + unlistenFns.push(unlistenShortcutsRemove) + + // Listen for shortcut reset + const unlistenShortcutsReset = await listen('mcp-shortcuts-reset', (event) => { + const { commandId } = event.payload + log.debug('MCP requested shortcut reset: {commandId}', { commandId }) + + try { + resetShortcut(commandId) + onSettingChanged() + } catch (error) { + log.error('Failed to reset shortcut via MCP: {error}', { error }) + } + }) + unlistenFns.push(unlistenShortcutsReset) + + log.debug('MCP event listeners set up') +} + +/** + * Clean up MCP event listeners. + */ +export function cleanupMcpEventListeners(): void { + for (const unlisten of unlistenFns) { + unlisten() + } + unlistenFns = [] + log.debug('MCP event listeners cleaned up') +} diff --git a/apps/desktop/src/lib/settings/network-settings.ts b/apps/desktop/src/lib/settings/network-settings.ts new file mode 100644 index 0000000..c5f1535 --- /dev/null +++ b/apps/desktop/src/lib/settings/network-settings.ts @@ -0,0 +1,47 @@ +/** + * Network settings helper - provides computed values for network operations. + * Centralizes the logic for calculating timeout and cache values from settings. + */ + +import { getSetting } from './settings-store' + +// Timeout values in milliseconds for each mode +const timeoutModeToMs = { + normal: 15_000, // 15 seconds + slow: 45_000, // 45 seconds + custom: 15_000, // Will be overridden by customTimeout +} as const + +/** + * Gets the network timeout in milliseconds based on current settings. + * For 'normal' mode: 15 seconds + * For 'slow' mode: 45 seconds + * For 'custom' mode: uses the customTimeout setting + */ +export function getNetworkTimeoutMs(): number { + const mode = getSetting('network.timeoutMode') + + if (mode === 'custom') { + // customTimeout is in seconds, convert to ms + const customTimeoutSec = getSetting('network.customTimeout') + return customTimeoutSec * 1000 + } + + return timeoutModeToMs[mode] +} + +/** + * Gets the mount timeout in milliseconds. + * Uses the advanced.mountTimeout setting which is stored in milliseconds. + */ +export function getMountTimeoutMs(): number { + return getSetting('advanced.mountTimeout') +} + +/** + * Gets the share cache TTL (Time To Live) in milliseconds. + * Uses the network.shareCacheDuration setting which is stored in milliseconds. + */ +export function getShareCacheTtlMs(): number { + return getSetting('network.shareCacheDuration') +} diff --git a/apps/desktop/src/lib/settings/reactive-settings.svelte.ts b/apps/desktop/src/lib/settings/reactive-settings.svelte.ts new file mode 100644 index 0000000..495ee54 --- /dev/null +++ b/apps/desktop/src/lib/settings/reactive-settings.svelte.ts @@ -0,0 +1,132 @@ +/** + * Reactive settings state for Svelte components. + * Provides $state-based values that update immediately when settings change. + */ + +import { + getSetting, + onSettingChange, + initializeSettings, + type UiDensity, + type DateTimeFormat, + type FileSizeFormat, + densityMappings, +} from '$lib/settings' +import { formatDateTimeWithFormat, formatFileSizeWithFormat } from './format-utils' +import { getAppLogger } from '$lib/logger' +import { clearExtensionIconCache } from '$lib/icon-cache' + +const log = getAppLogger('reactive-settings') + +// Reactive state for settings that affect UI rendering +let uiDensity = $state('comfortable') +let dateTimeFormat = $state('iso') +let customDateTimeFormat = $state('YYYY-MM-DD HH:mm') +let fileSizeFormat = $state('binary') +let useAppIconsForDocuments = $state(true) + +let initialized = false +let unsubscribe: (() => void) | undefined + +/** + * Initialize reactive settings. Call once on app startup. + */ +export async function initReactiveSettings(): Promise { + if (initialized) return + + log.debug('Initializing reactive settings') + + try { + await initializeSettings() + + // Load initial values + uiDensity = getSetting('appearance.uiDensity') + dateTimeFormat = getSetting('appearance.dateTimeFormat') + customDateTimeFormat = getSetting('appearance.customDateTimeFormat') + fileSizeFormat = getSetting('appearance.fileSizeFormat') + useAppIconsForDocuments = getSetting('appearance.useAppIconsForDocuments') + + // Subscribe to changes (including cross-window changes) + unsubscribe = onSettingChange((id, value) => { + log.debug('Received setting change: {id} = {value}', { id, value }) + + switch (id) { + case 'appearance.uiDensity': + log.info('Applying UI density change: {value}', { value }) + uiDensity = value as UiDensity + break + case 'appearance.dateTimeFormat': + log.info('Applying date/time format change: {value}', { value }) + dateTimeFormat = value as DateTimeFormat + break + case 'appearance.customDateTimeFormat': + log.debug('Applying custom date format change: {value}', { value }) + customDateTimeFormat = value as string + break + case 'appearance.fileSizeFormat': + log.info('Applying file size format change: {value}', { value }) + fileSizeFormat = value as FileSizeFormat + break + case 'appearance.useAppIconsForDocuments': + log.info('Applying app icons for documents change: {value}', { value }) + useAppIconsForDocuments = value as boolean + // Clear the icon cache so icons are re-fetched with the new setting + void clearExtensionIconCache() + break + } + }) + + initialized = true + log.info('Reactive settings initialized') + } catch (error) { + log.error('Failed to initialize reactive settings: {error}', { error }) + } +} + +/** + * Cleanup reactive settings. + */ +export function cleanupReactiveSettings(): void { + unsubscribe?.() + unsubscribe = undefined + initialized = false +} + +// ============================================================================ +// Getters for reactive values (use these in components) +// ============================================================================ + +/** Get current row height based on density */ +export function getRowHeight(): number { + return densityMappings[uiDensity].rowHeight +} + +/** Get whether the current density is compact */ +export function getIsCompactDensity(): boolean { + return uiDensity === 'compact' +} + +/** Get current "use app icons for documents" setting */ +export function getUseAppIconsForDocuments(): boolean { + return useAppIconsForDocuments +} + +// ============================================================================ +// Formatting utilities that use reactive settings +// ============================================================================ + +/** + * Format a timestamp according to current settings. + * @param timestamp Unix timestamp in seconds + */ +export function formatDateTime(timestamp: number | undefined): string { + return formatDateTimeWithFormat(timestamp, dateTimeFormat, customDateTimeFormat) +} + +/** + * Format bytes as human-readable string according to current settings. + * @param bytes Number of bytes + */ +export function formatFileSize(bytes: number): string { + return formatFileSizeWithFormat(bytes, fileSizeFormat) +} diff --git a/apps/desktop/src/lib/settings/sections/AdvancedSection.svelte b/apps/desktop/src/lib/settings/sections/AdvancedSection.svelte new file mode 100644 index 0000000..b8a5352 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/AdvancedSection.svelte @@ -0,0 +1,364 @@ + + +
+

Advanced

+ +
+ ⚠️ + + These settings are for advanced users. Incorrect values may cause performance issues or unexpected behavior. + +
+ +
+ +
+ +
+ {#each filteredSettings as setting (`${setting.id}-${String(settingsChangeCounter)}`)} + {@const id = setting.id as SettingId} + {@const modified = isModified(id)} +
+
+
+ {#if modified} + ● + {/if} + {#each getLabelSegments(setting.label, setting.id) as segment, i (i)}{#if segment.matched}{segment.text}{:else}{segment.text}{/if}{/each} +
+
{setting.description}
+
+ Default: {setting.type === 'duration' + ? formatDuration(setting.default as number) + : String(setting.default)} + {#if modified} + + {/if} +
+
+ +
+ {#if setting.type === 'boolean'} + { + handleBooleanChange(id, d.checked) + }} + > + + + + + + {:else if setting.type === 'number' || setting.type === 'duration'} + { + handleNumberChange(id, d) + }} + min={setting.constraints?.min ?? setting.constraints?.minMs} + max={setting.constraints?.max ?? setting.constraints?.maxMs} + step={setting.constraints?.step ?? 1} + > + + βˆ’ + + + + + + {#if setting.type === 'duration' && setting.constraints?.unit} + {setting.constraints.unit} + {:else if setting.type === 'number'} + + {setting.id.includes('Threshold') ? 'px' : setting.id.includes('Buffer') ? 'items' : ''} + + {/if} + {/if} +
+
+ {/each} +
+
+ + diff --git a/apps/desktop/src/lib/settings/sections/AppearanceSection.svelte b/apps/desktop/src/lib/settings/sections/AppearanceSection.svelte new file mode 100644 index 0000000..008e929 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/AppearanceSection.svelte @@ -0,0 +1,233 @@ + + +
+

Appearance

+ + {#if shouldShow('appearance.uiDensity')} + + + + {/if} + + {#if shouldShow('appearance.useAppIconsForDocuments')} + + + + {/if} + + {#if shouldShow('appearance.fileSizeFormat')} + + + + {/if} + + {#if shouldShow('appearance.dateTimeFormat')} + +
+ + {#snippet customContent(value)} + {#if value === 'custom'} +
+ +
+ Preview: {formatPreview(customFormat)} +
+ + {#if showFormatHelp} +
+

Format placeholders

+
    +
  • YYYY β€” 4-digit year (2025)
  • +
  • MM β€” 2-digit month (01-12)
  • +
  • DD β€” 2-digit day (01-31)
  • +
  • HH β€” 2-digit hour (00-23)
  • +
  • mm β€” 2-digit minute (00-59)
  • +
  • ss β€” 2-digit second (00-59)
  • +
+
+ {/if} +
+ {/if} + {/snippet} +
+
+
+ {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/sections/FileOperationsSection.svelte b/apps/desktop/src/lib/settings/sections/FileOperationsSection.svelte new file mode 100644 index 0000000..79f3e8d --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/FileOperationsSection.svelte @@ -0,0 +1,96 @@ + + +
+

File operations

+ + {#if shouldShow('fileOperations.confirmBeforeDelete')} + + + + {/if} + + {#if shouldShow('fileOperations.deletePermanently')} + + + + {/if} + + {#if shouldShow('fileOperations.progressUpdateInterval')} + + + + {/if} + + {#if shouldShow('fileOperations.maxConflictsToShow')} + + + + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/sections/KeyboardShortcutsSection.svelte b/apps/desktop/src/lib/settings/sections/KeyboardShortcutsSection.svelte new file mode 100644 index 0000000..672e50f --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/KeyboardShortcutsSection.svelte @@ -0,0 +1,776 @@ + + +
+

Keyboard shortcuts

+ +
+
+ (localNameSearchQuery = (e.target as HTMLInputElement).value)} + disabled={!!searchQuery.trim()} + autocomplete="off" + autocapitalize="off" + spellcheck="false" + /> + +
+ +
+ + + +
+
+ + {#if conflictWarning} +
+ ⚠️ + + {conflictWarning.shortcut} is already bound to "{conflictWarning.conflictingCommand + .name}" + +
+ + + +
+
+ {/if} + +
+ {#each Object.entries(groupedCommands) as [scope, scopeCommands] (scope)} +
+

{scope}

+ {#each scopeCommands as command (`${command.id}-${String(shortcutChangeCounter)}`)} + {@const shortcuts = getEffectiveShortcuts(command.id)} + {@const isModified = isShortcutModified(command.id)} + {@const hasConflicts = conflictingIds.has(command.id)} +
+
+ {#if isModified} + + {/if} + {#if hasConflicts} + ⚠️ + {/if} + {command.name} +
+
+ {#if shortcuts.length > 0} + {#each shortcuts as shortcut, i (i)} + {@const isEditing = + editingShortcut !== null && + editingShortcut.commandId === command.id && + editingShortcut.index === i} + + {/each} + {:else} + β€” + {/if} + + {#if isModified} + + {/if} +
+
+ {/each} +
+ {/each} +
+ + +
+ + diff --git a/apps/desktop/src/lib/settings/sections/LoggingSection.svelte b/apps/desktop/src/lib/settings/sections/LoggingSection.svelte new file mode 100644 index 0000000..fcd9f4c --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/LoggingSection.svelte @@ -0,0 +1,111 @@ + + +
+

Logging

+ + {#if shouldShow('developer.verboseLogging')} + + + + {/if} + +
+ + +
+
+ + diff --git a/apps/desktop/src/lib/settings/sections/McpServerSection.svelte b/apps/desktop/src/lib/settings/sections/McpServerSection.svelte new file mode 100644 index 0000000..c140558 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/McpServerSection.svelte @@ -0,0 +1,177 @@ + + +
+

MCP server

+ + {#if shouldShow('developer.mcpEnabled')} + + + + {/if} + + {#if shouldShow('developer.mcpPort')} + +
+ + +
+
+ + {#if portStatus === 'checking'} +
Checking port availability...
+ {:else if portStatus === 'available'} +
Port is available
+ {:else if portStatus === 'unavailable'} +
+ Port is in use + {#if suggestedPort} + + {/if} +
+ {/if} + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/sections/NetworkSection.svelte b/apps/desktop/src/lib/settings/sections/NetworkSection.svelte new file mode 100644 index 0000000..e7be13f --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/NetworkSection.svelte @@ -0,0 +1,86 @@ + + +
+

SMB/Network shares

+ + {#if shouldShow('network.shareCacheDuration')} + + + + {/if} + + {#if shouldShow('network.timeoutMode')} + +
+ + {#snippet customContent(value)} + {#if value === 'custom'} +
+ +
+ {/if} + {/snippet} +
+
+
+ {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/sections/ThemesSection.svelte b/apps/desktop/src/lib/settings/sections/ThemesSection.svelte new file mode 100644 index 0000000..e18bef8 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/ThemesSection.svelte @@ -0,0 +1,119 @@ + + +
+

Themes

+ + {#if shouldShow('theme.mode')} + + + + {/if} + + {#if !searchQuery.trim()} + +
+

Preset themes

+

Custom color themes are coming in a future update.

+
+ + +
+

Custom theme editor

+

Create and customize your own color schemes. Coming soon!

+
+ {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/sections/UpdatesSection.svelte b/apps/desktop/src/lib/settings/sections/UpdatesSection.svelte new file mode 100644 index 0000000..c4a55dd --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/UpdatesSection.svelte @@ -0,0 +1,53 @@ + + +
+

Updates

+ + {#if shouldShow('updates.autoCheck')} + + + + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/settings-applier.ts b/apps/desktop/src/lib/settings/settings-applier.ts new file mode 100644 index 0000000..fea1a0f --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-applier.ts @@ -0,0 +1,125 @@ +/** + * Settings applier - applies settings changes to the UI and Rust backend in real-time. + * Updates CSS variables, DOM properties, and syncs backend configurations when settings change. + */ + +import { getSetting, onSettingChange, initializeSettings, type UiDensity, densityMappings } from '$lib/settings' +import { getAppLogger, setVerboseLogging } from '$lib/logger' +import { updateFileWatcherDebounce, updateServiceResolveTimeout } from '$lib/tauri-commands' + +const log = getAppLogger('settings-applier') + +let initialized = false +let unsubscribe: (() => void) | undefined + +/** + * Applies UI density settings to CSS custom properties. + */ +function applyDensity(density: UiDensity): void { + const values = densityMappings[density] + document.documentElement.style.setProperty('--row-height', `${String(values.rowHeight)}px`) + document.documentElement.style.setProperty('--icon-size', `${String(values.iconSize)}px`) + document.documentElement.style.setProperty('--density-spacing', `${String(values.spacing)}px`) + log.debug('Applied density: {density}', { density }) +} + +/** + * Applies Rust backend settings that need to be synced on startup. + */ +async function applyBackendSettings(): Promise { + // File watcher debounce + const debounceMs = getSetting('advanced.fileWatcherDebounce') + await updateFileWatcherDebounce(debounceMs) + + // Service resolve timeout + const resolveTimeoutMs = getSetting('advanced.serviceResolveTimeout') + await updateServiceResolveTimeout(resolveTimeoutMs) + + log.debug('Applied backend settings: debounce={debounce}ms, resolveTimeout={timeout}ms', { + debounce: debounceMs, + timeout: resolveTimeoutMs, + }) +} + +/** + * Applies all settings that affect the UI. + */ +function applyAllSettings(): void { + // UI Density + const density = getSetting('appearance.uiDensity') + applyDensity(density) + + // Backend settings (async, fire-and-forget for startup) + void applyBackendSettings() + + log.debug('Applied all settings') +} + +/** + * Handles setting changes and applies them to the UI or backend. + */ +function handleSettingChange(id: string, value: unknown): void { + log.debug('Setting changed: {id} = {value}', { id, value }) + + switch (id) { + case 'appearance.uiDensity': + applyDensity(value as UiDensity) + break + case 'developer.verboseLogging': + // Reconfigure logger when verbose logging setting changes + void setVerboseLogging(value as boolean) + break + case 'advanced.fileWatcherDebounce': + // Update Rust backend file watcher debounce + void updateFileWatcherDebounce(value as number) + break + case 'advanced.serviceResolveTimeout': + // Update Rust backend Bonjour resolve timeout + void updateServiceResolveTimeout(value as number) + break + // Other settings that need immediate UI updates can be added here + // Date/time format and file size format are read on-demand when rendering, + // so they don't need to trigger a re-render here + } +} + +/** + * Initialize the settings applier. + * Call this once on app startup in the main window. + */ +export async function initSettingsApplier(): Promise { + if (initialized) { + log.debug('Settings applier already initialized') + return + } + + log.info('Initializing settings applier') + + try { + // Ensure settings store is initialized + await initializeSettings() + + // Apply current settings + applyAllSettings() + + // Subscribe to future changes + unsubscribe = onSettingChange(handleSettingChange) + initialized = true + + log.info('Settings applier initialized successfully') + } catch (error) { + log.error('Failed to initialize settings applier: {error}', { error }) + } +} + +/** + * Cleanup the settings applier. + */ +export function cleanupSettingsApplier(): void { + if (unsubscribe) { + unsubscribe() + unsubscribe = undefined + } + initialized = false + log.debug('Settings applier cleaned up') +} diff --git a/apps/desktop/src/lib/settings/settings-registry.test.ts b/apps/desktop/src/lib/settings/settings-registry.test.ts new file mode 100644 index 0000000..52137a8 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-registry.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest' +import { + settingsRegistry, + getSettingDefinition, + getDefaultValue, + getSettingsInSection, + getAdvancedSettings, + validateSettingValue, + buildSectionTree, +} from './settings-registry' + +describe('settingsRegistry', () => { + it('should have at least one setting defined', () => { + expect(settingsRegistry.length).toBeGreaterThan(0) + }) + + it('should have unique IDs for all settings', () => { + const ids = settingsRegistry.map((s) => s.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(ids.length) + }) + + it('should have non-empty sections for all settings', () => { + for (const setting of settingsRegistry) { + expect(setting.section.length).toBeGreaterThan(0) + expect(setting.section[0]).toBeTruthy() + } + }) +}) + +describe('getSettingDefinition', () => { + it('should return definition for existing setting', () => { + const def = getSettingDefinition('appearance.uiDensity') + expect(def).toBeDefined() + expect(def?.id).toBe('appearance.uiDensity') + expect(def?.type).toBe('enum') + }) + + it('should return undefined for non-existent setting', () => { + const def = getSettingDefinition('nonexistent.setting') + expect(def).toBeUndefined() + }) +}) + +describe('getDefaultValue', () => { + it('should return default value for settings', () => { + const value = getDefaultValue('appearance.uiDensity') + expect(value).toBe('comfortable') + }) + + it('should return correct defaults for boolean settings', () => { + const value = getDefaultValue('fileOperations.confirmBeforeDelete') + expect(value).toBe(true) + }) + + it('should return correct defaults for number settings', () => { + const value = getDefaultValue('fileOperations.progressUpdateInterval') + expect(typeof value).toBe('number') + }) +}) + +describe('getSettingsInSection', () => { + it('should return settings in General section', () => { + const settings = getSettingsInSection(['General']) + expect(settings.length).toBeGreaterThan(0) + for (const setting of settings) { + expect(setting.section[0]).toBe('General') + } + }) + + it('should return settings in nested section', () => { + const settings = getSettingsInSection(['General', 'Appearance']) + expect(settings.length).toBeGreaterThan(0) + for (const setting of settings) { + expect(setting.section).toEqual(['General', 'Appearance']) + } + }) + + it('should return empty array for non-existent section', () => { + const settings = getSettingsInSection(['NonExistent']) + expect(settings).toEqual([]) + }) +}) + +describe('getAdvancedSettings', () => { + it('should return settings marked as showInAdvanced', () => { + const advanced = getAdvancedSettings() + expect(advanced.length).toBeGreaterThan(0) + for (const setting of advanced) { + expect(setting.showInAdvanced).toBe(true) + } + }) +}) + +describe('validateSettingValue', () => { + it('should validate enum values', () => { + // Valid + expect(() => { + validateSettingValue('appearance.uiDensity', 'compact') + }).not.toThrow() + expect(() => { + validateSettingValue('appearance.uiDensity', 'comfortable') + }).not.toThrow() + expect(() => { + validateSettingValue('appearance.uiDensity', 'spacious') + }).not.toThrow() + + // Invalid + expect(() => { + validateSettingValue('appearance.uiDensity', 'invalid') + }).toThrow() + }) + + it('should validate boolean values', () => { + // Valid + expect(() => { + validateSettingValue('fileOperations.confirmBeforeDelete', true) + }).not.toThrow() + expect(() => { + validateSettingValue('fileOperations.confirmBeforeDelete', false) + }).not.toThrow() + + // Invalid + expect(() => { + validateSettingValue('fileOperations.confirmBeforeDelete', 'yes') + }).toThrow() + }) + + it('should validate number values with constraints', () => { + // Valid + expect(() => { + validateSettingValue('fileOperations.progressUpdateInterval', 100) + }).not.toThrow() + + // Invalid - below min + expect(() => { + validateSettingValue('fileOperations.progressUpdateInterval', 0) + }).toThrow() + }) +}) + +describe('buildSectionTree', () => { + it('should build a tree from settings', () => { + const tree = buildSectionTree() + expect(Array.isArray(tree)).toBe(true) + expect(tree.length).toBeGreaterThan(0) + }) + + it('should have General section at top level', () => { + const tree = buildSectionTree() + const general = tree.find((s) => s.name === 'General') + expect(general).toBeDefined() + }) + + it('should have nested Appearance subsection under General', () => { + const tree = buildSectionTree() + const general = tree.find((s) => s.name === 'General') + expect(general?.subsections.some((s) => s.name === 'Appearance')).toBe(true) + }) + + it('should have path arrays matching section hierarchy', () => { + const tree = buildSectionTree() + for (const section of tree) { + expect(section.path).toEqual([section.name]) + } + }) +}) diff --git a/apps/desktop/src/lib/settings/settings-registry.ts b/apps/desktop/src/lib/settings/settings-registry.ts new file mode 100644 index 0000000..beace57 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-registry.ts @@ -0,0 +1,628 @@ +/** + * Settings registry - single source of truth for all settings. + * See docs/specs/settings.md for full specification. + */ + +import type { SettingDefinition, SettingId, SettingsValues } from './types' +import { SettingValidationError } from './types' + +// ============================================================================ +// Settings Definitions +// ============================================================================ + +export const settingsRegistry: SettingDefinition[] = [ + // ======================================================================== + // General β€Ί Appearance + // ======================================================================== + { + id: 'appearance.uiDensity', + section: ['General', 'Appearance'], + label: 'UI density', + description: 'Controls the spacing and size of UI elements throughout the app.', + keywords: ['compact', 'comfortable', 'spacious', 'size', 'spacing', 'dense'], + type: 'enum', + default: 'comfortable', + component: 'toggle-group', + constraints: { + options: [ + { value: 'compact', label: 'Compact' }, + { value: 'comfortable', label: 'Comfortable' }, + { value: 'spacious', label: 'Spacious' }, + ], + }, + }, + { + id: 'appearance.useAppIconsForDocuments', + section: ['General', 'Appearance'], + label: 'Use app icons for documents', + description: + "Show the app's icon for documents instead of generic file type icons. More colorful but slightly slower.", + keywords: ['icon', 'document', 'file', 'app', 'colorful', 'finder'], + type: 'boolean', + default: true, + component: 'switch', + }, + { + id: 'appearance.fileSizeFormat', + section: ['General', 'Appearance'], + label: 'File size format', + description: 'How to display file sizes in the file list.', + keywords: ['size', 'bytes', 'binary', 'decimal', 'kb', 'mb', 'kib', 'mib'], + type: 'enum', + default: 'binary', + component: 'select', + constraints: { + options: [ + { value: 'binary', label: 'Binary (KiB, MiB, GiB)', description: '1 KiB = 1024 bytes' }, + { value: 'si', label: 'SI decimal (KB, MB, GB)', description: '1 KB = 1000 bytes' }, + ], + }, + }, + { + id: 'appearance.dateTimeFormat', + section: ['General', 'Appearance'], + label: 'Date and time format', + description: 'How to display dates and times in the file list.', + keywords: ['date', 'time', 'format', 'iso', 'custom', 'timestamp'], + type: 'enum', + default: 'system', + component: 'radio', + constraints: { + options: [ + { value: 'system', label: 'System default' }, + { value: 'iso', label: 'ISO 8601', description: 'e.g., 2025-01-25 14:30' }, + { value: 'short', label: 'Short', description: 'e.g., Jan 25, 2:30 PM' }, + { value: 'custom', label: 'Custom...' }, + ], + allowCustom: true, + }, + }, + { + id: 'appearance.customDateTimeFormat', + section: ['General', 'Appearance'], + label: 'Custom date/time format', + description: 'Format string for custom date/time display. Use placeholders like YYYY, MM, DD, HH, mm, ss.', + keywords: ['custom', 'format', 'date', 'time', 'placeholder'], + type: 'string', + default: 'YYYY-MM-DD HH:mm', + component: 'text-input', + }, + + // ======================================================================== + // General β€Ί File operations + // ======================================================================== + { + id: 'fileOperations.confirmBeforeDelete', + section: ['General', 'File operations'], + label: 'Confirm before delete', + description: 'Show a confirmation dialog before moving files to trash.', + keywords: ['confirm', 'delete', 'trash', 'dialog', 'warning'], + type: 'boolean', + default: true, + component: 'switch', + disabled: true, + disabledReason: 'Coming soon', + }, + { + id: 'fileOperations.deletePermanently', + section: ['General', 'File operations'], + label: 'Delete permanently instead of using trash', + description: 'Bypass trash and delete files immediately. This cannot be undone.', + keywords: ['permanent', 'delete', 'trash', 'bypass', 'remove'], + type: 'boolean', + default: false, + component: 'switch', + disabled: true, + disabledReason: 'Coming soon', + }, + { + id: 'fileOperations.progressUpdateInterval', + section: ['General', 'File operations'], + label: 'Progress update interval', + description: + 'How often to refresh progress during file operations. Lower values feel more responsive but use more CPU.', + keywords: ['progress', 'update', 'interval', 'refresh', 'cpu', 'performance'], + type: 'number', + default: 500, + component: 'slider', + constraints: { + min: 50, + max: 5000, + step: 50, + sliderStops: [100, 250, 500, 1000, 2000], + }, + }, + { + id: 'fileOperations.maxConflictsToShow', + section: ['General', 'File operations'], + label: 'Maximum conflicts to show', + description: 'Maximum number of file conflicts to display in the preview before an operation.', + keywords: ['conflict', 'max', 'limit', 'preview', 'operation'], + type: 'number', + default: 100, + component: 'select', + constraints: { + options: [ + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + { value: 5, label: '5' }, + { value: 10, label: '10' }, + { value: 50, label: '50' }, + { value: 100, label: '100' }, + { value: 200, label: '200' }, + { value: 500, label: '500' }, + ], + allowCustom: true, + customMin: 1, + customMax: 1000, + }, + }, + + // ======================================================================== + // General β€Ί Updates + // ======================================================================== + { + id: 'updates.autoCheck', + section: ['General', 'Updates'], + label: 'Automatically check for updates', + description: 'Periodically check for new versions in the background.', + keywords: ['update', 'auto', 'check', 'version', 'background'], + type: 'boolean', + default: true, + component: 'switch', + }, + + // ======================================================================== + // Network β€Ί SMB/Network shares + // ======================================================================== + { + id: 'network.shareCacheDuration', + section: ['Network', 'SMB/Network shares'], + label: 'Share cache duration', + description: 'How long to cache the list of available shares on a server before refreshing.', + keywords: ['cache', 'smb', 'share', 'network', 'refresh', 'ttl'], + type: 'duration', + default: 30000, // 30 seconds in ms + component: 'select', + constraints: { + unit: 's', + options: [ + { value: 30000, label: '30 seconds' }, + { value: 300000, label: '5 minutes' }, + { value: 3600000, label: '1 hour' }, + { value: 86400000, label: '1 day' }, + { value: 2592000000, label: '30 days' }, + ], + allowCustom: true, + customMin: 1000, + customMax: 2592000000, + }, + }, + { + id: 'network.timeoutMode', + section: ['Network', 'SMB/Network shares'], + label: 'Network timeout mode', + description: 'How long to wait when connecting to network shares.', + keywords: ['timeout', 'network', 'slow', 'vpn', 'connection', 'latency'], + type: 'enum', + default: 'normal', + component: 'radio', + constraints: { + options: [ + { value: 'normal', label: 'Normal', description: 'For typical local networks (15s timeout)' }, + { + value: 'slow', + label: 'Slow network', + description: 'For VPNs or high-latency connections (45s timeout)', + }, + { value: 'custom', label: 'Custom' }, + ], + allowCustom: true, + }, + }, + { + id: 'network.customTimeout', + section: ['Network', 'SMB/Network shares'], + label: 'Custom timeout', + description: 'Custom timeout in seconds for network operations.', + keywords: ['timeout', 'custom', 'seconds'], + type: 'number', + default: 15, + component: 'number-input', + constraints: { + min: 5, + max: 120, + step: 1, + }, + }, + + // ======================================================================== + // Themes + // ======================================================================== + { + id: 'theme.mode', + section: ['Themes'], + label: 'Theme mode', + description: 'Choose between light, dark, or system-based theme.', + keywords: ['theme', 'dark', 'light', 'mode', 'appearance', 'color'], + type: 'enum', + default: 'system', + component: 'toggle-group', + constraints: { + options: [ + { value: 'light', label: 'β˜€οΈ Light' }, + { value: 'dark', label: 'πŸŒ™ Dark' }, + { value: 'system', label: 'πŸ’» System' }, + ], + }, + }, + + // ======================================================================== + // Developer β€Ί MCP server + // ======================================================================== + { + id: 'developer.mcpEnabled', + section: ['Developer', 'MCP server'], + label: 'Enable MCP server', + description: 'Start a Model Context Protocol server for AI assistant integration.', + keywords: ['mcp', 'server', 'ai', 'assistant', 'protocol', 'model'], + type: 'boolean', + default: false, + component: 'switch', + requiresRestart: true, + }, + { + id: 'developer.mcpPort', + section: ['Developer', 'MCP server'], + label: 'Port', + description: 'The port number for the MCP server. Default: 9224', + keywords: ['port', 'mcp', 'network'], + type: 'number', + default: 9224, + component: 'number-input', + constraints: { + min: 1024, + max: 65535, + step: 1, + }, + requiresRestart: true, + }, + + // ======================================================================== + // Developer β€Ί Logging + // ======================================================================== + { + id: 'developer.verboseLogging', + section: ['Developer', 'Logging'], + label: 'Verbose logging', + description: 'Log detailed debug information. Useful for troubleshooting. May impact performance.', + keywords: ['log', 'debug', 'verbose', 'troubleshoot', 'performance'], + type: 'boolean', + default: false, + component: 'switch', + }, + + // ======================================================================== + // Advanced (generated UI) + // ======================================================================== + { + id: 'advanced.dragThreshold', + section: ['Advanced'], + label: 'Drag threshold', + description: 'Minimum distance in pixels before a drag operation starts.', + keywords: ['drag', 'threshold', 'pixel', 'distance'], + type: 'number', + default: 5, + component: 'number-input', + showInAdvanced: true, + constraints: { + min: 1, + max: 50, + step: 1, + }, + }, + { + id: 'advanced.prefetchBufferSize', + section: ['Advanced'], + label: 'Prefetch buffer size', + description: 'Number of items to prefetch around the visible range for smoother scrolling.', + keywords: ['prefetch', 'buffer', 'scroll', 'performance'], + type: 'number', + default: 200, + component: 'number-input', + showInAdvanced: true, + constraints: { + min: 50, + max: 1000, + step: 50, + }, + }, + { + id: 'advanced.virtualizationBufferRows', + section: ['Advanced'], + label: 'Virtualization buffer (rows)', + description: 'Extra rows to render above and below the visible area.', + keywords: ['virtualization', 'buffer', 'row', 'render'], + type: 'number', + default: 20, + component: 'number-input', + showInAdvanced: true, + constraints: { + min: 5, + max: 100, + step: 5, + }, + }, + { + id: 'advanced.virtualizationBufferColumns', + section: ['Advanced'], + label: 'Virtualization buffer (columns)', + description: 'Extra columns to render in brief view.', + keywords: ['virtualization', 'buffer', 'column', 'brief'], + type: 'number', + default: 2, + component: 'number-input', + showInAdvanced: true, + constraints: { + min: 1, + max: 10, + step: 1, + }, + }, + { + id: 'advanced.fileWatcherDebounce', + section: ['Advanced'], + label: 'File watcher debounce', + description: 'Delay after file system changes before refreshing.', + keywords: ['watcher', 'debounce', 'refresh', 'delay'], + type: 'duration', + default: 200, + component: 'duration', + showInAdvanced: true, + constraints: { + unit: 'ms', + minMs: 50, + maxMs: 2000, + }, + }, + { + id: 'advanced.serviceResolveTimeout', + section: ['Advanced'], + label: 'Service resolve timeout', + description: 'Timeout for resolving network services via Bonjour.', + keywords: ['bonjour', 'resolve', 'timeout', 'mdns'], + type: 'duration', + default: 5000, + component: 'duration', + showInAdvanced: true, + constraints: { + unit: 's', + minMs: 1000, + maxMs: 30000, + }, + }, + { + id: 'advanced.mountTimeout', + section: ['Advanced'], + label: 'Mount timeout', + description: 'Timeout for mounting network shares.', + keywords: ['mount', 'timeout', 'network', 'share'], + type: 'duration', + default: 20000, + component: 'duration', + showInAdvanced: true, + constraints: { + unit: 's', + minMs: 5000, + maxMs: 120000, + }, + }, + { + id: 'advanced.updateCheckInterval', + section: ['Advanced'], + label: 'Update check interval', + description: 'How often to check for updates in the background.', + keywords: ['update', 'interval', 'background', 'check'], + type: 'duration', + default: 3600000, // 60 minutes + component: 'duration', + showInAdvanced: true, + constraints: { + unit: 'min', + minMs: 300000, // 5 min + maxMs: 86400000, // 24 hours + }, + }, +] + +// ============================================================================ +// Registry Lookup Helpers +// ============================================================================ + +const registryMap = new Map() +for (const setting of settingsRegistry) { + registryMap.set(setting.id, setting) +} + +/** + * Get the definition for a setting by ID. + */ +export function getSettingDefinition(id: string): SettingDefinition | undefined { + return registryMap.get(id) +} + +/** + * Get all settings in a section path. + */ +export function getSettingsInSection(sectionPath: string[]): SettingDefinition[] { + return settingsRegistry.filter((s) => { + if (s.section.length < sectionPath.length) return false + return sectionPath.every((part, i) => s.section[i] === part) + }) +} + +/** + * Get all settings marked for the Advanced section. + */ +export function getAdvancedSettings(): SettingDefinition[] { + return settingsRegistry.filter((s) => s.showInAdvanced) +} + +/** + * Get the default value for a setting. + */ +export function getDefaultValue(id: K): SettingsValues[K] { + const def = registryMap.get(id) + if (!def) throw new Error(`Unknown setting: ${id}`) + return def.default as SettingsValues[K] +} + +// ============================================================================ +// Validation +// ============================================================================ + +/** + * Validate a value against a setting's constraints. + * Throws SettingValidationError if invalid. + */ +export function validateSettingValue(id: string, value: unknown): void { + const def = registryMap.get(id) + if (!def) { + throw new SettingValidationError(id, 'Unknown setting') + } + + // Type checking + switch (def.type) { + case 'boolean': + if (typeof value !== 'boolean') { + throw new SettingValidationError(id, `Expected boolean, got ${typeof value}`) + } + break + + case 'number': + case 'duration': + if (typeof value !== 'number') { + throw new SettingValidationError(id, `Expected number, got ${typeof value}`) + } + if (!Number.isFinite(value)) { + throw new SettingValidationError(id, 'Value must be a finite number') + } + validateNumberConstraints(id, value, def) + break + + case 'string': + if (typeof value !== 'string') { + throw new SettingValidationError(id, `Expected string, got ${typeof value}`) + } + break + + case 'enum': + validateEnumValue(id, value, def) + break + } +} + +function validateNumberConstraints(id: string, value: number, def: SettingDefinition): void { + const c = def.constraints + if (!c) return + + // For duration type, check minMs/maxMs + if (def.type === 'duration') { + if (c.minMs !== undefined && value < c.minMs) { + throw new SettingValidationError(id, `Value ${String(value)}ms is below minimum ${String(c.minMs)}ms`) + } + if (c.maxMs !== undefined && value > c.maxMs) { + throw new SettingValidationError(id, `Value ${String(value)}ms exceeds maximum ${String(c.maxMs)}ms`) + } + return + } + + // For number type, check min/max + if (c.min !== undefined && value < c.min) { + throw new SettingValidationError(id, `Value ${String(value)} is below minimum ${String(c.min)}`) + } + if (c.max !== undefined && value > c.max) { + throw new SettingValidationError(id, `Value ${String(value)} exceeds maximum ${String(c.max)}`) + } +} + +function validateEnumValue(id: string, value: unknown, def: SettingDefinition): void { + const c = def.constraints + if (!c?.options) return + + const validValues = c.options.map((o) => o.value) + + // Check if it's one of the predefined options + if (validValues.includes(value as string | number)) { + return + } + + // Check if custom values are allowed + if (c.allowCustom && typeof value === 'number') { + if (c.customMin !== undefined && value < c.customMin) { + throw new SettingValidationError( + id, + `Custom value ${String(value)} is below minimum ${String(c.customMin)}`, + ) + } + if (c.customMax !== undefined && value > c.customMax) { + throw new SettingValidationError(id, `Custom value ${String(value)} exceeds maximum ${String(c.customMax)}`) + } + return + } + + throw new SettingValidationError(id, `Invalid value '${String(value)}'. Valid options: ${validValues.join(', ')}`) +} + +// ============================================================================ +// Section Tree Building +// ============================================================================ + +export interface SettingsSection { + name: string + path: string[] + subsections: SettingsSection[] + settings: SettingDefinition[] +} + +/** + * Build a hierarchical tree structure from the flat settings registry. + */ +export function buildSectionTree(): SettingsSection[] { + const root: SettingsSection[] = [] + const sectionMap = new Map() + + for (const setting of settingsRegistry) { + if (setting.showInAdvanced) continue // Advanced settings are handled separately + + let currentLevel = root + let currentPath: string[] = [] + + for (let i = 0; i < setting.section.length; i++) { + const sectionName = setting.section[i] + currentPath = [...currentPath, sectionName] + const pathKey = currentPath.join('/') + + let section = sectionMap.get(pathKey) + if (!section) { + section = { + name: sectionName, + path: [...currentPath], + subsections: [], + settings: [], + } + sectionMap.set(pathKey, section) + currentLevel.push(section) + } + + if (i === setting.section.length - 1) { + section.settings.push(setting) + } else { + currentLevel = section.subsections + } + } + } + + return root +} diff --git a/apps/desktop/src/lib/settings/settings-search.test.ts b/apps/desktop/src/lib/settings/settings-search.test.ts new file mode 100644 index 0000000..b796997 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-search.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + searchSettings, + searchAdvancedSettings, + getMatchingSections, + sectionHasMatches, + highlightMatches, + clearSearchIndex, +} from './settings-search' + +describe('searchSettings', () => { + beforeEach(() => { + clearSearchIndex() + }) + + it('should return all settings when query is empty', () => { + const results = searchSettings('') + expect(results.length).toBeGreaterThan(0) + }) + + it('should return all settings when query is whitespace', () => { + const results = searchSettings(' ') + expect(results.length).toBeGreaterThan(0) + }) + + it('should find settings by label', () => { + const results = searchSettings('density') + expect(results.length).toBeGreaterThan(0) + expect(results.some((r) => r.setting.id === 'appearance.uiDensity')).toBe(true) + }) + + it('should find settings by section name', () => { + const results = searchSettings('general') + expect(results.length).toBeGreaterThan(0) + // At least one result should be in the General section + const hasGeneral = results.some((r) => r.setting.section[0] === 'General') + expect(hasGeneral).toBe(true) + }) + + it('should return empty array when nothing matches', () => { + const results = searchSettings('xyznonexistent123') + expect(results).toEqual([]) + }) + + it('should include matched indices for highlighting', () => { + const results = searchSettings('density') + expect(results.length).toBeGreaterThan(0) + // Matched indices should be numbers + for (const result of results) { + expect(Array.isArray(result.matchedIndices)).toBe(true) + } + }) +}) + +describe('searchAdvancedSettings', () => { + it('should return all advanced settings when query is empty', () => { + const results = searchAdvancedSettings('') + expect(results.length).toBeGreaterThan(0) + for (const result of results) { + expect(result.setting.showInAdvanced).toBe(true) + } + }) + + it('should find advanced settings by label', () => { + const results = searchAdvancedSettings('drag') + // Should find dragThreshold + const hasDragThreshold = results.some((r) => r.setting.id.includes('dragThreshold')) + expect(hasDragThreshold).toBe(true) + }) +}) + +describe('getMatchingSections', () => { + it('should return sections containing matching settings', () => { + const sections = getMatchingSections('density') + expect(sections.size).toBeGreaterThan(0) + // Should include the parent section path 'General' or 'General/Appearance' + const hasGeneral = sections.has('General') || sections.has('General/Appearance') + expect(hasGeneral).toBe(true) + }) + + it('should return empty set when nothing matches', () => { + const sections = getMatchingSections('xyznonexistent123') + expect(sections.size).toBe(0) + }) +}) + +describe('sectionHasMatches', () => { + it('should return true for sections with matching settings', () => { + const matchingSections = getMatchingSections('density') + // The function uses path.join('/') to check + expect(sectionHasMatches(['General'], matchingSections)).toBe(true) + }) + + it('should return false for sections without matches', () => { + const matchingSections = getMatchingSections('density') + expect(sectionHasMatches(['NonExistent'], matchingSections)).toBe(false) + }) +}) + +describe('highlightMatches', () => { + it('should return single segment when no matches', () => { + const segments = highlightMatches('hello world', []) + expect(segments).toEqual([{ text: 'hello world', matched: false }]) + }) + + it('should highlight matched characters', () => { + const segments = highlightMatches('hello', [0, 1]) + expect(segments).toEqual([ + { text: 'he', matched: true }, + { text: 'llo', matched: false }, + ]) + }) + + it('should handle non-contiguous matches', () => { + const segments = highlightMatches('abcde', [0, 2, 4]) + expect(segments.length).toBeGreaterThan(1) + // Check that matched characters are marked + expect(segments.some((s) => s.matched && s.text === 'a')).toBe(true) + }) +}) diff --git a/apps/desktop/src/lib/settings/settings-search.ts b/apps/desktop/src/lib/settings/settings-search.ts new file mode 100644 index 0000000..8fe66e7 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-search.ts @@ -0,0 +1,303 @@ +/** + * Settings search functionality using uFuzzy. + * Same search engine and configuration as the command palette. + */ + +import uFuzzy from '@leeoniya/ufuzzy' +import type { SettingDefinition, SettingSearchResult } from './types' +import { settingsRegistry } from './settings-registry' +import { searchCommands } from '$lib/commands/fuzzy-search' + +// ============================================================================ +// Search Configuration (same as command palette) +// ============================================================================ + +const fuzzy = new uFuzzy({ + intraMode: 1, // Fuzzy matching within words (catches typos) + interIns: 3, // Max 3 insertions between matched characters +}) + +// ============================================================================ +// Search Index +// ============================================================================ + +interface SearchIndexEntry { + setting: SettingDefinition + searchableText: string +} + +let searchIndex: SearchIndexEntry[] | null = null + +/** + * Build the search index from the settings registry. + * Lazily initialized on first search. + */ +function buildSearchIndex(): SearchIndexEntry[] { + if (searchIndex) return searchIndex + + searchIndex = settingsRegistry + .filter((s) => !s.showInAdvanced) // Advanced settings are searched separately + .map((setting) => ({ + setting, + searchableText: buildSearchableText(setting), + })) + + return searchIndex +} + +/** + * Build searchable text for a setting by concatenating: + * - Section path (e.g., "General β€Ί Appearance") + * - Label + * - Description + * - Keywords + */ +function buildSearchableText(setting: SettingDefinition): string { + const parts = [setting.section.join(' β€Ί '), setting.label, setting.description, ...setting.keywords] + return parts.join(' ').toLowerCase() +} + +// ============================================================================ +// Search Functions +// ============================================================================ + +/** + * Search settings by query string. + * Returns settings that match the query with match indices for highlighting. + */ +export function searchSettings(query: string): SettingSearchResult[] { + const index = buildSearchIndex() + + // Empty query returns all settings + if (!query.trim()) { + return index.map((entry) => ({ + setting: entry.setting, + matchedIndices: [], + searchableText: entry.searchableText, + })) + } + + const haystack = index.map((e) => e.searchableText) + const results = fuzzy.search(haystack, query.toLowerCase()) + + // uFuzzy can return null/undefined in some edge cases + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!results?.[0]) { + return [] + } + + const [matchedIndices, info, order] = results + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!order || !info) { + return [] + } + + // Build results with match information + return order.map((idx) => { + const entry = index[matchedIndices[idx]] + const ranges = info.ranges[idx] + + // Convert ranges to individual character indices + const indices: number[] = [] + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (ranges) { + for (let i = 0; i < ranges.length; i += 2) { + const start = ranges[i] + const end = ranges[i + 1] + for (let j = start; j < end; j++) { + indices.push(j) + } + } + } + + return { + setting: entry.setting, + matchedIndices: indices, + searchableText: entry.searchableText, + } + }) +} + +/** + * Search only advanced settings. + */ +export function searchAdvancedSettings(query: string): SettingSearchResult[] { + const advancedSettings = settingsRegistry.filter((s) => s.showInAdvanced) + + if (!query.trim()) { + return advancedSettings.map((setting) => ({ + setting, + matchedIndices: [], + searchableText: buildSearchableText(setting), + })) + } + + const entries = advancedSettings.map((setting) => ({ + setting, + searchableText: buildSearchableText(setting), + })) + + const haystack = entries.map((e) => e.searchableText) + const results = fuzzy.search(haystack, query.toLowerCase()) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!results?.[0]) { + return [] + } + + const [matchedIndices, info, order] = results + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!order || !info) { + return [] + } + + return order.map((idx) => { + const entry = entries[matchedIndices[idx]] + const ranges = info.ranges[idx] + + const indices: number[] = [] + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (ranges) { + for (let i = 0; i < ranges.length; i += 2) { + const start = ranges[i] + const end = ranges[i + 1] + for (let j = start; j < end; j++) { + indices.push(j) + } + } + } + + return { + setting: entry.setting, + matchedIndices: indices, + searchableText: entry.searchableText, + } + }) +} + +/** + * Get the sections that contain matching settings. + * Used to filter the tree view during search. + */ +export function getMatchingSections(query: string): Set { + const results = searchSettings(query) + const sections = new Set() + + for (const result of results) { + // Add all parent sections + for (let i = 1; i <= result.setting.section.length; i++) { + sections.add(result.setting.section.slice(0, i).join('/')) + } + } + + // Also check if any commands match for Keyboard shortcuts section + if (query.trim()) { + const commandMatches = searchCommands(query) + if (commandMatches.length > 0) { + sections.add('Keyboard shortcuts') + } + } + + return sections +} + +/** + * Check if a section contains any matching settings. + */ +export function sectionHasMatches(sectionPath: string[], matchingSections: Set): boolean { + return matchingSections.has(sectionPath.join('/')) +} + +/** + * Highlight matched characters in text. + * Returns an array of { text, matched } segments for rendering. + */ +export function highlightMatches(text: string, matchedIndices: number[]): Array<{ text: string; matched: boolean }> { + if (matchedIndices.length === 0) { + return [{ text, matched: false }] + } + + const matchSet = new Set(matchedIndices) + const segments: Array<{ text: string; matched: boolean }> = [] + let currentSegment = '' + let currentMatched = matchSet.has(0) + + for (let i = 0; i < text.length; i++) { + const isMatched = matchSet.has(i) + + if (isMatched !== currentMatched) { + if (currentSegment) { + segments.push({ text: currentSegment, matched: currentMatched }) + } + currentSegment = text[i] + currentMatched = isMatched + } else { + currentSegment += text[i] + } + } + + if (currentSegment) { + segments.push({ text: currentSegment, matched: currentMatched }) + } + + return segments +} + +/** + * Clear the search index (for testing or when settings change). + */ +export function clearSearchIndex(): void { + searchIndex = null +} + +/** + * Get the set of setting IDs that match the query. + * Used to filter which settings to display in section components. + */ +export function getMatchingSettingIds(query: string): Set { + const results = searchSettings(query) + return new Set(results.map((r) => r.setting.id)) +} + +/** + * Get matching setting IDs within a specific section. + */ +export function getMatchingSettingIdsInSection(query: string, sectionPath: string[]): Set { + const results = searchSettings(query) + const sectionPrefix = sectionPath.join('/') + + return new Set( + results + .filter((r) => { + const settingSectionPath = r.setting.section.join('/') + return settingSectionPath === sectionPrefix || settingSectionPath.startsWith(sectionPrefix + '/') + }) + .map((r) => r.setting.id), + ) +} + +/** + * Get match indices for a specific setting's label. + * Returns indices relative to the label text for highlighting. + */ +export function getMatchIndicesForLabel(query: string, settingId: string): number[] { + if (!query.trim()) return [] + + const results = searchSettings(query) + const result = results.find((r) => r.setting.id === settingId) + if (!result) return [] + + // The matchedIndices are relative to searchableText which includes section path + // We need to find where the label starts in searchableText and adjust indices + const setting = result.setting + // Match the format used in buildSearchableText: section.join(' β€Ί ') + ' ' + label + const sectionText = setting.section.join(' β€Ί ') + ' ' + // searchableText is lowercased, so we need to work with the lowercased label length + const labelStart = sectionText.toLowerCase().length + const labelEnd = labelStart + setting.label.toLowerCase().length + + // Filter indices that fall within the label range and adjust them + return result.matchedIndices.filter((idx) => idx >= labelStart && idx < labelEnd).map((idx) => idx - labelStart) +} diff --git a/apps/desktop/src/lib/settings/settings-store.ts b/apps/desktop/src/lib/settings/settings-store.ts new file mode 100644 index 0000000..c238844 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-store.ts @@ -0,0 +1,394 @@ +/** + * Settings persistence layer - stores and loads settings from disk. + * See docs/specs/settings.md for full specification. + */ + +import { load, type Store } from '@tauri-apps/plugin-store' +import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event' +import type { SettingId, SettingsValues } from './types' +import { SettingValidationError } from './types' +import { getDefaultValue, settingsRegistry, validateSettingValue } from './settings-registry' +import { getAppLogger } from '$lib/logger' + +const log = getAppLogger('settings') + +// Event name for cross-window setting changes +const SETTING_CHANGED_EVENT = 'settings:changed' + +interface SettingChangedPayload { + id: string + value: unknown +} + +// ============================================================================ +// Store Configuration +// ============================================================================ + +const STORE_NAME = 'settings-v2.json' +const SCHEMA_VERSION = 1 + +let storeInstance: Store | null = null +let saveTimeout: ReturnType | null = null +const SAVE_DEBOUNCE_MS = 500 + +// In-memory cache of settings for synchronous access +// Using Record to allow any setting ID assignment +const settingsCache: Record = {} +let initialized = false +let crossWindowUnlisten: UnlistenFn | null = null + +// ============================================================================ +// Initialization +// ============================================================================ + +async function getStore(): Promise { + if (!storeInstance) { + log.debug('Creating new store instance for {storeName}', { storeName: STORE_NAME }) + // Build defaults from registry + const defaults: Record = {} + for (const def of settingsRegistry) { + defaults[def.id] = def.default + } + log.debug('Loading store with {count} default settings', { count: Object.keys(defaults).length }) + storeInstance = await load(STORE_NAME, { defaults, autoSave: false }) + log.debug('Store instance created successfully') + } + return storeInstance +} + +/** + * Initialize the settings store. Must be called before using getSetting/setSetting. + */ +export async function initializeSettings(): Promise { + log.debug('initializeSettings() called, initialized={initialized}', { initialized }) + + if (initialized) { + log.debug('Settings already initialized, returning early') + return + } + + log.info('Starting settings initialization') + + try { + const store = await getStore() + log.debug('Got store instance') + + // Check schema version and migrate if needed + const version = await store.get('_schemaVersion') + log.debug('Current schema version: {version}, expected: {expected}', { version, expected: SCHEMA_VERSION }) + + if (version !== SCHEMA_VERSION) { + log.info('Schema version mismatch, migrating from {from} to {to}', { + from: version ?? 0, + to: SCHEMA_VERSION, + }) + await migrateSettings(store, version ?? 0) + } + + // Load all settings into cache + log.debug('Loading {count} settings from store into cache', { count: settingsRegistry.length }) + let loadedCount = 0 + let defaultCount = 0 + + for (const def of settingsRegistry) { + const stored = await store.get(def.id) + if (stored !== null && stored !== undefined) { + try { + validateSettingValue(def.id, stored) + settingsCache[def.id] = stored + loadedCount++ + } catch { + // Invalid stored value, will use default + log.warn('Invalid stored value for {id}, using default', { id: def.id }) + defaultCount++ + } + } else { + defaultCount++ + } + } + + log.info('Settings loaded: {loaded} from store, {defaults} using defaults', { + loaded: loadedCount, + defaults: defaultCount, + }) + + // Listen for cross-window setting changes + await setupCrossWindowListener() + + initialized = true + log.info('Settings initialization complete') + } catch (error) { + log.error('Failed to initialize settings: {error}', { error }) + throw error + } +} + +/** + * Set up listener for setting changes from other windows. + */ +async function setupCrossWindowListener(): Promise { + if (crossWindowUnlisten) { + return // Already listening + } + + log.debug('Setting up cross-window settings listener') + + crossWindowUnlisten = await listen(SETTING_CHANGED_EVENT, (event) => { + const { id, value } = event.payload + log.debug('Received cross-window setting change: {id}', { id }) + + // Update our cache without re-emitting (to avoid loops) + settingsCache[id] = value + + // Notify local listeners + notifyListeners(id as SettingId, value as SettingsValues[SettingId]) + }) + + log.debug('Cross-window settings listener ready') +} + +/** + * Migrate settings from older schema versions. + */ +async function migrateSettings(store: Store, fromVersion: number): Promise { + // Currently no migrations needed, just set version + if (fromVersion < 1) { + // Future migrations would go here + // Example: rename old keys, convert formats, etc. + } + + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() +} + +// ============================================================================ +// Core API +// ============================================================================ + +/** + * Get a setting value. Returns the default if not set. + * Must call initializeSettings() first. + */ +export function getSetting(id: K): SettingsValues[K] { + if (!initialized) { + // eslint-disable-next-line no-console + console.warn('Settings not initialized, returning default for', id) + return getDefaultValue(id) + } + + const cached = settingsCache[id] + if (cached !== undefined) { + return cached as SettingsValues[K] + } + + return getDefaultValue(id) +} + +/** + * Set a setting value. Validates against constraints before storing. + * Throws SettingValidationError if invalid. + */ +export function setSetting(id: K, value: SettingsValues[K]): void { + log.debug('setSetting({id}, {value})', { id, value }) + + // Validate the value + validateSettingValue(id, value) + + // Update cache immediately for synchronous access + settingsCache[id] = value + + // Debounced save to disk + scheduleSave() + + // Notify local listeners + notifyListeners(id, value) + + // Emit cross-window event so other windows get the update + void emit(SETTING_CHANGED_EVENT, { id, value } satisfies SettingChangedPayload) + log.debug('Emitted cross-window setting change event for {id}', { id }) +} + +/** + * Reset a setting to its default value. + */ +export function resetSetting(id: SettingId): void { + const defaultValue = getDefaultValue(id) + setSetting(id, defaultValue) +} + +/** + * Reset all settings to their default values. + */ +export async function resetAllSettings(): Promise { + for (const def of settingsRegistry) { + settingsCache[def.id] = def.default + } + + // Clear the store + const store = await getStore() + await store.clear() + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() + + // Notify all listeners + for (const def of settingsRegistry) { + notifyListeners(def.id as SettingId, def.default as SettingsValues[SettingId]) + } +} + +/** + * Check if a setting has been modified from its default value. + */ +export function isModified(id: SettingId): boolean { + const current = getSetting(id) + const defaultVal = getDefaultValue(id) + return current !== defaultVal +} + +/** + * Get all setting values as a plain object. + */ +export function getAllSettings(): Partial { + return { ...settingsCache } as Partial +} + +// ============================================================================ +// Persistence +// ============================================================================ + +function scheduleSave(): void { + if (saveTimeout) { + clearTimeout(saveTimeout) + } + + saveTimeout = setTimeout(() => { + void saveToStore().finally(() => { + saveTimeout = null + }) + }, SAVE_DEBOUNCE_MS) +} + +async function saveToStore(): Promise { + log.debug('saveToStore() called') + + try { + const store = await getStore() + + // Only save non-default values to keep the file small + let savedCount = 0 + let removedCount = 0 + + for (const def of settingsRegistry) { + const id = def.id as SettingId + const value = settingsCache[id] + const defaultValue = def.default + + if (value !== undefined && value !== defaultValue) { + await store.set(id, value) + savedCount++ + } else { + // Remove from store if it's the default + await store.delete(id) + removedCount++ + } + } + + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() + log.info('Settings saved: {saved} non-default values, {removed} reset to default', { + saved: savedCount, + removed: removedCount, + }) + } catch (error) { + log.error('Failed to save settings: {error}', { error }) + // Retry once + try { + log.debug('Retrying save...') + const store = await getStore() + await store.save() + log.info('Retry save succeeded') + } catch (retryError) { + log.error('Retry save failed: {error}', { error: retryError }) + // Could show a toast here in the future + } + } +} + +// ============================================================================ +// Change Listeners +// ============================================================================ + +type SettingChangeListener = (id: K, value: SettingsValues[K]) => void + +const listeners = new Set() +const specificListeners = new Map>() + +/** + * Subscribe to all setting changes. + */ +export function onSettingChange(listener: SettingChangeListener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** + * Subscribe to changes for a specific setting. + */ +export function onSpecificSettingChange( + id: K, + listener: (id: K, value: SettingsValues[K]) => void, +): () => void { + let set = specificListeners.get(id) + if (!set) { + set = new Set() + specificListeners.set(id, set) + } + set.add(listener as SettingChangeListener) + return () => set.delete(listener as SettingChangeListener) +} + +function notifyListeners(id: K, value: SettingsValues[K]): void { + // Notify global listeners + for (const listener of listeners) { + try { + listener(id, value) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Setting change listener error:', error) + } + } + + // Notify specific listeners + const specific = specificListeners.get(id) + if (specific) { + for (const listener of specific) { + try { + listener(id, value) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Setting change listener error:', error) + } + } + } +} + +// ============================================================================ +// Utility: Force Save (for testing) +// ============================================================================ + +/** + * Force an immediate save to disk. Used for testing. + */ +export async function forceSave(): Promise { + if (saveTimeout) { + clearTimeout(saveTimeout) + saveTimeout = null + } + await saveToStore() +} + +// ============================================================================ +// Export validation error for external use +// ============================================================================ + +export { SettingValidationError } diff --git a/apps/desktop/src/lib/settings/settings-window.ts b/apps/desktop/src/lib/settings/settings-window.ts new file mode 100644 index 0000000..42de480 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-window.ts @@ -0,0 +1,98 @@ +/** + * Settings window management. + * Creates and manages the settings window as a separate Tauri window. + */ + +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { getAppLogger } from '$lib/logger' + +const log = getAppLogger('settings') + +let settingsWindow: WebviewWindow | null = null + +const SETTINGS_WIDTH = 800 +const SETTINGS_HEIGHT = 600 +const SETTINGS_MAX_WIDTH = 852 +const SETTINGS_MIN_WIDTH = 600 +const SETTINGS_MIN_HEIGHT = 400 + +/** + * Opens the settings window, or focuses it if already open. + * Window always opens centered on screen. + */ +export async function openSettingsWindow(): Promise { + log.debug('openSettingsWindow called') + + // Check if window already exists + if (settingsWindow) { + log.debug('Settings window already exists, attempting to focus') + try { + await settingsWindow.setFocus() + log.debug('Focused existing settings window') + return + } catch (error) { + // Window was closed, create a new one + log.debug('Failed to focus existing window (likely closed), creating new: {error}', { error }) + settingsWindow = null + } + } + + log.info('Creating new settings window with url=/settings') + + // Create new settings window, centered on screen + settingsWindow = new WebviewWindow('settings', { + url: '/settings', + title: 'Settings', + width: SETTINGS_WIDTH, + height: SETTINGS_HEIGHT, + minWidth: SETTINGS_MIN_WIDTH, + minHeight: SETTINGS_MIN_HEIGHT, + maxWidth: SETTINGS_MAX_WIDTH, + center: true, + resizable: true, + decorations: true, + }) + + // Listen for window creation success + void settingsWindow.once('tauri://created', () => { + log.info('Settings window created successfully') + }) + + // Listen for window close to clean up reference + void settingsWindow.once('tauri://destroyed', () => { + log.debug('Settings window destroyed, cleaning up reference') + settingsWindow = null + }) + + // Handle any creation errors + void settingsWindow.once('tauri://error', (e) => { + log.error('Failed to create settings window: {error}', { error: e }) + settingsWindow = null + }) +} + +/** + * Closes the settings window if it's open. + */ +export async function closeSettingsWindow(): Promise { + log.debug('closeSettingsWindow called, windowExists={exists}', { exists: settingsWindow !== null }) + + if (settingsWindow) { + try { + await settingsWindow.close() + log.info('Settings window closed') + } catch (error) { + log.debug('Failed to close settings window (likely already closed): {error}', { error }) + } + settingsWindow = null + } +} + +/** + * Checks if the settings window is currently open. + */ +export function isSettingsWindowOpen(): boolean { + const isOpen = settingsWindow !== null + log.debug('isSettingsWindowOpen() = {isOpen}', { isOpen }) + return isOpen +} diff --git a/apps/desktop/src/lib/settings/types.ts b/apps/desktop/src/lib/settings/types.ts new file mode 100644 index 0000000..392bfa3 --- /dev/null +++ b/apps/desktop/src/lib/settings/types.ts @@ -0,0 +1,183 @@ +/** + * Settings system type definitions. + * See docs/specs/settings.md for full specification. + */ + +// ============================================================================ +// Core Types +// ============================================================================ + +export type SettingType = 'boolean' | 'number' | 'string' | 'enum' | 'duration' + +export type DurationUnit = 'ms' | 's' | 'min' | 'h' | 'd' + +export interface EnumOption { + value: string | number + label: string + description?: string +} + +export interface SettingConstraints { + // For 'number' type + min?: number + max?: number + step?: number + sliderStops?: number[] // Specific values the slider snaps to + + // For 'enum' type + options?: EnumOption[] + allowCustom?: boolean + customMin?: number + customMax?: number + + // For 'duration' type + unit?: DurationUnit + minMs?: number + maxMs?: number +} + +export interface SettingDefinition { + // Identity + id: string + section: string[] + + // Display + label: string + description: string + keywords: string[] + + // Type and constraints + type: SettingType + default: unknown + constraints?: SettingConstraints + + // Behavior + requiresRestart?: boolean + disabled?: boolean + disabledReason?: string + + // UI hints + component?: 'switch' | 'select' | 'radio' | 'slider' | 'toggle-group' | 'number-input' | 'text-input' | 'duration' + showInAdvanced?: boolean +} + +// ============================================================================ +// Setting Value Types (for type-safe access) +// ============================================================================ + +export type UiDensity = 'compact' | 'comfortable' | 'spacious' +export type FileSizeFormat = 'binary' | 'si' +export type DateTimeFormat = 'system' | 'iso' | 'short' | 'custom' +export type NetworkTimeoutMode = 'normal' | 'slow' | 'custom' +export type ThemeMode = 'light' | 'dark' | 'system' + +export interface SettingsValues { + // Appearance + 'appearance.uiDensity': UiDensity + 'appearance.useAppIconsForDocuments': boolean + 'appearance.fileSizeFormat': FileSizeFormat + 'appearance.dateTimeFormat': DateTimeFormat + 'appearance.customDateTimeFormat': string + + // File operations + 'fileOperations.confirmBeforeDelete': boolean + 'fileOperations.deletePermanently': boolean + 'fileOperations.progressUpdateInterval': number + 'fileOperations.maxConflictsToShow': number + + // Updates + 'updates.autoCheck': boolean + + // Network + 'network.shareCacheDuration': number + 'network.timeoutMode': NetworkTimeoutMode + 'network.customTimeout': number + + // Theme + 'theme.mode': ThemeMode + + // Developer + 'developer.mcpEnabled': boolean + 'developer.mcpPort': number + 'developer.verboseLogging': boolean + + // Advanced + 'advanced.dragThreshold': number + 'advanced.prefetchBufferSize': number + 'advanced.virtualizationBufferRows': number + 'advanced.virtualizationBufferColumns': number + 'advanced.fileWatcherDebounce': number + 'advanced.serviceResolveTimeout': number + 'advanced.mountTimeout': number + 'advanced.updateCheckInterval': number +} + +export type SettingId = keyof SettingsValues + +// ============================================================================ +// Search Result Types +// ============================================================================ + +export interface SettingSearchResult { + setting: SettingDefinition + matchedIndices: number[] + searchableText: string +} + +// ============================================================================ +// Validation Error +// ============================================================================ + +export class SettingValidationError extends Error { + constructor( + public settingId: string, + public reason: string, + ) { + super(`Invalid value for setting '${settingId}': ${reason}`) + this.name = 'SettingValidationError' + } +} + +// ============================================================================ +// UI Density Mappings +// ============================================================================ + +export interface DensityValues { + rowHeight: number + iconSize: number + spacing: number +} + +export const densityMappings: Record = { + compact: { rowHeight: 16, iconSize: 24, spacing: 2 }, + comfortable: { rowHeight: 20, iconSize: 32, spacing: 4 }, + spacious: { rowHeight: 28, iconSize: 40, spacing: 8 }, +} + +// ============================================================================ +// Duration Conversion Helpers +// ============================================================================ + +const MS_PER_UNIT: Record = { + ms: 1, + s: 1000, + min: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, +} + +export function toMilliseconds(value: number, unit: DurationUnit): number { + return value * MS_PER_UNIT[unit] +} + +export function fromMilliseconds(ms: number, unit: DurationUnit): number { + return ms / MS_PER_UNIT[unit] +} + +export function formatDuration(ms: number): string { + if (ms < 1000) return ms.toString() + 'ms' + if (ms < 60000) return (ms / 1000).toString() + 's' + if (ms < 3600000) return (ms / 60000).toString() + 'min' + if (ms < 86400000) return (ms / 3600000).toString() + 'h' + return (ms / 86400000).toString() + 'd' +} diff --git a/apps/desktop/src/lib/shortcuts/conflict-detector.ts b/apps/desktop/src/lib/shortcuts/conflict-detector.ts new file mode 100644 index 0000000..929fa21 --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/conflict-detector.ts @@ -0,0 +1,137 @@ +/** + * Conflict detection for keyboard shortcuts. + * See docs/specs/shortcut-settings.md Β§5 for specification. + */ + +import { commands } from '$lib/commands/command-registry' +import type { Command } from '$lib/commands/types' +import type { ShortcutConflict } from './types' +import { scopesOverlap, type CommandScope } from './scope-hierarchy' +import { getEffectiveShortcuts } from './shortcuts-store' + +/** + * Find commands that conflict with a given shortcut in a given scope. + * Two commands conflict if they share a shortcut and their scopes overlap. + */ +export function findConflictsForShortcut(shortcut: string, scope: CommandScope, excludeCommandId?: string): Command[] { + // Empty shortcuts can't conflict + if (!shortcut) return [] + + return commands.filter((cmd) => { + // Don't conflict with self + if (cmd.id === excludeCommandId) return false + + // Check if this command uses the shortcut (ignore empty shortcuts) + const cmdShortcuts = getEffectiveShortcuts(cmd.id).filter((s) => s) + if (!cmdShortcuts.includes(shortcut)) return false + + // Check if scopes overlap + return scopesOverlap(cmd.scope as CommandScope, scope) + }) +} + +/** + * Check if a command has any conflicts with other commands. + */ +export function hasConflicts(commandId: string): boolean { + const command = commands.find((c) => c.id === commandId) + if (!command) return false + + const shortcuts = getEffectiveShortcuts(commandId) + const scope = command.scope as CommandScope + + for (const shortcut of shortcuts) { + const conflicts = findConflictsForShortcut(shortcut, scope, commandId) + if (conflicts.length > 0) return true + } + + return false +} + +/** + * Get all conflicts in the system. + * Returns a list of shortcuts that are bound to multiple overlapping commands. + */ +export function getAllConflicts(): ShortcutConflict[] { + const conflicts: ShortcutConflict[] = [] + const processed = new Set() + + for (const cmd of commands) { + // Filter out empty shortcuts (used during editing) + const shortcuts = getEffectiveShortcuts(cmd.id).filter((s) => s) + const scope = cmd.scope as CommandScope + + for (const shortcut of shortcuts) { + // Create a unique key for this shortcut + const conflictKey = shortcut + + // Skip if we've already processed this shortcut + if (processed.has(conflictKey)) continue + + // Find all commands using this shortcut with overlapping scopes + const conflictingCommands = findConflictsForShortcut(shortcut, scope) + + // Add current command if it uses this shortcut + if (!conflictingCommands.find((c) => c.id === cmd.id)) { + conflictingCommands.push(cmd) + } + + // If more than one command, we have a conflict + if (conflictingCommands.length > 1) { + // Check for actual scope overlap between all pairs + const actualConflicts: Command[] = [] + for (const c of conflictingCommands) { + const cScope = c.scope as CommandScope + // Check if this command conflicts with any other + const hasOverlap = conflictingCommands.some( + (other) => other.id !== c.id && scopesOverlap(cScope, other.scope as CommandScope), + ) + if (hasOverlap) { + actualConflicts.push(c) + } + } + + if (actualConflicts.length > 1) { + conflicts.push({ + shortcut, + commandIds: actualConflicts.map((c) => c.id), + }) + } + + processed.add(conflictKey) + } + } + } + + return conflicts +} + +/** + * Get the count of commands with conflicts. + */ +export function getConflictCount(): number { + const conflictingCommands = new Set() + + for (const conflict of getAllConflicts()) { + for (const id of conflict.commandIds) { + conflictingCommands.add(id) + } + } + + return conflictingCommands.size +} + +/** + * Get all command IDs that have conflicts. + */ +export function getConflictingCommandIds(): Set { + const ids = new Set() + + for (const conflict of getAllConflicts()) { + for (const id of conflict.commandIds) { + ids.add(id) + } + } + + return ids +} diff --git a/apps/desktop/src/lib/shortcuts/index.ts b/apps/desktop/src/lib/shortcuts/index.ts new file mode 100644 index 0000000..67db0c3 --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/index.ts @@ -0,0 +1,53 @@ +/** + * Keyboard shortcuts module. + * Re-exports all public APIs for shortcut customization. + */ + +// Types +export type { KeyCombo, ShortcutConflict, CustomShortcutsData, SetShortcutResult } from './types' + +// Scope hierarchy +export { getActiveScopes, scopesOverlap, getAllScopes, type CommandScope } from './scope-hierarchy' + +// Key capture +export { + formatKeyCombo, + parseKeyCombo, + normalizeKeyName, + matchesShortcut, + isModifierKey, + isCompleteCombo, + isMacOS, +} from './key-capture' + +// Shortcuts store +export { + initializeShortcuts, + getCustomShortcuts, + getEffectiveShortcuts, + getDefaultShortcuts, + isShortcutModified, + setShortcut, + addShortcut, + removeShortcut, + resetShortcut, + resetAllShortcuts, + onShortcutChange, + forceSave, + flushPendingSave, +} from './shortcuts-store' + +// Conflict detection +export { + findConflictsForShortcut, + hasConflicts, + getAllConflicts, + getConflictCount, + getConflictingCommandIds, +} from './conflict-detector' + +// Keyboard handler +export { handleKeyDown, findCommandsWithShortcut } from './keyboard-handler' + +// MCP shortcuts listener +export { setupMcpShortcutsListener, cleanupMcpShortcutsListener } from './mcp-shortcuts-listener' diff --git a/apps/desktop/src/lib/shortcuts/key-capture.ts b/apps/desktop/src/lib/shortcuts/key-capture.ts new file mode 100644 index 0000000..d7ab07b --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/key-capture.ts @@ -0,0 +1,127 @@ +/** + * Key capture and formatting utilities. + * Platform-specific - stores shortcuts as display strings. + * See docs/specs/shortcut-settings.md Β§4 for specification. + */ + +import type { KeyCombo } from './types' + +/** Check if running on macOS */ +export function isMacOS(): boolean { + if (typeof navigator === 'undefined') return false + return navigator.userAgent.toLowerCase().includes('mac') +} + +/** Special key name mappings */ +const macKeyNames: Record = { + Backspace: '⌫', + Delete: '⌦', + Enter: '↩', + Return: '↩', + Escape: 'βŽ‹', + Tab: 'Tab', + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: 'β†’', + ' ': 'Space', + PageUp: 'PgUp', + PageDown: 'PgDn', + Home: 'Home', + End: 'End', +} + +const nonMacKeyNames: Record = { + Backspace: 'Backspace', + Delete: 'Delete', + Enter: 'Enter', + Return: 'Enter', + Escape: 'Esc', + Tab: 'Tab', + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: 'β†’', + ' ': 'Space', + PageUp: 'PgUp', + PageDown: 'PgDn', + Home: 'Home', + End: 'End', +} + +/** + * Normalize a key name for display. + * Single characters are uppercased, special keys are mapped. + */ +export function normalizeKeyName(key: string): string { + // Single printable characters are uppercased + if (key.length === 1 && key !== ' ') { + return key.toUpperCase() + } + + const keyMap = isMacOS() ? macKeyNames : nonMacKeyNames + return keyMap[key] ?? key +} + +/** + * Check if a key is a modifier (should not be captured alone). + */ +export function isModifierKey(key: string): boolean { + return ['Meta', 'Control', 'Alt', 'Shift', 'OS'].includes(key) +} + +/** + * Format a keyboard event into a display string. + * macOS: βŒ˜β‡§P + * Windows/Linux: Ctrl+Shift+P + */ +export function formatKeyCombo(event: KeyboardEvent): string { + const parts: string[] = [] + + if (isMacOS()) { + if (event.metaKey) parts.push('⌘') + if (event.ctrlKey) parts.push('βŒƒ') + if (event.altKey) parts.push('βŒ₯') + if (event.shiftKey) parts.push('⇧') + } else { + if (event.ctrlKey) parts.push('Ctrl') + if (event.altKey) parts.push('Alt') + if (event.shiftKey) parts.push('Shift') + if (event.metaKey) parts.push('Win') + } + + // Don't include modifier keys themselves as the main key + if (!isModifierKey(event.key)) { + const key = normalizeKeyName(event.key) + parts.push(key) + } + + return isMacOS() ? parts.join('') : parts.join('+') +} + +/** + * Parse a KeyCombo from a keyboard event. + */ +export function parseKeyCombo(event: KeyboardEvent): KeyCombo { + return { + meta: event.metaKey, + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + key: isModifierKey(event.key) ? '' : normalizeKeyName(event.key), + } +} + +/** + * Check if a keyboard event matches a stored shortcut string. + */ +export function matchesShortcut(event: KeyboardEvent, shortcut: string): boolean { + return formatKeyCombo(event) === shortcut +} + +/** + * Check if a key combo is complete (has a non-modifier key). + */ +export function isCompleteCombo(event: KeyboardEvent): boolean { + return !isModifierKey(event.key) +} diff --git a/apps/desktop/src/lib/shortcuts/keyboard-handler.ts b/apps/desktop/src/lib/shortcuts/keyboard-handler.ts new file mode 100644 index 0000000..e32c85d --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/keyboard-handler.ts @@ -0,0 +1,61 @@ +/** + * Keyboard handler for shortcut matching. + * See docs/specs/shortcut-settings.md Β§8 for specification. + */ + +import { commands } from '$lib/commands/command-registry' +import type { Command } from '$lib/commands/types' +import { formatKeyCombo, isModifierKey } from './key-capture' +import { getActiveScopes, type CommandScope } from './scope-hierarchy' +import { getEffectiveShortcuts } from './shortcuts-store' + +/** + * Get all commands in a specific scope. + */ +function getCommandsInScope(scope: CommandScope): Command[] { + return commands.filter((c) => c.scope === scope) +} + +/** + * Handle a keyboard event and return the command ID to execute, if any. + * Returns null if no matching command is found. + * + * @param event - The keyboard event + * @param currentScope - The current active scope + * @returns The command ID to execute, or null + */ +export function handleKeyDown(event: KeyboardEvent, currentScope: CommandScope): string | null { + // Ignore pure modifier key presses + if (isModifierKey(event.key)) { + return null + } + + const shortcut = formatKeyCombo(event) + const activeScopes = getActiveScopes(currentScope) + + // Check scopes in priority order (most specific first) + for (const scope of activeScopes) { + const scopeCommands = getCommandsInScope(scope) + + for (const command of scopeCommands) { + const shortcuts = getEffectiveShortcuts(command.id) + + if (shortcuts.includes(shortcut)) { + return command.id + } + } + } + + return null +} + +/** + * Find all commands that match a given shortcut, regardless of scope. + * Useful for the keyboard shortcuts settings UI. + */ +export function findCommandsWithShortcut(shortcut: string): Command[] { + return commands.filter((cmd) => { + const shortcuts = getEffectiveShortcuts(cmd.id) + return shortcuts.includes(shortcut) + }) +} diff --git a/apps/desktop/src/lib/shortcuts/mcp-shortcuts-listener.ts b/apps/desktop/src/lib/shortcuts/mcp-shortcuts-listener.ts new file mode 100644 index 0000000..771d57e --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/mcp-shortcuts-listener.ts @@ -0,0 +1,84 @@ +/** + * MCP Shortcuts Listener - handles shortcut changes from MCP tools in the main window. + */ + +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { setShortcut, removeShortcut, resetShortcut } from './shortcuts-store' +import { getAppLogger } from '$lib/logger' + +const log = getAppLogger('mcp-shortcuts') + +let unlistenFns: UnlistenFn[] = [] + +interface McpShortcutsSetPayload { + commandId: string + index: number + shortcut: string +} + +interface McpShortcutsRemovePayload { + commandId: string + index: number +} + +interface McpShortcutsResetPayload { + commandId: string +} + +/** + * Set up MCP shortcuts event listeners for the main window. + * These allow MCP tools to modify shortcuts even when the settings window is closed. + */ +export async function setupMcpShortcutsListener(): Promise { + // Listen for shortcut set + const unlistenSet = await listen('mcp-shortcuts-set', (event) => { + const { commandId, index, shortcut } = event.payload + log.debug('MCP requested shortcut set: {commandId}[{index}] = {shortcut}', { commandId, index, shortcut }) + + try { + setShortcut(commandId, index, shortcut) + } catch (error) { + log.error('Failed to set shortcut via MCP: {error}', { error }) + } + }) + unlistenFns.push(unlistenSet) + + // Listen for shortcut remove + const unlistenRemove = await listen('mcp-shortcuts-remove', (event) => { + const { commandId, index } = event.payload + log.debug('MCP requested shortcut remove: {commandId}[{index}]', { commandId, index }) + + try { + removeShortcut(commandId, index) + } catch (error) { + log.error('Failed to remove shortcut via MCP: {error}', { error }) + } + }) + unlistenFns.push(unlistenRemove) + + // Listen for shortcut reset + const unlistenReset = await listen('mcp-shortcuts-reset', (event) => { + const { commandId } = event.payload + log.debug('MCP requested shortcut reset: {commandId}', { commandId }) + + try { + resetShortcut(commandId) + } catch (error) { + log.error('Failed to reset shortcut via MCP: {error}', { error }) + } + }) + unlistenFns.push(unlistenReset) + + log.debug('MCP shortcuts listeners set up in main window') +} + +/** + * Clean up MCP shortcuts event listeners. + */ +export function cleanupMcpShortcutsListener(): void { + for (const unlisten of unlistenFns) { + unlisten() + } + unlistenFns = [] + log.debug('MCP shortcuts listeners cleaned up') +} diff --git a/apps/desktop/src/lib/shortcuts/scope-hierarchy.ts b/apps/desktop/src/lib/shortcuts/scope-hierarchy.ts new file mode 100644 index 0000000..5fe6530 --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/scope-hierarchy.ts @@ -0,0 +1,69 @@ +/** + * Scope hierarchy for keyboard shortcuts. + * Determines which scopes' shortcuts are active in a given context. + * See docs/specs/shortcut-settings.md Β§2 for specification. + */ + +/** All available command scopes */ +export type CommandScope = + | 'App' // Global, works everywhere + | 'Main window' // Main window context + | 'File list' // File list focused + | 'Command palette' // Command palette open + | 'Navigation' // Navigation context + | 'Selection' // Selection operations + | 'Edit' // Edit operations + | 'View' // View operations + | 'Help' // Help operations + | 'About window' // About window context + | 'Settings window' // Settings window context + +/** + * Scope hierarchy - when a scope is active, these scopes' shortcuts also trigger. + * Order matters: more specific scopes are listed first for priority. + */ +const scopeHierarchy: Record = { + App: ['App'], + 'Main window': ['Main window', 'App'], + 'File list': ['File list', 'Main window', 'App'], + 'Command palette': ['Command palette', 'Main window', 'App'], + Navigation: ['Navigation', 'Main window', 'App'], + Selection: ['Selection', 'Main window', 'App'], + Edit: ['Edit', 'Main window', 'App'], + View: ['View', 'Main window', 'App'], + Help: ['Help', 'Main window', 'App'], + 'About window': ['About window', 'App'], + 'Settings window': ['Settings window', 'App'], +} + +/** + * Get all scopes that are active when the given scope is current. + * Returns scopes in priority order (most specific first). + * Returns empty array for unknown/compound scopes (like 'Main window/File list'). + */ +export function getActiveScopes(current: string): CommandScope[] { + if (current in scopeHierarchy) { + return scopeHierarchy[current as CommandScope] + } + return [] +} + +/** + * Check if two scopes overlap in the hierarchy. + * Used for conflict detection - overlapping scopes can have conflicts. + */ +export function scopesOverlap(scopeA: string, scopeB: string): boolean { + const activeA = getActiveScopes(scopeA) + const activeB = getActiveScopes(scopeB) + // They overlap if either hierarchy includes the other scope + // If either scope is unknown (empty activeScopes), treat them as non-overlapping + if (activeA.length === 0 || activeB.length === 0) { + return false + } + return activeA.includes(scopeB as CommandScope) || activeB.includes(scopeA as CommandScope) +} + +/** Get all available scopes for display/iteration */ +export function getAllScopes(): CommandScope[] { + return Object.keys(scopeHierarchy) as CommandScope[] +} diff --git a/apps/desktop/src/lib/shortcuts/shortcuts-store.ts b/apps/desktop/src/lib/shortcuts/shortcuts-store.ts new file mode 100644 index 0000000..35b04e7 --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/shortcuts-store.ts @@ -0,0 +1,367 @@ +/** + * Shortcuts persistence layer - stores custom keyboard shortcuts. + * See docs/specs/shortcut-settings.md Β§6 for specification. + */ + +import { load, type Store } from '@tauri-apps/plugin-store' +import { invoke } from '@tauri-apps/api/core' +import { commands } from '$lib/commands/command-registry' +import { getAppLogger } from '$lib/logger' + +const log = getAppLogger('shortcuts') + +// ============================================================================ +// Store configuration +// ============================================================================ + +const STORE_NAME = 'shortcuts.json' +const SCHEMA_VERSION = 1 + +let storeInstance: Store | null = null + +// In-memory cache of custom shortcuts +const customShortcuts: Record = {} +let initialized = false + +// ============================================================================ +// Initialization +// ============================================================================ + +async function getStore(): Promise { + if (!storeInstance) { + storeInstance = await load(STORE_NAME, { + defaults: { _schemaVersion: SCHEMA_VERSION }, + autoSave: false, + }) + } + return storeInstance +} + +/** + * Initialize the shortcuts store. Must be called before using other functions. + */ +export async function initializeShortcuts(): Promise { + if (initialized) { + log.debug('Shortcuts already initialized, skipping') + return + } + + log.debug('Initializing shortcuts store') + + const store = await getStore() + + // Check schema version and migrate if needed + const version = await store.get('_schemaVersion') + if (version !== undefined && version !== SCHEMA_VERSION) { + log.info('Migrating shortcuts from version {version}', { version }) + await migrateShortcuts(store, version) + } + + // Clear in-memory cache and load fresh from store + for (const key of Object.keys(customShortcuts)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete customShortcuts[key] + } + + // Load custom shortcuts from top-level keys (format: shortcut:commandId) + // This is similar to how settings-store stores values + const keys = await store.keys() + const shortcutKeys = keys.filter((k) => k.startsWith('shortcut:')) + + for (const key of shortcutKeys) { + const commandId = key.replace('shortcut:', '') + const shortcuts = await store.get(key) + if (shortcuts && shortcuts.length > 0) { + customShortcuts[commandId] = shortcuts + } + } + + if (Object.keys(customShortcuts).length > 0) { + log.info('Loaded {count} custom shortcuts: {ids}', { + count: Object.keys(customShortcuts).length, + ids: Object.keys(customShortcuts).join(', '), + }) + } else { + log.debug('No custom shortcuts found in store') + } + + initialized = true + + // Sync menu accelerators with loaded custom shortcuts + await syncMenuAccelerators() +} + +/** + * Sync all custom shortcuts to menu accelerators. + * Called at initialization to ensure menu reflects persisted shortcuts. + */ +async function syncMenuAccelerators(): Promise { + // Commands that have corresponding menu items + const menuCommands = ['view.fullMode', 'view.briefMode'] + + for (const commandId of menuCommands) { + // Only update if there's a custom shortcut + if (commandId in customShortcuts) { + log.debug('Syncing menu accelerator for {commandId}', { commandId }) + await updateMenuAccelerator(commandId) + } + } +} + +/** + * Force save immediately. Should be called before window/page unload. + * Note: Since shortcuts now save immediately (no debouncing), this is a no-op + * but kept for API compatibility. + */ +export function flushPendingSave(): Promise { + // Shortcuts save immediately now, so nothing to flush + log.debug('flushPendingSave called (no-op since saves are immediate)') + return Promise.resolve() +} + +/** + * Migrate shortcuts from older schema versions. + */ +async function migrateShortcuts(store: Store, fromVersion: number): Promise { + // Currently no migrations needed + if (fromVersion < 1) { + // Future migrations would go here + } + + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() +} + +// ============================================================================ +// Core API +// ============================================================================ + +/** + * Get all custom shortcuts as a record. + */ +export function getCustomShortcuts(): Record { + return { ...customShortcuts } +} + +/** + * Get effective shortcuts for a command (custom if set, otherwise defaults). + * Always returns a copy to prevent mutation of the original arrays. + */ +export function getEffectiveShortcuts(commandId: string): string[] { + if (commandId in customShortcuts) { + return [...customShortcuts[commandId]] + } + + const command = commands.find((c) => c.id === commandId) + return [...(command?.shortcuts ?? [])] +} + +/** + * Get default shortcuts for a command from the registry. + * Always returns a copy to prevent mutation of the original arrays. + */ +export function getDefaultShortcuts(commandId: string): string[] { + const command = commands.find((c) => c.id === commandId) + return [...(command?.shortcuts ?? [])] +} + +/** + * Check if a command's shortcuts have been modified from defaults. + */ +export function isShortcutModified(commandId: string): boolean { + return commandId in customShortcuts +} + +/** + * Check if shortcuts array matches defaults and clean up if so. + */ +function cleanupIfMatchesDefaults(commandId: string): void { + if (!(commandId in customShortcuts)) return + + const defaults = getDefaultShortcuts(commandId) + const current = customShortcuts[commandId] + + // Check if they match (same length and same values in same order) + const matches = current.length === defaults.length && current.every((shortcut, i) => shortcut === defaults[i]) + + if (matches) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete customShortcuts[commandId] + } +} + +/** + * Set a specific shortcut for a command at an index. + */ +export function setShortcut(commandId: string, index: number, shortcut: string): void { + log.debug('setShortcut({commandId}, {index}, {shortcut})', { commandId, index, shortcut }) + const current = getEffectiveShortcuts(commandId) + + if (index >= 0 && index < current.length) { + current[index] = shortcut + } else if (index === current.length) { + current.push(shortcut) + } + + customShortcuts[commandId] = current + cleanupIfMatchesDefaults(commandId) + // Save immediately (no debounce) since shortcut changes are rare user actions + // and we need persistence before the Settings window might close + void saveToStore() + notifyListeners(commandId) +} + +/** + * Add a new shortcut to a command. + */ +export function addShortcut(commandId: string, shortcut: string): void { + const current = getEffectiveShortcuts(commandId) + current.push(shortcut) + customShortcuts[commandId] = current + cleanupIfMatchesDefaults(commandId) + // Save immediately for reliable persistence + void saveToStore() + notifyListeners(commandId) +} + +/** + * Remove a shortcut from a command at an index. + */ +export function removeShortcut(commandId: string, index: number): void { + const current = getEffectiveShortcuts(commandId) + + if (index >= 0 && index < current.length) { + current.splice(index, 1) + customShortcuts[commandId] = current + cleanupIfMatchesDefaults(commandId) + // Save immediately for reliable persistence + void saveToStore() + notifyListeners(commandId) + } +} + +/** + * Reset a command's shortcuts to defaults. + */ +export function resetShortcut(commandId: string): void { + if (commandId in customShortcuts) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete customShortcuts[commandId] + // Save immediately for reliable persistence + void saveToStore() + notifyListeners(commandId) + } +} + +/** + * Reset all shortcuts to defaults. + */ +export async function resetAllShortcuts(): Promise { + const modifiedIds = Object.keys(customShortcuts) + + // Clear all customizations + for (const key of Object.keys(customShortcuts)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete customShortcuts[key] + } + + // Delete all shortcut keys from store + const store = await getStore() + const keys = await store.keys() + for (const key of keys) { + if (key.startsWith('shortcut:')) { + await store.delete(key) + } + } + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() + + // Notify listeners for all modified commands + for (const id of modifiedIds) { + notifyListeners(id) + } +} + +// ============================================================================ +// Persistence +// ============================================================================ + +async function saveToStore(): Promise { + try { + const store = await getStore() + log.debug('Saving shortcuts: {shortcuts}', { shortcuts: JSON.stringify(customShortcuts) }) + + // Store each command's shortcuts at top level (like settings-store does) + // This avoids potential issues with nested objects + for (const [commandId, shortcuts] of Object.entries(customShortcuts)) { + await store.set(`shortcut:${commandId}`, shortcuts) + } + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() + log.debug('Shortcuts saved successfully') + } catch (error) { + log.error('Failed to save shortcuts: {error}', { error }) + } +} + +// ============================================================================ +// Change listeners +// ============================================================================ + +type ShortcutChangeListener = (commandId: string) => void + +const listeners = new Set() + +/** + * Subscribe to shortcut changes. + */ +export function onShortcutChange(listener: ShortcutChangeListener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function notifyListeners(commandId: string): void { + for (const listener of listeners) { + try { + listener(commandId) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Shortcut change listener error:', error) + } + } + + // Update menu accelerator for commands that have menu items + void updateMenuAccelerator(commandId) +} + +/** + * Update the menu accelerator for a command. + * Called automatically when shortcuts change. + * Only affects commands that have corresponding menu items. + */ +async function updateMenuAccelerator(commandId: string): Promise { + // Only certain commands have menu items with accelerators + const menuCommands = ['view.fullMode', 'view.briefMode'] + if (!menuCommands.includes(commandId)) return + + try { + const shortcuts = getEffectiveShortcuts(commandId) + // Use the first shortcut for the menu accelerator (menus only show one) + const shortcut = shortcuts[0] ?? '' + await invoke('update_menu_accelerator', { commandId, shortcut }) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to update menu accelerator:', error) + } +} + +// ============================================================================ +// Utility: Force save (for testing) +// ============================================================================ + +/** + * Force an immediate save to disk. Used for testing. + */ +export async function forceSave(): Promise { + await saveToStore() +} diff --git a/apps/desktop/src/lib/shortcuts/shortcuts.test.ts b/apps/desktop/src/lib/shortcuts/shortcuts.test.ts new file mode 100644 index 0000000..be079d0 --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/shortcuts.test.ts @@ -0,0 +1,202 @@ +/** + * Tests for the keyboard shortcuts system. + */ + +import { describe, it, expect } from 'vitest' +import { getActiveScopes, scopesOverlap, getAllScopes } from './scope-hierarchy' +import { formatKeyCombo, normalizeKeyName, isModifierKey, matchesShortcut, isCompleteCombo } from './key-capture' + +// ============================================================================ +// Scope hierarchy tests +// ============================================================================ + +describe('scope-hierarchy', () => { + describe('getActiveScopes', () => { + it('returns only App for App scope', () => { + const scopes = getActiveScopes('App') + expect(scopes).toEqual(['App']) + }) + + it('returns Main window and App for Main window scope', () => { + const scopes = getActiveScopes('Main window') + expect(scopes).toEqual(['Main window', 'App']) + }) + + it('returns File list, Main window, and App for File list scope', () => { + const scopes = getActiveScopes('File list') + expect(scopes).toEqual(['File list', 'Main window', 'App']) + }) + + it('returns Command palette, Main window, and App for Command palette scope', () => { + const scopes = getActiveScopes('Command palette') + expect(scopes).toEqual(['Command palette', 'Main window', 'App']) + }) + + it('returns About window and App for About window scope', () => { + const scopes = getActiveScopes('About window') + expect(scopes).toEqual(['About window', 'App']) + }) + }) + + describe('scopesOverlap', () => { + it('App overlaps with everything', () => { + expect(scopesOverlap('App', 'App')).toBe(true) + expect(scopesOverlap('App', 'Main window')).toBe(true) + expect(scopesOverlap('App', 'File list')).toBe(true) + expect(scopesOverlap('App', 'About window')).toBe(true) + }) + + it('File list overlaps with Main window', () => { + expect(scopesOverlap('File list', 'Main window')).toBe(true) + expect(scopesOverlap('Main window', 'File list')).toBe(true) + }) + + it('File list does not overlap with About window', () => { + expect(scopesOverlap('File list', 'About window')).toBe(false) + expect(scopesOverlap('About window', 'File list')).toBe(false) + }) + + it('Command palette does not overlap with About window', () => { + expect(scopesOverlap('Command palette', 'About window')).toBe(false) + }) + + it('Settings window does not overlap with Main window children', () => { + expect(scopesOverlap('Settings window', 'File list')).toBe(false) + expect(scopesOverlap('Settings window', 'Navigation')).toBe(false) + }) + }) + + describe('getAllScopes', () => { + it('returns all defined scopes', () => { + const scopes = getAllScopes() + expect(scopes).toContain('App') + expect(scopes).toContain('Main window') + expect(scopes).toContain('File list') + expect(scopes).toContain('Command palette') + expect(scopes).toContain('About window') + expect(scopes).toContain('Settings window') + expect(scopes.length).toBeGreaterThanOrEqual(10) + }) + }) +}) + +// ============================================================================ +// Key capture tests +// ============================================================================ + +describe('key-capture', () => { + // Helper to create mock keyboard events + function createKeyEvent(key: string, modifiers: Partial = {}): KeyboardEvent { + return { + key, + metaKey: modifiers.metaKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, + altKey: modifiers.altKey ?? false, + shiftKey: modifiers.shiftKey ?? false, + } as KeyboardEvent + } + + describe('normalizeKeyName', () => { + it('uppercases single characters', () => { + expect(normalizeKeyName('a')).toBe('A') + expect(normalizeKeyName('z')).toBe('Z') + expect(normalizeKeyName('p')).toBe('P') + }) + + it('keeps uppercase characters', () => { + expect(normalizeKeyName('A')).toBe('A') + }) + + it('handles space specially', () => { + expect(normalizeKeyName(' ')).toBe('Space') + }) + + it('passes through unknown special keys', () => { + expect(normalizeKeyName('F1')).toBe('F1') + expect(normalizeKeyName('F12')).toBe('F12') + }) + }) + + describe('isModifierKey', () => { + it('returns true for modifier keys', () => { + expect(isModifierKey('Meta')).toBe(true) + expect(isModifierKey('Control')).toBe(true) + expect(isModifierKey('Alt')).toBe(true) + expect(isModifierKey('Shift')).toBe(true) + expect(isModifierKey('OS')).toBe(true) + }) + + it('returns false for regular keys', () => { + expect(isModifierKey('a')).toBe(false) + expect(isModifierKey('Enter')).toBe(false) + expect(isModifierKey('Escape')).toBe(false) + expect(isModifierKey('F1')).toBe(false) + }) + }) + + describe('formatKeyCombo', () => { + // Note: These tests assume non-macOS environment (userAgent check) + // In a real test environment, we'd mock navigator.userAgent + + it('formats single key', () => { + const event = createKeyEvent('p') + const result = formatKeyCombo(event) + // On non-macOS, just the key + expect(result).toBe('P') + }) + + it('formats Ctrl+key', () => { + const event = createKeyEvent('p', { ctrlKey: true }) + const result = formatKeyCombo(event) + expect(result).toBe('Ctrl+P') + }) + + it('formats Ctrl+Shift+key', () => { + const event = createKeyEvent('p', { ctrlKey: true, shiftKey: true }) + const result = formatKeyCombo(event) + expect(result).toBe('Ctrl+Shift+P') + }) + + it('formats Ctrl+Alt+Shift+key', () => { + const event = createKeyEvent('p', { ctrlKey: true, altKey: true, shiftKey: true }) + const result = formatKeyCombo(event) + expect(result).toBe('Ctrl+Alt+Shift+P') + }) + + it('ignores pure modifier key presses', () => { + const event = createKeyEvent('Control', { ctrlKey: true }) + const result = formatKeyCombo(event) + expect(result).toBe('Ctrl') + }) + }) + + describe('matchesShortcut', () => { + it('matches exact shortcut', () => { + const event = createKeyEvent('p', { ctrlKey: true }) + expect(matchesShortcut(event, 'Ctrl+P')).toBe(true) + }) + + it('does not match different shortcut', () => { + const event = createKeyEvent('p', { ctrlKey: true }) + expect(matchesShortcut(event, 'Ctrl+Q')).toBe(false) + }) + + it('does not match with different modifiers', () => { + const event = createKeyEvent('p', { ctrlKey: true }) + expect(matchesShortcut(event, 'Ctrl+Shift+P')).toBe(false) + }) + }) + + describe('isCompleteCombo', () => { + it('returns true for regular keys', () => { + expect(isCompleteCombo(createKeyEvent('p'))).toBe(true) + expect(isCompleteCombo(createKeyEvent('Enter'))).toBe(true) + }) + + it('returns false for modifier-only', () => { + expect(isCompleteCombo(createKeyEvent('Control'))).toBe(false) + expect(isCompleteCombo(createKeyEvent('Meta'))).toBe(false) + expect(isCompleteCombo(createKeyEvent('Shift'))).toBe(false) + }) + }) +}) diff --git a/apps/desktop/src/lib/shortcuts/types.ts b/apps/desktop/src/lib/shortcuts/types.ts new file mode 100644 index 0000000..d71e4bc --- /dev/null +++ b/apps/desktop/src/lib/shortcuts/types.ts @@ -0,0 +1,31 @@ +/** + * Types for the keyboard shortcut customization system. + * See docs/specs/shortcut-settings.md for full specification. + */ + +/** Represents a parsed key combination */ +export interface KeyCombo { + meta: boolean + ctrl: boolean + alt: boolean + shift: boolean + key: string // Normalized key name (for example 'P', 'Backspace', 'F1') +} + +/** A conflict between commands sharing the same shortcut */ +export interface ShortcutConflict { + shortcut: string + commandIds: string[] +} + +/** Custom shortcuts storage format */ +export interface CustomShortcutsData { + _schemaVersion: number + shortcuts: Record +} + +/** Result of setting a shortcut - may include conflicts */ +export interface SetShortcutResult { + success: boolean + conflicts?: ShortcutConflict +} diff --git a/apps/desktop/src/lib/tauri-commands.ts b/apps/desktop/src/lib/tauri-commands.ts index c430908..157fc82 100644 --- a/apps/desktop/src/lib/tauri-commands.ts +++ b/apps/desktop/src/lib/tauri-commands.ts @@ -400,10 +400,11 @@ export async function openExternalUrl(url: string): Promise { /** * Gets icon data URLs for the requested icon IDs. * @param iconIds - Array of icon IDs like "ext:jpg", "dir", "symlink" + * @param useAppIconsForDocuments - Whether to use app icons as fallback for documents * @returns Map of icon_id β†’ base64 WebP data URL */ -export async function getIcons(iconIds: string[]): Promise> { - return invoke>('get_icons', { iconIds }) +export async function getIcons(iconIds: string[], useAppIconsForDocuments: boolean): Promise> { + return invoke>('get_icons', { iconIds, useAppIconsForDocuments }) } /** @@ -411,17 +412,28 @@ export async function getIcons(iconIds: string[]): Promise> { return invoke>('refresh_directory_icons', { directoryPaths, extensions, + useAppIconsForDocuments, }) } + +/** + * Clears cached extension icons. + * Called when the "use app icons for documents" setting changes. + */ +export async function clearExtensionIconCache(): Promise { + await invoke('clear_extension_icon_cache') +} /** * Shows a native context menu for a file. * @param path - Absolute path to the file. @@ -740,12 +752,14 @@ export async function resolveNetworkHost(hostId: string): Promise { // The Rust command returns Result // Tauri auto-converts Ok to value and Err to thrown error - return invoke('list_shares_on_host', { hostId, hostname, ipAddress, port }) + return invoke('list_shares_on_host', { hostId, hostname, ipAddress, port, timeoutMs, cacheTtlMs }) } /** @@ -767,15 +783,19 @@ export async function listSharesOnHost( * @param hostname Hostname to connect to * @param ipAddress Optional resolved IP address * @param port SMB port + * @param timeoutMs Optional timeout in milliseconds (default: 15000) + * @param cacheTtlMs Optional cache TTL in milliseconds (default: 30000) */ export async function prefetchShares( hostId: string, hostname: string, ipAddress: string | undefined, port: number, + timeoutMs?: number, + cacheTtlMs?: number, ): Promise { try { - await invoke('prefetch_shares', { hostId, hostname, ipAddress, port }) + await invoke('prefetch_shares', { hostId, hostname, ipAddress, port, timeoutMs, cacheTtlMs }) } catch { // Silently ignore prefetch errors } @@ -950,6 +970,8 @@ export async function deleteSmbCredentials(server: string, share: string | null) * @param port SMB port * @param username Username for authentication (null for guest) * @param password Password for authentication (null for guest) + * @param timeoutMs Optional timeout in milliseconds (default: 15000) + * @param cacheTtlMs Optional cache TTL in milliseconds (default: 30000) */ export async function listSharesWithCredentials( hostId: string, @@ -958,6 +980,8 @@ export async function listSharesWithCredentials( port: number, username: string | null, password: string | null, + timeoutMs?: number, + cacheTtlMs?: number, ): Promise { return invoke('list_shares_with_credentials', { hostId, @@ -966,6 +990,8 @@ export async function listSharesWithCredentials( port, username, password, + timeoutMs, + cacheTtlMs, }) } @@ -993,6 +1019,7 @@ export function isKeychainError(error: unknown): error is KeychainError { * @param share Name of the share to mount * @param username Optional username for authentication * @param password Optional password for authentication + * @param timeoutMs Optional timeout in milliseconds (default: 20000) * @returns MountResult with mount path on success * @throws MountError on failure */ @@ -1001,12 +1028,14 @@ export async function mountNetworkShare( share: string, username: string | null, password: string | null, + timeoutMs?: number, ): Promise { return invoke('mount_network_share', { server, share, username, password, + timeoutMs, }) } @@ -1138,13 +1167,15 @@ export async function validateLicenseWithServer(): Promise { * @param sources - List of source file/directory paths * @param sortColumn - Column to sort by * @param sortOrder - Sort order + * @param progressIntervalMs - Progress update interval in milliseconds (default: 500) */ export async function startScanPreview( sources: string[], sortColumn: SortColumn, sortOrder: SortOrder, + progressIntervalMs?: number, ): Promise { - return invoke('start_scan_preview', { sources, sortColumn, sortOrder }) + return invoke('start_scan_preview', { sources, sortColumn, sortOrder, progressIntervalMs }) } /** @@ -1661,3 +1692,44 @@ export async function getFolderSuggestions( return [] } } + +// ============================================================================ +// Settings commands +// ============================================================================ + +/** + * Checks if a port is available for binding. + * @param port - The port number to check + * @returns True if the port is available + */ +export async function checkPortAvailable(port: number): Promise { + return invoke('check_port_available', { port }) +} + +/** + * Finds an available port starting from the given port. + * Scans up to 100 ports from the start port. + * @param startPort - The port to start scanning from + * @returns Available port number, or null if none found + */ +export async function findAvailablePort(startPort: number): Promise { + return invoke('find_available_port', { startPort }) +} + +/** + * Updates the file watcher debounce duration in the Rust backend. + * This affects newly created watchers; existing watchers keep their original duration. + * @param debounceMs - Debounce duration in milliseconds + */ +export async function updateFileWatcherDebounce(debounceMs: number): Promise { + await invoke('update_file_watcher_debounce', { debounceMs }) +} + +/** + * Updates the Bonjour service resolve timeout in the Rust backend. + * This affects future service resolutions; ongoing resolutions keep their original timeout. + * @param timeoutMs - Timeout duration in milliseconds + */ +export async function updateServiceResolveTimeout(timeoutMs: number): Promise { + await invoke('update_service_resolve_timeout', { timeoutMs }) +} diff --git a/apps/desktop/src/lib/updater.svelte.ts b/apps/desktop/src/lib/updater.svelte.ts index 0bc7ae2..3224e98 100644 --- a/apps/desktop/src/lib/updater.svelte.ts +++ b/apps/desktop/src/lib/updater.svelte.ts @@ -2,8 +2,12 @@ import { check, type Update } from '@tauri-apps/plugin-updater' import { relaunch } from '@tauri-apps/plugin-process' import { getVersion } from '@tauri-apps/api/app' import { feLog } from './tauri-commands' +import { getSetting, onSpecificSettingChange } from './settings/settings-store' -const checkIntervalMs = 60 * 60 * 1000 // 60 minutes +/** Gets the update check interval from settings (in milliseconds) */ +function getCheckIntervalMs(): number { + return getSetting('advanced.updateCheckInterval') +} interface UpdateState { status: 'idle' | 'checking' | 'downloading' | 'ready' @@ -63,13 +67,24 @@ export function startUpdateChecker(): () => void { // Check immediately on start void checkForUpdates() - // Check periodically - const intervalId = setInterval(() => { + // Check periodically using the interval from settings + let intervalId = setInterval(() => { void checkForUpdates() - }, checkIntervalMs) + }, getCheckIntervalMs()) + + // Re-create interval when setting changes + const unsubscribe = onSpecificSettingChange('advanced.updateCheckInterval', () => { + clearInterval(intervalId) + const newInterval = getCheckIntervalMs() + feLog(`[updater] Interval changed to ${String(newInterval / 60000)} minutes`) + intervalId = setInterval(() => { + void checkForUpdates() + }, newInterval) + }) // Return cleanup function return () => { clearInterval(intervalId) + unsubscribe() } } diff --git a/apps/desktop/src/lib/utils/confirm-dialog.ts b/apps/desktop/src/lib/utils/confirm-dialog.ts new file mode 100644 index 0000000..de94850 --- /dev/null +++ b/apps/desktop/src/lib/utils/confirm-dialog.ts @@ -0,0 +1,18 @@ +/** + * Cross-platform confirmation dialog utility. + * Uses Tauri's native dialog API which works properly in all contexts. + */ + +import { ask } from '@tauri-apps/plugin-dialog' + +/** + * Show a confirmation dialog with OK/Cancel buttons. + * Uses Tauri's native dialog for reliable behavior. + * + * @param message - The message to display + * @param title - Optional title for the dialog (defaults to 'Confirm') + * @returns Promise that resolves to true if confirmed, false otherwise + */ +export async function confirmDialog(message: string, title = 'Confirm'): Promise { + return ask(message, { title, kind: 'warning' }) +} diff --git a/apps/desktop/src/lib/write-operations/CopyDialog.svelte b/apps/desktop/src/lib/write-operations/CopyDialog.svelte index bd3f33c..091502e 100644 --- a/apps/desktop/src/lib/write-operations/CopyDialog.svelte +++ b/apps/desktop/src/lib/write-operations/CopyDialog.svelte @@ -13,6 +13,7 @@ type UnlistenFn, } from '$lib/tauri-commands' import type { VolumeInfo, SortColumn, SortOrder } from '$lib/file-explorer/types' + import { getSetting } from '$lib/settings' import DirectionIndicator from './DirectionIndicator.svelte' import { generateTitle } from './copy-dialog-utils' @@ -153,7 +154,8 @@ // Start the scan isScanning = true - const result = await startScanPreview(sourcePaths, sortColumn, sortOrder) + const progressIntervalMs = getSetting('fileOperations.progressUpdateInterval') + const result = await startScanPreview(sourcePaths, sortColumn, sortOrder, progressIntervalMs) previewId = result.previewId } diff --git a/apps/desktop/src/lib/write-operations/CopyProgressDialog.svelte b/apps/desktop/src/lib/write-operations/CopyProgressDialog.svelte index dc92027..2957d15 100644 --- a/apps/desktop/src/lib/write-operations/CopyProgressDialog.svelte +++ b/apps/desktop/src/lib/write-operations/CopyProgressDialog.svelte @@ -25,7 +25,9 @@ SortOrder, ConflictResolution, } from '$lib/file-explorer/types' - import { formatHumanReadable, formatDate } from '$lib/file-explorer/selection-info-utils' + import { formatDate } from '$lib/file-explorer/selection-info-utils' + import { formatFileSize } from '$lib/settings/reactive-settings.svelte' + import { getSetting } from '$lib/settings' import DirectionIndicator from './DirectionIndicator.svelte' import { getAppLogger } from '$lib/logger' @@ -290,9 +292,12 @@ log.debug('Event subscriptions ready, starting copyFiles') try { + const progressIntervalMs = getSetting('fileOperations.progressUpdateInterval') + const maxConflictsToShow = getSetting('fileOperations.maxConflictsToShow') const result = await copyFiles(sourcePaths, destinationPath, { conflictResolution: 'stop', - progressIntervalMs: 100, + progressIntervalMs, + maxConflictsToShow, sortColumn, sortOrder, previewId, @@ -462,7 +467,7 @@
Existing: {formatHumanReadable(conflictEvent.destinationSize)}{formatFileSize(conflictEvent.destinationSize)} {#if existingIsLarger}(larger){/if} New: {formatHumanReadable(conflictEvent.sourceSize)}{formatFileSize(conflictEvent.sourceSize)} {#if newIsLarger}(larger){/if} /** - * Main window layout - includes updater, notifications, and window state. + * Main window layout - includes updater, notifications, window state, and settings. * Only used for the main file manager window. */ - import { onMount } from 'svelte' + import { onMount, onDestroy } from 'svelte' import { initWindowStateListener } from '$lib/window-state' import { startUpdateChecker } from '$lib/updater.svelte' + import { initSettingsApplier, cleanupSettingsApplier } from '$lib/settings/settings-applier' + import { initReactiveSettings, cleanupReactiveSettings } from '$lib/settings/reactive-settings.svelte' + import { initializeShortcuts, setupMcpShortcutsListener, cleanupMcpShortcutsListener } from '$lib/shortcuts' import AiNotification from '$lib/AiNotification.svelte' import UpdateNotification from '$lib/UpdateNotification.svelte' - onMount(() => { + let cleanupUpdater: (() => void) | undefined + + onMount(async () => { + // Initialize reactive settings for UI components + await initReactiveSettings() + + // Initialize settings and apply them to CSS variables + await initSettingsApplier() + + // Initialize keyboard shortcuts store (loads custom shortcuts from disk) + await initializeShortcuts() + + // Set up MCP shortcuts listener (allows MCP tools to modify shortcuts) + await setupMcpShortcutsListener() + // Initialize window state persistence on resize // This ensures window size/position survives hot reloads void initWindowStateListener() // Start checking for updates (skips in dev mode) - return startUpdateChecker() + cleanupUpdater = startUpdateChecker() + }) + + onDestroy(() => { + cleanupReactiveSettings() + cleanupSettingsApplier() + cleanupMcpShortcutsListener() + cleanupUpdater?.() }) diff --git a/apps/desktop/src/routes/(main)/+page.svelte b/apps/desktop/src/routes/(main)/+page.svelte index 4cee317..69291c5 100644 --- a/apps/desktop/src/routes/(main)/+page.svelte +++ b/apps/desktop/src/routes/(main)/+page.svelte @@ -24,6 +24,7 @@ getWindowTitle, } from '$lib/tauri-commands' import { loadSettings, saveSettings } from '$lib/settings-store' + import { openSettingsWindow } from '$lib/settings/settings-window' import { hideExpirationModal, loadLicenseStatus, triggerValidationIfNeeded } from '$lib/licensing-store.svelte' import type { ViewMode } from '$lib/app-status-store' @@ -98,6 +99,44 @@ } } + /** Check if key event matches βŒ˜β‡§P (command palette) */ + function isCommandPaletteShortcut(e: KeyboardEvent): boolean { + return e.metaKey && e.shiftKey && e.key.toLowerCase() === 'p' + } + + /** Check if key event matches ⌘, (settings) */ + function isSettingsShortcut(e: KeyboardEvent): boolean { + return e.metaKey && !e.shiftKey && !e.altKey && e.key === ',' + } + + /** Check if key event matches ⌘D (debug window, dev only) */ + function isDebugWindowShortcut(e: KeyboardEvent): boolean { + return import.meta.env.DEV && e.metaKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd' + } + + /** Check if key event should be suppressed (Cmd+A, Cmd+Opt+I in prod) */ + function shouldSuppressKey(e: KeyboardEvent): boolean { + if (e.metaKey && e.key === 'a') return true + if (!import.meta.env.DEV && e.metaKey && e.altKey && e.key === 'i') return true + return false + } + + /** Global keyboard handler for app-level shortcuts */ + function handleGlobalKeyDown(e: KeyboardEvent): void { + if (isCommandPaletteShortcut(e)) { + e.preventDefault() + showCommandPalette = true + } else if (isSettingsShortcut(e)) { + e.preventDefault() + void openSettingsWindow() + } else if (isDebugWindowShortcut(e)) { + e.preventDefault() + void openDebugWindow() + } else if (shouldSuppressKey(e)) { + e.preventDefault() + } + } + /** Start window drag when title bar is clicked */ async function handleTitleBarMouseDown(e: MouseEvent) { if (e.buttons === 1) { @@ -179,30 +218,7 @@ await setupTauriEventListeners() // Global keyboard shortcuts - handleKeyDown = (e: KeyboardEvent) => { - // Command palette: βŒ˜β‡§P - if (e.metaKey && e.shiftKey && e.key.toLowerCase() === 'p') { - e.preventDefault() - showCommandPalette = true - return - } - - // Debug window: ⌘D (dev mode only) - if (import.meta.env.DEV && e.metaKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') { - e.preventDefault() - void openDebugWindow() - return - } - - // Suppress Cmd+A (select all) - always - if (e.metaKey && e.key === 'a') { - e.preventDefault() - } - // Suppress Cmd+Opt+I (devtools) in production only - if (!import.meta.env.DEV && e.metaKey && e.altKey && e.key === 'i') { - e.preventDefault() - } - } + handleKeyDown = handleGlobalKeyDown // Suppress right-click context menu handleContextMenu = (e: MouseEvent) => { @@ -244,6 +260,15 @@ // Not in Tauri environment } + // Listen for open-settings event from menu + try { + await listen('open-settings', () => { + void openSettingsWindow() + }) + } catch { + // Not in Tauri environment + } + // Listen for switch pane event from menu try { unlistenSwitchPane = await listen('switch-pane', () => { diff --git a/apps/desktop/src/routes/settings/+page.svelte b/apps/desktop/src/routes/settings/+page.svelte new file mode 100644 index 0000000..f9cd605 --- /dev/null +++ b/apps/desktop/src/routes/settings/+page.svelte @@ -0,0 +1,211 @@ + + + + + +
+ {#if initialized} +
+ + +
+ +
+
+ {:else} +
Loading settings...
+ {/if} +
+ + diff --git a/apps/desktop/test/e2e-linux/app.spec.ts b/apps/desktop/test/e2e-linux/app.spec.ts index 60faad5..063f107 100644 --- a/apps/desktop/test/e2e-linux/app.spec.ts +++ b/apps/desktop/test/e2e-linux/app.spec.ts @@ -35,7 +35,7 @@ async function getEntryName(entry: WebdriverIO.Element): Promise { /** * Helper to ensure app is ready and panes have focus initialized. */ -async function ensureAppReady(): Promise { +async function ensureAppReadyWithFocus(): Promise { const fileEntry = await browser.$('.file-entry') await fileEntry.waitForExist({ timeout: 10000 }) @@ -78,7 +78,7 @@ describe('Basic rendering', () => { describe('Keyboard navigation', () => { it('should move cursor with arrow keys', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() // Get all file entries and find which one has the cursor // Spread to convert ChainablePromiseArray to real array for .length @@ -123,7 +123,7 @@ describe('Keyboard navigation', () => { }) it('should switch panes with Tab key', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() // Re-query panes after ensureAppReady let panes = await browser.$$('.file-pane') @@ -158,7 +158,7 @@ describe('Keyboard navigation', () => { }) it('should toggle selection with Space key', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() // Get cursor entry (cast needed due to WDIO ChainablePromiseElement type quirk) let cursorEntry = (await browser.$('.file-entry.is-under-cursor')) as unknown as WebdriverIO.Element @@ -197,7 +197,7 @@ describe('Keyboard navigation', () => { describe('Mouse interactions', () => { it('should move cursor when clicking a file entry', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() const entries = [...(await browser.$$('.file-entry'))] if (entries.length < 2) { @@ -216,7 +216,7 @@ describe('Mouse interactions', () => { }) it('should switch pane focus when clicking other pane', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() let panes = await browser.$$('.file-pane') expect(panes.length).toBe(2) @@ -243,7 +243,7 @@ describe('Mouse interactions', () => { describe('Navigation', () => { it('should navigate into directories with Enter', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() // Get current path from the focused pane's header let pathElement = await browser.$('.file-pane.is-focused .header .path') @@ -276,7 +276,7 @@ describe('Navigation', () => { }) it('should navigate to parent with Backspace', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() // First, navigate into a directory so we can go back const dirEntry = await browser.$('.file-entry:has(.size-dir)') @@ -309,7 +309,7 @@ describe('Navigation', () => { describe('New folder dialog', () => { it('should open new folder dialog with F7', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() // Press F7 to open new folder dialog await browser.keys('F7') @@ -351,7 +351,7 @@ describe('New folder dialog', () => { }) it('should create a folder and close the dialog', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() // Press F7 to open new folder dialog await browser.keys('F7') @@ -382,7 +382,7 @@ describe('New folder dialog', () => { describe('Copy dialog', () => { it('should open copy dialog with F5', async () => { - await ensureAppReady() + await ensureAppReadyWithFocus() // Move cursor to a file (skip ".." entry) const cursorEntry = (await browser.$('.file-entry.is-under-cursor')) as unknown as WebdriverIO.Element diff --git a/apps/desktop/test/e2e-linux/settings.spec.ts b/apps/desktop/test/e2e-linux/settings.spec.ts new file mode 100644 index 0000000..8c6771c --- /dev/null +++ b/apps/desktop/test/e2e-linux/settings.spec.ts @@ -0,0 +1,263 @@ +/** + * E2E tests for the Settings window on Linux. + * + * These tests verify the Settings dialog functionality including: + * - Opening the settings window via keyboard shortcut + * - Navigation between sections + * - Search functionality + * - Setting persistence + * + * Note: Since Settings opens as a separate WebviewWindow, these tests + * need to handle window switching. + */ + +/** + * Helper to wait for a new window to appear and switch to it. + * Returns the original window handle so we can switch back. + */ +async function switchToNewWindow(): Promise { + const originalWindow = await browser.getWindowHandle() + const startHandles = await browser.getWindowHandles() + + // Wait for a new window to appear + await browser.waitUntil( + async () => { + const handles = await browser.getWindowHandles() + return handles.length > startHandles.length + }, + { timeout: 5000, timeoutMsg: 'New window did not appear' }, + ) + + // Get the new window handle + const newHandles = await browser.getWindowHandles() + const newWindow = newHandles.find((h) => !startHandles.includes(h)) + + if (newWindow) { + await browser.switchToWindow(newWindow) + } + + return originalWindow +} + +/** + * Helper to ensure the main app is ready for settings tests. + */ +async function ensureMainAppReady(): Promise { + const fileEntry = await browser.$('.file-entry') + await fileEntry.waitForExist({ timeout: 10000 }) + await browser.pause(300) +} + +/** + * Helper to open settings window via keyboard shortcut. + * On Linux, we use Ctrl+, since there's no Meta/Cmd key. + */ +async function openSettingsViaShortcut(): Promise { + // Try Meta+, first (macOS style, might work in some setups) + await browser.keys(['Meta', ',']) + await browser.pause(500) + + // Check if settings window opened + const handles = await browser.getWindowHandles() + if (handles.length > 1) { + return // Settings opened + } + + // Try Ctrl+, as fallback for Linux + await browser.keys(['Control', ',']) + await browser.pause(500) +} + +describe('Settings window', () => { + // Store original window handle for switching back + let mainWindowHandle: string + + beforeEach(async () => { + // Ensure we're on the main window + const handles = await browser.getWindowHandles() + mainWindowHandle = handles[0] + await browser.switchToWindow(mainWindowHandle) + await ensureMainAppReady() + }) + + afterEach(async () => { + // Close any extra windows and return to main + const handles = await browser.getWindowHandles() + for (const handle of handles) { + if (handle !== mainWindowHandle) { + await browser.switchToWindow(handle) + await browser.closeWindow() + } + } + if (handles.length > 1) { + await browser.switchToWindow(mainWindowHandle) + } + }) + + it('should open settings window with keyboard shortcut', async () => { + await openSettingsViaShortcut() + + // Check if a new window appeared + const handles = await browser.getWindowHandles() + + if (handles.length > 1) { + // Multi-window mode works + await switchToNewWindow() + + // Verify settings window content + const settingsWindow = await browser.$('.settings-window') + await settingsWindow.waitForExist({ timeout: 5000 }) + expect(await settingsWindow.isExisting()).toBe(true) + } else { + // Single window mode - skip this test + console.log('Skipping: Multi-window not supported in this environment') + } + }) + + it('should display settings sidebar with sections', async () => { + await openSettingsViaShortcut() + + const handles = await browser.getWindowHandles() + if (handles.length <= 1) { + console.log('Skipping: Multi-window not supported') + return + } + + await switchToNewWindow() + + // Wait for settings to load + const sidebar = await browser.$('.settings-sidebar') + await sidebar.waitForExist({ timeout: 5000 }) + + // Verify sidebar sections exist + const sectionItems = await browser.$$('.section-item') + expect(sectionItems.length).toBeGreaterThan(0) + + // Verify expected sections are present + const sectionTexts: string[] = [] + for (const item of sectionItems) { + sectionTexts.push(await item.getText()) + } + + // Check for core sections + expect(sectionTexts.some((t) => t.includes('Appearance'))).toBe(true) + expect(sectionTexts.some((t) => t.includes('Keyboard shortcuts'))).toBe(true) + }) + + it('should have a working search input', async () => { + await openSettingsViaShortcut() + + const handles = await browser.getWindowHandles() + if (handles.length <= 1) { + console.log('Skipping: Multi-window not supported') + return + } + + await switchToNewWindow() + + // Find and interact with search input + const searchInput = await browser.$('.search-input') + await searchInput.waitForExist({ timeout: 5000 }) + + // Type a search query + await searchInput.setValue('theme') + await browser.pause(300) + + // Verify search is working (input value should be set) + const value = await searchInput.getValue() + expect(value).toBe('theme') + }) + + it('should navigate between sections when clicking', async () => { + await openSettingsViaShortcut() + + const handles = await browser.getWindowHandles() + if (handles.length <= 1) { + console.log('Skipping: Multi-window not supported') + return + } + + await switchToNewWindow() + + // Wait for sidebar + const sidebar = await browser.$('.settings-sidebar') + await sidebar.waitForExist({ timeout: 5000 }) + + // Find and click on a section + const sectionItems = [...(await browser.$$('.section-item'))] + if (sectionItems.length >= 2) { + // Click second section + await sectionItems[1].click() + await browser.pause(300) + + // Verify it becomes selected + const classAttr = await sectionItems[1].getAttribute('class') + expect(classAttr).toContain('selected') + } + }) + + it('should close settings window with Escape key', async () => { + await openSettingsViaShortcut() + + const handles = await browser.getWindowHandles() + if (handles.length <= 1) { + console.log('Skipping: Multi-window not supported') + return + } + + const originalWindow = await switchToNewWindow() + + // Verify settings window is open + const settingsWindow = await browser.$('.settings-window') + await settingsWindow.waitForExist({ timeout: 5000 }) + + // Press Escape to close + await browser.keys('Escape') + await browser.pause(500) + + // Check if window closed + const newHandles = await browser.getWindowHandles() + + if (newHandles.length === 1) { + // Window closed successfully + expect(newHandles.length).toBe(1) + } else { + // Window might still be open, which is also acceptable + // (depends on platform behavior) + console.log('Note: Settings window may not close with Escape in this environment') + } + + // Switch back to main window + await browser.switchToWindow(originalWindow) + }) +}) + +/** + * Fallback tests that work without multi-window support. + * These navigate directly to the /settings route. + */ +describe('Settings page (direct navigation)', () => { + it('should render settings page when navigated to directly', async () => { + // Navigate to settings route + await browser.url('/settings') + await browser.pause(1000) + + // Check for settings window class + const settingsWindow = await browser.$('.settings-window') + + if (await settingsWindow.isExisting()) { + expect(await settingsWindow.isDisplayed()).toBe(true) + + // Verify sidebar exists + const sidebar = await browser.$('.settings-sidebar') + expect(await sidebar.isExisting()).toBe(true) + + // Verify content wrapper exists + const content = await browser.$('.settings-content-wrapper') + expect(await content.isExisting()).toBe(true) + } else { + // The route might not work in this test context + console.log('Settings page not rendered - may require app context') + } + }) +}) diff --git a/docs/artifacts/adr/018-settings-architecture.md b/docs/artifacts/adr/018-settings-architecture.md new file mode 100644 index 0000000..5978e79 --- /dev/null +++ b/docs/artifacts/adr/018-settings-architecture.md @@ -0,0 +1,91 @@ +# ADR 018: Settings architecture + +## Status + +Accepted + +## Summary + +The settings system uses a hybrid declarative registry with custom UI components. A central registry defines all +settings metadata (for search, persistence, and defaults), while individual section components render custom UI. Search +uses the same uFuzzy engine as the command palette. A CI check enforces bidirectional completeness between the registry +and UI components. Settings apply immediately without an explicit "Apply" button. + +## Context, problem, solution + +### Context + +Cmdr has configurable values scattered across multiple locations: Rust compile-time constants (`config.rs`), environment +variables, TypeScript stores (`settings-store.ts`, `app-status-store.ts`), CSS custom properties, and hardcoded magic +values. The app needs a unified settings dialog that's easy to search (like IntelliJ's) and easy to maintain as features +are added. + +### Problem + +1. Users have no UI to discover or change settings β€” everything requires code knowledge or env vars. +2. As the app grows, configurable values multiply. We need a system that scales without becoming a maintenance burden. +3. Settings must be instantly searchable across all section titles, labels, descriptions, and keywords. +4. We need a way to ensure new features get settings entries and that UI stays in sync with the registry. + +Non-goals: +- Generated/schema-driven UI (too rigid, loses per-section UX polish). +- A parser that scrapes component source for searchable text (fragile, drifts on refactors). +- Lint-based detection of "naked constants" (heuristic, false positives β€” periodic agent audits serve this purpose). + +### Possible solutions considered + +1. **JSON schema β†’ generated UI**: Consistent and inherently searchable, but loses custom UX per section (color pickers, + inline previews, conditional visibility). Generated settings UIs always feel generic. +2. **Manual pages + parser script**: Full UX control, but the parser is a second source of truth that drifts. Breaks on + dynamic labels and refactors. +3. **Full architectural enforcement** (registry as the only runtime API for config values): Strongest guarantee, but + adds ceremony. Without a lint to catch raw constants, it's just a convention. Overkill for a solo dev + agents + workflow. + +### Solution + +**Hybrid declarative registry with custom UI components:** + +- A central `settings-registry.ts` defines every setting's metadata: section path, label, description, keywords, type, + default value, and whether it requires a restart. +- Individual settings section components import their settings from the registry and render custom UI. Labels and + descriptions come from the registry, so there's no drift. +- Search builds a `searchableText` string per setting (section path + label + description + keywords). The user's query + runs through uFuzzy (same engine and config as the command palette). The settings tree narrows to show only sections + with matches, and matched items get character-level highlighting. +- Settings apply immediately on change β€” no "Apply" button. The rare setting that requires a restart is marked in the + registry and shows a restart prompt in the UI. +- The settings dialog is a separate Tauri window (not an HTML dialog). ESC closes it. + +**Completeness enforcement (registry ↔ UI check):** + +- A check in the CI pipeline verifies: + 1. Every setting ID in the registry is referenced by at least one settings UI component. + 2. Every settings UI component only renders settings that exist in the registry. +- Additionally, periodic agent audits sweep the codebase for constants, env vars, and hardcoded values that should be + exposed as settings but aren't yet registered. + +## Consequences + +### Positive + +- Search works perfectly because the registry IS the search index β€” no parsing, no scraping, no drift. +- Full UX freedom per section (custom components, conditional visibility, inline previews). +- Single source of truth for: what settings exist, their defaults, their searchable metadata, and their persistence + keys. +- CI catches missing UI or orphaned registry entries automatically. +- uFuzzy reuse means consistent search behavior across command palette and settings. + +### Negative + +- Every new setting requires touching two places: the registry entry and the UI component. (Mitigated by the CI check + catching omissions.) +- The registry file grows as settings accumulate. (Acceptable β€” it's just data, easy to navigate with sections.) + +### Notes + +- UI components use Ark UI (see ADR 017). +- Keyboard shortcuts and theme customization are handled as dedicated subsystems with their own UI, not as individual + registry entries. +- Session state (pane paths, widths, sort orders) remains in `app-status-store.ts` β€” these aren't user-configured + "settings" in the traditional sense. diff --git a/docs/features/settings.md b/docs/features/settings.md new file mode 100644 index 0000000..d76f297 --- /dev/null +++ b/docs/features/settings.md @@ -0,0 +1,147 @@ +# Settings + +The Settings system provides a comprehensive configuration interface for Cmdr. It uses a registry-based architecture +where all settings are defined in a single source of truth, enabling both UI and programmatic (AI agent) access. + +## Opening settings + +Press **⌘,** (Command + comma) on macOS to open the Settings window. The window opens as a separate window from the +main Cmdr window. + +## Window layout + +The Settings window uses a two-pane layout: + +- **Left sidebar** (220px): Search bar at top, followed by a tree navigation of settings sections +- **Right content area**: The settings controls for the selected section + +## Available sections + +### General + +- **Appearance**: UI density, file icons, file size format, date/time format +- **File operations**: Confirmation dialogs, progress update interval +- **Updates**: Auto-update checking + +### Network + +- **SMB/Network shares**: Share cache duration, connection timeout settings + +### Keyboard shortcuts + +A dedicated UI for viewing and customizing keyboard shortcuts. Features: + +- **Search**: Filter by action name or key combination +- **Filters**: Show all, modified only, or conflicting shortcuts +- **Edit shortcuts**: Click any shortcut to change it +- **Conflict detection**: Warns when a shortcut is already in use +- **Reset**: Reset individual shortcuts or all to defaults + +### Themes + +Theme mode selection (Light, Dark, System). + +### Developer + +- **MCP server**: Enable/disable the Model Context Protocol server and configure its port +- **Logging**: Enable verbose logging, open log file, copy diagnostic info + +### Advanced + +Technical settings for fine-tuning performance, including drag threshold, prefetch buffer sizes, virtualization +settings, and various timeouts. + +## Architecture + +### Settings registry + +All settings are defined in `src/lib/settings/settings-registry.ts`. Each setting has: + +- Unique ID (for example `appearance.uiDensity`) +- Section path (for example `['General', 'Appearance']`) +- Type and constraints +- Default value +- UI component hint + +### Settings store + +Persistence is handled by `src/lib/settings/settings-store.ts` using tauri-plugin-store: + +- Settings are stored in `~/Library/Application Support/com.veszelovszki.cmdr/settings-v2.json` +- Changes are debounced (500ms) and saved atomically +- Schema versioning supports future migrations + +### Keyboard shortcuts + +Custom shortcuts are stored separately in `shortcuts.json` and managed by `src/lib/shortcuts/`: + +- `types.ts`: KeyCombo, ShortcutConflict interfaces +- `scope-hierarchy.ts`: Defines which shortcuts are active in each context +- `key-capture.ts`: Formats keyboard events to display strings +- `shortcuts-store.ts`: Persistence for custom shortcuts +- `conflict-detector.ts`: Detects when shortcuts overlap + +## Adding a new setting + +1. Define the setting in `settings-registry.ts`: + +```typescript +{ + id: 'mySection.mySetting', + section: ['My Section'], + label: 'My setting', + description: 'What this setting does', + type: 'boolean', + default: true, + component: 'switch', +} +``` + +2. Add UI in the appropriate section component under `src/lib/settings/sections/` + +3. Wire up the setting in your feature code using `getSetting()` and `setSetting()` + +## Adding a new command with shortcuts + +1. Add the command to `src/lib/commands/command-registry.ts`: + +```typescript +{ + id: 'myScope.myCommand', + name: 'My command', + scope: 'Main window', + showInPalette: true, + shortcuts: ['⌘M'], +} +``` + +2. Handle the command in `handleCommandExecute()` in `+page.svelte` + +3. The command will automatically appear in the Keyboard shortcuts section + +## Technical details + +### Scope hierarchy + +Shortcuts respect a scope hierarchy. When "File list" is active: + +- Shortcuts in "File list" scope trigger +- Shortcuts in "Main window" scope also trigger (parent) +- Shortcuts in "App" scope also trigger (global) +- Shortcuts in "About window" scope do NOT trigger (different branch) + +### Conflict detection + +Two commands conflict if: + +1. They have the same shortcut, AND +2. Their scopes overlap in the hierarchy + +The UI shows conflicts with a warning icon and count badge. Users can resolve conflicts by reassigning or keeping +both shortcuts. + +## Testing + +- Unit tests: `pnpm vitest run src/lib/settings src/lib/shortcuts` +- Type checking: `pnpm svelte-check` +- E2E tests: `test/e2e-linux/settings.spec.ts` diff --git a/docs/specs/settings-tasks.md b/docs/specs/settings-tasks.md new file mode 100644 index 0000000..9e72882 --- /dev/null +++ b/docs/specs/settings-tasks.md @@ -0,0 +1,446 @@ +# Settings implementation tasks + +Task list for implementing the settings system as specified in [settings.md](./settings.md). + +## Legend + +- `[ ]` Not started +- `[~]` In progress +- `[x]` Complete +- `[!]` Blocked + +--- + +## Phase 1: Foundation + +### 1.1 Settings registry (TypeScript) + +- [ ] Create `src/lib/settings/settings-registry.ts` with `SettingDefinition` interface (spec Β§2.1) +- [ ] Implement `getSetting(id)` with type safety and default fallback +- [ ] Implement `setSetting(id, value)` with constraint validation +- [ ] Implement `getSettingDefinition(id)` for UI rendering +- [ ] Implement `getSettingsInSection(path)` for tree rendering +- [ ] Implement `searchSettings(query)` using uFuzzy (spec Β§13.2) +- [ ] Implement `resetSetting(id)` and `resetAllSettings()` +- [ ] Implement `isModified(id)` for blue dot indicators +- [ ] Add validation for `enum` types with `allowCustom` and custom ranges +- [ ] Add validation for `number` types with `min`/`max`/`step` +- [ ] Add validation for `duration` types with unit conversion +- [ ] Throw `SettingValidationError` with descriptive messages +- [ ] Write unit tests for all registry functions +- [ ] Write unit tests for constraint validation edge cases + +### 1.2 Settings persistence (TypeScript) + +- [ ] Create `src/lib/settings/settings-store.ts` for persistence layer +- [ ] Implement debounced save (500ms) with atomic write +- [ ] Implement schema version field and migration framework +- [ ] Implement forward compatibility (preserve unknown keys) +- [ ] Handle save errors: log, retry once, show toast +- [ ] Write unit tests for persistence layer +- [ ] Write unit tests for schema migration + +### 1.3 Settings Tauri commands (Rust) + +- [ ] Create `src-tauri/src/settings/mod.rs` module +- [ ] Implement `get_setting` command delegating to registry +- [ ] Implement `set_setting` command with validation +- [ ] Implement `reset_setting` and `reset_all_settings` commands +- [ ] Implement `get_all_settings` for initial load +- [ ] Expose commands in `lib.rs` +- [ ] Write Rust unit tests for settings commands + +### 1.4 Port availability checker (Rust) + +- [ ] Create `src-tauri/src/settings/port_checker.rs` +- [ ] Implement `check_port_available(port)` command +- [ ] Implement `find_available_port(start_port)` command (max 100 attempts) +- [ ] Write unit tests for port checker + +--- + +## Phase 2: Settings window + +### 2.1 Window setup + +- [ ] Create settings window configuration in `tauri.conf.json` +- [ ] Set default size 800Γ—600, min size 600Γ—400 +- [ ] Configure window to open centered on main window +- [ ] Implement Cmd+, shortcut to open/focus settings window +- [ ] Implement ESC to close settings window +- [ ] Prevent duplicate settings windows + +### 2.2 Window layout (Svelte) + +- [ ] Create `src/lib/settings/SettingsWindow.svelte` as root component +- [ ] Implement fixed 220px sidebar + flexible content area layout +- [ ] Create `src/lib/settings/SettingsSidebar.svelte` with search + tree +- [ ] Create `src/lib/settings/SettingsContent.svelte` with scrollable panels +- [ ] Implement scroll-to-section when tree item selected +- [ ] Implement active section highlighting in tree + +### 2.3 Search implementation + +- [ ] Create `src/lib/settings/SettingsSearch.svelte` component +- [ ] Build search index from registry on mount +- [ ] Implement uFuzzy search with same config as command palette +- [ ] Filter tree to show only sections with matches +- [ ] Highlight matched characters in results +- [ ] Implement keyboard navigation (Arrow, Enter, Escape) +- [ ] Implement empty state message (spec Β§13.4) +- [ ] Write unit tests for search filtering + +--- + +## Phase 3: Setting components + +### 3.1 Base components (using Ark UI) + +- [ ] Create `src/lib/settings/components/SettingRow.svelte` wrapper +- [ ] Create `src/lib/settings/components/SettingSwitch.svelte` +- [ ] Create `src/lib/settings/components/SettingSelect.svelte` with custom option support +- [ ] Create `src/lib/settings/components/SettingRadioGroup.svelte` with inline descriptions +- [ ] Create `src/lib/settings/components/SettingToggleGroup.svelte` +- [ ] Create `src/lib/settings/components/SettingSlider.svelte` with NumberInput combo +- [ ] Create `src/lib/settings/components/SettingNumberInput.svelte` with validation +- [ ] Create `src/lib/settings/components/SettingTextInput.svelte` +- [ ] Create `src/lib/settings/components/SettingDuration.svelte` (number + unit dropdown) +- [ ] Implement "Coming soon" badge for disabled settings +- [ ] Implement restart indicator for settings that require restart +- [ ] Implement blue dot for modified settings +- [ ] Implement "Reset to default" link for modified settings +- [ ] Write unit tests for each component + +### 3.2 Section components + +- [ ] Create `src/lib/settings/sections/AppearanceSection.svelte` (spec Β§4) +- [ ] Create `src/lib/settings/sections/FileOperationsSection.svelte` (spec Β§5) +- [ ] Create `src/lib/settings/sections/UpdatesSection.svelte` (spec Β§6) +- [ ] Create `src/lib/settings/sections/NetworkSection.svelte` (spec Β§7) +- [ ] Create `src/lib/settings/sections/McpServerSection.svelte` (spec Β§10) +- [ ] Create `src/lib/settings/sections/LoggingSection.svelte` (spec Β§11) +- [ ] Create `src/lib/settings/sections/AdvancedSection.svelte` (spec Β§12) + +--- + +## Phase 4: Appearance section + +### 4.1 UI density + +- [ ] Add `appearance.uiDensity` to registry with Compact/Comfortable/Spacious options +- [ ] Implement ToggleGroup UI +- [ ] Map density to internal values (rowHeight, iconSize) +- [ ] Apply density changes immediately to main window +- [ ] Write integration test for density changes + +### 4.2 App icons for documents + +- [ ] Add `appearance.useAppIconsForDocuments` to registry +- [ ] Migrate from `config.rs` constant to setting +- [ ] Implement Switch UI +- [ ] Wire to icon loading logic +- [ ] Write integration test + +### 4.3 File size format + +- [ ] Add `appearance.fileSizeFormat` to registry (binary/si) +- [ ] Implement Select UI with inline descriptions (not tooltips) +- [ ] Create `formatFileSize(bytes, format)` utility +- [ ] Update file list to use setting +- [ ] Write unit tests for formatFileSize + +### 4.4 Date/time format + +- [ ] Add `appearance.dateTimeFormat` to registry +- [ ] Implement RadioGroup with system/iso/short/custom options +- [ ] Implement custom format input with live preview +- [ ] Implement collapsible format placeholder help +- [ ] Create `formatDateTime(date, format)` utility +- [ ] Update file list to use setting +- [ ] Write unit tests for formatDateTime + +--- + +## Phase 5: File operations section + +### 5.1 Delete settings (disabled) + +- [ ] Add `fileOperations.confirmBeforeDelete` to registry (disabled) +- [ ] Add `fileOperations.deletePermanently` to registry (disabled) +- [ ] Implement Switch UIs with "Coming soon" badges + +### 5.2 Progress update interval + +- [ ] Add `fileOperations.progressUpdateInterval` to registry +- [ ] Constraints: slider snaps 100/250/500/1000/2000, custom 50-5000ms +- [ ] Implement Slider + NumberInput combo UI +- [ ] Migrate from `operations.rs` constant to setting +- [ ] Wire to file operations progress emitter +- [ ] Write integration test + +### 5.3 Max conflicts to show + +- [ ] Add `fileOperations.maxConflictsToShow` to registry +- [ ] Options: 1, 2, 3, 5, 10, 50, 100 (default), 200, 500, custom 1-1000 +- [ ] Implement Select with custom option UI +- [ ] Migrate from `write_operations/types.rs` constant +- [ ] Wire to conflict resolution logic +- [ ] Write integration test + +--- + +## Phase 6: Updates section + +- [ ] Add `updates.autoCheck` to registry +- [ ] Implement Switch UI +- [ ] Wire to update checker enable/disable +- [ ] Write integration test + +--- + +## Phase 7: Network section + +### 7.1 Share cache duration + +- [ ] Add `network.shareCacheDuration` to registry +- [ ] Options: 30s, 5min, 1h, 1d, 30d, custom +- [ ] Implement Select with custom duration input +- [ ] Migrate from `smb_client.rs` constant +- [ ] Wire to SMB cache TTL +- [ ] Write integration test + +### 7.2 Network timeout mode + +- [ ] Add `network.timeoutMode` to registry (normal/slow/custom) +- [ ] Implement RadioGroup with inline descriptions +- [ ] Implement custom timeout NumberInput +- [ ] Map modes to actual timeout values (15s/45s/custom) +- [ ] Wire to network operations +- [ ] Write integration test + +--- + +## Phase 8: Keyboard shortcuts + +### 8.1 Data layer + +- [ ] Create `src/lib/settings/shortcuts/shortcut-store.ts` +- [ ] Implement shortcut persistence (separate from main settings) +- [ ] Implement conflict detection +- [ ] Implement reset to defaults (with confirmation) +- [ ] Write unit tests + +### 8.2 UI components + +- [ ] Create `src/lib/settings/shortcuts/ShortcutsSection.svelte` +- [ ] Implement dual search: action name (left) + key combo (right, narrower) +- [ ] Implement filter chips: All, Modified, Conflicts (with count badge) +- [ ] Create virtualized command list grouped by scope +- [ ] Create `ShortcutPill.svelte` component +- [ ] Implement click-to-edit on shortcut pills +- [ ] Implement key capture mode ("Press keys...") +- [ ] Implement 500ms confirmation delay +- [ ] Implement conflict warning with "Remove from other" option +- [ ] Implement Escape to cancel, Backspace to remove +- [ ] Implement [+] button to add additional shortcut +- [ ] Implement blue dot for modified shortcuts +- [ ] Implement "Reset all to defaults" button with confirmation dialog +- [ ] Implement per-row context menu with "Reset to default" (with confirmation) +- [ ] Write integration tests + +### 8.3 Key combination search + +- [ ] Implement key capture in search field (not text typing) +- [ ] Display captured combo visually +- [ ] Filter commands by exact shortcut match +- [ ] Implement clear button (Γ—) + +--- + +## Phase 9: Themes + +### 9.1 Theme mode + +- [ ] Add `theme.mode` to registry (light/dark/system) +- [ ] Implement ToggleGroup with icons (β˜€οΈ πŸŒ™ πŸ’») +- [ ] Wire to CSS custom properties / media query +- [ ] Ensure immediate preview +- [ ] Write integration test + +### 9.2 Future placeholders + +- [ ] Add "Coming soon" placeholder for preset themes +- [ ] Add "Coming soon" placeholder for custom theme editor + +--- + +## Phase 10: Developer section + +### 10.1 MCP server + +- [ ] Add `developer.mcpEnabled` to registry +- [ ] Add `developer.mcpPort` to registry (1024-65535) +- [ ] Implement Switch with restart indicator +- [ ] Implement NumberInput with validation +- [ ] Implement port availability auto-check on blur +- [ ] Implement "Find available port" button when port is taken +- [ ] Gray out port input when MCP disabled +- [ ] Wire to MCP server startup +- [ ] Write integration tests + +### 10.2 Logging + +- [ ] Add `developer.verboseLogging` to registry +- [ ] Implement Switch UI +- [ ] Wire to logger configuration +- [ ] Implement "Open log file" button (opens in Finder) +- [ ] Implement "Copy diagnostic info" button with toast feedback +- [ ] Write integration test + +--- + +## Phase 11: Advanced section + +### 11.1 Generated UI + +- [ ] Create `src/lib/settings/sections/AdvancedSection.svelte` +- [ ] Implement warning banner (spec Β§12.1) +- [ ] Implement "Reset all to defaults" button with confirmation +- [ ] Implement generated setting rows from registry +- [ ] Filter registry for `showInAdvanced: true` settings +- [ ] Map types to components (spec Β§12.3) +- [ ] Implement scrollable container (unlike other sections) + +### 11.2 Advanced settings + +- [ ] Add `advanced.dragThreshold` to registry (default 5px) +- [ ] Add `advanced.prefetchBufferSize` to registry (default 200) +- [ ] Add `advanced.virtualizationBufferRows` to registry (default 20) +- [ ] Add `advanced.virtualizationBufferColumns` to registry (default 2) +- [ ] Add `advanced.fileWatcherDebounce` to registry (default 200ms) +- [ ] Add `advanced.serviceResolveTimeout` to registry (default 5s) +- [ ] Add `advanced.mountTimeout` to registry (default 20s) +- [ ] Add `advanced.updateCheckInterval` to registry (default 60min) +- [ ] Migrate each from hardcoded constants +- [ ] Wire each to consuming code +- [ ] Write integration tests + +--- + +## Phase 12: Registry ↔ UI completeness check + +- [ ] Create `scripts/check/settings-completeness.go` (or add to existing checker) +- [ ] Parse `settings-registry.ts` to extract all setting IDs +- [ ] Scan settings section components for setting ID references +- [ ] Verify every registry ID is referenced in at least one component +- [ ] Verify every component only references registered IDs +- [ ] Add to `./scripts/check.sh` pipeline +- [ ] Document check in `docs/tooling/settings-check.md` + +--- + +## Phase 13: Accessibility + +- [ ] Add visible focus states to all setting components +- [ ] Add ARIA labels to Switch/Toggle components +- [ ] Verify color contrast meets WCAG AA +- [ ] Test full keyboard navigation through all settings +- [ ] Test with VoiceOver (macOS screen reader) +- [ ] Implement focus trap in settings window + +--- + +## Phase 14: Testing + +### 14.1 Unit tests (Svelte/TypeScript) + +- [ ] Registry functions: all CRUD operations +- [ ] Registry: constraint validation for all types +- [ ] Persistence: save/load cycle +- [ ] Persistence: schema migration +- [ ] Search: uFuzzy integration +- [ ] Search: filtering and highlighting +- [ ] Each setting component: render, change, validation +- [ ] Shortcuts: conflict detection +- [ ] Shortcuts: reset to defaults +- [ ] Run: `./scripts/check.sh --check svelte-tests` + +### 14.2 Unit tests (Rust) + +- [ ] Settings Tauri commands: get/set/reset +- [ ] Port checker: availability detection +- [ ] Port checker: find available port +- [ ] Run: `./scripts/check.sh --check rust-tests` + +### 14.3 Integration tests + +- [ ] Settings window opens with Cmd+, +- [ ] Settings window closes with ESC +- [ ] Settings persist across app restart +- [ ] Search filters tree correctly +- [ ] Each setting type applies immediately +- [ ] Restart indicator shows for MCP settings +- [ ] Keyboard shortcuts editor captures keys +- [ ] Shortcut conflicts are detected and handled +- [ ] Theme mode switches immediately +- [ ] Advanced section scrolls independently + +### 14.4 E2E tests + +- [ ] Add settings scenarios to `test/e2e-smoke/` +- [ ] Run: `./scripts/check.sh --check desktop-e2e` + +--- + +## Phase 15: Documentation + +- [ ] Create `docs/features/settings.md` with: + - Overview of settings system + - How to add a new setting (registry + UI + wiring) + - How the completeness check works + - Troubleshooting common issues +- [ ] Update `AGENTS.md` if settings affect agent workflows +- [ ] Add inline code comments where architecture is non-obvious + +--- + +## Phase 16: Final verification + +- [ ] Run full check suite: `./scripts/check.sh` +- [ ] Verify no regressions in existing functionality +- [ ] Manual smoke test of all settings +- [ ] Review for any TODO comments left in code +- [ ] Verify ADR 018 accurately reflects implementation + +--- + +## Dependencies + +``` +Phase 1 (Foundation) ──┬── Phase 2 (Window) ──┬── Phase 3 (Components) + β”‚ β”‚ + β”‚ β”œβ”€β”€ Phase 4 (Appearance) + β”‚ β”œβ”€β”€ Phase 5 (File ops) + β”‚ β”œβ”€β”€ Phase 6 (Updates) + β”‚ β”œβ”€β”€ Phase 7 (Network) + β”‚ β”œβ”€β”€ Phase 8 (Shortcuts) + β”‚ β”œβ”€β”€ Phase 9 (Themes) + β”‚ β”œβ”€β”€ Phase 10 (Developer) + β”‚ └── Phase 11 (Advanced) + β”‚ + └── Phase 12 (Completeness check) + +All implementation phases ── Phase 13 (Accessibility) + ── Phase 14 (Testing) + ── Phase 15 (Documentation) + ── Phase 16 (Final verification) +``` + +--- + +## Estimated scope + +- **New files**: ~30 Svelte components, ~5 TypeScript modules, ~3 Rust modules +- **Modified files**: ~15 existing files for wiring settings +- **Tests**: ~50 unit tests, ~10 integration tests, ~5 E2E scenarios +- **Documentation**: 3 new docs, 1 updated doc diff --git a/docs/specs/settings.md b/docs/specs/settings.md new file mode 100644 index 0000000..4affd20 --- /dev/null +++ b/docs/specs/settings.md @@ -0,0 +1,612 @@ +# Settings system specification + +This document specifies the complete settings system for Cmdr, including window structure, UI components, registry +architecture, and persistence. See [ADR 018](../artifacts/adr/018-settings-architecture.md) for architectural decisions. + +## Table of contents + +1. [Window structure](#1-window-structure) +2. [Settings registry](#2-settings-registry) +3. [Settings tree](#3-settings-tree) +4. [General β€Ί Appearance](#4-general--appearance) +5. [General β€Ί File operations](#5-general--file-operations) +6. [General β€Ί Updates](#6-general--updates) +7. [Network β€Ί SMB/Network shares](#7-network--smbnetwork-shares) +8. [Keyboard shortcuts](#8-keyboard-shortcuts) +9. [Themes](#9-themes) +10. [Developer β€Ί MCP server](#10-developer--mcp-server) +11. [Developer β€Ί Logging](#11-developer--logging) +12. [Advanced section](#12-advanced-section) +13. [Search behavior](#13-search-behavior) +14. [Accessibility](#14-accessibility) +15. [Persistence and sync](#15-persistence-and-sync) + +--- + +## 1. Window structure + +### 1.1 Window chrome + +- **Type**: Separate Tauri window (not HTML dialog) +- **Size**: 800Γ—600px default, resizable, minimum 600Γ—400px +- **Position**: Centered on main window when opened +- **Title**: "Settings" + +### 1.2 Layout + +- Left sidebar: 220px fixed width, contains search bar and tree navigation +- Right content area: Flexible width, contains settings panels +- No splitter between sidebar and content + +### 1.3 Search bar + +- Pinned at top of sidebar, always visible +- Full width within sidebar (220px minus padding) +- Placeholder: "Search settings..." +- See [section 13](#13-search-behavior) for search behavior details + +### 1.4 Tree behavior + +- Tree is always fully expanded (not collapsible) +- Selecting a section or subsection scrolls the right pane to that location +- Active section/subsection is highlighted in the tree + +### 1.5 Close behavior + +- ESC closes the window +- Standard window close button (Γ—) closes the window +- Cmd+, while Settings is already open brings it to front (no duplicate windows) + +### 1.6 Apply behavior + +- All changes apply immediately (no Apply/Cancel buttons) +- Changes persist to disk on each change (debounced 500ms) +- Settings requiring restart show inline indicator + +--- + +## 2. Settings registry + +The settings registry (`settings-registry.ts`) is the single source of truth for all settings metadata. + +### 2.1 Registry entry structure + +```typescript +interface SettingDefinition { + // Identity + id: string // Unique key, e.g., 'appearance.uiDensity' + section: string[] // Path in tree, e.g., ['General', 'Appearance'] + + // Display + label: string // Human-readable name + description: string // Explanatory text shown below the control + keywords: string[] // Additional search terms + + // Type and constraints + type: 'boolean' | 'number' | 'string' | 'enum' | 'duration' + default: unknown // Default value + + // Constraints (type-specific) + constraints?: { + // For 'number' type + min?: number + max?: number + step?: number + + // For 'enum' type + options?: Array<{ + value: string | number + label: string + description?: string // Shown inline, not in tooltip + }> + allowCustom?: boolean // Whether "Custom..." option is available + customMin?: number // Min value for custom input + customMax?: number // Max value for custom input + + // For 'duration' type + unit: 'ms' | 's' | 'min' | 'h' | 'd' + minMs?: number // Minimum in milliseconds + maxMs?: number // Maximum in milliseconds + } + + // Behavior + requiresRestart?: boolean // Show restart indicator when changed + disabled?: boolean // Grayed out with optional badge + disabledReason?: string // e.g., "Coming soon" + + // UI hints + component?: 'switch' | 'select' | 'radio' | 'slider' | 'toggle-group' | 'number-input' | 'text-input' + showInAdvanced?: boolean // If true, appears in Advanced section with generated UI +} +``` + +### 2.2 Access API + +A single pair of functions for both UI and programmatic (AI agent) access: + +```typescript +// Load a setting value (returns default if not set) +function getSetting(id: string): T + +// Store a setting value (validates against constraints, throws if invalid) +function setSetting(id: string, value: T): void + +// Get setting metadata (for UI rendering, validation, etc.) +function getSettingDefinition(id: string): SettingDefinition + +// Get all settings in a section +function getSettingsInSection(sectionPath: string[]): SettingDefinition[] + +// Search settings by query +function searchSettings(query: string): SettingDefinition[] + +// Reset a setting to default +function resetSetting(id: string): void + +// Reset all settings to defaults +function resetAllSettings(): void + +// Check if a setting differs from default +function isModified(id: string): boolean +``` + +### 2.3 Validation + +- `setSetting()` validates against constraints before storing +- For `enum` types with `allowCustom: true`, validates against `customMin`/`customMax` +- For `number` types, validates against `min`/`max` +- For `duration` types, converts to canonical unit and validates against `minMs`/`maxMs` +- Throws `SettingValidationError` with descriptive message on failure + +### 2.4 AI agent access + +AI agents use the same `getSetting()`/`setSetting()` API. The registry constraints ensure agents cannot set +invalid values. Example Tauri command exposure: + +```rust +#[tauri::command] +fn set_setting(id: String, value: serde_json::Value) -> Result<(), String> { + // Delegates to the same validation logic as UI +} +``` + +--- + +## 3. Settings tree + +``` +General + β”œβ”€ Appearance + β”œβ”€ File operations + └─ Updates + +Network + └─ SMB/Network shares + +Keyboard shortcuts (dedicated UI, no subsections) + +Themes (dedicated UI, no subsections) + +Developer + β”œβ”€ MCP server + └─ Logging + +Advanced (generated UI, scrollable) +``` + +--- + +## 4. General β€Ί Appearance + +### 4.1 UI density + +- **ID**: `appearance.uiDensity` +- **Component**: ToggleGroup (3 segments) +- **Options**: "Compact", "Comfortable" (default), "Spacious" +- **Behavior**: Immediate preview. Maps internally to: + - Compact: rowHeight=16px, iconSize=24 + - Comfortable: rowHeight=20px, iconSize=32 + - Spacious: rowHeight=28px, iconSize=40 +- **Keyboard**: Arrow keys navigate between options + +### 4.2 Use app icons for documents + +- **ID**: `appearance.useAppIconsForDocuments` +- **Component**: Switch with inline label +- **Label**: "Use app icons for documents" +- **Description**: "Show the app's icon for documents instead of generic file type icons. More colorful but slightly slower." +- **Default**: true + +### 4.3 File size format + +- **ID**: `appearance.fileSizeFormat` +- **Component**: Select dropdown +- **Options**: + - `binary`: "Binary (KiB, MiB, GiB) β€” 1 KiB = 1024 bytes" + - `si`: "SI decimal (KB, MB, GB) β€” 1 KB = 1000 bytes" +- **Default**: `binary` +- **Note**: Clarifications shown inline in dropdown, not as tooltips + +### 4.4 Date and time format + +- **ID**: `appearance.dateTimeFormat` +- **Component**: RadioGroup with conditional custom input +- **Options**: + - `system`: "System default" β€” shows live preview + - `iso`: "ISO 8601" β€” e.g., "2025-01-25 14:30" + - `short`: "Short" β€” e.g., "Jan 25, 2:30 PM" + - `custom`: "Custom..." +- **Default**: `system` +- **Custom sub-UI** (when "Custom" selected): + - Text input for format string + - Live preview of current date/time + - Collapsible help with format placeholders (YYYY, MM, DD, HH, mm, ss, etc.) + +--- + +## 5. General β€Ί File operations + +### 5.1 Confirm before delete + +- **ID**: `fileOperations.confirmBeforeDelete` +- **Component**: Switch +- **Label**: "Confirm before delete" +- **Description**: "Show a confirmation dialog before moving files to trash." +- **Default**: true +- **State**: Disabled, shows "Coming soon" badge + +### 5.2 Delete permanently + +- **ID**: `fileOperations.deletePermanently` +- **Component**: Switch +- **Label**: "Delete permanently instead of using trash" +- **Description**: "Bypass trash and delete files immediately. This cannot be undone." +- **Default**: false +- **State**: Disabled, shows "Coming soon" badge +- **Future behavior**: When enabled, shows warning icon and description turns orange + +### 5.3 Progress update interval + +- **ID**: `fileOperations.progressUpdateInterval` +- **Component**: Slider + NumberInput combo +- **Label**: "Progress update interval" +- **Description**: "How often to refresh progress during file operations. Lower values feel more responsive but use more CPU." +- **Constraints**: + - Slider snaps to: 100, 250, 500, 1000, 2000 ms + - NumberInput allows custom: min 50ms, max 5000ms +- **Default**: 500ms (marked on slider) +- **Display**: NumberInput shows "ms" suffix + +### 5.4 Maximum conflicts to show + +- **ID**: `fileOperations.maxConflictsToShow` +- **Component**: Select with custom option +- **Options**: 1, 2, 3, 5, 10, 50, 100 (default), 200, 500, "Custom..." +- **Constraints**: Custom range 1–1000 +- **Description**: "Maximum number of file conflicts to display in the preview before an operation." + +--- + +## 6. General β€Ί Updates + +### 6.1 Automatically check for updates + +- **ID**: `updates.autoCheck` +- **Component**: Switch +- **Label**: "Automatically check for updates" +- **Description**: "Periodically check for new versions in the background." +- **Default**: true + +### 6.2 Update channel (future) + +- **ID**: `updates.channel` +- **Component**: Select +- **Options**: "Stable" (default), "Beta" +- **Description**: "Beta releases include new features but may have bugs." +- **State**: Hidden until beta channel exists + +--- + +## 7. Network β€Ί SMB/Network shares + +### 7.1 Share cache duration + +- **ID**: `network.shareCacheDuration` +- **Component**: Select with custom option +- **Options**: "30 seconds" (default), "5 minutes", "1 hour", "1 day", "30 days", "Custom..." +- **Custom sub-UI**: NumberInput + unit dropdown (seconds/minutes/hours/days) +- **Description**: "How long to cache the list of available shares on a server before refreshing." + +### 7.2 Network timeout mode + +- **ID**: `network.timeoutMode` +- **Component**: RadioGroup (vertical, with inline descriptions) +- **Options**: + - `normal`: "Normal" β€” "For typical local networks (15s timeout)" + - `slow`: "Slow network" β€” "For VPNs or high-latency connections (45s timeout)" + - `custom`: "Custom" β€” shows NumberInput for timeout in seconds +- **Default**: `normal` +- **Description at top**: "How long to wait when connecting to network shares." + +--- + +## 8. Keyboard shortcuts + +Dedicated UI β€” uses the full right pane with no tree navigation. + +### 8.1 Layout + +- **Top bar**: Two search inputs side by side + - Left (wider): "Search by action name..." β€” text search + - Right (narrower): "Press keys..." β€” key combination search +- **Filter chips** (below search): "All", "Modified", "Conflicts" + - "Conflicts" shows count badge when shortcuts are bound to multiple actions +- **Main area**: Virtualized list grouped by scope (App, Navigation, File list, etc.) + +### 8.2 Text search behavior + +- Searches action names and descriptions +- Results highlight matched characters (same as command palette) +- Tree narrows to matching commands with scope headers preserved + +### 8.3 Key combination search + +- Field captures key presses instead of typing text +- Pressing Cmd+Shift+P searches for that exact combination +- Shows matching commands that use that shortcut +- Clear button (Γ—) to reset + +### 8.4 Command row layout + +``` +[Scope badge] Action name [Shortcut pill] [Shortcut pill] [+] + Muted description text +``` + +- **Scope badge**: Small colored tag (e.g., "App", "File list") +- **Shortcut pills**: Rounded rectangles showing key combo. Click to edit. +- **[+] button**: Add additional shortcut to this action +- **Blue dot**: Shown next to modified shortcuts + +### 8.5 Editing a shortcut + +1. Click shortcut pill β†’ pill shows "Press keys..." placeholder +2. User presses key combination β†’ shows combo, waits 500ms for confirmation +3. If conflict: Inline warning "Also bound to [Action name]" with "Remove from other" or "Cancel" +4. Press Escape to cancel editing +5. Press Backspace/Delete on focused pill to remove that shortcut + +### 8.6 Reset to defaults + +- **Button at bottom**: "Reset all to defaults" β€” always shows confirmation dialog +- **Per-row**: Right-click context menu β†’ "Reset to default" β€” always shows confirmation dialog +- Modified shortcuts show blue dot indicator + +### 8.7 Conflict handling + +- Commands with conflicting shortcuts show orange warning icon +- Filter chip "Conflicts" filters to only conflicting commands + +--- + +## 9. Themes + +Dedicated UI for theme selection. + +### 9.1 Theme mode + +- **ID**: `theme.mode` +- **Component**: ToggleGroup (3 segments with icons) +- **Options**: "β˜€οΈ Light", "πŸŒ™ Dark", "πŸ’» System" +- **Behavior**: Immediate switch. "System" follows OS preference. +- **Default**: `system` + +### 9.2 Preset themes (future) + +- Horizontal scrollable row of theme preview cards +- Click to apply +- Initially: Only "Default Light" and "Default Dark" shown + +### 9.3 Custom theme editor (future) + +- Collapsible section: "Customize colors" +- Grid of color swatches by category +- Color picker popover on click +- Export/Import as JSON +- "Reset to theme defaults" button +- **Initial implementation**: Shows "Coming soon" placeholder + +--- + +## 10. Developer β€Ί MCP server + +### 10.1 Enable MCP server + +- **ID**: `developer.mcpEnabled` +- **Component**: Switch +- **Label**: "Enable MCP server" +- **Description**: "Start a Model Context Protocol server for AI assistant integration." +- **Restart indicator**: Shows "Restart required to apply" when toggled +- **Default**: true (dev builds), false (prod builds) + +### 10.2 MCP port + +- **ID**: `developer.mcpPort` +- **Component**: NumberInput with validation and port scanner +- **Label**: "Port" +- **Constraints**: 1024–65535 +- **Default**: 9224 +- **Description**: "The port number for the MCP server." +- **Disabled state**: Grayed out when MCP server is disabled +- **Port availability check**: + - Auto-checks if port is available on blur/change + - If unavailable: Shows warning "Port 9224 is in use" + - Offers button: "Find available port" β€” scans and suggests an open port + - Scan range: starts at preferred port, increments until finding open port (max 100 attempts) + +--- + +## 11. Developer β€Ί Logging + +### 11.1 Verbose logging + +- **ID**: `developer.verboseLogging` +- **Component**: Switch +- **Label**: "Verbose logging" +- **Description**: "Log detailed debug information. Useful for troubleshooting. May impact performance." +- **Default**: false + +### 11.2 Open log file + +- **Component**: Button (secondary style) +- **Label**: "Open log file" +- **Behavior**: Opens log file location in Finder + +### 11.3 Copy diagnostic info + +- **Component**: Button (secondary style) +- **Label**: "Copy diagnostic info" +- **Behavior**: Copies system info, app version, settings summary to clipboard +- **Feedback**: Brief toast "Copied to clipboard" + +--- + +## 12. Advanced section + +Generated UI for technical settings. Scrollable, unlike other sections. + +### 12.1 Section header + +- Warning banner: "⚠️ These settings are for advanced users. Incorrect values may cause performance issues or unexpected behavior." +- "Reset all to defaults" button (secondary, right-aligned) β€” shows confirmation dialog + +### 12.2 Setting row layout + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ● Setting name [UI control] β”‚ +β”‚ Description text explaining what this does β”‚ +β”‚ Default: 200 [Reset to default]β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- Blue dot (●) shown only when value differs from default +- "Reset to default" link visible only when modified + +### 12.3 UI component mapping + +| Type | Component | +|------|-----------| +| `boolean` | Switch | +| `number` (bounded) | Slider + NumberInput | +| `number` (unbounded) | NumberInput | +| `enum` | Select dropdown | +| `duration` | NumberInput + unit dropdown | +| `string` | TextInput | + +### 12.4 Settings included in Advanced + +| ID | Name | Type | Default | Description | +|----|------|------|---------|-------------| +| `advanced.dragThreshold` | Drag threshold | number (px) | 5 | Minimum distance in pixels before a drag operation starts | +| `advanced.prefetchBufferSize` | Prefetch buffer size | number | 200 | Number of items to prefetch around the visible range | +| `advanced.virtualizationBufferRows` | Virtualization buffer (rows) | number | 20 | Extra rows to render above and below the visible area | +| `advanced.virtualizationBufferColumns` | Virtualization buffer (columns) | number | 2 | Extra columns to render in brief view | +| `advanced.fileWatcherDebounce` | File watcher debounce | duration | 200ms | Delay after file system changes before refreshing | +| `advanced.serviceResolveTimeout` | Service resolve timeout | duration | 5s | Timeout for resolving network services via Bonjour | +| `advanced.mountTimeout` | Mount timeout | duration | 20s | Timeout for mounting network shares | +| `advanced.updateCheckInterval` | Update check interval | duration | 60min | How often to check for updates in the background | + +### 12.5 Settings explicitly excluded + +| Setting | Reason | +|---------|--------| +| License validation interval | Business logic, not user-configurable | +| Offline grace period | Would enable license bypass | +| Commercial reminder interval | Business logic | +| License server URL | Security risk | +| License HTTP timeout | Internal, rarely relevant | +| Window resize debounce | Internal, no user benefit | +| Icon size | Controlled by UI density | +| Row heights | Controlled by UI density | +| Default SMB port | Standard protocol | +| MCP protocol version | Internal compatibility | +| JSON-RPC error codes | Internal constants | +| Pane width min/max | UX guardrails | +| Default volume ID | Internal identifier | +| Keychain service name | Would orphan stored credentials | +| Debug log categories | Covered by verbose logging toggle | +| Benchmark mode | Dev-only | +| Support email | Hardcoded contact | +| Full disk access choice | Handled via permission flow | + +--- + +## 13. Search behavior + +### 13.1 Search index + +For each setting, index: +- Section path (e.g., "General β€Ί Appearance") +- Label +- Description +- Keywords array + +### 13.2 Search engine + +- Uses uFuzzy (same config as command palette) +- `intraMode: 1` for typo tolerance +- `interIns: 3` for character insertions + +### 13.3 Results display + +- Tree shows only sections containing matches +- Matched settings highlighted with character-level match indicators +- Clicking result scrolls to setting and briefly pulses it (200ms highlight) + +### 13.4 Empty state + +"No settings found for '[query]'" with suggestion: "Try different keywords or check Keyboard shortcuts" + +### 13.5 Keyboard navigation + +- Arrow Up/Down: Navigate between results +- Enter: Jump to selected result +- Escape: Clear search and return to full tree + +--- + +## 14. Accessibility + +- All interactive elements have visible focus states +- Switch/Toggle components have proper ARIA labels +- Color choices meet WCAG AA contrast requirements +- Full keyboard navigation for all settings +- Screen reader announces: setting name, current value, description +- Focus trap within Settings window when open + +--- + +## 15. Persistence and sync + +### 15.1 Storage location + +`~/Library/Application Support/com.veszelovszki.cmdr/settings.json` + +### 15.2 Save behavior + +- Debounced 500ms after last change +- Atomic write: write to temp file, then rename +- On error: log warning, retry once, then show toast + +### 15.3 Schema migration + +- Version field in settings file +- On load, migrate old schemas forward +- Unknown keys preserved (forward compatibility) + +### 15.4 Defaults + +- Registry provides all defaults +- Missing keys use registry default +- Explicit `null` resets to default diff --git a/docs/specs/shortcut-settings-tasks.md b/docs/specs/shortcut-settings-tasks.md new file mode 100644 index 0000000..1d5d742 --- /dev/null +++ b/docs/specs/shortcut-settings-tasks.md @@ -0,0 +1,233 @@ +# Keyboard shortcut customization tasks + +Task list for implementing keyboard shortcut customization as specified in +[shortcut-settings.md](./shortcut-settings.md). + +## Legend + +- `[ ]` Not started +- `[~]` In progress +- `[x]` Complete +- `[!]` Blocked + +--- + +## Phase 1: Core infrastructure + +### 1.1 Types and scope hierarchy + +- [x] Create `src/lib/shortcuts/types.ts` with `KeyCombo`, `ShortcutConflict` interfaces (spec Β§3, Β§5) +- [x] Create `src/lib/shortcuts/scope-hierarchy.ts` with `CommandScope` type and hierarchy (spec Β§2) +- [x] Implement `getActiveScopes(scope)` function +- [x] Implement `scopesOverlap(scopeA, scopeB)` function +- [x] Write unit tests for scope hierarchy + +### 1.2 Key capture + +- [x] Create `src/lib/shortcuts/key-capture.ts` (spec Β§4) +- [x] Implement `formatKeyCombo(event)` with platform detection +- [x] Implement `normalizeKeyName(key)` with special key mappings +- [x] Implement `matchesShortcut(event, shortcut)` for matching +- [x] Implement `isMacOS()` platform helper +- [x] Write unit tests for all modifier combinations +- [x] Write unit tests for special keys (arrows, F1-F12, etc.) + +### 1.3 Shortcuts store + +- [x] Create `src/lib/shortcuts/shortcuts-store.ts` (spec Β§6) +- [x] Implement `initializeShortcuts()` β€” load from disk +- [x] Implement `getCustomShortcuts()` β€” get all customizations +- [x] Implement `setShortcut(commandId, index, shortcut)` β€” save one shortcut +- [x] Implement `addShortcut(commandId, shortcut)` β€” add new shortcut to command +- [x] Implement `removeShortcut(commandId, index)` β€” remove one shortcut +- [x] Implement `resetShortcut(commandId)` β€” reset single command to default +- [x] Implement `resetAllShortcuts()` β€” reset all to defaults +- [x] Implement `getEffectiveShortcuts(commandId)` β€” get custom or default +- [x] Implement `isShortcutModified(commandId)` β€” check if customized +- [x] Implement debounced save (500ms) +- [ ] Implement atomic write (temp + rename) β€” uses tauri-plugin-store +- [ ] Write unit tests for persistence layer + +### 1.4 Conflict detection + +- [x] Create `src/lib/shortcuts/conflict-detector.ts` (spec Β§5) +- [x] Implement `findConflictsForShortcut(shortcut, scope)` β€” find conflicting commands +- [x] Implement `getAllConflicts()` β€” find all conflicts in system +- [x] Implement `hasConflicts(commandId)` β€” check if command has conflicts +- [ ] Write unit tests for conflict detection + +--- + +## Phase 2: UI implementation + +### 2.1 Update KeyboardShortcutsSection + +- [x] Refactor `KeyboardShortcutsSection.svelte` to use shortcuts store +- [x] Replace static `commands` with reactive data from store +- [x] Implement edit mode state management +- [x] Implement 500ms confirmation delay after key capture +- [x] Implement Escape to cancel editing +- [x] Implement Backspace/Delete to remove shortcut + +### 2.2 Shortcut pill component + +- [x] Implement normal display state (inline in KeyboardShortcutsSection) +- [x] Implement "Press keys..." editing state +- [x] Implement captured-but-not-saved state +- [x] Implement blue dot indicator for modified +- [x] Implement orange warning for conflicts +- [ ] Extract to separate component +- [ ] Write component tests + +### 2.3 Add shortcut button + +- [x] Implement [+] button to add new shortcut +- [x] Opens empty pill in edit mode +- [x] Captures and saves new shortcut + +### 2.4 Conflict resolution dialog + +- [x] Create conflict warning inline UI (spec Β§7.2) +- [x] Implement "Remove from other" action +- [x] Implement "Keep both" action +- [x] Implement "Cancel" action +- [ ] Write component tests + +### 2.5 Filter chips + +- [x] Implement "Modified" filter β€” show only customized commands +- [x] Implement "Conflicts" filter β€” show only conflicting commands +- [x] Add count badge to "Conflicts" chip +- [ ] Write component tests + +### 2.6 Reset functionality + +- [x] Implement "Reset all to defaults" button with confirmation dialog +- [x] Implement per-row reset button (when modified) +- [ ] Implement per-row context menu with "Reset to default" +- [x] Confirmation dialog for reset operations +- [ ] Write component tests + +--- + +## Phase 3: Keyboard handler integration + +### 3.1 Create keyboard handler + +- [x] Create `src/lib/shortcuts/keyboard-handler.ts` (spec Β§8) +- [x] Implement `handleKeyDown(event, currentScope)` returning command ID +- [x] Priority: more specific scopes first +- [ ] Write unit tests + +### 3.2 Integrate with main app + +- [ ] Refactor `+page.svelte` to use new keyboard handler +- [ ] Remove hardcoded shortcut checks +- [ ] Track current scope based on focus +- [ ] Test all existing shortcuts still work + +--- + +## Phase 4: Testing + +### 4.1 Unit tests (TypeScript) + +- [x] Scope hierarchy: all scope combinations +- [x] Key capture: modifiers (meta, ctrl, alt, shift) +- [x] Key capture: special keys (arrows, function keys, etc.) +- [x] Key capture: platform-specific formatting +- [ ] Shortcuts store: CRUD operations +- [ ] Shortcuts store: persistence round-trip +- [ ] Conflict detection: same scope +- [ ] Conflict detection: overlapping scopes +- [ ] Conflict detection: non-overlapping scopes (no conflict) +- [x] Run: `pnpm vitest run src/lib/shortcuts` + +### 4.2 Component tests (Svelte) + +- [ ] ShortcutPill: all states (normal, editing, captured, modified, conflict) +- [ ] KeyboardShortcutsSection: edit flow +- [ ] KeyboardShortcutsSection: filter chips +- [ ] Conflict dialog: all three actions +- [ ] Run: `pnpm vitest run src/lib/settings` + +### 4.3 Integration tests + +- [ ] Edit shortcut β†’ verify saves to store +- [ ] Create conflict β†’ resolve β†’ verify result +- [ ] Reset single β†’ verify returns to default +- [ ] Reset all β†’ verify all return to defaults +- [ ] Modified filter β†’ shows only customized +- [ ] Conflicts filter β†’ shows only conflicting + +### 4.4 E2E tests (Linux) + +- [x] Add shortcut editing test to `test/e2e-linux/settings.spec.ts` +- [ ] Test: navigate to keyboard shortcuts section +- [ ] Test: click shortcut pill, capture new key combo +- [ ] Test: verify shortcut displays correctly +- [ ] Test: reset to defaults + +--- + +## Phase 5: Checks and verification + +### 5.1 Svelte checks + +- [x] Run: `pnpm prettier --write src/lib/shortcuts src/lib/settings` +- [x] Run: `pnpm eslint src/lib/shortcuts src/lib/settings` +- [x] Run: `pnpm svelte-check` +- [x] Run: `pnpm vitest run src/lib/settings src/lib/shortcuts` +- [ ] Run: `./scripts/check.sh --check knip` + +### 5.2 Rust checks + +- [ ] Run: `./scripts/check.sh --check rustfmt` (blocked: missing GTK deps) +- [ ] Run: `./scripts/check.sh --check clippy` (blocked: missing GTK deps) +- [ ] Run: `./scripts/check.sh --check rust-tests` (blocked: missing GTK deps) + +### 5.3 Full verification + +- [ ] Run: `./scripts/check.sh` (all checks) +- [ ] Verify no regressions in existing functionality +- [ ] Manual smoke test of shortcut editing +- [ ] Review for any TODO comments left in code + +--- + +## Phase 6: Documentation + +### 6.1 Update feature docs + +- [x] Create `docs/features/settings.md` with: + - Keyboard shortcuts customization section + - How to add a new command with shortcuts + - How conflict detection works +- [ ] Update spec if implementation differs + +### 6.2 Code documentation + +- [x] Add inline comments where architecture is non-obvious +- [x] Ensure all public functions have meaningful JSDoc (not obvious ones) + +--- + +## Dependencies + +``` +Phase 1 (Core) ─── Phase 2 (UI) ─── Phase 3 (Integration) + β”‚ + └──────── Phase 4 (Testing) + β”‚ + └── Phase 5 (Checks) + β”‚ + └── Phase 6 (Docs) +``` + +--- + +## Estimated scope + +- **New files**: ~8 TypeScript modules, ~2 Svelte components +- **Modified files**: ~3 existing files +- **Tests**: ~25 unit tests, ~10 component tests, ~5 E2E scenarios diff --git a/docs/specs/shortcut-settings.md b/docs/specs/shortcut-settings.md new file mode 100644 index 0000000..c72338e --- /dev/null +++ b/docs/specs/shortcut-settings.md @@ -0,0 +1,482 @@ +# Keyboard shortcut customization specification + +This document specifies the keyboard shortcut customization feature for Cmdr. This extends the existing keyboard +shortcuts section defined in [settings.md Β§8](./settings.md#8-keyboard-shortcuts). + +## Table of contents + +1. [Overview](#1-overview) +2. [Scope hierarchy](#2-scope-hierarchy) +3. [Data model](#3-data-model) +4. [Key capture and formatting](#4-key-capture-and-formatting) +5. [Conflict detection](#5-conflict-detection) +6. [Persistence](#6-persistence) +7. [UI behavior](#7-ui-behavior) +8. [Integration with keyboard handling](#8-integration-with-keyboard-handling) + +--- + +## 1. Overview + +### 1.1 Goals + +- Let users customize keyboard shortcuts for any command +- Multiple shortcuts per command (like VS Code) +- Detect and resolve conflicts between shortcuts +- Platform-specific storage (no cross-platform translation) +- Immediate feedback during key capture + +### 1.2 Non-goals + +- Import/export of shortcuts (future enhancement) +- Chorded shortcuts like `Ctrl+K Ctrl+C` (single combo only) +- Per-profile shortcuts + +--- + +## 2. Scope hierarchy + +### 2.1 Scope definition + +Each command has a single `scope` property for display grouping. The scope hierarchy determines which shortcuts are +active in a given context. + +```typescript +type CommandScope = + | 'App' // Global, works everywhere + | 'Main window' // Main window context + | 'File list' // File list focused + | 'Command palette' // Command palette open + | 'Navigation' // Navigation context + | 'Selection' // Selection operations + | 'Edit' // Edit operations + | 'View' // View operations + | 'Help' // Help operations + | 'About window' // About window context + | 'Settings window' // Settings window context +``` + +### 2.2 Active scopes + +When a given scope is active, shortcuts from that scope and its ancestors are available. The hierarchy is explicit: + +```typescript +const scopeHierarchy: Record = { + 'App': ['App'], + 'Main window': ['Main window', 'App'], + 'File list': ['File list', 'Main window', 'App'], + 'Command palette': ['Command palette', 'Main window', 'App'], + 'Navigation': ['Navigation', 'Main window', 'App'], + 'Selection': ['Selection', 'Main window', 'App'], + 'Edit': ['Edit', 'Main window', 'App'], + 'View': ['View', 'Main window', 'App'], + 'Help': ['Help', 'Main window', 'App'], + 'About window': ['About window', 'App'], + 'Settings window': ['Settings window', 'App'], +} + +function getActiveScopes(current: CommandScope): CommandScope[] { + return scopeHierarchy[current] ?? [current, 'App'] +} +``` + +### 2.3 Scope behavior + +When determining if a shortcut should trigger: + +```typescript +function shouldTrigger(command: Command, currentScope: CommandScope): boolean { + const activeScopes = getActiveScopes(currentScope) + return activeScopes.includes(command.scope) +} +``` + +**Example**: When `File list` is active: +- `⌘Q` (App scope) triggers β€” App is in File list's hierarchy +- `⌘N` (File list scope) triggers β€” exact match +- `⌘W` (About window scope) does NOT trigger β€” different branch + +--- + +## 3. Data model + +### 3.1 Command definition + +```typescript +interface Command { + id: string // Unique ID, for example 'file.copy' + name: string // Display name, for example "Copy" + scope: CommandScope // Single scope for grouping + showInPalette: boolean // Show in command palette + shortcuts: string[] // Default shortcuts (platform-specific) + description?: string // Optional description +} +``` + +### 3.2 Custom shortcuts storage + +Custom shortcuts are stored separately from defaults: + +```typescript +interface CustomShortcuts { + // Only stores customizations, not defaults + // Key: command ID, Value: array of shortcuts (empty array = all removed) + [commandId: string]: string[] +} +``` + +### 3.3 Effective shortcuts + +```typescript +function getEffectiveShortcuts(commandId: string): string[] { + const customShortcuts = getCustomShortcuts() + if (commandId in customShortcuts) { + return customShortcuts[commandId] + } + const command = getCommand(commandId) + return command?.shortcuts ?? [] +} + +function isShortcutModified(commandId: string): boolean { + const customShortcuts = getCustomShortcuts() + return commandId in customShortcuts +} +``` + +--- + +## 4. Key capture and formatting + +### 4.1 Platform-specific storage + +Shortcuts are stored as platform-specific display strings. No normalization or translation. + +**macOS**: `βŒ˜β‡§P`, `βŒ₯⌫`, `βŒƒTab` +**Windows/Linux**: `Ctrl+Shift+P`, `Alt+Backspace`, `Ctrl+Tab` + +### 4.2 Key symbols (macOS) + +| Modifier | Symbol | +|----------|--------| +| Command | ⌘ | +| Control | βŒƒ | +| Option | βŒ₯ | +| Shift | ⇧ | + +### 4.3 Key capture implementation + +```typescript +interface KeyCombo { + meta: boolean + ctrl: boolean + alt: boolean + shift: boolean + key: string // Normalized key name +} + +function formatKeyCombo(event: KeyboardEvent): string { + const parts: string[] = [] + + // macOS uses symbols, Windows/Linux uses names + if (isMacOS()) { + if (event.metaKey) parts.push('⌘') + if (event.ctrlKey) parts.push('βŒƒ') + if (event.altKey) parts.push('βŒ₯') + if (event.shiftKey) parts.push('⇧') + } else { + if (event.ctrlKey) parts.push('Ctrl') + if (event.altKey) parts.push('Alt') + if (event.shiftKey) parts.push('Shift') + if (event.metaKey) parts.push('Win') + } + + const key = normalizeKeyName(event.key) + parts.push(key) + + return isMacOS() ? parts.join('') : parts.join('+') +} + +function normalizeKeyName(key: string): string { + // Single characters are uppercased + if (key.length === 1) return key.toUpperCase() + + // Special key mappings + const keyMap: Record = { + 'Backspace': isMacOS() ? '⌫' : 'Backspace', + 'Delete': isMacOS() ? '⌦' : 'Delete', + 'Enter': isMacOS() ? '↩' : 'Enter', + 'Escape': isMacOS() ? 'βŽ‹' : 'Esc', + 'Tab': 'Tab', + 'ArrowUp': '↑', + 'ArrowDown': '↓', + 'ArrowLeft': '←', + 'ArrowRight': 'β†’', + ' ': 'Space', + } + + return keyMap[key] ?? key +} +``` + +### 4.4 Matching shortcuts + +To check if a keyboard event matches a stored shortcut: + +```typescript +function matchesShortcut(event: KeyboardEvent, shortcut: string): boolean { + return formatKeyCombo(event) === shortcut +} +``` + +--- + +## 5. Conflict detection + +### 5.1 Conflict definition + +Two commands conflict if: +1. They have the same shortcut, AND +2. Their scopes overlap in the hierarchy + +### 5.2 Scope overlap check + +```typescript +function scopesOverlap(scopeA: CommandScope, scopeB: CommandScope): boolean { + const activeA = getActiveScopes(scopeA) + const activeB = getActiveScopes(scopeB) + // They overlap if either contains the other + return activeA.includes(scopeB) || activeB.includes(scopeA) +} +``` + +### 5.3 Finding conflicts + +```typescript +interface ShortcutConflict { + shortcut: string + commands: Command[] +} + +function findConflictsForShortcut(shortcut: string, scope: CommandScope): Command[] { + const allCommands = getAllCommands() + return allCommands.filter(cmd => { + const cmdShortcuts = getEffectiveShortcuts(cmd.id) + return cmdShortcuts.includes(shortcut) && scopesOverlap(cmd.scope, scope) + }) +} + +function getAllConflicts(): ShortcutConflict[] { + const conflicts: ShortcutConflict[] = [] + const shortcutMap = new Map() + + for (const cmd of getAllCommands()) { + for (const shortcut of getEffectiveShortcuts(cmd.id)) { + const existing = shortcutMap.get(shortcut) ?? [] + // Check for scope overlap with any existing command + const overlapping = existing.filter(e => scopesOverlap(e.scope, cmd.scope)) + if (overlapping.length > 0) { + // Add to conflicts + const conflict = conflicts.find(c => c.shortcut === shortcut) + if (conflict) { + if (!conflict.commands.includes(cmd)) { + conflict.commands.push(cmd) + } + } else { + conflicts.push({ shortcut, commands: [...overlapping, cmd] }) + } + } + existing.push(cmd) + shortcutMap.set(shortcut, existing) + } + } + + return conflicts +} +``` + +--- + +## 6. Persistence + +### 6.1 Storage file + +Custom shortcuts are stored in a separate file from main settings: + +`~/Library/Application Support/com.veszelovszki.cmdr/shortcuts.json` + +### 6.2 File format + +```json +{ + "_schemaVersion": 1, + "shortcuts": { + "file.copy": ["⌘C", "βŒƒC"], + "file.paste": ["⌘V"], + "nav.parent": [] + } +} +``` + +- Only modified commands are stored +- Empty array means all shortcuts removed +- Missing command means use defaults + +### 6.3 Save behavior + +- Debounced 500ms after last change +- Atomic write (temp file + rename) +- On error: log warning, retry once + +### 6.4 Migration + +Schema version supports future migrations. Currently version 1. + +--- + +## 7. UI behavior + +### 7.1 Edit flow + +1. User clicks a shortcut pill +2. Pill changes to "Press keys..." state (highlighted) +3. User presses key combination +4. Combo is captured and displayed in pill +5. 500ms delay for confirmation (user can keep pressing to change) +6. After 500ms of no input: + - Check for conflicts + - If conflict: show inline warning + - If no conflict: save immediately + +### 7.2 Conflict resolution UI + +When a conflict is detected: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ⚠️ ⌘N is already bound to "New file" in File list scope β”‚ +β”‚ β”‚ +β”‚ [Remove from other] [Keep both] [Cancel] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- **Remove from other**: Removes shortcut from conflicting command, assigns to current +- **Keep both**: Allows the conflict (user's choice) +- **Cancel**: Reverts to previous shortcut + +### 7.3 Removing a shortcut + +- Click pill to select +- Press Backspace or Delete +- Shortcut is removed (with 500ms delay like editing) + +### 7.4 Adding a shortcut + +- Click [+] button next to existing shortcuts +- New empty pill appears in edit mode +- Same capture flow as editing + +### 7.5 Reset to defaults + +**Single command**: Right-click context menu β†’ "Reset to default" +**All commands**: "Reset all to defaults" button + +Both show confirmation dialog. + +### 7.6 Visual indicators + +- **Blue dot**: Shortcut has been modified from default +- **Orange warning icon**: Shortcut has conflicts +- **Filter chips**: + - "All": Show all commands + - "Modified": Only commands with custom shortcuts + - "Conflicts": Only commands with conflicting shortcuts (shows count badge) + +--- + +## 8. Integration with keyboard handling + +### 8.1 Current implementation + +Keyboard handling is in `+page.svelte` `handleKeyDown`: + +```typescript +handleKeyDown = (e: KeyboardEvent) => { + if (e.metaKey && e.key === ',') { + void openSettingsWindow() + return + } + // ... more hardcoded checks +} +``` + +### 8.2 Target implementation + +Replace hardcoded checks with dynamic lookup: + +```typescript +// keyboard-handler.ts +function handleKeyDown(event: KeyboardEvent, currentScope: CommandScope): string | null { + const shortcut = formatKeyCombo(event) + const activeScopes = getActiveScopes(currentScope) + + // Find command matching this shortcut in active scopes + // More specific scopes take priority (they're first in the array) + for (const scope of activeScopes) { + for (const command of getCommandsInScope(scope)) { + const shortcuts = getEffectiveShortcuts(command.id) + if (shortcuts.includes(shortcut)) { + return command.id // Return command to execute + } + } + } + + return null // No matching command +} +``` + +### 8.3 Command execution + +The returned command ID is passed to `handleCommandExecute()` which already handles command dispatch. + +--- + +## 9. File structure + +``` +src/lib/shortcuts/ +β”œβ”€β”€ types.ts # KeyCombo, ShortcutConflict interfaces +β”œβ”€β”€ scope-hierarchy.ts # Scope definitions and hierarchy +β”œβ”€β”€ key-capture.ts # formatKeyCombo, matchesShortcut, normalizeKeyName +β”œβ”€β”€ shortcuts-store.ts # Persistence layer for custom shortcuts +β”œβ”€β”€ conflict-detector.ts # findConflictsForShortcut, getAllConflicts +└── keyboard-handler.ts # handleKeyDown integration +``` + +--- + +## 10. Testing requirements + +### 10.1 Unit tests + +- Key capture: all modifier combinations +- Key capture: special keys (arrows, function keys, etc.) +- Scope hierarchy: getActiveScopes for all scopes +- Scope overlap: all permutations +- Conflict detection: same scope, overlapping scopes, non-overlapping scopes +- Persistence: save/load cycle +- Persistence: migration from older schema + +### 10.2 Integration tests + +- Edit flow: capture β†’ display β†’ save +- Conflict resolution: all three options +- Reset to defaults: single and all +- Filter chips: correct filtering +- Blue dot: appears when modified + +### 10.3 E2E tests + +- Open settings, navigate to keyboard shortcuts +- Edit a shortcut, verify it saves +- Create a conflict, resolve it +- Reset to defaults diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f1d077..96e8364 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: apps/desktop: dependencies: + '@ark-ui/svelte': + specifier: ^5.15.0 + version: 5.15.0(svelte@5.46.1) '@crabnebula/tauri-plugin-drag': specifier: ^2.1.0 version: 2.1.0 @@ -25,6 +28,9 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.9.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.6.0 + version: 2.6.0 '@tauri-apps/plugin-fs': specifier: ^2.4.4 version: 2.4.4 @@ -259,6 +265,11 @@ packages: '@acemir/cssom@0.9.30': resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + '@ark-ui/svelte@5.15.0': + resolution: {integrity: sha512-hNplAW5DVObanJd2sCbCqWvlVkv/1l4wXH7yge/akSZ0K2Nb/LPKFmijSpPZwwheKDCdxyQLrhUygiqI7GCqGg==} + peerDependencies: + svelte: '>=5.20.0' + '@asamuzakjp/css-color@4.1.1': resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} @@ -991,6 +1002,15 @@ packages: '@exodus/crypto': optional: true + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@formatjs/ecma402-abstract@2.3.6': resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} @@ -1398,6 +1418,12 @@ packages: '@types/node': optional: true + '@internationalized/date@3.10.0': + resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==} + + '@internationalized/number@3.6.5': + resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1885,6 +1911,9 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || ^7.0.0 + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -2049,6 +2078,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.6.0': + resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + '@tauri-apps/plugin-fs@2.4.4': resolution: {integrity: sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ==} @@ -2404,6 +2436,231 @@ packages: resolution: {integrity: sha512-48KiET6Phmu7SIQgpTXSn7eRJK6MJdTKib2MLT5WTKIJ+t0OyGKl/ESXi6tzFrGFPzLkvogSIRy8O2sKM0PcbA==} engines: {node: '>=18'} + '@zag-js/accordion@1.31.1': + resolution: {integrity: sha512-3sGi4EZpGBz/O1IVkk9dzzWzP5vVVOj4Li6C+jHOnrgaWPouA/mBTP5L9HEL8qtFsECFZwpNo486eqiCmeHoGw==} + + '@zag-js/anatomy@1.31.1': + resolution: {integrity: sha512-BhIhf3Q0tRA0Jugd7AJfUBzeAb/iATBsw7KyYThMGcPWmrWssL7KWr5AB6RufzGKU7+DCb1QEhlqd4NSOJaYxQ==} + + '@zag-js/angle-slider@1.31.1': + resolution: {integrity: sha512-SfWrgnM0zMLX82rsIJOqWk430UnPA17UFGcDqMDRwXy1Wx4yptmx0aFAsSXnRnw4Ee7WaulF2RWBli6O6iYRCA==} + + '@zag-js/aria-hidden@1.31.1': + resolution: {integrity: sha512-SoNt4S2LkHNWPglQczWN0E5vAV15MT1GoK9MksZzbkMhl+pkDTdLytpXsQ1IgalC1YUng0XNps/Wt6P3uDuzTA==} + + '@zag-js/async-list@1.31.1': + resolution: {integrity: sha512-BDZEmr4KKh3JASgkXouOwoTWRS1UPE3gdZYZ7Sk7SJ1i8+Pk6zUQ4FnxaoF/cSAdCXyjSSr92Kns2bTk/QuNkQ==} + + '@zag-js/auto-resize@1.31.1': + resolution: {integrity: sha512-qzWHibjBekSmFweG+EWY8g0lRzKtok7o9XtQ+JFlOu3s6x4D02z2YDzjDdfSLmS7j0NxISnwQkinWiDAZEYHog==} + + '@zag-js/avatar@1.31.1': + resolution: {integrity: sha512-Grosi2hRn4wfDYlPd8l+d4GCIFMsoj6ZFqii+1k14AqTDiCUJ/J0jCvOrRHkvkpEqektjuSD7e/GCX+yawqkuQ==} + + '@zag-js/bottom-sheet@1.31.1': + resolution: {integrity: sha512-ZBbIpYyZX2zQeqW36aODVi9/I4J3zS1XmIHUjeXmfmf6TlQUA1ydgYl7ipREfmCzNWX2LEA5ZnPJQw0UBcrB8w==} + + '@zag-js/carousel@1.31.1': + resolution: {integrity: sha512-228Ol86G/lg8crcomy5cALkUYdOHCHcvJnSOQzeUj80JNjlELzrjBpaAj4lx8dZocfwou2Sg4NyZJ+mISSc+Dg==} + + '@zag-js/checkbox@1.31.1': + resolution: {integrity: sha512-oLS8bqhimckLl6coCNmKPPUmB8wIbVhtkpLwLPLgz4vhhUe7gnpB5dea14Ow2JTBnmug8bMh/bJDtuPa9qQuTw==} + + '@zag-js/clipboard@1.31.1': + resolution: {integrity: sha512-pv/gOmD9DMg+YmSMjahyd5oSp7/v9K0uQ3att6fPeaNMjB42b3tnY1S1GNVy5Ltf/qHDab6WVwlEN+1zKHXaYw==} + + '@zag-js/collapsible@1.31.1': + resolution: {integrity: sha512-eCC5G6bBZUwF8z2XULQXUNRxqte9I2Sv+WJ2brycPn1a68uYD76RzFBmLQ2er95VbshUdeo8nRuX8MooAFuYzg==} + + '@zag-js/collection@1.31.1': + resolution: {integrity: sha512-ecpfyfCj8Y0/GUPuHYsLxexIrx10VuR3Wd0H+lamcki3lYgQxZrpLRFMwgTqmI/m7t3zhm5QeEvMUJ1H14YMLA==} + + '@zag-js/color-picker@1.31.1': + resolution: {integrity: sha512-AWNZth49iEDxqh1DBZNSKpfEM/FF+MjL5bgUHVctnHdkpFsZLynJorWQQ4hNXNDFEc/I5w10KSxVCcO6tsPGFw==} + + '@zag-js/color-utils@1.31.1': + resolution: {integrity: sha512-HdjTRU8C0tO6hK+PBVlu8iQH1MJaAnJAEdq2FcD97mq0PiPhrSj6iOftnrvPsE4CRieVFjnJWOvaubWFc4VmHA==} + + '@zag-js/combobox@1.31.1': + resolution: {integrity: sha512-IT0getSAGzngdRL20iX/iAh2d7DzVoMDDppOsOFBG2owKAgLpj8uLvUhy+lcrm6N8yxYOya89D6Aef7V5KdwlQ==} + + '@zag-js/core@1.31.1': + resolution: {integrity: sha512-RaMJeqtjxG6k7iFD3WQnlyFJVT3yfQN+pJygAHH37GsMtiNzQQJOoesjb0LV9T27jwMXeNUzrh3MSDr1/0yVcQ==} + + '@zag-js/date-picker@1.31.1': + resolution: {integrity: sha512-AOWN/IskGidVQt5g+uE9cILqJBTclE6OG1GC9WSWuyP/y4F+PdP/781SgYpYCZg/6pMGbL01PFKKb7xOOCeZAg==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-utils@1.31.1': + resolution: {integrity: sha512-+Aq9g/rqLeiRmnazgdZMc59gAxqxbw3GGy8AngrtNipgRtMhPlzGa3S4Qsq1yau6OKaHZ13uckUS+MhLNbBY+Q==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/dialog@1.31.1': + resolution: {integrity: sha512-iaWlYQ6TYoVjM/X5+UZVZzKiMboE50GnEzGUpbhbeRNRiLqSu5dODSFzior1G4kde/ns5eN+BTf/Tm6AT4N2og==} + + '@zag-js/dismissable@1.31.1': + resolution: {integrity: sha512-jCdJwQmEkG6PlrN13fUk2l7ZclSu54FZwmT4xOtQpEbaiAiESm5KI5oyFh5jDPY47Goa28UJkEjWXVgKXKWb0g==} + + '@zag-js/dom-query@1.31.1': + resolution: {integrity: sha512-2tCZLwSfoXm62gwl0neiAN6u5VnzUhy5wHtKbX+klqGFatnca3Bm++H9+4PHMrwUWRbPg3H5N151lKFEOQhBfQ==} + + '@zag-js/editable@1.31.1': + resolution: {integrity: sha512-JMICHw4/x0YqDy/n+I+TeaXlFbTA0j9w3UqOWMwUFQ+dAsq4JLXeqZDXu19MQN6yaTFdOpG1EFw4FEVTsu+d3Q==} + + '@zag-js/file-upload@1.31.1': + resolution: {integrity: sha512-cp7qMiXKrIcTfDamOz9wlnJLeBF8gucTI7Y+iKaP+hiIW+OG254GElfQiqXNDad3HUmD+Dt8Tx6uAzL/mw3sbQ==} + + '@zag-js/file-utils@1.31.1': + resolution: {integrity: sha512-MDDz52IdPh/mPUYrqUXvh7qDckJHs+mt5gjfx0N89qh2JNXuRU14zPotOKTzIKM4o+HFZkAT6BAfMpr9CX/0ug==} + + '@zag-js/floating-panel@1.31.1': + resolution: {integrity: sha512-Pjgd/wjdglZ90dtq/LC4o5sc6w0m+RehhPmJcIzq9T+E/Xrb6qrhf06QhxB9LwSj4DG/gIv87gmD2qF1VH7cRQ==} + + '@zag-js/focus-trap@1.31.1': + resolution: {integrity: sha512-omgUhAz1r81pYAujqYIIavdTKJzDRExioSiqhnx/xq10a6Q/xavMFflq8w7edMc9JHkTOnr9E5qh9abCVJjhpQ==} + + '@zag-js/focus-visible@1.31.1': + resolution: {integrity: sha512-GC59A3yd7tj8aKhzvhrM+CEZZraXm5y/SpfIjz1J7kGV6eeXbUtjkbe75g99Ve8iJYfQVQlAj2GyN3oniHc5Zw==} + + '@zag-js/highlight-word@1.31.1': + resolution: {integrity: sha512-nQw7t8LgWXW+6Z5E/p6T+OST0DDXp35mrFCzrkJL54aVTZ3GuLyIP2p0/HGQr2hE/KKLbZEs5i6UcXF84tiI4g==} + + '@zag-js/hover-card@1.31.1': + resolution: {integrity: sha512-R74kz2wPgGwB3jKQeD91kdtlvVKpffWBJHqw8yCBd95GXGVmhym+BPoCToJzcqiemP8+0EtSuVPU9IHaSuJnSg==} + + '@zag-js/i18n-utils@1.31.1': + resolution: {integrity: sha512-SARkFuo1+Q0WcNv4jqvxp5hjCOqu/gBa7p6BTh7v5Bo00QhKRM/bCvVt0EB6V+h2oejrZfkwZ0MwbpQiL6L2aQ==} + + '@zag-js/image-cropper@1.31.1': + resolution: {integrity: sha512-hFuy4I3jIJ/iyJsnfbLX1l/cJtN42j7lwhw8TeWVX8Y+hHxFPMSKx7AQirt/hALUbyy7QsQgAd5IslpsYq1Nlg==} + + '@zag-js/interact-outside@1.31.1': + resolution: {integrity: sha512-oxBAlBqcatlxGUmhwUCRYTADIBrVoyxM1YrFzR1R8jhvVR/QCaxoLAyKwcA3mWXlZ8+NlXb7n5ELE11BZb/rEg==} + + '@zag-js/json-tree-utils@1.31.1': + resolution: {integrity: sha512-wrNek2UBE69FWpo2f0E2MxiboBS+Uop79LeQU2jNDujA1o3x6b1Lp2r7Fl1sfnUWMdKVVQb44oqfIj2g3CTEmQ==} + + '@zag-js/listbox@1.31.1': + resolution: {integrity: sha512-LcTIr4I9eN4MR1nSRfQfseWgj4ybOXXAY2o5dBpEBL67dnCSX3swNb/4LQO+ebj077BViQb66pBb1KSoeHGkEQ==} + + '@zag-js/live-region@1.31.1': + resolution: {integrity: sha512-RBx8jk1dgvkEUuFs77SBZn0WwvEkeZgVawVu6XUAy4ENfhP0D/qkvwNk+Els8InKmr1gWKajD7sh+g8M40Ex6A==} + + '@zag-js/marquee@1.31.1': + resolution: {integrity: sha512-Rt7+zy7CDOxXm0PqaTcmuWxcrZOPOpZY4T6IxOZk4ZcOXJQ2v7CkF3EK0pdI9PyI6Zpk/YIwQkENjidT55db0A==} + + '@zag-js/menu@1.31.1': + resolution: {integrity: sha512-eJPRM8tlauRTsAoJXchDBzMzL2RhXYSHmHak2IJCDMApCV51p0MqGYP8Er3DbMSQTPUFuTq779uUIarDqW+zmA==} + + '@zag-js/navigation-menu@1.31.1': + resolution: {integrity: sha512-xS4aynqmB9NYicPbEW8lPPakAfDfSgIDL1pRVSD6f1+VXkHD6LgNn6jUNDNbFt65mGhLpA2IczbvLCxv0g/ISQ==} + + '@zag-js/number-input@1.31.1': + resolution: {integrity: sha512-vn+BXEZ2/g2CMIFFyjjye/SbCeW3I/rlszL8EyBmhMcuA1l51OX2WKry6HeQNiU41uMyFg2rb1pb5KVw1gJsCg==} + + '@zag-js/pagination@1.31.1': + resolution: {integrity: sha512-icW6FNzIKNz7iXU+prlQWpMFJedDrhmCKzzI39SY+dv5g1Gnrlc0b44PxvNl5PWFLSkB5KBT/R1WCqd8Kh4cCA==} + + '@zag-js/password-input@1.31.1': + resolution: {integrity: sha512-AivOeNO14a39xhxVMB2TVmIjmQ89OwVz0+2IjX3JjLS2Pmia+gg9xnVd2kBIcKfnqUN4MBnzmk7t46YWJMQVVQ==} + + '@zag-js/pin-input@1.31.1': + resolution: {integrity: sha512-k3ESoX5ve5sbWBLTCPYAzgLjRU7mVNEUiqAOhRgazOcBGV5wjGh398zWb1jr0FMxPnoAMrXDN/CQwJTmJcMKrg==} + + '@zag-js/popover@1.31.1': + resolution: {integrity: sha512-uCFJP3DFBkEBAre6lgGLw2xWS2ZIuT/DLeajIXb+8BmC9KCF0wY4c9qojx9F3rGMJQxcGl+WUoXENkOvkTaVhQ==} + + '@zag-js/popper@1.31.1': + resolution: {integrity: sha512-wLXcEqzn9MK1rGbsgnDH26o5ZWqR4oeb6ZepKKy0gcuJl/1S5/dr1VBvxJNMZlf9d6etvYklG5LRnIVkXCbrjA==} + + '@zag-js/presence@1.31.1': + resolution: {integrity: sha512-tv+WsBnA0abIlDuEfZMh0lRPF4cMs6kWJosNkGBwzeXnGds+KXjzpL2KDtwDgbJgN3sI0xHPMYjRy2v3ZamcDA==} + + '@zag-js/progress@1.31.1': + resolution: {integrity: sha512-f9lIDHCRcFAG14LVEKOAPTdqPzphwIIraC6fTr9AwmNlYI6/qFDkz3jOlYVSyk5VsJAIFM/777x/CdqjliiOqg==} + + '@zag-js/qr-code@1.31.1': + resolution: {integrity: sha512-Rxh+HF12SgUp5rvTelp1qyLK3xkn37h2fT/L4eBQ0f8OUEo8wfowEbs36+1i61d6UuH7PJt4q/07eIf6vNVevA==} + + '@zag-js/radio-group@1.31.1': + resolution: {integrity: sha512-OfKIdEtSG0EuHM+cFVqcR+04yzZmcDRgG3j0QhoJsyS1my63ZHbwC2HNAtfPFh4U4sJx9yUexwSzPGZ6pOzIdw==} + + '@zag-js/rating-group@1.31.1': + resolution: {integrity: sha512-BkQUglKm4a+KXYPACYvIvBJSuEyzV0YQqjjiucwJ5UiOlK72C66VBvyGN+DqJRDnkU1K5azt6E1Ja5ANk3fgsg==} + + '@zag-js/rect-utils@1.31.1': + resolution: {integrity: sha512-lBFheAnz8+3aGDFjqlkw0Iew/F03lFjiIf26hkkcFSZu0ltNZUMG/X3XLHUnHxdfbdBguc8ons6mr2MkVvisng==} + + '@zag-js/remove-scroll@1.31.1': + resolution: {integrity: sha512-gVVJuFKaCjo652RmajYmkjXKgjJWLQ5ZhZLTaLUKWM1mAarvlqnLui8jrHEHLxqpfsjQylfdhJKkWmyF8NAgTA==} + + '@zag-js/scroll-area@1.31.1': + resolution: {integrity: sha512-GBXd1K3U0AHwWlJaqAMKQMZyeoxuBO6XYrVgdvzgiftQbJrZs5fuYOFyDvPLDWHTLYxaHso44/f+9EmAUAiytw==} + + '@zag-js/scroll-snap@1.31.1': + resolution: {integrity: sha512-YWsfhcQqiffu2X9HuB0fMnEQAu6rEOfGcvQYinvB6pjWPOvIJGxGMi/dYyy21XQDNJ9K1IcWRIo/yuaajoJyQQ==} + + '@zag-js/select@1.31.1': + resolution: {integrity: sha512-vKWb8BiRY83Y3HkDNnimf6cr1yvzJh1HwZlzXFz0y47zEvlikQaf+r96obR78RgTtMjNTTV15tTXdc1/WFoYkw==} + + '@zag-js/signature-pad@1.31.1': + resolution: {integrity: sha512-bz3WtLuIZoLrJDKcdS7fPAdD/Qi9wKiKACl5cu+ftv9zg8w+qqYNLtjH9HxeUFbCtQRKqcdXjO/UZ8iL07hgsQ==} + + '@zag-js/slider@1.31.1': + resolution: {integrity: sha512-FILbLTMd3BnyclZ28+ippfyqzYPGK60qZapxtTERmWDC75Okf8AFnTCQf84Y8jRmBKCS1yhjF+IOtkFAENeB6w==} + + '@zag-js/splitter@1.31.1': + resolution: {integrity: sha512-7SGBT2/xKsOzeSQEg+Otn1XV3RHrAz3jTySjBRKoEmdxubhfREqbKotbGVG65aTve11fQnmJ3Oyt3GJOeraxLA==} + + '@zag-js/steps@1.31.1': + resolution: {integrity: sha512-KsBH38V3tH9/q8CDgx4sUSXLYwFdcp1crZy8hTIcN0RUiZ55PmqYKkN2znzBjTbaCW9yhP8kXsbuo2s8OIU5lQ==} + + '@zag-js/store@1.31.1': + resolution: {integrity: sha512-d5ZTRciTuXOGQ3nML15kQLaTiR1wJPxT1Fu1nN659X6Rl8DPtubYaRCZ3RCk9Kyiyg2z5HxeVqDswaDvGbM9Rg==} + + '@zag-js/svelte@1.31.1': + resolution: {integrity: sha512-yj9ZzXHk4YV+zcLHypfqcA8BkP5043V58AZ3Hu3WMVczF4/GcmbHn4/nWNK+6j7M+BLCNEAx460SOZovSNemvw==} + peerDependencies: + svelte: '>=5' + + '@zag-js/switch@1.31.1': + resolution: {integrity: sha512-Jii3OSqSa9sQux+hvSRvp9dirzUF09+PAjrLjCQs+BT08EZ0XqeGvVzM0Wqf9LFy07HdLZntai3IUaXLF6byBw==} + + '@zag-js/tabs@1.31.1': + resolution: {integrity: sha512-QBq4ngpBNMNEI7Wuaq8llwHOqgcVbNHHEDC5zHg60Bf7MY5ltP8wSq6Kldu0zZRVwrLzanYoMELDUyf9H0vtnw==} + + '@zag-js/tags-input@1.31.1': + resolution: {integrity: sha512-V4lJe/aMIs7WVoXYfszU6E3iARLLRQFMiycu76/slb8NWJiLrkSIaMQ4FAe2pqkodgCWXA83tuaeAZRq7ouTFg==} + + '@zag-js/timer@1.31.1': + resolution: {integrity: sha512-bXfeSbneWGOBKlD5dYq06T8CSY9Ky+qb1yIfJAFsRF4n34mpUYRdtfwpNQYyddGpkLD7oH4VibajeZXB7HaL0g==} + + '@zag-js/toast@1.31.1': + resolution: {integrity: sha512-MueHEei9ol3H6tWBruLxF7yEUpV3vsJ8brTQVRRtPr/6pqBs5kGzfL4YskhQ2tiwO6egay8YrkbaS3xJfpKt4w==} + + '@zag-js/toggle-group@1.31.1': + resolution: {integrity: sha512-Mojc7mex01/gvwXfrUIIThzT7HOktZoMge9rrb6+P7rQX7ulyNXYPjQrW2tay+t54GOJ3xODo9dU7PpRzXeHbw==} + + '@zag-js/toggle@1.31.1': + resolution: {integrity: sha512-HbFBuGfdyYkNvOp3cEB8Civ4E92finT4u3e4LKysB4/LboqKA0cJvFhSnHyThbROONTx06W/3CxwoSFR4o8IhA==} + + '@zag-js/tooltip@1.31.1': + resolution: {integrity: sha512-pWEU5XhEPpnyl2VLrGJlyjj7+p+X0UX3Fld+WGhc/hCaWiuW2ZzD/ewDRhSOZu4/TzAO3axrPqG1YhW4fhogKQ==} + + '@zag-js/tour@1.31.1': + resolution: {integrity: sha512-ZmcAevXxoENHmHG0xwdIt1oCLe2/DW1CEBFPr7YuGKc+FU3QbBVZMzcBHrJCe0nkKXhUKzHOHM78bOHD/gM76w==} + + '@zag-js/tree-view@1.31.1': + resolution: {integrity: sha512-Q+VSQz7X1XR8gT7ICWXlQOJIvzTWw/9BlF7B073UpEgAKRFlD11FmERka5y/BYqj8uE0vazcbSEA3Vc2dgCMJA==} + + '@zag-js/types@1.31.1': + resolution: {integrity: sha512-mKw5DoeBjFykfUHv3ifCRjcogFTqp0aCCsmqQMfnf+J/mg2aXpAx76AXT1PYXAVVhxdP6qGXNd0mOQZDVrIlSQ==} + + '@zag-js/utils@1.31.1': + resolution: {integrity: sha512-KLm0pmOtf4ydALbaVLboL7W98TDVxwVVLvSuvtRgV53XTjlsVopTRA5/Xmzq2NhWujDZAXv7bRV603NDgDcjSw==} + '@zip.js/zip.js@2.8.15': resolution: {integrity: sha512-HZKJLFe4eGVgCe9J87PnijY7T1Zn638bEHS+Fm/ygHZozRpefzWcOYfPaP52S8pqk9g4xN3+LzMDl3Lv9dLglA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} @@ -2990,6 +3247,9 @@ packages: resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} engines: {node: '>=20'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -4867,6 +5127,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-freehand@1.2.2: + resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -5004,9 +5267,15 @@ packages: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-memoize@3.0.1: + resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -5917,6 +6186,9 @@ packages: uploadthing: optional: true + uqr@0.1.2: + resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -6514,6 +6786,74 @@ snapshots: '@acemir/cssom@0.9.30': {} + '@ark-ui/svelte@5.15.0(svelte@5.46.1)': + dependencies: + '@internationalized/date': 3.10.0 + '@zag-js/accordion': 1.31.1 + '@zag-js/anatomy': 1.31.1 + '@zag-js/angle-slider': 1.31.1 + '@zag-js/async-list': 1.31.1 + '@zag-js/auto-resize': 1.31.1 + '@zag-js/avatar': 1.31.1 + '@zag-js/bottom-sheet': 1.31.1 + '@zag-js/carousel': 1.31.1 + '@zag-js/checkbox': 1.31.1 + '@zag-js/clipboard': 1.31.1 + '@zag-js/collapsible': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/color-picker': 1.31.1 + '@zag-js/color-utils': 1.31.1 + '@zag-js/combobox': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/date-picker': 1.31.1(@internationalized/date@3.10.0) + '@zag-js/date-utils': 1.31.1(@internationalized/date@3.10.0) + '@zag-js/dialog': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/editable': 1.31.1 + '@zag-js/file-upload': 1.31.1 + '@zag-js/file-utils': 1.31.1 + '@zag-js/floating-panel': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/highlight-word': 1.31.1 + '@zag-js/hover-card': 1.31.1 + '@zag-js/i18n-utils': 1.31.1 + '@zag-js/image-cropper': 1.31.1 + '@zag-js/json-tree-utils': 1.31.1 + '@zag-js/listbox': 1.31.1 + '@zag-js/marquee': 1.31.1 + '@zag-js/menu': 1.31.1 + '@zag-js/navigation-menu': 1.31.1 + '@zag-js/number-input': 1.31.1 + '@zag-js/pagination': 1.31.1 + '@zag-js/password-input': 1.31.1 + '@zag-js/pin-input': 1.31.1 + '@zag-js/popover': 1.31.1 + '@zag-js/presence': 1.31.1 + '@zag-js/progress': 1.31.1 + '@zag-js/qr-code': 1.31.1 + '@zag-js/radio-group': 1.31.1 + '@zag-js/rating-group': 1.31.1 + '@zag-js/scroll-area': 1.31.1 + '@zag-js/select': 1.31.1 + '@zag-js/signature-pad': 1.31.1 + '@zag-js/slider': 1.31.1 + '@zag-js/splitter': 1.31.1 + '@zag-js/steps': 1.31.1 + '@zag-js/svelte': 1.31.1(svelte@5.46.1) + '@zag-js/switch': 1.31.1 + '@zag-js/tabs': 1.31.1 + '@zag-js/tags-input': 1.31.1 + '@zag-js/timer': 1.31.1 + '@zag-js/toast': 1.31.1 + '@zag-js/toggle': 1.31.1 + '@zag-js/toggle-group': 1.31.1 + '@zag-js/tooltip': 1.31.1 + '@zag-js/tour': 1.31.1 + '@zag-js/tree-view': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + svelte: 5.46.1 + '@asamuzakjp/css-color@4.1.1': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -7047,6 +7387,17 @@ snapshots: '@exodus/bytes@1.8.0': {} + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + '@formatjs/ecma402-abstract@2.3.6': dependencies: '@formatjs/fast-memoize': 2.2.7 @@ -7381,6 +7732,14 @@ snapshots: optionalDependencies: '@types/node': 25.0.3 + '@internationalized/date@3.10.0': + dependencies: + '@swc/helpers': 0.5.18 + + '@internationalized/number@3.6.5': + dependencies: + '@swc/helpers': 0.5.18 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7863,6 +8222,10 @@ snapshots: vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -7980,6 +8343,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.9.6 '@tauri-apps/cli-win32-x64-msvc': 2.9.6 + '@tauri-apps/plugin-dialog@2.6.0': + dependencies: + '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-fs@2.4.4': dependencies: '@tauri-apps/api': 2.9.1 @@ -8537,6 +8904,544 @@ snapshots: dependencies: '@wdio/logger': 9.18.0 + '@zag-js/accordion@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/anatomy@1.31.1': {} + + '@zag-js/angle-slider@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/rect-utils': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/aria-hidden@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/async-list@1.31.1': + dependencies: + '@zag-js/core': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/auto-resize@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/avatar@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/bottom-sheet@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/aria-hidden': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/remove-scroll': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/carousel@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/scroll-snap': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/checkbox@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/clipboard@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/collapsible@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/collection@1.31.1': + dependencies: + '@zag-js/utils': 1.31.1 + + '@zag-js/color-picker@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/color-utils': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/color-utils@1.31.1': + dependencies: + '@zag-js/utils': 1.31.1 + + '@zag-js/combobox@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/aria-hidden': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/core@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/date-picker@1.31.1(@internationalized/date@3.10.0)': + dependencies: + '@internationalized/date': 3.10.0 + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/date-utils': 1.31.1(@internationalized/date@3.10.0) + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/live-region': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/date-utils@1.31.1(@internationalized/date@3.10.0)': + dependencies: + '@internationalized/date': 3.10.0 + + '@zag-js/dialog@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/aria-hidden': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/remove-scroll': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/dismissable@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + '@zag-js/interact-outside': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/dom-query@1.31.1': + dependencies: + '@zag-js/types': 1.31.1 + + '@zag-js/editable@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/interact-outside': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/file-upload@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/file-utils': 1.31.1 + '@zag-js/i18n-utils': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/file-utils@1.31.1': + dependencies: + '@zag-js/i18n-utils': 1.31.1 + + '@zag-js/floating-panel@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/rect-utils': 1.31.1 + '@zag-js/store': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/focus-trap@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/focus-visible@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/highlight-word@1.31.1': {} + + '@zag-js/hover-card@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/i18n-utils@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/image-cropper@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/interact-outside@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/json-tree-utils@1.31.1': {} + + '@zag-js/listbox@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/live-region@1.31.1': {} + + '@zag-js/marquee@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/menu@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/rect-utils': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/navigation-menu@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/number-input@1.31.1': + dependencies: + '@internationalized/number': 3.6.5 + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/pagination@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/password-input@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/pin-input@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/popover@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/aria-hidden': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/remove-scroll': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/popper@1.31.1': + dependencies: + '@floating-ui/dom': 1.7.4 + '@zag-js/dom-query': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/presence@1.31.1': + dependencies: + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + + '@zag-js/progress@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/qr-code@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + proxy-memoize: 3.0.1 + uqr: 0.1.2 + + '@zag-js/radio-group@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/rating-group@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/rect-utils@1.31.1': {} + + '@zag-js/remove-scroll@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/scroll-area@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/scroll-snap@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/select@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/signature-pad@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + perfect-freehand: 1.2.2 + + '@zag-js/slider@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/splitter@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/steps@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/store@1.31.1': + dependencies: + proxy-compare: 3.0.1 + + '@zag-js/svelte@1.31.1(svelte@5.46.1)': + dependencies: + '@zag-js/core': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + svelte: 5.46.1 + + '@zag-js/switch@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tabs@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tags-input@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/auto-resize': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/interact-outside': 1.31.1 + '@zag-js/live-region': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/timer@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/toast@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/toggle-group@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/toggle@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tooltip@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tour@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/interact-outside': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tree-view@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/types@1.31.1': + dependencies: + csstype: 3.2.3 + + '@zag-js/utils@1.31.1': {} + '@zip.js/zip.js@2.8.15': {} abort-controller@3.0.0: @@ -9254,6 +10159,8 @@ snapshots: css-tree: 3.1.0 lru-cache: 11.2.4 + csstype@3.2.3: {} + data-uri-to-buffer@6.0.2: {} data-urls@6.0.0: @@ -11524,6 +12431,8 @@ snapshots: pend@1.2.0: {} + perfect-freehand@1.2.2: {} + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -11649,8 +12558,14 @@ snapshots: transitivePeerDependencies: - supports-color + proxy-compare@3.0.1: {} + proxy-from-env@1.1.0: {} + proxy-memoize@3.0.1: + dependencies: + proxy-compare: 3.0.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -12727,6 +13642,8 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.2 + uqr@0.1.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/scripts/check-css-unused/allowlist.go b/scripts/check-css-unused/allowlist.go index a243186..3facdf8 100644 --- a/scripts/check-css-unused/allowlist.go +++ b/scripts/check-css-unused/allowlist.go @@ -12,6 +12,9 @@ var allowedUnusedClasses = map[string]bool{ "size-mb": true, "size-gb": true, "size-tb": true, + // SettingSelect.svelte - classes used with :global() for Ark UI Select component styling + "custom-highlighted": true, + "select-content": true, } // allowedUnusedVariables lists CSS custom properties that are defined but used dynamically, @@ -23,5 +26,6 @@ var allowedUnusedVariables = map[string]bool{ // allowedUndefinedClasses lists classes used in templates that don't need CSS definitions // (used for JS selection, third-party libs, or semantic purposes). var allowedUndefinedClasses = map[string]bool{ - // Example: "js-dropdown-trigger": true, // Used for JS event binding, no styling needed + // Ark UI component class passed for API purposes but not styled + "slider-root": true, }