Skip to content

ZenNotes 2.42.0: an update under a running app asks for a restart, Vim chords and app shortcuts get one rule, and five long-standing editor bugs close - #724

Merged
adibhanna merged 7 commits into
mainfrom
v2.42.0
Sep 2, 2026
Merged

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

Release branch for 2.42.0. Work accumulates here; main fast-forwards at release time.

Shipped so far

Fix(desktop): an update installed under a running ZenNotes asks for a restart (fa16dfd, reported on Discord by unyanda)

Upgrade ZenNotes with a package manager while it is open and the next window the old process opened came up as a page of raw JavaScript. Electron caches the app.asar header for the life of the process but reopens the archive by path for every load, so once pacman, dpkg, rpm or a Homebrew cask swapped the archive, the old process read the new file at the old offsets. Replaying the 2.40.0 header against the published 2.41.0 archive reproduces the reported screenshot byte for byte; the install itself was never broken. A packaged ZenNotes now records its archive's identity and header digest at boot and checks them on focus, on a slow poll and before any renderer load or reload. A stale load is refused: the window shows a plain explanation and a sheet names both versions with "Restart ZenNotes" and "Not Now". A byte-identical reinstall never prompts; AppImage, Nix and Windows installs never see a change. The sheet only ever attaches to a visible window, because a parentless macOS alert ended from outside reports the Restart button (observed during verification). Reproduced and fixed on the packaged macOS app over CDP.

Fix(keymaps): app shortcuts win over Vim's Ctrl chords, and the exceptions are written down (443a210, closes #510)

On Linux and Windows with Vim mode on, Ctrl+P moved the cursor up and Search Notes never opened; Ctrl+N moved down and New Note Here never fired. codemirror-vim aliases those chords to k/j and stops propagation, and the app was listening in the bubble phase. Both move to the capture phase next to the pane shortcuts that were moved there for the same reason (#124, #579). Ctrl+F was the opposite case: CodeMirror's find panel took it from Vim's page-forward while Ctrl+B paged back; with Vim on and Mod resolving to Ctrl the find binding is dropped, so Ctrl+F pages and / searches (macOS keeps Cmd+F, Vim-off keeps the panel). Ctrl+W stays Vim's pane prefix as it has since 2.4.0, and the help now says so, with the rule: an app shortcut on a Ctrl chord wins over Vim's, except Ctrl+W, Ctrl+O / Ctrl+I and Ctrl+D / Ctrl+U. Verified before and after on macOS with the shortcuts rebound to Ctrl chords.

Fix(desktop): links work in a note opened from Finder (be38419, closes #626)

A markdown file opened from outside every vault lands in the standalone window, where every link was dead: the app's resolvers are all vault-bound. The window now hands links to the host, which resolves them from the file's own directory: a relative or absolute href (adding .md when omitted), a [[wikilink]] by name in that folder or below it (four levels, dot-folders and node_modules skipped, shallowest match wins, alias and anchors stripped). A markdown target opens the way Finder would (its vault, else its own standalone window); other files open with the OS; a miss shows in the window's banner. Click in preview, plain click on a rendered link or Cmd/Ctrl-click in the editor, and gd in Vim mode. Verified on the reporter's layout in both modes. Relative images in that window remain a separate change.

Fix(editor): a renamed note stays open (4f13af1, closes #713)

Rename the note you are editing and on Linux it closed the moment you confirmed. The watcher reports a move as unlink then add, and on inotify the unlink reached the renderer before the rename reply (which waits for inbound wikilinks to be rewritten), so the tab was closed as a deletion and the reply renamed a tab that was gone. Reproduced on macOS with 400 inbound links. The store now tracks renames in flight: the old path's unlink is recognised as the rename's own echo and refreshes keep the tab, so the reply moves it to the new name. Three tests pin the event order; the race test fails with the guard disabled.

Fix(editor): a trashed note's bin icon deletes for good (2c0fcf0, closes #712)

Open a note from the Trash and the header already showed Restore, but the bin icon still ran Move to Trash and asked to move an already-trashed note. In the Trash it now reads "Delete permanently", asks the same question the Trash view asks, removes the file and closes the tab; the command palette gains Delete Note Permanently. The wording lives in one helper shared by the Trash view, the header and the palette, and the tab cleanup that trashing, archiving and deleting all need is one function instead of two hand copies. Verified over CDP end to end.

Fix(excalidraw): Escape leaves the Arrow tool while the sidebar is open (8444bb3, closes #721)

With Vim mode on and the sidebar open, Escape in a drawing did nothing. VimNav's capture-phase router hands every key no text editor holds to the open sidebar, where Escape means "back to the editor", so Excalidraw never saw it. The router now yields to a focused canvas right before the panel routing, after the leader, gt/gT and Ctrl+W blocks so those still work inside a drawing; giving the keyboard to the sidebar blurs the canvas, and clicking into a drawing claims the focused panel like the editor does. Verified over CDP with vim on and off.

Docs

Both surfaces updated for #510, #626 and #712 (in-app help and the website's docs page, plus the feature index), committed in the website repo and ready to push with the release.

Verification

  • npm run typecheck clean; desktop suite 684 tests, app-core suite 1862 tests, all green
  • Every fix driven live over CDP with isolated stores (packaged app for the restart guard, built renderer for the rest)
  • Not driven by automation: the Restart button click and the guarded Reload menu item (no AppleScript access), and Ctrl+F paging on Linux (covered by unit tests; the host reports the real platform)

… restart

Upgrade ZenNotes with a package manager while it is open, and the next
window the old process opened came up as a page of raw JavaScript. That
is the "white screen of death" unyanda reported on Discord after 2.41.0
landed on Arch, and the install was never at fault: the published
archive is intact, offsets and integrity hashes included.

Electron parses the app.asar header once per process and keeps it for
that process's lifetime (asar_util.cc, GetOrCreateAsarArchive), but
every file:// load reopens the archive on disk by path and reads at the
cached offset (asar_url_loader.cc). pacman, dpkg, rpm and a Homebrew
cask all put a new archive at the same path, so from that moment the
old process reads the new file at the old offsets. Replaying the 2.40.0
header's index.html entry against the 2.41.0 archive produces the text
in the screenshot byte for byte: the tail of the zh-HK Excalidraw locale
chunk and the head of zh-TW, rendered as HTML. The same swap reproduces
on macOS with the shipped 2.41.0 build.

Nothing in a process can be repaired once its header is stale, so the
fix does not try. A packaged ZenNotes records its archive's identity and
a digest of the raw header at boot (installed-bundle.ts), reading the
file with process.noAsar because Electron's fs hooks present an .asar
path as a directory and would answer from the same stale header. The
status is checked when a window regains focus, on a 30 second poll, and
right before any renderer load or reload; once the archive on disk is a
different layout, that status is sticky. All five renderer loads now go
through one loadRenderer, which refuses a stale load: the window shows a
plain explanation page instead of whatever bytes sit at the old offsets,
and a sheet names the installed and running versions with "Restart
ZenNotes" and "Not Now". Declining silences the passive checks; only an
action that would actually load through the stale header asks again.
View > Reload and Force Reload are explicit items for the same reason,
since the stock roles reload straight through the stale header.

Three edges are deliberate. A byte-identical reinstall keeps every
offset valid, so the header digest decides, not the inode. The restart
is offered, never taken: the user may be mid-sentence. And the sheet
only ever attaches to a visible window. A parentless macOS alert runs a
nested modal session, and anything ending that session from outside
comes back as NSModalResponseCancel, which is 0, which is the Restart
button: the app relaunched itself with nobody at the keyboard during
verification. AppImage, Nix and Windows installs never rewrite the
running path and are never asked.

Verified on the packaged macOS app over CDP with isolated userData and
config dirs: the shipped build renders garbage in a window opened after
the swap, the fixed build renders the explanation page, logs the
refusal, raises the sheet and keeps waiting, and the poll caught a swap
28 seconds after it happened. Not driven by automation: the Restart
click (the same app.relaunch plus app.quit the updater uses) and the
Reload item, which CDP key events cannot reach on macOS.

How to test locally: npm run pack, launch
dist/mac-arm64/ZenNotes.app/Contents/MacOS/ZenNotes with ZEN_PERF=1,
ZENNOTES_USER_DATA_PATH and ZENNOTES_CONFIG_DIR pointing at scratch
folders, and --remote-debugging-port=9333. Extract the app's own
app.asar in a scratch directory, change the version in its package.json,
repack with npx asar pack, and copy it over Contents/Resources/app.asar
while the app runs. Press Cmd+R, or open a note by launching the binary
a second time with a .md path. Before: a window of minified JavaScript
with an empty title. After: "ZenNotes Was Updated", the sheet names both
versions, and the main process logs "[install] app.asar was replaced on
disk".
In an Excalidraw drawing with Vim mode on, pressing Escape while the
sidebar was open did nothing: the Arrow tool, or whichever tool was
active, stayed selected, and the same key worked the moment the sidebar
was hidden or Zen mode was on. That is #721, and it only ever affected
Vim mode.

VimNav's window-level key router runs in the capture phase and, once no
text editor holds focus, hands every key it does not recognise to the
open sidebar, where Escape means "back to the editor" and is consumed
with stopImmediatePropagation. Excalidraw binds its keys on its own
container in the bubble phase, so it never saw the Escape at all: a
document-capture probe counted zero arrivals while the toolbar stayed
on Arrow.

The obvious fix, adding the canvas to SELF_KEYED_SURFACES so VimNav
ignores it outright, is wrong on purpose: the leader on a Space tap
(#309), gt/gT between buffers and the Ctrl+W pane prefix are meant to
work from inside a drawing, and all three live in the same handler. So
the router lets its global bindings run and yields to a DOM-focused
canvas right before it would route into a panel. A canvas holding focus
owns Escape, the tool letters, arrow nudges and Delete.

The handoff in the other direction is covered too. Giving the keyboard
to the sidebar now blurs the canvas, the rule the database grid already
follows, so Ctrl+W h from a drawing still lands in the sidebar and j/k
drive it. And clicking into a drawing claims focusedPanel the way the
editor and the archive list do, so the sidebar stops painting its cursor
on the row that opened the drawing and pane navigation measures from the
right panel.

Verified over CDP on the built renderer with a seeded vault: with the
sidebar open, `a` then Escape went from Arrow to Selection only after
the change; Space tap then `e` inside the canvas still toggles the
sidebar; Ctrl+W h from the drawing blurs the canvas, lights the sidebar
cursor, and j/k move it exactly as they do from a markdown note. Vim
mode off was unaffected before and after.

How to test locally: npm run dev, Vim mode on, sidebar open, open a
.excalidraw note, click the canvas, press `a`, then Escape. Before: the
Arrow tool stays selected. After: the toolbar is back on Selection.

Closes #721
Open a note from the Trash view and the editor header already knew
where it was: Archive had become Restore. The bin icon next to it had
not been told. It still ran Move to Trash, so pressing it asked whether
to move to the Trash a note that was already there, and a confirmed
yes moved nothing and closed the tab, which is not what the prompt
promised. That is #712. The command palette was already right (it
hides Move to Trash for a trashed note); only the header lagged.

In the Trash, the bin icon now reads "Delete permanently" and does
exactly that: the same question the Trash view's own row action asks,
then the file is removed and its tab closed. Outside the Trash nothing
changes. The palette gains Delete Note Permanently for a trashed note,
so the action is reachable without the mouse.

Two things are shared on purpose. The wording for deleting a note for
good now lives in one confirmDeletePermanently, used by the Trash view,
the header and the palette, so three prompts cannot drift apart. And
the workspace cleanup that trashing, archiving and deleting all need
(close the tabs, drop the buffer and dirty flag, unpin the reference
pane) was two hand copies in the store; it is now withoutNoteInWorkspace
and all three go through it. A refused delete keeps the note open and
says so in a toast, mirroring Move to Trash.

Verified over CDP on the built renderer: trash a note from the header,
open it from Trash, the header shows Restore and Delete permanently, the
prompt reads 'Delete "Doomed" permanently? This cannot be undone.', and
confirming removes the file from the vault's trash folder, empties the
Trash list and closes the tab. Both docs surfaces describe the header's
two actions for a trashed note.

How to test locally: npm run dev, open any note, press the bin icon
and confirm, then open Trash from the sidebar and click the note.
Before: the header's bin icon is titled "Move to trash" and its prompt
offers to move the note to the Trash. After: it is titled "Delete
permanently", the prompt says so, and confirming removes the note from
the Trash list and closes the tab.

Closes #712
Rename the note you are editing, from the command palette or anywhere
else, and on Linux it closed the moment you confirmed: the tab vanished
and the editor went blank while the file itself was renamed correctly.
That is #713.

A rename is a move on disk, and the vault watcher reports a move as an
unlink of the old path followed by an add of the new one. On Linux
(inotify) that unlink reaches the renderer within milliseconds, but the
reply to the rename waits until main has done fs.rename, moved the
note's comments and rewritten every inbound [[wikilink]] in the vault.
So the unlink arrived first, applyChange read it as a deletion and
closed the tab, and the reply then renamed a tab that was no longer
there. macOS delivers file events late enough that the reply usually
won, which is why this was a Linux report; it reproduced on macOS the
moment the rename had 400 links to rewrite, with the tab gone 300ms
after Enter.

The store now remembers the renames it has asked for: renamesInFlight,
next to lastWrittenByPath, which is the same idea for our own writes.
renameNote adds the old path before the IPC call and removes it in a
finally. While a path is in flight, closeUnlinkedNote treats its unlink
as the rename's own echo (both the live branch and the resync sweep),
and the keep rule in refreshNotes holds its tab instead of pruning it,
so the reply finds the tab where it left it and moves it to the new
name. Every other unlink still closes its note, including one that
arrives after a refused rename.

Three tests pin the event order: unlink and add delivered before the
reply, then the reply moving the tab; a genuine deletion still closing;
a refused rename forgetting its path. The first fails with the guard
disabled. Verified live on the built renderer with 400 inbound links:
the tab stays open through the rename and ends under the new name with
its heading synced.

How to test locally: npm run dev on Linux, or on any platform give a
note a few hundred inbound [[links]], open it, run Rename Note… from
the command palette, type a new name and press Enter. Before: the tab
disappears and the editor goes blank; the renamed file is in the
sidebar. After: the same tab stays open under its new name.

Closes #713
Open a markdown file that lives outside every vault (Finder "Open With",
a double-click, `zn open <file>`) and it comes up in the standalone "Not
in a vault" window, where every link was dead: `[[topic]]` did nothing,
`[README](../../README.md)` did nothing, in edit and preview alike, with
no error anywhere. That is #626, and it held through 2.41.0.

The app's link resolvers are all vault-bound. The preview looks a
wikilink up in the vault's note index, resolves a relative link against
a vault-relative note path, and ends in "open this note in the
workspace"; the standalone window has none of those, and its own editor
never had the main editor's link-follow handler at all. A loose file
does have a directory, though, which is the base its author meant.

So the window now hands links to the host. `resolveStandaloneLink`
takes a relative or absolute href from the file's own folder, adding
`.md` when a link leaves it off, and finds a `[[wikilink]]` by name in
that folder or below it: four levels deep, dot-folders and node_modules
skipped, the shallowest match wins, alias and anchors stripped, and
Obsidian's path-like `[[sub/page]]` taken relative to the file. The IPC
handler resolves against the path main already holds for that window,
never one the renderer supplies, and routes a markdown target through
the Finder opener, so it lands in its vault when it has one and in a
standalone window of its own otherwise; any other file opens with the
OS, and a miss comes back as a reason the window shows in its banner.

In the renderer, `standaloneLinkForAnchor` reads the target the
markdown pipeline stamps on a wikilink and judges everything else by
href shape, keeping web and mail links in the browser and in-page
anchors with the browser's scrolling. ExternalFileApp intercepts preview
clicks in the capture phase, ahead of Preview's vault-bound handler,
gives its editor the same mousedown rule as EditorPane (a plain click
follows a rendered link in live preview, Cmd/Ctrl-click follows any
link, #201) and maps `gd`. `pointerOverRange` moves out of EditorPane
into lib/cm-pointer-range.ts so both editors share one copy.

Deliberately left alone: relative images in that window's preview still
resolve through the vault asset path and stay broken. Same shape of
fix, separate change.

Verified over CDP on the built app with the layout from the report
(proj/README.md, proj/docs/wiki/index.md, pages beside and below it):
in edit mode, plain and Cmd-clicks opened topic-name.md, the converter
page through its alias, README.md two levels up and the report in a
subfolder, each in its own window; preview mode did the same on a fresh
launch; a link to a missing file explains itself in the banner. The
resolver has 11 tests on a real temp tree, the classifier 5.

How to test locally: outside any vault, make proj/README.md and
proj/docs/wiki/index.md containing `[[topic]]`,
`[README](../../README.md)` and `[report](out/REPORT.md)` with the
targets present, open index.md from Finder or with the file as an
argument, and click each link in edit and preview. Before: nothing
happens. After: each target opens in its own window.

Closes #626
…tions are written down

On Linux and Windows, where Mod is Ctrl, pressing Ctrl+P inside a
Vim-mode editor moved the cursor up and the Search Notes palette never
opened; Ctrl+N moved down and New Note Here never fired. Meanwhile
Ctrl+F opened CodeMirror's find panel over Vim's page-forward while
Ctrl+B paged back as Vim intended, and Ctrl+W did nothing visible with
no word anywhere about why. That is #510, in three parts.

The first is a bug. codemirror-vim aliases <C-p> and <C-n> to k and j
and stops propagation of every key it handles, and the app listened for
those two shortcuts in the window's bubble-phase handler, after the
editor. Everything the app already wanted to win over Vim runs in the
capture phase for exactly this reason (pane focus #124, pane modes #579,
the inline-format chords, the jumplist #488); these two were the ones
left behind. They move there now, under the same modal, recorder and
autocomplete guards, with one carve-out so Search Notes keeps closing
the palette it opened.

The second is a gap in the docs. Ctrl+W has been Vim's pane prefix
while a tab is open since 2.4.0, on purpose, with :q and :bd as the
way to close a tab, and the help said "Mod+W closes the tab" without
the caveat. The Global shortcuts help now states the rule: with Vim
mode on and the editor focused, an app shortcut on a Ctrl chord wins
over Vim's, except the chords Vim mode keeps, Ctrl+W (pane prefix),
Ctrl+O and Ctrl+I (jumplist) and Ctrl+D and Ctrl+U (half page). The
shortcut descriptions say the same where each applies.

The third is a judgment call, made in Vim's favour. The find panel's
Mod-f binding was the one motion chord the editor still took from Vim,
and only because CodeMirror's search keymap happens to claim it. With
Vim mode on and Mod resolving to Ctrl, vimAwareSearchKeymap drops it,
so Ctrl+F pages forward like Ctrl+B and / searches, the Vim way; every
other search binding stays. macOS keeps Cmd+F because it never
collided, and Vim-off keeps the panel everywhere. Linux and Windows Vim
users who reached the find panel with Ctrl+F now get page-forward
there; the release notes say so.

Verified live before and after, by stashing the fix and rebuilding. A
browser user-agent override does not flip the app's Mod key (the app
asks the host for its platform), so the Linux collision was staged on a
Mac by rebinding the two shortcuts to Ctrl+P and Ctrl+N. Before: each
Ctrl+P moved the cursor up a line and nothing opened, Ctrl+N created
nothing. After: Ctrl+P opens the palette and a second press closes it,
Ctrl+N creates the note with its title focused, Cmd+F still opens the
find bar with Vim on. The Ctrl+F paging half is covered by the keymap
unit tests, not driven live.

How to test locally: on Linux or Windows, Vim mode on, click into a
note, press Ctrl+P. Before: the cursor moves up. After: the search
palette opens, and Ctrl+P again closes it. Then Ctrl+F. Before: the
find bar. After: the view pages forward, and Ctrl+B pages back.

Closes #510
@adibhanna
adibhanna merged commit a323265 into main Sep 2, 2026
7 checks passed
@adibhanna
adibhanna deleted the v2.42.0 branch September 2, 2026 15:27
@adibhanna
adibhanna restored the v2.42.0 branch September 2, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment