diff --git a/package-lock.json b/package-lock.json index ec5a918..4e9d421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2656,7 +2656,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" @@ -3484,7 +3483,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": { @@ -6782,7 +6780,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 f400a4a..22db0ea 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,10 +6,12 @@ //! - Use descriptive error messages 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 { @@ -205,3 +207,37 @@ pub fn search_workspace(path: String, query: String) -> Result search_dir(workspace_path, workspace_path, &query_lower, &mut results); Ok(results) } + +/// 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 42a637f..49e1f0a 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, @@ -32,6 +34,8 @@ pub fn run() { commands::create_folder, commands::delete_folder, commands::search_workspace, + 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..8bd8026 --- /dev/null +++ b/src-tauri/src/watcher.rs @@ -0,0 +1,84 @@ +//! 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 mut watcher = notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + let is_structural_change = matches!( + event.kind, + EventKind::Create(_) + | EventKind::Remove(_) + | EventKind::Modify(notify::event::ModifyKind::Name(_)) + ); + if is_structural_change && !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))?; + + 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 c4524a2..84f36f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { SearchPanel } from './components/features/SearchPanel'; 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); @@ -14,6 +15,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..88ca7a7 --- /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]); +}