Skip to content

Release 2.74.0: pages for every record, next and previous tab, date links that create daily notes - #978

Merged
adibhanna merged 20 commits into
mainfrom
v2.74.0
Oct 22, 2026
Merged

adibhanna merged 20 commits into
mainfrom
v2.74.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

ZenNotes 2.74.0: desktop, released with zn 0.10.0 (zn base pages, already out), server 2.74.0 with Docker, the boundary packages, the website release page, and both phones on core 2.74.0.

Features

  • Create pages for all records (ThatDwayne, Discord; de82278e, ad5e52d9): planRecordPages in shared-domain splits a database into missing, adopt, existing and untitled; createAllRecordPages confirms, writes the pages, links page files already in the folder under a record's name instead of copying them, and updates the schema once. Reached from the database's new ⋯ menu, the palette (database.create-record-pages) and O in the grid with Vim on. Opening one record adopts an existing page the same way.
  • Next and previous tab with Vim on or off ([Feature] Next / previous tab actions that work with Vim mode off #973, d06cf66d): tabs.next / tabs.previous, Ctrl+PageDown / Ctrl+PageUp, Cmd+Option+Right / Left on macOS, palette entries, the same order as gt / gT across panes.
  • A link to a missing [[2026-11-03]] creates that day's daily note (Dave, Discord; ee2fd2a0): dateNoteForTitle matches the current daily, weekly or monthly pattern to exactly one date; a confirm, then the template.

Fixes

Also

  • Bundled zn 0.10.0, verified against zn-release-1 (fe1bec33); What's New 2.74.0 (47a1bba7); version bump (37b314e0); @modelcontextprotocol/sdk 1.32.0 for the advisories that failed npm audit at high (9e2c651c, only that package moves in the lockfile).

Gates on 9e2c651c, in a clean checkout with its own npm ci: typecheck 8/8 uncached; shared-domain 2,054, app-core 3,394, desktop 1,190 and quicklook 15 uncached; fresh build:prod green; npm run pack and the packaged launch check pass (page target, rendered, 1.6 s, 2.74.0); vim-editor, sidebar-vim and editor-improvements pass on the first run. ci-sweep.py v2.73.0: no failed jobs. hazard-scan.py --since v2.73.0: no hazards. dash-lint clean on the notes, the diff and every commit. Record pages, next tab, the date link and the calendar hover were driven in the built app over CDP; the release screenshots come from that build.

Follow-up to 10597fe. Eight more live-preview and editor plugins read
the syntax tree but rebuilt only on an edit, a scroll and, for some, a
selection change: the code block font and language label, hashtags, live
template tokens, heading arrows, highlights, list indent and wikilinks.
CodeMirror parses under a time budget and finishes a cut parse in a later
update that changes nothing else, so whatever they drew from the partial
tree stayed until the next edit, scroll or cursor move. In the built app,
opening a 6 MB note and pressing G at once drew the last screen before
the parse reached it, and it stayed that way after the parse finished
about 0.9 s later: code in the body font with no language label, list
lines with no hanging indent, heading arrows on `# comment` lines in a
fence, and hashtag pills, live tokens, highlights and wikilinks inside
code.

Each plugin now also rebuilds when syntaxTree(update.startState) differs
from syntaxTree(update.state), as cm-live-preview and cm-wysiwyg-blocks
do. Each gets a test that changes only the tree (the markdown language
swapped in through a compartment) and expects its decorations: all eight
fail without this change and pass with it.

Cost: the seven rebuilds bound to the viewport take under 0.5 ms each.
The heading arrows' rebuild scans to the end of each visible heading's
section, about 37 ms at the top of a 3 MB note, which every cursor move
and keystroke there already pays; the guard adds one more per finished
cut parse (the first parse-only update after opening that note went from
about 73 ms to 112 ms). perf:desktop-runtime on 5,000 notes is unchanged:
note open 34.8 vs 36.6 ms, switch p50 24.4 vs 24.5 ms.

Not in this change: calloutFolding() draws nothing; it folds `> [!type]-`
callouts once per opened note from ensureSyntaxTree with a 200 ms budget,
so a collapsed callout past what that parses (about 2 MB on an M-series
Mac) opens expanded. Folding again on a tree change would close callouts
the reader opened, so it needs a fix of its own.

app-core 3,337 tests, typecheck clean.
The heading arrows rebuild on every cursor move, keystroke and finished
cut parse, and for each visible heading they found its whole section (a
line-by-line scan to the next heading of the same or a higher level, with
a syntax tree lookup for every `#` line) just to learn two things: whether
the heading folds at all and whether its fold is closed. For the H1 at the
top of a long note that scan is the whole note.

Whether a heading folds only depends on the next line: its section is
empty only on the last line or right above a heading of the same or a
higher level. Whether its fold is closed can only be true when a fold
starts at the end of its line, so the section end is now found in that
case alone. The decorations are the same: new tests pin the arrows, level
labels and folded states for headings with and without a body, every
level, a heading right above the same or a higher level, the last line,
`#` lines in code, folds that are not exactly a section and a trailing
newline, and they pass on the old code too. In the built app the plugin's
decorations matched in every compared state, before and after.

Cost of the plugin's update at the top of a 3 MB note (13,000 sections,
13,000 `# comment` lines in bash fences), built app over CDP, two runs per
build, medians:
- cursor move: 44.1 ms -> 0.0 ms (126 moves each)
- keystroke: 46.4 ms -> 0.0 ms (30 each)
- finished cut parse after opening: 43.1 ms -> 0.1 ms (6 each)
- with the top H1 folded, a move still finds that section: 49.7 vs 50.0 ms
- the whole cursor move (view.update), one more run each: 119.7 ms ->
  79.0 ms; most of what is left is computing the new state (58 to 65 ms)

app-core 3,341 tests, typecheck clean.
A `> [!type]-` callout folds by default once per opened note. The plugin
did that in one pass when the note opened, from
ensureSyntaxTree(state, doc.length, 200) or else the tree as far as it
was parsed, and then marked the note done. CodeMirror parses a long note
only near the viewport (and 100 KB ahead of it), so a collapsed callout
past what 200 ms could parse never folded. On an M-series Mac a
collapsed callout at the end of a note folded at 0.5 and 1 MB and opened
expanded at 2, 3 and 5 MB; slower machines hit it on smaller notes.

The note now keeps where the callouts still wait to fold, mapped through
edits, and each time the tree grows it folds the collapsed callouts the
parser has produced since. A callout counts only once the parser has read
past it: the tree holds a node that starts after it, or nothing but blank
lines follows it. A parse that stops early closes whatever block is still
open where it stopped, so until then a long callout looks as if it ended
at its first paragraph. The tree's length cannot answer this, because
the note grammar's tree always spans the whole note
(cm-markdown-language.ts); a first version that read it passed with plain
markdown and failed in the app. A note swap (a whole-document replace
under noteEditingSync) starts over, as before, and the state still
outlives the live-preview reconfigures, so a theme switch or a later
parse does not close again what the reader opened.

Two rules for folds that now land after the note opened:
- A collapsed callout the reader typed or edited before the parser got
  there stays open. Typing a marker has never folded a callout (#853);
  typing a whole new `> [!note]-` in a part of a long note the parser has
  not reached is the same case and gets the same answer.
- A default fold never closes over the caret. CodeMirror already opens a
  fold the caret moves into; a fold that arrives after a jump (G, a
  search hit, a reopened tab's remembered caret) must not hide the line
  the reader is on.

Tests, on the note grammar and on plain markdown: a tree that reaches the
callout only in a later update (the language swapped in through a
compartment), a parse that stops inside a callout, collapsed callouts
that open and end the note, a callout typed before the parser got there,
and the caret. The new tests fail on the old code.

In the built app, a 3 MB note with a collapsed callout at the end, opened
and then jumped to its end: before, the callout became an open card about
0.55 s after the jump and stayed open; after, it went from raw text to
folded 0.59 to 0.73 s after the jump (three runs), stayed open once clicked
open through an edit and a reparse, a collapsed callout typed at the end
before the parse got there stayed open, and one holding the caret stayed
open. Long tasks after the jump: 118/152/199 ms before, 117/152/207 and
120/164/202 and 86/100/100/202 after.

app-core 3,345 tests on c950259, typecheck clean.
…tor (#970)

With Vim on, a click below the last line of a short note left the caret
showing but took every key away: a, A, i and I did nothing, and the
editor looked frozen until a click on the text itself.

The editor's scroller pads 20vh under the text and centers a capped text
column, so the space below a short note, the 16 px above it and the space
beside the column belong to `.cm-scroller`, not to the content.
CodeMirror listens for mousedown on the content only, so it never saw
those clicks, and the browser gave their focus to the scroller, which
CodeMirror makes focusable (tabindex -1) and where no key handler runs.

scrollerClickFocus (lib/cm-scroller-click-focus.ts) takes a left press
whose target is the scroller itself, prevents the default so the
scroller never takes the focus, puts the caret where CodeMirror's
posAtCoords puts it for that point (the document's end below the text,
its start above, the nearest column of that row beside it; Shift
extends the selection) and focuses the text. Vim keeps its mode, as it
does for a click on the text. Presses on the scrollbars, other buttons
and whatever the scroller holds (gutters, the content and its widgets)
are left alone. Every editor that shares the scroller's padding gets it:
note panes, the floating note window, the pinned reference pane, Quick
capture, the standalone file window and the template editor.

It does not start a drag selection from the padding; a press there only
places the caret.

In the built app over CDP, Vim on, a real mouse click at four spots
under a five-line note, each followed by A and typing: before, the three
outside the content (the padding at two heights and the left margin)
left the focus on `.cm-scroller` and the typing never arrived; after,
all four kept the focus in `.cm-content` and the typing landed on the
last line, in Normal mode with the block cursor. Vim off types there
too.

On Windows 11 (a VM), the shipped 2.73.0 zip against the same zip with
this branch's renderer, Vim on, a real click in the padding and then A
and a letter typed on the VM's keyboard: before, the focus stayed on
`.cm-scroller` and nothing was typed; after, the focus was in
`.cm-content` and the letter landed on the last line.

Tests: a click in the padding places the caret and focuses the text,
Shift-click extends, a press on a child, on either scrollbar or with
another button is ignored, and Vim stays in Normal mode. The three that
expect the new behavior fail without this change.

app-core 3,357 tests, typecheck clean.
The editor's context menu lists Redo as Mod+Shift+Z, which reads
Ctrl+Shift+Z on Windows, but there only Ctrl+Y redid. CodeMirror's
historyKeymap binds Mod-y everywhere but macOS (where redo is
Cmd+Shift+Z) and adds Ctrl-Shift-z on Linux only. Windows apps take both,
and Ctrl+Shift+Z is the one the reporter reached for.

appHistoryKeymap (lib/history-keymap.ts) is CodeMirror's keymap plus
Ctrl-Shift-z on Windows, and every editor that used historyKeymap now
uses it: note panes, the floating note window, the pinned reference
pane, Quick capture, the standalone file window and the template editor.
Ctrl+Y keeps redoing, and the macOS and Linux bindings are unchanged.
The menu keeps its hint, which is now true on every platform.

On Windows 11 (a VM), keys pressed on the VM's keyboard, against the
shipped 2.73.0 zip and the same zip with this branch's renderer (this
branch's main bundle is byte-identical to the shipped one): with Vim
off, after an undo, Ctrl+Shift+Z did nothing before and redid after,
and Ctrl+Y redid in both; with Vim on, after u, Ctrl+Shift+Z did nothing
before and redid after. The context menu there shows Redo as
Ctrl+Shift+Z.

Tests, on CodeMirror's Windows platform: its own keymap leaves
Ctrl+Shift+Z unbound (which also proves the platform took), and
appHistoryKeymap redoes on Ctrl+Shift+Z and on Ctrl+Y. The Ctrl+Shift+Z
test fails without this change.

app-core 3,357 tests, typecheck clean.
…976)

With two displays, the Quick capture hotkey always opened the panel on
the display it was first shown on, even with the pointer and the focused
window on the other one. The panel is created once and hidden between
captures, and showQuickCaptureWindow() only called show() and focus(), so
it kept the bounds of its first show: centered on the main display. As a
macOS panel floating above other windows, tiling window managers
(AeroSpace, yabai) leave it alone too, so nothing outside the app could
move it either. Switching Spaces on the same display already worked.

A hidden panel now opens on the display under the pointer, the way
Spotlight-style launchers do. On the display it is already on, it stays
exactly where the user left it. On another display it takes the same
place there (quickCaptureBoundsOnDisplay in quick-capture-placement.ts):
its center keeps its relative position in the work area, so a centered
panel opens centered and one dragged to a corner opens in that corner,
shrunk to fit a smaller work area and kept inside it. Main remembers
where it last sat on each display it left, so coming back restores that
exact spot instead of mapping again, which on a real Mac drifted a pixel
per round trip.

It does not move a panel that is already on screen: the hotkey only
focuses it, as before, so a pinned panel stays where it was put. Under
native Wayland the compositor places windows and ignores the move.

On a Mac with a Studio Display and the built-in display, with the panel
opened through the same toggle the hotkey runs and the real pointer moved
between them: 2.73.0 opened it at the same spot on the Studio Display
every time, with the pointer and the main window on the built-in one;
this build opened it on the pointer's display in all five shows
alternating between the two, at exactly the same spot on each return.

Tests: the same display stays put, centered and corner placements carry
over, a smaller work area shrinks and contains it, a panel hanging off
its display lands fully on the new one, the remembered spot comes back
exactly and gives way when it no longer fits. The remembered-spot test
fails without the memory.

desktop main 1,002 tests, typecheck clean.
…ly note

Following a wikilink to a note that does not exist yet creates it from
the link text, in the Inbox root. For a link named like a daily note
that was the wrong note: with daily notes on, the day's note lives where
the Periodic notes folder and title pattern put it (`Daily Notes/` by
default), and the calendar counts a note only at that exact path
(classifyDateNote). So `[[2026-11-03]]` made `inbox/2026-11-03.md`, which
never showed on the calendar, skipped the daily template, and became a
second note of the same name once the day was created from the calendar.
A Discord user planning tasks on future days ran into it: the only way
to get a future day's note seemed to be typing its date into a title.

dateNoteForTitle (vault-layout.ts) reads a title against the current
daily, weekly and monthly title patterns: it names the periodic note
when exactly one date produces that title, for a kind that is on. The
link paths in create-note-from-link.ts check it first. Cmd/Ctrl-click
and `gD` create the note at once through openDailyNoteForDate (or the
weekly or monthly twin), the same call the calendar makes, so it lands
in its folder with its template; a plain click, gd and the reading view
ask the calendar's question ("2026-11-04 does not exist yet. Create it
as your daily note?") instead of the path prompt. `[[2026-W45]]` and
`[[2026-11]]` work the same way for weekly and monthly notes.

Not in this change: a link that names a folder (`journal/2026-11-03`)
says where the note goes, so it keeps the plain path. Only the current
patterns count, since a new note always takes the current one, and a
title the pattern cannot pin to one date (the year in the directory,
say) names none. The website docs mirror the manual in a separate
change.

In the built app, a vault with daily notes on in `Daily Notes`, a note
linking [[2026-11-03]] and [[2026-11-04]]: before, a Cmd-click created
inbox/2026-11-03.md and a plain click offered the path prompt for
/2026-11-04.md; after, the Cmd-click created
inbox/Daily Notes/2026-11-03.md, the plain click asked "New daily note"
and created inbox/Daily Notes/2026-11-04.md, the calendar panel offered
"Open daily note" for both days (and still "Create" for the 5th), and
both links drew as live.

The manual says so under Links are actionable, gD and Settings → Daily
notes, which now also names the calendar's double-click for another day.

Tests: daily, weekly and monthly titles, a custom pattern and only its
exact output, a kind that is off, an impossible day, unrelated titles, a
title the pattern cannot pin; the link paths create the daily or weekly
note, ask first on the confirming path, create nothing when declined,
and keep the plain path with daily notes off or a folder in the link.
The three link tests that expect the new note fail without this change.

app-core 3,367 tests, typecheck clean.
Stepping through open tabs from the keyboard was Vim's alone: `gt` and
`gT` are Vim bindings, and the command palette had Close, Reopen, the
buffer switcher and Back/Forward but no Next or Previous Tab. With Vim
mode off, the only tab keys were Alt+1-9 (Ctrl+1-9 on macOS), which jump
to a position rather than to the neighbouring tab and stop at nine.

Two app-level actions, Next tab (`tabs.next`) and Previous tab
(`tabs.previous`), work with Vim on or off and from any panel. They walk
the order `gt`/`gT` and the digit shortcuts use, every pane's tabs in
pane order (openTabOrder in buffer-navigation.ts), and wrap at either end.
getTabStepTarget is open tabs only: with one tab open it goes nowhere,
where `gt` falls back to a recent note or a new quick note the way Vim's
buffer commands do. From a pane showing no tab, next starts at the first
tab and previous at the last. The palette gains Next Tab and Previous
Tab under Tabs, with their keys.

The defaults leave Ctrl+Tab to Switch to previous note, which it has
meant on every platform, and take the other common conventions:
Ctrl+PageDown / Ctrl+PageUp (browsers, VS Code) on Windows and Linux, and
Cmd+Option+Right / Left on macOS (Chrome, VS Code), where Ctrl+PageDown
needs fn. Plain PageDown, Cmd+Right and Option+Right keep paging and
moving the caret. Anyone who wants Ctrl+Tab for tabs can rebind it under
Settings, Keymaps, and the manual says so. Like Alt+1-9, the shortcuts sit
mid-chain in the app handler, so an explicit rebind of the same keys to a
later action wins, and they stay quiet while an overlay is open.

Found while testing in the app: with Vim on and the sidebar focused,
VimNav's panel handlers take every ArrowLeft and ArrowRight whatever the
modifiers, so Cmd+Option+Right opened the sidebar row under the cursor.
VimNav now leaves the two tab chords to the app handler before it routes
keys to a panel.

The manual lists the keys next to Alt+1-9, and the `gt / gT` entry now
says it walks the open tabs across panes (it said the active pane) and
names the keys for Vim mode off.

In the built app on macOS, four notes open as tabs, Cmd+Option+Right and
Left pressed over CDP: from the editor, Delta stepped to Alpha (wrapping),
Bravo, back to Alpha, Delta (wrapping) and Charlie, with no text typed
into the note; from the sidebar and from the palette's Next Tab, the same.
Vim on and Vim off alike, after the VimNav guard (before it, the sidebar
case did nothing with Vim on). The Windows and Linux keys are covered by
the unit tests, not run in an app.

Tests: the step order against gt's across panes, wrapping both ways, no
fallback with one tab, a pane showing no tab; the defaults per platform,
the chords firing, plain paging and word motions untouched, no clash
with any default, and Ctrl+Tab still Switch to previous note. The
catalog mirror test covers the config.toml list.

app-core 3,374 tests, shared-domain 2,048, desktop 1,190, typecheck clean.
Hovering a day in the calendar panel showed the note's index excerpt in
a 260 px card: one run of text with every line joined, cut off after four
lines, with `- [ ]` and the heading words in the middle of it. A Discord
user plans each day in its daily note (Focus, Schedule, Notes, Tasks) and
wanted to read a day at a glance without opening it; the card turned
that plan into one paragraph. The excerpt is the index's summary and
feeds other views and the Go server, so it stays as it is.

The calendar now shows the same preview a link gets (NoteHoverPreview):
the note's opening rendered as the note, headings, lists and task boxes
on their own lines, under the note's title and path. It opens beside the
panel, level with the hovered day, so it never covers the days and weeks
the pointer moves to next; the old card sat over the left of the grid.
Weekly notes in the week column get the same preview.

NoteHoverPreview said "Loading preview…" forever on a note with an empty
body, which the calendar meets often (a day created ahead of time and
left blank). It now tells loading from empty and says "Empty note", for
link previews and the Connections panel too.

Not in this change: plain lines with no blank line between them still
join into one line, as in standard Markdown and the reading view, so the
preview matches Preview mode rather than the editor. Lines kept apart as
a list read one per line. The preview is for the pointer; the calendar's
keyboard selection does not open it.

The manual says so under Settings, Daily notes.

In the built app, a vault with daily notes on and a note shaped like the
report: before, hovering the 28th showed the excerpt card (one paragraph,
no headings, lists or boxes); after, the preview drew 6 headings, 3 list
items and the task box, 380 px wide, to the left of the panel with the
whole grid and week list in view; a day whose note is only its title
showed the title, and a day with no note showed nothing.

Tests: the preview draws a day's plan with its headings, list items and
task box, says an empty note is empty, and says it is loading while the
note is read. The empty-note test fails without this change.

app-core 3,377 tests, typecheck clean.
… out of time

While checking #973, the full app-core run failed about one time in four,
once in cm-heading-fold ("folds the list item on the cursor line") and once
in cm-mermaid-render ("rebuilds when the theme identity changes"), never
when either file ran alone. Both tests assumed a parse finishes inside
CodeMirror's 20 ms slice, and on a cold, loaded worker it sometimes does not.

The fold tests: the "list item folding (#848)" and "heading arrows" mounts
never forced the parse. A cut first parse keeps only the blocks it reached
(markdown runs inside parseMixed, which reports no progress until its base
parse ends), so foldAtCursor found no list, and the `#` lines in the fence
and the indented block read as headings. Both mounts now call forceParsing,
as the other mounts in the repo do. The first describe's mounts stay as
they are: their notes hold no code, and headings are matched by their text.

The diagram test: not the first parse. A cut there is harmless, since the
field rebuilds when the parse finishes. The mount's two nudge edits (insert
a space, delete it) were the exposure: a note under 128 characters parses
from scratch on every edit, under the same 20 ms slice, and a cut parse
drops the fence from the tree. The field then forgets the block, and the
fence returns as a new widget that renders a second time (the "called 2
times" of the failed run), or, when the second nudge is cut, not at all.
The nudges were never needed, because the field has rebuilt on tree changes
since #530, so the mount now ends at forceParsing. The theme test checks
the renderer's calls exactly, right after each step: one render of the
diagram after the mount, one more after the theme change. Reconfiguring
with the same theme key fails it.

Proof: a setup file that lets each 20 ms slice take a chosen number of
parser steps. On the old files, a cut first parse failed 8 to 9 fold tests,
and a cut on the first nudge failed the theme test with the message of the
failed run. The new files pass at every cut point tried. The full app-core
suite passed 16 runs in a row, typecheck clean.

Not covered here: "keeps the last good drawing" still fails when its own
edit's parse is cut, because the cut drops the block's memory in
cm-mermaid-render.ts itself, and six other test files nudge after
forceParsing the same way. Both are being looked at separately.
…otes

A Discord user renamed Focus in the built-in Daily Note template and
left the rest alone. From then on every daily note came out as its title
and nothing else, today's and the ones created ahead from the calendar,
until they reset the template.

Editing a built-in saves an edited copy, a custom template under its own
id that carries the built-in's id as `builtinId`, and mergeTemplates
drops the built-in it replaces. The daily, weekly and monthly note
creators look their template up by the id Periodic notes saved, which is
the built-in's (`builtin.daily`): with the built-in gone from the merged
list and the copy under another id, the lookup found nothing and the note
was created blank. Settings had the same gap and showed the daily note
template as "[Missing template]". The zn TUI already matched both ids.

findTemplate (shared-domain template-files.ts) looks a template up by its
id and then by the built-in id an edited copy stands in for. The store's
periodic note creators, the Settings template picker and workflow
`apply-template` steps go through it. Picking an edited copy in the
picker now saves the built-in's id (templateSettingId), so resetting the
copy falls back to the built-in instead of leaving the setting on a file
that is gone; picking any other template saves its own id as before.

In the built app, a vault with daily notes on, Periodic notes set to the
Daily Note template and an edited copy with Focus renamed to Today:
before, today's note and the 26th double-clicked in the calendar were
each just "# <title>", and Settings read "[Missing template]"; after,
both had the edited body (## Today and their own date heading) and
Settings read "Personal · Daily Note".

Tests: the built-in's id reaches its edited copy once merging dropped the
built-in, own ids and untouched built-ins still resolve, a template with
the id itself wins over a copy pointing at it, missing ids find nothing,
and an edited copy is stored by the built-in's id so a reset falls back;
a workflow step finds an edited built-in by the built-in's id.

app-core 3,383 tests, shared-domain 2,048, typecheck clean.
Six test files mounted the editor, forced the parse, then "nudged" the note
(insert a space at one end, delete it) so the plugin under test would rebuild.
A note under 128 characters parses from scratch on every edit, under
CodeMirror's 20 ms slice, so on a cold, loaded worker a nudge could end on a
cut tree that stops before the node under test. Same cause as the mermaid
mount in 5d1de89.

Where the plugin or field rebuilds when the syntax tree changes, the nudge
was never needed (forceParsing dispatches when the tree changed), so those
mounts now end at forceParsing: cm-math-render, cm-math-nav,
cm-wikilink-render, cm-table and table-to-database. cm-task-metadata's
plugin redraws only on edits and viewport changes, so its mount finishes the
parse first and then adds the plugin with StateEffect.appendConfig, which
keeps the parsed tree. cm-table's "re-resizing replaces the marker" also
finishes the parse between its two drags: a cut parse of the first drag's
marker edit dropped the table, and the second drag found no handle.

Proof: a setup file that lets each 20 ms slice take a chosen number of parser
steps and cuts only the parses dispatched from the mount helpers. On the old
files, cutting the nudges failed 41 of 116 tests (cm-math-render 2, cm-table
37, table-to-database 2). cm-wikilink-render, cm-task-metadata and
cm-math-nav were immune only because their code sits in the note's first
block, which the first parser step always covers; probe notes with the code
further down failed the old mounts and passed the new ones. The new files
pass with every parse cut, at every cut point tried. The full app-core suite
passed 6 runs in a row, typecheck clean.
One more plugin of the kind c950259 fixed. cm-task-metadata reads the
syntax tree to skip task-looking lines inside code, but rebuilt only on an
edit or a scroll. CodeMirror parses under a time budget and finishes a cut
parse in a later update that changes nothing else, so a task line inside a
code fence that the first parse had not reached kept its priority, due date
and @field chips until the next edit or scroll.

The plugin now also rebuilds when syntaxTree(update.startState) differs from
syntaxTree(update.state), as the others do. Its test changes only the tree
(the markdown language swapped in through a compartment) and fails without
this change. With the plugin redrawing on tree changes, the test mount no
longer adds the plugin after the parse; it ends at forceParsing like the
other mounts.
A mermaid or d2 block edited into an invalid source keeps showing its last
good drawing instead of an error, from a memory that follows the block
through edits. The field rebuilt that list of memories from the fences in
the current syntax tree only, so one update whose parse ran out of time
before a block dropped the block's memory. When the parse caught up, the
block came back as a new one and showed the error. In the test suite this
was "keeps the last good drawing for a block while its edited source is
invalid" failing when its own edit's parse was cut.

While the tree is incomplete, the memories of fences past where the parse
stopped are now kept until a tree reaches them again; only a complete tree
can say a block is gone. Where it stopped is the end of the tree's last
block: the note language's mixed parser pads a cut tree to the note's full
length, so tree.length cannot tell.

Tests: a parse cut on purpose (the clock stalled for one edit, so the 20 ms
slice stops after its first step) keeps the drawing in plain markdown and in
the note language; both fail without this change, and a version that reads
tree.length fails the note language case. A deleted fence is still
forgotten once a complete parse no longer has it, so a new invalid fence
typed in its place shows the error; a version that always kept memories
fails that test.

Not changed: fences past a cut still show as source until the parse catches
up, as every decoration that reads the tree does today.
A database record is a note links can reach only once it has a page,
and a page was made only by opening its record, one at a time. A
Discord user converted CSVs to databases with zn and found none of
their records could be linked until each was opened, with hundreds of
records to go.

"Create pages for all records" gives every record that has no page its
page note in the database's folder, the page opening it would make: the
row's properties as frontmatter and its title as the heading.
planRecordPages (shared-domain database-records.ts) decides which: a
record whose page is mapped and still there keeps it, one whose page was
deleted gets a new one, as opening it would, and one with an empty title
is left out, since its page would only be "Untitled" and nothing could
link to it by name. The note list says whether a mapped page still
exists, so planning reads no files. The store action asks first, with
the counts, writes the pages, then updates the schema once on the latest
doc, so a row edited or deleted meanwhile keeps that edit; when a page
fails partway it keeps the pages already written and says how far it
got. A loose CSV, which cannot hold pages, is told so.

It runs from a new ⋯ menu in the database header, from the command
palette while a database is open (Create Pages for All Records), and as
`O` in the grid, the capital of `o` (open this record's page). `O` is a
new single-key shortcut, so it only fires in Vim mode.

`zn base pages` does the same from the terminal, with the same rules, in
the zn repo. The manual names it, the grid key and the menu.

Not in this change: the grid's existing single keys (j, k, o, x, dd and
the rest) still fire with Vim mode off.

In the built app, a vault with Books.base holding only its data.csv
(three titled records and a blank one) and a note linking [[Kindred]] and
[[Emma]]: before, both links drew as broken; the ⋯ menu asked "Create
pages for 3 records?", left the blank record out, created Dune.md,
Kindred.md and Emma.md with their frontmatter and heading, mapped them in
schema.json, and both links drew as live; `O` then reported that every
titled record has a page; the palette listed the action; with Vim mode
off, `O` did nothing and wrote nothing. A vault whose pages `zn base
pages` wrote opened with its links live and every page counted.

Tests: the planner (pages kept, deleted pages made again, blank titles
and a database with no title field left out) and the store action (the
pages and the schema, declining, a failure partway, nothing to do).

app-core 3,391 tests, shared-domain 2,051, typecheck clean.
…d, not copied

The Discord user whose CSV databases had no linkable records worked
around it before "Create pages for all records" existed: a script wrote
a page file for each record, named after it, into the database folder,
but no schema.json entry maps a row to it. "Create pages for all
records" took those records for pageless and wrote `Dune 2.md` and
`Kindred 2.md` beside the files, pointed the rows at the copies, and
left two notes per title for `[[Dune]]` to choose between. Opening one
record did the same, as it always had for an unmapped file.

A record with no mapped page is now linked to a page file already there
under its name: in the database folder or its `pages/` subfolder, under
each name a page gets (the app's note-title rules for a local page, the
slash-only rule of the server path and zn, and the title as it is,
which a script most likely used). recordPageToAdopt (shared-domain
database-records.ts) picks the file, the first candidate that exists and
no row maps, and planRecordPages and openRecordPage both go through it,
so a file another row maps, or an earlier record with the same title
took, is never taken twice. A linked file is left as it is by the bulk
run; opening it re-mirrors the row's properties into its frontmatter and
keeps the body, as for any page. The confirm and the toast count the
links ("Created 1 record page and linked 2 existing pages."). zn mirrors
the candidates name for name.

In the built app, Books.base with its data.csv, a script-made Dune.md
and Kindred.md, and no schema: before, the run wrote Dune 2.md and
Kindred 2.md and mapped the rows to them; after, it asked "Create pages
for 1 record?" noting the 2 files it would link, created only Emma.md,
mapped Dune.md and Kindred.md, left their "My notes on" bodies as they
were, and every link drew as live.

Tests: files in the folder and in pages/ linked, one mapped by another
row left alone, twins sharing no file, the candidate names; the bulk run
linking without writing, and opening one record linking its file.

app-core 3,394 tests, shared-domain 2,054, typecheck clean.
What's New opens on the first launch after the update with three highlights. First, Create pages for all records, which gives every database record its page at once so each can be linked, and links a page file already in the folder instead of copying it. Second, next and previous tab with Vim on or off (#973). Third, planning days ahead: a link to a day with no note becomes that day's daily note, and hovering a day in the calendar shows its note.
The bundled CLI is the floor a desktop build guarantees, and 0.10.0 is the
newest release. It adds `zn base pages` (ZenNotes/tui#15), the terminal
side of this release's Create pages for all records: every database
record without a page gets one, and a page file already in the folder is
linked, not copied. It also carries 0.9.2: a second `$` on a wrapped line
reaches the end of the line, and three backticks make a code fence. The
pin is the release's terminal-release.json copied byte for byte after it
verified against packaging/release-signing/zn-release-1.pub in ZenNotes/tui
(source commit ced79267, integration protocol 1, all four archives it names
matching checksums.txt). Staged through `npm run terminal:stage`; the native
darwin-arm64 binary reports zn v0.10.0. The in-app manual's CLI card says
0.10.0 and what 0.9.2 and 0.10.0 change.
…pm audit

The production dependency audit (npm audit --omit=dev --audit-level=high)
fails on @modelcontextprotocol/sdk 1.31.0, the SDK behind the desktop's
bundled MCP server, with four advisories published since 2.73.0, all fixed
in 1.32.0: GHSA-22jm-h49p-29qw (experimental tasks not tied to the session
that created them, high), GHSA-46jr-4wx8-v453 (one request within the
default size limit could exhaust server memory during validation, high),
GHSA-6prh-2h8m-c8cw (client transports followed cross-origin redirects with
custom headers, moderate) and GHSA-rvq5-wwqv-78pq (server bearer auth
accepted tokens issued for another service, moderate). The package moves
to its first fixed version and nothing else in the lockfile changes; the
audit passes at its level. The remaining advisories are moderate or low and
do not fail it.
@adibhanna
adibhanna merged commit 9e2c651 into main Oct 22, 2026
10 checks passed
@adibhanna
adibhanna deleted the v2.74.0 branch October 22, 2026 17:45
@adibhanna
adibhanna deployed to boundary-artifacts October 22, 2026 18:07 — with GitHub Actions Active
@adibhanna
adibhanna deployed to boundary-artifacts October 22, 2026 18:07 — with GitHub Actions Active

This branch was successfully deployed

1 active deployment
boundary-artifacts — 9e2c651c Deployed Oct 22, 2026 by adibhanna via draft #67
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant