From 4727b80146879389943188b00a7bb70193c3b332 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:50:13 +0000 Subject: [PATCH 1/5] Initial plan From 7d75feb7d4b8022245b0ebc3b59b2d3ea87aff09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:00:04 +0000 Subject: [PATCH 2/5] Add file system watcher for reactive sidebar updates - Add `notify` crate for file watching (Rust backend) - Add `tempfile` dev-dependency (fixes pre-existing test issue) - Create `watcher.rs` module with debounced FileWatcherState - Add `watch_workspace` and `unwatch_workspace` Tauri commands - Register watcher state and commands in lib.rs - Create `useFileWatcher` hook for frontend event listening - Integrate file watcher hook in App.tsx Co-authored-by: 7sg56 <102475617+7sg56@users.noreply.github.com> --- package-lock.json | 3 - src-tauri/Cargo.lock | 223 +++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 4 + src-tauri/src/commands.rs | 36 ++++++ src-tauri/src/lib.rs | 4 + src-tauri/src/watcher.rs | 86 ++++++++++++++ src/App.tsx | 4 + src/hooks/useFileWatcher.ts | 50 ++++++++ 8 files changed, 404 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/watcher.rs create mode 100644 src/hooks/useFileWatcher.ts diff --git a/package-lock.json b/package-lock.json index 4e19d7b..1e4b4ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2652,7 +2652,6 @@ "version": "19.2.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3398,7 +3397,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -6317,7 +6315,6 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dd02a4f..86c961d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -80,6 +80,7 @@ name = "app" version = "0.1.5" dependencies = [ "log", + "notify", "serde", "serde_json", "tauri", @@ -87,6 +88,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-log", + "tempfile", ] [[package]] @@ -790,6 +792,22 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fdeflate" version = "0.3.7" @@ -818,6 +836,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -876,6 +905,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1567,6 +1605,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1677,6 +1735,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -1743,8 +1821,15 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall 0.7.3", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -1837,6 +1922,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -1911,6 +2008,25 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.10.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2224,7 +2340,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2657,6 +2773,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2839,6 +2964,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3199,7 +3337,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -3692,6 +3830,19 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -3809,7 +3960,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -4570,6 +4721,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4612,6 +4772,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4669,6 +4844,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4687,6 +4868,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4705,6 +4892,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4735,6 +4928,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4753,6 +4952,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4771,6 +4976,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4789,6 +5000,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3a5f7cb..8ffe6a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,3 +25,7 @@ tauri = { version = "2.9.5", features = [] } tauri-plugin-log = "2" tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" +notify = "6" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 766fbf9..d1e50d6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,9 +6,11 @@ //! - Use descriptive error messages use crate::types::FileEntry; +use crate::watcher::FileWatcherState; use crate::workspace::{scan_directory_recursive, validate_workspace_path}; use std::fs; use std::path::Path; +use tauri::State; /// Scan a workspace directory and return the file tree /// @@ -133,3 +135,37 @@ pub fn delete_folder(path: String) -> Result<(), String> { fs::remove_dir_all(&path) .map_err(|e| format!("Failed to delete folder '{}': {}", path, e)) } + +/// Start watching a workspace directory for external file changes +/// +/// # Arguments +/// * `path` - Absolute path to the workspace directory +/// * `app_handle` - Tauri app handle for emitting events +/// * `watcher_state` - Managed file watcher state +/// +/// # Returns +/// * `Ok(())` - Watcher started successfully +/// * `Err(String)` - Error message if watching fails +#[tauri::command] +pub fn watch_workspace( + path: String, + app_handle: tauri::AppHandle, + watcher_state: State<'_, FileWatcherState>, +) -> Result<(), String> { + watcher_state.watch(&path, app_handle) +} + +/// Stop watching the workspace directory +/// +/// # Arguments +/// * `watcher_state` - Managed file watcher state +/// +/// # Returns +/// * `Ok(())` - Watcher stopped successfully +/// * `Err(String)` - Error message if stopping fails +#[tauri::command] +pub fn unwatch_workspace( + watcher_state: State<'_, FileWatcherState>, +) -> Result<(), String> { + watcher_state.unwatch() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2dede3d..8017c48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod commands; mod types; +mod watcher; mod workspace; // Re-export types for use in other modules @@ -22,6 +23,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) + .manage(watcher::FileWatcherState::new()) .invoke_handler(tauri::generate_handler![ commands::scan_workspace, commands::read_note, @@ -31,6 +33,8 @@ pub fn run() { commands::rename_note, commands::create_folder, commands::delete_folder, + commands::watch_workspace, + commands::unwatch_workspace, ]) .setup(|app| { // Build a custom app menu without Cmd+W (Close Window) diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs new file mode 100644 index 0000000..ab5d944 --- /dev/null +++ b/src-tauri/src/watcher.rs @@ -0,0 +1,86 @@ +//! File system watcher for detecting external changes to workspace files +//! +//! Uses the `notify` crate to watch for file creation, deletion, and rename +//! events in the workspace directory. Emits a debounced "workspace-changed" +//! event to the frontend via Tauri's event system. + +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; +use tauri::{AppHandle, Emitter}; + +/// Holds the active file system watcher. +/// +/// Wrapped in a `Mutex` so it can be managed as Tauri application state +/// and accessed from command handlers. +pub struct FileWatcherState { + watcher: Mutex>, +} + +impl FileWatcherState { + pub fn new() -> Self { + Self { + watcher: Mutex::new(None), + } + } + + /// Start watching a workspace directory for file changes. + /// + /// Stops any previously active watcher before starting a new one. + /// Only reacts to create, remove, and rename events (not content modifications), + /// since the sidebar only displays the file tree structure. + /// + /// Events are debounced: the first event triggers a 300ms delay before + /// emitting "workspace-changed" to the frontend. Additional events during + /// the delay are coalesced. + pub fn watch(&self, path: &str, app_handle: AppHandle) -> Result<(), String> { + let mut guard = self.watcher.lock().map_err(|e| e.to_string())?; + + // Drop existing watcher + *guard = None; + + let pending = Arc::new(AtomicBool::new(false)); + let handle = app_handle.clone(); + let pending_clone = pending.clone(); + + let watcher = notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + let dominated = matches!( + event.kind, + EventKind::Create(_) + | EventKind::Remove(_) + | EventKind::Modify(notify::event::ModifyKind::Name(_)) + ); + if dominated && !pending_clone.swap(true, Ordering::SeqCst) { + let pending_inner = pending_clone.clone(); + let handle_inner = handle.clone(); + thread::spawn(move || { + thread::sleep(Duration::from_millis(300)); + pending_inner.store(false, Ordering::SeqCst); + let _ = handle_inner.emit("workspace-changed", ()); + }); + } + } + }) + .map_err(|e| format!("Failed to create file watcher: {}", e))?; + + // We need a mutable watcher to call .watch() + let mut watcher = watcher; + watcher + .watch(Path::new(path), RecursiveMode::Recursive) + .map_err(|e| format!("Failed to watch path '{}': {}", path, e))?; + + *guard = Some(watcher); + Ok(()) + } + + /// Stop the active file system watcher. + pub fn unwatch(&self) -> Result<(), String> { + let mut guard = self.watcher.lock().map_err(|e| e.to_string())?; + *guard = None; + Ok(()) + } +} diff --git a/src/App.tsx b/src/App.tsx index 9f77789..58df116 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { FloatingHub } from "./components/features/FloatingHub"; import { WorkspaceWelcome } from "./components/onboarding/WorkspaceWelcome"; import { useAppStore } from "./store/useAppStore"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; +import { useFileWatcher } from "./hooks/useFileWatcher"; function App() { const workspacePath = useAppStore((state) => state.workspacePath); @@ -13,6 +14,9 @@ function App() { // Register global keyboard shortcuts useKeyboardShortcuts(); + // Watch workspace directory for external file changes + useFileWatcher(); + // Show workspace selection if no workspace is set if (!workspacePath) { return ; diff --git a/src/hooks/useFileWatcher.ts b/src/hooks/useFileWatcher.ts new file mode 100644 index 0000000..74501ff --- /dev/null +++ b/src/hooks/useFileWatcher.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef, useCallback } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useWorkspace } from "./useWorkspace"; + +/** + * Hook that watches the workspace directory for external file changes + * (files created, deleted, or renamed outside the app) and automatically + * refreshes the sidebar file tree. + */ +export function useFileWatcher() { + const { workspacePath, refreshWorkspace } = useWorkspace(); + const isRefreshing = useRef(false); + + // Keep a stable ref to refreshWorkspace to avoid re-running the effect + const refreshRef = useRef(refreshWorkspace); + refreshRef.current = refreshWorkspace; + + const handleChange = useCallback(async () => { + if (isRefreshing.current) return; + isRefreshing.current = true; + try { + await refreshRef.current(); + } catch (err) { + console.error("Failed to refresh workspace:", err); + } finally { + isRefreshing.current = false; + } + }, []); + + useEffect(() => { + if (!workspacePath) return; + + // Start watching the workspace directory + invoke("watch_workspace", { path: workspacePath }).catch((err) => + console.error("Failed to start file watcher:", err), + ); + + // Listen for change events from the backend + const unlisten = listen("workspace-changed", handleChange); + + return () => { + // Clean up: stop watching and remove event listener + unlisten.then((fn) => fn()); + invoke("unwatch_workspace").catch((err) => + console.error("Failed to stop file watcher:", err), + ); + }; + }, [workspacePath, handleChange]); +} From a1b7cbe225f178f8454af90b5c2dac1156451ffb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:04:13 +0000 Subject: [PATCH 3/5] Address code review: rename variable and remove unnecessary rebinding Co-authored-by: 7sg56 <102475617+7sg56@users.noreply.github.com> --- src-tauri/src/watcher.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index ab5d944..8bd8026 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -46,15 +46,15 @@ impl FileWatcherState { let handle = app_handle.clone(); let pending_clone = pending.clone(); - let watcher = notify::recommended_watcher(move |res: Result| { + let mut watcher = notify::recommended_watcher(move |res: Result| { if let Ok(event) = res { - let dominated = matches!( + let is_structural_change = matches!( event.kind, EventKind::Create(_) | EventKind::Remove(_) | EventKind::Modify(notify::event::ModifyKind::Name(_)) ); - if dominated && !pending_clone.swap(true, Ordering::SeqCst) { + if is_structural_change && !pending_clone.swap(true, Ordering::SeqCst) { let pending_inner = pending_clone.clone(); let handle_inner = handle.clone(); thread::spawn(move || { @@ -67,8 +67,6 @@ impl FileWatcherState { }) .map_err(|e| format!("Failed to create file watcher: {}", e))?; - // We need a mutable watcher to call .watch() - let mut watcher = watcher; watcher .watch(Path::new(path), RecursiveMode::Recursive) .map_err(|e| format!("Failed to watch path '{}': {}", path, e))?; From a6e7970adbe32d6c483ac84de72d79d1ad494e06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:50:04 +0000 Subject: [PATCH 4/5] Resolve merge conflicts with main branch (search feature) Co-authored-by: 7sg56 <102475617+7sg56@users.noreply.github.com> --- package-lock.json | 1 + package.json | 1 + src-tauri/src/commands.rs | 72 +++++ src-tauri/src/lib.rs | 1 + src/App.tsx | 2 + src/components/features/SearchPanel.tsx | 277 ++++++++++++++++++ .../layout/editor/CodeMirrorEditor.tsx | 3 + src/hooks/useKeyboardShortcuts.ts | 14 + src/store/useAppStore.ts | 25 ++ src/theme/cinderTheme.ts | 78 ++++- src/theme/editor.css | 38 --- 11 files changed, 473 insertions(+), 39 deletions(-) create mode 100644 src/components/features/SearchPanel.tsx diff --git a/package-lock.json b/package-lock.json index 1e4b4ef..c840416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@codemirror/commands": "^6.10.2", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language-data": "^6.5.2", + "@codemirror/search": "^6.6.0", "@tailwindcss/typography": "^0.5.19", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", diff --git a/package.json b/package.json index 3e78742..81a9020 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@codemirror/commands": "^6.10.2", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language-data": "^6.5.2", + "@codemirror/search": "^6.6.0", "@tailwindcss/typography": "^0.5.19", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d1e50d6..22db0ea 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,10 +8,19 @@ use crate::types::FileEntry; use crate::watcher::FileWatcherState; use crate::workspace::{scan_directory_recursive, validate_workspace_path}; +use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; use tauri::State; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + pub file_path: String, + pub file_name: String, + pub line_number: usize, + pub content_preview: String, +} + /// Scan a workspace directory and return the file tree /// /// # Arguments @@ -136,6 +145,69 @@ pub fn delete_folder(path: String) -> Result<(), String> { .map_err(|e| format!("Failed to delete folder '{}': {}", path, e)) } +/// Search workspace for files whose name matches a query +/// +/// Only matches against file names (not content). This is used for +/// the global file finder (Cmd+Shift+F). +/// +/// # Arguments +/// * `path` - Absolute path to the workspace directory +/// * `query` - Search string to match against file names +/// +/// # Returns +/// * `Ok(Vec)` - List of matching files +/// * `Err(String)` - Error message if scan fails +#[tauri::command] +pub fn search_workspace(path: String, query: String) -> Result, String> { + let workspace_path = Path::new(&path); + validate_workspace_path(workspace_path)?; + + let mut results = Vec::new(); + let query_lower = query.to_lowercase(); + + fn search_dir(dir: &Path, root: &Path, query_lower: &str, results: &mut Vec) { + let read_dir = match fs::read_dir(dir) { + Ok(rd) => rd, + Err(_) => return, + }; + for entry in read_dir { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + + if file_name.starts_with('.') { + continue; + } + + if let Ok(metadata) = entry.metadata() { + if metadata.is_dir() { + search_dir(&path, root, query_lower, results); + } else if file_name.ends_with(".md") { + if file_name.to_lowercase().contains(query_lower) { + // Build a relative path for display + let relative = path.strip_prefix(root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| file_name.clone()); + + results.push(SearchResult { + file_path: path.to_string_lossy().to_string(), + file_name: file_name.clone(), + line_number: 0, + content_preview: relative, + }); + } + } + } + } + } + + search_dir(workspace_path, workspace_path, &query_lower, &mut results); + Ok(results) +} + /// Start watching a workspace directory for external file changes /// /// # Arguments diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8017c48..49e1f0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ pub fn run() { commands::rename_note, commands::create_folder, commands::delete_folder, + commands::search_workspace, commands::watch_workspace, commands::unwatch_workspace, ]) diff --git a/src/App.tsx b/src/App.tsx index 58df116..5f77234 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { MainLayout } from "./components/layout/MainLayout"; import { FileExplorer } from "./components/layout/explorer/FileExplorer"; import { EditorPane } from "./components/layout/editor/EditorPane"; import { FloatingHub } from "./components/features/FloatingHub"; +import { SearchPanel } from "./components/features/SearchPanel"; import { WorkspaceWelcome } from "./components/onboarding/WorkspaceWelcome"; import { useAppStore } from "./store/useAppStore"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; @@ -29,6 +30,7 @@ function App() { editorContent={} /> + ); } diff --git a/src/components/features/SearchPanel.tsx b/src/components/features/SearchPanel.tsx new file mode 100644 index 0000000..d1b466b --- /dev/null +++ b/src/components/features/SearchPanel.tsx @@ -0,0 +1,277 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { useAppStore, type SearchResult } from "../../store/useAppStore"; +import { Search, FileText, X } from "lucide-react"; + +export function SearchPanel() { + const { + isSearchOpen, + searchQuery, + searchResults, + workspacePath, + setSearchOpen, + setSearchQuery, + setSearchResults, + selectFile, + } = useAppStore(); + + const [isLoading, setIsLoading] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); + const inputRef = useRef(null); + + // Focus input when modal opens + useEffect(() => { + if (isSearchOpen) { + setHoveredIndex(null); + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isSearchOpen]); + + // Handle actual search backend call + const handleSearch = useCallback( + async (query: string) => { + if (!query.trim() || !workspacePath) { + setSearchResults([]); + return; + } + + setIsLoading(true); + try { + const results = await invoke("search_workspace", { + path: workspacePath, + query: query.trim(), + }); + setSearchResults(results); + } catch (error) { + console.error("Search failed:", error); + setSearchResults([]); + } finally { + setIsLoading(false); + } + }, + [workspacePath, setSearchResults], + ); + + // Debounce user input + useEffect(() => { + const timer = setTimeout(() => { + handleSearch(searchQuery); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery, handleSearch]); + + if (!isSearchOpen) return null; + + return ( +
setSearchOpen(false)} + > +
e.stopPropagation()} + > + {/* Search input row */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + setSearchOpen(false); + } + }} + style={{ + flex: 1, + backgroundColor: "transparent", + color: "var(--text-primary)", + border: "none", + outline: "none", + fontSize: "15px", + fontFamily: "inherit", + }} + /> + {isLoading && ( +
+ )} + +
+ + {/* Results */} +
+ {searchResults.length > 0 ? ( +
+ {searchResults.map((result, i) => { + const isHovered = hoveredIndex === i; + return ( + + ); + })} +
+ ) : searchQuery.trim().length > 0 && !isLoading ? ( +
+ No files found for "{searchQuery}" +
+ ) : null} +
+
+
+ ); +} diff --git a/src/components/layout/editor/CodeMirrorEditor.tsx b/src/components/layout/editor/CodeMirrorEditor.tsx index a393827..1fcf490 100644 --- a/src/components/layout/editor/CodeMirrorEditor.tsx +++ b/src/components/layout/editor/CodeMirrorEditor.tsx @@ -17,6 +17,8 @@ import { EditorView, type ViewUpdate } from "@codemirror/view"; import { cinderTheme } from "../../../theme/cinderTheme"; import { markdownStylingPlugin } from "./markdownStylingPlugin"; +import { search } from "@codemirror/search"; + interface CodeMirrorEditorProps { /** Current file content */ value: string; @@ -37,6 +39,7 @@ const extensions = [ }), EditorView.lineWrapping, markdownStylingPlugin, + search({ top: true, caseSensitive: true }), ]; export function CodeMirrorEditor({ diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 3f6e89c..47debcf 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -18,6 +18,8 @@ export function useKeyboardShortcuts() { closeFile, toggleExplorerCollapsed, createFolder, + isSearchOpen, + setSearchOpen, } = useAppStore(); useEffect(() => { @@ -69,6 +71,16 @@ export function useKeyboardShortcuts() { toggleExplorerCollapsed(); break; } + + // Cmd+Shift+F -- Global search + case "f": { + if (e.shiftKey) { + e.preventDefault(); + setSearchOpen(!isSearchOpen); + } + // Plain Cmd+F is left to CodeMirror's built-in find/replace + break; + } } }; @@ -82,5 +94,7 @@ export function useKeyboardShortcuts() { closeFile, toggleExplorerCollapsed, createFolder, + isSearchOpen, + setSearchOpen, ]); } diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 5a46a54..1ef05ea 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -2,6 +2,13 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; import type { FileNode } from "../types/fileSystem"; +export interface SearchResult { + file_path: string; + file_name: string; + line_number: number; + content_preview: string; +} + interface AppState { files: FileNode[]; workspacePath: string | null; @@ -18,11 +25,21 @@ interface AppState { pendingFileId: string | null; isAutoSave: boolean; + // Search State + isSearchOpen: boolean; + searchQuery: string; + searchResults: SearchResult[]; + // Workspace Actions setWorkspacePath: (path: string | null) => void; setFiles: (files: FileNode[]) => void; resetWorkspace: () => void; + // Search Actions + setSearchOpen: (isOpen: boolean) => void; + setSearchQuery: (query: string) => void; + setSearchResults: (results: SearchResult[]) => void; + // Actions selectFile: (fileId: string) => void; openFileInNewTab: (fileId: string) => void; @@ -79,9 +96,17 @@ export const useAppStore = create((set, get) => ({ expandedFolderIds: [], isAutoSave: true, + isSearchOpen: false, + searchQuery: "", + searchResults: [], + // Workspace actions setWorkspacePath: (path: string | null) => set({ workspacePath: path }), + setSearchOpen: (isOpen: boolean) => set({ isSearchOpen: isOpen }), + setSearchQuery: (query: string) => set({ searchQuery: query }), + setSearchResults: (results: SearchResult[]) => set({ searchResults: results }), + setFiles: (files: FileNode[]) => set({ files, diff --git a/src/theme/cinderTheme.ts b/src/theme/cinderTheme.ts index b8081dc..dd89cdb 100644 --- a/src/theme/cinderTheme.ts +++ b/src/theme/cinderTheme.ts @@ -116,7 +116,83 @@ const cinderEditorTheme = EditorView.theme({ fontStyle: "italic", border: "1px solid rgba(255, 255, 255, 0.03)", borderLeft: "none" - } + }, + + /* ── Search / Find Panel (VS Code-style floating widget) ── */ + ".cm-panels": { + backgroundColor: "transparent !important", + border: "none !important", + }, + ".cm-panels-top": { + position: "absolute", + top: "0", + right: "16px", + left: "auto !important", + zIndex: "10", + border: "none !important", + }, + ".cm-search": { + backgroundColor: "var(--bg-secondary)", + border: "1px solid var(--border-secondary)", + borderTop: "none", + borderRadius: "0 0 8px 8px", + padding: "8px 12px !important", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", + display: "flex", + alignItems: "center", + gap: "8px", + width: "320px", + fontFamily: "'Space Grotesk', system-ui, Avenir, Helvetica, Arial, sans-serif", + }, + /* Hide everything except the find input and close button */ + ".cm-search br": { display: "none" }, + ".cm-search input[name=replace]": { display: "none" }, + ".cm-search button[name=replace]": { display: "none" }, + ".cm-search button[name=replaceAll]": { display: "none" }, + ".cm-search button[name=next]": { display: "none" }, + ".cm-search button[name=prev]": { display: "none" }, + ".cm-search button[name=select]": { display: "none" }, + ".cm-search label": { display: "none" }, + /* Find input */ + ".cm-search input.cm-textfield": { + backgroundColor: "var(--bg-primary)", + color: "var(--text-primary)", + border: "1px solid var(--border-secondary)", + borderRadius: "6px", + padding: "5px 10px", + fontSize: "13px", + fontFamily: "inherit", + outline: "none", + flex: "1", + minWidth: "0", + }, + ".cm-search input.cm-textfield:focus": { + borderColor: "var(--editor-header-accent)", + boxShadow: "0 0 0 2px var(--accent-glow, rgba(244, 140, 37, 0.15))", + }, + /* Close button */ + ".cm-search [name=close]": { + position: "static !important", + color: "var(--text-tertiary)", + fontSize: "16px", + padding: "2px 4px", + borderRadius: "4px", + cursor: "pointer", + flexShrink: "0", + }, + ".cm-search [name=close]:hover": { + color: "var(--text-primary)", + backgroundColor: "var(--bg-hover)", + }, + /* Search match highlight */ + ".cm-searchMatch": { + backgroundColor: "var(--editor-selection-bg)", + borderRadius: "2px", + outline: "1px solid var(--editor-header-accent)", + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: "var(--editor-header-accent)", + }, }, { dark: true }); // ── Syntax highlighting (markdown tokens) ──────────────────────────────────── diff --git a/src/theme/editor.css b/src/theme/editor.css index a84d5b1..0cc7c88 100644 --- a/src/theme/editor.css +++ b/src/theme/editor.css @@ -96,45 +96,7 @@ background: transparent; } -/* Search panel styling */ -.cm-editor .cm-panels { - background-color: var(--bg-secondary); - color: var(--text-primary); - border-bottom: 1px solid var(--border-primary); -} -.cm-editor .cm-panels .cm-button { - background-image: none; - background-color: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-secondary); - border-radius: 4px; - padding: 2px 8px; - font-size: 12px; -} - -.cm-editor .cm-panels .cm-textfield { - background-color: var(--bg-primary); - color: var(--text-primary); - border: 1px solid var(--border-secondary); - border-radius: 4px; - font-size: 13px; -} - -.cm-editor .cm-panels .cm-textfield:focus { - border-color: var(--editor-header-accent); - outline: none; -} - -/* Search match highlighting */ -.cm-editor .cm-searchMatch { - background-color: rgba(244, 140, 37, 0.3); - border-radius: 2px; -} - -.cm-editor .cm-searchMatch.cm-searchMatch-selected { - background-color: rgba(244, 140, 37, 0.6); -} /* Matching bracket highlighting */ .cm-editor.cm-focused .cm-matchingBracket { From 179ce0e4d29a798d474631f759e0db1866fa80e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:52:35 +0000 Subject: [PATCH 5/5] Fix duplicate SearchResult struct from merge Co-authored-by: 7sg56 <102475617+7sg56@users.noreply.github.com> --- src-tauri/src/commands.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ed99218..22db0ea 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -21,14 +21,6 @@ pub struct SearchResult { pub content_preview: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { - pub file_path: String, - pub file_name: String, - pub line_number: usize, - pub content_preview: String, -} - /// Scan a workspace directory and return the file tree /// /// # Arguments