Repository navigation
Conversation
…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.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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,KorShift+Mod+Kand edits the real note in place; a missing note gets Create note.wikilinkReachesis now the one "is this link broken" rule for the editor, table cells and the view. New portable pref[editor] link_popover_on_hover.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 throughreadNote(tag-block-index.ts), so local, remote and web vaults all work.#tagin 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.95f48cde): the core part; the phone shells are onfeat/calendar-entryin their own repos.Fixes
61eac765): Ctrl+A increments in Normal mode on Linux and Windows (CodeMirror'sMod-adefers to Vim outside insert mode); Ctrl+J/K in Settings follow the order the list shows;lon a folder keeps the sidebar cursor there instead of jumping to the open note.37a68a33).3f0356de): the reveal remembers the open note while the sidebar is unfocused too.03df3dde); a link to a missing note looks unresolved in a table cell (fa200ea3).7afe7a3b).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).4050388f).Verification, release gates on
3f0356dein 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; signednpm run packand 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, onf69b5fa5: 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.0in zennotesios with #76 and #77,release/1.1.47in zennotesandroid with #133 and #134, both withfeat/calendar-entry).