Skip to content

Release 2.53.0: the settings question asked per setting, an update check that waits for the network, Favorites on the home view, and zn open -n - #818

Merged
adibhanna merged 12 commits into
mainfrom
v2.53.0
Sep 19, 2026
Merged

adibhanna merged 12 commits into
mainfrom
v2.53.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

ZenNotes 2.53.0

Seven reports from the issue tracker plus two that came in through the Android repo and are fixed in the shared core here. Three from @uNyanda (#816, #812, #813), one each from @naingyeminn (#817), @vlcinsky (#814), @radiorambo (#815) and @saran-ncsu (#810); zennotesandroid#74 from @Alastor1991 and zennotesandroid#78 from @uNyanda. Every issue is closed with a commit-linked comment.

Features

Fixes

Verified before this PR

  • npm run typecheck 7 of 7, nothing from cache; npm run test:run: shared-domain 1,683, app-core 2,542 (1 skipped), desktop 886 (4 skipped), nothing from cache.
  • cd apps/desktop && npm run build:prod fresh, then npm run pack; the packaged app launched with both stores isolated: CDP page target in 1.5 s, version 2.53.0, the bundled zn inside the app is 0.4.0 from ZenNotes/tui 6481d17.
  • Smoke against the release build: test:vim-editor, test:sidebar-vim, test:editor-improvements, all green with no renderer console errors.
  • Every fix reproduced on the previous build first and verified in the built app over CDP with real key events; two captioned clips recorded (Show the Cloud Vault Settings Conflict Prompt Immediately When Opening a Synced Vault #816, Update Checker Does Not Resume After Network Connection Is Restored #812).

Docs: in-app help updated in the commits; the website mirror is the branch website/docs-2.53, merged with the release.

adibhanna and others added 12 commits September 18, 2026 12:28
…ve a settings save

The main process rewrites vault.json through a normalizer that lists
the view keys it keeps, and three keys added later were never put on
that list: assetSortOrder, kanbanFolderRoot and kanbanStatuses. Any
settings save from the desktop (a new favorite, a folder icon) dropped
them from the file, so the renderer's per-vault choice silently never
stuck.

They are now carried through, kanbanStatuses reduced to its string
entries. The comment on the normalizer says that every
VaultViewSettings key must be listed, since the type cannot enforce
it. Found while writing the settings merge for #816, which saves
through the same path; the round-trip test gains a case for the three
keys.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
Co-authored-by: Amp <amp@ampcode.com>
…setting (#816)

When this device and another one both changed a vault's settings since
the last sync, sync parks the cloud's copy as
.zennotes/vault.cloud-conflict.json and keeps this device's in use.
Until now the only place that said so was a card in Settings > Cloud
with two whole-file buttons, so the question sat there until the user
happened to look, and answering it meant choosing between "my
favorites" and "their folder icons" when they wanted both. @uNyanda
hit exactly that (#816).

The question now opens on its own right after the sync that found it.
It lists each settings section that differs, this device's value beside
the cloud's, down to the field that changed, and offers This device or
Cloud per section, plus one button for all. Keys the cloud's file
carries that this build has no setting for are listed under "Not used
on this device" and never applied. Decide later applies nothing: the
status bar reads "Settings need review" until answered, and Space r,
the Review Cloud conflicts palette command or the status bar's Review
brings the question back, the file queue first when both are pending.
The card in Settings > Cloud stays, with a "Compare and choose…" button
into the same dialog; its two whole-file answers behave as before.

The pure core lives in shared-domain (vault-settings-conflict.ts):
diffVaultSettings, mergeVaultSettings and unknownVaultSettingsKeys,
with SECTIONS typed as Record<keyof VaultSettings, true> so a new
settings key fails to typecheck until it is added to the prompt or
knowingly left out. The desktop's settingsConflict() now includes the
parsed parked file as cloud_settings, optional in the bridge contract:
a host without it keeps the whole-file question, and a parked file
that does not parse is still reported. store.setVaultSettings resolves
to a boolean so callers can tell a refused save from a completed one.
The Cloud demo fixture's /demo/arm also bumps the cloud's vault.json
so the flow can be reproduced locally.

Verified in the built app over CDP against the fixture, both stores
isolated: the prompt opened on its own, Decide later left both files
untouched, Space r reopened it, and Cloud for two sections plus Apply
produced exactly that mix in vault.json with the parked copy gone.
Not exercised live: the web client and the mobile shells.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
Co-authored-by: Amp <amp@ampcode.com>
…ain by itself (#812)

The startup update check runs once, 8 seconds after launch, and never
rescheduled itself. Launch before the network is up and that one check
failed, Settings > About read "Update error", the "update needs
attention" toast fired, and nothing tried again until the user pressed
Check for Updates. On the package-manager path the message was just
"fetch failed": undici keeps the real cause (ENOTFOUND, ENETUNREACH)
in error.cause. @uNyanda reported it from an AUR install (#812).

A check that cannot reach GitHub at all is no longer an error. The
updater enters a new offline phase ("Waiting for network" in About,
the cause named in the message, no toast) and waits: net.isOnline() is
read every 15 s without making a request, and the check runs again on
a down-to-up transition. When the link is up but GitHub still cannot
be reached (a captive portal, a router with no WAN) it retries 30 s
after the failure, doubling up to every 15 minutes, and it makes a
real attempt at least every 15 minutes whatever isOnline() says, so a
notifier that is wrong about this machine cannot silence the check.
Any phase other than offline or checking ends the wait, so a manual
check owns its result. electron-updater's own error event is ignored
for network-class errors so the state never flashes error before the
check's catch decides. Errors that are not the network (a 404, a bad
signature) still show as errors, now with the most specific line of
the cause chain instead of "fetch failed".

isNetworkUnreachableError walks message, code and cause five levels
deep for the Node, undici and Chromium net::ERR_* spellings, including
undici's bare "fetch failed". The bridge contract gains the offline
phase; a host that does not know it shows no badge, notice or action.

Verified with fake timers and a mocked net.isOnline (link down: no
request until the link is back; link up: retries at 30, 60 and 120 s;
the 15-minute attempt; a 404 does not wait; a manual check while
waiting), and live in the built app over CDP with the feed on a closed
local port: "Waiting for network (connect ECONNREFUSED ...)" and no
toast, then, with the feed up and nobody touching the app, "Update
available" 25 s later. Not exercised live: the electron-updater path
and a real link transition.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
Co-authored-by: Amp <amp@ampcode.com>
…d of replaying it (#813)

Delete Untitled.md on the desktop while the Trash already holds an
older Untitled.md and the note is trashed as trash/Untitled 2.md, a
move that changes folder and name in one step. Trash is synced, so the
phone had the older trash/Untitled.md too, and its Storage Access
Framework plugin did such a move as "move the document, then rename
it". The move step keeps the source name, so Android refused the
intermediate trash/Untitled.md with "rename failed: Already exists".
@uNyanda reported the sync stuck there for good, the deleted note
coming back on the phone with every Sync now (#813).

The plugin's ordering is fixed in the Android repo (rename first, in
the source folder, then move). What this commit fixes is why the
failure kept coming back. pullChanges saved its cursor only after a
whole batch had been applied, but the files a change touched stayed
changed when a later change threw. Every retry then replayed the
earlier upsert of Untitled.md from the old cursor, re-creating the
note the user had just deleted, and failed again on the same move.

The loop now keeps a second state, the newest one that can be
persisted on its own, and saves it before letting the error escape,
so a retry resumes at the change that failed. That state only
advances when no coalesced upsert is still waiting for the change
that lands it: a skipped revision is reduced into the live state but
never written (#661), and persisting past it would make this device
vouch for bytes it does not have. The success path still saves the
final state once; the conflict branches are untouched. For the
reporter this alone turns the loop into a clean retry: delete the
note on the phone once more, Sync now, and the move and delete become
no-ops without a source.

Verified with two coordinator tests: the #813 sequence (an upsert
then a move that throws; the cursor lands on 2, not 1, and after the
local delete the retry succeeds with cursor 4 and re-creates nothing),
and a guard that progress is never persisted past a coalesced revision
whose landing change has not run, which fails against a naive
save-after-every-change implementation. Not driven against a live
phone.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
…d still rolls over (#817)

Open today's daily note with "Roll over unfinished tasks to today" on
and nothing to roll yet, then type a task into yesterday's note and
open today's again: the task stayed where it was until the next day.
@naingyeminn reported it as the setting doing nothing at all (#817),
which is what it looks like when today's note is always opened first.

The rollover kept a once-per-day marker in localStorage, written after
every run including the ones that found nothing, so the first open of
the day locked the feature for the rest of it. The marker existed to
avoid re-reading years of daily notes on every open, and that cost is
real, so it is not simply dropped. Its replacement is a per-vault
record of the past daily notes already read and found free of open
tasks, each with the updatedAt:size signature of the listing that was
scanned. A note edited since, typed in this app, changed by sync or
another editor, stops matching and is read again; a note with an open
buffer is always read from the buffer, which costs nothing; a note the
rollover just trimmed is read once more before it is trusted, so the
trim itself can never hide a task. The command-palette run ignores the
record and reads everything, as it ignored the marker before.

Today's note now takes the tasks before the sources give them up, so a
failure midway leaves a task in two notes rather than in none.

extractOpenTaskBlocks also matched on lines split at "\n" alone, so a
Windows file's trailing "\r" defeated the "$" in TASK_LINE_RE: its
tasks showed in the Tasks view, whose scanner normalizes endings, and
never rolled over. Matching now runs on "\r"-stripped copies while the
remaining body keeps its endings byte for byte. Whether the reporter's
files are CRLF is not known; the failure is silent and belongs to the
same feature.

Verified with a store test that walks the reported sequence (first
open moves nothing, a second unchanged open reads no past note, the
task typed in between rolls on the next open, the trimmed note is read
once more) and a CRLF case in tasks-rollover.test.ts, both red on the
old code, and in the built app over CDP with the reporter's steps.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
In Vim mode, `:version` printed "Codemirror-vim version: 6.3.0" in
red and nothing else. That is the stock ex command of the Vim library
ZenNotes embeds, and it answers a question nobody asks: the library's
version is fixed by the ZenNotes build, and the line says nothing
about the build. @vlcinsky asked for the ZenNotes version and the
details a bug report needs (#814).

`:version` (and `:ve`) is now ZenNotes' own. It prints the ZenNotes
version and host kind, the operating system and architecture, the
engine (Electron with its Chromium and Node, or the browser's user
agent on the web client), how this copy was installed (macOS app
bundle, Mac App Store, NSIS installer, portable exe, AppImage, deb,
rpm, pacman, or a package manager or tarball install), and, when the
window is connected to a remote workspace, the server's version and
address. `:version copy` or `:version!` also puts the lines on the
clipboard. The text is shown in the editor's own color for 15 seconds:
red is what codemirror-vim uses for errors, and this is not one.

Settings > About shows the same lines under the version, as a Version
details block with a Copy details button, so a user who does not use
Vim mode has the same report one click away. One function,
lib/version-report.ts, builds the lines for both surfaces, and leaves
out any field the host did not fill in instead of printing "unknown".

The OS version and install format only main knows (os-release,
resourcesPath, app.isPackaged), so the preload asks for them over one
synchronous IPC, cached after the first getAppInfo() call, since that
call runs many times at boot for its runtime field alone. The Linux
install label reuses the updater's own format detection, so what
`:version` prints is the format the updater acts on. Nothing in
describeInstall() may throw: the preload asks while the window boots.

The codemirror-vim version line is gone on purpose: the library does
not expose it at runtime, and the ZenNotes version pins it anyway.

Verified with unit tests for installLabel and osReleasePrettyName
(desktop updater.test.ts) and buildVersionReport (app-core), and in
the built app over CDP: `:version` prints the four lines, `:version
copy` puts them on the clipboard, `:ve` resolves to this command, and
the About block matches the ex output byte for byte.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
…first (#815)

`zn open <folder>` bound to a key, a window on that folder already up
in another workspace, and the app raised that window: focus jumped back
to the workspace the user had just left, tabs and all. @radiorambo
wanted what Chrome's `-n` gives: a fresh window on the same notes beside
the new workspace, with its own tabs, the first window left where it was
(#815).

`zn open -n <path>` (or `--new-window`) does that now, in both the
bundled Node CLI and the Go `zn`. The CLI hands the app a `--new-window`
argv switch ahead of the paths. Chromium ignores switches it does not
know and Electron does not define this one, so it reaches
`second-instance` untouched, and the app's path collector already skips
`-`-prefixed entries, so an older app raises the existing window as
before. On a cold start the switch changes nothing: there is no window
to reuse yet.

What "a second window" means depends on what the path is:

- A vault a window has open for real: a second real vault window on the
  same root, exactly what "Open Vault in New Window" does. A temporary
  session on that root is not an option, because the ephemeral registry
  is keyed by root and never unregistered, so it would silently switch
  the first window's workspace-state and settings writes off and send
  its deleted notes to the system Trash.
- A folder that is not a vault: a second ephemeral session window.
- A note inside a vault: a new vault window with the note queued.
- A markdown file outside every vault keeps reusing its window even
  with `-n`: nothing keeps two standalone editors of one file in sync,
  so the second would overwrite the first's saves.

The flag also needed a parser fix, since `zn open --new-window ~/notes`
parsed as new-window="~/notes" with no path. `VALUELESS_FLAGS` in
cli/args.ts lists the long flags that are switches, and the parser never
takes the next token as their value (`--flag=value` still works). Every
flag on the list was read only through getBool(), so nothing else
changes, and `zn delete --yes inbox/a.md` stops swallowing its path too.
The Go CLI's parser mirrors the list.

Help: the `zn --help` OPEN row names the flag with an example, and the
in-app manual's CLI section gains a card for opening a second window.

Verified with unit tests (switch detection, switch-before-positional for
every valueless flag, the argv the CLI spawns) and in the built app over
CDP with isolated stores: `-n <vault>` gives a second real window on the
same root while the first keeps its note; `-n <vault>/Ideas.md` opens
the note in a third window with the first untouched; a plain folder
reuses without `-n` and gets a second session window with it; closing
the second window leaves the first intact. The Go `zn open -n` was run
end to end against the same app.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
…from anywhere (#810)

@saran-ncsu asked for a list of pinned or favorite notes to open from
without keeping them around as tabs, and on the phones, which open
Recent from the home screen, that same list (#810). ZenNotes already
had Favorites: the sidebar section that `Space l s` and the row's
context menu fill, stored in vault.json so it travels with the vault
and with sync. What it lacked was a place to see them when no note is
open, and a way to favorite a note that does not go through the
sidebar.

The home view now shows them. A Favorites section sits right after
Recent, in the order the user arranged, with the icon and color the
sidebar gives each entry and, for a note, where it lives ("Projects /
Alpha"). A favorited note opens in place. A favorited folder opens that
folder in the note list, brings the sidebar back if it was hidden and
expands the folder and its ancestors in the tree first: folders start
collapsed, and in the unified sidebar the tree is the only place the
folder's notes appear, so a highlighted collapsed row would look like
nothing happened. j/k (Vim mode) and the arrows walk from Recent into
Favorites and Enter opens. The section stays out of the way until
something is favorited, and a favorite whose note is gone (trashed,
moved away) is skipped, as in the sidebar.

Favoriting gains two routes. The command palette has "Add Note to
Favorites" / "Remove Note from Favorites" (`note.favorite`; the title
follows the active note's state, `when` hides it for trashed notes,
the shortcut is resolved from the `vim.leaderToggleFavorite` chord).
Hosts get `requestToggleNoteFavorite(host, path)`, wrapped in the usual
`requestNoteAction` staleness guard, and `ShellSnapshot.favorites` (a
frozen array, rebuilt only when the contents change so a folder-color
save does not wake subscribers), which is what the phone shells use to
put an "Add to Favorites" row in their ••• sheet and long-press menu,
next to Pin. Pin stays the phone's own list order; Favorites is the
vault's. The shell halves live in the phone repos.

`resolveFavoriteItems(favorites, notes, folders)` moves out of
Sidebar.tsx into lib/vault-layout.ts, and both the sidebar and HomeView
read the same list through it, so a stale key can never show on one
surface and not the other. Home stamps `data-home-section` (recent,
favorites, today) on its sections and `data-home-favorite` (note or
folder) plus `data-home-note-path` on rows; the phone shells' CSS and
their row-swipe resolver key off these instead of counting sections
and resolving Recent rows by position, which is what they did before.
Pinned tabs are unchanged. The in-app manual's home-view card and the
`Space l s` entries name the new surface.

Verified with unit tests (vault-layout, commands, note-actions,
public-host-api) and in the built app over CDP with isolated stores: a
note favorite, a folder favorite and a deleted note's key resolve to
two rows with the deleted one skipped; j moves past Recent onto the
first favorite and Enter opens it; the folder row restores the hidden
sidebar and expands the collapsed folder; unfavoriting removes the row
at once; a vault without favorites shows no section. The phone
stylesheet was checked against this build and against a core with no
section markers; the Android shell was driven on the emulator and the
iPhone shell with an XCUITest.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
…g mode (android#74)

Reported from the Android app by @Alastor1991, where Reading is often
the default view (ZenNotes/zennotesandroid#74): tap `[[Note#Heading]]`
and the target note opened in edit mode with the cursor on the heading,
keyboard and all. The desktop did the same. Every jump inside a note
(`[[Note#Heading]]`, `[[Note#^block]]`, a same-note `[[#Heading]]`, a
search hit, Ctrl+O / Ctrl+I) went through one path written for the
editor: set the cursor, then switch the pane to edit so the cursor
could show.

A pane in reading mode now plans the jump itself. `planPreviewJump(jump,
body)` in lib/preview-outline-jump.ts is the pure decision: a jump
carrying `highlightLine` (a task opened from the Tasks view) still goes
to edit, on purpose, since the highlight it paints on the source line
is something only the editor can show; the `editorScrollMode:
'preserve'` shape that Ctrl+O / Ctrl+I (the phone's Back) send restores
the reading view to the `previewScrollTop` it had when the user left,
rather than jumping to a line; everything else becomes a line, taken
from `editorSelectionAnchor`, and the rendered block for that source
line is scrolled to the top of the reading view. A note the user last
left in edit mode still opens in edit mode with the cursor on the
heading; nothing changes for it.

Two details keep the scroll honest. `previewShowsNote(el, path)` guards
against scrolling the previous note's blocks: Preview.tsx stamps
`data-note-path` on the article in the same step as the DOM swap,
because the render is asynchronous and the effect can run before it.
And a line plan whose blocks are not rendered yet is parked in
`pendingPreviewLineRef` (the #543 cursor-line ref, renamed; same
`onRendered` consumer), which is reset on note change before the
pending-jump effect runs, so a line the previous note never got to
render cannot fire on a later visit.

Verified with 14 cases in preview-outline-jump.test.ts (heading, block
and same-note links, a search hit, the task jump, the restore shape)
and in the built app over CDP with isolated stores and default mode
Reading: clicking `[[Reference#Deploy]]` from a scrolled Guide keeps
reading mode with the Deploy heading at the top; Ctrl+O returns to
Guide in reading mode within 40 px of where it was; a same-note
`[[#Appendix]]` and a `[[Reference#^gotcha]]` block link both land the
target at the top; a note remembered in edit mode still puts the cursor
on the heading.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
…s (android#78)

@uNyanda (ZenNotes/zennotesandroid#78): Settings → Templates → edit
showed the editor and the preview side by side at any width, which on a
phone left each about half a screen wide, too narrow to write in or to
read.

The two panes now follow the viewport. From 768 px up (the app's `md`
breakpoint) they share a row as before, each 60vh tall. Below it the
editor sits on top at 38vh and the preview under it at 22vh, each
scrolling on its own, and the row of variable chips scrolls sideways
(`flex-nowrap overflow-x-auto`) instead of wrapping into several lines
that pushed the editor down. Viewport heights are the accepted
exception to the no-arbitrary-values rule; no new tokens. The dialog
itself, its header, name field and buttons are unchanged, so a narrow
desktop window gets the stacked layout too.

Verified in the built app over CDP, reading the layout from the DOM at
1280 px (two grid columns, 60vh each, chips wrapping) and at 390 px
(one column, editor 38vh over preview 22vh, chips in one scrolling row,
dialog still centered).

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
The desktop's managed `zn` install still pinned ZenNotes CLI 0.2.0, so a
2.53.0 build would have shipped a `zn` that raises the existing window on
`zn open -n` instead of opening a second one (#815). The pin now names
0.4.0, released today from ZenNotes/tui `6481d17`: the source commit, the
four macOS and Linux archive URLs and their SHA-256 digests, each checked
against the release's checksums.txt and against the downloaded archives.

Verified through `npm run terminal:stage`, the same download, checksum and
`--desktop-integration` probe 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.0. `test:terminal` 12 of 12.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4e0-a062-700d-8add-505c3d057740
@adibhanna
adibhanna merged commit 3a62263 into main Sep 19, 2026
9 checks passed
@adibhanna
adibhanna deleted the v2.53.0 branch September 19, 2026 01:43
@adibhanna
adibhanna restored the v2.53.0 branch September 19, 2026 01:43
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