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/<Title>.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 `<Title>.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 (`![…](Attachments/pie.png)`). 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 `<url>` 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"; + /// `<path>.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<notify::RecommendedWatcher>, + subscribers: HashMap<String, u32>, +} + +/// 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<HashMap<String, WatcherEntry>>); + +/// 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<String>, +} + +/// 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<String> { + 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<String, u32>, 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<String, u32>, 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<WatcherEntry> { + let mut map = watchers.0.lock().unwrap(); + let emptied: Vec<String> = 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::<Watchers>(), 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<String> = 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<String> = 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<String> = { + let watchers = app.state::<Watchers>(); + 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::<Watchers>(), 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::<WindowWorkspaces>(); - let mut st = state.0.lock().unwrap(); - st.labels.remove(window.label()); - st.notes.remove(window.label()); + { + let state = window.state::<WindowWorkspaces>(); + 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::<Watchers>(), 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<String, u32> = 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<string, unknown> = {}) { 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: <Icon data={ArrowsRotateRight} />, + 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<boolean>; /** Re-read the note list from the store (e.g. after importing notes). */ refresh(): Promise<void>; + /** + * 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<void>; + /** + * 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<T>(fn: () => Promise<T>): Promise<T>; /** Conflict resolvers (act on the open note). */ reloadDisk(): Promise<void>; keepMine(): Promise<void>; @@ -396,17 +429,62 @@ export function useNotes( const flushRef = useRef<() => Promise<boolean>>(async () => false); /** Latest `conflict`, read inside the long-lived window listeners so they needn't re-subscribe. */ const conflictRef = useRef<NoteConflict | null>(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<Map<string, number>>(new Map()); + /** True while flush()'s store.save is in flight — see checkOpenNoteConflict. */ + const saveInFlightRef = useRef(false); + + /** Mark `relPath` (and its ancestor folders) as locally written just now. */ + const stampLocalWrite = useCallback((relPath: string) => { + const now = Date.now(); + const map = recentLocalWritesRef.current; + for (let p = relPath; p; p = dirname(p)) map.set(p, now); + // Keep the map bounded: drop stamps that already aged out of the window. + for (const [key, at] of map) { + if (now - at > LOCAL_WRITE_WINDOW_MS) map.delete(key); + } + }, []); + const isRecentLocalWrite = useCallback((relPath: string) => { + const at = recentLocalWritesRef.current.get(relPath); + return at !== undefined && Date.now() - at <= LOCAL_WRITE_WINDOW_MS; + }, []); const refresh = useCallback(async () => { const [list, folderList] = await Promise.all([store.list(), store.listFolders()]); - setNotes(list); + // Newest-wins per row: an autosave can land while list() walks the folder, so its + // bumpInList row (fresher mtime + preview) must survive this snapshot — which was read + // before the write and would otherwise clobber it back to stale (and the save's own + // watcher echo is suppressed, so nothing would correct the row until the next trigger). + setNotes((prev) => { + const prevById = new Map(prev.map((n) => [n.id, n])); + return list.map((n) => { + const cur = prevById.get(n.id); + return cur && (cur.updatedAt ?? 0) > (n.updatedAt ?? 0) ? cur : n; + }); + }); setFolders(folderList); // Reconcile against notes AND folders, so a pinned empty folder isn't pruned. - applyMetadata( - reconcile(metadataRef.current, [...list.map((n) => n.id), ...folderList], { - recursive: store.listsRecursively, - }), - ); + let next = reconcile(metadataRef.current, [...list.map((n) => n.id), ...folderList], { + recursive: store.listsRecursively, + }); + // 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) and strand "Save as copy" with pre-deletion content. Every resolution + // path rewrites or clears the pointer, so it can't leak past the conflict. + const prevActive = metadataRef.current.active; + if (!next.active && prevActive && conflictRef.current?.id === prevActive) { + next = withActive(next, prevActive); + } + applyMetadata(next); // NOTE: refresh() deliberately does NOT re-stat `.trash/`. The trash registry (and thus the // `trashCount` badge) is kept in sync in-memory by the trash mutations (withTrashed / // withoutTrashEntry / …), and refresh() runs after every note/folder op — folding a @@ -447,7 +525,15 @@ export function useNotes( // by note moves. Metadata stays consistent via the with* helpers each handler already applies. /** Append a freshly-created note to the list (preview derived from its initial body). */ const addNote = useCallback((meta: NoteMeta, content = '') => { - setNotes((prev) => [...prev, {...meta, preview: previewFromContent(content)}]); + setNotes((prev) => { + const row = {...meta, preview: previewFromContent(content)}; + // A concurrent watcher/focus refresh may already have listed the just-created file + // (create awaits a metadata write before patching the list) — replace, don't append, + // or the row (and its React key) would double until the next full refresh. + return prev.some((n) => n.id === meta.id) + ? prev.map((n) => (n.id === meta.id ? row : n)) + : [...prev, row]; + }); }, []); /** Re-key a note after a rename/move (id + title + mtime change; body/preview unchanged). */ const rekeyNote = useCallback((oldId: string, meta: NoteMeta) => { @@ -479,6 +565,9 @@ export function useNotes( // from each branch avoids the stale-closure trap of reading `conflict` state right after await. if (!pending) return conflictRef.current !== null; pendingRef.current = null; + // The watcher's echo of this write is not an external change; mark it before writing. + stampLocalWrite(pending.id); + saveInFlightRef.current = true; // Hold the raw save promise so a timeout below can still reconcile our baseline if it later // succeeds in the background (the underlying write can't be cancelled — it keeps running). const savePromise = store.save(pending.id, pending.content, baselineRef.current ?? 0); @@ -489,6 +578,7 @@ export function useNotes( baselineRef.current = meta.updatedAt ?? null; retryRef.current = 0; setSaveState('saved'); + stampLocalWrite(pending.id); // re-stamp at completion — a slow save outlives the entry stamp bumpInList(pending.id, meta.updatedAt, pending.content); return false; // saved cleanly — no conflict } catch (err) { @@ -517,11 +607,19 @@ export function useNotes( // the newer edit (already in pendingRef, with its own timer) must win, not be clobbered. if (pendingRef.current === null) pendingRef.current = pending; if (err instanceof ConflictError) { - setConflict({id: err.id, diskUpdatedAt: err.diskUpdatedAt, deleted: false}); + // Mirror into the ref synchronously (the effect mirror lags a render): a + // refresh() racing this raise must see the conflict to keep `active` alive. + conflictRef.current = { + id: err.id, + diskUpdatedAt: err.diskUpdatedAt, + deleted: false, + }; + setConflict(conflictRef.current); setSaveState('conflict'); return true; // unresolved conflict still holds the unsaved edit } else if (err instanceof DOMException && err.name === 'NotFoundError') { - setConflict({id: pending.id, diskUpdatedAt: 0, deleted: true}); + conflictRef.current = {id: pending.id, diskUpdatedAt: 0, deleted: true}; + setConflict(conflictRef.current); setSaveState('conflict'); return true; // note deleted out from under us; the edit is unsaved } else { @@ -539,8 +637,10 @@ export function useNotes( } // A non-conflict error re-queues + retries the edit; there's no conflict to confirm here. return false; + } finally { + saveInFlightRef.current = false; } - }, [store, onError, bumpInList, clearTimer]); + }, [store, onError, bumpInList, clearTimer, stampLocalWrite]); // Keep the schedule target current so flush can re-arm its own retry without a circular dep. useEffect(() => { @@ -609,6 +709,7 @@ export function useNotes( await flush(); try { const meta = await store.create(title?.trim() || 'Untitled', parentPath); + stampLocalWrite(meta.id); // our write — not an external change for the watcher await persistMetadata( withCreatedStamp(metadataRef.current, meta.id, meta.updatedAt ?? 0), ); @@ -621,7 +722,7 @@ export function useNotes( return null; } }, - [flush, store, persistMetadata, addNote, relistFolders, open, onError], + [flush, store, persistMetadata, addNote, relistFolders, open, onError, stampLocalWrite], ); const duplicate = useCallback( @@ -631,6 +732,7 @@ export function useNotes( // Copy the body verbatim so the copy shares the original's attachment refs. const source = await store.get(id); const meta = await store.create(`${titleFromFileName(id)} copy`, dirname(id)); + stampLocalWrite(meta.id); // our writes (create + save below) — not external const saved = await store.save(meta.id, source.content, meta.updatedAt ?? 0); await persistMetadata( withCreatedStamp(metadataRef.current, meta.id, saved.updatedAt ?? 0), @@ -645,19 +747,22 @@ export function useNotes( return null; } }, - [flush, store, persistMetadata, addNote, relistFolders, open, onError], + [flush, store, persistMetadata, addNote, relistFolders, open, onError, stampLocalWrite], ); const createFolder = useCallback( async (parentPath: string, name: string): Promise<void> => { try { - await store.createFolder(parentPath, name); + // Stamp the path the store actually created (it sanitizes the segment) — the + // raw typed name would stamp a key the watcher's echo never matches. + const path = await store.createFolder(parentPath, name); + stampLocalWrite(path); // our dir write — not external await refresh(); } catch (err) { onError(err instanceof Error ? err.message : 'Failed to create folder'); } }, - [store, refresh, onError], + [store, refresh, onError, stampLocalWrite], ); const removeFolder = useCallback( @@ -673,13 +778,14 @@ export function useNotes( } try { await store.removeFolder(path); + stampLocalWrite(path); // our dir removal — not external // refresh() reconciles against the new folder set, which drops a pin on the gone folder. await refresh(); } catch (err) { onError(err instanceof Error ? err.message : 'Failed to remove folder'); } }, - [store, refresh, onError, notes, folders], + [store, refresh, onError, notes, folders, stampLocalWrite], ); const moveFolder = useCallback( @@ -721,6 +827,10 @@ export function useNotes( }; try { await store.moveFolder(fromPath, toPath); + // Our dir rename (+ ancestor pruning) — not external. Only the two dirs move on + // disk; FSEvents reports the directory paths, not per-child events. + stampLocalWrite(fromPath); + stampLocalWrite(toPath); // Re-prefix metadata (note + folder pins, created stamps, active) in one write; this // sets metadataRef.active synchronously, so a racing edit() already sees the new id. await persistMetadata(withReprefixed(metadataRef.current, fromPath, toPath)); @@ -740,7 +850,7 @@ export function useNotes( moveInProgressRef.current = false; } }, - [conflict, flush, store, persistMetadata, refresh, clearTimer, onError], + [conflict, flush, store, persistMetadata, refresh, clearTimer, onError, stampLocalWrite], ); const rename = useCallback( @@ -754,6 +864,8 @@ export function useNotes( const meta = await store.rename(id, nextTitle); // A no-op rename (same leaf): nothing re-keys on disk, so just report the id. if (meta.id === id) return meta.id; + stampLocalWrite(id); // our rename — both ends echo through the watcher + stampLocalWrite(meta.id); const wasActive = metadataRef.current.active === id; // A keystroke during the flush/rename await may have re-queued an edit for the renamed // note (the editor stays live — no remount). Kill its timer so it can't flush against @@ -798,7 +910,7 @@ export function useNotes( return null; } }, - [conflict, flush, store, persistMetadata, rekeyNote, clearTimer, onError], + [conflict, flush, store, persistMetadata, rekeyNote, clearTimer, onError, stampLocalWrite], ); const move = useCallback( @@ -814,6 +926,8 @@ export function useNotes( const meta = await store.move(id, destFolder); // No-op move (already in that folder): nothing to reconcile. if (meta.id === id) return meta.id; + stampLocalWrite(id); // our move — both ends echo through the watcher + stampLocalWrite(meta.id); const wasActive = metadataRef.current.active === id; // A keystroke during the flush/move awaits may have re-queued an edit for the moved @@ -863,7 +977,17 @@ export function useNotes( moveInProgressRef.current = false; } }, - [conflict, flush, store, persistMetadata, rekeyNote, relistFolders, clearTimer, onError], + [ + conflict, + flush, + store, + persistMetadata, + rekeyNote, + relistFolders, + clearTimer, + onError, + stampLocalWrite, + ], ); const remove = useCallback( @@ -873,6 +997,7 @@ export function useNotes( await flush(); try { await store.remove(id); + stampLocalWrite(id); // our deletion — not an external change notifyShellNoteRemoved(id); // drop any note-window focus-if-open entry for it if (pendingRef.current?.id === id) pendingRef.current = null; const wasActive = metadataRef.current.active === id; @@ -889,7 +1014,16 @@ export function useNotes( onError(err instanceof Error ? err.message : 'Failed to delete note'); } }, - [flush, store, persistMetadata, dropNote, relistFolders, clearTimer, onError], + [ + flush, + store, + persistMetadata, + dropNote, + relistFolders, + clearTimer, + onError, + stampLocalWrite, + ], ); // Reload the trash from the store and reconcile the registry to mirror it (drop vanished entries, @@ -927,6 +1061,8 @@ export function useNotes( const icon = metadataRef.current.icons[id]; const appearance = metadataRef.current.appearances[id]; const trashId = await store.trash(id); + // Our move-to-trash: the live id vanishes (the `.trash/` end is filtered anyway). + stampLocalWrite(id); notifyShellNoteRemoved(id); // the live id is gone — drop its note-window entry if (pendingRef.current?.id === id) pendingRef.current = null; const wasActive = metadataRef.current.active === id; @@ -958,7 +1094,17 @@ export function useNotes( return false; } }, - [conflict, flush, store, persistMetadata, dropNote, relistFolders, clearTimer, onError], + [ + conflict, + flush, + store, + persistMetadata, + dropNote, + relistFolders, + clearTimer, + onError, + stampLocalWrite, + ], ); const restoreFromTrash = useCallback( @@ -966,6 +1112,7 @@ export function useNotes( const entry = metadataRef.current.trashed.find((t) => t.id === trashId); try { const meta = await store.restore(trashId, entry?.originalPath ?? ''); + stampLocalWrite(meta.id); // our restore write — not an external change // Reinstate the original creation stamp (truthy chain dodges a 0/undefined mtime), the // icon, and the appearance — keyed to `meta.id`, the (possibly deduped) id the note // actually restored to. @@ -985,7 +1132,7 @@ export function useNotes( return null; } }, - [store, persistMetadata, refresh, onError], + [store, persistMetadata, refresh, onError, stampLocalWrite], ); const purgeFromTrash = useCallback( @@ -1053,6 +1200,7 @@ export function useNotes( // the user to "Save as copy". create() reuses the same name when it's free, so the // id usually survives; adopt the resulting id in place if it differs. const recreated = await store.create(note?.title ?? 'Note', dirname(conflict.id)); + stampLocalWrite(recreated.id); // our recreate + save — not external const meta = await store.save(recreated.id, content, recreated.updatedAt ?? 0); baselineRef.current = meta.updatedAt ?? null; if (recreated.id !== conflict.id) { @@ -1079,6 +1227,7 @@ export function useNotes( return; } const meta = await store.save(conflict.id, content, conflict.diskUpdatedAt); + stampLocalWrite(conflict.id); // our overwrite — not an external change baselineRef.current = meta.updatedAt ?? null; setConflict(null); setSaveState('saved'); @@ -1087,7 +1236,7 @@ export function useNotes( pendingRef.current = {id: conflict.id, content}; onError(err instanceof Error ? err.message : 'Failed to save note'); } - }, [conflict, note, store, onError, bumpInList, persistMetadata, refresh]); + }, [conflict, note, store, onError, bumpInList, persistMetadata, refresh, stampLocalWrite]); const saveAsCopy = useCallback(async (): Promise<string | null> => { if (!conflict) return null; @@ -1096,6 +1245,7 @@ export function useNotes( pendingRef.current = null; try { const copy = await store.create(`${title} (conflicted copy)`, dirname(conflict.id)); + stampLocalWrite(copy.id); // our copy write — not an external change await store.save(copy.id, content, copy.updatedAt ?? 0); await persistMetadata( withCreatedStamp(metadataRef.current, copy.id, copy.updatedAt ?? 0), @@ -1109,7 +1259,7 @@ export function useNotes( onError(err instanceof Error ? err.message : 'Failed to save a copy'); return null; } - }, [conflict, note, store, refresh, open, persistMetadata, onError]); + }, [conflict, note, store, refresh, open, persistMetadata, onError, stampLocalWrite]); const discard = useCallback(() => { pendingRef.current = null; @@ -1260,37 +1410,93 @@ export function useNotes( }; }, [flush, flushMetadata]); + /** + * Stat the open note and raise a conflict if the disk diverged from our baseline. Shared by + * the focus/visibility listeners and the watcher pipeline below (each applies its own + * visibility policy before calling). + */ + const checkOpenNoteConflict = useCallback(async () => { + const id = metadataRef.current.active; + if (!id || conflictRef.current || pendingRef.current || moveInProgressRef.current) return; + // An in-flight save: pendingRef is already null but baselineRef hasn't advanced yet, so + // a stat now would see the just-written mtime and raise a phantom conflict. + if (saveInFlightRef.current) return; + const baselineAtStart = baselineRef.current; + let diskMtime: number | null; + try { + diskMtime = await store.stat(id); + } catch { + // stat() can throw on permission loss / unexpected FS errors; don't let the + // fire-and-forget check surface as an unhandled rejection. + return; + } + // Re-validate after the await: a note switch, fresh edit, or save that started (or + // started AND finished — the baseline recapture) during the stat means this result + // belongs to a stale world. Acting on it would raise a phantom conflict for the WRONG + // note, and the banner's actions key off conflict.id — "Keep mine" would save the now- + // open note's body over the statted one's file. + if ( + metadataRef.current.active !== id || + conflictRef.current || + pendingRef.current || + moveInProgressRef.current || + saveInFlightRef.current || + baselineRef.current !== baselineAtStart + ) { + return; + } + if (diskMtime === null) { + // Mirror into the ref synchronously (the effect mirror lags a render): the refresh + // that typically follows this check must see the conflict to keep `active` alive. + conflictRef.current = {id, diskUpdatedAt: 0, deleted: true}; + setConflict(conflictRef.current); + setSaveState('conflict'); + } else if (baselineRef.current !== null && diskMtime !== baselineRef.current) { + conflictRef.current = {id, diskUpdatedAt: diskMtime, deleted: false}; + setConflict(conflictRef.current); + setSaveState('conflict'); + } + }, [store]); + + /** + * Manual full re-sync (the orb menu's "Reload notes"): same check-then-refresh order as the + * watcher pipeline — a bare refresh() would let reconcile null an externally-deleted open + * note's `active` with no banner, silently dead-ending the editor. + */ + const reload = useCallback(async () => { + await checkOpenNoteConflict(); + await refresh(); + }, [checkOpenNoteConflict, refresh]); + + /** + * Run a bulk store mutation (import) with the watcher/focus pipelines held off: bulk writes + * go through the store directly — below this hook's echo stamps — so every batch would + * otherwise refresh mid-import (full re-list churn racing the import's own writes). Reuses + * the move guard; the watcher pipeline re-arms and settles once `fn` resolves, and the + * caller refreshes afterwards anyway (import does). + */ + const withBulkWrites = useCallback(async <T>(fn: () => Promise<T>): Promise<T> => { + moveInProgressRef.current = true; + try { + return await fn(); + } finally { + moveInProgressRef.current = false; + } + }, []); + // Detect an external change to the open note when returning to the tab/window. useEffect(() => { - const check = async () => { + const onFocus = () => { if (document.visibilityState !== 'visible') return; - const id = metadataRef.current.active; - if (!id || conflictRef.current || pendingRef.current || moveInProgressRef.current) - return; - let diskMtime: number | null; - try { - diskMtime = await store.stat(id); - } catch { - // stat() can throw on permission loss / unexpected FS errors; don't let the - // fire-and-forget check surface as an unhandled rejection. - return; - } - if (diskMtime === null) { - setConflict({id, diskUpdatedAt: 0, deleted: true}); - setSaveState('conflict'); - } else if (baselineRef.current !== null && diskMtime !== baselineRef.current) { - setConflict({id, diskUpdatedAt: diskMtime, deleted: false}); - setSaveState('conflict'); - } + void checkOpenNoteConflict(); }; - const onFocus = () => void check(); window.addEventListener('focus', onFocus); document.addEventListener('visibilitychange', onFocus); return () => { window.removeEventListener('focus', onFocus); document.removeEventListener('visibilitychange', onFocus); }; - }, [store]); + }, [checkOpenNoteConflict]); // Re-list on window focus: the check above covers only the OPEN note, but with single-note // windows the same folder is routinely shown in several windows at once, so a returning window @@ -1305,13 +1511,143 @@ export function useNotes( const now = Date.now(); if (now - last < FOCUS_REFRESH_MIN_GAP_MS) return; last = now; - refresh().catch(() => { - // A transient read failure keeps the current list; the next focus retries. - }); + // Same order as the watcher pipeline: the check must raise a deleted-conflict (and + // pin `active`) BEFORE refresh()'s reconcile can null the vanished pointer — + // refresh-first would swallow the banner and dead-end the editor. + checkOpenNoteConflict() + .then(() => refresh()) + .catch(() => { + // A transient read failure keeps the current list; the next focus retries. + }); }; window.addEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus); - }, [ready, refresh]); + }, [ready, refresh, checkOpenNoteConflict]); + + // Desktop live watching: the store pushes external on-disk changes (another window, another + // app, a sync agent), so the list — and with it the search corpus, which keys off the list + // signature — refreshes and the open note's conflict check runs WITHOUT waiting for a window + // focus. Feature-detected off the seam (`store.watch`); the web backends keep the + // focus-driven refresh above. Our own writes echo back through the watcher too — echo-only + // batches skip the fast 300 ms cadence and defer ONE trailing verify run instead (a perf + // guard that can only delay a refresh, never lose one). + useEffect(() => { + if (!ready) return undefined; + const watch = store.watch?.bind(store); + if (!watch) return undefined; + let disposed = false; + let unwatch: (() => void) | undefined; + let timer: ReturnType<typeof setTimeout> | null = null; + let verifyTimer: ReturnType<typeof setTimeout> | null = null; + let running = false; + let queued = false; + let pendingWhileHidden = false; + + // Trailing debounce on top of the backend's own event debounce, so a burst of batches + // (bulk external writes) lands as one refresh. Function declarations: run and schedule + // reference each other, in deferred positions only. + function schedule() { + // A sooner full run supersedes a pending echo-verify (it reads the same disk truth). + if (verifyTimer) { + clearTimeout(verifyTimer); + verifyTimer = null; + } + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void run(); + }, WATCH_REFRESH_DEBOUNCE_MS); + } + // Echo-only batches: one deferred run per burst, trailing past the stamp window, so a + // false-positive suppression (external dir event under an ancestor stamp; external + // rewrite of a just-saved note) is healed a few seconds later instead of lost. + function scheduleVerify() { + if (timer) return; // a full run is already due sooner + if (verifyTimer) clearTimeout(verifyTimer); + verifyTimer = setTimeout(() => { + verifyTimer = null; + void run(); + }, WATCH_SUPPRESSED_VERIFY_MS); + } + async function run() { + if (running) { + queued = true; // coalesce: at most one refresh running + one queued + return; + } + if (document.visibilityState === 'hidden') { + // Don't refresh a window the user can't see (minimized/occluded) — latch and + // replay on visibility. Checked at fire time so a timer armed while visible + // can't walk the store behind a window that hid meanwhile. + pendingWhileHidden = true; + return; + } + if (moveInProgressRef.current) { + schedule(); // re-arm rather than drop; the move settles in well under a window + return; + } + running = true; + try { + // Conflict check FIRST: refresh()'s reconcile nulls a vanished `active`, so the + // other order would miss an externally-deleted open note (no banner). + await checkOpenNoteConflict(); + await refresh(); + } catch { + // A transient read failure keeps the current list; the next event/focus retries. + } finally { + running = false; + if (queued && !disposed) { + queued = false; + schedule(); + } + } + } + const onChange = (relPaths: string[]) => { + if (disposed) return; + // Pure echoes of our own writes defer to the verify run. Empty = "many/unknown + // changes" — NEVER suppressible ([].every() is vacuously true). + if (relPaths.length > 0 && relPaths.every(isRecentLocalWrite)) { + scheduleVerify(); + return; + } + if (document.visibilityState === 'hidden') { + // Latch and replay on visibility: becoming visible doesn't imply gaining focus, + // so the focus re-list above wouldn't cover it. + pendingWhileHidden = true; + return; + } + schedule(); + }; + const onVisible = () => { + if (document.visibilityState === 'visible' && pendingWhileHidden) { + pendingWhileHidden = false; + schedule(); + } + }; + document.addEventListener('visibilitychange', onVisible); + watch(onChange).then( + (dispose) => { + // Unmounted while subscribing (StrictMode remount / workspace switch): the + // backend refcounts subscriptions, so disposing the late handle is exact. + if (disposed) dispose(); + else unwatch = dispose; + }, + (err: unknown) => { + // Watching is best-effort — the focus-driven refresh still covers external + // edits — but leave a trace, or a failed watcher degrades the session silently. + console.warn( + 'Live folder watching unavailable; falling back to refresh on window focus.', + err, + ); + }, + ); + return () => { + disposed = true; + if (timer) clearTimeout(timer); + if (verifyTimer) clearTimeout(verifyTimer); + document.removeEventListener('visibilitychange', onVisible); + unwatch?.(); + }; + }, [ready, store, refresh, checkOpenNoteConflict, isRecentLocalWrite]); return { notes, @@ -1348,6 +1684,8 @@ export function useNotes( edit, flushPending, refresh, + reload, + withBulkWrites, reloadDisk, keepMine, saveAsCopy, diff --git a/src/storage/fileSystemStore.ts b/src/storage/fileSystemStore.ts index 3427037..0c94eb1 100644 --- a/src/storage/fileSystemStore.ts +++ b/src/storage/fileSystemStore.ts @@ -280,6 +280,8 @@ export class FileSystemNoteStore implements NoteStore { // even transiently), remove the source so the new-cased name is free, then write it. The // bytes live safely in the temp across the (necessary, unavoidable on this FS) window // between dropping `oldLeaf` and committing `nextLeaf`. + // `.rename-tmp` mirrors RENAME_TMP_SUFFIX in src-tauri/src/lib.rs (shared with the + // Tauri store so the desktop folder watcher's temp filter covers both spellings). const tempLeaf = `${nextLeaf}.rename-tmp`; await this.copyFileBytes(srcHandle, dir, tempLeaf); const tempHandle = await this.existingFileHandle(dir, tempLeaf); diff --git a/src/storage/tauriStore.test.ts b/src/storage/tauriStore.test.ts index a9df456..07dc14f 100644 --- a/src/storage/tauriStore.test.ts +++ b/src/storage/tauriStore.test.ts @@ -12,6 +12,33 @@ vi.mock('@tauri-apps/api/core', () => ({ fsHolder.current!.dispatch(cmd, args), })); +/** The window's live `notes:changed` listeners (registered by `watch`), reset per test. */ +const {eventHolder} = vi.hoisted(() => ({ + eventHolder: { + listeners: [] as Array<(event: {payload: unknown}) => void>, + unlistenCalls: 0, + }, +})); + +// `watch` loads the event API via dynamic import; vi.mock intercepts that too. +vi.mock('@tauri-apps/api/webviewWindow', () => ({ + getCurrentWebviewWindow: () => ({ + listen: async (_event: string, handler: (event: {payload: unknown}) => void) => { + eventHolder.listeners.push(handler); + return () => { + eventHolder.unlistenCalls += 1; + const idx = eventHolder.listeners.indexOf(handler); + if (idx >= 0) eventHolder.listeners.splice(idx, 1); + }; + }, + }), +})); + +/** Emit a `notes:changed` event to every registered listener, like the Rust `emit_to`. */ +function emitNotesChanged(dir: string, paths: string[]) { + for (const handler of [...eventHolder.listeners]) handler({payload: {dir, paths}}); +} + /** * In-memory stand-in for the Rust `notes_*` commands, modelling a case-INSENSITIVE filesystem * (macOS's default) so case-only renames and collisions behave like the real desktop app. Keys are @@ -19,6 +46,11 @@ vi.mock('@tauri-apps/api/core', () => ({ * mtime so optimistic-concurrency conflicts are deterministic. */ class FakeFs { + /** Recorded `notes_watch` / `notes_unwatch` invokes (see the dispatch arms below). */ + watchCalls: Array<{dir: string; listenersAtCall: number}> = []; + unwatchCalls: string[] = []; + failWatch = false; + private files = new Map<string, {name: string; content: string; mtime: number}>(); private clock = 1000; @@ -47,6 +79,18 @@ class FakeFs { return this.stat(name); case 'notes_move_dir': return this.moveDir(args.from as string, args.to as string); + case 'notes_watch': + if (this.failWatch) throw new Error('watch failed'); + // Record how many listeners were live at call time: `watch` must subscribe the + // window BEFORE registering the native watcher, or an early event is lost. + this.watchCalls.push({ + dir: args.dir as string, + listenersAtCall: eventHolder.listeners.length, + }); + return null; + case 'notes_unwatch': + this.unwatchCalls.push(args.dir as string); + return null; default: throw new Error(`unknown command: ${cmd}`); } @@ -173,6 +217,8 @@ const {TauriNoteStore} = await import('./tauriStore'); function newStore() { fsHolder.current = new FakeFs(); + eventHolder.listeners = []; + eventHolder.unlistenCalls = 0; return {store: new TauriNoteStore('/notes'), fs: fsHolder.current}; } @@ -502,4 +548,51 @@ describe('TauriNoteStore', () => { expect(await store.listTrash()).toEqual([]); }); }); + + describe('watch', () => { + it('subscribes the window listener BEFORE registering the native watcher', async () => { + await store.watch(() => {}); + expect(fs.watchCalls).toEqual([{dir: '/notes', listenersAtCall: 1}]); + }); + + it('delivers this folder’s change paths and filters other folders’ events', async () => { + const seen: string[][] = []; + await store.watch((paths) => seen.push(paths)); + + emitNotesChanged('/notes', ['Note.md', 'Work/Sub']); + // A stale listener for another store's folder (mid workspace switch) must not fire. + emitNotesChanged('/elsewhere', ['Other.md']); + emitNotesChanged('/notes', []); // the "many/unknown changes" signal passes through + + expect(seen).toEqual([['Note.md', 'Work/Sub'], []]); + }); + + it('the disposer unlistens and unregisters the native watcher', async () => { + const seen: string[][] = []; + const dispose = await store.watch((paths) => seen.push(paths)); + + dispose(); + emitNotesChanged('/notes', ['Note.md']); + + expect(seen).toEqual([]); + expect(eventHolder.unlistenCalls).toBe(1); + // The fire-and-forget notes_unwatch invoke has been dispatched. + await vi.waitFor(() => expect(fs.unwatchCalls).toEqual(['/notes'])); + + // Idempotent per handle: the Rust side decrements a refcount on every unwatch, so a + // double-dispose must not double-decrement (it could tear the watcher down under + // this window's other live subscription). + dispose(); + expect(eventHolder.unlistenCalls).toBe(1); + await Promise.resolve(); // give a (wrongly) dispatched invoke a tick to record + expect(fs.unwatchCalls).toEqual(['/notes']); + }); + + it('a failed native registration unlistens and rethrows', async () => { + fs.failWatch = true; + await expect(store.watch(() => {})).rejects.toThrow('watch failed'); + expect(eventHolder.listeners).toEqual([]); + expect(eventHolder.unlistenCalls).toBe(1); + }); + }); }); diff --git a/src/storage/tauriStore.ts b/src/storage/tauriStore.ts index 01d5871..e79c9d6 100644 --- a/src/storage/tauriStore.ts +++ b/src/storage/tauriStore.ts @@ -47,6 +47,14 @@ interface AttachmentEntry { size: number; modifiedMs: number; } +/** + * Payload of the Rust `notes:changed` watcher event. `dir` is the raw folder path (strict-equal + * to this store's `dir`); empty `paths` means "many/unknown changes". + */ +interface NotesChangedPayload { + dir: string; + paths: string[]; +} /** Mirror the web backend's deleted-note signal so `useNotes` maps it to a "deleted" conflict. */ function notFound(id: string): DOMException { @@ -155,6 +163,8 @@ export class TauriNoteStore implements NoteStore { // On a case-SENSITIVE volume `nextName` could be a distinct existing file (the collision // probe above is skipped for case-only renames); the Rust `notes_rename` no-clobber guard // refuses to overwrite a different file, so this can't silently destroy data. + // `.rename-tmp` mirrors RENAME_TMP_SUFFIX in src-tauri/src/lib.rs — the folder + // watcher filters these; a changed suffix there would leak the temp's events. const tempName = `${nextName}.rename-tmp`; await invoke('notes_rename', {dir: this.dir, from: id, to: tempName}); const updatedAt = await invoke<number>('notes_rename', { @@ -336,6 +346,44 @@ export class TauriNoteStore implements NoteStore { await invoke('reveal_path', {dir: this.dir, name: relPath}); } + /** + * Subscribe to external on-disk changes (see `NoteStore.watch`): one debounced native + * watcher per folder, refcount-shared across windows Rust-side. Window-scoped `listen` — + * the shell emits `notes:changed` per subscriber via `emit_to`. + */ + async watch(onChange: (relPaths: string[]) => void): Promise<() => void> { + // Dynamic import keeps the Tauri event API out of the web bundle (this class is only + // constructed in the shell, but the module is imported unconditionally). + const {getCurrentWebviewWindow} = await import('@tauri-apps/api/webviewWindow'); + // Listen BEFORE registering the native watcher, so no event can slip between the two. + const unlisten = await getCurrentWebviewWindow().listen<NotesChangedPayload>( + 'notes:changed', + (event) => { + // During a workspace switch this window can briefly hold listeners for the + // outgoing store too — deliver only this folder's events. + if (event.payload.dir === this.dir) onChange(event.payload.paths); + }, + ); + try { + await invoke('notes_watch', {dir: this.dir}); + } catch (err) { + unlisten(); + throw err; + } + let disposed = false; + return () => { + // Idempotent per handle: the Rust side decrements a per-window refcount on EVERY + // unwatch (only a fully-gone label no-ops), so a double-dispose would tear the + // count down under another live subscription of this same window. + if (disposed) return; + disposed = true; + unlisten(); + // Fire-and-forget: the disposer runs in effect cleanups that can't await, and a + // late unwatch after window destroy no-ops on the Rust side. + void invoke('notes_unwatch', {dir: this.dir}).catch(() => {}); + }; + } + async readMetadata(): Promise<NotesMetadata> { try { const entry = await invoke<NoteFull | null>('notes_read_opt', { diff --git a/src/storage/types.ts b/src/storage/types.ts index 0cb046a..a28f418 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -234,6 +234,16 @@ export interface NoteStore { * affordance on the web / in-browser backends, which have no file to reveal. */ reveal?(relPath: string): Promise<void>; + /** + * Subscribe to EXTERNAL on-disk changes in the notes folder (another app, another window, + * a sync agent). `onChange` receives root-relative POSIX paths of changed notes/folders — an + * EMPTY array means "many/unknown changes, treat everything as changed". Events are debounced + * backend-side; the caller still owns its own coalescing, self-echo suppression, and refresh + * policy. Resolves to a disposer that unsubscribes (sync — the underlying native teardown is + * fire-and-forget). Present only on the native desktop backend; callers feature-detect it + * (`store.watch`), and the web backends keep the focus-driven refresh instead. + */ + watch?(onChange: (relPaths: string[]) => void): Promise<() => void>; /** Current `lastModified` for a note, or `null` if it no longer exists. */ stat(id: string): Promise<number | null>; /** Read the folder's notes metadata (sort, pins, created times); defaults if absent or corrupt. */