File Watchers
The native watcher system monitors explicitly local files and development REST fallbacks. Production workspace changes arrive through the cloud bridge instead; there is no process-wide local workspace watcher. Native watchers use the notify crate with custom debouncing via Instant tracking (no blocking sleep).
Architecture
Each watcher consists of:
A
notify::RecommendedWatcherthat monitors a directory or fileAn
mpscchannel that receives raw filesystem eventsA background thread that debounces events and emits Tauri events
All watcher state is stored in WatcherState:
pub struct WatcherState {
pub messages_watcher: Option<notify::RecommendedWatcher>,
pub messages_write_mtime: Arc<Mutex<HashMap<String, u64>>>,
pub active_draft_watcher: Option<notify::RecommendedWatcher>,
pub active_draft_watched_path: Option<String>,
pub draft_write_mtime: Arc<Mutex<u64>>,
pub skills_watchers: HashMap<String, notify::RecommendedWatcher>,
pub notes_watchers: HashMap<String, NotesWatcherEntry>,
pub external_file_watchers: HashMap<String, ExternalWatcherEntry>,
}messages_watcher,active_draft_watcher, andnotes_watcherssupportdev:restwhen no cloud workspace is availableskills_watcherswatches user-config skill directories and other explicitly requested skill rootsexternal_file_watchersis the refcounted map used by External File EditorThe mtime maps and fields suppress events caused by the app's own local writes
Inbox directory additions/removals are handled by the directory-parameterized notes watcher (notes_watch_dir / notes_unwatch_dir). The active inbox file has its own one-file watcher (inbox_watch_active / inbox_unwatch_active) so the editor can detect external writes to the currently open numbered note.
Debounced Watch Loop
The watcher implementations share a generic debounced_watch_loop() function that eliminates the duplicated debounce pattern:
fn debounced_watch_loop<T, F, E>(
rx: mpsc::Receiver<Event>,
debounce: Duration,
matches_event: F, // Returns Some(T) if relevant, None to skip
on_emit: E, // Called after debounce with the matched value
)This keeps all captured variables Send-safe inside thread::spawn closures, using return values instead of shared mutable state.
Development REST Watchers
When dev:rest runs without a cloud workspace, start_messages_watcher can monitor the fallback archives/ directory and publish messages:changed over SSE. Note Tray and active-draft watchers provide the equivalent fallback events for their requested directories and files. These watchers are not a production workspace storage mechanism.
Event: messages:changed with { filename: string }
Behavior:
Watches
archives/non-recursivelyFilters for
CreateandModifyevents on.mdfilesExcludes files starting with
indexDebounce: 300ms — uses
recv_timeoutwithInstanttracking. After the timeout fires with no new events, the last filename is emittedDoes nothing if the fallback
archives/directory doesn't existAlso sends SSE events via
event_txfor REST adapter clients
Self-Write Detection
Local watchers use mtime-based self-write detection:
After the app writes a watched file, it records the file's current mtime
When the watcher detects a change, it compares the current mtime against the recorded mtime
If the current mtime is greater, it's an external change — emit the event
If the current mtime matches, the change was caused by the app — ignore it
The values are shared between watcher background threads and write helpers. On-demand watchers are released when their final consumer unsubscribes; cloud workspace changes use their own subscription lifecycle.
Locking
Watcher bookkeeping is protected by the watchers mutex. Operations copy the paths and handles they need, then release that lock before doing longer-running I/O.
Debounce Implementation
All watchers use the same non-blocking debounce pattern via debounced_watch_loop:
loop {
match rx.recv_timeout(debounce_duration) {
Ok(event) => {
// Record event, update last_event_time
}
Err(Timeout) => {
if pending && last_event_time.elapsed() >= debounce_duration {
// Emit the event
}
}
Err(Disconnected) => break,
}
}This avoids thread::sleep() and ensures events are emitted promptly after the debounce window closes.
External File Watcher
Watches arbitrary files on the filesystem for external modifications. Used by the External File Editor (EFE) to detect when another program modifies the currently open file and prompt the user to resolve the conflict.
Commands: file_watch_external(path: String) and file_unwatch_external(path: String) — #[tauri::command] exposed to the frontend via the bridge (bridge.files.watchExternal / bridge.files.unwatchExternal).
Event: files:externalChange with { path: string } (camelCase) — global event, no frame targeting. Each EFE leaf filters on its own live active-tab path in the onExternalChange callback.
Behavior:
Keyed by absolute file path in
external_file_watchers(aHashMap<String, ExternalWatcherEntry>)Refcounted —
file_watch_externalincrements the refcount for the path;file_unwatch_externaldecrements it and only tears down the OS watcher when the count reaches zero. This lets multiple EFE leaves (or re-mounts of the same leaf) watch the same path without interfering with each other.Watches the parent directory of the target file non-recursively; filters for events matching the target filename
Debounce: 200ms (same as the draft watcher)
Self-write guard:
files_write_textbumps thewrite_mtimefield of the matching path entry after the atomic write. The watcher background thread compares the event mtime againstwrite_mtime; if equal the event is treated as a self-write and suppressed. A secondary content-equality guard runs in the frontend (disk === buffer → bail) as the definitive defence.
Bridge surface (bridge.files)
| Method | Description |
|---|---|
watchExternal(path) | Start watching path; increments the refcount. Returns Promise<boolean>. |
unwatchExternal(path) | Stop watching path; decrements the refcount. Returns Promise<boolean>. |
onExternalChange(cb: (path: string) => void) | Register a listener for files:externalChange; returns an unsubscribe function. |
The mock adapter (createMockAdapter) stubs watchExternal / unwatchExternal as no-ops that always resolve to true. Listeners registered via onExternalChange are stored and can be dispatched from tests via controls.triggerFilesExternalChange(path).
Lifecycle in the EFE provider
The EFE provider (external-file-editor-provider.tsx) manages the watcher lifecycle around the active tab path:
On mount — registers a single global
bridge.files.onExternalChangelistener (once, not re-registered on tab switch).On active-path change — calls
bridge.files.unwatchExternal(prevPath)thenbridge.files.watchExternal(newPath).Immediately after switching to a tab — performs a disk-vs-buffer comparison (
bridge.files.readText(newPath)) to catch edits that happened while the tab was inactive (the silent-stale-background-tab hole; no watcher was running for inactive tabs).On unmount — calls
bridge.files.unwatchExternal(activePath)and unregisters the global listener.
When the onExternalChange callback fires for the active path and the disk content differs from the buffer, session.setTabConflict(tabId, diskContent) is called. This sets ExternalTab.externalIncoming and notifies subscribers, causing the conflict banner to render.