feat(desktop): Documents — a local markdown vault, behind a preview flag - #4997
feat(desktop): Documents — a local markdown vault, behind a preview flag#4997derekross wants to merge 13 commits into
Conversation
Adds `documents` as a gated preview feature: a sidebar entry, a
`/documents` route, a Settings section with a native folder picker, and a
read-only three-pane vault browser (tree | note | rail). Editing lands in a
follow-up; this phase is deliberately read-only so the Rust surface, tree
walker, and pane layout can be proven against a real Obsidian vault while it
is impossible to damage a file.
Ported from the Onyx editor, with two hardenings over its vault model:
* The vault root lives in Rust `VaultState`, not in a command argument.
Onyx's `read_file(path, vault_path)` lets a caller supply both sides of
the containment check, and five of its commands skip validation
entirely. Buzz ships `"csp": null` and renders remote content, so the
IPC surface is the only boundary there is.
* `validate_vault_path` returns a `ValidatedVaultPath` newtype that the
`fs::` calls are the only consumers of. Onyx enforces "use the returned
path, never the string you passed in" by doc comment; the newtype makes
a new call site that ignores it a compile error.
Containment stays lexical rather than `canonicalize()`-based, so a folder
the user deliberately symlinked into their vault keeps working -- the
regression that motivated the design upstream, covered by a ported test.
Also drops five Onyx commands this phase has no caller for (binary read and
write, asset listing, vault search, file stats), avoiding a second new Rust
dependency along with them.
Two supporting refactors were needed first, because `AppShell.tsx` (999) and
`AppSidebar.tsx` (998) both sat against the 1000-line file-size gate:
* The `AppView` union was copied inline in four places; it now has one
home in `AppShell.helpers.ts`. `ChatHeader` takes the union minus
`messages` via `Exclude`.
* The settings branch of the shell moved to `AppShellSettingsPane.tsx`,
which absorbs the ten-prop fan-out of a single hook result.
`AppShell.tsx` 999 -> 972, `AppSidebar.tsx` 998 -> 992.
Verified: 4295 desktop unit tests, 25 Rust vault tests, 914 E2E smoke,
clippy `-D warnings`, and the biome / file-size / px-text / pubkey gates.
Signed-off-by: Derek Ross <derekross@gmail.com>
…guard
Turns the read-only vault browser into an editor: live-preview TipTap, a raw
source mode, per-file tabs with dirty tracking, 2s debounced autosave, Cmd+S,
filesystem watching, and create/rename/delete from a tree context menu.
The load-bearing part is the round-trip guard. tiptap-markdown is markdown-it
plus prosemirror-markdown, and anything outside the TipTap schema does not
survive a parse/serialize cycle. Measured against the real pipeline (see the
corpus test), that includes:
* a GFM table, which serializes down to its concatenated cell text --
`| a | b |` and its rows become `ab12`
* YAML frontmatter, where the opening fence becomes a thematic break and
every property becomes a heading
* callouts, footnotes, raw HTML, setext headings, `_em_`, `+`/`*` bullet
markers, four-space nesting, and two-space hard breaks
Autosaving any of those over someone's vault would destroy the file. So on
open we parse, serialize, and compare: anything lossy opens in source mode --
a plain textarea that never touches the serializer -- with a banner saying
why. Live preview stays one click away, but it is a choice rather than a
silent default. Frontmatter is split off before the editor sees it and
re-attached byte-for-byte, which is what makes ordinary notes editable at all.
Two further rules protect files:
* Nothing is written unless the user edited it. Dirtiness is derived by
comparing projected bytes against what was read, so opening a note -- or
undoing back to its original text -- never triggers a save. Onyx wires its
listener to an event that also fires on load, which is how "open a file,
touch nothing, and it rewrites on disk" happens.
* A dirty buffer is never silently replaced. Onyx's watcher rule is
"external edits always win"; that is right for a clean tab and wrong for
one with unsaved work, and it also loses keystrokes to the app's own save
echoing back through the watcher. Writes return their mtime so the echo is
recognised and ignored, and a genuine external change to a dirty tab
raises a banner offering reload or keep-mine.
Also folds the escape-stripping hack into one owner. Onyx carries three
divergent copies and the chat composer a fourth; `[[wikilink]]` survives
precisely because it undoes the serializer's escaping.
New Rust: atomic writes returning mtime, create/rename/delete with a
move-into-itself guard, and a `notify` watcher that filters dotted paths --
Onyx skips hidden entries when building its tree but watches them anyway, so
a vault containing `.git` thrashes the event stream.
Verified: 4347 desktop unit tests, 33 Rust vault tests, 21 Documents E2E,
clippy `-D warnings`, and `cargo deny check licenses` for the new dependency.
Signed-off-by: Derek Ross <derekross@gmail.com>
Adds `[[wikilink]]` rendering with click-to-open, Obsidian-compatible note
resolution, and a backlinks rail listing linked and unlinked mentions.
Onyx carries two different wikilink regexes: the editor plugin splits
`[[Note#Heading]]` into a target and a heading, while the note index captures
`Note#Heading` whole. They disagree, so the editor renders a link the graph
never records and the backlink silently goes missing. There is one pattern
here, covering all six forms plus same-note anchors, and two details that
took measuring to get right:
* `\[\[…\]\]` must parse. prosemirror-markdown escapes brackets in text
nodes, so a link read back out of the serializer arrives escaped.
* Excluding embeds needs two lookbehinds. `(?<!!)` catches `![[x]]`, but for
the escaped `!\[\[x\]\]` the match starts one character in -- at the `[`
after the backslash -- and sees only `\` behind it, so `(?<!!\\)` is
needed as well.
Resolution follows Obsidian: `-`, `_` and space are equivalent, matching is
case- and extension-insensitive, a slash means a vault-relative path, and ties
prefer a note in the same folder before falling back to the shortest path.
Ties break alphabetically so results do not depend on enumeration order.
The extension is decoration-only, which matters more than it sounds: wikilinks
stay plain text in the document, so they serialize back byte-identically and
notes containing them still pass the round-trip guard. The corpus test pins
that.
Following the house pattern from `mentionHighlightExtension`, the note index
and click handler live in extension `storage` rather than Onyx's module-level
mutable globals, which make the editor a singleton and would have blocked
split panes later.
Unlinked mentions are word-boundary matched so "Notes" does not match inside
"Notesworthy", and a line already containing a resolving link is not also
counted as an unlinked mention.
Verified: 4378 desktop unit tests (31 new across wikilink syntax, note index
and backlinks), 16 Documents E2E, and the biome / file-size / px-text gates.
Signed-off-by: Derek Ross <derekross@gmail.com>
Adds callouts, `==highlight==`, `%%comment%%`, `#tag`, clickable task
checkboxes, and a heading outline with scroll-spy, alongside E2E coverage for
the wikilink and backlink behaviour added previously.
Everything is decoration-driven. Nothing here becomes a schema node, because a
node would have to serialize itself back and any imperfection there means the
round-trip guard starts pushing perfectly good notes into source mode.
Decorations leave the text untouched, so `==highlight==` on disk is still
`==highlight==` after a save. The corpus test pins that the guard's verdicts
did not move.
Callouts are the honest exception, and the tests say so rather than hiding it:
they are decorated, but a note containing one still fails the round-trip guard
because the serializer merges the blockquote's lines. Such notes open in
source mode, and callout styling appears only if the user opts into live
preview. Both halves are pinned, so a future serializer fix shows up as a
visible test change rather than a silent behaviour shift.
Two details worth naming:
* The tag pattern has to reject markdown headings (`# Title`), bare hashes,
hex colours and URL fragments. Requiring a non-digit and anchoring to
start-of-line or whitespace covers all four.
* Task checkboxes toggle by hit-testing the rendered marker element. Onyx
uses a hardcoded `clickX > 30` measured against its own CSS, which breaks
the moment Cmd +/- zoom changes the font size.
Callout colours resolve through theme tokens via a single `--callout-accent`
custom property rather than Onyx's hardcoded hexes, which only work on a dark
background.
Verified: 4390 desktop unit tests, every `just ci` recipe individually
(check, test-unit, desktop-test, desktop-build, desktop-tauri-check,
desktop-tauri-test, web-build, mobile-test), and 24 Documents E2E covering
wikilink click-through, broken links, escape preservation across a save,
linked vs unlinked backlinks, the outline, and both sides of the callout
round-trip behaviour.
Signed-off-by: Derek Ross <derekross@gmail.com>
Completes the Documents feature set: `^block-id` anchors are decorated, and open tabs plus expanded folders survive a restart. Session restore deliberately persists paths only, never content. Restoring a stale buffer over a file that changed on disk would be a silent overwrite the moment autosave fired -- the same class of bug the round-trip guard and the watcher reconciliation exist to prevent. Every note is re-read from disk, so the on-disk version always wins. A snapshot belonging to a different vault is discarded rather than filtered, since its paths do not exist in the current one. Block anchors are matched only at end-of-line, which makes excluding the caret inside `[[Note^id]]` automatic: a wikilink's caret is always followed by `]]`. Onyx scans for anchors and then separately filters out the ones inside wikilinks. Also fixes a swallowed click: a `#tag` with no click handler registered consumed the event and returned handled, so tagged text could not be selected or have the caret placed in it. It now falls through when there is nothing to call. Verified: 4399 desktop unit tests, 25 Documents E2E, every `just ci` recipe, and the biome / file-size / px-text gates. Signed-off-by: Derek Ross <derekross@gmail.com>
…w setting Four fixes from real-vault testing, plus table support. **Tables now round-trip.** `tiptap-markdown` already ships table serialization and lists the four `@tiptap/extension-table*` packages as optional peers; they simply were not installed. Without them markdown-it still parsed a GFM table, the nodes were dropped, and it serialized back as bare concatenated cell text -- `| a | b |` and its rows became `ab12`. They move from the corpus test's lossy list to its stable list. **The guard was firing on almost every real file.** Measured against 40 real markdown files, only 5 passed. The dominant cause was a false positive: prose hard-wrapped at ~80 columns. A single newline inside a paragraph is a CommonMark soft break, and the serializer legitimately re-emits the paragraph as one line -- nothing is lost, but the bytes differ. The comparison now joins soft-wrapped lines, conservatively: any line that could begin a block stops the join, and fenced regions are skipped, so failing to join merely routes a file to source mode. Table delimiter widths are normalized too, since GFM ignores them, while alignment colons are preserved because they do not. **The editor had no typography at all.** `prose` classes were used but this project has no `@tailwindcss/typography`, so they were inert and ProseMirror rendered with zero block spacing. Adds real editor CSS in rem/em so Cmd +/- zoom still scales it. **Two competing live-preview controls.** The notice carried its own "use live preview anyway" button while the header toggle sat immediately below, reading as two controls for one action. The notice is now a single informational line; the header toggle is the only mode control. Adds a Settings toggle, "Always open in live preview", off by default. The guard still classifies every file -- the setting only decides which mode a note opens in, so the warning still shows. Turning it on means saving a note the guard flagged will reformat it, which the setting copy states plainly. Outline and backlink sections are now collapsible, matching Onyx. Verified: 4405 desktop unit tests, 27 Documents E2E, and the biome / file-size / px-text gates. Signed-off-by: Derek Ross <derekross@gmail.com>
…chrome
Round-trip guard now tolerates two more purely cosmetic differences found by
testing against a real vault:
* Soft-wrapped prose inside a blockquote. Prose wraps inside `>` blocks just
like outside one, and the serializer joins it the same way. Callouts are
deliberately exempt -- their first line is a title, so joining it into the
body would change the rendered callout, and they must keep failing the
guard until an extension can round-trip them.
* `*` and `+` bullet markers, which mean the same as `-` in CommonMark.
Both moved from the corpus test's lossy list to its stable list, which is the
signal that what the guard blesses has changed.
UI fixes from the same session of real use:
* The right rail (outline and backlinks) now has a toggle in the header and
remembers the choice; it was permanently visible.
* The Documents header no longer offers "Copy channel name" -- a document is
not a channel, and its title is a filename.
* The editor pane no longer repeats the filename already shown in the tab.
* With "Always open in live preview" enabled, the reformatting notice is
suppressed: the user has already accepted the trade, so repeating it on
every note is noise.
Verified: 4409 desktop unit tests, 28 Documents E2E, gates clean.
Signed-off-by: Derek Ross <derekross@gmail.com>
Measured against a 470-note Obsidian vault, the guard passed **4%** of files,
which is indistinguishable from not shipping live preview at all. Every failure
sampled was a difference no reader could see.
Isolating one reported file found the cause was a *single trailing space* on one
line of 64. Bisecting the rest of the vault found five more of the same kind, so
`normalizeForComparison` now also tolerates:
* A lone trailing space. Two or more are a hard break and still fail.
* Blank lines that only separate blocks -- writing a list or prose directly
under its heading is how every daily-note template in that vault is written,
and the serializer always adds the blank. Runs of blanks collapse to one.
* `***` and `___` thematic breaks, which mean the same as `---`.
* Table column padding and delimiter dash counts.
* `_em_` versus `*em*`, honouring CommonMark's intraword rule so
`_See weekly_report.py._` pairs its underscores the way the parser does and
`snake_case_name` is left alone entirely.
* Runs of spaces inside a line, which every renderer collapses.
Pass rate: **4% -> 63%**. The remaining 176 failures span 164 distinct causes --
a genuine long tail of callouts, raw HTML, footnotes and dropped link text,
which are real losses the guard should keep catching.
The line these tolerances share is that a reader cannot see any of them.
Differences a reader *would* see still route the file to source mode. Two
exceptions in the block-separation rule are load-bearing and now pinned by the
corpus test: a blank line between list items makes the list loose, and a blank
line between blockquotes is the only thing keeping them from merging. Both
survive the editor intact, so collapsing them would have hidden a real change.
Also in this change:
* `saveAllDirty` now runs on unmount. It was exported and never called, so
leaving Documents inside the 2s autosave debounce dropped the last edit --
exactly the data loss the module exists to prevent.
* Tags no longer render with a pointer cursor and hover underline. Nothing
supplies `onTagClick` and nothing can until the vault gains a search, so the
styling was advertising an action that never happened. The seam stays.
* The four `@tiptap/extension-table*` packages were resolving to 3.29.2 while
the rest of TipTap sat at 3.22.5, leaving an unsatisfied *exact* peer
dependency on `@tiptap/core@3.29.2`. Tables are used in one file and core is
used throughout the message composer, so they align down. Tables still
round-trip on 3.22.5.
Verified: 4417 desktop unit tests, typecheck, and `pnpm check` clean.
Signed-off-by: Derek Ross <derekross@gmail.com>
Two problems found testing Documents against a real vault.
**Saving accused the user of a conflict.** Typing into a note and pausing
reliably raised "This file changed on disk while you had unsaved edits."
`write_vault_file` replaces the file by renaming a temp file over it. The
filesystem watcher fires on that rename immediately, while the command is still
stat-ing the file, serialising, and crossing the IPC boundary back to the
webview. Those are independent channels with no ordering guarantee, and the
watcher event usually won -- so the mtime the echo-suppression check needed had
not been recorded yet. It fell through, saw a dirty tab, and raised the banner.
Autosave runs 2s after every typing pause, so this fired on essentially every
save.
Comparing mtimes cannot win a race against the thing that produces the mtime.
The authoritative check is now the bytes: the session records exactly what it
last read or wrote in a ref updated *synchronously*, before the write begins,
and a watcher event reads the file and compares. Same bytes means our own echo.
That is ordering-independent, so there is no race left to lose. It samples
either side of the read as well, since an autosave can land while that read is
in flight.
This also fixes a case that was never reachable before: an external tool
rewriting a file with byte-identical content -- what sync clients and formatters
do constantly -- no longer raises a spurious conflict.
**Switching notes took about two seconds.** Measured against the 471-note vault
rather than guessed at:
* `getBacklinks` ran on every switch, scanning every note, compiling a fresh
`RegExp` for each of ~75k lines and parsing each line's wikilinks twice. It
now compiles once per pass, parses once per line, and rules out most of the
vault with two native substring scans before splitting anything into lines.
**103ms -> 22ms**, identical output.
* The live editor is remounted per file so undo cannot resurrect another
note's text. Creating the editor turned out to be cheap (~10ms) -- the cost
was the `setContent` that follows re-parsing the whole note through
markdown-it every time: 249ms for a 110KB note. Handing `setContent` the
ProseMirror JSON it produced last time skips the parse entirely, measured
**30-47x faster** (249ms -> 8ms on that note). The remount, and so the undo
isolation, is kept.
The parsed-document cache is keyed by path *and* the exact markdown it was built
from, so a stale entry cannot be served: any difference in the source text is a
miss, and a miss just re-parses.
Verified: 137 Documents unit tests, typecheck and lint clean.
Signed-off-by: Derek Ross <derekross@gmail.com>
The existing "a dirty tab keeps the user's buffer" test emitted a
`vault-file-modified` event without changing the file. That passed for the
wrong reason: a watcher event whose bytes match what we already hold is not a
conflict and is now deliberately ignored, so the test has to actually write
different content to the mock vault first.
Two new cases, both of which failed before the reconciliation fix:
* A watcher event carrying an mtime the app never recorded -- what happens in
the real app when the rename inside `write_vault_file` trips the watcher
before the command's response crosses back to the webview.
* A rewrite with byte-identical content, as sync clients and formatters do.
Signed-off-by: Derek Ross <derekross@gmail.com>
Findings from a security pass over the vault commands before review.
**An unbounded read could freeze the app.** `read_vault_file` and
`read_vault_files` both called `read_to_string` with no size limit. Every read
loads the whole file into a `String`, ships it across IPC as JSON, and the
frontend then parses it through TipTap twice — once for the round-trip guard,
once for the editor. One stray export, database dump, or log file ending in
`.md` was enough to hang the app, and `read_vault_files` does the whole vault in
a single call.
Notes are prose: the largest in a real 471-note vault is 110 KB, so the cap is
2 MB — roughly 18x the observed worst case. Oversized notes are refused with a
message naming the size and the limit rather than silently failing, and the
corpus batch skips them instead of failing the whole index.
**Two properties are now pinned by tests rather than by reasoning:**
* Deleting a linked folder unlinks it and leaves the target alone. Containment
is lexical, so a symlinked folder is reachable on purpose, and
`delete_vault_entry` branches on `is_dir()` — which follows the link — then
calls `remove_dir_all` on it. That is only safe because `remove_dir_all`
refuses to descend through a symlink (the fix for CVE-2022-21658). Verified
empirically first, then pinned: if that ever stops holding, or the branch is
rewritten to canonicalize, deleting a linked folder would destroy the real
directory behind it.
* Hostile markdown never becomes live markup. A vault is not trusted input —
notes arrive from git repos, sync clients and shared folders — and Buzz
ships with `"csp": null`, so the renderer has no second line of defence.
`editorSecurity.test.mjs` feeds script tags, event-handler attributes,
`javascript:`/`data:`/`vbscript:` links and image-URL escapes through the
real editor and asserts against the parsed DOM that none of them
materialise as elements or attributes. It also asserts an ordinary link
still works, so the file cannot pass by rendering nothing.
The properties those tests protect are `html: false`, `linkify: false` and
`openOnClick: false` in `vaultEditorExtensions.ts`. Turning any of them on now
fails here instead of in someone's vault.
Verified: 36 Rust vault tests, 4427 desktop unit tests, clippy `-D warnings`
clean.
Signed-off-by: Derek Ross <derekross@gmail.com>
…claims Rebasing onto main brought in block#4614, which enables a content security policy. Two comments on this branch asserted the opposite — that Buzz ships `"csp": null` and the renderer therefore has no second line of defence. Both are now wrong, so both are corrected rather than left to mislead the next reader: * `vault_path.rs` justified holding the vault root in Rust partly on the absence of a CSP. The real reason does not depend on one: CSP constrains what the page may load and execute, not what the app's own code may ask the backend to do, so containment has to be decided on the Rust side either way. * `editorSecurity.test.mjs` claimed no second line of defence existed. There is one — `script-src` omits `'unsafe-inline'` — but it is not a reason to stop escaping: `img-src` and `connect-src` both allow `https:`, so raw markup reaching the DOM could still beacon a note's contents out without running any script. Main also grew `lib.rs` to 991 lines, so this branch's 17 lines of vault registration pushed it to 1008 and tripped the file-size ratchet. Rather than raise the limit, the `pending_sync` flush loop moves into `persona_events::spawn_flush_loop`, next to the function it drives — the loop existed only to call it on a timer, and its recovery-mode caveat now sits on the function that enforces it. `lib.rs` lands at 988. Verified after rebase: 4512 desktop unit tests, 2267 Tauri Rust tests, clippy `-D warnings`, `pnpm check` and typecheck clean. Signed-off-by: Derek Ross <derekross@gmail.com>
Two things reviewing the PR screenshots turned up. **The Settings copy named tables as unsupported.** They gained a schema node and now round-trip, so the list is just callouts, footnotes and raw HTML. Leaving tables in it told users to expect source mode for a construct that works. **The outline showed the previous note's headings.** Opening a note in source mode leaves it standing, because the outline is published by the live editor's plugin and source mode has no live editor. The panel therefore listed headings from a file the user was no longer looking at, whose scroll-to targets pointed into an unmounted document. Source mode now clears it. Also adds `documents-screenshots.spec.ts`, which seeds a vault rich enough to show the feature — live preview with outline and backlinks, a populated linked mention, and a note the round-trip guard sent to source mode. Each shot is scoped with `locator.screenshot()` so two cannot come out byte-identical, and the three hashes were verified distinct. Verified: 32 Documents E2E pass, typecheck and lint clean. Signed-off-by: Derek Ross <derekross@gmail.com>
|
@derekross thanks for the PR but this is a significant change to the Buzz core. Also, we have a "nest" in buzz already that might be useful for this usecase but without the UI. If you check in your home folder Also, we should look into a way to store these on the relay vs. locally so they would be available to all your devices connected to the relay vs. just local file system. But this is a dope idea! Def gets the ideas flowing |
|
Yes, those documents are stored there, but you have to open an application outside of Buzz to view them. That was the whole point of the PR so that don't need to leave Buzz to view documents that you and agents create together. It's a better workflow. Yes, we can store them on the relay. I had planned on that being a phase two after the initial UI was approved and merged. It would have been a larger undertaking to try and accomplish both right away. But yes, sharing documents would be the ultimate goal. |
Documents — a local markdown vault, behind a preview flag
Adds Documents: a file tree, tabbed editor, and live preview over a folder
of plain
.mdfiles on disk. No relay, no events, no new kinds — the vault islocal and per-machine, so Obsidian and
gitsee exactly what you would expect.Off by default, under Settings → Experiments, following the Projects
pattern (
FeatureGateon the sidebar entry,usePreviewFeatureWarningon theroute).
The editor and file browser are ported from Onyx,
an open-source Obsidian-like app. Its Milkdown plugins are plain ProseMirror,
so they port onto Buzz's existing TipTap stack by rewriting imports.
Documents over a real vault — file tree, tabs, and live preview. Files stay plain
.mdon disk.Off by default, under Settings → Experiments, following the Projects pattern.
Settings → Documents — the vault folder is global and per-machine, not per-community.
The idea the design rests on
A WYSIWYG editor autosaving over someone's real vault is a data-destruction
feature unless proven otherwise.
tiptap-markdownis markdown-it plusprosemirror-markdown, and anything outside the TipTap schema does not survive
the trip.
So rather than guess which constructs are safe, every note is asked one
question before the editor touches it: does
serialize(parse(x))returnx? If not, it opens in source mode — a raw textarea that never touchesthe serializer — with a one-line notice. Editing is still possible; silent
reformatting is not.
That guard is
lib/roundTripGuard.ts, andlib/markdownRoundTrip.test.mjsisits corpus test: it pins exactly which constructs survive, so an extension that
changes the answer fails there loudly instead of quietly rewriting a vault.
Measured, not assumed. Byte-exact comparison passed 4% of a real
470-note vault, which is the same as not shipping. Bisecting the failures found
six differences no reader can see — a lone trailing space, blank lines that
only separate blocks,
***vs---, table padding,_em_vs*em*, and runsof spaces inside a line. Tolerating exactly those took it to 63%. The
remaining 176 failures span 164 distinct causes: a real long tail, mostly
callouts, footnotes and raw HTML, which the guard should keep catching.
The trade this encodes, stated plainly: saving a note may rewrite those
cosmetic differences into canonical form. Nothing rendered changes, but a
git-tracked vault will show the diff on first save.
Two exceptions are load-bearing and pinned by tests: a blank line between list
items makes a list loose, and a blank line between blockquotes is the only
thing keeping them from merging. Both survive the editor intact, so collapsing
them as "block separation" would have hidden a real change.
Security
The vault commands are the security-sensitive surface, so:
VaultStateon theRust side. Onyx's commands took
(path, vault_path)— letting a caller supplyboth sides of the containment check — and five of them skipped validation
entirely.
canonicalize(). A folder deliberatelysymlinked into a vault is an intentional grant and keeps working;
..escapesare still rejected. There is a ported regression test for this, and
ValidatedVaultPathmakes "use the returned path, not the string you passedin" a compile error rather than a review note.
over IPC as JSON, and gets parsed twice frontend-side; one stray export ending
in
.mdwould otherwise freeze the app.empirically, then pinned — it depends on
remove_dir_allrefusing to descendthrough a symlink (the CVE-2022-21658 fix).
editorSecurity.test.mjsfeeds script tags, event handlers,javascript:/data:/vbscript:links and image-URL escapes through the real editor andasserts against the parsed DOM that none materialise. It also asserts an
ordinary link still works, so it cannot pass by rendering nothing.
One accepted residual, called out for review:
set_active_vaulttakes arenderer-supplied path, so a compromised renderer could point the vault
somewhere sensitive. Given
terminal_attach+terminal_inputalready grantarbitrary command execution to the renderer, this is not an escalation — but the
defence-in-depth fix (persist the granted path backend-side, accept only a
dialog-picked or previously-granted one) is worth considering separately.
In scope
Tree, tabs with dirty state, live preview, raw source mode, 2s debounced
autosave, ⌘S, filesystem watcher, create/rename/delete, wikilinks with
click-through, backlinks (linked and unlinked), heading outline with scroll-spy,
Obsidian syntax decorations, tables, session restore.
Out of scope for v1: frontmatter UI, daily notes, templates, vault search,
quick-switcher, graph view,
![[embeds]], and any Nostr sync.Wikilinks, tags, highlights and task markers are decoration-only — they stay
plain text in the document so they serialize back byte-identically. That is why
they render with their
[[brackets]]visible.Notable fixes found by dogfooding
write_vault_filereplaces the file with a rename; the watcher fires on thatrename and its event routinely beats the command's own response back to the
webview, so the mtime echo-suppression needed had not been recorded yet.
Comparing mtimes cannot win a race against the thing that produces the mtime,
so reconciliation now compares bytes, recorded synchronously before the
write starts. This also fixes byte-identical external rewrites (what sync
clients and formatters do constantly) raising a spurious conflict.
getBacklinksscanned every note on everyswitch, compiling a fresh
RegExpper line across ~75k lines: 103ms → 22ms.The bigger cost was re-parsing markdown on every tab switch; handing
setContentthe parsed document from last time is 30–47× faster(249ms → 8ms on a 110KB note). The per-file editor remount is kept — it is
what stops undo resurrecting another note's text, and creating the editor is
only ~10ms.
Testing
pnpm test(desktop unit)cargo test --lib(Tauri)cargo clippy -D warnings,pnpm check,tscOne new Rust dependency:
notify(MIT OR Apache-2.0).lib.rswas at 991 lines on main and this branch's vault registration pushed itover the ratchet, so the
pending_syncflush loop moved intopersona_events::spawn_flush_loop— next to the function it drives — ratherthan raising the limit.
The five smoke failures
Each was triaged to a cause rather than waved through. None is a regression
from this branch:
inbox-edit.spec.ts:175origin/maintoo — verified in a detached worktreethread-focus-mode.spec.ts:139relay-reconnect.spec.ts:158community-rail.spec.ts:509community-rail.spec.ts:1078