Skip to content

Save‐Architecture

Umi edited this page May 25, 2026 · 2 revisions

Phing Save Architecture

This page documents how Phing persists data to disk — covering the debounce pipeline, atomic writes, dirty-state tracking, the quit dialogue, the macOS Cmd+Q interception, and external conflict detection. It is intended for contributors and anyone curious about why the code is structured the way it is.


Overview

Phing stores every note as a plain Markdown file and every mind map as a JSON file inside the user's vault directory. All disk I/O flows through two Zustand stores — noteStore and mindMapStore — which share the same three-layer pipeline:

user keystroke
      │
      ▼
  store mutation  (synchronous, in-memory)
      │
      ▼
  100 ms debounce  (coalesces rapid bursts)
      │
      ▼
  WriteQueue  (per-ID coalescing, parallel across IDs)
      │
      ▼
  atomic write  (.tmp → rename)
      │
      ▼
  vault on disk

No write ever reaches disk synchronously. This ensures the UI stays responsive while typing and that a crash mid-write can never corrupt an existing file.


The 100 ms Debounce

Why it exists

A user typing at 60 WPM produces roughly one keystroke every 170 ms. Without debouncing, each character would schedule a separate disk write, causing hundreds of IPC calls per minute and saturating Tauri's inter-process channel.

How it works

Each store maintains a module-level Map<string, ReturnType<typeof setTimeout>> called saveTimers (mind maps) or flushTimers (notes). When a mutation arrives for a document ID:

  1. Any existing timer for that ID is cleared.
  2. A new setTimeout is scheduled for 100 ms in the future.
  3. When the timer fires, the current in-memory state for that ID is serialised and handed to the WriteQueue.

The net effect is that no matter how many mutations arrive within a 100 ms window, only one disk write is issued — carrying the very latest state.

mutations:  ──●──●──●──●──────────────────●──
timers:       [  100ms  ]                 [  100ms  ]
writes:                  ▼                           ▼

Debounce constants

Store Constant Value
noteStore FLUSH_DELAY_MS 100 ms
mindMapStore FLUSH_DELAY_MS 100 ms

Atomic Writes

The problem with direct writes

A direct writeTextFile(path, content) call is not atomic. If Phing crashes or is force-killed mid-write, the target file can be left truncated or entirely empty. For a Markdown note vault, a truncated file means data loss.

The .tmp → rename pattern

Every write follows this two-step pattern:

writeTextFile(path + '.tmp', content)   // write to a temporary file
rename(path + '.tmp', path)             // atomically replace the target

The rename system call on both macOS (via renameat2) and Linux is atomic with respect to the filesystem: any reader of path sees either the old complete file or the new complete file — never a partial state. On macOS specifically, APFS guarantees this even across power failures.

If Phing crashes after the writeTextFile but before the rename, a .tmp file is left on disk. This is handled by the crash recovery system described below.

Implementation locations

Module Function
src/services/mindMapService.ts saveMindMapDoc
src/store/noteStore.ts flushNote (via WriteQueue)

Dirty-State Tracking

Purpose

Before Phing closes, it needs to know whether any writes are still in-flight so it can offer the user a chance to save. This is handled by a synchronous hasPendingWrites() function on each store.

Note store

noteStore tracks two sets:

  • dirtyNoteIds — IDs of notes whose debounce timer has fired and whose write is queued or in-flight.
  • flushTimers — IDs of notes whose debounce timer has not yet fired (mutations arrived but 100 ms has not elapsed).
hasPendingWrites: () => flushTimers.size > 0 || get().dirtyNoteIds.size > 0

A note is removed from dirtyNoteIds only after its write completes successfully.

Mind map store

The mind map store uses a module-level Set<string> called dirtyMindMapIds, mirroring the notes pattern. The check is:

hasPendingWrites: () => saveTimers.size > 0 || dirtyMindMapIds.size > 0

Brute-force flush on quit

Rather than trying to replay only the dirty IDs (which requires careful coordination), flushAllPending() saves every loaded document unconditionally when called at quit time. This is safe because:

  • It runs after the user confirms they wish to save.
  • Writing an unchanged document is idempotent.
  • It eliminates any edge case where a document's dirty flag was incorrectly cleared.

Flush-on-Quit: the Quit Dialogue

Overview

When the user tries to close Phing, a modal dialogue — QuitDialogue — is shown with three options:

Button Behaviour
Save & Quit Calls flushAll() to flush both stores, then invokes confirm_quit in Rust to exit. A 5-second bail timer ensures the process always terminates even if a write hangs.
Discard Invokes confirm_quit immediately, abandoning any unsaved changes.
Cancel Dismisses the dialogue; the window remains open.

Two interception paths

The quit dialogue is triggered from two independent sources, both of which call setQuitPending(true) in React:

  1. The × button — handled by Tauri's onCloseRequested window event. event.preventDefault() holds the window open while the dialogue is shown.
  2. Cmd+Q on macOS — handled by a custom menu item (see below). The menu event fires before [NSApp terminate:] is ever called.

The confirm_quit Rust command

Once the user confirms, the frontend invokes confirm_quit:

#[tauri::command]
fn confirm_quit(app: tauri::AppHandle) {
    app.state::<QuitConfirmed>().0.store(true, Ordering::SeqCst);
    app.exit(0);
}

The QuitConfirmed flag is checked by the RunEvent::ExitRequested fallback handler (used on Windows and Linux) to prevent it from intercepting the app's own exit call and showing the dialogue a second time.


macOS Cmd+Q Interception

Why the obvious approach fails

The natural Tauri approach for intercepting application-level quit is RunEvent::ExitRequested with api.prevent_exit(). On Windows and Linux, this works correctly. On macOS, it does not.

The root cause is in tao (the window framework underlying Tauri): tao's macOS NSApplicationDelegate has no implementation of applicationShouldTerminate:. When the user presses Cmd+Q, macOS sends this selector to the app delegate, receives the default NSTerminateNow response from NSObject, and proceeds immediately with app termination — tearing down the WKWebView as part of the Cocoa shutdown sequence. Only after the webview has been suspended does RunEvent::ExitRequested arrive in the Rust event loop. At that point, win.emit('phing://close-requested', ()) is sent to a context that can no longer process JavaScript, so the dialogue never appears.

The menu hijack

The fix exploits a macOS UI invariant: Cmd+Q is always bound to the 'Quit' item in the first application submenu. By replacing PredefinedMenuItem::quit — which calls [NSApp terminate:] when activated — with a plain MenuItem carrying the same Cmd+Q accelerator, we intercept the key binding at the menu-selection stage, before any Cocoa termination sequence has started.

let quit_item = MenuItem::with_id(
    app, 'phing-quit', 'Quit Phing', true, Some('Cmd+Q')
)?;

When this item is selected (whether via the menu bar or the keyboard shortcut), Tauri fires on_menu_event. At this point the application is in a completely normal running state, the WKWebView is fully alive, and the emitted event reliably reaches the JS listener:

app.on_menu_event(|app, event| {
    if event.id() == 'phing-quit' {
        if let Some(win) = app.get_webview_window('main') {
            let _ = win.emit('phing://close-requested', ());
        }
    }
});

The frontend's listen('phing://close-requested', ...) handler calls setQuitPending(true), and the QuitDialogue appears as normal.

Edit submenu

Building a custom application menu requires that all desired submenus be declared explicitly. The Edit submenu is included because WKWebView relies on the macOS first-responder chain to implement Undo, Redo, Cut, Copy, Paste, and Select All. Without the corresponding PredefinedMenuItem entries, these actions have no menu-level target and their keyboard shortcuts stop working in the editor.

Full quit lifecycle on macOS

User presses Cmd+Q
      │
      ▼
macOS activates 'Quit Phing' menu item
      │   (custom MenuItem — no [NSApp terminate:])
      ▼
on_menu_event fires in Rust
      │
      ▼
win.emit('phing://close-requested')
      │   (webview fully alive)
      ▼
JS listen() handler: setQuitPending(true)
      │
      ▼
QuitDialogue renders
      │
      ├── Save & Quit → flushAll() → invoke('confirm_quit')
      │                                     │
      │                                     ▼
      │                              Rust: QuitConfirmed = true
      │                              Rust: app.exit(0)
      │
      ├── Discard     → invoke('confirm_quit')  (same path, no flush)
      │
      └── Cancel      → setQuitPending(false)  (nothing exits)

External Conflict Detection

Phing uses vaultWatcher — a thin wrapper around @tauri-apps/plugin-fs's file-watcher — to detect when an external process (another editor, a sync daemon such as iCloud Drive or Dropbox) modifies a file that Phing currently has open in memory.

Detection algorithm

When a filesystem change event arrives for a file that matches an in-memory note:

  1. The file is read from disk.
  2. A SHA-1-style content hash is computed for both the disk version and the in-memory version (serialised via encodeNote).
  3. If the hashes differ, a ConflictInfo object is set in React state, which triggers the ConflictDialogue.

The hash comparison means that Phing's own writes — which temporarily change the file on disk — do not spuriously trigger the dialogue. A short suppression window around each write provides additional protection against false positives from filesystem event latency.

Resolution options

Action Behaviour
Reload from disk The in-memory note is replaced with the on-disk version via reloadNote.
Keep local The in-memory version is force-flushed to disk via flushNote, overwriting the external change.
Dismiss The dialogue is closed; neither version is changed. The conflict remains unresolved until the next save.

Crash Recovery

When Phing writes a file it first creates a .tmp sibling, then renames it over the target. If the process is killed between those two steps, a .md.tmp or .json.tmp file is left on disk.

On the next vault open, findOrphanedTmpFiles scans the vault for these files. For each one found, recoverOrphanedTmp attempts to:

  1. Read the .tmp file and parse it as a valid note or mind map document.
  2. If valid, rename it over the target path (completing the interrupted write).
  3. If invalid or unreadable, delete it.

This ensures that even a hard crash mid-write results in at most one revision of lost work, never a corrupted or empty file.


Architecture at a Glance

┌─────────────────────────────────────────────────────────┐
│                     React / TypeScript                  │
│                                                         │
│  keystroke → patchNote / renameMindMap                  │
│                   │                                     │
│           100 ms debounce                               │
│                   │                                     │
│             WriteQueue (per-ID coalescing)              │
│                   │                                     │
│          Tauri IPC (invoke / plugin-fs)                 │
└───────────────────┬─────────────────────────────────────┘
                    │
┌───────────────────▼─────────────────────────────────────┐
│                  Rust / Tauri                           │
│                                                         │
│  tauri_plugin_fs::writeTextFile(.tmp)                   │
│  tauri_plugin_fs::rename(.tmp → target)  ← atomic      │
│                                                         │
│  on_menu_event('phing-quit')                            │
│    └─ win.emit('phing://close-requested')               │
│                                                         │
│  confirm_quit command                                   │
│    └─ QuitConfirmed = true → app.exit(0)               │
└─────────────────────────────────────────────────────────┘
                    │
┌───────────────────▼─────────────────────────────────────┐
│                   Vault on disk                         │
│                                                         │
│  notes/         *.md                                    │
│  .phing/        mindmaps/*.json                         │
│  (transient)    *.md.tmp, *.json.tmp  → crash recovery  │
└─────────────────────────────────────────────────────────┘

Clone this wiki locally