Skip to content

ZenNotes 2.77.0 - #996

Merged
adibhanna merged 16 commits into
mainfrom
v2.77.0
Oct 26, 2026
Merged

adibhanna merged 16 commits into
mainfrom
v2.77.0

Conversation

@adibhanna

@adibhanna adibhanna commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

ZenNotes 2.77.0, the release PR: everything on v2.77.0, with What's New (9ab0f1c7) and the version bump (69b6b5ff). The phone apps pick up the core changes once core 2.77.0 is published.

Features

  • Every unresolved link in one list, and a popover to write the note in place (uNyanda on Discord, f69b5fa5): the Unresolved Links view (Space u, :unresolved, the palette, a row under the Connections panel's missing links) lists each [[link]] no note answers, most linked first, with the linking notes and lines. The link popover opens on hover, K or Shift+Mod+K and edits the real note in place; a missing note gets Create note. wikilinkReaches is now the one "is this link broken" rule for the editor, table cells and the view. New portable pref [editor] link_popover_on_hover.
  • The Tags view lists the tagged blocks inside each note (Allow Tagging for Individual Blocks #837, 69f34c0f): list items with their children, paragraphs, quotes, table rows, and a heading's whole section; Enter jumps to the line with a highlight and records the jump; } / { hop notes. Blocks are read on demand through readNote (tag-block-index.ts), so local, remote and web vaults all work.
  • A #tag in Search notes lists each note's tagged blocks (Allow Tagging for Individual Blocks #837, 878225d3): two per note, then "+N more" into the Tags view; free text narrows the blocks. New portable pref [search] tagged_blocks, on by default.
  • The phone calendar opens from Home (Dave on Discord, 95f48cde): the core part; the phone shells are on feat/calendar-entry in their own repos.

Fixes

  • Vim mode (Some issues with Vim mode #993, 61eac765): Ctrl+A increments in Normal mode on Linux and Windows (CodeMirror's Mod-a defers to Vim outside insert mode); Ctrl+J/K in Settings follow the order the list shows; l on a folder keeps the sidebar cursor there instead of jumping to the open note.
  • The sidebar Vim cursor stays on its row when rows renumber, e.g. after an outside write (37a68a33).
  • The first outside write after a folder click no longer pulls the sidebar cursor to the open note (Some issues with Vim mode #993 follow-up, 3f0356de): the reveal remembers the open note while the sidebar is unfocused too.
  • A link in a list, heading or emphasis keeps its link color (03df3dde); a link to a missing note looks unresolved in a table cell (fa200ea3).
  • Code in a note saved with Windows line endings stops showing up as tags (7afe7a3b).
  • Cloud: a conflict review opens while a sync is running, shows Retry with Details after 20 s, and keeps Finish later working (yeminn on Discord, dc4210a6); a phone notices a stalled request in 30 seconds, not 300 (4f50b8ca); a pulled change costs the same in a vault of any size (0000cc4c).
  • Phones: a note moved to Trash leaves you on Home, not in an old note (Dave on Discord, 4050388f).

Verification, release gates on 3f0356de in a clean worktree with its own npm ci: audit (high) clean; typecheck 8/8 and test:run uncached (shared-domain 2,145, app-core 3,541, desktop 1,194, quicklook 15); fresh build:prod; signed npm run pack and the packaged launch check (page target, rendered, 1.5 s, 2.77.0); vim-editor, sidebar-vim and editor-improvements smoke suites pass (editor-improvements needed one rerun: What's New opened after the suite's 4 s dismiss window while the machine was loaded and took a Space keystroke, reproduced and explained in the release state). Earlier, on f69b5fa5: typecheck 8/8 and test:run uncached (shared-domain 2,145, app-core 3,541, desktop 1,194, quicklook 15). CDP smoke on the built app: sidebar-vim 13/13, tag-blocks 43/43 (Tags view, Search palette, the setting, Vim on and off), Unresolved Links and the popover with Vim off 19/19, plus the Vim-on flows. #993 was also checked on Linux (Ubuntu 26.04 under Xvfb on beelink) against the released 2.76.0 tarball. Captioned demo clips for the features, Vim on and off, are in the local release media folder.

Outside this PR: the website docs and release page (branch website/docs-2.77), and the phone releases (release/1.33.0 in zennotesios with #76 and #77, release/1.1.47 in zennotesandroid with #133 and #134, both with feat/calendar-entry).

…ace (#993)

Three Vim mode reports from one issue.

Ctrl+A selected the whole note in normal mode on Linux and Windows
instead of incrementing the number. CodeMirror's defaultKeymap binds
Mod-a to select all, and Mod is Ctrl there; the keymap's handler runs
ahead of the Vim plugin's (measured in the built app's plugin order),
so Vim never saw <C-a>. Ctrl+X, its decrement, has no binding and
always worked. In Vim mode off macOS, Mod-a now defers to Vim outside
insert mode, the way the arrows and Enter already do (#287): normal and
visual mode increment, and insert mode keeps select all. macOS, where
select all is Cmd+A, and Vim off are unchanged.

In Settings, Ctrl+J/K and the arrows walked the categories out of
order. The list shows them grouped by section, but the keys step
through the results list, which followed the order the categories are
defined in (Appearance, Editor, Keymap, Tasks, Typography, and so on).
The results are now built from the categories in the navigation's
order, so the keys and the list agree, and a search lists its matches
in that order too.

In the sidebar, the first `l` on a collapsed folder moved the Vim
cursor to the open note. Opening a folder clears selectedPath, and the
expand prefetches the folder's notes; the prefetch, like most store
updates, rebuilds the active fields from the open tab, which puts
selectedPath back. The Sidebar's reveal effect read that as a new
selection and moved the cursor onto the note. A second expand found
the notes cached and nothing happened, hence "only the first time",
and a vault small enough to be prefetched at boot never shows it. The
reveal now moves the cursor only when the selection really changes
(sidebarSelectionChanged): the last note counts through the gap a
folder selection leaves, and a re-run for the same selection, such as
a listing refresh, leaves the cursor where the keys put it. Focusing
the sidebar still lands on the current item.

What this does not fix: the cursor is a render-order row number, so a
refresh that adds or removes a row numbered before it (a file written
from outside the app) still shifts it onto a neighbor. That needs the
cursor to track its row, and is left for its own change.

On Linux (beelink: an Ubuntu 26.04 container under Xvfb, driven over
CDP), against the released 2.76.0 tarball and a tarball of this branch,
same vault and keys: Ctrl+A in normal mode selected all before and
turned 41 into 42 after, and insert mode selected all in both; Settings
Ctrl+J went Appearance, Editor, Keymap, Tasks, Typography, Cloud, Vault
before and followed the list after; `l` on a 30-note folder moved the
cursor to the open note before and kept it on the folder after, with
`j` stepping into the folder. The sidebar and Settings checks also pass
on the macOS dev build.

Tests: Ctrl+A dispatched through the keymap and Vim in the editor's key
order (fails on the old keymap), the selection comparison, and the
navigation ordering. app-core 3,445 tests and the sidebar Vim smoke
test pass; typecheck clean.
)

uNyanda jots ideas down to develop later and tags them, several
unrelated ideas to a note. Searching a tag returned the whole note, so
finding one idea meant reading through all of them. Tags were a flat
per-note list (NoteMeta.tags), and nothing recorded where in a note a
tag sat.

Opening a tag (a sidebar tag, a #tag clicked in a note, :tag) now lists
each matching note with its tagged blocks under the title: the list
item, paragraph, quote or table row that carries the tag. A list item
brings its nested items along, and a tag on a heading tags its whole
section, shown as the heading with the first lines under it. Enter, o
or a click on a block opens the note at that line with the line
highlighted, recorded as a jump so Ctrl+O comes back; Enter on the note
row opens the note at the top as before. The header counts both, and
the filter narrows to the blocks whose text matches. A title match
keeps all of a note's blocks; an excerpt match keeps only the note row,
since the excerpt is the opening of the body and letting it keep every
block undid the narrowing whenever the idea sat near the top. With Vim
mode on, } and { hop from note to note past the blocks (tags.nextNote /
tags.previousNote). Not ] and [: VimNav takes [ as the prefix of
[b / ]b from every panel, and claiming it here would break buffer
switching from the Tags view. With Vim off they do nothing, as the
other single keys there.

The block scan is new pure logic in shared-domain (tag-blocks.ts). It
reads tags by the exact rules of the note-level scan, so a block lists
for a tag only when that scan counted the tag from that line: the same
frontmatter cut, the same code mask, the same pattern. To share the
mask without a sixth copy of stripCodeContent, app-core's copy (with
extractTags) moved to shared-domain/tag-scan.ts and app-core re-exports
it. There are still five copies; the cross-references in the others
now name the new home (the Go copy in ZenNotes/znserver still names the
old path). Block structure is read from the lines scanMarkdownLines
owns, so a fence inside a list item stays part of the item and a #
line inside a fence never ends a section. CRLF bodies lose the CR
before LF first, so line numbers do not move.

The occurrences are indexed on demand, not at boot. NoteMeta.tags
already says which notes carry a tag, so the Tags view reads only the
notes it lists, through the readNote bridge every host implements, and
caches each note's blocks stamped with its updatedAt and size; a
watcher update changes the stamp, so a stale entry is never served.
Nothing is read while the view is closed, and a note open in the
editor is read from its buffer, so a block typed a moment ago lists
right away. Because readNote is the only dependency, local vaults,
remote workspaces and the web client all get blocks with no new IPC,
and the Go server and MCP are untouched.

Deliberately unchanged, as Adib decided: the workflows `tag #x`
condition stays note-level; the search palette stays a note finder;
frontmatter tags tag the whole note, so a note tagged only there stays
a plain row; block rows are always shown, with no new setting.

openNoteAtOffset gains a highlightLine option, the line flash task
jumps already use.

Verified over CDP on the built app (tooling/scripts/tag-blocks-smoke.mjs,
isolated userData and config dir, Vim on and off, 24 checks): blocks
under their notes with code ignored and the frontmatter-only note a
plain row; j, } and { over the rows; Enter landing on the highlighted
line; Ctrl+O back to the view; a freshly typed block listing; the
filter; Vim off leaving } and j inert while the arrows and Enter work;
no console errors. A tag on 500 notes lists all 500 blocks about 340 ms
after Enter. Perf harness (5000 notes, 2 runs before and 3 after): note
switch 33.7 vs 33.4 ms, virtual scroll 34.1 vs 34.0 ms, heap 21.3 MB,
no long tasks, no budget failures; main-process listing read 422 to
428 ms before and 432 to 517 ms after, back to 432 ms on a third run,
so noise on a path this change does not touch.

Tests: block extraction (list items and children, continuation and
lazy lines, paragraphs, heading sections, quotes, table rows, code
ignored, frontmatter staying note-level, CRLF, the jump offset) and
the Tags view rows and note hops. shared-domain 2,091 tests, app-core
293 files and desktop 75 files pass; typecheck clean. Docs: the in-app
manual (a "Tags find the idea, not just the note" card and the Tags
keys) here; the website mirror lands separately.
A resolved [[wikilink]] or [text](url) inside a list item rendered in
the list's body color, cream on Gruvbox dark, with only the faint link
underline left to mark it. After creating a note from a link in a list,
the link looked unresolved. Headings, *emphasis* and **strong** did the
same, and so did quotes in source mode. Paragraphs, quotes and callouts
in Live Preview were fine.

It came with 5d10a36 (v2.50.3), which stopped undefined bracket text
such as [EE] from looking like a link. That change took the tok-link
class off the highlight span and put it on a mark decoration wrapped
around it, applied only to links that resolve. Before, the label span
inside a list was `tok-list tok-link`, and `.cm-editor .tok-link`, later
in the stylesheet at the same specificity, won. Now the span is
`tok-list tok-link-syntax` inside the mark, and its own `.tok-list`
color beats the color the mark only passes down.

One rule gives the label span back the accent:
`.cm-editor :where(.tok-link) .tok-link-syntax`. The :where() keeps it
at the specificity the old span-level class had, and it sits where
`.tok-link` sits, so the cascade is the one from before 2.50.3: the
block colors declared before it lose, and the link brackets, titles and
labels declared after it keep their own colors. Broken links keep their
grey and dashed underline (a more specific rule), [EE] gets no mark and
stays prose, and the gray completed-task style still wins.

Not changed: plain list, heading and emphasis text look as before, and
a broken [[link]] in a table cell still renders as resolved. That is the
table widget rendering cells without a resolver, a separate fix.

Verified over CDP on the built app (isolated userData and config dir):
on Gruvbox dark, links in a bullet, ordered, task and nested list item,
H2, H6, emphasis and strong measured cream (212,190,152; 221,199,161 in
H6) before and the accent (231,138,78) after, matching the paragraph;
the same on GitHub light (31,35,40 before, 9,105,218 after) and in
source mode, where the brackets keep 216,166,87. Broken links stayed
grey with a dashed underline, plain list text and [EE] stayed prose.

Tests: link-label-style.test.ts pins the rule's place in the cascade
(fails without it, or if it moves before the block colors or after the
link syntax colors). app-core 3,457 tests pass; typecheck clean.
In live preview, a [[wikilink]] to a note that does not exist is grey
with a dashed underline everywhere in the editor, and the reading view
marks it a.wikilink.broken. Inside a table cell it was orange with a
solid underline, the same as a link that opens a note, so the table
gave no hint that a click would create one. The table widget renders
idle cells through renderInlineCell, which emitted every wikilink as
a.wikilink without asking whether it resolves, and the cell styles
color every anchor with the accent.

renderInlineCell now takes an optional resolver and gives a link that
reaches nothing the reading view's broken class. The widget passes
wikilinkResolves, the editor's own memoized decision (a note, a spot in
this note, a .base database or a vault file), now exported from
cm-wikilink-render, so a cell and a paragraph cannot disagree. Called
without a resolver it renders as before. A new rule draws
.cm-table-cell a.wikilink.broken with the colors of .cm-wikilink-broken.

A link's fate also changes while the note stays open: the notes list
arrives after mount, or the missing note gets created. The table's
source has not changed, so its widget is not rebuilt; a rebuild would
drop focus and any cell being edited. Instead a view plugin, provided by
tablePlugin itself so every surface that renders tables gets it,
watches the same store inputs as the editor's wikilinks and re-decides
the idle cells' anchors in place. Clicking a link is unchanged: a
broken one still offers to create its note.

In the built app (isolated profile, CDP), a broken cell link went from
rgb(231,138,78) solid, the resolved color, to rgb(146,131,116) with a
dashed rgb(124,111,100) underline, exactly the editor's broken link in
a paragraph; aliased and bold-wrapped links follow it. Writing the
missing note to disk turned it orange with the same table DOM kept. The
resolved link and a web link are unchanged, a light theme matches the
editor too, and no console errors fired.

Tests: the broken class (alias and code span included), turning live in
place and back, re-deciding a cell after an edit, and the click still
offering to create the note. All four fail with the resolver off, and
the in-place one fails without the plugin. app-core 3,459 tests pass;
typecheck clean.
…837)

After the Tags view learned to list tagged blocks, uNyanda asked for the
same where they search most: typing #idea in Search notes still listed
only notes, so the idea itself was another step away, in the Tags view.

With a #tag in the query, each matching note now lists up to two of its
tagged blocks (list item, paragraph, quote, table row, or a heading's
section) and, when it has more, a "+N more tagged blocks" row. That row
opens the Tags view on the query's tags, matching all of them, with the
free text already in its filter, so it shows the blocks the row stood
for (a store hand-off, openTagViewFor plus a one-shot tagViewFilterSeed
the view consumes). Enter on a block opens the note at that line,
highlighted, recorded as a jump so Ctrl+O comes back; Enter on a note row
opens it at the top as before. Arrows, Ctrl+J/K and Ctrl+N/P walk every
row with Vim on or off. Ctrl+D trashes note rows only: a block row is a
place inside a note, and trashing the whole note from it would surprise.

With several tags, notes still carry all of them (the search decides
that, unchanged), and under each note the blocks carrying any of the
tags are listed, those carrying all of them first. Words after the tag
narrow the blocks to those containing them, the Tags view filter's rule,
and mark the match. A note whose matching block sits deep in its body is
listed too, ahead of title matches: the note search only scores title,
path and excerpt, so that idea would otherwise never show. A note that
matched only by its title stays a plain row.

Without a #tag the palette is exactly as it was. uNyanda also worried it
could feel cluttered, so it is a setting, on by default: "Show tagged
blocks in note search" in Settings, Editor, Search (a new Note search
heading). It is portable, `tagged_blocks` under [search] in config.toml,
listed in PORTABLE_PREF_KEYS and the desktop TOML map. Off, the palette
lists notes only.

Blocks come from the same on-demand index as the Tags view: a bare tag
query reads only the listed results; with free text it reads the notes
carrying the tags, cached by updatedAt and size. The rows fill in after
the notes, so the cursor follows a row's key rather than its position
and does not drift while they arrive. Reads now go through a promise, so
a host without readNote rejects instead of throwing inside the effect.
Opening a block and drawing its text moved into a shared helper
(openTaggedBlock) and component (TaggedBlockText) the Tags view uses too;
its filter now marks the matching words in its blocks as well.

Verified over CDP on the built app (tooling/scripts/tag-blocks-smoke.mjs,
isolated userData and config dir, 43 checks, Vim on and off): two blocks
and "+3 more" under the note, a frontmatter-only note as a plain row,
Ctrl+J and the arrows onto a block, Enter landing on the highlighted
line, "#idea zine" narrowing across notes with the word marked, "+1
more" opening the Tags view filtered to "the" with 3 blocks, and the
Settings switch writing tagged_blocks = false and leaving note rows
only; no console errors. Tests: the row builder (cap and "+N more",
all-tags-first ordering, narrowing, deep-block notes, setting off) and
the palette component (Enter on a block, "+N more", Ctrl+D inert on a
block, narrowing and marking, setting off). app-core 3,472 tests,
shared-domain 2,091 and desktop 1,192 pass; typecheck clean. Docs: the
in-app manual's Tags card and Search notes key rows here; the website
mirror lands separately. Demo clip: 837-palette-tag-blocks in the 2.77.0
release media.
…r spins forever

yeminn (Discord, 2026-10-26) had just started ZenNotes Cloud and was
syncing a vault from the PC to Android. Two notes existed on both sides,
and the review sheet sat on "Loading the versions kept for you..." with
Finish later greyed out. Reopening the app showed the banner and a
"Syncing..." pill, and Review led back to the same loading line.

Reproduced on an Android 37 emulator running 1.1.46 against a local fake
Cloud with a 600-note PC vault and two first-sync clashes. On a phone,
opening a conflict went through the vault's one-at-a-time queue, so it
waited for whatever sync was running. A phone gives a request 300
seconds, so a sync waiting on a slow or dropped connection held the
review that long, and a long first sync held it for the whole run. With
one request held open the sheet stayed on its loading line, and the
versions appeared two seconds after that request timed out. Finish later
was disabled while any sync ran and the sheet cannot be dismissed
otherwise, so closing the app was the only way out. Opening one conflict
also scanned the whole vault, and the phones turn their scan cache off
for review, so every file was read and hashed to look at one note.

Reading a waiting conflict changes nothing, so on the phones it no
longer queues behind a sync. Saving a draft or a decision still waits
its turn, and a decision still checks that neither version changed
since the read. The coordinator now reads the conflict's own file
through a new optional CloudSyncRepository.readLocalItem and scans only
when the file is no longer at its path. PortableCloudSyncRepository,
which both phone repositories extend, implements it with one listing per
folder and an exact spelling match; any other spelling is left to the
scan, which reports the name on disk. The desktop keeps its queue, which
its review lock and sync-window barrier rely on, and its requests give
up after 30 seconds.

The review itself can no longer spin forever. A load still going after
20 seconds becomes the usual Cloud error with Details, Copy details and
Report an Issue, plus Retry, and an answer that arrives late still shows
both versions. A failed load gets Retry too. Finish later is held only
while a decision is being saved: a draft is saved before every sync and
cannot change during one, so leaving never has to wait for that sync.

Not done here: the phones get this through a core boundary release.

Verified on the emulator over CDP against the same stalled sync: before,
still loading after four minutes with Finish later greyed; after, both
versions in 0.4 seconds while the sync stays stalled, Finish later
closes the sheet mid-sync and the banner stays, a load forced to hang
ends in Retry and Details at 20 seconds and Retry loads it, and keeping
the other device's version wrote the PC text and synced. Tests, each
failing on the old code: opening a conflict reads its one file instead
of all 201, a moved file is still found, spelling rules for
readLocalItem, a conflict opens while a sync is stuck, and the resolver
cases (a load that never answers, a failed load, a late answer, Finish
later during a sync). shared-domain 2,097, app-core 3,480 and desktop
1,192 tests pass, typecheck clean; the Android shell's typecheck and 312
tests pass on a rehearsal core with this change.
While reproducing yeminn's conflict review report (Discord, 2026-10-26)
a first sync of a 2,500-note vault looked like it pulled about one note
a second on Android. That did not reproduce: from a clean app on an API
37 emulator a first sync pulls a flat ~65 notes a second at 600, 2,500
and 5,000 notes, in the foreground or the background, and the change
feed ~70. The time per file there is ~16 native bridge calls, the same
in any vault.

One cost did grow with the vault. pullChanges reduced each change with
reduceCloudSyncChange, which copies every tracked item, so catching up
on m changes in an n-note vault cost n x m. In Node the pull loop took
176 us a change at 600 notes and 1,749 us at 10,000. A pull now keeps
one working copy of the items and changes it in place
(reduceCloudSyncChangeInto): 18 to 21 us a change at every size. The
state saved when a later change fails (#813) stays exactly what it was:
what each item held at the landed point is kept until the next landing
and put back before that save. On the phone the gain is lost under the
bridge calls (2,000 changes into 5,000 notes: 38.1 s before, 39.2 s
after); it matters on the desktop, which runs this same loop over a fast
disk, and for very large vaults.

New tests: the in-place reducer matches reduceCloudSyncChange step by
step and leaves the items untouched whenever it throws; a failing change
while a coalesced revision is still owed saves only what landed (passes
on the old code too, so the behaviour is pinned); and a change into a
20,000-note vault costs no more than five times one into a 200-note
vault (old code: 3.57 ms against 0.14 ms, fails). shared-domain 2,101
tests pass, typecheck clean; the Android shell's typecheck and 312 tests
pass on a rehearsal core with this change.
…p opens the day

Dave (Discord, 2026-10-26) asked for a calendar on the phone like the
desktop sidebar: the month on top, tasks by date below, and a tap on a
date that opens or creates that day's note. The phones have had it since
their first builds, but only behind a note's ••• sheet and only with
daily or weekly notes on, so he never found it.

The phone shells could not do better on their own. They reach core
through its public exports, the calendar panel is private to core, and
EditorPane mounts it only beside an open note. Showing it from Home
would have meant opening a note behind it, which for a day without one
means creating that note (and rolling unfinished tasks into it).

So core now lends the calendar out, and teaches it a finger:

- `@zennotes/app-core/calendar` exports DateCalendar: the same
  CalendarPanel, with no note required. It orients on the open daily or
  weekly note, else on today. Its onOpen runs once it has opened a day,
  a week or a task's line, a note that was already open included, so a
  host that shows it as a sheet can put it away. The pane passes none,
  so the side panel stays open the way it always has.
- On a touch screen a tap on the selected day opens that day's note, or
  offers to create it with the same "New daily note" confirm. A finger
  has no double-click, and the only other way was the small "Day +" in
  the agenda's corner. The first tap still only selects, so the week of
  tasks below follows it, the way a phone's own calendar works. A mouse
  keeps click to select and double-click to open: the desktop is
  unchanged.
- Settings can open on Vault, Periodic notes (a `periodic-notes`
  settings target, public as showSettingsPage), so a phone with daily
  notes off can take you to the switch instead of hiding the calendar.

The new subpath is listed in app-core's package.json and in
pack-app-core.mjs, which writes the published exports.

New tests: with no note the calendar orients on today; a touch tap
selects and a second offers to create; an existing day opens on the
second tap and onOpen runs; a declined create runs nothing; a mouse
click stays select-only (the two touch tests fail without the change);
showSettingsPage opens Settings on that page. app-core's suite passes
and the typecheck is clean. The phone side (a Calendar row in the circle
menu, the sheet, a note when daily notes are off) is ready in both
shells on a rehearsal core, checked in the iPhone preview and on an
Android 37 emulator with real taps, and ships when they adopt 2.77.0.
…note

Dave (Discord, 2026-10-26) moved a note to the Trash on Android and the
app opened a note he had not looked at in a while, not the first in his
vault alphabetically, where he expected the Home screen with his recent
notes.

A phone keeps every note it opens as a pane tab, with the tab strip
hidden, and Home is a pane with no active tab and those tabs kept
behind it. When a path leaves the workspace, rewritePathsInTree gives a
pane whose active tab was dropped, or that had none, its first tab: the
oldest note still open. Desktop wants that, its tab strip shows where
it landed. On a phone it is a hidden note nobody asked for. The
phone-only Home guard caught that promotion only in an update that also
changed the note list, and moving a note to the Trash closes its tab in
a separate update, so both cases slipped through: trashing the open
note, and trashing a note from a list while on Home (a note that was
not even open still pulled the first tab out of Home). Archiving,
deleting for good and a delete from outside the app went the same way.

rewritePathsInTree now marks the panes whose active tab it chose on its
own (activeTabWasPicked). A renamed or kept active tab is not marked,
and the mark goes with the next change to the pane. installHomeGuard
shows Home when the active pane carries it; its rescan rule stays as it
was. Only the phone shells install the guard, so desktop and web keep
landing on a remaining tab. Tablets install it too and now land on Home
as well.

There is no Undo for a trash; Restore in the Trash view is the way back
and works as before.

New tests: trashing the open note shows Home and Restore brings it back
byte for byte; on Home, trashing a note from a list stays on Home;
archiving and deleting for good show Home; opening the first open note
on purpose still opens it; without the guard the remaining tab still
takes over. The three Home tests fail on the old code. app-core's suite
passes and the typecheck is clean. Both phone shells pass their checks
on a rehearsal core with this change; before and after were checked in
the iPhone preview and on an Android 37 emulator with real taps (the
shipped 1.1.46 opened the oldest hidden note on both paths, the fix
stays on Home). The phones get it when they adopt 2.77.0.
yeminn (Discord, 2026-10-26) syncs a 28 MB vault to Android behind a
national filter that inspects every connection, and a network like that
can hold a connection open and pass nothing. A phone gave every Cloud
request 300 seconds before giving up, and a request that gave up failed
the whole run: each stall cost five minutes, the next run started over
from the manifest, and the vault's queue waited behind it. Phones ask
for 1 MiB content pages, so a vault like hers comes down as about 2,700
requests, and every one of them is a chance to stall.

A phone's HTTP stack measures silence, not total time: Android's read
timeout and an iOS URLRequest's timeout both start over whenever bytes
arrive. The new cloud-sync-stall.ts gives the phones one bound for that
silence, 30 seconds (the desktop gives a whole request 30 seconds, so a
slow connection that keeps moving is never treated worse than there),
15 seconds to open a connection, and what to do next.
CloudSyncTransportError marks a request that got no HTTP answer at all,
and retryCloudTransfer runs work that is safe to repeat again after 1, 4
and 15 seconds, each wait shortened at random by up to half so devices
behind one unsteady link do not come back together.

The rate-limit coordinator already replays a GET, or a request marked
idempotent, after a 429. It now also replays one after a transport
failure when the host opts in with transportRetryDelaysMs. The replay
happens below the encryption layer, so an end-to-end encrypted push goes
out again with the same sealed bytes under the same operation id and the
service applies it once. Upload completion opts out
(retryOnTransportFailure: false): the service hashes the whole object
before it answers and refuses a second completion while that runs, and
the phones already retry completion themselves. The desktop does not opt
in, so nothing changes there.

Sending is different. Measured with Foundation's URLSession on a Mac:
the timeout restarts only on bytes received, so an upload moving at
64 KiB a second failed a 5 second timeout at 5 seconds, and the body is
reported sent the moment URLSession holds it, so no progress watchdog
can see a stalled upload. cloudSendTimeoutMs gives a request with a body
the stall bound plus the body's time at 8 KiB a second, which for a note
at the 5 MiB inline cap is never less than the 300 seconds every iOS
request used to get.

After its retries, a transport failure reads "took too long to answer"
when the connection went quiet, or "can't reach ZenNotes Cloud" when it
failed outright, instead of Android's bare "timeout".

Not done here: the phones get this through a core boundary release;
their shells' changes are on their own branches.

New tests: the retry schedule, jitter and cancellation; replays only for
GETs and idempotent pushes, byte for byte, never for a plain POST or an
upload completion, and only for a host that opts in; 429 and transport
retries keep separate allowances; an encrypted push that stalls goes out
again with the same sealed body and its encryption header; the send
timeout; the copy. shared-domain's 2,124 tests pass, the monorepo
typechecks, and the desktop's 181 and app-core's 168 Cloud tests pass.

On an Android emulator, with the shell's changes on a rehearsal core of
this commit, a first sync of a 2,000-note, 29 MB vault with four
requests stalled mid-body (a content page, a download instruction, two
file downloads) took 3 min 10 s in one run, each stall sent again 30.6
to 30.8 seconds after it went quiet. Shipped 1.1.46 took 21 min 23 s:
four runs failed after about 300 seconds each before a fifth finished.
Four transfers trickled over 44 to 49 seconds each finished without a
resend, a clean sync took the same 57 seconds on both builds, and the
vaults matched byte for byte.
…ng up as tags

A note saved with CRLF line endings leaked its code into the tag
index: `#include <x>` inside a ```c fence became a tag `include` in
the sidebar, the Tags view and tag completion, and a [[link]] in a
fence counted as a real link. The scanners split a body at "\n", so
every line still ends in "\r", and the fence pattern ends in `(.*)$`
with no m flag. JavaScript's `.` never matches "\r", so `$` never
matched and a CRLF fence never opened. The close check would have
tolerated the CR; the open never got that far.

The four TypeScript copies of stripCodeContent (shared-domain
tag-scan, desktop main, MCP, app-core wikilinks) now allow `\r?`
before `$`. The info string still excludes the CR, so an LF body
scans byte for byte as before and each copy stays line-preserving.
The shared FENCE_OPEN_RE and FENCE_CLOSE_RE had the same blind spot,
so in a CRLF note the outline listed `# comment` lines from a shell
fence as headings and a `^Z2` inside a bash fence read as a block
anchor; both gain the same `\r?`. The Go copy in ZenNotes/znserver
was already right (RE2's `.` matches "\r"), and the workflows fence
map splits on \r?\n, so neither changes here.

Tests: a CRLF case for every copy (extractTags, wikilink targets,
the outline, block anchors, and listNotes tags and links in desktop
main and MCP), each failing before. Typecheck clean; shared-domain
2,092, app-core 3,458 and desktop 1,194 tests pass.
The sidebar's Vim cursor is a row number: sidebarCursorIndex names the
row whose data-sidebar-idx matches it, and Sidebar hands those numbers
out again on every render, in render order. Render order is not display
order: a folder numbers its notes before the subfolders it shows above
them. So when the listing changed under the cursor (a note written from
outside the app, a sync), every row numbered after the change moved by
one, and the cursor stayed on the number and landed on another row.
Measured in the built app: inbox with Areas, Projects and Reading and
two notes, cursor on Areas (12); writing inbox/Churn.md from outside
renumbered Areas to 13, and the cursor showed on Open note, which had
inherited 12.

The number stays the cursor, since VimNav, panel-rows and the windowed
rows all speak it. Sidebar now also remembers which row the number
named: a store subscription takes an anchor the moment the index
changes, while the DOM is still the one the number was read from, and a
layout effect puts the number back on that row after each render,
before paint. A row is identified by its data-sidebar-* identity
attributes (lib/sidebar-cursor.ts), so a note pinned under Favorites is
not the tree note, a windowed placeholder is the full row it stands in
for, and Atlas and Workflows, which share a type, are told apart by
their order.

When the row itself is gone, the cursor takes the row now in its place,
so a note renamed in place keeps it, except at the end of a folder,
where it takes the row before it instead of jumping to the next folder
or the System section; a folder's only note hands it to the folder. It
only runs while the sidebar has focus: unfocused the cursor is not
drawn, and coming back lands on the current item. The reveal effect
reads the live index now, because the layout effect can move it in the
same commit the reveal runs for.

On the built macOS app over CDP, the repro above keeps the cursor on
Areas (13) and j goes on to Projects; a row removed above the cursor,
the cursor's own row deleted (stays in the folder), `l` on a folder
(#993), `h` back onto it, and a write above a cursor inside an expanded
folder all hold. With the new effect switched off, the same run lands on
Open note at 12. On 5,000 notes, taking the anchor costs 0.5 ms per
cursor move and the check after a render 0.13 ms, next to the 6 ms row
scan VimNav already does per key.

Tests: 18 for the row identity, twins and the gone-row fallback, in the
measured layout; the sidebar Vim smoke test gains an outside-write check
that fails without the fix. app-core 3,463 tests and the smoke test
pass; typecheck clean.
…e the note in place

uNyanda links notes they mean to write later and then forgets to write
them, and wanted to edit or create a linked note without leaving the one
they are in, the way Logseq's hover editor works. Both asks are about
wikilinks to notes that do not exist yet.

The Unresolved Links view (a tab, like Assets) lists every [[wikilink]]
in the vault that no note answers, grouped by the note it names, most
linked first, with each linking note and the line the link sits on.
Space u, :unresolved, Open Unresolved Links in the palette, or a new row
under the Connections panel's missing links opens it. Enter on a name
creates the note where gD would; Enter on a line opens the linking note
with the cursor on the link. j/k, gg/G and / are Vim mode's; with it off
the arrows, Enter and Escape do the work. The view holds the keyboard
the way the Tasks and Tags views do: VimNav stands down for it except
for the leader.

What counts as unresolved is one decision. The editor already drew a
link as broken when it reached no note, spot in this note, database or
file; that check moved out of cm-wikilink-render.ts into wikilinkReaches
(lib/unresolved-links.ts), and the view asks it about every link in the
vault, so a link the editor draws broken is exactly a link the list
shows. Headings, blocks and aliases of existing notes resolve; a
[[link]] inside code is text. Grouping runs on the index's per-note link
lists, with no file reads; the lines come from reading only the linking
notes, and only while the view is open. The line scan is new pure logic
in shared-domain (unresolved-links.ts) and masks code through
scanMarkdownLines, which gains an includeFrontmatter option, instead of
a sixth copy of stripCodeContent.

The link popover opens when the pointer rests on a wikilink in the
editor, or from K (Vim) and Shift+Mod+K (editor.linkPopover, Vim on or
off) on the link under the cursor. It is a real editor on the store's
noteContents, the buffer the panes and the pinned reference pane share,
so typing there saves through the usual debounce and shows in any tab of
the same note. A link to a missing note offers Create note, which writes
the note without opening it in the pane (createNoteInPlace; a daily note
through ensureDailyNoteForDate) and puts the cursor at its end in insert
mode. Escape closes it from Vim normal mode and gives focus and cursor
back to the link; Mod+Enter opens the note in a tab; K inside it follows
the next link; a [[Note#Heading]] link opens on the heading. Weekly and
monthly note links still create through the pane, where their templates
run.

Every cache prune drops content no pane references, so the store now
holds the popover's note (linkPopoverHolds) and, on close or when
another link replaces it, saves the last edit before letting go:
dropping it first would leave the debounced save nothing to write.
Renames rewrite its path, and trashing its note closes it. Moving from a
link onto the popover fires the popover's pointerenter before the
editor's mouseleave, which re-armed the close; the hover timer now
checks where the pointer is.

Hover is a new portable preference, link_popover_on_hover under
[editor] (Settings, Writing, Link popover on hover), on by default; the
keys work either way. Both features are in the in-app manual.

Verified in the built app over CDP with isolated userData and config
dir. With Vim on (24 checks): Space u lists three missing notes in
order with their lines and skips links in a fence and in inline code;
Enter on a line lands on line 3; Enter on a name creates the note and
drops it from the list; hovering opens a note in the popover without
taking focus; the popover survives the pointer crossing onto it and
closes after it leaves; an edit made in it lands on disk and Escape
returns to the original cursor; K on a missing link then Enter creates
the note in place and typing lands in it; the link stops drawing as
broken; a heading link opens on its heading. With Vim off (12 checks):
Shift+Cmd+K, Create, the palette entry, the Connections row and the
Settings toggle work, j does nothing in the list and the arrows do. No
console errors.

Tests: the occurrence scan (fences, tilde and list fences, inline code,
frontmatter, CRLF offsets), grouping, the vault scan against the real
resolver (anchors, aliases, assets, databases, trash, remapped system
folders), the popover's target rules and anchor offsets, and the
in-place create path. shared-domain 2,085, app-core 3,470 and desktop
1,192 tests pass; typecheck clean.
async function clickAt(client, selector) {
const rect = await evaluate(
client,
`(() => { const r = document.querySelector(${JSON.stringify(selector)})?.getBoundingClientRect(); return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null })()`
await launch('Tag blocks smoke test, Vim on', true, async (client, errors, ctx) => {
await vimOnScenario(client, errors)
console.log('\n Search notes, Vim on\n')
await paletteScenario(client, errors, ctx)
What's New opens on the first launch after the update with four
highlights. First, the Unresolved Links view lists every [[link]] to a
note that does not exist yet, opened from the palette or Space u.
Second, the link popover edits a linked note in place and creates a
missing one, on hover, K or Shift+Cmd+K. Third, tags find the idea:
the Tags view and Search notes list the tagged blocks inside each note
(#837). Fourth, ZenNotes Cloud retries a stalled request after 30
seconds and opens a conflict review at once, even mid-sync.
… Vim cursor alone (#993)

61eac76 made the reveal effect move the sidebar's Vim cursor only when
the selection really changes, with the last open note standing in for
the gap a folder selection leaves: selecting a folder clears
selectedPath, and the next store update usually puts it back. The
effect remembered that note only while the sidebar had focus; it
returned before the line that remembers it otherwise.

So one order still slipped through. Open a note, click a folder with
the mouse (selectedPath goes null while the sidebar is unfocused, so
the note was never remembered), then Ctrl+W h into the sidebar: the
effect's first selection there was "no note". The first refresh after
that, a file written from outside the app or a sync, rebuilt the active
fields from the open tab, selectedPath came back, and the effect read
the note as a new pick and moved the cursor onto it. Found while
recording the 2.77.0 demo clip; a store log on a debug build showed
selectedPath going from null to the open note on that refresh and
nothing else in the selection changing.

The note is now remembered on every run, focused or not. The early
return for an unfocused sidebar, which resets the revealed selection
so coming back lands on the current item, comes after it and is
unchanged.

On the built macOS app over CDP, the outside-write probe (a folder
clicked before Ctrl+W h, then four outside writes with the cursor on
Tasks, the vault header twice and Areas) keeps the cursor on its row
for all four; before, the first write moved it to the open note. The
#993 repro (`l` on a 30-note folder, then j), the sidebar Vim smoke
test and the Vim-off tag and link checks pass. app-core 3,541 tests
pass; typecheck clean.
@adibhanna adibhanna changed the title Release 2.77.0: unresolved links and the link popover, tagged blocks, Cloud conflict and stall fixes ZenNotes 2.77.0 Oct 26, 2026
@adibhanna
adibhanna merged commit 3f0356d into main Oct 26, 2026
10 checks passed
@adibhanna
adibhanna deleted the v2.77.0 branch October 26, 2026 21:30
@adibhanna
adibhanna restored the v2.77.0 branch October 26, 2026 21:31
@adibhanna
adibhanna deployed to boundary-artifacts October 26, 2026 21:53 — with GitHub Actions Active
@adibhanna
adibhanna deployed to boundary-artifacts October 26, 2026 21:59 — with GitHub Actions Active

This branch was successfully deployed

1 active deployment
boundary-artifacts — 3f0356de Deployed Oct 26, 2026 by adibhanna via draft #73
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