-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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:
- Any existing timer for that ID is cleared.
- A new
setTimeoutis scheduled for 100 ms in the future. - 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: ▼ ▼
| Store | Constant | Value |
|---|---|---|
noteStore |
FLUSH_DELAY_MS |
100 ms |
mindMapStore |
FLUSH_DELAY_MS |
100 ms |
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.
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.
| Module | Function |
|---|---|
src/services/mindMapService.ts |
saveMindMapDoc |
src/store/noteStore.ts |
flushNote (via WriteQueue) |
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.
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 > 0A note is removed from dirtyNoteIds only after its write completes successfully.
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 > 0Rather 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.
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. |
The quit dialogue is triggered from two independent sources, both of which call setQuitPending(true) in React:
-
The × button — handled by Tauri's
onCloseRequestedwindow event.event.preventDefault()holds the window open while the dialogue is shown. -
Cmd+Q on macOS — handled by a custom menu item (see below). The menu event fires before
[NSApp terminate:]is ever called.
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.
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 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.
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.
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)
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.
When a filesystem change event arrives for a file that matches an in-memory note:
- The file is read from disk.
- A SHA-1-style content hash is computed for both the disk version and the in-memory version (serialised via
encodeNote). - If the hashes differ, a
ConflictInfoobject is set in React state, which triggers theConflictDialogue.
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.
| 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. |
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:
- Read the
.tmpfile and parse it as a valid note or mind map document. - If valid, rename it over the target path (completing the interrupted write).
- 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.
┌─────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────┘
Back to Home | Report an Issue | Official Repository | Technical Architecture & Data Reliability Overview | Save-Architecture
© 2026 Umi. All rights reserved.