Skip to content

ZenNotes 2.57.0 - #864

Merged
adibhanna merged 20 commits into
mainfrom
v2.57.0
Sep 28, 2026
Merged

adibhanna merged 20 commits into
mainfrom
v2.57.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

ZenNotes 2.57.0, cut from branch v2.57.0. main fast-forwards to this branch once the checks pass; the tag v2.57.0 goes on the release commit.

Features

  • List items and tasks fold like headings: an arrow beside any bullet, numbered item or task with indented lines under it, in Edit and Preview. zc / zo, Ctrl+Alt+F / U (Cmd+Option+F / U on macOS) and Fold All work on list items too, zM then zo on a heading shows one line per task, and the Tasks view is unchanged (Fold nested list items and tasks, like headings #848, asked for by @Sullti).
  • Callouts fold the Obsidian way: > [!type]- starts collapsed and > [!type]+ expanded. The title row toggles it in Preview and a chevron in Edit mode, the fold keys and zM work on it, and a PDF export prints it open (Support foldable callouts (> [!note]- and > [!note]+) #853, asked for by @Sullti).

Fixes

Maintenance

  • [Feature Request] rename vault display name #692 follow-ups for the phone shells: VaultInfo.folderName for hosts whose root is a label, carried through the shell snapshot (no desktop change).
  • Test and tooling hardening from the 2.56.0 gate: note-integrity tests settle their dirty buffers, and the Vim editor smoke puts focus back before each check block.

Docs

Release notes, demo clips and the verification log live in docs/releases/v2.57.0/ (local).

…re the next one starts

The 2.56.0 gate caught one test in this file leaking into another: typing
arms the store's 350 ms debounced save, the test ended with the buffer
dirty, and the timer fired while a later test was collecting writes into
the file's shared writeCalls log. `2c3dd065` settled that one test by
hand. Twenty-odd other tests here type and stop without settling; they
pass because their timing has never lined up, which is not a reason.

Each test's store module is now kept, and afterEach settles it: real
timers back first (a fake-timer test's pending saves fall away with the
fake clock), then persistNote on every dirty path, raced against 50 ms
because a test may have left the bridge's writeNote gated forever.
persistNote clears the pending timer before its first await, so the
race can only cut short a write that goes to the finished test's own
bridge anyway. Proven with a throwaway probe test that waited past the
debounce after the dirty #852 test: it saw `unsaved typing` land with
the guard neutered and nothing with it in place. The store is untouched.
…block

On the 2.56.0 gate run the suite passed its first three checks and then
failed four in a row, every one reporting focused:false over a cursor
that had not moved: something took DOM focus from the editor after the
`$` check, and from then on each key went nowhere. The second run passed
12 of 12, so the app was fine and the run was not.

Each check block now starts with ensureEditorFocus: if the editor's
.cm-content is not the active element and the ex prompt does not own
focus on purpose, the script says so in a `note` line and calls the
existing focusEditor before typing. gotoLine does the same, since five
blocks begin there. One stolen focus therefore costs at most the block
it happened in, and the log names it instead of four unrelated FAIL
lines. Verified by injecting a focused button at the exact spot of the
gate failure: the script logged the note and passed 12 of 12; without
the injection it passes silently as before.
`14ad7597` typed the kept module through loadStore's return type while
loadStore's return type came from that same variable, which tsc rejects
as a circular annotation (TS2502, TS7023). vitest ran the file fine, so
the suite alone did not catch it; the workspace typecheck did. Both now
name `typeof import('./store')` outright.
The display name shipped in 2.56.0 falls back to the folder's name, and
the app took that from the root's last path segment: right for desktop,
the web client and a remote workspace, whose roots are paths. The phone
shells describe a vault by a label instead ("On this device › ZenNotes
› docs"), which has no path segment, so on a phone the whole label stood
in for the folder name. The first vault-settings save on a vault with no
display name (a favorite toggled is enough) then renamed the vault to
that label in the drawer, since the store re-derives the name after
every save, and clearing a display name landed on the label too.
Reproduced against the store with a phone-style root before this fix.

VaultInfo gains an optional `folderName`: the folder's own name on
disk, for a host whose root is not a path. `vaultFolderName` moves from
the renderer into shared-domain's vault-display-name module, prefers
that field, and otherwise keeps taking the root's last segment, so no
existing host changes behaviour; the store's fallback, the rename
prompt and the Settings field's placeholder all go through it. The
shells set `folderName` to the folder they opened. Desktop main is left
alone: its roots are paths.

The three core packages move to 2.56.1 for the phones' core-only
release; the desktop app stays 2.56.0.
The shell snapshot's vault is typed as a VaultInfo, but getShellSnapshot
rebuilt it from three named fields: root, name and temporary. The
folderName that f8b24c0 added for hosts whose root is a label never got
through, so a phone shell reading it from the snapshot got undefined with
no error, and its vault switcher could not tell which folder was open
once a display name made the shown name a different string. Found while
driving the iPhone shell's switcher on the simulator; the shells now take
the folder from their own bridge, which holds the open vault by folder,
so nothing waits on this, but the type's promise should hold.

The rebuild names folderName too, and the change check compares it, so a
host that hands over a different folder gets a fresh frozen snapshot. A
test pins both.
… carries work across

runMcpServer resolved the vault once and kept it for the session, so an
agent on the bundled MCP (every Windows install, and any client set up
without the managed zn) kept working in the vault the app had left until
the client restarted the server. The Go zn mcp did the same (Kevin on
Discord, 2026-09-25: the app went from his server back to a local vault
and the agent kept hitting the server). The pin was deliberate, since an
agent that read notes in one vault and writes after the user switched
would change a note at the same path in the other one.

VaultSession mirrors the Go fix (ZenNotes/tui 35a77be, session.go): the
target is resolved before every tool call; after a switch every tool but
vault_info fails with VaultSwitchedError, which names both vaults and runs
nothing, and calls resolve one at a time so a parallel batch stops as one;
vault_info moves the session and its notes say which vault it left. The
startup check still reports an unresolvable --vault on stderr (#831), but
the session starts at the first call it serves, so a vault the app left
before any call is no switch. sameVault compares servers by normalized URL
and directories by device and inode. A saved server is named in quotes
with its URL after "at", because the app often names a profile after its
host already. The test that pinned the first vault now expects the
refusal, and two new ones drive a real in-memory client through a switch,
a refused write that never lands, and vault_info.

The MCP and CLI cards in the manual say how the server follows a switch
and how a server token reaches zn: `zn connect <url> --no-default` once,
or ZENNOTES_REMOTE_TOKEN. The token half describes zn's fallback
(ZenNotes/tui 86e2149), which desktop-managed installs get with the next
terminal-release.json pin; this engine still reads its token from the
environment only. Verified with the dev app driven through its own UI and
both MCP engines attached, plus a headless Claude Code agent that recovered
from the refusal on its own.
…hever one you scroll (#859)

Split mode kept its panes in step with two different mappings. The preview
followed the editor by source line, but the editor followed the preview by
raw scroll ratio, and in a note full of KaTeX and callouts the rendered
preview is about twice as tall as its source, so the two disagreed by whole
subsections. Worse, the editor's lag came back: every time CodeMirror
re-measured the live-preview widgets the sync had just scrolled into view,
its scroll and geometry updates snapped the preview to the editor's lagging
line, and a one-shot "ignore the next event" flag caught only the first of
them. Driving the app over CDP with a lecture-style note: smooth scrolling
the preview yanked it backwards 21 times (up to 1569px) and the reader never
got past a fifth of the note; after reading ahead in the preview, one click
on an editor line threw the preview back 1211px (@ShowhyT's "click/selection
jump"). At the top, the preview started 16px down, which pinned the H1
against the pane's top edge.

Both directions now map through one list of anchors (lib/split-scroll-sync):
the document start, every top-level block where it begins in each pane
(blocks folded into one editor widget share a position; the first stands
for all), and both panes' ends. Between two anchors a position maps by the
fraction of the way it is from one to the next, so a formula that renders
ten times taller than its source stretches only its own stretch, and
preview-to-editor is the exact inverse of editor-to-preview. The top of one
pane is the top of the other and the end is the end.

The pane the reader drives now leads: wheel, touch and keys claim it before
its scroll events arrive, and while it leads the other pane's scroll events
are echoes, however many CodeMirror fires. An editor re-measure settles the
editor onto the preview while the preview leads, and leaves the preview
alone when the preview was the pane last driven, so clicking or typing in
the editor no longer moves what the reader was looking at. Only the
article's own blocks are anchors, so an embed stamped with another note's
lines cannot misplace one. scrollTopForScrollRatio goes with the ratio sync.

Not done: anchors stay at top-level blocks, so inside one tall callout the
mapping is linear. The report also mentions the note title drawn over the
H1, which never reproduced here and is not in the video; the top-edge pin
above is the part that was.

Same harness after the fix: no backward jumps in either direction, the click
moves nothing, both panes start at 0 and end together. Tests cover the
mapping (round trip, folded blocks, both ends, no anchors) and the echo
window.
The desktop's managed zn still pinned ZenNotes CLI 0.4.0, so a 2.57.0
build would have shipped a zn mcp that keeps the first vault it finds and
answers 401 on the server the app is connected to, whatever token zn
connect saved for it, while this version's manual already describes both
fixes (5f8095b). The pin now names 0.4.1, released today from
ZenNotes/tui 86e2149 with the two fixes for that report (35a77be, 86e2149):
the source commit, the four macOS and Linux archive URLs and their SHA-256
digests, each read from the release's asset digests and matched against its
checksums.txt.

Verified through `npm run terminal:stage`, the same download, checksum and
binary inspection that `apps/desktop/build/after-pack.js` runs at packaging
time, for all four platform pairs; the darwin-arm64 binary answers protocol
1, version 0.4.1. `test:terminal` 12 of 12. The released darwin-arm64 binary
also ran the MCP repro against a token-protected server: with only a zn
connect token it lists the notes, and it follows a vault switch once
vault_info confirms it.
…ng (#860)

With Vim mode off, the in-note find bar opened once. F3 and Mod-G run
CodeMirror's findNext and findPrevious, which open the bar only while no
query is set: after one search and Escape they moved the caret to the next
match of the old query with the bar closed, the selection toolbar popping
up over it, and nothing on the keyboard brought the bar back, in any note,
until a restart. In the main window F3 was the only key into the bar with
Vim off, since Mod+F opens note search there (reported by @Sullti, who
named findNext as the cause). The same report found the editor's Mod-f
hard-coded to note search: unbinding "Search notes in non-Vim mode" under
Settings → Keymap did not free it, in the main editor or the pinned
reference pane.

F3, Mod-G and their Shift variants now open a closed bar, the last query
prefilled and selected as Mod+F does, and step through the matches only
while it is open. The change sits in vimAwareSearchKeymap, so every editor
surface has it. Both editors' Mod-f now come from the configured
`global.searchNotesNonVim` binding, so moving or unbinding it hands Mod+F
to the find bar (what #376 asks for, by way of Settings). The manual's Mod+F
row said the bar opens with Vim off, which was never true in the main
window; it now says where Mod+F goes and how to change that, and a new row
covers F3 and Mod+G.

Not reproduced: the report's floating-window Ctrl+F, which reopened the bar
on every press here, with Cmd+F on macOS and with Ctrl+F under Windows key
handling (navigator.platform emulated as Win32). The fix covers any path
that runs findNext.

Verified over CDP with Vim mode off, both stores isolated: the build before
this change passes 5 of 13 checks (F3 jumps from match to match with the
bar closed in the main and a floating window, and Mod+F never reaches the
bar with note search unbound); this one passes 15 of 15, with F3 and
Shift+F3 still stepping between matches while the bar is open and Mod+F
still opening note search by default. Five new tests run the bindings on a
real EditorView; all five fail on the stock keymap.
…ks, not on them (#861)

Atlas lays the vault out once, caches the positions per vault, and from
then on only places newcomers, so the map never shuffles. A newcomer (a new
note, or one renamed or moved) went to the centroid of the notes it links
to, give or take 11 units a side, with no look at what was already there.
For the most common newcomer, a note that links to one note, that centroid
is the note itself: the dot landed on top of it, every later note linking
there piled onto the same spot, and the cache kept the pile for good. The
first map of a vault never had this, only notes written after Atlas was
first opened, hence the "sometimes" in @vinivinoo's report, whose
screenshot shows a pile on one hub and pairs on two smaller notes.

A newcomer now takes the nearest spot around the same anchor (its links'
centroid, or its region slot while none of them is on the map) that keeps
24 units between dots, edge to edge. On a map too crowded for that near its
links, it takes the nearest spot where it touches no dot and keeps 12 from
what it links to. New notes that link only to each other are placed
breadth-first from the map, so a chain grows off the note it hangs from.
Maps that earlier versions already piled up repair themselves on the first
open: a cached note within 12 units of a note it links to moves to the
nearest free spot, and of the two the note with more links (then the older
one) stays. Nothing else moves, and the result settles to itself, so
reopening the map moves nothing. The dot radius now lives in lib/atlas.ts
(atlasNodeRadius), shared by the layout and the view. The design doc's
placement rule says the same, and its banner, which still said nothing had
shipped, now says what has.

What it deliberately does not do: separate dots that the first layout of a
very large vault already leaves overlapping (about 390 pairs in the map of
a synthetic 5000-note vault). They predate this and come from the
relaxation, not from placement, and repairing them would move about 7% of
such a map at once. Dragging single notes by hand, the report's other
half, is left out as well: in Atlas a drag moves the camera by design.

Verified over CDP on a vault shaped like the report's (29 notes in 7
folders, then 8 notes linking to one hub and two leaves), both stores
isolated. The build before this change piles them up: 20 overlapping pairs
in the map and 11 in the sky, each newcomer 0.2 to 14 units from its link.
This one places them 37 to 59 units from their links with at least 24
between dots, and opening the map 2.56 cached repairs it: 0 overlaps,
exactly the 8 stacked notes moved, none of the other 29. The first map is
byte-identical to the one 2.56 draws, and reopening moves nothing. Offline
against the old layout, full layouts of 45 and 300 notes are unchanged,
1000 to 5000 notes move one to three notes, and the time is the same. Five
new tests; the three placement tests fail on the old layout.
Home's Recent and Favorites rows opened a note with no hand-off to the
editor. The click swaps the Home view out, button and all, so DOM focus
fell to <body>: the note showed, but with Vim mode off typing went
nowhere, and with it on VimNav, which routes keys to the sidebar whenever
no editor has focus, read the letters as sidebar and leader commands.
That is what @phoscoder met on Omarchy (#863): typing moved the sidebar
cursor up to the note's folder, the Plans row highlighted in the report's
screenshot, and turning Vim mode off only made typing do nothing. The
sidebar tree, the note list and the palettes have handed the keyboard
over since #599; Home never did.

Both rows now open the note and then call focusEditorNormalMode, the same
hand-off. Home's New note buttons create the note with focusTitle, like
every other New note button, so the new note's title field takes the
keyboard instead of nothing.

Verified over CDP on a vault shaped like the report's (notes at the vault
root, a Plans folder), both stores isolated. Before, a note opened from
Recent left focus on <body>: with Vim mode off the first keystrokes were
lost, and with it on "h" moved the sidebar cursor up to Plans and a space
and "i" opened the Insert template picker. After, the keys reach the
editor in both modes, and New note leaves the cursor in the title field.
Four tests render HomeView; all four fail on the old view.
… all (#863)

An open note whose file another program deleted and wrote back, the way
git, some sync clients and some editors replace files, closed under the
reader. chokidar merges an unlink and an add into a change only inside
100 ms, so a slower replace reached the store as an unlink, and
closeUnlinkedNote dropped the tab and the buffer with it, text not yet
saved included; with one note open, the reader landed on Home while the
note showed up again in Recent as edited "just now". A note-list refresh
taken while the file was gone could likewise prune a background tab with
unsaved edits.

An unlink for an open note now waits 1.5 s and asks the disk again. The
tab stays when the file is back (its add re-reads the text as before) and
closes only when it is still gone, and it never closes over unsaved
edits: the pending save writes the note back, the rule a stale listing
already follows for the active tab. The refresh keeps any dirty tab, in
the background as in front. The app's own deletes, trashes and moves
close their tabs themselves and are unaffected.

Verified over CDP: a note whose file was deleted and rewritten 300 ms
later went to Home and lost the words typed just before; now it stays
open with them. A saved note deleted for good is still open 0.6 s later
and on Home by 3 s. Four new store tests fail on the old store; the three
#713 rename tests now model the deleted file on disk and pass on both.
…d settings takes the keyboard

The #863 audit of every place that opens a note by mouse. Four still
opened it with no hand-off to the editor, so focus stayed on the clicked
row or fell to <body>: typing went nowhere with Vim mode off, and to the
sidebar and the leader with it on.

- Connections panel: clicking a link or a backlink row, and creating the
  note a missing link names. Enter on a row already handed over through
  VimNav.
- Assets view: the Used button's note, and the menu of notes when several
  use the asset. Opening the asset itself is unchanged.
- Atlas: Enter on the focused note, and a second click on a node.
- Cloud settings: Open and Open copy on a file that needs attention,
  which close Settings first.

Each now calls focusEditorNormalMode once the note has opened. Left alone
on purpose: Tags, Quick Notes, Archive and Trash already hand the keyboard
over with a one-shot focus that works (checked in the app); hover
previews, Excalidraw menus, the Obsidian drawing converter and Preview's
asset and diagram links open no text editor; and a note link followed in
Preview shares its code with the editor's own link following, where a
reader is not asking to type.

Verified over CDP with Vim mode off, opening a note by mouse from each
surface: before, the Connections panel, the Assets view and the Atlas
left focus on <body>; after, every surface lands in the editor. Cloud
settings is covered by its test only (it needs a sync conflict). Eight
new component tests, all but the asset-row guard failing on the old
components, and the Cloud settings Open copy test now expects the
hand-off.
…eview (#848)

A long to-do note with a few indented detail lines under every task had
no way to show just the task lines: headings folded, list items did not,
not visibly. @Sullti asked in discussion #848 for the heading treatment:
an arrow on hover for any item with indented children, in Edit and
Preview, the fold keys, Fold All and Vim's fold commands on list items
too, and the Tasks view left alone.

A bullet, numbered item or task with a child block below its first line
(a nested list, another paragraph, a code block) now gets an arrow beside
its marker, shown on hover or on the caret's line and kept showing while
folded. The range is the one @codemirror/lang-markdown already gives a
ListItem, from the end of the item's first line to the end of the item,
so the arrow, the fold keys and CodeMirror's own foldCode fold the same
span and each reopens what another closed. An item that only wraps onto
a second line has nothing under it and gets no arrow. In Preview the
same items get an arrow left of their bullet, number or checkbox, and
the fold state lives in the DOM, as Preview's heading folds do.

The fold keys were heading-only: Ctrl+Alt+F (Cmd+Option+F) did nothing
on an item, while Vim's zc already folded one through the syntax fold.
Both now run foldAtCursor: the heading's section, else the item on the
line, else the syntax fold, else the item the cursor sits under, with
the caret moved to it. Fold All (zM, :foldall) folded only the outermost
ranges, so a heading swallowed its tasks; it now folds every heading and
every list item, nested, and zo on the heading shows one line per task.
Command ids stay; their titles, the keymap catalog, the manual and the
demo tour's cheat sheet name list items.

Folding only hides lines: nothing is written to the note, and the Tasks
view lists the same tasks.

Driving the built app turned up two layout traps, and the CSS says why.
A list line's negative text-indent is inherited, and it pushed the
arrow's glyph out by the marker's width (off screen on a task). In
Preview an em or ch inside the arrow's own 12 px font shrank every
offset, so the offsets use lengths registered with @Property and
resolved on the item. The Preview arrow is the item's last child, since
as the first it broke the rule that seats a loose task's paragraph
beside its checkbox, and it is added after the task pass, which would
otherwise take it into the task body.

Tests: 7 in cm-heading-fold.test.ts, 8 in preview-list-fold.test.ts.
…idian (#853)

Obsidian marks a foldable callout with - or + right after its type, and
vaults moving over use it for long blocks that should stay closed:
screenshots, logs, config excerpts. ZenNotes did not know the marker.
Preview's callout pattern wanted whitespace right after [!type], so
"> [!example]- Screenshots" fell back to a plain quote showing the whole
line as text, and Edit mode titled it "- Screenshots". @Sullti asked in
discussion #853 for - collapsed, + expanded, no marker as today, and a
click on the title row to toggle it, in Edit and Preview.

Preview renders a callout with a marker as a <details> with its title as
the <summary>: the whole title row toggles it, Tab and Enter work, the
browser's find opens a closed one on a match, and Vim's hint mode
(Space h) now labels summaries. A callout with nothing under its title
has nothing to fold and stays a plain box. DOMPurify's defaults already
keep details, summary and open. A PDF cannot be clicked open, so the
export window opens every callout before it waits on images and prints.

In Edit mode the marker hides with the type, a chevron follows the title
(off the caret's line, where the title is source; a click on the title
text still edits it), and a folded callout closes its card on the title
line. The fold range is the quote below the title line, so the chevron,
the fold keys and Fold All close the same span. The fold keys fold a
callout from its title line, and a foldable one from any line inside it;
Fold All folds foldable callouts along with headings and list items.

A collapsed callout folds each time a note opens. That state is a field
rather than a view plugin's constructor: EditorPane reconfigures the live
preview compartment on a theme or math renderer change, and a
constructor would close again what the reader opened. The field keeps
its value across a reconfigure and resets only when a note sync replaces
the whole document, which is how the editor swaps notes.

Two things turned up on the way. The WYSIWYG block walk visits a quote
once per visible range, and a fold splits the viewport into ranges, so
the chevron came twice; a quote already decorated is now skipped. And a
code block's closing fence inside a quote or callout stayed visible in
Edit mode, because the fence test expected the fence at the start of the
line.

Left as it was: an image embed on a quoted line (> ![[shot.png]]) shows
as source in Edit mode, as in any quote; Preview draws it.

The manual, the MCP instructions and the demo tour's callout note
describe the marker.

Tests: 10 in cm-callout-fold.test.ts, 5 in markdown.test.ts, 1 in
cm-wysiwyg-blocks.test.ts (the quoted fence, failing on the old
pattern).
The session compared remote targets by lowercasing the entire URL. A reverse proxy could serve different vaults at /Work and /work, yet a write planned against the first would silently reach the second.

Compare parsed URLs so the origin is normalized while paths and query values retain their case. A real MCP session against a local HTTP server now proves that no write reaches the new path before vault_info confirms it, and that writes resume after confirmation.

Verified the target and MCP suites (47 tests) and the desktop main-process typecheck.
Collecting only headings, list items and marked callouts made zM and :foldall do nothing in notes containing standalone code blocks. Those ranges still folded individually with zc.

Ask the fold services and syntax tree at each non-heading line, then combine those ranges with the nested outline folds. Deduplicate shared ranges so reopening a heading leaves each child folded once. Existing code, table and quote folds participate again.

Verified the heading and callout folding suites (25 tests) and the app-core typecheck. The regression cases cover standalone fences, repeated Fold All, and code remaining folded after its heading opens.
The reference editor read note search from its initial preference snapshot. Unbinding or moving the shortcut in Settings updated the main editor but left the reference capturing the old chord until it was unpinned.

Keep the reference keymap in its own compartment and reconfigure it when overrides or Vim mode change. The editor instance, selection, document and undo history stay in place while the old shortcut is released and the new one takes effect.

Regression tests drive a mounted CodeMirror editor through unbinding, rebinding and resetting, and verify undo survives. The 25 reference and search-keymap tests and the app-core typecheck pass.
A content block exactly at one pane's scroll limit was accepted as an anchor, then stopped the paired terminal anchor from being added. Scrolling that pane to the bottom could leave hundreds of pixels unread in the other.

Reserve either scroll limit for the shared terminal anchor. Regression cases cover a block at the editor limit, the preview limit and both limits, checking reachability, near-end movement and round trips.

The full uncached test suite, workspace typecheck and desktop build pass. Isolated CDP checks confirm code folding, live reference keymap updates and final content reachable from either split pane.
@adibhanna
adibhanna merged commit 08096c0 into main Sep 28, 2026
9 checks passed
@adibhanna
adibhanna deleted the v2.57.0 branch September 28, 2026 20:06
@adibhanna
adibhanna restored the v2.57.0 branch September 28, 2026 20:06
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