zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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:

  1. A notify::RecommendedWatcher that monitors a directory or file

  2. An mpsc channel that receives raw filesystem events

  3. A 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, and notes_watchers support dev:rest when no cloud workspace is available

  • skills_watchers watches user-config skill directories and other explicitly requested skill roots

  • external_file_watchers is the refcounted map used by External File Editor

  • The 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-recursively

  • Filters for Create and Modify events on .md files

  • Excludes files starting with index

  • Debounce: 300ms — uses recv_timeout with Instant tracking. After the timeout fires with no new events, the last filename is emitted

  • Does nothing if the fallback archives/ directory doesn't exist

  • Also sends SSE events via event_tx for REST adapter clients

Self-Write Detection

Local watchers use mtime-based self-write detection:

  1. After the app writes a watched file, it records the file's current mtime

  2. When the watcher detects a change, it compares the current mtime against the recorded mtime

  3. If the current mtime is greater, it's an external change — emit the event

  4. 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 (a HashMap<String, ExternalWatcherEntry>)

  • Refcountedfile_watch_external increments the refcount for the path; file_unwatch_external decrements 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_text bumps the write_mtime field of the matching path entry after the atomic write. The watcher background thread compares the event mtime against write_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)

MethodDescription
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:

  1. On mount — registers a single global bridge.files.onExternalChange listener (once, not re-registered on tab switch).

  2. On active-path change — calls bridge.files.unwatchExternal(prevPath) then bridge.files.watchExternal(newPath).

  3. 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).

  4. 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.