diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 62aff0ad..3912f8f3 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -444,6 +444,10 @@ interface PulseCommit { - `pullAndPush()`: pulls then auto-pushes for divergence recovery - `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs) +### External Vault Refresh + +External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note from disk, and closes the active tab if the file disappeared. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. + `useGitRemoteStatus` is the commit-time companion to `useAutoSync`: - Re-checks `git_remote_status` when the Commit dialog opens and right before submit - Converts `hasRemote: false` into a local-only commit path diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 22ede24f..64401ed6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -83,6 +83,10 @@ flowchart LR 4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file. 5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem. +#### External Change Detection + +The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads. + ## Tech Stack | Layer | Technology | Version | @@ -99,6 +103,7 @@ flowchart LR | Build | Vite | 7.3.1 | | Backend language | Rust (edition 2021) | 1.77.2 | | Frontmatter parsing | gray_matter | 0.2 | +| Filesystem watcher | notify | 6.1 | | AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - | | Search | Keyword (walkdir-based file scan) | - | | Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync | @@ -656,6 +661,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `batch_delete_notes` | Permanently delete notes from disk | | `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec` | | `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` | +| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events | | `check_vault_exists` | Check if vault path exists | | `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults | | `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean | diff --git a/docs/adr/0089-active-vault-filesystem-watcher.md b/docs/adr/0089-active-vault-filesystem-watcher.md new file mode 100644 index 00000000..e8f607ab --- /dev/null +++ b/docs/adr/0089-active-vault-filesystem-watcher.md @@ -0,0 +1,36 @@ +--- +type: ADR +id: "0089" +title: "Active vault filesystem watcher" +status: active +date: 2026-04-27 +--- + +## Context + +Tolaria treats the filesystem as the source of truth, but before this decision the running app only noticed external file changes after a manual Reload Vault, a Git pull, or an AI-agent-specific refresh callback. Edits from another editor, terminal commands, another Tolaria window, or a non-pull Git operation could leave React state and the editor surface stale. + +ADR-0071 already defines the safe reconciliation policy for external vault mutations: reload vault-derived state, protect unsaved local edits, and reopen the clean active note from disk when needed. Filesystem watching needed to reuse that policy instead of adding another ad hoc reload path. + +## Decision + +**Tolaria watches the active desktop vault with a native filesystem watcher and routes external change batches through the shared external-refresh reconciler.** + +The desktop backend exposes `start_vault_watcher` and `stop_vault_watcher` commands backed by Rust `notify`. It watches the active vault recursively, ignores known non-content churn such as `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`, then emits `vault-changed` events with the active vault path and changed paths. + +The renderer owns batching and reconciliation. `useVaultWatcher` starts the backend watcher for the active main-window vault, debounces native events into one refresh, filters out recent app-owned saves, and calls `refreshPulledVaultState()`. Manual Reload Vault still uses `reload_vault` directly, but now exposes visible reload feedback. + +## Options considered + +- **Native active-vault watcher plus shared reconciliation** (chosen): keeps external changes visible without polling and preserves ADR-0071 behavior for clean and unsaved tabs. Cons: adds one native dependency and a long-lived watcher state. +- **Frontend-only polling**: simpler backend surface, but wastes work on idle vaults and still needs careful active-note reconciliation. +- **Direct `reloadVault()` on every native event**: easy to implement, but bypasses clean-tab reopen handling and can clobber the user experience around unsaved edits. +- **Watch every configured vault**: could pre-warm state, but burns resources for inactive vaults and complicates event ownership across windows. + +## Consequences + +- External writes converge automatically into the visible vault state after a short debounce. +- Active clean notes are refreshed through the same path as pull and AI-agent updates; unsaved local edits remain protected. +- Tolaria app-owned saves are suppressed briefly so autosave does not trigger a full external refresh loop. +- The status bar can show reload progress for manual and automatic refreshes. +- The watcher is a desktop-only integration; mobile builds keep no-op command stubs until a mobile-specific filesystem strategy exists. diff --git a/docs/adr/README.md b/docs/adr/README.md index 46be83e0..8197c07b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -141,3 +141,4 @@ proposed → active → superseded | [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active | | [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active | | [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active | +| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active | diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 253c8067..8480d740 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1239,6 +1239,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" @@ -2069,6 +2078,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" @@ -2207,6 +2236,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" @@ -2386,6 +2435,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" @@ -2489,6 +2550,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.11.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" @@ -5042,7 +5122,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "pin-project-lite", "socket2", "tokio-macros", @@ -5103,6 +5183,7 @@ dependencies = [ "futures-util", "gray_matter", "log", + "notify", "regex", "sentry", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1a4312d4..2c0e6f71 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" log = "0.4" +notify = "6.1" tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] } tauri-plugin-log = "2" gray_matter = "0.2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18928f09..f8eca0d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ pub mod settings; pub mod telemetry; pub mod vault; pub mod vault_list; +pub mod vault_watcher; #[cfg(desktop)] mod window_state; @@ -355,7 +356,9 @@ macro_rules! app_invoke_handler { commands::reinit_telemetry, commands::list_views, commands::save_view_cmd, - commands::delete_view_cmd + commands::delete_view_cmd, + vault_watcher::start_vault_watcher, + vault_watcher::stop_vault_watcher ] }; } @@ -389,7 +392,8 @@ pub fn run() { let builder = builder .manage(WsBridgeChild(Mutex::new(None))) .manage(ActiveAssetScopeRoots(Mutex::new(Vec::new()))) - .manage(window_state::MainWindowFrameState::default()); + .manage(window_state::MainWindowFrameState::default()) + .manage(vault_watcher::VaultWatcherState::new()); with_invoke_handler(builder) .setup(setup_app) diff --git a/src-tauri/src/vault_watcher.rs b/src-tauri/src/vault_watcher.rs new file mode 100644 index 00000000..d773e3a4 --- /dev/null +++ b/src-tauri/src/vault_watcher.rs @@ -0,0 +1,255 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +pub const VAULT_CHANGED_EVENT: &str = "vault-changed"; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct VaultChangedPayload { + vault_path: String, + paths: Vec, +} + +fn has_ignored_component(path: &Path) -> bool { + path.components().any(|part| { + let component = part.as_os_str(); + component == OsStr::new(".git") || component == OsStr::new("node_modules") + }) +} + +fn is_temp_file_name(name: &OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + name == ".DS_Store" + || name == ".tolaria-rename-txn" + || name.starts_with(".#") + || name.ends_with('~') + || name.ends_with(".tmp") + || name.ends_with(".swp") + || name.ends_with(".swx") +} + +fn is_watchable_path(path: &Path) -> bool { + if has_ignored_component(path) { + return false; + } + match path.file_name() { + Some(name) => !is_temp_file_name(name), + None => true, + } +} + +#[cfg(desktop)] +mod desktop { + use std::sync::Mutex; + + use notify::{ + recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, + }; + use tauri::Emitter; + + use super::{is_watchable_path, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT}; + + struct ActiveVaultWatcher { + path: PathBuf, + _watcher: RecommendedWatcher, + } + + pub struct VaultWatcherState { + active: Mutex>, + } + + impl Default for VaultWatcherState { + fn default() -> Self { + Self::new() + } + } + + impl VaultWatcherState { + pub fn new() -> Self { + Self { + active: Mutex::new(None), + } + } + } + + fn validate_vault_path(vault_path: PathBuf) -> Result { + if vault_path.as_os_str().is_empty() { + return Err("Vault path is required".to_string()); + } + if !vault_path.is_dir() { + return Err(format!( + "Vault path is not a directory: {}", + vault_path.display() + )); + } + Ok(vault_path) + } + + fn should_emit_event(event: &Event) -> bool { + !matches!(event.kind, EventKind::Access(_)) + } + + fn changed_paths(event: Event) -> Vec { + if !should_emit_event(&event) { + return Vec::new(); + } + event + .paths + .into_iter() + .filter(|path| is_watchable_path(path)) + .map(|path| path.to_string_lossy().to_string()) + .collect() + } + + fn emit_vault_change(app: &tauri::AppHandle, vault_path: &Path, event: Event) { + let paths = changed_paths(event); + if paths.is_empty() { + return; + } + let payload = VaultChangedPayload { + vault_path: vault_path.to_string_lossy().to_string(), + paths, + }; + if let Err(err) = app.emit(VAULT_CHANGED_EVENT, payload) { + log::warn!("Failed to emit vault watcher event: {}", err); + } + } + + pub fn start( + app: tauri::AppHandle, + state: tauri::State<'_, VaultWatcherState>, + path: PathBuf, + ) -> Result<(), String> { + let vault_path = validate_vault_path(path)?; + let mut active = state + .active + .lock() + .map_err(|_| "Failed to lock vault watcher state".to_string())?; + if active + .as_ref() + .is_some_and(|watcher| watcher.path == vault_path) + { + return Ok(()); + } + + let event_vault_path = vault_path.clone(); + let event_app = app.clone(); + let mut watcher = recommended_watcher(move |event| match event { + Ok(event) => emit_vault_change(&event_app, &event_vault_path, event), + Err(err) => log::warn!("Vault watcher event failed: {}", err), + }) + .map_err(|err| format!("Failed to create vault watcher: {err}"))?; + watcher + .watch(&vault_path, RecursiveMode::Recursive) + .map_err(|err| format!("Failed to watch {}: {err}", vault_path.display()))?; + + *active = Some(ActiveVaultWatcher { + path: vault_path, + _watcher: watcher, + }); + Ok(()) + } + + pub fn stop(state: tauri::State<'_, VaultWatcherState>) -> Result<(), String> { + let mut active = state + .active + .lock() + .map_err(|_| "Failed to lock vault watcher state".to_string())?; + *active = None; + Ok(()) + } +} + +#[cfg(not(desktop))] +mod mobile { + use super::PathBuf; + + pub struct VaultWatcherState; + + impl Default for VaultWatcherState { + fn default() -> Self { + Self::new() + } + } + + impl VaultWatcherState { + pub fn new() -> Self { + Self + } + } + + pub fn start(_path: PathBuf) -> Result<(), String> { + Ok(()) + } + + pub fn stop() -> Result<(), String> { + Ok(()) + } +} + +#[cfg(desktop)] +pub use desktop::VaultWatcherState; +#[cfg(not(desktop))] +pub use mobile::VaultWatcherState; + +#[cfg(desktop)] +#[tauri::command] +pub fn start_vault_watcher( + app: tauri::AppHandle, + state: tauri::State<'_, VaultWatcherState>, + path: PathBuf, +) -> Result<(), String> { + desktop::start(app, state, path) +} + +#[cfg(desktop)] +#[tauri::command] +pub fn stop_vault_watcher(state: tauri::State<'_, VaultWatcherState>) -> Result<(), String> { + desktop::stop(state) +} + +#[cfg(not(desktop))] +#[tauri::command] +pub fn start_vault_watcher(path: PathBuf) -> Result<(), String> { + mobile::start(path) +} + +#[cfg(not(desktop))] +#[tauri::command] +pub fn stop_vault_watcher() -> Result<(), String> { + mobile::stop() +} + +#[cfg(test)] +mod tests { + use super::is_watchable_path; + use std::path::Path; + + #[test] + fn ignores_git_and_dependency_directory_changes() { + assert!(!is_watchable_path(Path::new(".git/index.lock"))); + assert!(!is_watchable_path(Path::new( + "node_modules/package/index.js" + ))); + } + + #[test] + fn ignores_common_temporary_files() { + assert!(!is_watchable_path(Path::new("note.md.tmp"))); + assert!(!is_watchable_path(Path::new("note.md.swp"))); + assert!(!is_watchable_path(Path::new("draft.md~"))); + assert!(!is_watchable_path(Path::new(".DS_Store"))); + assert!(!is_watchable_path(Path::new(".tolaria-rename-txn"))); + } + + #[test] + fn keeps_notes_assets_and_saved_views_watchable() { + assert!(is_watchable_path(Path::new("notes/day.md"))); + assert!(is_watchable_path(Path::new("attachments/image.png"))); + assert!(is_watchable_path(Path::new(".laputa/views/work.yml"))); + } +} diff --git a/src/App.tsx b/src/App.tsx index d9124eef..4a49dec3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import { useAiAgentsStatus } from './hooks/useAiAgentsStatus' import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus' import { useAutoGit } from './hooks/useAutoGit' import { useVaultLoader } from './hooks/useVaultLoader' +import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher' import { useAiAgentPreferences } from './hooks/useAiAgentPreferences' import { useSettings } from './hooks/useSettings' import { useDocumentThemeMode } from './hooks/useDocumentThemeMode' @@ -369,6 +370,7 @@ function App() { }, [resolvedPath, setToastMessage]) const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath) + const recentVaultWrites = useRecentVaultWrites({ vaultPath: noteWindowParams ? '' : resolvedPath }) const { status: vaultAiGuidanceStatus, refresh: refreshVaultAiGuidance, @@ -588,6 +590,11 @@ function App() { vault.reloadViews, vault.unsavedPaths, ]) + useVaultWatcher({ + vaultPath: noteWindowParams ? '' : resolvedPath, + onVaultChanged: handlePulledVaultUpdate, + filterChangedPaths: recentVaultWrites.filterExternalPaths, + }) const autoSync = useAutoSync({ enabled: gitRepoState === 'ready', vaultPath: resolvedPath, @@ -739,6 +746,7 @@ function App() { handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename, replaceEntry: vault.replaceEntry, resolvedPath, initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false, + onInternalVaultWrite: recentVaultWrites.markInternalWrite, }) const aiActivity = useAiActivity({ @@ -747,7 +755,7 @@ function App() { onSetFilter: (filterType) => { handleSetSelection({ kind: 'sectionGroup', type: filterType }) }, - onVaultChanged: () => { vault.reloadVault() }, + onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : []) }, }) const handleInitializeProperties = useCallback(async (path: string) => { @@ -1350,6 +1358,12 @@ function App() { () => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, [activeDeletedFile, handleDiscardFile], ) + const reloadVaultForCommand = vault.reloadVault + const handleManualVaultReload = useCallback(async () => { + const entries = await reloadVaultForCommand() + setToastMessage(`Vault reloaded (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'})`) + return entries + }, [reloadVaultForCommand, setToastMessage]) const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, @@ -1416,7 +1430,7 @@ function App() { onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent, onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent, selectedAiAgentLabel: aiAgentPreferences.defaultAiAgentLabel, - onReloadVault: vault.reloadVault, + onReloadVault: handleManualVaultReload, onRepairVault: handleRepairVault, onSetNoteIcon: handleSetNoteIconCommand, onRemoveNoteIcon: handleRemoveNoteIconCommand, @@ -1615,7 +1629,7 @@ function App() { - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} /> setToastMessage(null)} /> diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 378383c5..447cce44 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -85,6 +85,11 @@ describe('StatusBar', () => { expect(screen.getByText('b?')).toBeInTheDocument() }) + it('shows the vault reload badge while a reload is active', () => { + render() + expect(screen.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk') + }) + it('calls onCheckForUpdates when clicking build number', () => { const onCheckForUpdates = vi.fn() render() diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index a571fbff..51dd083a 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -70,6 +70,7 @@ interface StatusBarProps { onCommitPush?: () => void onInitializeGit?: () => void isOffline?: boolean + isVaultReloading?: boolean isGitVault?: boolean syncStatus?: SyncStatus lastSyncTime?: number | null @@ -117,6 +118,7 @@ function StatusBarPrimaryFromFooter({ onCommitPush, onInitializeGit, isOffline = false, + isVaultReloading = false, isGitVault = true, syncStatus = 'idle', lastSyncTime = null, @@ -156,6 +158,7 @@ function StatusBarPrimaryFromFooter({ onCommitPush={onCommitPush} onInitializeGit={onInitializeGit} isOffline={isOffline} + isVaultReloading={isVaultReloading} isGitVault={isGitVault} syncStatus={syncStatus} lastSyncTime={lastSyncTime} diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index 7ba0d4e6..64de1a1c 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -552,6 +552,32 @@ export function OfflineBadge({ ) } +export function VaultReloadingBadge({ + isReloading, + showSeparator = true, + compact = false, + locale = 'en', +}: { + isReloading?: boolean + showSeparator?: boolean + compact?: boolean + locale?: AppLocale +}) { + if (!isReloading) return null + + return ( + <> + + + + + {compact ? null : translate(locale, 'status.vault.reloading')} + + + + ) +} + export function NoRemoteBadge({ remoteStatus, onAddRemote, diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index 7da24e86..0a3df947 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -24,6 +24,7 @@ import { OfflineBadge, PulseBadge, SyncBadge, + VaultReloadingBadge, } from './StatusBarBadges' import { ICON_STYLE, SEP_STYLE } from './styles' import type { VaultOption } from './types' @@ -52,6 +53,7 @@ interface StatusBarPrimarySectionProps { onCommitPush?: () => void onInitializeGit?: () => void isOffline?: boolean + isVaultReloading?: boolean isGitVault?: boolean syncStatus: SyncStatus lastSyncTime: number | null @@ -192,6 +194,7 @@ function StatusBarPrimaryBadges({ claudeCodeStatus, claudeCodeVersion, isOffline, + isVaultReloading, compact, locale, }: { @@ -219,12 +222,14 @@ function StatusBarPrimaryBadges({ claudeCodeStatus?: ClaudeCodeStatus claudeCodeVersion?: string | null isOffline: boolean + isVaultReloading: boolean compact: boolean locale: AppLocale }) { return ( <> + {isGitVault ? ( <> @@ -311,6 +316,7 @@ export function StatusBarPrimarySection({ onCommitPush, onInitializeGit, isOffline = false, + isVaultReloading = false, isGitVault = true, syncStatus, lastSyncTime, @@ -402,7 +408,7 @@ export function StatusBarPrimarySection({ onRestoreVaultAiGuidance={onRestoreVaultAiGuidance} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} - isOffline={isOffline} + isOffline={isOffline} isVaultReloading={isVaultReloading} compact={compact} locale={locale} /> diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index bf40e9ef..512bf615 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -424,6 +424,7 @@ interface AppSaveDeps { replaceEntry: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void resolvedPath: string initialH1AutoRenameEnabled: boolean + onInternalVaultWrite?: (path: string) => void } function useAppSaveStateRefs({ @@ -592,6 +593,7 @@ function useEditorPersistence({ loadModifiedFiles, trackUnsaved, clearUnsaved, + onInternalVaultWrite, reloadViews, scheduleUntitledRename, resolveCurrentPath, @@ -603,6 +605,7 @@ function useEditorPersistence({ loadModifiedFiles: AppSaveDeps['loadModifiedFiles'] trackUnsaved?: AppSaveDeps['trackUnsaved'] clearUnsaved: AppSaveDeps['clearUnsaved'] + onInternalVaultWrite?: AppSaveDeps['onInternalVaultWrite'] reloadViews: AppSaveDeps['reloadViews'] scheduleUntitledRename: (path: string, content: string) => void resolveCurrentPath: (path: string) => string @@ -613,10 +616,11 @@ function useEditorPersistence({ }, [loadModifiedFiles]) const onNotePersisted = useCallback((path: string, content: string) => { + onInternalVaultWrite?.(path) clearUnsaved(path) if (path.endsWith('.yml')) reloadViews?.() scheduleUntitledRename(path, content) - }, [clearUnsaved, reloadViews, scheduleUntitledRename]) + }, [clearUnsaved, onInternalVaultWrite, reloadViews, scheduleUntitledRename]) const { handleSave: handleSaveRaw, @@ -746,6 +750,7 @@ export function useAppSave({ trackUnsaved, clearUnsaved, unsavedPaths, tabs, activeTabPath, handleRenameNote, handleRenameFilename: handleRenameFilenameRaw, replaceEntry, resolvedPath, initialH1AutoRenameEnabled, + onInternalVaultWrite, }: AppSaveDeps) { const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) const { tabsRef, activeTabPathRef, unsavedPathsRef } = useAppSaveStateRefs({ tabs, activeTabPath, unsavedPaths }) @@ -769,6 +774,7 @@ export function useAppSave({ loadModifiedFiles, trackUnsaved, clearUnsaved, + onInternalVaultWrite, reloadViews, scheduleUntitledRename, resolveCurrentPath, @@ -799,14 +805,7 @@ export function useAppSave({ }) return { - contentChangeRef, - handleContentChange, - handleFilenameRename, - handleSave, - handleTitleSync, - savePending, - savePendingForPath, - trackRenamedPath: registerRenamedPath, - flushBeforeAction, + contentChangeRef, handleContentChange, handleFilenameRename, handleSave, handleTitleSync, + savePending, savePendingForPath, trackRenamedPath: registerRenamedPath, flushBeforeAction, } } diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index aff5eab5..2ccaa3fd 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -706,6 +706,34 @@ describe('useVaultLoader', () => { }) describe('reloadVault', () => { + it('reports reload progress while reload_vault is pending', async () => { + const reload = createDeferred() + backendInvokeFn.mockImplementation(((cmd: string) => { + if (cmd === 'list_vault') return Promise.resolve(mockEntries) + if (cmd === 'reload_vault') return reload.promise + if (cmd === 'get_modified_files') return Promise.resolve([]) + if (cmd === 'list_vault_folders') return Promise.resolve([]) + if (cmd === 'list_views') return Promise.resolve([]) + return Promise.resolve(null) + }) as typeof defaultMockInvoke) + + const { result } = await renderVaultLoader() + + let pendingReload: Promise | null = null + act(() => { + pendingReload = result.current.reloadVault() + }) + + expect(result.current.isReloading).toBe(true) + + await act(async () => { + reload.resolve(mockEntries) + await pendingReload! + }) + + expect(result.current.isReloading).toBe(false) + }) + it('refreshes entries from reload_vault and reloads modified files', async () => { const reloadedEntry = { ...mockEntries[0], @@ -819,77 +847,84 @@ describe('useVaultLoader', () => { describe('resolveNoteStatus', () => { const mf = (path: string, status: string): ModifiedFile => ({ path, relativePath: path.replace('/vault/', ''), status }) + const status = ( + path: string, + newPaths: Set, + modifiedFiles: ModifiedFile[], + pendingSavePaths?: Set, + unsavedPaths?: Set, + ) => resolveNoteStatus({ path, newPaths, modifiedFiles, pendingSavePaths, unsavedPaths }) it('returns new when path is in newPaths (not yet on disk)', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') + expect(status('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') }) it('returns new for untracked files in git', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'untracked')])).toBe('new') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'untracked')])).toBe('new') }) it('returns new for added files in git', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'added')])).toBe('new') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'added')])).toBe('new') }) it('returns modified for git-modified files', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') }) it('returns clean for files not in git status', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(), [])).toBe('clean') + expect(status('/vault/x.md', new Set(), [])).toBe('clean') }) it('returns modified for deleted files so deleted previews keep diff affordances', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('modified') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('modified') }) it('returns clean for unsupported git statuses', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'renamed')])).toBe('clean') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'renamed')])).toBe('clean') }) it('newPaths takes priority over git modified', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new') + expect(status('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new') }) it('pendingSave takes priority over new status', () => { const pendingSave = new Set(['/vault/x.md']) - expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], pendingSave)).toBe('pendingSave') + expect(status('/vault/x.md', new Set(['/vault/x.md']), [], pendingSave)).toBe('pendingSave') }) it('pendingSave takes priority over modified status', () => { const pendingSave = new Set(['/vault/x.md']) - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], pendingSave)).toBe('pendingSave') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], pendingSave)).toBe('pendingSave') }) it('pendingSave takes priority over clean status', () => { const pendingSave = new Set(['/vault/x.md']) - expect(resolveNoteStatus('/vault/x.md', new Set(), [], pendingSave)).toBe('pendingSave') + expect(status('/vault/x.md', new Set(), [], pendingSave)).toBe('pendingSave') }) it('without pendingSavePaths parameter, behavior is unchanged', () => { // Omitting the optional parameter should produce the same results as before - expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') - expect(resolveNoteStatus('/vault/x.md', new Set(), [])).toBe('clean') + expect(status('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') + expect(status('/vault/x.md', new Set(), [])).toBe('clean') }) it('empty pendingSavePaths set does not affect other statuses', () => { const emptyPending = new Set() - expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], emptyPending)).toBe('new') - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], emptyPending)).toBe('modified') - expect(resolveNoteStatus('/vault/x.md', new Set(), [], emptyPending)).toBe('clean') + expect(status('/vault/x.md', new Set(['/vault/x.md']), [], emptyPending)).toBe('new') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], emptyPending)).toBe('modified') + expect(status('/vault/x.md', new Set(), [], emptyPending)).toBe('clean') }) it('unsaved takes priority over all other statuses', () => { const unsaved = new Set(['/vault/x.md']) - expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], undefined, unsaved)).toBe('unsaved') - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], undefined, unsaved)).toBe('unsaved') - expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], new Set(['/vault/x.md']), unsaved)).toBe('unsaved') + expect(status('/vault/x.md', new Set(['/vault/x.md']), [], undefined, unsaved)).toBe('unsaved') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], undefined, unsaved)).toBe('unsaved') + expect(status('/vault/x.md', new Set(['/vault/x.md']), [], new Set(['/vault/x.md']), unsaved)).toBe('unsaved') }) it('without unsavedPaths parameter, behavior is unchanged', () => { - expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') - expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') + expect(status('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') + expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') }) }) diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 722eec3a..9adb236b 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types' @@ -160,9 +160,21 @@ function usePendingSaveTracker() { return { pendingSavePaths, addPendingSave, removePendingSave } } -export function resolveNoteStatus( - path: string, newPaths: Set, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set, unsavedPaths?: Set, -): NoteStatus { +interface ResolveNoteStatusOptions { + path: string + newPaths: Set + modifiedFiles: ModifiedFile[] + pendingSavePaths?: Set + unsavedPaths?: Set +} + +export function resolveNoteStatus({ + path, + newPaths, + modifiedFiles, + pendingSavePaths, + unsavedPaths, +}: ResolveNoteStatusOptions): NoteStatus { if (unsavedPaths?.has(path)) return 'unsaved' if (pendingSavePaths?.has(path)) return 'pendingSave' if (newPaths.has(path)) return 'new' @@ -173,18 +185,31 @@ export function resolveNoteStatus( return 'clean' } -export function useVaultLoader(vaultPath: string) { - const [entries, setEntries] = useState([]) - const [folders, setFolders] = useState([]) - const [isLoading, setIsLoading] = useState(() => hasVaultPath(vaultPath)) - const [views, setViews] = useState([]) - const [modifiedFiles, setModifiedFiles] = useState([]) - const [modifiedFilesError, setModifiedFilesError] = useState(null) - const tracker = useNewNoteTracker() - const pendingSave = usePendingSaveTracker() - const unsaved = useUnsavedTracker() - const isCurrentVaultPath = useCurrentVaultPathGuard(vaultPath) - +function useInitialVaultLoad({ + vaultPath, + tracker, + unsaved, + isCurrentVaultPath, + resetReloading, + setEntries, + setFolders, + setIsLoading, + setModifiedFiles, + setModifiedFilesError, + setViews, +}: { + vaultPath: string + tracker: ReturnType + unsaved: ReturnType + isCurrentVaultPath: (path: string) => boolean + resetReloading: () => void + setEntries: (entries: VaultEntry[]) => void + setFolders: (folders: FolderNode[]) => void + setIsLoading: (isLoading: boolean) => void + setModifiedFiles: (files: ModifiedFile[]) => void + setModifiedFilesError: (message: string | null) => void + setViews: (views: ViewFile[]) => void +}) { useEffect(() => { const path = vaultPath resetVaultState({ @@ -197,10 +222,9 @@ export function useVaultLoader(vaultPath: string) { setModifiedFilesError, setViews, }) + resetReloading() - if (!hasVaultPath(path)) { - return - } + if (!hasVaultPath(path)) return let cancelled = false void loadInitialVaultState({ @@ -212,7 +236,24 @@ export function useVaultLoader(vaultPath: string) { setViews, }) return () => { cancelled = true } - }, [vaultPath, tracker.clear, unsaved.clearAll, isCurrentVaultPath]) + }, [ + vaultPath, + tracker.clear, + unsaved.clearAll, + isCurrentVaultPath, + resetReloading, + setEntries, + setFolders, + setIsLoading, + setModifiedFiles, + setModifiedFilesError, + setViews, + ]) +} + +function useModifiedFilesLoader(vaultPath: string, isCurrentVaultPath: (path: string) => boolean) { + const [modifiedFiles, setModifiedFiles] = useState([]) + const [modifiedFilesError, setModifiedFilesError] = useState(null) const loadModifiedFiles = useCallback(async () => { const path = vaultPath @@ -225,8 +266,7 @@ export function useVaultLoader(vaultPath: string) { try { const files = await tauriCall('get_modified_files', { vaultPath: path }, {}) - if (!isCurrentVaultPath(path)) return - setModifiedFiles(files) + if (isCurrentVaultPath(path)) setModifiedFiles(files) } catch (err) { if (!isCurrentVaultPath(path)) return const message = typeof err === 'string' ? err : 'Failed to load changes' @@ -238,13 +278,26 @@ export function useVaultLoader(vaultPath: string) { useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load + return { + modifiedFiles, + modifiedFilesError, + setModifiedFiles, + setModifiedFilesError, + loadModifiedFiles, + } +} + +function useEntryMutations( + setEntries: Dispatch>, + trackNew: (path: string) => void, +) { const addEntry = useCallback((entry: VaultEntry) => { setEntries((prev) => { if (prev.some(e => e.path === entry.path)) return prev return [entry, ...prev] }) - tracker.trackNew(entry.path) - }, [tracker]) + trackNew(entry.path) + }, [setEntries, trackNew]) const updateEntry = useCallback((path: string, patch: Partial) => { setEntries((prev) => { @@ -255,22 +308,26 @@ export function useVaultLoader(vaultPath: string) { }) return changed ? next : prev }) - }, []) + }, [setEntries]) const removeEntry = useCallback((path: string) => { setEntries((prev) => prev.filter((e) => e.path !== path)) - }, []) + }, [setEntries]) const removeEntries = useCallback((paths: string[]) => { if (paths.length === 0) return const pathSet = new Set(paths) setEntries((prev) => prev.filter((entry) => !pathSet.has(entry.path))) - }, []) + }, [setEntries]) const replaceEntry = useCallback((oldPath: string, patch: Partial & { path: string }) => { setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e)) - }, []) + }, [setEntries]) + return { addEntry, updateEntry, removeEntry, removeEntries, replaceEntry } +} + +function useGitLoaders(vaultPath: string) { const loadGitHistory = useCallback(async (path: string): Promise => { try { return await tauriCall('get_file_history', { vaultPath, path }, { path }) } catch (err) { console.warn('Failed to load git history:', err); return [] } @@ -282,45 +339,69 @@ export function useVaultLoader(vaultPath: string) { const loadDiff = useCallback((path: string): Promise => tauriCall('get_file_diff', { vaultPath, path }, { path }), [vaultPath]) - const getNoteStatus = useCallback((path: string): NoteStatus => - resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths]) - const commitAndPush = useCallback((message: string): Promise => commitWithPush(vaultPath, message), [vaultPath]) - const reloadFolders = useCallback( - () => { - const path = vaultPath - return loadVaultFolders(path) - .then((f) => { - if (!isCurrentVaultPath(path)) return [] as FolderNode[] - const nextFolders = f ?? [] - setFolders(nextFolders) - return nextFolders - }) - .catch(() => [] as FolderNode[]) - }, - [vaultPath, isCurrentVaultPath], - ) + return { loadGitHistory, loadDiffAtCommit, loadDiff, commitAndPush } +} - const reloadVault = useCallback( - () => { - const path = vaultPath - clearPrefetchCache() - return tauriCall('reload_vault', { path }) - .then((entries) => { - if (!isCurrentVaultPath(path)) return [] as VaultEntry[] - setEntries(entries) - void loadModifiedFiles() - return entries - }) - .catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] }) - }, - [vaultPath, loadModifiedFiles, isCurrentVaultPath], - ) +function useVaultReloads({ + vaultPath, + isCurrentVaultPath, + loadModifiedFiles, + setEntries, + setFolders, + setViews, +}: { + vaultPath: string + isCurrentVaultPath: (path: string) => boolean + loadModifiedFiles: () => Promise + setEntries: (entries: VaultEntry[]) => void + setFolders: (folders: FolderNode[]) => void + setViews: (views: ViewFile[]) => void +}) { + const [activeReloads, setActiveReloads] = useState(0) + const isReloading = activeReloads > 0 + const beginReload = useCallback(() => setActiveReloads((count) => count + 1), []) + const finishReload = useCallback(() => setActiveReloads((count) => Math.max(0, count - 1)), []) + const resetReloading = useCallback(() => setActiveReloads(0), []) + + const reloadFolders = useCallback(async () => { + const path = vaultPath + if (!hasVaultPath(path)) return [] as FolderNode[] + try { + const folders = await loadVaultFolders(path) + if (!isCurrentVaultPath(path)) return [] as FolderNode[] + const nextFolders = folders ?? [] + setFolders(nextFolders) + return nextFolders + } catch { + return [] as FolderNode[] + } + }, [vaultPath, isCurrentVaultPath, setFolders]) + + const reloadVault = useCallback(async () => { + const path = vaultPath + if (!hasVaultPath(path)) return [] as VaultEntry[] + clearPrefetchCache() + beginReload() + try { + const entries = await tauriCall('reload_vault', { path }) + if (!isCurrentVaultPath(path)) return [] as VaultEntry[] + setEntries(entries) + void loadModifiedFiles() + return entries + } catch (err) { + console.warn('Vault reload failed:', err) + return [] as VaultEntry[] + } finally { + finishReload() + } + }, [vaultPath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries]) const reloadViews = useCallback(async () => { const path = vaultPath + if (!hasVaultPath(path)) return [] try { const nextViews = await loadVaultViews(path) if (!isCurrentVaultPath(path)) return [] @@ -329,13 +410,70 @@ export function useVaultLoader(vaultPath: string) { return resolvedViews } catch { /* views are optional */ } return [] - }, [vaultPath, isCurrentVaultPath]) + }, [vaultPath, isCurrentVaultPath, setViews]) + + return { isReloading, reloadFolders, reloadVault, reloadViews, resetReloading } +} + +export function useVaultLoader(vaultPath: string) { + const [entries, setEntries] = useState([]) + const [folders, setFolders] = useState([]) + const [isLoading, setIsLoading] = useState(() => hasVaultPath(vaultPath)) + const [views, setViews] = useState([]) + const tracker = useNewNoteTracker() + const pendingSave = usePendingSaveTracker() + const unsaved = useUnsavedTracker() + const isCurrentVaultPath = useCurrentVaultPathGuard(vaultPath) + const { + modifiedFiles, + modifiedFilesError, + setModifiedFiles, + setModifiedFilesError, + loadModifiedFiles, + } = useModifiedFilesLoader(vaultPath, isCurrentVaultPath) + const entryMutations = useEntryMutations(setEntries, tracker.trackNew) + const gitLoaders = useGitLoaders(vaultPath) + const vaultReloads = useVaultReloads({ + vaultPath, + isCurrentVaultPath, + loadModifiedFiles, + setEntries, + setFolders, + setViews, + }) + + useInitialVaultLoad({ + vaultPath, + tracker, + unsaved, + isCurrentVaultPath, + resetReloading: vaultReloads.resetReloading, + setEntries, + setFolders, + setIsLoading, + setModifiedFiles, + setModifiedFilesError, + setViews, + }) + + const getNoteStatus = useCallback((path: string): NoteStatus => + resolveNoteStatus({ + path, + newPaths: tracker.newPaths, + modifiedFiles, + pendingSavePaths: pendingSave.pendingSavePaths, + unsavedPaths: unsaved.unsavedPaths, + }), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths]) return { - entries, folders, isLoading, views, modifiedFiles, modifiedFilesError, - addEntry, updateEntry, removeEntry, removeEntries, replaceEntry, - loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit, - getNoteStatus, commitAndPush, reloadVault, reloadFolders, reloadViews, + entries, folders, isLoading, isReloading: vaultReloads.isReloading, views, modifiedFiles, modifiedFilesError, + ...entryMutations, + loadModifiedFiles, + ...gitLoaders, + getNoteStatus, + reloadVault: vaultReloads.reloadVault, + reloadFolders: vaultReloads.reloadFolders, + reloadViews: vaultReloads.reloadViews, addPendingSave: pendingSave.addPendingSave, removePendingSave: pendingSave.removePendingSave, unsavedPaths: unsaved.unsavedPaths, diff --git a/src/hooks/useVaultWatcher.test.ts b/src/hooks/useVaultWatcher.test.ts new file mode 100644 index 00000000..d67aea68 --- /dev/null +++ b/src/hooks/useVaultWatcher.test.ts @@ -0,0 +1,187 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + VAULT_CHANGED_EVENT, + VAULT_WATCHER_DEBOUNCE_MS, + normalizeWatchPath, + resolveChangedPath, + useRecentVaultWrites, + useVaultWatcher, +} from './useVaultWatcher' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + isTauri: vi.fn(), + listen: vi.fn(), + listener: undefined as ((event: { payload: { vaultPath: string; paths: string[] } }) => void) | undefined, + unlisten: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mocks.invoke, +})) + +vi.mock('@tauri-apps/api/event', () => ({ + listen: mocks.listen, +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: mocks.isTauri, +})) + +function emitVaultChanged(payload: { vaultPath: string; paths: string[] }) { + act(() => { + mocks.listener?.({ payload }) + }) +} + +async function flushWatcherDebounce() { + await act(async () => { + vi.advanceTimersByTime(VAULT_WATCHER_DEBOUNCE_MS) + await Promise.resolve() + }) +} + +async function settleWatcherSubscription() { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('watch path helpers', () => { + it('normalizes slashes and private tmp aliases', () => { + expect(normalizeWatchPath('/private/tmp/vault//')).toBe('/tmp/vault') + expect(normalizeWatchPath('C:\\Users\\Luca\\Vault')).toBe('C:/Users/Luca/Vault') + }) + + it('resolves relative watcher paths against the active vault', () => { + expect(resolveChangedPath({ path: 'notes/day.md', vaultPath: '/vault' })).toBe('/vault/notes/day.md') + expect(resolveChangedPath({ path: '/vault/notes/day.md', vaultPath: '/vault' })).toBe('/vault/notes/day.md') + }) +}) + +describe('useRecentVaultWrites', () => { + it('filters recent app-owned writes but keeps later external changes', () => { + let now = 1000 + const { result } = renderHook(() => useRecentVaultWrites({ vaultPath: '/vault', now: () => now })) + + act(() => { + result.current.markInternalWrite('/vault/notes/self.md') + }) + + expect(result.current.filterExternalPaths([ + '/vault/notes/self.md', + '/vault/notes/external.md', + ])).toEqual(['/vault/notes/external.md']) + + now += 5000 + expect(result.current.filterExternalPaths(['/vault/notes/self.md'])).toEqual(['/vault/notes/self.md']) + }) + + it('clears recent writes when the active vault changes', () => { + const { result, rerender } = renderHook( + ({ vaultPath }) => useRecentVaultWrites({ vaultPath, now: () => 1000 }), + { initialProps: { vaultPath: '/vault-a' } }, + ) + + act(() => { + result.current.markInternalWrite('/vault-a/note.md') + }) + rerender({ vaultPath: '/vault-b' }) + + expect(result.current.filterExternalPaths(['/vault-a/note.md'])).toEqual(['/vault-a/note.md']) + }) +}) + +describe('useVaultWatcher', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.invoke.mockReset() + mocks.isTauri.mockReset() + mocks.listen.mockReset() + mocks.unlisten.mockReset() + mocks.listener = undefined + mocks.isTauri.mockReturnValue(true) + mocks.invoke.mockResolvedValue(undefined) + mocks.listen.mockImplementation((_event: string, listener: typeof mocks.listener) => { + mocks.listener = listener + return Promise.resolve(mocks.unlisten) + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not start a watcher outside Tauri', () => { + mocks.isTauri.mockReturnValue(false) + + renderHook(() => useVaultWatcher({ + vaultPath: '/vault', + onVaultChanged: vi.fn(), + })) + + expect(mocks.invoke).not.toHaveBeenCalled() + expect(mocks.listen).not.toHaveBeenCalled() + }) + + it('starts, subscribes, and stops the native watcher', async () => { + const { unmount } = renderHook(() => useVaultWatcher({ + vaultPath: '/vault', + onVaultChanged: vi.fn(), + })) + + await settleWatcherSubscription() + expect(mocks.listen).toHaveBeenCalledWith(VAULT_CHANGED_EVENT, expect.any(Function)) + expect(mocks.invoke).toHaveBeenCalledWith('start_vault_watcher', { path: '/vault' }) + + unmount() + + expect(mocks.unlisten).toHaveBeenCalledOnce() + expect(mocks.invoke).toHaveBeenCalledWith('stop_vault_watcher') + }) + + it('batches changed paths for the active vault', async () => { + const onVaultChanged = vi.fn() + renderHook(() => useVaultWatcher({ vaultPath: '/vault', onVaultChanged })) + + await settleWatcherSubscription() + expect(mocks.listener).toBeDefined() + emitVaultChanged({ vaultPath: '/vault', paths: ['notes/a.md'] }) + emitVaultChanged({ vaultPath: '/vault', paths: ['/vault/notes/b.md'] }) + + expect(onVaultChanged).not.toHaveBeenCalled() + await flushWatcherDebounce() + + expect(onVaultChanged).toHaveBeenCalledWith(['/vault/notes/a.md', '/vault/notes/b.md']) + }) + + it('ignores watcher events for other vaults', async () => { + const onVaultChanged = vi.fn() + renderHook(() => useVaultWatcher({ vaultPath: '/vault', onVaultChanged })) + + await settleWatcherSubscription() + expect(mocks.listener).toBeDefined() + emitVaultChanged({ vaultPath: '/other', paths: ['/other/note.md'] }) + await flushWatcherDebounce() + + expect(onVaultChanged).not.toHaveBeenCalled() + }) + + it('lets callers suppress app-owned writes before refreshing', async () => { + const onVaultChanged = vi.fn() + renderHook(() => useVaultWatcher({ + vaultPath: '/vault', + onVaultChanged, + filterChangedPaths: (paths) => paths.filter((path) => path.endsWith('external.md')), + })) + + await settleWatcherSubscription() + expect(mocks.listener).toBeDefined() + emitVaultChanged({ vaultPath: '/vault', paths: ['self.md', 'external.md'] }) + await flushWatcherDebounce() + + expect(onVaultChanged).toHaveBeenCalledWith(['/vault/external.md']) + }) +}) diff --git a/src/hooks/useVaultWatcher.ts b/src/hooks/useVaultWatcher.ts new file mode 100644 index 00000000..62b24916 --- /dev/null +++ b/src/hooks/useVaultWatcher.ts @@ -0,0 +1,318 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { isTauri } from '../mock-tauri' + +export const VAULT_CHANGED_EVENT = 'vault-changed' +export const VAULT_WATCHER_DEBOUNCE_MS = 350 +export const INTERNAL_WRITE_SUPPRESSION_MS = 4000 + +type WatchPath = string +type TimestampProvider = () => number + +interface VaultChangedPayload { + vaultPath: WatchPath + paths: WatchPath[] +} + +interface UseVaultWatcherOptions { + vaultPath: WatchPath + onVaultChanged: (paths: WatchPath[]) => Promise | void + debounceMs?: number + filterChangedPaths?: (paths: WatchPath[]) => WatchPath[] +} + +interface ChangedPathOptions { + path: WatchPath + vaultPath: WatchPath +} + +interface PathContainmentOptions { + path: WatchPath + parent: WatchPath +} + +function isAbsoluteWatchPath(path: WatchPath): boolean { + return path.startsWith('/') || /^[A-Za-z]:\//u.test(path) +} + +function trimTrailingSlash(path: WatchPath): WatchPath { + return path.length > 1 ? path.replace(/\/+$/u, '') : path +} + +export function normalizeWatchPath(path: WatchPath): WatchPath { + return trimTrailingSlash(path.replaceAll('\\', '/').replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')) +} + +export function resolveChangedPath({ path, vaultPath }: ChangedPathOptions): WatchPath { + const normalizedPath = normalizeWatchPath(path) + if (isAbsoluteWatchPath(normalizedPath)) return normalizedPath + return normalizeWatchPath(`${vaultPath}/${normalizedPath}`) +} + +function isSamePathOrChild({ path, parent }: PathContainmentOptions): boolean { + const normalizedPath = normalizeWatchPath(path) + const normalizedParent = normalizeWatchPath(parent) + return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`) +} + +function pruneRecentWrites(writes: Map, now: number) { + for (const [path, timestamp] of writes) { + if (now - timestamp > INTERNAL_WRITE_SUPPRESSION_MS) writes.delete(path) + } +} + +function useLatestRef(value: T) { + const ref = useRef(value) + useEffect(() => { + ref.current = value + }, [value]) + return ref +} + +function useVaultPathRef(vaultPath: WatchPath) { + const vaultPathRef = useRef(normalizeWatchPath(vaultPath)) + + useEffect(() => { + vaultPathRef.current = normalizeWatchPath(vaultPath) + }, [vaultPath]) + + return vaultPathRef +} + +export function useRecentVaultWrites({ + vaultPath, + now = Date.now, +}: { + vaultPath: WatchPath + now?: TimestampProvider +}) { + const recentWritesRef = useRef>(new Map()) + const vaultPathRef = useVaultPathRef(vaultPath) + + useEffect(() => { + recentWritesRef.current.clear() + }, [vaultPath]) + + const markInternalWrite = useCallback((path: WatchPath) => { + const root = vaultPathRef.current + if (!root) return + const resolvedPath = resolveChangedPath({ path, vaultPath: root }) + if (isSamePathOrChild({ path: resolvedPath, parent: root })) { + recentWritesRef.current.set(resolvedPath, now()) + } + }, [now, vaultPathRef]) + + const filterExternalPaths = useCallback((paths: WatchPath[]) => { + const root = vaultPathRef.current + if (!root || paths.length === 0) return paths + const currentTime = now() + pruneRecentWrites(recentWritesRef.current, currentTime) + return paths.filter((path) => !recentWritesRef.current.has(resolveChangedPath({ path, vaultPath: root }))) + }, [now, vaultPathRef]) + + return { markInternalWrite, filterExternalPaths } +} + +function clearRefreshQueue({ + debounceTimerRef, + queuedPathsRef, + fullRefreshPendingRef, +}: { + debounceTimerRef: RefObject | null> + queuedPathsRef: RefObject> + fullRefreshPendingRef: RefObject +}) { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = null + queuedPathsRef.current.clear() + fullRefreshPendingRef.current = false +} + +function addQueuedChangedPaths({ + root, + paths, + queuedPaths, + fullRefreshPendingRef, +}: { + root: WatchPath + paths: WatchPath[] + queuedPaths: Set + fullRefreshPendingRef: RefObject +}) { + if (paths.length === 0) { + fullRefreshPendingRef.current = true + return + } + for (const path of paths) { + const resolvedPath = resolveChangedPath({ path, vaultPath: root }) + if (isSamePathOrChild({ path: resolvedPath, parent: root })) queuedPaths.add(resolvedPath) + } +} + +function hasRefreshWork({ + queuedPathsRef, + fullRefreshPendingRef, +}: { + queuedPathsRef: RefObject> + fullRefreshPendingRef: RefObject +}) { + return fullRefreshPendingRef.current || queuedPathsRef.current.size > 0 +} + +function pendingRefreshPaths({ + queuedPathsRef, + fullRefreshPendingRef, +}: { + queuedPathsRef: RefObject> + fullRefreshPendingRef: RefObject +}) { + const fullRefresh = fullRefreshPendingRef.current + return { + fullRefresh, + queuedPaths: fullRefresh ? [] : Array.from(queuedPathsRef.current), + } +} + +function filteredRefreshPaths({ + fullRefresh, + queuedPaths, + filterChangedPaths, +}: { + fullRefresh: boolean + queuedPaths: WatchPath[] + filterChangedPaths?: (paths: WatchPath[]) => WatchPath[] +}) { + return fullRefresh ? queuedPaths : filterChangedPaths?.(queuedPaths) ?? queuedPaths +} + +function handleWatcherEvent({ + event, + root, + enqueueChangedPaths, +}: { + event: { payload: VaultChangedPayload } + root: WatchPath + enqueueChangedPaths: (paths: WatchPath[]) => void +}) { + if (normalizeWatchPath(event.payload.vaultPath) === root) { + enqueueChangedPaths(event.payload.paths ?? []) + } +} + +function usePendingVaultRefresh({ + vaultPathRef, + onVaultChanged, + filterChangedPaths, + debounceMs, +}: { + vaultPathRef: RefObject + onVaultChanged: UseVaultWatcherOptions['onVaultChanged'] + filterChangedPaths: UseVaultWatcherOptions['filterChangedPaths'] + debounceMs: number +}) { + const onVaultChangedRef = useLatestRef(onVaultChanged) + const filterChangedPathsRef = useLatestRef(filterChangedPaths) + const queuedPathsRef = useRef>(new Set()) + const fullRefreshPendingRef = useRef(false) + const debounceTimerRef = useRef | null>(null) + + const clearPendingRefresh = useCallback(() => clearRefreshQueue({ + debounceTimerRef, + queuedPathsRef, + fullRefreshPendingRef, + }), []) + + const flushQueuedRefresh = useCallback(() => { + const { fullRefresh, queuedPaths } = pendingRefreshPaths({ queuedPathsRef, fullRefreshPendingRef }) + clearPendingRefresh() + const filteredPaths = filteredRefreshPaths({ + fullRefresh, + queuedPaths, + filterChangedPaths: filterChangedPathsRef.current, + }) + if (!fullRefresh && filteredPaths.length === 0) return + void Promise.resolve(onVaultChangedRef.current(filteredPaths)).catch((err) => { + console.warn('Vault watcher refresh failed:', err) + }) + }, [clearPendingRefresh, filterChangedPathsRef, onVaultChangedRef]) + + const enqueueChangedPaths = useCallback((paths: WatchPath[]) => { + const root = vaultPathRef.current + if (!root) return + addQueuedChangedPaths({ + root, + paths, + queuedPaths: queuedPathsRef.current, + fullRefreshPendingRef, + }) + if (!hasRefreshWork({ queuedPathsRef, fullRefreshPendingRef })) return + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = setTimeout(flushQueuedRefresh, debounceMs) + }, [debounceMs, flushQueuedRefresh, vaultPathRef]) + + return { clearPendingRefresh, enqueueChangedPaths } +} + +function useNativeVaultWatcher({ + vaultPath, + enqueueChangedPaths, + clearPendingRefresh, +}: { + vaultPath: WatchPath + enqueueChangedPaths: (paths: WatchPath[]) => void + clearPendingRefresh: () => void +}) { + + useEffect(() => { + const root = normalizeWatchPath(vaultPath) + if (!root || !isTauri()) return + + let cancelled = false + let unlisten: UnlistenFn | null = null + + void listen(VAULT_CHANGED_EVENT, (event) => { + handleWatcherEvent({ event, root, enqueueChangedPaths }) + }).then((nextUnlisten) => { + if (cancelled) { + nextUnlisten() + } else { + unlisten = nextUnlisten + } + }).catch((err) => { + console.warn('Failed to subscribe to vault watcher events:', err) + }) + + void invoke('start_vault_watcher', { path: vaultPath }).catch((err) => { + console.warn('Failed to start vault watcher:', err) + }) + + return () => { + cancelled = true + clearPendingRefresh() + unlisten?.() + void invoke('stop_vault_watcher').catch(() => {}) + } + }, [vaultPath, enqueueChangedPaths, clearPendingRefresh]) +} + +export function useVaultWatcher({ + vaultPath, + onVaultChanged, + debounceMs = VAULT_WATCHER_DEBOUNCE_MS, + filterChangedPaths, +}: UseVaultWatcherOptions) { + const vaultPathRef = useVaultPathRef(vaultPath) + const pendingRefresh = usePendingVaultRefresh({ + vaultPathRef, + onVaultChanged, + filterChangedPaths, + debounceMs, + }) + + useNativeVaultWatcher({ + vaultPath, + enqueueChangedPaths: pendingRefresh.enqueueChangedPaths, + clearPendingRefresh: pendingRefresh.clearPendingRefresh, + }) +} diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index c4a1c05b..b2266222 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -316,6 +316,8 @@ "status.vault.cloneGit": "Clone Git repo", "status.vault.cloneGettingStarted": "Clone Getting Started Vault", "status.vault.notFound": "Vault not found: {path}", + "status.vault.reloading": "Reloading vault...", + "status.vault.reloadingTooltip": "Reloading vault from disk", "status.vault.remove": "Remove {label} from list", "status.remote.noneConfigured": "No remote configured", "status.remote.inSync": "In sync with remote",