diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3bf05e3..342de0d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -350,6 +350,30 @@ pub enum BackendEvent { DocModifiedExternally { doc_id: DocId, new_mtime: DateTime }, /// Emitted when save status changes (for UI feedback) SaveStatusChanged { doc_id: DocId, status: SaveStatus }, + /// Emitted when the filesystem watcher detects file or directory changes. + FilesystemChanged { + location_id: LocationId, + entry_kind: FsEntryKind, + change_kind: FsChangeKind, + rel_path: PathBuf, + old_rel_path: Option, + }, +} + +/// Filesystem entry kind for watcher events +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum FsEntryKind { + File, + Directory, +} + +/// Filesystem change kind for watcher events +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum FsChangeKind { + Created, + Modified, + Deleted, + Renamed, } /// Save status for UI feedback loop diff --git a/docs/roadmap.md b/docs/roadmap.md index 6a88ec5..356e664 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -76,16 +76,11 @@ Migrate core application state and heavy computation to the Rust backend to redu ### Tasks -1. **Reactive File System** - - Leverage `RecommendedWatcher` to eliminate the need for manual frontend file list updates - - Implement event-driven UI updates based on backend file system events -2. **High-Performance Analysis** +1. **High-Performance Analysis** - Move `PatternMatcher` and `StyleCheck` logic to Rust using the `aho-corasick` crate - Offload heavy multi-pattern matching from the JS main thread -3. **Unified Metadata Extraction** +2. **Unified Metadata Extraction** - Calculate document metadata (word counts, outlines) during the `markdown_render` pass in Rust -4. **Architectural Hardening** - - Simplify and unify `CommandResult` and `AppError` patterns across all Tauri commands ## Hardening diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7d58be4..9efb80e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -14,7 +14,7 @@ use writer_core::{ use writer_md::{MarkdownEngine, MarkdownProfile, PdfRenderResult, RenderResult}; use writer_store::{Store, StyleCheckSettings, UiLayoutSettings}; -type Result = std::result::Result; +type CommandResponse = std::result::Result, AppError>; /// Application state shared across commands pub struct AppState { @@ -32,14 +32,16 @@ impl AppState { #[tauri::command] pub async fn location_add_via_dialog( app: AppHandle, state: State<'_, AppState>, -) -> Result> { +) -> CommandResponse { tracing::debug!("Opening folder picker dialog"); let folder_path = app.dialog().file().blocking_pick_folder(); match folder_path { Some(path) => { - let path_buf: PathBuf = path.into_path().map_err(|_| ())?; + let path_buf: PathBuf = path + .into_path() + .map_err(|_| AppError::invalid_path("Selected folder path is invalid"))?; tracing::info!("Folder selected: {:?}", path_buf); let name = path_buf @@ -80,7 +82,7 @@ pub async fn location_add_via_dialog( /// Lists all registered locations #[tauri::command] -pub fn location_list(state: State<'_, AppState>) -> Result>> { +pub fn location_list(state: State<'_, AppState>) -> CommandResponse> { tracing::debug!("Listing all locations"); match state.store.location_list() { @@ -97,7 +99,7 @@ pub fn location_list(state: State<'_, AppState>) -> Result, location_id: i64) -> Result> { +pub fn location_remove(state: State<'_, AppState>, location_id: i64) -> CommandResponse { let id = LocationId(location_id); tracing::info!("Removing location: id={}", location_id); @@ -123,7 +125,7 @@ pub fn location_remove(state: State<'_, AppState>, location_id: i64) -> Result) -> Result>> { +pub fn location_validate(state: State<'_, AppState>) -> CommandResponse> { tracing::debug!("Validating all locations"); match state.store.validate_locations() { @@ -149,7 +151,7 @@ pub fn location_validate(state: State<'_, AppState>) -> Result) -> Result> { +pub fn ui_layout_get(state: State<'_, AppState>) -> CommandResponse { tracing::debug!("Loading persisted UI layout settings"); match state.store.ui_layout_get() { @@ -162,7 +164,7 @@ pub fn ui_layout_get(state: State<'_, AppState>) -> Result, settings: UiLayoutSettings) -> Result> { +pub fn ui_layout_set(state: State<'_, AppState>, settings: UiLayoutSettings) -> CommandResponse { tracing::debug!("Persisting UI layout settings"); match state.store.ui_layout_set(&settings) { @@ -175,7 +177,7 @@ pub fn ui_layout_set(state: State<'_, AppState>, settings: UiLayoutSettings) -> } #[tauri::command] -pub fn session_last_doc_get(state: State<'_, AppState>) -> Result>> { +pub fn session_last_doc_get(state: State<'_, AppState>) -> CommandResponse> { tracing::debug!("Loading last opened document session state"); match state.store.last_open_doc_get() { @@ -190,7 +192,7 @@ pub fn session_last_doc_get(state: State<'_, AppState>) -> Result, doc_ref: Option, -) -> Result> { +) -> CommandResponse { tracing::debug!("Persisting last opened document session state"); match state.store.last_open_doc_set(doc_ref.as_ref()) { @@ -203,7 +205,7 @@ pub fn session_last_doc_set( } #[tauri::command] -pub fn session_get(state: State<'_, AppState>) -> Result> { +pub fn session_get(state: State<'_, AppState>) -> CommandResponse { tracing::debug!("Loading persisted session state"); match state.store.session_get() { @@ -218,7 +220,7 @@ pub fn session_get(state: State<'_, AppState>) -> Result, doc_ref: writer_store::CaptureDocRef, title: String, -) -> Result> { +) -> CommandResponse { tracing::debug!( "Opening session tab: location_id={}, rel_path={}", doc_ref.location_id, @@ -235,9 +237,7 @@ pub fn session_open_tab( } #[tauri::command] -pub fn session_select_tab( - state: State<'_, AppState>, tab_id: String, -) -> Result> { +pub fn session_select_tab(state: State<'_, AppState>, tab_id: String) -> CommandResponse { tracing::debug!("Selecting session tab: {}", tab_id); match state.store.session_select_tab(&tab_id) { @@ -250,9 +250,7 @@ pub fn session_select_tab( } #[tauri::command] -pub fn session_close_tab( - state: State<'_, AppState>, tab_id: String, -) -> Result> { +pub fn session_close_tab(state: State<'_, AppState>, tab_id: String) -> CommandResponse { tracing::debug!("Closing session tab: {}", tab_id); match state.store.session_close_tab(&tab_id) { @@ -267,7 +265,7 @@ pub fn session_close_tab( #[tauri::command] pub fn session_reorder_tabs( state: State<'_, AppState>, tab_ids: Vec, -) -> Result> { +) -> CommandResponse { tracing::debug!("Reordering session tabs: count={}", tab_ids.len()); match state.store.session_reorder_tabs(&tab_ids) { @@ -282,7 +280,7 @@ pub fn session_reorder_tabs( #[tauri::command] pub fn session_mark_tab_modified( state: State<'_, AppState>, tab_id: String, is_modified: bool, -) -> Result> { +) -> CommandResponse { tracing::debug!( "Marking session tab modified: tab_id={}, is_modified={}", tab_id, @@ -302,7 +300,7 @@ pub fn session_mark_tab_modified( pub fn session_update_tab_doc( state: State<'_, AppState>, location_id: i64, old_rel_path: String, new_doc_ref: writer_store::CaptureDocRef, title: String, -) -> Result> { +) -> CommandResponse { tracing::debug!( "Updating session tab document: location_id={}, old_rel_path={}, new_rel_path={}", location_id, @@ -325,7 +323,7 @@ pub fn session_update_tab_doc( #[tauri::command] pub fn session_drop_doc( state: State<'_, AppState>, location_id: i64, rel_path: String, -) -> Result> { +) -> CommandResponse { tracing::debug!( "Dropping document from session tabs: location_id={}, rel_path={}", location_id, @@ -344,7 +342,7 @@ pub fn session_drop_doc( #[tauri::command] pub fn session_prune_locations( state: State<'_, AppState>, valid_location_ids: Vec, -) -> Result> { +) -> CommandResponse { tracing::debug!("Pruning session tabs by locations: count={}", valid_location_ids.len()); let valid_ids: HashSet = valid_location_ids.into_iter().collect(); @@ -361,7 +359,7 @@ pub fn session_prune_locations( #[tauri::command] pub fn doc_list( state: State<'_, AppState>, location_id: i64, options: Option, -) -> Result>> { +) -> CommandResponse> { let id = LocationId(location_id); let list_options = Some(options.unwrap_or(DocListOptions { recursive: true, ..Default::default() })); tracing::debug!("Listing documents for location: id={}", location_id); @@ -380,7 +378,7 @@ pub fn doc_list( /// Opens a document by location_id and relative path #[tauri::command] -pub fn doc_open(state: State<'_, AppState>, location_id: i64, rel_path: String) -> Result> { +pub fn doc_open(state: State<'_, AppState>, location_id: i64, rel_path: String) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -415,7 +413,7 @@ pub fn doc_open(state: State<'_, AppState>, location_id: i64, rel_path: String) #[tauri::command] pub fn doc_save( app: AppHandle, state: State<'_, AppState>, location_id: i64, rel_path: String, text: String, -) -> Result> { +) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -483,7 +481,7 @@ pub fn doc_save( /// Checks if a document exists in a location #[tauri::command] -pub fn doc_exists(state: State<'_, AppState>, location_id: i64, rel_path: String) -> Result> { +pub fn doc_exists(state: State<'_, AppState>, location_id: i64, rel_path: String) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -523,7 +521,7 @@ pub fn doc_exists(state: State<'_, AppState>, location_id: i64, rel_path: String #[tauri::command] pub fn doc_rename( state: State<'_, AppState>, location_id: i64, rel_path: String, new_name: String, -) -> Result> { +) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -559,7 +557,7 @@ pub fn doc_rename( #[tauri::command] pub fn doc_move( state: State<'_, AppState>, location_id: i64, rel_path: String, new_rel_path: String, -) -> Result> { +) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); let new_rel_path = PathBuf::from(&new_rel_path); @@ -594,7 +592,7 @@ pub fn doc_move( /// Deletes a document from disk and removes it from the index #[tauri::command] -pub fn doc_delete(state: State<'_, AppState>, location_id: i64, rel_path: String) -> Result> { +pub fn doc_delete(state: State<'_, AppState>, location_id: i64, rel_path: String) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -627,7 +625,7 @@ pub fn doc_delete(state: State<'_, AppState>, location_id: i64, rel_path: String /// Creates a directory at a relative path within a location #[tauri::command] -pub fn dir_create(state: State<'_, AppState>, location_id: i64, rel_path: String) -> Result> { +pub fn dir_create(state: State<'_, AppState>, location_id: i64, rel_path: String) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -646,7 +644,7 @@ pub fn dir_create(state: State<'_, AppState>, location_id: i64, rel_path: String #[tauri::command] pub fn dir_rename( state: State<'_, AppState>, location_id: i64, rel_path: String, new_name: String, -) -> Result> { +) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -670,7 +668,7 @@ pub fn dir_rename( #[tauri::command] pub fn dir_move( state: State<'_, AppState>, location_id: i64, rel_path: String, new_rel_path: String, -) -> Result> { +) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); let new_rel_path = PathBuf::from(&new_rel_path); @@ -693,7 +691,7 @@ pub fn dir_move( /// Deletes a directory and all indexed documents beneath it #[tauri::command] -pub fn dir_delete(state: State<'_, AppState>, location_id: i64, rel_path: String) -> Result> { +pub fn dir_delete(state: State<'_, AppState>, location_id: i64, rel_path: String) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -710,7 +708,7 @@ pub fn dir_delete(state: State<'_, AppState>, location_id: i64, rel_path: String /// Enables filesystem watching for a location and reindexes changed files. #[tauri::command] -pub fn watch_enable(app: AppHandle, state: State<'_, AppState>, location_id: i64) -> Result> { +pub fn watch_enable(app: AppHandle, state: State<'_, AppState>, location_id: i64) -> CommandResponse { let location_id_wrapped = LocationId(location_id); let location = match state.store.location_get(location_id_wrapped) { @@ -768,7 +766,7 @@ pub fn watch_enable(app: AppHandle, state: State<'_, AppState>, location_id: i64 /// Disables filesystem watching for a location. #[tauri::command] -pub fn watch_disable(state: State<'_, AppState>, location_id: i64) -> Result> { +pub fn watch_disable(state: State<'_, AppState>, location_id: i64) -> CommandResponse { let mut watchers = match state.watchers.lock() { Ok(guard) => guard, Err(_) => return Ok(CommandResult::err(AppError::io("Failed to lock watchers map"))), @@ -781,7 +779,7 @@ pub fn watch_disable(state: State<'_, AppState>, location_id: i64) -> Result, query: String, filters: Option, limit: Option, -) -> Result>> { +) -> CommandResponse> { let limit = limit.unwrap_or(50); match state.store.search(&query, filters, limit) { @@ -797,7 +795,7 @@ pub fn search( #[tauri::command] pub fn markdown_render( _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, -) -> Result> { +) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -838,7 +836,7 @@ pub fn markdown_render( #[tauri::command] pub fn markdown_render_for_pdf( _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, -) -> Result> { +) -> CommandResponse { let location_id = LocationId(location_id); let rel_path = PathBuf::from(&rel_path); @@ -873,7 +871,7 @@ pub fn markdown_render_for_pdf( } #[tauri::command] -pub fn style_check_get(state: State<'_, AppState>) -> Result> { +pub fn style_check_get(state: State<'_, AppState>) -> CommandResponse { tracing::debug!("Loading persisted style check settings"); match state.store.style_check_get() { @@ -886,7 +884,7 @@ pub fn style_check_get(state: State<'_, AppState>) -> Result, settings: StyleCheckSettings) -> Result> { +pub fn style_check_set(state: State<'_, AppState>, settings: StyleCheckSettings) -> CommandResponse { tracing::debug!("Persisting style check settings"); match state.store.style_check_set(&settings) { @@ -900,7 +898,7 @@ pub fn style_check_set(state: State<'_, AppState>, settings: StyleCheckSettings) /// Gets global capture settings #[tauri::command] -pub fn global_capture_get(state: State<'_, AppState>) -> Result> { +pub fn global_capture_get(state: State<'_, AppState>) -> CommandResponse { tracing::debug!("Loading global capture settings"); match state.store.global_capture_get() { @@ -916,7 +914,7 @@ pub fn global_capture_get(state: State<'_, AppState>) -> Result, settings: writer_store::GlobalCaptureSettings, -) -> Result> { +) -> CommandResponse { tracing::debug!("Persisting global capture settings"); if let Err(e) = capture::validate_shortcut_format(&settings.shortcut) { @@ -940,7 +938,7 @@ pub fn global_capture_set( /// Opens the quick capture window #[tauri::command] -pub fn global_capture_open(app: AppHandle) -> Result> { +pub fn global_capture_open(app: AppHandle) -> CommandResponse { tracing::debug!("Opening quick capture window"); match capture::show_quick_capture_window(&app) { @@ -957,7 +955,7 @@ pub fn global_capture_open(app: AppHandle) -> Result> { pub async fn global_capture_submit( app: AppHandle, state: State<'_, AppState>, mode: writer_store::CaptureMode, text: String, destination: Option, open_main_after_save: Option, -) -> Result> { +) -> CommandResponse { tracing::debug!("Submitting capture: mode={:?}, text_len={}", mode, text.len()); let settings = match state.store.global_capture_get() { @@ -1006,7 +1004,7 @@ pub async fn global_capture_submit( /// Pauses or resumes the global shortcut #[tauri::command] -pub fn global_capture_pause(app: AppHandle, state: State<'_, AppState>, paused: bool) -> Result> { +pub fn global_capture_pause(app: AppHandle, state: State<'_, AppState>, paused: bool) -> CommandResponse { tracing::debug!("Setting global capture pause state: {}", paused); let mut settings = match state.store.global_capture_get() { @@ -1033,7 +1031,7 @@ pub fn global_capture_pause(app: AppHandle, state: State<'_, AppState>, paused: /// Validates a shortcut format #[tauri::command] -pub fn global_capture_validate_shortcut(shortcut: String) -> Result> { +pub fn global_capture_validate_shortcut(shortcut: String) -> CommandResponse { tracing::debug!("Validating shortcut: {}", shortcut); match capture::validate_shortcut_format(&shortcut) { @@ -1044,7 +1042,7 @@ pub fn global_capture_validate_shortcut(shortcut: String) -> Result Result> { +pub fn markdown_help_get() -> CommandResponse { tracing::debug!("Fetching markdown help content"); Ok(CommandResult::ok(writer_store::get_markdown_help().to_string())) } diff --git a/src-tauri/src/locations.rs b/src-tauri/src/locations.rs index a71ec2b..7c91d1b 100644 --- a/src-tauri/src/locations.rs +++ b/src-tauri/src/locations.rs @@ -1,12 +1,17 @@ use super::AppState; +use notify::event::{ModifyKind, RemoveKind}; use notify::{Event, EventKind}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_fs::FsExt; -use writer_core::{AppError, BackendEvent, DocId, LocationDescriptor, LocationId}; +use writer_core::{AppError, BackendEvent, DocId, FsChangeKind, FsEntryKind, LocationDescriptor, LocationId}; use writer_store::Store; +fn should_process_watcher_event(kind: &EventKind) -> bool { + matches!(kind, EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)) +} + pub(super) fn emit_doc_modified_event(app: &AppHandle, doc_id: DocId, mtime: chrono::DateTime) { let event = BackendEvent::DocModifiedExternally { doc_id, new_mtime: mtime }; if let Err(error) = app.emit("backend-event", event) { @@ -14,25 +19,184 @@ pub(super) fn emit_doc_modified_event(app: &AppHandle, doc_id: DocId, mtime: chr } } -pub(super) fn handle_watcher_event( - app: &AppHandle, store: &Arc, location_id: LocationId, root_path: &PathBuf, event: Event, +fn relative_path(root_path: &Path, path: &Path) -> Option { + match path.strip_prefix(root_path) { + Ok(rel_path) if !rel_path.as_os_str().is_empty() => Some(rel_path.to_path_buf()), + _ => None, + } +} + +fn emit_filesystem_changed_event( + app: &AppHandle, location_id: LocationId, entry_kind: FsEntryKind, change_kind: FsChangeKind, rel_path: PathBuf, + old_rel_path: Option, ) { - let should_process = matches!( - event.kind, - EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + let event = BackendEvent::FilesystemChanged { location_id, entry_kind, change_kind, rel_path, old_rel_path }; + if let Err(error) = app.emit("backend-event", event) { + tracing::error!("Failed to emit FilesystemChanged event: {}", error); + } +} + +fn change_kind_from_event_kind(kind: &EventKind) -> FsChangeKind { + match kind { + EventKind::Create(_) => FsChangeKind::Created, + EventKind::Remove(_) => FsChangeKind::Deleted, + EventKind::Modify(ModifyKind::Name(_)) => FsChangeKind::Renamed, + EventKind::Modify(_) => FsChangeKind::Modified, + _ => FsChangeKind::Modified, + } +} + +fn remove_document_from_index_if_present(store: &Store, doc_id: &DocId, path: &Path) { + if let Err(error) = store.remove_document_from_index(doc_id) { + tracing::error!("Failed to remove deleted file {:?} from index: {}", path, error); + } +} + +fn reindex_document_and_emit( + app: &AppHandle, store: &Store, location_id: LocationId, path: &Path, doc_id: DocId, change_kind: FsChangeKind, +) { + match store.reindex_document(&doc_id) { + Ok(()) => { + let new_mtime = std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .map(chrono::DateTime::::from) + .unwrap_or_else(|_| chrono::Utc::now()); + + emit_doc_modified_event(app, doc_id.clone(), new_mtime); + emit_filesystem_changed_event( + app, + location_id, + FsEntryKind::File, + change_kind, + doc_id.rel_path.clone(), + None, + ); + } + Err(error) => { + tracing::error!("Failed to reindex changed file {:?}: {}", path, error); + } + } +} + +fn reconcile_directory_index_and_emit( + app: &AppHandle, store: &Store, location_id: LocationId, rel_path: PathBuf, change_kind: FsChangeKind, + old_rel_path: Option, +) { + if let Err(error) = store.reconcile_location_index(location_id) { + tracing::error!( + "Failed to reconcile index after directory change {:?} in location {:?}: {}", + rel_path, + location_id, + error + ); + return; + } + + emit_filesystem_changed_event( + app, + location_id, + FsEntryKind::Directory, + change_kind, + rel_path, + old_rel_path, ); +} + +fn handle_rename_event(app: &AppHandle, store: &Store, location_id: LocationId, root_path: &Path, paths: &[PathBuf]) { + if paths.len() < 2 { + return; + } + + let from_path = &paths[0]; + let to_path = &paths[1]; + + let Some(old_rel_path) = relative_path(root_path, from_path) else { + return; + }; + let Some(new_rel_path) = relative_path(root_path, to_path) else { + return; + }; - if !should_process { + let is_directory_rename = to_path.is_dir() || from_path.is_dir(); + if is_directory_rename { + reconcile_directory_index_and_emit( + app, + store, + location_id, + new_rel_path, + FsChangeKind::Renamed, + Some(old_rel_path), + ); return; } + let old_doc_id = match DocId::new(location_id, old_rel_path.clone()) { + Ok(doc_id) => doc_id, + Err(error) => { + tracing::warn!("Ignoring watcher rename source path that failed validation: {}", error); + return; + } + }; + let new_doc_id = match DocId::new(location_id, new_rel_path.clone()) { + Ok(doc_id) => doc_id, + Err(error) => { + tracing::warn!( + "Ignoring watcher rename destination path that failed validation: {}", + error + ); + return; + } + }; + + remove_document_from_index_if_present(store, &old_doc_id, from_path); + match store.reindex_document(&new_doc_id) { + Ok(()) => { + let new_mtime = std::fs::metadata(to_path) + .and_then(|metadata| metadata.modified()) + .map(chrono::DateTime::::from) + .unwrap_or_else(|_| chrono::Utc::now()); + emit_doc_modified_event(app, new_doc_id.clone(), new_mtime); + emit_filesystem_changed_event( + app, + location_id, + FsEntryKind::File, + FsChangeKind::Renamed, + new_doc_id.rel_path.clone(), + Some(old_doc_id.rel_path.clone()), + ); + } + Err(error) => { + tracing::error!("Failed to reindex renamed file {:?}: {}", to_path, error); + } + } +} + +pub(super) fn handle_watcher_event( + app: &AppHandle, store: &Arc, location_id: LocationId, root_path: &Path, event: Event, +) { + if !should_process_watcher_event(&event.kind) { + return; + } + + if matches!(event.kind, EventKind::Modify(ModifyKind::Name(_))) { + handle_rename_event(app, store, location_id, root_path, &event.paths); + return; + } + + let change_kind = change_kind_from_event_kind(&event.kind); + for path in event.paths { - let rel_path = match path.strip_prefix(root_path) { - Ok(rel_path) if !rel_path.as_os_str().is_empty() => rel_path.to_path_buf(), - _ => continue, + let rel_path = match relative_path(root_path, &path) { + Some(rel_path) => rel_path, + None => continue, }; - let doc_id = match DocId::new(location_id, rel_path) { + if path.exists() && path.is_dir() { + reconcile_directory_index_and_emit(app, store, location_id, rel_path, change_kind, None); + continue; + } + + let doc_id = match DocId::new(location_id, rel_path.clone()) { Ok(doc_id) => doc_id, Err(error) => { tracing::warn!("Ignoring watcher path that failed validation: {}", error); @@ -41,27 +205,27 @@ pub(super) fn handle_watcher_event( }; if path.exists() && path.is_file() { - match store.reindex_document(&doc_id) { - Ok(()) => { - let new_mtime = std::fs::metadata(&path) - .and_then(|metadata| metadata.modified()) - .map(chrono::DateTime::::from) - .unwrap_or_else(|_| chrono::Utc::now()); - emit_doc_modified_event(app, doc_id, new_mtime); - } - Err(error) => { - tracing::error!("Failed to reindex changed file {:?}: {}", path, error); - } - } - } else if !path.exists() { - match store.remove_document_from_index(&doc_id) { - Ok(()) => { - emit_doc_modified_event(app, doc_id, chrono::Utc::now()); - } - Err(error) => { - tracing::error!("Failed to remove deleted file {:?} from index: {}", path, error); - } + reindex_document_and_emit(app, store, location_id, &path, doc_id, change_kind); + continue; + } + + if !path.exists() { + let is_directory_delete = matches!(event.kind, EventKind::Remove(RemoveKind::Folder)); + if is_directory_delete { + reconcile_directory_index_and_emit(app, store, location_id, rel_path, FsChangeKind::Deleted, None); + continue; } + + remove_document_from_index_if_present(store, &doc_id, &path); + emit_doc_modified_event(app, doc_id.clone(), chrono::Utc::now()); + emit_filesystem_changed_event( + app, + location_id, + FsEntryKind::File, + FsChangeKind::Deleted, + doc_id.rel_path.clone(), + None, + ); } } } @@ -226,3 +390,42 @@ pub fn reconcile(app: &AppHandle) -> Result<(), AppError> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode}; + + #[test] + fn watcher_filter_allows_create_modify_remove() { + assert!(should_process_watcher_event(&EventKind::Create(CreateKind::Any))); + assert!(should_process_watcher_event(&EventKind::Modify(ModifyKind::Any))); + assert!(should_process_watcher_event(&EventKind::Remove(RemoveKind::Any))); + } + + #[test] + fn watcher_filter_ignores_non_mutating_events() { + assert!(!should_process_watcher_event(&EventKind::Any)); + assert!(!should_process_watcher_event(&EventKind::Other)); + } + + #[test] + fn maps_event_kinds_to_fs_change_kind() { + assert_eq!( + change_kind_from_event_kind(&EventKind::Create(CreateKind::Any)), + FsChangeKind::Created + ); + assert_eq!( + change_kind_from_event_kind(&EventKind::Modify(ModifyKind::Any)), + FsChangeKind::Modified + ); + assert_eq!( + change_kind_from_event_kind(&EventKind::Modify(ModifyKind::Name(RenameMode::Any))), + FsChangeKind::Renamed + ); + assert_eq!( + change_kind_from_event_kind(&EventKind::Remove(RemoveKind::Any)), + FsChangeKind::Deleted + ); + } +} diff --git a/src/__tests__/Sidebar.test.tsx b/src/__tests__/Sidebar.test.tsx index a6d93b7..3e61af5 100644 --- a/src/__tests__/Sidebar.test.tsx +++ b/src/__tests__/Sidebar.test.tsx @@ -103,12 +103,12 @@ describe("Sidebar", () => { it("shows refresh feedback for the selected location and disables refresh action", () => { vi.mocked(useSidebarState).mockReturnValue( - createSidebarState({ refreshingLocationId: 1, sidebarRefreshReason: "save" }), + createSidebarState({ refreshingLocationId: 1, sidebarRefreshReason: "external" }), ); render(); - expect(screen.getByText("Updating after save...")).toBeInTheDocument(); + expect(screen.getByText("Applying external file changes...")).toBeInTheDocument(); expect(screen.getByTitle("Refresh Sidebar")).toBeDisabled(); }); diff --git a/src/__tests__/stores/app.test.ts b/src/__tests__/stores/app.test.ts index 63e3d68..441a1ea 100644 --- a/src/__tests__/stores/app.test.ts +++ b/src/__tests__/stores/app.test.ts @@ -150,6 +150,7 @@ describe("appStore", () => { act(() => { locationsActions.current.setLoadingLocations(false); locationsActions.current.setSidebarFilter("draft"); + locationsActions.current.setLocations([{ id: 9, name: "N", root_path: "/n", added_at: "2024-01-01" }]); documentsActions.current.setDocuments([{ location_id: 1, rel_path: "a.md", @@ -158,8 +159,6 @@ describe("appStore", () => { word_count: 10, }]); documentsActions.current.setLoadingDocuments(true); - locationsActions.current.addLocation({ id: 9, name: "N", root_path: "/n", added_at: "2024-01-01" }); - locationsActions.current.removeLocation(9); }); expect(locationsState.current.isLoadingLocations).toBeFalsy(); @@ -172,7 +171,12 @@ describe("appStore", () => { word_count: 10, }]); expect(documentsState.current.isLoadingDocuments).toBeTruthy(); - expect(locationsState.current.locations).toStrictEqual([]); + expect(locationsState.current.locations).toStrictEqual([{ + id: 9, + name: "N", + root_path: "/n", + added_at: "2024-01-01", + }]); }); it("tabs selector hooks apply backend session state", () => { diff --git a/src/__tests__/useBackendEvents.test.tsx b/src/__tests__/useBackendEvents.test.tsx index 1e3a886..5be838a 100644 --- a/src/__tests__/useBackendEvents.test.tsx +++ b/src/__tests__/useBackendEvents.test.tsx @@ -20,6 +20,32 @@ describe(useBackendEvents, () => { expect(onLocationMissing).toHaveBeenCalledWith(42, "/missing/path"); }); + it("invokes FilesystemChanged callback", () => { + const onFilesystemChanged = vi.fn(); + + renderHook(() => useBackendEvents({ onFilesystemChanged })); + + act(() => { + emitBackendEvent({ + type: "FilesystemChanged", + location_id: 42, + entry_kind: "File", + change_kind: "Renamed", + rel_path: "new.md", + old_rel_path: "old.md", + }); + }); + + expect(onFilesystemChanged).toHaveBeenCalledWith({ + type: "FilesystemChanged", + location_id: 42, + entry_kind: "File", + change_kind: "Renamed", + rel_path: "new.md", + old_rel_path: "old.md", + }); + }); + it("invokes latest callback after rerender", () => { const onLocationMissing1 = vi.fn(); const onLocationMissing2 = vi.fn(); diff --git a/src/__tests__/useWorkspaceController.test.tsx b/src/__tests__/useWorkspaceController.test.tsx index fdf8ace..6787175 100644 --- a/src/__tests__/useWorkspaceController.test.tsx +++ b/src/__tests__/useWorkspaceController.test.tsx @@ -1,5 +1,14 @@ import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; -import { docList, runCmd, sessionGet, sessionPruneLocations } from "$ports"; +import { + docDelete, + docList, + docRename, + runCmd, + sessionDropDoc, + sessionGet, + sessionPruneLocations, + sessionUpdateTabDoc, +} from "$ports"; import { resetAppStore, useAppStore } from "$state/stores/app"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -15,6 +24,7 @@ vi.mock( docMove: vi.fn(() => ({ type: "None" })), docRename: vi.fn(() => ({ type: "None" })), locationAddViaDialog: vi.fn(() => ({ type: "None" })), + locationList: vi.fn(() => ({ type: "None" })), locationRemove: vi.fn(() => ({ type: "None" })), sessionGet: vi.fn((_onOk: (session: { tabs: unknown[]; activeTabId: string | null }) => void) => ({ type: "None", @@ -65,4 +75,61 @@ describe("useWorkspaceController", () => { expect(docList).toHaveBeenCalledWith(1, expect.any(Function), expect.any(Function)); expect(runCmd).toHaveBeenCalled(); }); + + it("does not patch documents in JS after rename", async () => { + const originalDoc = { + location_id: 1, + rel_path: "draft.md", + title: "Draft", + updated_at: "2024-01-01T00:00:00Z", + word_count: 10, + }; + useAppStore.getState().setDocuments([originalDoc]); + vi.mocked(docRename).mockImplementation((_locationId, _relPath, _newName, onOk) => { + onOk({ ...originalDoc, rel_path: "renamed.md", title: "Renamed" }); + return { type: "None" }; + }); + + const { result } = renderHook(() => useWorkspaceController()); + + const renamed = await act(async () => { + return await result.current.handleRenameDocument(1, "draft.md", "renamed.md"); + }); + + expect(renamed).toBeTruthy(); + expect(useAppStore.getState().documents).toStrictEqual([originalDoc]); + expect(sessionUpdateTabDoc).toHaveBeenCalledWith( + 1, + "draft.md", + { location_id: 1, rel_path: "renamed.md" }, + "Renamed", + expect.any(Function), + expect.any(Function), + ); + }); + + it("does not patch documents in JS after delete", async () => { + const originalDoc = { + location_id: 1, + rel_path: "delete-me.md", + title: "Delete me", + updated_at: "2024-01-01T00:00:00Z", + word_count: 8, + }; + useAppStore.getState().setDocuments([originalDoc]); + vi.mocked(docDelete).mockImplementation((_locationId, _relPath, onOk) => { + onOk(true); + return { type: "None" }; + }); + + const { result } = renderHook(() => useWorkspaceController()); + + const deleted = await act(async () => { + return await result.current.handleDeleteDocument(1, "delete-me.md"); + }); + + expect(deleted).toBeTruthy(); + expect(useAppStore.getState().documents).toStrictEqual([originalDoc]); + expect(sessionDropDoc).toHaveBeenCalledWith(1, "delete-me.md", expect.any(Function), expect.any(Function)); + }); }); diff --git a/src/__tests__/useWorkspaceSync.test.tsx b/src/__tests__/useWorkspaceSync.test.tsx new file mode 100644 index 0000000..45101df --- /dev/null +++ b/src/__tests__/useWorkspaceSync.test.tsx @@ -0,0 +1,95 @@ +import { useWorkspaceSync } from "$hooks/useWorkspaceSync"; +import { docList, locationList, runCmd, startWatch, stopWatch } from "$ports"; +import { resetAppStore, useAppStore } from "$state/stores/app"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearMockListeners, emitBackendEvent } from "./setup"; + +const LOCATIONS = [{ id: 1, name: "One", root_path: "/one", added_at: "2024-01-01" }, { + id: 2, + name: "Two", + root_path: "/two", + added_at: "2024-01-01", +}]; + +vi.mock( + "$ports", + () => ({ + runCmd: vi.fn(async () => {}), + locationList: vi.fn((onOk: (locations: typeof LOCATIONS) => void) => { + onOk(LOCATIONS); + return { type: "None" }; + }), + docList: vi.fn((locationId: number, onOk: (docs: unknown[]) => void) => { + onOk([{ + location_id: locationId, + rel_path: `${locationId}.md`, + title: `${locationId}`, + updated_at: "2024-01-01T00:00:00Z", + word_count: 1, + }]); + return { type: "None" }; + }), + startWatch: vi.fn((locationId: number) => ({ type: "StartWatch", locationId })), + stopWatch: vi.fn((locationId: number) => ({ type: "StopWatch", locationId })), + }), +); + +describe("useWorkspaceSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearMockListeners(); + resetAppStore(); + }); + + it("starts filesystem watchers for all loaded locations", async () => { + renderHook(() => useWorkspaceSync()); + + await waitFor(() => { + expect(locationList).toHaveBeenCalled(); + expect(startWatch).toHaveBeenCalledTimes(2); + }); + + expect(startWatch).toHaveBeenNthCalledWith(1, 1); + expect(startWatch).toHaveBeenNthCalledWith(2, 2); + expect(runCmd).toHaveBeenCalled(); + }); + + it("reloads selected location documents when backend emits filesystem changes", async () => { + renderHook(() => useWorkspaceSync()); + + await waitFor(() => { + expect(docList).toHaveBeenCalledWith(1, expect.any(Function), expect.any(Function)); + }); + + act(() => { + emitBackendEvent({ + type: "FilesystemChanged", + location_id: 1, + entry_kind: "File", + change_kind: "Modified", + rel_path: "1.md", + }); + }); + + await waitFor(() => { + expect(vi.mocked(docList).mock.calls.filter(([locationId]) => locationId === 1).length).toBeGreaterThan(1); + }); + }); + + it("stops watchers for removed locations", async () => { + renderHook(() => useWorkspaceSync()); + + await waitFor(() => { + expect(startWatch).toHaveBeenCalledTimes(2); + }); + + act(() => { + useAppStore.getState().setLocations([LOCATIONS[0]]); + }); + + await waitFor(() => { + expect(stopWatch).toHaveBeenCalledWith(2); + }); + }); +}); diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx index 8eb4e53..e1462a4 100644 --- a/src/components/Sidebar/SidebarLocationItem.tsx +++ b/src/components/Sidebar/SidebarLocationItem.tsx @@ -169,7 +169,7 @@ const LocationActions = ({ isMenuOpen, handleMenuClick, handleRemoveClick }: Loc const RefreshStatus = ({ reason }: { reason: SidebarRefreshReason | null }) => (
- {reason === "save" ? "Updating after save..." : "Refreshing files..."} + {reason === "external" ? "Applying external file changes..." : "Refreshing files..."}
); diff --git a/src/hooks/app/useEditorPreviewEffects.ts b/src/hooks/app/useEditorPreviewEffects.ts index 795db46..d57670b 100644 --- a/src/hooks/app/useEditorPreviewEffects.ts +++ b/src/hooks/app/useEditorPreviewEffects.ts @@ -1,6 +1,5 @@ -import type { SidebarRefreshReason } from "$state/types"; import type { DocRef, SaveStatus, Tab } from "$types"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; type UseEditorPreviewEffectsArgs = { activeTab: Tab | null; @@ -9,28 +8,15 @@ type UseEditorPreviewEffectsArgs = { markActiveTabModified: (isModified: boolean) => void; setPreviewDoc: (docRef: DocRef | null) => void; renderPreview: (docRef: DocRef, text: string) => void; - handleRefreshSidebar: (locationId?: number, options?: { source?: SidebarRefreshReason }) => void; }; export function useEditorPreviewEffects( - { activeTab, text, saveStatus, markActiveTabModified, setPreviewDoc, renderPreview, handleRefreshSidebar }: - UseEditorPreviewEffectsArgs, + { activeTab, text, saveStatus, markActiveTabModified, setPreviewDoc, renderPreview }: UseEditorPreviewEffectsArgs, ): void { - const previousSaveStatusRef = useRef(saveStatus); - useEffect(() => { markActiveTabModified(saveStatus === "Dirty"); }, [saveStatus, markActiveTabModified]); - useEffect(() => { - const previousSaveStatus = previousSaveStatusRef.current; - if (previousSaveStatus === "Saving" && saveStatus === "Saved") { - handleRefreshSidebar(activeTab?.docRef.location_id, { source: "save" }); - } - - previousSaveStatusRef.current = saveStatus; - }, [activeTab, saveStatus, handleRefreshSidebar]); - useEffect(() => { if (activeTab) { setPreviewDoc(activeTab.docRef); diff --git a/src/hooks/controllers/useWorkspaceController.ts b/src/hooks/controllers/useWorkspaceController.ts index c867608..3aeb406 100644 --- a/src/hooks/controllers/useWorkspaceController.ts +++ b/src/hooks/controllers/useWorkspaceController.ts @@ -5,6 +5,7 @@ import { docMove, docRename, locationAddViaDialog, + locationList, locationRemove, runCmd, sessionCloseTab, @@ -68,7 +69,7 @@ export function useWorkspaceController() { const { selectedDocPath, documents, isLoadingDocuments, refreshingLocationId, sidebarRefreshReason } = useWorkspaceDocumentsState(); const { setSidebarRefreshState } = useWorkspaceDocumentsActions(); - const { setSidebarFilter, setSelectedLocation, addLocation, removeLocation } = useWorkspaceLocationsActions(); + const { setSidebarFilter, setSelectedLocation, setLocations } = useWorkspaceLocationsActions(); const { tabs, activeTabId, isSessionHydrated } = useTabsState(); const { applySessionState } = useTabsActions(); const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [activeTabId, tabs]); @@ -100,23 +101,34 @@ export function useWorkspaceController() { [documents, selectedLocationId], ); + const refreshLocations = useCallback((nextSelectedLocationId?: number) => { + runCmd(locationList((nextLocations) => { + setLocations(nextLocations); + if (nextSelectedLocationId && nextLocations.some((location) => location.id === nextSelectedLocationId)) { + setSelectedLocation(nextSelectedLocationId); + } + }, (error) => { + logger.error(f("Failed to refresh locations", { error })); + })); + }, [setLocations, setSelectedLocation]); + const handleAddLocation = useCallback(() => { runCmd(locationAddViaDialog((location) => { - addLocation(location); + refreshLocations(location.id); }, (error) => { logger.error(f("Failed to add location", { error })); })); - }, [addLocation]); + }, [refreshLocations]); const handleRemoveLocation = useCallback((locationId: number) => { runCmd(locationRemove(locationId, (removed) => { if (removed) { - removeLocation(locationId); + refreshLocations(); } }, (error) => { logger.error(f("Failed to remove location", { locationId, error })); })); - }, [removeLocation]); + }, [refreshLocations]); const openTab = useCallback((docRef: DocRef, title: string) => { void runCmd(sessionOpenTab(docRef, title, applySession, (error) => { @@ -240,12 +252,6 @@ export function useWorkspaceController() { const handleRenameDocument = useCallback((locationId: number, relPath: string, newName: string): Promise => { return new Promise((resolve) => { runCmd(docRename(locationId, relPath, newName, (newMeta) => { - const workspaceState = useWorkspaceStore.getState(); - const updatedDocuments = workspaceState.documents.map((doc) => - doc.location_id === locationId && doc.rel_path === relPath ? newMeta : doc - ); - workspaceState.setDocuments(updatedDocuments); - void runCmd( sessionUpdateTabDoc( locationId, @@ -270,12 +276,6 @@ export function useWorkspaceController() { (locationId: number, relPath: string, newRelPath: string): Promise => { return new Promise((resolve) => { runCmd(docMove(locationId, relPath, newRelPath, (newMeta) => { - const workspaceState = useWorkspaceStore.getState(); - const updatedDocuments = workspaceState.documents.map((doc) => - doc.location_id === locationId && doc.rel_path === relPath ? newMeta : doc - ); - workspaceState.setDocuments(updatedDocuments); - void runCmd( sessionUpdateTabDoc( locationId, @@ -306,12 +306,6 @@ export function useWorkspaceController() { return; } - const workspaceState = useWorkspaceStore.getState(); - const updatedDocuments = workspaceState.documents.filter((doc) => - !(doc.location_id === locationId && doc.rel_path === relPath) - ); - workspaceState.setDocuments(updatedDocuments); - void runCmd(sessionDropDoc(locationId, relPath, applySession, () => {})); logger.info(f("Document deleted", { locationId, relPath })); diff --git a/src/hooks/controllers/useWorkspaceViewController.ts b/src/hooks/controllers/useWorkspaceViewController.ts index def4e44..c8c3863 100644 --- a/src/hooks/controllers/useWorkspaceViewController.ts +++ b/src/hooks/controllers/useWorkspaceViewController.ts @@ -162,7 +162,6 @@ export function useWorkspaceViewController(): WorkspaceViewController { markActiveTabModified, setPreviewDoc, renderPreview, - handleRefreshSidebar, }); useSettingsSync(); diff --git a/src/hooks/useBackendEvents.ts b/src/hooks/useBackendEvents.ts index 2e94967..8ed4e80 100644 --- a/src/hooks/useBackendEvents.ts +++ b/src/hooks/useBackendEvents.ts @@ -16,6 +16,15 @@ export type UseBackendEventsOptions = { onLocationChanged?: (locationId: LocationId, oldPath: string, newPath: string) => void; onReconciliationComplete?: (checked: number, missing: LocationId[]) => void; onDocModifiedExternally?: (docRef: { location_id: LocationId; rel_path: string }) => void; + onFilesystemChanged?: ( + event: { + location_id: LocationId; + entry_kind: "File" | "Directory"; + change_kind: "Created" | "Modified" | "Deleted" | "Renamed"; + rel_path: string; + old_rel_path?: string | null; + }, + ) => void; }; const MAX_ALERT_ITEMS = 100; @@ -96,6 +105,18 @@ function handleBackendEvent(payload: BackendEvent): void { logger.info(f("Save status changed", { docId: payload.doc_id, status: payload.status })); break; } + case "FilesystemChanged": { + logger.info( + f("Filesystem changed", { + locationId: payload.location_id, + entryKind: payload.entry_kind, + changeKind: payload.change_kind, + relPath: payload.rel_path, + oldRelPath: payload.old_rel_path, + }), + ); + break; + } } for (const subscriber of eventSubscribers) { @@ -183,6 +204,9 @@ export function useBackendEvents(options: UseBackendEventsOptions = {}): Backend case "DocModifiedExternally": optionsRef.current.onDocModifiedExternally?.(payload.doc_id); break; + case "FilesystemChanged": + optionsRef.current.onFilesystemChanged?.(payload); + break; default: break; } diff --git a/src/hooks/useWorkspaceSync.ts b/src/hooks/useWorkspaceSync.ts index 7aecc73..62fe44b 100644 --- a/src/hooks/useWorkspaceSync.ts +++ b/src/hooks/useWorkspaceSync.ts @@ -10,9 +10,9 @@ import { useCallback, useEffect, useRef } from "react"; import { useBackendEvents } from "./useBackendEvents"; export function useWorkspaceSync(): void { - const { selectedLocationId } = useWorkspaceLocationsState(); + const { locations, selectedLocationId } = useWorkspaceLocationsState(); const { setLocations, setLoadingLocations } = useWorkspaceLocationsActions(); - const { setDocuments, setLoadingDocuments } = useWorkspaceDocumentsActions(); + const { setDocuments, setLoadingDocuments, setSidebarRefreshState } = useWorkspaceDocumentsActions(); const hasLoadedLocationsRef = useRef(false); const loadLocations = useCallback((showLoading = true) => { @@ -49,26 +49,37 @@ export function useWorkspaceSync(): void { selectedLocationRef.current = selectedLocationId; }, [selectedLocationId]); - const loadDocuments = useCallback((locationId: number) => { + const loadDocuments = useCallback((locationId: number, source: "manual" | "external" = "manual") => { const requestId = ++documentRequestRef.current; - setLoadingDocuments(true); + if (source === "manual") { + setLoadingDocuments(true); + } else { + setSidebarRefreshState(locationId, source); + } + runCmd(docList(locationId, (nextDocuments) => { if (documentRequestRef.current !== requestId) { return; } setDocuments(nextDocuments); - setLoadingDocuments(false); + if (source === "manual") { + setLoadingDocuments(false); + } + setSidebarRefreshState(undefined, null); }, (error) => { if (documentRequestRef.current !== requestId) { return; } logger.error(f("Failed to load documents", { locationId, error })); - setLoadingDocuments(false); + if (source === "manual") { + setLoadingDocuments(false); + } + setSidebarRefreshState(undefined, null); })); - }, [setDocuments, setLoadingDocuments]); + }, [setDocuments, setLoadingDocuments, setSidebarRefreshState]); useEffect(() => { if (!selectedLocationId) { @@ -77,30 +88,59 @@ export function useWorkspaceSync(): void { return; } - loadDocuments(selectedLocationId); + loadDocuments(selectedLocationId, "manual"); }, [selectedLocationId, loadDocuments, setDocuments, setLoadingDocuments]); + const watchedLocationIdsRef = useRef>(new Set()); + useEffect(() => { - if (!selectedLocationId) { - return; + const nextWatchedIds = new Set(locations.map((location) => location.id)); + const currentWatchedIds = watchedLocationIdsRef.current; + + for (const locationId of nextWatchedIds) { + if (!currentWatchedIds.has(locationId)) { + void runCmd(startWatch(locationId)); + } } - void runCmd(startWatch(selectedLocationId)); + for (const locationId of currentWatchedIds) { + if (!nextWatchedIds.has(locationId)) { + void runCmd(stopWatch(locationId)); + } + } + watchedLocationIdsRef.current = nextWatchedIds; + }, [locations]); + + useEffect(() => { return () => { - void runCmd(stopWatch(selectedLocationId)); + for (const locationId of watchedLocationIdsRef.current) { + void runCmd(stopWatch(locationId)); + } + watchedLocationIdsRef.current.clear(); }; - }, [selectedLocationId]); + }, []); useBackendEvents({ - onDocModifiedExternally: (docRef) => { + onLocationMissing: () => { + loadLocations(false); + }, + onLocationChanged: () => { + loadLocations(false); + }, + onReconciliationComplete: () => { + loadLocations(false); + }, + onFilesystemChanged: (event) => { const currentLocationId = selectedLocationRef.current; - if (currentLocationId && docRef.location_id === currentLocationId) { - loadDocuments(currentLocationId); + if (currentLocationId && event.location_id === currentLocationId) { + loadDocuments(currentLocationId, "external"); return; } - loadLocations(false); + if (event.entry_kind === "Directory") { + loadLocations(false); + } }, }); } diff --git a/src/ports/types.ts b/src/ports/types.ts index d5c3ba7..ee80b87 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -63,6 +63,14 @@ export type BackendEvent = | { type: "ReconciliationComplete"; checked: number; missing: LocationId[] } | { type: "ConflictDetected"; location_id: LocationId; rel_path: string; conflict_filename: string } | { type: "DocModifiedExternally"; doc_id: DocRef; new_mtime: string } + | { + type: "FilesystemChanged"; + location_id: LocationId; + entry_kind: "File" | "Directory"; + change_kind: "Created" | "Modified" | "Deleted" | "Renamed"; + rel_path: string; + old_rel_path?: string | null; + } | { type: "SaveStatusChanged"; doc_id: DocRef; status: SaveStatus }; export type ErrorCallback = (error: AppError) => void; diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 710357b..6e09887 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -135,8 +135,6 @@ export const useWorkspaceLocationsActions = () => setLocations: state.setLocations, setLoadingLocations: state.setLoadingLocations, setSelectedLocation: state.setSelectedLocation, - addLocation: state.addLocation, - removeLocation: state.removeLocation, })), ); diff --git a/src/state/stores/workspace.ts b/src/state/stores/workspace.ts index d9343ea..488ea5f 100644 --- a/src/state/stores/workspace.ts +++ b/src/state/stores/workspace.ts @@ -43,20 +43,6 @@ export const useWorkspaceStore = create()((set) => ({ }, setLoadingLocations: (value) => set({ isLoadingLocations: value }), setSelectedLocation: (locationId) => set({ selectedLocationId: locationId, selectedDocPath: undefined }), - addLocation: (location) => { - set((state) => ({ - locations: [...state.locations, location], - selectedLocationId: location.id, - selectedDocPath: undefined, - })); - }, - removeLocation: (locationId) => { - set((state) => ({ - locations: state.locations.filter((location) => location.id !== locationId), - selectedLocationId: state.selectedLocationId === locationId ? undefined : state.selectedLocationId, - selectedDocPath: state.selectedLocationId === locationId ? undefined : state.selectedDocPath, - })); - }, setSelectedDocPath: (path) => set({ selectedDocPath: path }), setDocuments: (documents) => set({ documents }), diff --git a/src/state/types.ts b/src/state/types.ts index 11c9545..3d9f75a 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -107,7 +107,7 @@ export type WorkspaceLocationsState = { sidebarFilter: string; }; -export type SidebarRefreshReason = "manual" | "save" | "external"; +export type SidebarRefreshReason = "manual" | "external"; export type WorkspaceDocumentsState = { selectedDocPath?: string; @@ -122,8 +122,6 @@ export type WorkspaceLocationsActions = { setLocations: (locations: LocationDescriptor[]) => void; setLoadingLocations: (value: boolean) => void; setSelectedLocation: (locationId?: number) => void; - addLocation: (location: LocationDescriptor) => void; - removeLocation: (locationId: number) => void; }; export type WorkspaceDocumentsActions = {