Skip to content

Release 2.62.0 - #895

Merged
adibhanna merged 17 commits into
mainfrom
v2.62.0
Oct 5, 2026
Merged

adibhanna merged 17 commits into
mainfrom
v2.62.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

ZenNotes 2.62.0.

Features

  • Quick Look for Markdown files on macOS: Space on a .md file in Finder renders it the ZenNotes way, with Open in ZenNotes (new apps/quicklook workspace, built and signed in after-pack.js).
  • Kanban Order: Manual / Due date on every board ([feature request] sort taks in custom kanban by due date #889), plus s, :order, the palette, Settings and kanban_card_sort.
  • Settings → Cloud shows a skipped automatic backup.

Fixes

Local gates on fb3d13f: typecheck 8/8 and test:run 6/6 with no cache (shared-domain 1,896, app-core 3,096, quicklook 15, desktop 1,049), npm run pack signed with the Quick Look extension inside, deep strict codesign OK, packaged launch check: page target in 1.5 s, version 2.62.0.

Restoring a Cloud backup on the Mac (2026-10-05) brought a trashed note
back to inbox/ and left its Trash copy in place. The next scan found a
file this device had never tracked and uploaded it as a new note, so
every device ended up with two.

A restore does not send a move. It sends an upsert whose previous_path is
the note's old location, and the desktop wrote the new path without ever
looking back at the old one. It now retires the previous path once the
new one holds the Cloud bytes, on every way out: bytes already there,
written inline, or published from a staged download. The old file goes
only when this device tracked it at that path and still holds exactly the
bytes it tracked. An edit made here since is reported as a local edit
conflict, a path this device never tracked is left alone, and two
spellings that reach one file on a case-insensitive volume (same device
and inode, compared as bigints for Windows file ids) are never treated as
two files, since removing the "old" spelling there removes the note just
written.

The coordinator settled an upsert whose bytes already sat at the target
without asking the repository (a replay after a crash lands there), which
skipped that cleanup. It now takes the shortcut only when the item keeps
its case-folded path.

The phones' portable repository already removed a moved file's old path,
but on a case-insensitive volume (Android's default vault storage among
them) a case-only rename deleted the only copy. It now asks the directory
listing whether both spellings exist. When they are one file it writes in
place and gives the file its new spelling through a temporary name,
because a rename that overwrites its destination would remove the file
itself first.

What this does not do: change how the server plans a restore, or touch an
untracked file at a previous_path. Folder-name case changes and case-only
moves can still bounce or raise a false conflict, without losing data.

Desktop 1,044 tests (19 new), shared-domain 1,870 (9 new), typecheck
clean. Verified in the built app on a Cloud vault: the restore brought
the trashed note back to inbox/, the Trash copy was gone, and Cloud held
exactly the backup's 99 items.
Cloud acceptance testing (2026-10-05) turned up backup copy that left the
person guessing.

A backup the service refuses for a plan limit said only "This backup
would exceed your plan limits." The service names the limit and both
counts, but only in the details of BACKUP_QUOTA_EXCEEDED, and desktop
errors cross IPC as message text, so the details were gone before the
window could read them. Both hosts (desktop main, and the shared host
service the phones use) now reword that refusal while the details are
still attached:

- Size: "This vault holds 54 MB, and backups on your plan hold up to
  52 MB. Remove large files, or contact support to raise the limit."
  When both sizes round to the same figure it says "just over 52 MB".
- Files: "This vault has 12,345 files, and backups on your plan hold up
  to 10,000", with the same advice.
- Count: "This vault already keeps 30 backups, the most your plan
  allows. Delete an older backup to make room." Over the limit, it says
  how many to delete.

Any other failure, and details this version cannot read, keep the
service's own message. formatCloudBytes moves to shared-domain so the
hosts can word sizes before IPC; app-core re-exports it from its old path.

Restoring a backup labeled "Restore QA baseline" asked "Restore Restore
QA baseline?". The prompt now quotes the label. The result read "Restored
97 items and removed 1 newer items"; it now counts in the singular when
it should and leaves the removal out when there was none.

shared-domain 1,879 tests, app-core 2,949, desktop 1,026, typecheck clean.
…f current

Four rough edges on the Cloud page, found in device testing (2026-10-02)
and the restore run (2026-10-05).

Review and Set up land on This vault. The status bar's Review (kept-both
copies, rejected changes, capacity), its Set up, the removed-vault Review,
and Space r or the palette after a removal all open Settings on the Cloud
page, which then sat at its top with the keyboard in the settings search.
The page now scrolls This vault into view once the account and link have
loaded, and focuses the first thing in it that waits on the person (a
removed-vault notice, the vault settings card, a sync failure, files needing
attention), or the section itself when nothing does. It moves focus only
while Settings still holds it, and only once per open. A file conflict's
Review is unchanged: it opens the conflict queue, not Settings.

The usage card stays current. It read usage when the page opened and after
the page's own actions, but most sync runs are not the page's. A run that
moved files now asks for a fresh read once runs settle (3 s), a hidden
window waits until it is shown, and an answer that a newer sign-in has
superseded is dropped.

Failed automatic backups are shown. The service sends `last_failure` with
the schedule; the app never declared it, so a skipped daily backup was
silent. While automatic backups are on, the row now says what happened: a
plan limit is worded from its numbers with the same sentence a refused
manual backup uses, anything else says the backup failed and will be tried
again.

The restore result drops "This vault is synced to cursor 18" for "This
vault now matches the backup", and says nothing when the sync after the
restore left something for This vault to list.

app-core 2,965 tests, shared-domain 1,889, desktop 1,046, typecheck clean.
A file too large for Cloud stays on the device that has it, while its
note syncs everywhere. Every other device then showed `![[clip.mp4]]` as an
empty video player, and images and PDFs as broken frames. Found in Cloud
device testing (2026-10-02) with a 151 MB video.

The resolver never said a file was absent. When no listed file answered an
href it guessed a URL beside the note and returned it like any other, and
both the reading view and the editor built a player on it. An empty vault
also read as "not listed yet", so a vault whose only file was held back
left the embed as raw source.

Now the store records when the vault's own listing has arrived, and an
image, PDF, audio or video embed whose name no listed file carries draws a
one-line card instead: "clip.mp4 isn't on this device.", worded and
coloured like the "Not synced to Cloud" notice the source device shows. The
editor's card keeps the `</>` action and the reading view's keeps its
source line, so editing the line works as on any block. Imports index the
new file before the embed goes in, so a drop or paste never flashes the
card. A list seeded by hand (the share viewer, PDF export) never calls a
file missing, and neither does a name two listed files share.

Desktop's asset listing now includes a file reached through a symlink
(zen-asset serves it), or every embed of a linked file would read as
missing; a broken link stays out. The web app needs the same from
znserver's listing, done there.

Not changed: attachment chips and pictures inside a sentence, which the
listing cannot vouch for (notes, drawings and dotfiles are not in it).
On the phones a folder showed its notes and subfolders but none of its
attachments (PDFs, pictures, audio, video), which the desktop's folder view
shows. Found in Cloud device testing (2026-10-02). The phones hide app-core's
sidebar and note list and build their folder view from the public Browse API,
and `getBrowseDirectory` returned only folders, databases and notes.

Browse now carries `files`, in the snapshot and in each directory listing,
placed exactly as the desktop sidebar places them (assetBelongsToFolderView,
assetFolderSubpath): the root asset folders, the other system folders,
`.zennotes` and files inside a database stay out. Each row's `path` is an
opaque assetTabPath for the existing openNote action, the way database rows
open; files follow the note sort, by last change where notes sort by
creation. Re-reading an unchanged file index keeps the delivered rows. A
folder rename, move or delete through the Browse actions re-reads the file
index, since the phones have no watcher to catch up for them.

Additive: the desktop and web apps do not use the Browse API. The phones
show the rows once they adopt a core with this change.
…s off (#892)

With Blinking cursor turned off, a note opened in a floating window, the
Quick Capture window and the external-file window still blinked. The main
editor builds `drawSelection({ cursorBlinkRate })` from the preference, and
the drawn caret and the Vim block cursor both read that one value; these
windows build their own editor from their own prefs reader, which never
read `cursorBlink`, and called a bare `drawSelection()`, which blinks at
CodeMirror's default. The reference pane and the template editor had the
same bare call.

Every editor now builds its cursor layer through cm-cursor-blink.ts. The
windows that seed from the stored prefs blob also follow a change made in
Settings while they are open, through the `storage` event (the only signal
another window gets), reconfiguring only when the value really changed.
Quick Capture is hidden rather than closed, so it no longer keeps its first
value all session. A test fails if any other file calls drawSelection().

Not changed: other preferences these windows still never apply (custom
themes, completed-task style, math scale, line-number position) need more
than this one value and are left for their own change.
…#894)

Space h labelled every control on screen and the rendered links in
Preview, but none of the links in the Edit view: the page-wide scan
collects real controls (`a[href]`, buttons, `[role=link]`), and the editor
draws a wikilink as a span, a Markdown link as text spans and a bare URL as
plain text.

Hint mode now also asks every visible note editor for its links (bare URLs,
`[[wikilinks]]` with an alias or heading, Markdown links and images), from
the lines CodeMirror has drawn. A label sits on the link's first character
that is actually drawn, so hidden `[[` or `[` is skipped and a link hidden
behind an embed gets none; a link must be inside the visible part of the
editor, links in code get none, and labels run down the note in document
order with the page's other targets. Following a label makes that pane
active and does what a click does: a wikilink through the same path as a
rendered wikilink (so `[[2024.01.15]]` opens the note, not a browser), any
other link through followLinkTarget. The link patterns now live in one
table that Cmd-click, `gd` and hint mode all read.

Unchanged: Space h still labels every control too, and stays behind the
Vim leader.
Settings is a dialog inside the main window, and the macOS menu has no
Close item, so ⌘W reached the renderer's close-tab branch, which never
looked for overlays: it closed the tab behind Settings (or the whole window
when no tab was left), and did the same behind palettes, dialogs and menus.
Quick Capture handled ⌘↩, ⌘N, ⌘P, ⇧⌘P and Esc but not ⌘W, though it shows a
normal close button. The floating note and external-file windows had a
hard-coded ⌘W/Ctrl+W check that compared the typed character, so it failed
on non-Latin layouts, ignored a rebind or unbind of Close active tab, and
fired on Ctrl+W on macOS.

⌘W now closes the frontmost window, the macOS rule, and Settings counts as
one: it closes Settings and leaves the tabs alone. Dialogs, palettes and
menus are not windows: while one is open ⌘W does nothing (Esc dismisses
it), which also keeps ⌘W from throwing away a nested draft such as the
template editor inside Settings. The floating note, external-file and Quick
Capture windows close on the Close active tab binding through their close
button's own path (a pending save still flushes; Quick Capture hides and
keeps its draft), and follow a rebind made while they are open.
resolveCloseShortcut keeps the main window's rules: Ctrl+W as the Vim pane
prefix on Linux and Windows, and closing the window when no tab is left
(#192).

The in-app manual describes the binding and the new Quick Capture row.
A wikilink names a note, but every path that followed one by its name read
it as an href first, and the bare-domain guess that lets `[site](google.com)`
open the web (#201) took a dotted name for a domain. So `gd`, `gD`,
Cmd-click on the link's source, a dead link clicked in Preview, a link in a
table cell and a database relation chip all sent `[[2024.01.15]]` to
https://2024.01.15 even with the note right there, and `gy` and the link
menu offered to copy or open that address. A plain click on a rendered
wikilink and hint mode resolve the name first, so they were right. Found
while fixing #894.

linkRangeAtCursor now reports which kind of link it found, from the same
shape table, and externalUrlForLink(target, kind) lets a wikilink reach the
browser only with an explicit scheme (`[[https://…]]`). followLinkTarget,
gd, gD, Cmd-click, the copy menu, table cells, relation chips and Preview's
dead links pass the kind. extractLinkAtCursor, which dropped it, is gone.

Not changed: a Markdown href or a bare URL keeps the bare-domain guess, so
`[day](2024.01.15)` still reads as a web address, and a wikilink written
with a scheme still opens in the browser.
In the Edit view a rendered wikilink followed on mousedown with any mouse
button. A right-click opened the linked note and then drew the editor's
context menu over that note, a right-click on a link to a missing note
asked to create it, and a middle click followed as well.

Only the primary button follows now. A right-click leaves the note where it
is and opens its menu; Cmd or Ctrl with the primary button still creates a
missing note at once (#768).
GHSA-vfj7-8cjw-p6xm (braces, stack exhaustion on deeply nested patterns,
no fixed release) failed `npm audit --omit=dev --audit-level=high`. Braces
reached production through @excalidraw/excalidraw, which lists sass 1.51.0
as a dependency; that sass pulls in chokidar 3, which pulls in braces.
Excalidraw never loads sass at runtime: nothing in its build output imports
it.

Excalidraw's sass is overridden to 1.103.1, the copy the tree already has
for Vite, which watches with a chokidar that has no braces. braces stays
only under build tools. No package changes version.

The packaged app trades one unused native module for another: the old
chain's fsevents is gone (the app's own chokidar 4 never used it) and
sass's optional @parcel/watcher arrives, signed with hardened runtime and a
timestamp like the rest. Checked on an electron-builder --dir build: deep
codesign verify passes and the packaged launch check finds its page target.
…ve between steps survives

On storage that ignores case (Android's default, the iOS simulator), a
Cloud change that only respells a note's name takes two renames through a
temporary name; renaming straight onto the new spelling removes the file
as its own destination (7679d21). Two things could go wrong between the
steps:

- The app stopping there left the note as `Note.md.<uuid>.tmp`. Sync
  ignores that name, and the run that follows replays the change (it was
  never saved as applied) and writes the note again from Cloud, so nothing
  was lost, but the parked copy stayed on the phone for good.
- A save landing there, from an editor still holding the old spelling, was
  removed by the second rename as an existing destination. The typed text
  was gone everywhere.

matchCase now writes a small record under
.zennotes/zennotes-cloud-sync/respellings (a local-only folder that scans
never list) before the first step, and checks the name is still free
before the second. A taken name means a save landed: the save stays, it
syncs as the newest version, and the record waits for the next run. Each
sync run first settles any records: a parked note whose name is free goes
back under it, one whose name holds the same bytes again is a duplicate
and goes, and anything else stays, since it may be the only copy of one
side. Recovery never fails a run; a record it cannot settle yet waits.

Not changed: a save already in flight inside the second rename call can
still be removed, a much narrower window. Desktop never takes this path,
and the phones reach it only with a core built from this release.
…otes way

Select a .md file in Finder and press Space: Quick Look shows the note
rendered by the app's own Preview, in the user's theme and fonts from
config.toml (callouts, tasks, tables, highlighted code, KaTeX, Mermaid, the
images it embeds), with an Open in ZenNotes button. Enter opens it too;
with Vim mode on, `o` opens it and `j` / `k`, Ctrl+D / Ctrl+U, `gg` and `G`
move through it.

Three pieces ship in ZenNotes.app/Contents/PlugIns:

- apps/quicklook/src: the page, a small build beside the share viewer that
  borrows its approach (a bridge shim, the full app stylesheet) with its own
  payload: the note, the asset references the extension resolved, and the
  raw config.toml, read with smol-toml. Typst math and the plot renderers
  stay out (KaTeX typesets math), as do the Excalidraw fonts: 11 MB instead
  of the share viewer's 55. The typography variables App.tsx sets moved to
  lib/typography-variables.ts so both read one source.
- ZenNotesQuickLook.appex: the sandboxed extension (Quick Look loads no
  other kind). It serves the page and the note's embedded files over its own
  zenql:// scheme, behind a Content-Security-Policy that allows nothing
  else, so a preview never reaches the network or a remote image. WebKit's
  helper processes refuse to start without the network-client entitlement
  even so. A read-only absolute-path exception lets it find embedded images
  beside the note and the theme in ~/.config.
- ZenNotesQuickLookOpener.xpc, inside the extension: Quick Look's sandbox
  lets a preview launch nothing (NSWorkspace, a URL, even its own app all
  fail), so this unsandboxed service opens the note in the ZenNotes that
  contains it, the route of a Finder double-click (a note in a known vault
  opens there), and outside links in the browser. It checks every request
  again: an existing Markdown file, this ZenNotes, web or mail links only.

electron-builder never signs anything under Contents/PlugIns, so afterPack
builds the extension per architecture and signs it, opener first, with the
identity and timestamp server electron-builder chose for the app.

Not included: editing, thumbnails, Typst math, plots, TikZ and pictures
hosted on the web. A missing embedded image shows as a broken image.

Checked with a packed build registered under separate bundle ids (so the
installed app kept .md and zennotes://) and launching only on an isolated
profile: the showcase note rendered in the user's Gruvbox theme with images
by wikilink and by path, KaTeX and Mermaid; the self-test build pressed the
button and ZenNotes opened the note; `j j j`, `G` and `gg` scrolled the real
panel 120 px, to the bottom and back. Tests: payload, prefs and keys (15).
The Kanban board's key handler runs ahead of everything else and never
asked whether a menu or dialog was open. A card's right-click menu filters
as you type, and the in-app manual suggests typing "prio" to narrow it to
the priority entries; on the board that `i` reached the board first and
marked the card in progress, leaving the menu filtering on "pro". An `x`
or a `c` in a filter would check the card off or cancel it the same way.
Behind Settings the Tasks view's own keys fired as well: `1`, `2` and `3`
switched the view, and `a` opened the new-task prompt on top of Settings.

Both handlers now stand down while a menu, a prompt or any dialog is open,
Settings included. isOverlayOrDialogOpen adds the aria-modal panels that
the shared Modal shell and Settings draw to what isAppOverlayOpen already
covered. The close shortcut keeps its own rule, since Mod+W closes Settings
itself, and the other list views keep isAppOverlayOpen for now.
A Kanban column was built in due-date order, but the first drag saved an
arrangement for every column on the board, and from then on the saved
order won: a task whose date moved up stayed where it had been dragged, so
a custom status board could no longer show what is due first.

The board's toolbar now has Order next to Group by: Manual, as before, or
Due date. By due date every column on every board, custom status boards
included, sorts earliest first with undated cards last, ties broken by
note and line. A card sent to another column with Shift+H / Shift+L or a
drag lands in its date slot, and the insertion line shows that slot,
counting any date the drop itself sets (Today, Upcoming). A drag inside
its own column moves nothing. The dragged arrangement is never written
while ordered by date, so Manual brings it back exactly.

The order is one global preference, portable as `kanban_card_sort =
"manual" | "due"` under [view] in config.toml, and every way in is a key
away: `s` on the board in Vim mode (the cursor stays on its card),
`:order due | manual` on the Tasks ex line (bare `:order` switches), the
palette's "Kanban: Order Cards by Due Date" or "...Manually", and
Settings > Tasks > Card order. It is deliberately not a per-vault view
override, which would need the Go server's vault.json mirror to learn it.

The column builders' three copies of the due comparator are now one,
compareCardsByDue. Documented in the in-app manual (the Kanban card, the
:order ex command, the Settings reference) and on the website.
The config.toml watcher skipped any file text the app itself had written
among its last sixteen writes. That guard is for a stale read: with two
writes in a row the watcher can read the file around the second one and
see the first (a Windows CI flake), and taking that for an edit reverted
settings. But the guard never expired. A user who changed a setting in the
app and then set it back by hand left the file byte for byte as the app
had written it before, so the edit was ignored: the app stayed on the newer
value while the file said otherwise, and its next save would write that
value back over the edit.

An earlier own write now counts as a stale read only while the app's writes
are landing: during a write, or within 1.5 s of the last one. A read
skipped in that window is checked again once the writes settle, and a text
still in the file then applies, since nothing the app wrote explains it. A
read that outlives its watcher is dropped.

The race test used to write the old text back after both writes had
finished, which is exactly the hand edit the guard swallowed; it now holds
the second write mid-flight. Two new tests put an earlier version back,
after the window and inside it.
const view = await mount()
savePrefs({ keymapOverrides: { 'global.closeActiveTab': 'Shift+Mod+W' } })
await act(async () => {
window.dispatchEvent(new StorageEvent('storage', { key: PREFS_KEY }))
localStorage.setItem(PREFS_KEY, newValue)
await act(async () => {
window.dispatchEvent(
new StorageEvent('storage', { key: PREFS_KEY, newValue, storageArea: localStorage })
@adibhanna
adibhanna merged commit fb3d13f into main Oct 5, 2026
9 checks passed
@adibhanna
adibhanna deleted the v2.62.0 branch October 5, 2026 21:57
@adibhanna
adibhanna restored the v2.62.0 branch October 5, 2026 21:57
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.

2 participants