diff --git a/CLAUDE.md b/CLAUDE.md
index 075b3ba..24263ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -40,7 +40,26 @@ Notes also move between backends via `.md` export/import (`src/storage/transfer.
lives in `src-tauri/` (only `src-tauri/src/lib.rs` carries app code: `notes_*` + `attachment_*` fs
commands (the bulk walks skip iCloud **dataless** files' content — `SF_DATALESS` in `st_flags` —
because reading one blocks on download; they list by name/mtime with empty previews, and an explicit
-single-note open still materializes), the folder ops, `reveal_path`, and the **window commands** — a
+single-note open still materializes), the folder ops, `reveal_path`, the **live folder watcher**
+(`notes_watch`/`notes_unwatch` + the `Watchers` state: ONE debounced `notify`/FSEvents stream per
+open folder, with per-window-label REFCOUNTED subscriptions — StrictMode makes `watch, watch,
+unwatch` a legal wire order, so a set would break — emitting `notes:changed` `{dir, paths}` to each
+subscriber via `emit_to`; `dir` is the RAW path the frontend passed (strict-equals
+`TauriNoteStore.dir`) while `strip_prefix` runs against the canonicalized root — FSEvents resolves
+`/var`→`/private/var` and symlinks; the `watch_rel_path` filter passes `.md` leaves (INCLUDING
+dot-named ones — the note walks list `.hidden.md`, only dot DIRS are skipped), existing DIRS
+(a Finder folder rename reports ONLY the dir paths), and vanished paths (unclassifiable → include),
+dropping dot dirs/`node_modules`/root-`Attachments/`/write temps (the `WRITE_TMP_SUFFIX`/
+`RENAME_TMP_SUFFIX` consts — the TS stores mint `.rename-tmp`, mirror-commented there); >64 paths,
+a watcher error, or an event on the WATCH ROOT itself (FSEvents' queue-overflow rescan signal, all
+that survives the debouncer) → EMPTY `paths` = "refresh everything"; never drop a `WatcherEntry`
+under the mutex — Drop joins the debouncer thread, so `remove_window_subscriptions` returns the
+emptied entries for the caller to drop unlocked; subscriptions are drained on `Destroyed`, on
+`on_page_load` Started (a reload orphans the old JS context's disposers and fires no Destroyed —
+without the reset every crash-reload would leak refcounts and pin dead watchers alive), and by
+`reap_dead_window_subscriptions` after each `notes_watch` (the unlocked build gap can land a
+subscription for an already-destroyed window that nothing else would ever drain)), and the
+**window commands** — a
label→workspace map plus a label→note map powering
`window_workspace`/`set_window_workspace`/`focus_workspace_window`/`open_workspace_window` and
`window_note`/`set_window_note`/`window_note_renamed`/`window_note_removed`/`open_note_window`, plus
@@ -117,8 +136,8 @@ FolderGate ──▶ NoteStore (filesystem | tauri-fs | indexeddb) ──▶ use
All persistence sits behind the **`NoteStore` interface** (`src/storage/types.ts`) — the key extension
seam, with three backends (two of them folder-of-`.md`). A note id is its POSIX rel-path (`
.md`
at the root, `Work/Sub/.md` when nested); the basename without `.md` is the title. The seam
-spans notes, **nested folders**, and **media attachments** (plus an optional desktop-only `reveal`).
-Anything above the seam (`useNotes`, navigation, UI) is backend-agnostic.
+spans notes, **nested folders**, and **media attachments** (plus optional desktop-only `reveal` and
+`watch`). Anything above the seam (`useNotes`, navigation, UI) is backend-agnostic.
Key modules:
@@ -127,7 +146,11 @@ Key modules:
seam also covers nested folders (`create(title, parentPath)`, `move`, `createFolder`/`removeFolder`/
`moveFolder`/`listFolders`, and `listsRecursively` so metadata reconcile never prunes ids a backend
can't yet enumerate), media attachments (`writeAttachment`/`writeAttachmentAt`/`readAttachment`/
- `listAttachments`/`removeAttachment`), and an optional desktop-only `reveal(relPath)`.
+ `listAttachments`/`removeAttachment`), an optional desktop-only `reveal(relPath)`, and an optional
+ desktop-only `watch(onChange)` (external-change push: rel-paths, EMPTY = "many/unknown"; resolves
+ to a SYNC disposer; `TauriNoteStore.watch` listens window-scoped for `notes:changed` filtered by
+ `payload.dir === this.dir`, subscribes the listener BEFORE invoking `notes_watch` so no event can
+ slip the gap, and unlistens+rethrows if the invoke fails).
- `src/search.ts` — pure full-text ranking (no I/O, no React): `tokenizeQuery`, `scoreNoteText`
(multi-term AND; title ≫ body, word-boundary/prefix/phrase boosts), `buildSnippet`, `searchNotes`.
The corpus (id → body, plus a pre-lowercased `lowerById` so a big folder isn't re-lowercased on
@@ -230,7 +253,36 @@ Key modules:
`active` keeps tracking the window's own note for the conflict checks). Re-lists on window focus
(2 s throttle, `ready`-gated) — main + note windows
routinely show the same folder now, so a returning window picks up notes created/edited elsewhere.
- Exposes `flushPending()` (teardown) and `refresh()` (re-list after import). Takes a `NoteStore` —
+ On desktop it also subscribes to `store.watch` (`ready`-gated): external changes push a
+ 300 ms-debounced, in-flight-coalesced pipeline that runs `checkOpenNoteConflict` THEN `refresh()`
+ (that order matters — `reconcile` nulls a vanished `active`, so refresh-first would swallow the
+ deleted-conflict banner; the focus re-list runs the same check-then-refresh order for the same
+ reason; the corpus follows the list signature for free). `checkOpenNoteConflict` RE-VALIDATES
+ after its `store.stat` await (same id, no conflict/pending/save, baseline unchanged) — a note
+ switch mid-stat would otherwise raise a phantom conflict for the WRONG note, whose banner
+ actions key off `conflict.id` and would cross-contaminate the two notes — and mirrors a raised
+ conflict into `conflictRef` synchronously (the effect mirror lags a render; flush's raise sites
+ do too), because `refresh()` reads the ref to KEEP a vanished `active` alive while its
+ deleted-conflict banner is up (nulling it would silently drop every keystroke typed under the
+ banner — `edit()` records nothing without an id). `refresh()` also merges newest-wins per row,
+ so a save landing mid-walk isn't clobbered back to a stale preview (`addNote` replaces-not-
+ appends for the same reason). The hook's own writes echo back through the watcher via
+ `recentLocalWritesRef` (3 s window; every mutation stamps its rel-path — store-returned, post-
+ sanitize — AND the `dirname()` ancestor chain, since fs ops churn ancestor dirs; an EMPTY
+ `paths` event is NEVER suppressible, `[].every()` is vacuously true): echo-only batches skip
+ the 300 ms cadence but are NEVER dropped — they defer ONE trailing verify run
+ (`WATCH_SUPPRESSED_VERIFY_MS`, past the stamp window, superseded by any sooner real run), so a
+ stamp false-positive (an external dir-only event under an ancestor stamp, an external rewrite
+ of a just-saved note) delays a refresh instead of losing it; correctness additionally never
+ rests on suppression because a save advances `baselineRef` before its echo lands.
+ `saveInFlightRef` gates the conflict check (mid-save, `pendingRef` is already null and
+ `baselineRef` stale → phantom conflict). Events while `hidden` latch and replay on
+ `visibilitychange` (visible ≠ focused, so the focus re-list wouldn't cover it; `run()` re-checks
+ visibility at fire time). A failed `watch()` subscription degrades to focus-refresh with a
+ `console.warn`. Exposes `flushPending()` (teardown), `refresh()` (plain re-list), `reload()`
+ (check-then-refresh — the orb menu's "Reload notes" and post-import path; a bare `refresh()`
+ there would swallow the deleted-conflict), and `withBulkWrites()` (holds the watcher/focus
+ pipelines off during import — bulk writes bypass the echo stamps). Takes a `NoteStore` —
agnostic to which backend it is.
- `src/hooks/useCorpus.ts` — the **shared body corpus**, loaded once (lazily, while a query is live or a
note is open) and shared by full-text search AND backlinks, so `getAll()` runs once (one big IPC on
@@ -384,6 +436,16 @@ Key modules:
branches; `@tauri-apps/plugin-dialog`/`-updater`/`-process` are loaded via dynamic `import()` so they
never enter the web bundle).
+## Docs
+
+Human-facing docs live in `docs/`: `architecture.md` (how it's built, the on-disk format, and the
+**known limitations** — the canonical list) and `shortcuts.md` (the full shortcut sheet). The
+README stays slim — features, two-path Getting started (download / from source), doc pointers,
+backlog. Keep these in sync with code changes: a new/changed shortcut updates `docs/shortcuts.md`
+alongside the `SHORTCUTS` descriptor in `src/shortcuts.ts` (the in-app `⌘/` dialog renders from the
+descriptor, the doc is by hand); a new limitation, storage-format change, or architectural shift
+updates `docs/architecture.md`; a new user-facing feature gets a README feature bullet.
+
## Roadmap
Roadmap, TODOs, and backlog live in `README.md`.
diff --git a/README.md b/README.md
index 3a586a5..5baaf16 100644
--- a/README.md
+++ b/README.md
@@ -20,105 +20,38 @@ Markdown files, and they are yours.
## Features
-- **Choose your storage** on first run: a folder of plain `.md` files (Chromium browsers, or natively
- in the desktop app), or in-browser/in-app storage that works everywhere
-- **Export / import**: download all notes as a `.md` zip, or import `.md` files / a zip — so you own
- your data regardless of backend, and can migrate between them. Your **folder structure** is
- preserved both ways, including deliberately-empty folders (kept alive by a `.gnkeep` marker)
-- Sidebar list of notes with create / rename / delete, **pinning**, and four **sort modes**
- (updated, created, title A→Z / Z→A)
-- **Note icons** (opt-in via Settings, `⌘,`): tag a note with a Gravity icon or an emoji from a
- searchable picker — on the note title and on every list row
-- **Trash**: deleting a note moves it to a Trash (a hidden `.trash/` folder, so it leaves your notes
- but isn't erased) you can **restore** from — back to its original folder — or **empty**. Open it from
- the storage menu (the `⋯` orb)
-- **Full-text search** across note titles _and_ bodies, ranked by relevance, with the matching
- passage shown as a snippet in the list (multi-word queries match all terms)
-- Gravity Markdown editor (WYSIWYG + markup modes) with a read-only **preview** mode (a floating
- "Preview" chip shows while it's on — click it to exit)
-- **Live URLs**: bare `https://…` links render as real links in the editor and the preview (typing one
- linkifies on the trailing space), and open in your browser via ⌘-click (or a plain click in
- preview). Explicit schemes only — a stray `Notes.md` never turns into a link or gets rewritten
-- **`[[wiki links]]`** between notes: type `[[` for a note picker, or write them by hand. They render
- like links (no brackets) and ⌘-click follows them — creating the note if it doesn't exist yet;
- unresolved links are dimmed. Stored verbatim as `[[Title]]`, so they're Obsidian-compatible
-- **Backlinks**: a "linked references" panel under each note lists every note that links to it, with
- the surrounding context
-- **Recent-note history**: `⌘[` / `⌘]` step back / forward through the notes you've visited, browser-style
-- Debounced **autosave**, with a status indicator and unsaved-changes guards
-- **Conflict handling** when a note changes underneath you (reload / keep mine / save a copy / discard)
-- Light / dark / system theme
-- **Appearance settings** (`⌘,`): pick the editor font (system sans, a bundled serif, or mono), an
- accent color, and the text-column width — app-wide, per workspace, or per note (the `⋯` button or
- `⌘⇧I`). Per-note choices live in the folder's metadata, so they survive renames and moves
-- **Multiple workspaces**: open several note folders and switch with `⌃R` (or the orb menu's Open
- Recent) — on the desktop, each workspace can have its own window (`⌘-click` a recent, `⌘↵` in the
- switcher — works on "Open Folder…" too)
-- **Per-note windows** (desktop): `⌘↵` or `⌘-click` a note in the list (or "Open in New Window" from
- its menu) to open just that note in a small window with the panels tucked away — reopening the same
- note focuses its existing window, and `⌘0` brings back the workspace's main window
-- **Automatic updates** (desktop app): the macOS app checks for a newer release on launch and installs
- it in place once you confirm — plus a manual **Check for Updates…** in the storage menu. Updates are
- delivered through GitHub Releases and verified by signature
-- **Keyboard-first** navigation (nvALT / Notational Velocity style): type to search-or-create, arrow to
- preview, Enter to edit, Esc to step back. Press `⌘/` in the app for the full shortcut sheet.
-
-### Keyboard shortcuts
-
-| Keys | Action |
-| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
-| Type in the search box | Full-text search of titles + bodies (ranked); `Enter` opens the top match, or creates a note titled with the query when nothing matches |
-| `Tab` (in the search box) | Accept the inline autocomplete — fill the box with the top match's title (nvALT-style) |
-| `↑` / `↓` (or `k` / `j`) | Preview the previous / next note |
-| `⌘J` / `⌘K` | Preview next / previous note (works while editing) |
-| `⌘[` / `⌘]` | Go back / forward through visited notes (browser-style history) |
-| `Enter` | Edit the selected note |
-| `⌘Enter` / `⌘-click` | Open the selected note in its own window (desktop) |
-| `⌘0` | Show this workspace's main window (desktop; also in Window ▸ Main Window) |
-| `Esc` | Editor → list → search (then close / clear) |
-| `⌘L` | Jump to the search box (`⌘L` in the desktop app; browsers reserve it) |
-| `⌘⇧Enter` / `⌘N` | New note (`⌘N` in the desktop app; browsers reserve it) |
-| `⌘\` | Toggle the sidebar |
-| `⌘'` | Peek the collapsed sidebar / focus the list |
-| `⌘⇧\` | Toggle the folder rail |
-| `⌘⇧;` | Toggle WYSIWYG / Markup |
-| `⌘⇧P` | Toggle read-only preview |
-| `⌘⇧K` | Insert link (in the editor) |
-| `[[` | Open the wiki-link note picker (in the editor) |
-| `⌘-click` a link | Open a URL in your browser, or follow a `[[wiki link]]` to its note (creating it if needed) |
-| `F2` | Rename the selected note, or the focused folder in the rail |
-| `⌘⇧M` | Move the selected note to a folder |
-| `⌘⇧⌫` | Move the selected note to the Trash (recoverable) |
-| `⌘/` | Show the shortcut help |
-
-**Right-click** a note or folder for its actions (pin, rename, move, duplicate, delete, …) — the same
-menu the row's `⋯` button opens, at the cursor.
-
-### Folders
-
-A **folder rail** (toggle with `⌘⇧\`) lists your folders left of the notes list; it's off by default,
-so the app stays a two-pane view until you want it. Selecting a folder scopes the notes list to it
-(**All Notes** shows everything), while search stays global. With the rail closed, a small **folder
-chip** above the list names the active scope (click it to open the rail, ✕ to go back to All Notes).
-**New note** (`⌘N`) lands in the selected
-folder. Drag a note onto a folder to file it, or drag a folder onto another to nest it (onto **All
-Notes** to move it back to the root). Double-click or `F2` renames a folder; with a folder focused, `n`
-makes a subfolder and `⌫` removes an empty one.
-
-## Requirements
-
-Any modern browser works. **Folder storage** in the browser uses the
-[File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API), which is
-Chromium-only (Chrome, Edge, …); on Firefox/Safari the first-run screen offers only **in-browser
-storage** (IndexedDB). The **desktop app** (Tauri, macOS arm64) reads/writes the folder natively, so
-folder storage works there regardless — with no per-session permission re-grant. Use **Export** to
-get your notes out as plain `.md` files at any time.
-
-Building the desktop app needs a Rust toolchain (**rustc ≥ 1.88**; [rustup](https://rustup.rs) is
-recommended) and the Xcode Command Line Tools.
+- **Your notes are plain `.md` files** in a folder you own — or in-browser storage; export/import
+ a zip either way
+- **One box, keyboard-first** (nvALT-style): type to search or create, `Enter` to open, `Esc` to
+ step back — every action has a key (`⌘/` for the sheet, or [docs/shortcuts.md](docs/shortcuts.md))
+- **Gravity Markdown editor**: WYSIWYG and markup modes, read-only preview, live URLs
+- **`[[Wiki links]]` and backlinks**, stored verbatim — Obsidian-compatible
+- **Full-text search** across titles and bodies, ranked, with match snippets
+- **Nested folders, pins, sort modes, note icons**, and a recoverable **Trash**
+- **Images**: paste or drop into a note — stored as files in `Attachments/`, click to zoom
+- **Plays nice with other tools**: external edits show up live in the desktop app (a folder
+ watcher), with conflict handling when a note changes underneath you
+- **Multiple workspaces & windows** (desktop): switch folders with `⌃R`, open a workspace — or a
+ single note — in its own window
+- **Make it yours**: light/dark/system theme, editor font, accent color, text width — app-wide,
+ per workspace, or per note
+- **Autosave**, visited-note history (`⌘[` / `⌘]`), and automatic signed updates in the macOS app
## Getting started
+### Download the app (macOS)
+
+Grab the latest `.dmg` from the
+[releases page](https://github.com/resure/gravity-notes/releases/latest) (Apple silicon). The app
+updates itself in place from there. On first run, pick **Open a folder** (plain `.md` files you
+own) or **Store inside the app** — you can switch later, or export/import, from the storage menu
+in the top bar.
+
+### Run from source
+
+You'll need Node.js. The desktop app additionally needs a Rust toolchain (**rustc ≥ 1.88**;
+[rustup](https://rustup.rs) is recommended) and the Xcode Command Line Tools.
+
```bash
npm install
npm run dev # start the dev server (http://localhost:5173)
@@ -130,71 +63,24 @@ npm run tauri:dev # run the macOS desktop app against the dev server
npm run tauri:build # build the .app / .dmg (arm64) → src-tauri/target/release/bundle
```
-On first run, pick **Open a folder** or **Store in this browser** (**Store inside the app** in the
-desktop build). The choice is remembered across reloads. In the browser, a folder re-prompts for
-permission each session (a browser security requirement); the desktop app does not. Switch later — or
-export/import — from the storage menu in the top bar.
+In the browser, any modern one works — but **folder storage** uses the
+[File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API), which
+is Chromium-only (Chrome, Edge, …) and re-prompts for permission each session; Firefox/Safari get
+in-browser storage. The desktop app reads the folder natively, with no re-prompting.
-## Architecture
+## Documentation
-```
-FolderGate ──▶ NoteStore (filesystem | tauri-fs | indexeddb) ──▶ useNotes() ──▶ NoteList + EditorPane
-(choose storage) (.md on disk: web FSA / native Rust; or IndexedDB) (state + autosave) (UI)
-```
+- [docs/shortcuts.md](docs/shortcuts.md) — every keyboard shortcut (also `⌘/` in the app)
+- [docs/architecture.md](docs/architecture.md) — how it's built, what's written to your disk,
+ and known limitations
-All persistence sits behind the `NoteStore` interface (`src/storage/types.ts`), with three backends:
-`FileSystemNoteStore` (`src/storage/fileSystemStore.ts`, plain `.md` files via the browser File System
-Access API), `TauriNoteStore` (`src/storage/tauriStore.ts`, the same `.md` folder via native Rust `fs`
-commands in the desktop app), and `IndexedDbNoteStore` (`src/storage/indexedDbStore.ts`, in-browser).
-All share the same `.md` ids, canonical body shape, and `updatedAt`-based conflict semantics
-(`src/storage/noteText.ts`), so everything above the seam is backend-agnostic. Per-folder/-store
-metadata — sort mode, pins, created stamps, the open note — lives in `metadata.ts`. Every opened
-folder/store is a **workspace** in an IndexedDB registry (`src/storage/workspaceRegistry.ts`) that
-feeds the recents UI: the orb menu's "Open Recent" submenu, the ⌃R switcher, and (desktop) one native
-window per workspace. The desktop shell is `src-tauri/` (Tauri 2); its app code is the `notes_*`
-filesystem commands plus the workspace-window commands in `src-tauri/src/lib.rs`.
-
-Key modules:
-
-- `src/storage/` — `types.ts` (the `NoteStore` seam), `fileSystemStore.ts` + `tauriStore.ts` +
- `indexedDbStore.ts` (the three backends), `noteText.ts` (shared id/body helpers), `metadata.ts`
- (sort/pins sidecar), `transfer.ts` (`.md` zip export/import), `workspaceRegistry.ts` (the
- workspace registry: known folders/stores + recency + last-active)
-- `src/hooks/useNotesStorage.ts` — workspace lifecycle (first-run choice, restore, switching,
- new-window opens) + FSA permission lifecycle; yields a ready `NoteStore`
-- `src/hooks/useNotes.ts` — note list, selection, debounced autosave, and conflict detection
-- `src/hooks/useNoteNavigation.ts`, `useNoteSearch.ts`, `useBacklinks.ts`, `useShortcuts.ts` —
- cursor/focus flow, full-text search-or-create and backlinks (both scoring a shared in-memory corpus
- loaded once by `useCorpus`; ranking + `[[wiki link]]` resolution are pure `src/search.ts` /
- `src/wikiLinks.ts`, fed by `NoteStore.getAll()`), and global keyboard shortcuts
-- `src/components/` — `FolderGate`, `Workspace`, `TopBar`, `NoteList` (virtualized), `EditorPane`
- (+ `NoteTitle`, `NotePreview`), `BacklinksPanel`, `ConflictBanner`, `ShortcutsDialog`,
- `ThemeSwitcher`, `ErrorBoundary`
-- `src/main.tsx` — app-shell styles; `src/App.tsx` — Gravity providers + theme
-
-### Known limitations
-
-- **Single-tab.** Metadata (sort/pins/active) is last-write-wins, and conflict detection uses a
- modification timestamp — coarse enough that rapid multi-tab editing of the same notes can miss or
- over-report changes. Use one tab per store for now.
-- **In-browser storage is per-browser and per-origin.** It isn't synced across devices, and clearing
- the browser's site data erases it — use **Export** to keep a `.md` backup.
-- **External changes** to the open note are detected when you return focus to the tab, not live while
- it stays focused.
-- **A selected image shows a faint caret line** beside it in the editor — the browser's native
- object-selection caret, which resists CSS hiding. Cosmetic only; editing is unaffected.
-- **Auto-update (desktop) starts from the release that introduced it.** A build without the updater
- (≤ 0.2.0) has to be updated by hand once; from there the macOS app updates itself in place. arm64 only.
-
-### Backlog
+## Backlog
- iCloud dataless files: not-yet-downloaded files list by name/mtime with an empty preview/search
body, and recover once macOS materializes them (a focus refresh picks up the filled-in preview).
Consider `startDownloadingUbiquitousItemAtURL` to kick off downloads in the background + a
"downloading…" preview state so it isn't silent.
-- Live file-watching for external edits (today the list + search index refresh when the window
- regains focus, not while it stays focused) + an explicit "reload workspace" menu item?
- Density (line spacing) setting to complement the per-note font/width overrides?
- Restore all workspace windows on relaunch (today only the last-active one comes back)
@@ -206,9 +92,9 @@ Key modules:
- Easter egg in top bar (to the right of the search bar) ?
- Notion-like page title backgrounds
+- Better looking checklists
- Auto-empty the Trash and clean unused attachments (after 30 days?)
- Metadata storage approach review
- Mobile view
- Mobile app
-
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..545166c
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,119 @@
+# Architecture
+
+How Gravity Notes is put together, what it writes to your disk, and where the edges are.
+
+## Overview
+
+```
+FolderGate ──▶ NoteStore (filesystem | tauri-fs | indexeddb) ──▶ useNotes() ──▶ NoteList + EditorPane
+(choose storage) (.md on disk: web FSA / native Rust; or IndexedDB) (state + autosave) (UI)
+```
+
+The app ships two ways from one codebase: a **web app** and a **macOS desktop app** (Tauri 2).
+Everything interesting sits behind one seam.
+
+## The storage seam
+
+All persistence goes through the `NoteStore` interface
+([`src/storage/types.ts`](../src/storage/types.ts)), with three backends:
+
+- **`FileSystemNoteStore`** ([`src/storage/fileSystemStore.ts`](../src/storage/fileSystemStore.ts)) —
+ plain `.md` files via the browser File System Access API (Chromium only).
+- **`TauriNoteStore`** ([`src/storage/tauriStore.ts`](../src/storage/tauriStore.ts)) — the same
+ `.md` folder through native Rust `fs` commands in the desktop app (the FSA API doesn't exist in
+ WKWebView). No per-session permission re-grant.
+- **`IndexedDbNoteStore`** ([`src/storage/indexedDbStore.ts`](../src/storage/indexedDbStore.ts)) —
+ in-browser/in-app storage that works everywhere.
+
+All three share the same note ids, canonical body shape, and `updatedAt`-based conflict semantics
+([`src/storage/noteText.ts`](../src/storage/noteText.ts)), so everything above the seam — state,
+search, navigation, UI — is backend-agnostic. Notes move between backends via `.md` zip
+export/import ([`src/storage/transfer.ts`](../src/storage/transfer.ts)), which preserves folder
+structure and attachment bytes both ways.
+
+## What's on disk
+
+A notes folder is meant to be shared with other tools — Obsidian, scripts, `grep`, iCloud. The
+format, in full:
+
+- **A note is a file.** Its id is its POSIX relative path (`Inbox.md`, `Work/Sub/Title.md`); the
+ file name without `.md` is the title. Folders are real directories.
+- **`.gravity-notes.json`** — one metadata sidecar at the root: sort mode, pins (notes and
+ folders), created stamps, note icons, per-note appearance overrides, the open note, and the
+ trash registry. Losing it loses pins and icons, never notes.
+- **`.trash/`** — deleting a note moves it here (a hidden folder, excluded from listings), so
+ Trash survives across app restarts and is restorable from the storage menu. Emptying the Trash
+ is the only permanent delete.
+- **`Attachments/`** — pasted/dropped images land here as files; notes reference them
+ root-relatively (``). The stored Markdown never contains `blob:` URLs.
+- **`.gnkeep`** — a marker file that keeps a deliberately-empty folder alive (Git-style).
+- **`[[Wiki links]]`** are stored verbatim — `[[Title]]` on disk, Obsidian-compatible round-trip.
+- **Bare URLs** you type are linkified in the editor and normalized to `` on save; a stray
+ `Notes.md` never turns into a link (fuzzy linkify is off — `.md` is a real TLD).
+- **Blank lines** inside a note persist as ` ` lines (the editor's `preserveEmptyRows`), so
+ intentional vertical space survives the Markdown round-trip.
+
+## Workspaces & windows
+
+Every opened folder/store is a **workspace**, remembered (with recency) in an IndexedDB registry
+([`src/storage/workspaceRegistry.ts`](../src/storage/workspaceRegistry.ts)) that feeds the orb
+menu's Open Recent, the `⌃R` switcher, and launch restore. On the desktop each workspace can have
+its own native window, plus Apple-Notes-style **per-note windows** (`⌘↵` on a list row) — small,
+panels tucked away, focus-if-open, with `⌘0` bringing back the workspace's main window. The window
+↔ workspace/note assignments live in the Rust shell, so new windows boot straight into the right
+folder.
+
+## Live file-watching (desktop)
+
+The desktop shell runs one debounced FSEvents watcher per open folder
+(`notify` in [`src-tauri/src/lib.rs`](../src-tauri/src/lib.rs)), shared by every window showing
+it. External changes — another app, another window, a sync agent — push a `notes:changed` event;
+the frontend re-checks the open note for conflicts and refreshes the list, which the search index
+and backlinks follow automatically. The app's own saves are recognized and skipped, so typing
+never triggers refresh churn. Noise (`.DS_Store`, the sidecar, `Attachments/`, temp files) is
+filtered at the source. On the web there's no watching API, so external edits surface when the tab
+regains focus — or via **Reload notes** in the storage menu.
+
+## Search, backlinks & performance
+
+Full-text ranking ([`src/search.ts`](../src/search.ts)) and wiki-link/backlink resolution
+([`src/wikiLinks.ts`](../src/wikiLinks.ts)) are pure functions with no I/O — they score a shared
+in-memory corpus loaded once by [`src/hooks/useCorpus.ts`](../src/hooks/useCorpus.ts) (one bulk
+read, lazily, only while searching or a note is open). The corpus refreshes **incrementally** —
+only notes whose mtime changed are re-read — and the backlink graph is rebuilt only when a note's
+links actually change, so a plain autosave costs nothing. The note list is virtualized
+(`@tanstack/react-virtual`). There is deliberately **no inverted search index**: for
+personal-vault sizes, scanning a pre-lowercased corpus is fast enough that an index would cost
+more in build/invalidations than it saves.
+
+Editing is decoupled from React state ([`src/hooks/useNotes.ts`](../src/hooks/useNotes.ts)):
+keystrokes flow into a ref + a 500 ms autosave timer, so the editor instance is never re-created
+mid-typing. Conflicts (a note changing underneath you) are detected by mtime and resolved through
+a banner: reload / keep mine / save a copy / discard.
+
+## The desktop shell
+
+[`src-tauri/`](../src-tauri) is a thin Tauri 2 shell: the `notes_*`/`attachment_*` filesystem
+commands (deliberately dumb primitives — the note semantics live in TypeScript, mirroring the web
+backend), the folder watcher, window management, native menu, and in-app **auto-update** via
+GitHub Releases (signature-verified). Bulk listings skip iCloud **dataless** files' content so a
+not-yet-downloaded vault doesn't block on the network; such notes list by name and fill in once
+macOS materializes them.
+
+## Known limitations
+
+- **Two windows on one store can clobber each other's _metadata_.** The sidecar (sort, pins,
+ icons, per-note appearance) is last-write-wins everywhere: desktop windows coordinate their
+ workspace assignments and note _bodies_ (the live watcher + conflict banner) through the shell,
+ but not the sidecar — and browser tabs coordinate nothing, seeing external changes only on
+ refocus. Body conflicts are detected by modification timestamp, coarse enough that rapid
+ multi-window editing of the same note can miss or over-report changes.
+- **In-browser storage is per-browser and per-origin.** It isn't synced across devices, and
+ clearing the browser's site data erases it — use **Export** to keep a `.md` backup.
+- **External changes are live only in the desktop app** (the folder watcher). In the browser
+ they're detected when you return focus to the tab — or via **Reload notes** in the storage menu.
+- **A selected image shows a faint caret line** beside it in the editor — the browser's native
+ object-selection caret, which resists CSS hiding. Cosmetic only.
+- **Auto-update starts from the release that introduced it.** A build without the updater
+ (≤ 0.2.0) has to be updated by hand once; from there the macOS app updates itself in place.
+ Apple silicon (arm64) only.
diff --git a/docs/shortcuts.md b/docs/shortcuts.md
new file mode 100644
index 0000000..e2d043b
--- /dev/null
+++ b/docs/shortcuts.md
@@ -0,0 +1,78 @@
+# Keyboard shortcuts
+
+Press `⌘/` inside the app for this sheet, always up to date. `⌘` reads as `Ctrl` outside macOS.
+
+## The search box
+
+The search box is the heart of the app (nvALT-style **search or create**):
+
+| Keys | Action |
+| --------- | --------------------------------------------------------------------------------------- |
+| Type | Full-text search of titles + bodies, ranked; the top match's title autocompletes inline |
+| `Tab` | Accept the inline autocomplete — fill the box with the top match's title |
+| `Enter` | Open the top match — or **create** a note titled with the query when nothing matches |
+| `↓` / `↑` | Step into the results list |
+| `Esc` | Clear the query, then close the open note |
+
+## Navigation
+
+| Keys | Action |
+| ------------------------ | ------------------------------------------------------------------------------------- |
+| `↑` / `↓` (or `k` / `j`) | Preview the previous / next note |
+| `⌘J` / `⌘K` | Preview next / previous note (works while editing) |
+| `⌘[` / `⌘]` | Go back / forward through visited notes (browser-style history) |
+| `Enter` | Edit the selected note (in the title → jump to the body) |
+| `⌘Enter` / `⌘-click` | Open the selected note in its own window _(desktop)_ |
+| `⌘0` | Show this workspace's main window _(desktop; also Window ▸ Main Window)_ |
+| `Esc` | Editor → list → search (then close / clear) |
+| `Esc` `Esc` | Focus the search box |
+| `⌘L` | Jump to the search box (desktop app; browsers reserve it for the address bar) |
+| `⌘\` | Toggle the sidebar |
+| `⌘⇧\` | Toggle the folder rail |
+| `⌘'` | Peek the collapsed sidebar / focus the list (again to close) |
+| `⌃R` | Switch workspace — the recent-folders dialog (`↵` open, `⌘↵` new window, `⌘⌫` remove) |
+
+## Editing
+
+| Keys | Action |
+| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `⌘⇧Enter` / `⌘N` | New note, in the selected folder (`⌘N` in the desktop app; browsers reserve it) |
+| `⌘⇧;` | Toggle WYSIWYG / Markup |
+| `⌘⇧P` | Toggle read-only preview |
+| `⌘⇧I` | Note appearance — per-note font and text width |
+| `⌘⇧K` | Insert link (in the editor) |
+| `[[` | Open the wiki-link note picker (in the editor) |
+| `⌘-click` a link | Open a URL in your browser, or follow a `[[wiki link]]` to its note (creating it if needed); in read-only preview a plain click works too |
+| `F2` | Rename the selected note, or the focused folder in the rail |
+| `⌘⇧M` | Move the selected note to a folder (from the list; in the editor it's the heading shortcut) |
+| `⌘D` | Duplicate the selected note (most dependable in the desktop app; browsers reserve it) |
+| `⌘⇧⌫` | Move the selected note to the Trash (asks to confirm; recoverable) |
+
+## General
+
+| Keys | Action |
+| ---- | ----------------------- |
+| `⌘/` | Show the shortcut sheet |
+| `⌘,` | Open settings |
+
+## Folders
+
+With the folder rail open (`⌘⇧\`) and a folder focused:
+
+| Keys / mouse | Action |
+| --------------------------- | -------------------------------------------------------------------------------- |
+| Click a folder | Scope the notes list to it (**All Notes** shows everything; search stays global) |
+| `F2` / double-click | Rename the folder |
+| `n` | Create a subfolder |
+| `⌫` | Remove an empty folder |
+| Drag a note onto a folder | File it there |
+| Drag a folder onto a folder | Nest it (onto **All Notes** to move it back to the root) |
+
+With the rail closed, a small **folder chip** above the list names the active scope — click it to
+open the rail, `✕` to go back to All Notes. **New note** (`⌘N`) lands in the selected folder.
+
+## Mouse
+
+**Right-click** a note or folder for its actions (pin, rename, move, duplicate, delete, …) — the
+same menu the row's `⋯` button opens, at the cursor. **⌘-click** a note row opens it in its own
+window (desktop). Neither moves your selection.
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 5bf216d..dc054ac 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -80,6 +80,8 @@ name = "app"
version = "0.6.0"
dependencies = [
"log",
+ "notify",
+ "notify-debouncer-mini",
"objc2",
"objc2-app-kit",
"serde",
@@ -993,6 +995,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"
@@ -1676,6 +1687,26 @@ dependencies = [
"cfb",
]
+[[package]]
+name = "inotify"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
+dependencies = [
+ "bitflags 2.13.0",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ea94e891b3606826e9c998be69ddca42247dad8ad50b1649a5cb7e1c9ae06fd"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1829,6 +1860,26 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "kqueue"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+]
+
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -1972,6 +2023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
+ "log",
"wasi",
"windows-sys 0.61.2",
]
@@ -2027,6 +2079,45 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags 2.13.0",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-debouncer-mini"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2"
+dependencies = [
+ "log",
+ "notify",
+ "notify-types",
+ "tempfile",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
[[package]]
name = "num-conv"
version = "0.2.2"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index f99d76f..fc27763 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -26,6 +26,9 @@ tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
+# Live watching of the notes folder (FSEvents on macOS), for external-edit refresh.
+notify = "8.2"
+notify-debouncer-mini = "0.7"
# The tall-titlebar toolbar trick in apply_macos_chrome (already in the tree via tao/wry).
[target."cfg(target_os = \"macos\")".dependencies]
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 81d67f5..0598963 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -198,6 +198,14 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
}
}
+/// Suffix of the write-temp `notes_write` stages through (see `tmp_sibling`). Also consumed by
+/// the folder watcher's filter — a suffix changed in one place but not the other would leak
+/// every save's temp churn as spurious `notes:changed` events.
+const WRITE_TMP_SUFFIX: &str = ".gn-tmp";
+/// Suffix of the case-only-rename temp. Minted in TypeScript (`tauriStore.ts` /
+/// `fileSystemStore.ts` — mirror-commented there); Rust only filters it in the watcher.
+const RENAME_TMP_SUFFIX: &str = ".rename-tmp";
+
/// `.gn-tmp` next to the target. The suffix isn't `.md`, so listings ignore it
/// even while it transiently exists.
fn tmp_sibling(path: &Path) -> PathBuf {
@@ -205,7 +213,7 @@ fn tmp_sibling(path: &Path) -> PathBuf {
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
- name.push(".gn-tmp");
+ name.push(WRITE_TMP_SUFFIX);
match path.parent() {
Some(parent) => parent.join(name),
None => PathBuf::from(name),
@@ -721,6 +729,273 @@ fn open_external(url: String) -> Result<(), String> {
}
}
+/// Quiet period the native debouncer waits before flushing a batch of fs events. The frontend
+/// adds its own trailing debounce on top (`WATCH_REFRESH_DEBOUNCE_MS`, 300 ms, in
+/// `useNotes.ts`), so end-to-end refresh latency is roughly the sum of the two.
+const WATCH_DEBOUNCE_MS: u64 = 400;
+/// Above this many distinct changed rel-paths in one batch, the payload sends an EMPTY list
+/// instead ("many changes — refresh everything") to bound the IPC payload.
+const WATCH_MAX_PATHS: usize = 64;
+
+/// One live folder watcher: the debounced FSEvents stream plus per-window subscription counts.
+/// Counts, not a set: React StrictMode double-mounts the frontend effect, so `watch, watch,
+/// unwatch` is a legal wire order and must leave the subscription alive.
+struct WatcherEntry {
+ /// Held for its `Drop` (stops the watcher and joins its thread); never read.
+ _debouncer: notify_debouncer_mini::Debouncer,
+ subscribers: HashMap,
+}
+
+/// Raw notes-folder path (exactly as the frontend passes `dir`) → its live watcher. One watcher
+/// per folder, shared by every window subscribed to it (main + note windows on the same folder).
+#[derive(Default)]
+struct Watchers(Mutex>);
+
+/// Payload of the `notes:changed` event. `dir` is the RAW folder path — strict-equal to the
+/// subscribing `TauriNoteStore`'s `dir`, so the frontend can filter events for its own store
+/// (FSEvents-canonicalized paths would not compare equal). `paths` are root-relative POSIX
+/// paths; EMPTY means "many/unknown changes — treat everything as changed".
+#[derive(Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct NotesChangedPayload {
+ dir: String,
+ paths: Vec,
+}
+
+/// Root-relative POSIX path of one fs event, if it's note-relevant; `None` to ignore. FSEvents
+/// reports canonicalized absolute paths (`/var` → `/private/var`, symlinks resolved), so
+/// `canon_root` must be the canonicalized watch root or `strip_prefix` misses every event.
+/// Skips what the note walks skip — dot-entries (`.trash/`, `.git/`, `.DS_Store`, the sidecar,
+/// `.gnkeep`), `node_modules`, the root `Attachments/` — plus in-flight write temps. Directory
+/// events are load-bearing: a Finder folder rename reports ONLY the directory paths, no
+/// per-child events. A path that no longer exists can't be classified (was it a note? a
+/// folder?) — include it: a spurious refresh is cheap, a missed deletion is a bug.
+fn watch_rel_path(canon_root: &Path, path: &Path) -> Option {
+ let rel = path.strip_prefix(canon_root).ok()?;
+ let mut segments: Vec<&str> = Vec::new();
+ for component in rel.components() {
+ match component {
+ Component::Normal(segment) => segments.push(segment.to_str()?),
+ _ => return None,
+ }
+ }
+ let leaf = *segments.last()?; // empty rel-path (the root itself) → None
+ let dirs = &segments[..segments.len() - 1];
+ if dirs.iter().any(|segment| is_skipped_dir(segment)) {
+ return None;
+ }
+ // The leaf gets the same skip rule EXCEPT for `.md` files: the note walks skip dot-DIRS
+ // only and list a dot-named `.hidden.md`, so the watcher must pass its events too — else
+ // an externally-created dot-note is listed but never live-refreshed.
+ if !is_md(leaf) && is_skipped_dir(leaf) {
+ return None;
+ }
+ if segments.first() == Some(&ATTACHMENTS_DIR) {
+ return None;
+ }
+ if leaf.ends_with(WRITE_TMP_SUFFIX) || leaf.ends_with(RENAME_TMP_SUFFIX) {
+ return None;
+ }
+ if is_md(leaf) {
+ return Some(segments.join("/"));
+ }
+ match fs::symlink_metadata(path) {
+ Ok(meta) if meta.is_dir() => Some(segments.join("/")),
+ Ok(_) => None, // an existing non-md file — not note-relevant
+ Err(_) => Some(segments.join("/")), // gone/unreadable — unclassifiable, include
+ }
+}
+
+/// Add one subscription for `label` (a window can hold several across StrictMode remounts).
+fn add_subscription(subs: &mut HashMap, label: &str) {
+ *subs.entry(label.to_string()).or_insert(0) += 1;
+}
+
+/// Drop one subscription for `label`; returns `true` when NO subscribers remain (the caller
+/// should tear the watcher down). An unknown label is a no-op — a late disposer arriving after
+/// the `Destroyed` cleanup must not error.
+fn drop_subscription(subs: &mut HashMap, label: &str) -> bool {
+ if let Some(count) = subs.get_mut(label) {
+ *count -= 1;
+ if *count == 0 {
+ subs.remove(label);
+ }
+ }
+ subs.is_empty()
+}
+
+/// Remove EVERY subscription `label` holds (window destroyed, or its page reloaded — both
+/// orphan the JS disposers), returning the watchers that emptied. The caller MUST drop the
+/// returned entries OUTSIDE any lock on `Watchers`: a `WatcherEntry`'s Drop joins the debouncer
+/// thread, whose callback locks the same mutex — dropping under the lock can deadlock.
+#[must_use]
+fn remove_window_subscriptions(watchers: &Watchers, label: &str) -> Vec {
+ let mut map = watchers.0.lock().unwrap();
+ let emptied: Vec = map
+ .iter_mut()
+ .filter_map(|(dir, entry)| {
+ entry.subscribers.remove(label);
+ entry.subscribers.is_empty().then(|| dir.clone())
+ })
+ .collect();
+ emptied.into_iter().filter_map(|dir| map.remove(&dir)).collect()
+}
+
+/// Belt for the `notes_watch` ↔ `Destroyed` race: the watcher build runs outside the lock, so a
+/// subscription can land for a window whose `Destroyed` sweep already ran — nothing would ever
+/// drain it, and a dead label in `subscribers` blocks the last LIVE unsubscriber from tearing
+/// the watcher down. Re-check liveness after subscribing; if the window is gone, sweep again.
+fn reap_dead_window_subscriptions(app: &tauri::AppHandle, label: &str) {
+ if app.get_webview_window(label).is_some() {
+ return;
+ }
+ let removed = remove_window_subscriptions(&app.state::(), label);
+ drop(removed); // joins the debouncer threads — outside the lock (see above)
+}
+
+/// The debouncer callback: filter one flushed batch down to note-relevant rel-paths and emit
+/// `notes:changed` to every window subscribed to this folder. Runs on the debouncer's own
+/// thread — `AppHandle` is `Send + Sync` and `emit_to` is thread-safe.
+fn handle_watch_events(
+ app: &tauri::AppHandle,
+ dir: &str,
+ canon_root: &Path,
+ res: notify_debouncer_mini::DebounceEventResult,
+) {
+ let paths: Vec = match res {
+ // An event on the WATCH ROOT itself is FSEvents' queue-overflow signal
+ // (kFSEventStreamEventFlagMustScanSubDirs — the debouncer strips the flag, so the root
+ // path is all that's left of it): events were missed, refresh everything. A genuine
+ // root event (an attr change) is rare enough that over-refreshing on it is cheap.
+ Ok(events) if events.iter().any(|event| event.path.as_path() == canon_root) => Vec::new(),
+ Ok(events) => {
+ // BTreeSet: dedup (one save fires several events per file) + stable order.
+ let set: std::collections::BTreeSet = events
+ .iter()
+ .filter_map(|event| watch_rel_path(canon_root, &event.path))
+ .collect();
+ if set.is_empty() {
+ return; // nothing note-relevant (sidecar churn, .DS_Store, attachments…)
+ }
+ if set.len() > WATCH_MAX_PATHS {
+ Vec::new() // "many changes" — the frontend refreshes everything
+ } else {
+ set.into_iter().collect()
+ }
+ }
+ // Watcher error (e.g. inotify-style overflow surfaces here): refresh everything.
+ Err(_) => Vec::new(),
+ };
+ let labels: Vec = {
+ let watchers = app.state::();
+ let map = watchers.0.lock().unwrap();
+ match map.get(dir) {
+ Some(entry) => entry.subscribers.keys().cloned().collect(),
+ None => return, // unwatched between the debouncer flush and now
+ }
+ };
+ for label in labels {
+ let _ = app.emit_to(
+ &label,
+ "notes:changed",
+ NotesChangedPayload {
+ dir: dir.to_string(),
+ paths: paths.clone(),
+ },
+ );
+ }
+}
+
+/// Subscribe the calling window to external-change events for `dir` (see `Watchers`). Async so
+/// `canonicalize` + FSEvents stream creation stay off the main thread.
+#[tauri::command]
+async fn notes_watch(
+ app: tauri::AppHandle,
+ window: tauri::WebviewWindow,
+ state: tauri::State<'_, Watchers>,
+ dir: String,
+) -> Result<(), String> {
+ let subscribed_existing = {
+ let mut map = state.0.lock().unwrap();
+ match map.get_mut(&dir) {
+ Some(entry) => {
+ add_subscription(&mut entry.subscribers, window.label());
+ true
+ }
+ None => false,
+ }
+ };
+ if subscribed_existing {
+ reap_dead_window_subscriptions(&app, window.label());
+ return Ok(());
+ }
+ // Build the watcher outside the lock (stream creation can block).
+ let canon_root = fs::canonicalize(&dir).map_err(stringify)?;
+ let handle = app.clone();
+ let key = dir.clone();
+ let root = canon_root.clone();
+ let mut debouncer = notify_debouncer_mini::new_debouncer(
+ std::time::Duration::from_millis(WATCH_DEBOUNCE_MS),
+ move |res| handle_watch_events(&handle, &key, &root, res),
+ )
+ .map_err(stringify)?;
+ debouncer
+ .watcher()
+ .watch(&canon_root, notify::RecursiveMode::Recursive)
+ .map_err(stringify)?;
+ // Re-lock to insert. A concurrent notes_watch for the same dir may have won the race — keep
+ // ITS entry and discard our duplicate stream, but only after releasing the lock (dropping a
+ // debouncer joins its thread; never do that under the mutex).
+ let duplicate = {
+ let mut map = state.0.lock().unwrap();
+ match map.entry(dir) {
+ std::collections::hash_map::Entry::Occupied(mut occupied) => {
+ add_subscription(&mut occupied.get_mut().subscribers, window.label());
+ Some(debouncer)
+ }
+ std::collections::hash_map::Entry::Vacant(vacant) => {
+ let mut subscribers = HashMap::new();
+ add_subscription(&mut subscribers, window.label());
+ vacant.insert(WatcherEntry {
+ _debouncer: debouncer,
+ subscribers,
+ });
+ None
+ }
+ }
+ };
+ drop(duplicate);
+ // The build above ran unlocked — the window may have been destroyed (and its `Destroyed`
+ // sweep run) meanwhile, which would leave this fresh subscription undrainable.
+ reap_dead_window_subscriptions(&app, window.label());
+ Ok(())
+}
+
+/// Drop one of the calling window's subscriptions for `dir`; the folder's watcher is torn down
+/// when the last one goes. Unknown dir/label is a silent no-op (idempotent — see
+/// `drop_subscription`).
+#[tauri::command]
+async fn notes_unwatch(
+ window: tauri::WebviewWindow,
+ state: tauri::State<'_, Watchers>,
+ dir: String,
+) -> Result<(), String> {
+ let removed = {
+ let mut map = state.0.lock().unwrap();
+ let emptied = match map.get_mut(&dir) {
+ Some(entry) => drop_subscription(&mut entry.subscribers, window.label()),
+ None => false,
+ };
+ if emptied {
+ map.remove(&dir)
+ } else {
+ None
+ }
+ };
+ drop(removed); // joins the debouncer thread — after the lock is released
+ Ok(())
+}
+
/// Per-window workspace tracking.
/// - `labels`: a live window's label → the workspace id it shows. Fed by the frontend
/// (`set_window_workspace` on every workspace open) and by `open_workspace_window` /
@@ -1316,6 +1591,19 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(WindowWorkspaces::default())
+ .manage(Watchers::default())
+ // A page (re)load without a window teardown — WKWebView's default content-process-crash
+ // recovery reloads in place, and dev reloads do too — orphans the old JS context's watch
+ // subscriptions (its disposers are gone, and no `Destroyed` event will ever come). Reset
+ // the label's refcounts before the fresh page subscribes anew; on a window's FIRST load
+ // this is a no-op (nothing subscribed yet — `notes_watch` only runs from page JS).
+ .on_page_load(|webview, payload| {
+ if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) {
+ let removed =
+ remove_window_subscriptions(&webview.state::(), webview.label());
+ drop(removed); // outside the lock (Drop joins the debouncer thread)
+ }
+ })
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -1342,10 +1630,17 @@ pub fn run() {
}
// A closed window's workspace/note assignments must not keep answering focus-if-open.
tauri::WindowEvent::Destroyed => {
- let state = window.state::();
- let mut st = state.0.lock().unwrap();
- st.labels.remove(window.label());
- st.notes.remove(window.label());
+ {
+ let state = window.state::();
+ let mut st = state.0.lock().unwrap();
+ st.labels.remove(window.label());
+ st.notes.remove(window.label());
+ }
+ // Drop the window's watch subscriptions too; a watcher left with no
+ // subscribers is torn down — outside the lock (Drop joins its thread).
+ let removed =
+ remove_window_subscriptions(&window.state::(), window.label());
+ drop(removed);
}
_ => {}
}
@@ -1364,6 +1659,8 @@ pub fn run() {
attachment_remove,
notes_exists,
notes_stat,
+ notes_watch,
+ notes_unwatch,
reveal_path,
open_external,
notes_create_folder,
@@ -1879,4 +2176,59 @@ mod tests {
unassign_note_windows(&labels, &mut notes, "tauri:/b", "Other.md");
assert_eq!(notes.get("note-2").map(String::as_str), Some("Gone.md"));
}
+
+ #[test]
+ fn watch_rel_path_filters_note_relevant_paths() {
+ let dir = temp_dir();
+ // The filter compares against the CANONICAL root (FSEvents reports resolved paths;
+ // macOS's temp dir itself sits behind the /var → /private/var symlink).
+ let root = fs::canonicalize(&dir).unwrap();
+ fs::create_dir_all(root.join("Work").join("Sub")).unwrap();
+ fs::write(root.join("Work").join("Sub").join("Deep.md"), "x").unwrap();
+ fs::write(root.join("readme.txt"), "x").unwrap();
+
+ let rel = |p: &Path| watch_rel_path(&root, p);
+ // Notes pass as POSIX rel-paths — existing or already deleted (a deleted path can't be
+ // classified, and a missed deletion would be a bug).
+ assert_eq!(rel(&root.join("Work/Sub/Deep.md")), Some("Work/Sub/Deep.md".into()));
+ assert_eq!(rel(&root.join("Note.md")), Some("Note.md".into()));
+ // A dot-NAMED note passes: the note walks skip dot-DIRS only and do list `.hidden.md`,
+ // so the watcher must report its changes too (listed-but-never-refreshed otherwise).
+ assert_eq!(rel(&root.join(".hidden.md")), Some(".hidden.md".into()));
+ // Existing directories pass: a Finder folder rename reports ONLY the dir paths.
+ assert_eq!(rel(&root.join("Work/Sub")), Some("Work/Sub".into()));
+ // A vanished path of unknown kind passes (unclassifiable → refresh, cheap).
+ assert_eq!(rel(&root.join("Gone")), Some("Gone".into()));
+ // Noise is dropped: dot-entries (incl. the sidecar + trash), attachments, deps,
+ // in-flight write temps, and existing non-md files.
+ assert_eq!(rel(&root.join(".DS_Store")), None);
+ assert_eq!(rel(&root.join(".gravity-notes.json")), None);
+ assert_eq!(rel(&root.join(".trash/Old.md")), None);
+ assert_eq!(rel(&root.join("Attachments/pic.png")), None);
+ assert_eq!(rel(&root.join("node_modules/x.md")), None);
+ assert_eq!(rel(&root.join("Work/node_modules/y.md")), None);
+ assert_eq!(rel(&root.join("Note.md.gn-tmp")), None);
+ assert_eq!(rel(&root.join("Note.md.rename-tmp")), None);
+ assert_eq!(rel(&root.join("readme.txt")), None);
+ // The root itself and paths outside it are ignored.
+ assert_eq!(rel(&root), None);
+ assert_eq!(rel(Path::new("/elsewhere/Note.md")), None);
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn watch_subscriptions_refcount_across_strictmode_interleaving() {
+ let mut subs: HashMap = HashMap::new();
+ // StrictMode's legal wire order — watch, watch, unwatch — must stay subscribed.
+ add_subscription(&mut subs, "main");
+ add_subscription(&mut subs, "main");
+ assert!(!drop_subscription(&mut subs, "main"));
+ // A second window keeps the watcher alive after the first fully unsubscribes.
+ add_subscription(&mut subs, "note-1");
+ assert!(!drop_subscription(&mut subs, "main"));
+ assert!(drop_subscription(&mut subs, "note-1"));
+ // Idempotent: an unknown label on an empty map is a no-op that reports empty.
+ assert!(drop_subscription(&mut subs, "ghost"));
+ }
}
diff --git a/src/components/TopBar.test.tsx b/src/components/TopBar.test.tsx
index f4ca9dd..1ba7bfa 100644
--- a/src/components/TopBar.test.tsx
+++ b/src/components/TopBar.test.tsx
@@ -37,6 +37,7 @@ function setup(overrides: Record = {}) {
onExport: vi.fn(),
onImport: vi.fn(),
onManageAttachments: vi.fn(),
+ onReload: vi.fn(),
onOpenTrash: vi.fn(),
trashCount: 0,
onOpenHelp: vi.fn(),
@@ -198,6 +199,7 @@ function StatefulTopBar({onCommit}: {onCommit: () => void}) {
onExport={noop}
onImport={noop}
onManageAttachments={noop}
+ onReload={noop}
onOpenTrash={noop}
trashCount={0}
onOpenHelp={noop}
diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx
index 8eee2a4..581dc2b 100644
--- a/src/components/TopBar.tsx
+++ b/src/components/TopBar.tsx
@@ -65,6 +65,11 @@ export interface TopBarProps {
onImport: () => void;
/** Open the media-attachments manager. */
onManageAttachments: () => void;
+ /**
+ * Re-read the notes list from the store. The manual lever for picking up external edits on
+ * backends without live watching (web FSA), and an escape hatch on the desktop.
+ */
+ onReload: () => void;
/** Open the Trash (soft-deleted notes). */
onOpenTrash: () => void;
/** Number of notes currently in the Trash, for the menu-item badge. */
@@ -152,6 +157,7 @@ export function TopBar({
onExport,
onImport,
onManageAttachments,
+ onReload,
onOpenTrash,
trashCount,
onOpenHelp,
@@ -411,6 +417,11 @@ export function TopBar({
},
]
: []),
+ {
+ text: 'Reload notes',
+ iconStart: ,
+ action: onReload,
+ },
],
[
{
diff --git a/src/components/Workspace.tsx b/src/components/Workspace.tsx
index c3b565d..d439dfb 100644
--- a/src/components/Workspace.tsx
+++ b/src/components/Workspace.tsx
@@ -872,13 +872,18 @@ export function Workspace({
if (!files || files.length === 0) return;
void (async () => {
try {
- const count = await importNotes(store, files);
- await notes.refresh();
+ // Hold the watcher/focus pipelines off while the bulk write runs — imports
+ // write below the hook's echo stamps, so every batch would otherwise trigger
+ // a full mid-import re-list. reload() (not refresh()) afterwards: an import
+ // can overwrite the OPEN note, which must surface as a conflict, not a
+ // silent reconcile.
+ const count = await notes.withBulkWrites(() => importNotes(store, files));
+ await notes.reload();
notify(count === 1 ? 'Imported 1 note' : `Imported ${count} notes`);
} catch (err) {
// The import may have written some notes before throwing — refresh so those
// become visible, and word the error as a possible partial import.
- await notes.refresh();
+ await notes.reload();
onError(
err instanceof Error
? `Import failed (some notes may have been imported): ${err.message}`
@@ -1218,6 +1223,13 @@ export function Workspace({
onExport={handleExport}
onImport={handleImportClick}
onManageAttachments={handleManageAttachments}
+ onReload={() => {
+ // reload(), not refresh(): the conflict check must run first, or an
+ // externally-deleted open note reloads with no banner (see useNotes).
+ notes.reload().catch((err: unknown) => {
+ onError(err instanceof Error ? err.message : 'Failed to reload notes');
+ });
+ }}
onOpenTrash={() => setTrashOpen(true)}
trashCount={notes.trashCount}
onOpenHelp={() => setHelpOpen(true)}
diff --git a/src/hooks/useNotes.test.tsx b/src/hooks/useNotes.test.tsx
index 59b517c..30d9093 100644
--- a/src/hooks/useNotes.test.tsx
+++ b/src/hooks/useNotes.test.tsx
@@ -1608,3 +1608,223 @@ describe('useNotes — trash', () => {
expect(onError).not.toHaveBeenCalled();
});
});
+
+/**
+ * An FSA-backed store posing as a desktop store: `watch` is grafted on (the seam's optional
+ * method), capturing the hook's onChange so tests can push watcher events by hand.
+ */
+async function setupWatched(seed?: (dir: FakeDirectoryHandle) => void) {
+ const onError = vi.fn();
+ const dir = new FakeDirectoryHandle();
+ seed?.(dir);
+ const store: NoteStore = new FileSystemNoteStore(asDirectoryHandle(dir));
+ const watchState = {
+ onChange: null as ((relPaths: string[]) => void) | null,
+ };
+ store.watch = async (onChange) => {
+ watchState.onChange = onChange;
+ return () => {
+ watchState.onChange = null;
+ };
+ };
+ const listSpy = vi.spyOn(store, 'list');
+ const hook = renderHook(() => useNotes(store, onError));
+ await waitFor(() => expect(hook.result.current.ready).toBe(true));
+ // The watch effect subscribes once `ready` lands (async — the store's watch() awaits).
+ await waitFor(() => expect(watchState.onChange).not.toBeNull());
+ return {hook, dir, store, onError, watchState, listSpy};
+}
+
+describe('useNotes — live watch', () => {
+ it('refreshes the list when the watcher reports an external change', async () => {
+ const {hook, dir, watchState} = await setupWatched((d) => d.seedFile('A.md', 'a', 100));
+ await waitFor(() => expect(hook.result.current.notes).toHaveLength(1));
+
+ dir.seedFile('B.md', 'b', 200); // an external tool created a note
+ act(() => watchState.onChange!(['B.md']));
+
+ // The debounced watcher pipeline re-lists without any focus event.
+ await waitFor(() => expect(hook.result.current.notes).toHaveLength(2));
+ });
+
+ it('raises a conflict for an externally-changed open note without any focus event', async () => {
+ const {hook, dir, watchState} = await setupWatched((d) =>
+ d.seedFile('Note.md', 'disk v1', 100),
+ );
+ await act(async () => {
+ await hook.result.current.open('Note.md');
+ });
+
+ dir.seedFile('Note.md', 'disk v2', 200); // external edit while the app stays focused
+ act(() => watchState.onChange!(['Note.md']));
+
+ await waitFor(() => expect(hook.result.current.conflict).toBeTruthy());
+ expect(hook.result.current.conflict).toMatchObject({id: 'Note.md', deleted: false});
+ });
+
+ it('defers the echo of its own autosave to one trailing verify run', async () => {
+ const {hook, watchState, listSpy} = await setupWatched((d) =>
+ d.seedFile('Note.md', 'v1', 100),
+ );
+ await act(async () => {
+ await hook.result.current.open('Note.md');
+ });
+ act(() => {
+ hook.result.current.edit('v2 local');
+ });
+ // Flush the pending edit now (the visibilitychange path), stamping it as a local write.
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await waitFor(() => expect(hook.result.current.saveState).toBe('saved'));
+
+ const listCalls = listSpy.mock.calls.length;
+ vi.useFakeTimers();
+ try {
+ // The watcher echoes our own write back; no refresh at the fast 300 ms cadence…
+ act(() => watchState.onChange!(['Note.md']));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2_000);
+ });
+ expect(listSpy.mock.calls.length).toBe(listCalls);
+ expect(hook.result.current.conflict).toBeNull();
+ // …but the batch is deferred, not dropped: ONE verify run fires once the echoes go
+ // quiet — a stamp false-positive (e.g. an external dir event matching an ancestor
+ // stamp) may only ever delay a refresh, never lose one.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2_000);
+ });
+ expect(listSpy.mock.calls.length).toBe(listCalls + 1);
+ expect(hook.result.current.conflict).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('keeps the deleted note active (and its keystrokes) under the conflict banner', async () => {
+ const {hook, dir, store, watchState} = await setupWatched((d) =>
+ d.seedFile('Note.md', 'v1', 100),
+ );
+ await act(async () => {
+ await hook.result.current.open('Note.md');
+ });
+
+ await dir.removeEntry('Note.md'); // deleted externally while open
+ act(() => watchState.onChange!(['Note.md']));
+ await waitFor(() =>
+ expect(hook.result.current.conflict).toMatchObject({id: 'Note.md', deleted: true}),
+ );
+
+ // The pipeline's refresh must NOT null `active` while the banner is up — otherwise
+ // edit() records nothing and everything typed under the banner is silently lost.
+ expect(hook.result.current.activeId).toBe('Note.md');
+ act(() => {
+ hook.result.current.edit('typed under the banner');
+ });
+ let copyId: string | null = null;
+ await act(async () => {
+ copyId = await hook.result.current.saveAsCopy();
+ });
+ expect(copyId).not.toBeNull();
+ const copy = await store.get(copyId!);
+ expect(copy.content).toBe('typed under the banner');
+ });
+
+ it('reload() raises the deleted-conflict instead of letting refresh swallow it', async () => {
+ const {hook, dir} = await setupWatched((d) => d.seedFile('Note.md', 'v1', 100));
+ await act(async () => {
+ await hook.result.current.open('Note.md');
+ });
+
+ await dir.removeEntry('Note.md'); // deleted externally; the window never lost focus
+ await act(async () => {
+ await hook.result.current.reload(); // the orb menu's "Reload notes"
+ });
+
+ // A bare refresh() would reconcile `active` away with no banner (dead editor).
+ expect(hook.result.current.conflict).toMatchObject({id: 'Note.md', deleted: true});
+ expect(hook.result.current.activeId).toBe('Note.md');
+ });
+
+ it('an empty paths list ("many changes") refreshes even right after a local write', async () => {
+ const {hook, dir, watchState, listSpy} = await setupWatched((d) =>
+ d.seedFile('Note.md', 'v1', 100),
+ );
+ await act(async () => {
+ await hook.result.current.open('Note.md');
+ });
+ act(() => {
+ hook.result.current.edit('v2 local');
+ });
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await waitFor(() => expect(hook.result.current.saveState).toBe('saved'));
+
+ dir.seedFile('Bulk.md', 'x', 300);
+ const listCalls = listSpy.mock.calls.length;
+ // Empty = "unknown/overflow": never suppressible ([].every() is vacuously true).
+ act(() => watchState.onChange!([]));
+
+ await waitFor(() => expect(listSpy.mock.calls.length).toBeGreaterThan(listCalls));
+ await waitFor(() => expect(hook.result.current.notes).toHaveLength(2));
+ });
+
+ it('latches an event arriving while hidden and replays it on visibility', async () => {
+ let visibility: DocumentVisibilityState = 'visible';
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => visibility,
+ });
+ const {hook, dir, watchState, listSpy} = await setupWatched((d) =>
+ d.seedFile('A.md', 'a', 100),
+ );
+ await waitFor(() => expect(hook.result.current.notes).toHaveLength(1));
+
+ visibility = 'hidden';
+ dir.seedFile('B.md', 'b', 200);
+ const listCalls = listSpy.mock.calls.length;
+ vi.useFakeTimers();
+ try {
+ // Hidden window: the event is latched, not processed.
+ act(() => watchState.onChange!(['B.md']));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2_000);
+ });
+ expect(listSpy.mock.calls.length).toBe(listCalls);
+ } finally {
+ vi.useRealTimers();
+ }
+
+ // Becoming visible (without a focus event) replays the latched change.
+ visibility = 'visible';
+ await act(async () => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await waitFor(() => expect(hook.result.current.notes).toHaveLength(2));
+ });
+
+ it('disposes a subscription that resolves only after unmount', async () => {
+ const onError = vi.fn();
+ const dir = new FakeDirectoryHandle();
+ dir.seedFile('A.md', 'a', 100);
+ const store: NoteStore = new FileSystemNoteStore(asDirectoryHandle(dir));
+ let resolveWatch: ((dispose: () => void) => void) | null = null;
+ let disposeCalls = 0;
+ store.watch = (_onChange) =>
+ new Promise((resolve) => {
+ resolveWatch = resolve;
+ });
+ const hook = renderHook(() => useNotes(store, onError));
+ await waitFor(() => expect(hook.result.current.ready).toBe(true));
+ await waitFor(() => expect(resolveWatch).not.toBeNull());
+
+ // Unmount while the subscription is still in flight (StrictMode/workspace-switch shape),
+ // then let it land: the late disposer must still be called (backend refcounts it).
+ hook.unmount();
+ resolveWatch!(() => {
+ disposeCalls += 1;
+ });
+ await waitFor(() => expect(disposeCalls).toBe(1));
+ });
+});
diff --git a/src/hooks/useNotes.ts b/src/hooks/useNotes.ts
index 07baedf..4499324 100644
--- a/src/hooks/useNotes.ts
+++ b/src/hooks/useNotes.ts
@@ -85,6 +85,27 @@ const RETRY_MAX_DELAY = 30_000;
* but focus can flap when ⌘-tabbing through windows, and a full list() walks the whole store.
*/
const FOCUS_REFRESH_MIN_GAP_MS = 2_000;
+/**
+ * Trailing debounce for watcher-driven refreshes, on top of the backend's own event debounce
+ * (`WATCH_DEBOUNCE_MS`, 400 ms, in `src-tauri/src/lib.rs` — end-to-end latency is the sum) —
+ * coalesces multi-batch arrivals (bulk external writes) into one re-list.
+ */
+const WATCH_REFRESH_DEBOUNCE_MS = 300;
+/**
+ * How long one of our own writes marks the watcher's echo of its path as an echo. Wide enough to
+ * cover the backend debounce + IPC; an external edit landing on the SAME note inside this window
+ * is still caught authoritatively — by the next autosave's optimistic-concurrency check, or by
+ * the deferred verify run below once the echoes go quiet.
+ */
+const LOCAL_WRITE_WINDOW_MS = 3_000;
+/**
+ * Echo-only watcher batches are not dropped — they defer one verify run this long past the LAST
+ * echo (trailing). The stamps can false-positive (an external dir-only event covered by ancestor
+ * stamps; an external rewrite of a just-saved note), so suppression may only ever DELAY a
+ * refresh, never lose one. Sized past the stamp window so steady typing (echo per autosave)
+ * can't fire it mid-burst.
+ */
+const WATCH_SUPPRESSED_VERIFY_MS = LOCAL_WRITE_WINDOW_MS + WATCH_REFRESH_DEBOUNCE_MS;
/**
* Tell the desktop shell a note's id (rel-path) changed, so any single-note WINDOW pinned to the
@@ -222,6 +243,18 @@ export interface UseNotes {
flushPending(): Promise;
/** Re-read the note list from the store (e.g. after importing notes). */
refresh(): Promise;
+ /**
+ * Manual full re-sync (the orb menu's "Reload notes" / post-import): checks the open note
+ * for an external conflict FIRST, then refreshes — a bare refresh() would let reconcile
+ * null an externally-deleted open note's `active` with no banner.
+ */
+ reload(): Promise;
+ /**
+ * Run a bulk store mutation (import) with the watcher/focus refresh pipelines held off —
+ * bulk writes bypass this hook's echo stamps, so each batch would otherwise trigger a full
+ * mid-import re-list.
+ */
+ withBulkWrites(fn: () => Promise): Promise;
/** Conflict resolvers (act on the open note). */
reloadDisk(): Promise;
keepMine(): Promise;
@@ -396,17 +429,62 @@ export function useNotes(
const flushRef = useRef<() => Promise>(async () => false);
/** Latest `conflict`, read inside the long-lived window listeners so they needn't re-subscribe. */
const conflictRef = useRef(null);
+ /**
+ * rel-paths (notes AND their ancestor folders — our fs ops churn those too, via
+ * create_dir_all / empty-ancestor pruning) this hook itself recently wrote, so the watcher's
+ * echo of our own writes isn't refreshed at full cadence (each would cost a full re-list —
+ * write amplification while typing). An optimization only, twice over: a suppressed echo
+ * can't hide a real conflict (a save advances baselineRef before its echo lands), and an
+ * echo-only batch still schedules one deferred verify run (see the watch effect) — so a
+ * false-positive stamp (e.g. an external dir event matching an ancestor stamp) delays a
+ * refresh by a few seconds instead of losing it.
+ */
+ const recentLocalWritesRef = useRef