Repository navigation
Conversation
From the sidebar, `m` and Change color… or Change icon… opened a picker, but the keys kept driving the sidebar behind it. j and k moved the row cursor under the backdrop, l opened that row and pulled focus out of the dialog into the editor, and Enter opened the row instead of picking. Only Tab reached the choices. With Vim off, arrows and Enter did the same. The picker did hold DOM focus. Panel keys follow the store's focused panel instead, which stays on the sidebar while a dialog is open, and both routers (VimNav, and the Vim-off handler in App) stood down only for dialogs that carry a data-* marker of their own. The pickers are plain Modal shells with none. Both routers now also stand down for any aria-modal dialog, which every Modal panel sets, so the other unmarked dialogs are covered too. The pickers then answer the keys themselves: arrows move between the choices, and h j k l do with Vim on, as single-key motions do everywhere. Up and down keep the column the grid shows (three, or two on a narrow window), and an edge stays put. Enter picks the focused choice, Escape closes, and the picker opens on the color or icon already set, with a focus ring on the choice. Left alone: pane focus (Alt+h/j/k/l) and the global shortcuts guarded in App keep their own marker lists. Closing that gap is a separate change. Nine tests for the grid. Driven over CDP on a built app with Vim on and off. Before, j j l moved the sidebar from Bravo to Delta and opened Delta behind the open picker, and the shipped 2.71.0 did the same with arrows and Enter with Vim off. After, the sidebar stays put, the keys walk the grid, and Enter writes the color or icon to vault.json. Reported by @Luistfer.
…es (#960) The follow-up the #960 fix left open. Two keydown guards in App decided "is a dialog open?" from a list of data-* markers (context menu, prompt, confirm, Cloud conflict) plus the palette and Settings flags. Dialogs the shared Modal shell draws without a marker of their own, such as the sidebar icon and color pickers, Publish and the workflow pickers, were invisible to them. So under the sidebar color picker, Alt+L moved focus and the focused panel into the editor behind the backdrop, Ctrl+1 switched the tab under it, and Mod+N and Shift+Mod+N each made a note there and pulled the keyboard into its title field. Both guards now use isOverlayOrDialogOpen(), which keeps every marker the old lists checked and adds any aria-modal panel. The store flags stay as they were, so palettes and Settings behave as before: the keymap recorder still takes a focusPane chord (#124), and Mod+P is still no toggle over an open palette (#938). Driven over CDP on a built app, picker open from a right-clicked sidebar row: before, Alt+L, Ctrl+1, Mod+N and Shift+Mod+N all acted behind it; after, all four leave the dialog, tab and vault alone. Without a dialog, Alt+H and Shift+Mod+N still work, Settings still keeps Alt+H, the recorder captures Alt+L, and the search palette's Ctrl+N and Ctrl+P still move.
In a quote or callout, `$$` and Enter auto-closed the math block with its body line and closing `$$` indented by spaces instead of quoted. A line without its `>` leaves the quote, so the block fell out of the callout: Preview drew an empty math box in the callout and the formula as plain text below it. A fence (```) opened there broke the same way. The snippet's continuation prefix turned every marker before the opener into spaces, which is right for a list item (#405) and wrong for a quote. It now keeps quote markers and still turns list markers into spaces, so `> $$`, `> > $$`, `> - $$` and `- > $$` each continue the way their lines need. A quote marker may also be followed by more than one space, so a list item's continuation inside a callout (`> $$`) opens a block at all; it never auto-closed before. The #405 fallback for Enter on an existing fence opener keeps the `>` the same way. Lines typed inside a math block in a callout already continued with `> ` on Enter, through the markdown command. Left alone: Enter on a content line of a fenced code block in a quote still starts a bare line, because the markdown command stands down inside fenced code. That is a separate change. Twelve tests: eight for the snippet and four through the editor's real Enter chain, whose harness now mounts the snippets ahead of the markdown keymap as EditorPane does (in the old order, markdown's Enter took `$$` in a quote first and the snippet never ran). Driven over CDP on a built app: before, `> $$` and Enter wrote two-space lines; after, with Vim on and off, every line is quoted, typing on continues with `> `, and Preview draws the math inside the callout. Reported by @athu16.
Asked on Discord what they would change about Cloud, uNyanda said the error messages: not everyone using Cloud is technical, so a message should say what went wrong, why, and what to do about it. They could not. IPC carries only an error's text across to the window, so desktop users read whatever a failure happened to say: "fetch failed" when offline, "The operation was aborted due to timeout", "ZenNotes Cloud object upload failed (411).", the server's "Authentication is required." for a device removed on the website, and about thirty internal sync checks such as "Expected sync sequence 41, received 43". The conflict review printed the Electron wrapper whole, "Error invoking remote method 'cloud-vault-conflict:resolve': Error: ...", and so did the publish dialog. One translator now words every Cloud failure: describeCloudError in shared-domain/cloud-error-copy. It reads the HTTP status and code where they still exist, which is the desktop main process before an error crosses IPC (every cloud* handler goes through it) and the phones, whose hosts run in-process. It knows the runtime's network and timeout texts, the encryption error codes, and disk errors. Internal sync checks now throw cloudProtocolError or a CloudIssueError (download incomplete or damaged, Cloud changed during a first sync, two names that differ only in case) with their old messages unchanged, so tests and logs still match them. Anything else is a sentence this code wrote for people and passes through. The technical line is kept for support, out of the way. Settings -> Cloud shows the sentence with a closed Details disclosure under it: the original text, status and code, Copy details, and Report an Issue (the existing command's name). The status bar, its hover text and toasts show the sentence alone. The sentences were approved row by row from a before-and-after table, and every place they point to carries that name on screen: Connect ZenNotes Cloud, Disconnect, Sync now, Review, This vault, Save archive, and Billing on zennotes.org. Two behaviors changed with the words, both approved: - A device Cloud no longer accepts (401, a revoked device) now signs out on the desktop too, as the phones already did. The status row reads "Signed out of Cloud" and Settings says why above Connect ZenNotes Cloud, until the person connects again. - A failed sign-in on the loopback path (Windows, Linux, dev builds) showed its reason nowhere: the browser said only that sign-in failed, and Settings kept "Finish signing in in your browser" for up to five minutes. Every platform now shows the reason in a dialog and returns to Connect. That is the path uNyanda's keyring error took. Offline or timed-out runs read "Can't reach Cloud" in the status row. Storage, file-count and vault limits, a plan without Sync, Backups or Publishing (including a free month that ended), the encryption passphrase and recovery-key errors, and the conflict review's stale choices are reworded to match. A help entry, "When Cloud reports a problem", names Details and Copy details; the website mirror waits on its own branch. Left alone: server wording. The website sends developer text in several answers (RESTORE_FAILED forwards a raw exception, bare aborts send empty messages); the app words those codes itself, and the server fixes are listed for a separate change. Tests: the translator has 24 of its own, and the suites that asserted the old wording now assert the new (shared-domain 2,018, app-core 3,291, desktop 1,177). Driven over CDP on a built app with both stores isolated, against a fake Cloud on 127.0.0.1 with the browser, keychain and dialogs stubbed: a refused sign-in, a keychain refusal, a server 500, a dropped connection, a revoked device and a plan without Sync each show the approved words, Details holds the technical line, Copy details copies it, and the revoked device really signs out (29/29).
#961) The follow-up #961 left open. With the caret on a code line of a fence inside a quote or callout (`> xy` under `> ```js`), Enter started a bare line. A line without its `>` leaves the quote, so whatever was typed next fell out of the callout, the code card ended there and the closing fence opened a stray empty block below it. The markdown Enter command returns no markup context inside fenced code, and the default newline copies only leading whitespace, which on a quoted line is nothing. The fence Enter command that already handles the opener line (#405) now also takes the content and closing lines of a fence whose opener sits in a quote. The new line gets the opener's prefix with list markers turned into spaces and quote markers kept, the same rule as the opener and the block snippet, plus whatever indentation the line carries past it. Where the quote markup ends is read from the parse, so code that starts with `>` (a `>>>` prompt) is not taken for a marker. Otherwise it behaves like the default newline: whitespace after the caret goes, and a line left holding only its markup is trimmed to it. The closing line keeps the quote too, so the caret stays in the callout after the block. Unquoted fences, several carets, and a caret inside the quote markup are left to the default command, as is Vim normal mode, where Enter is the <CR> motion. In a quoted fence the code language's own indentation (after a `{`) is not applied: it counts columns from a line start that holds the `>`. Twelve tests through the editor's real Enter chain, mounted with the note grammar so a ```js body parses as JavaScript, as in EditorPane: content and closing lines, nested quotes, a fence in a quoted list item, extra indentation, a mid-line split, a `>>>` line, a trimmed blank line, Vim off, Vim normal mode, and two controls (a line with no `>` after the quote, and an unquoted fence keeping its bracket indentation). Driven over CDP on a built app with Vim on and off: before, every quoted case typed a bare line and the card broke; after, each keeps `> `, `> > ` or `> ` and the block stays whole. Not changed: Vim `o` and `O` open a bare line on any quoted line, fenced or not. That is a separate change.
The website now words its own refusals (ZenNotes/website bfad4b9), and the translator from 72ab890 matched a few of the old sentences to tell limits apart. With the new text a vault-limit refusal read as a full plan, a publishing attachment over the size limit read as a sync file that "stays on this device", and a backup restore over a limit was told that "some changes are waiting on this device". Limits are now told apart by the dimension Cloud sends with them (sync_active_vaults, publish_*), with the old and the new sentences as the fallback for a service that sends none. Restore refusals ("Restoring this backup ...", "A file in this backup ...") keep the service's own words, as restore-a-note ones already did. GONE, a code the service now sends for a bare 410, keeps its sentence too. The invite-only answer said to choose a plan under Billing. In invite-only mode a plan does not open Cloud, so it now says Cloud is invite-only and the account has not been invited yet, as the service does. Two tests: limits by dimension and by the new sentences, and a 403 UNAUTHENTICATED (an outdated sign-in) treated as signed out. shared-domain 2,020 pass.
ZenNotes Cloud keeps its change log for 90 days (sync.change_retention_days) and answers 410 CURSOR_EXPIRED to a device whose cursor is older than what is left. No app handled it. The coordinator asked /changes after the same stale cursor on every run and every run failed, and unlinking and relinking did not help, because the desktop keys its sync state by vault folder and server and reused it. A laptop left in a drawer for a season could not sync again, and the honest message from 72ab890 was "choose Report an Issue". The coordinator now catches that answer (status 410, code CURSOR_EXPIRED) in both pulls of a run and rebuilds from the manifest, then follows the log again from the manifest's cursor. It lives in the shared coordinator, so desktop and both phones get it with no host change. A first sync knows nothing about this device's files. This rebuild still knows what sync last agreed on for each, so it replays what the pruned log would have said, item by item, from the difference between the tracked state and the manifest, through the same path the change feed takes. Both repositories already refuse to replace or delete a file whose bytes are not what sync last agreed on, so that guard covers every write: - Changed or renamed in Cloud, untouched here: lands. - Edited here, untouched in Cloud: pushed as an edit on its tracked revision. Deleted here: pushed as a delete. Created here: uploaded. - Edited on both sides: queued for a decision, never overwritten. - New to this device: lands like a first sync. A queued decision moves to the Cloud version it now faces, without touching the file. The order is what the manifest cannot give back: deletes run first so the paths they free are free, then renames, then edits, then arrivals. Whether an arrival is already on disk is judged from a fresh scan, because a delete just before it can take away a file with the same bytes at its path. A chain of renames through each other's paths can come out as conflicts, never as an overwrite. If one file fails midway, what landed is saved at the old cursor, so a retry rebuilds from there and does not redo a change undone since (#813). A file deleted in Cloud and edited here is kept and queued as a delete conflict, as the feed would. The manifest lists only live files, so the revision that deleted it is unknown, and an upsert onto a deleted item needs that revision. Such a conflict carries a null Cloud revision, and keeping this device's version sends it back as a new file. A file deleted on both sides just drops the question. The translator now says sync is catching up by comparing every file with Cloud and that edits here are kept, instead of pointing to Report an Issue. It shows only if a catch-up is cut short. Tests: an expired cursor with no local edits (edit, rename, delete and new file in Cloud, then the log resumes), with local edits (edit, delete, new file, and a file both sides changed), a Cloud delete of a file edited here (keep it, or accept the delete), a queued decision moved forward, a same-bytes file re-created where a deleted one stood, and a failure midway. Also a metadata-first host downloading only the changed file, the reference-mode streaming path, the phones' host service keeping the link, and the desktop repository and state store on disk, once through the real desktop HTTP client reading Cloud's 410 body. typecheck clean; shared-domain 2,030, app-core 3,303, desktop 1,179 pass. Not verified: a built app against a Cloud with a pruned log, and the phone builds.
A vault moving into ZenNotes often has checkboxes everywhere. The only way to
keep them out of Tasks was to exclude folder after folder, and to remember
every new one. The ask was the other direction: one Tasks folder, and nothing
else counts.
The vault's `tasks` settings gain `includedFolders`, the Task folders, next to
`excludedFolders`. Empty means the whole vault, as before. Once it names
folders, only the notes inside them feed the Tasks list, boards and
calendars, and the excluded list still carves subfolders out of them. The
rule lives in the shared `isPathExcludedFromTasks`, whose new third argument
is optional, so a caller that passes only the excluded list keeps today's
behavior. Desktop main, the MCP server and the Node zn read both lists, and
`includeExcluded` / `--include-excluded` scan past both.
Settings, Tasks, Checklists shows the Task folders above the excluded folders
(one row component serves both now), and the sidebar folder menu, which `m`
opens in Vim mode, gains Use as Task Folder / Stop Using as Task Folder next
to Exclude from Tasks.
Two things came along because a Task folder makes them matter more. The
exclusion toggle rebuilt `tasks` from its own list, so excluding a folder
would have dropped the Task folders; both toggles now go through one helper
that keeps the other list. And a folder rename or move now repoints both
lists, in main and in the renderer's copy, because a renamed Task folder left
behind would match nothing and empty Tasks. The exclusion list never followed
a rename either.
Left alone: the phone bridges apply the lists themselves (the iPhone passes
only the excluded list, Android neither), so mobile is a follow-up and the
in-app help leaves it out for now. An older build that rewrites vault.json
rebuilds `tasks` from the fields it knows and drops `includedFolders`.
Tests for the rule, the rename rewrite, the toggle helper, the MCP scan and
Settings search ("tasks folder" still finds where new task files go). Driven
over CDP on a built app: `m` on a folder, a filter and Enter set the Task
folder and Tasks dropped every other note; excluding a subfolder inside it
kept both lists; Stop Using brought the whole vault back; renaming the folder
moved both entries and Tasks kept its contents.
Reported by @davi-jorge-art.
The bundled CLI is the floor a desktop build guarantees, and 0.9.0 is the newest release. It carries two zn releases into a desktop-managed install from the first launch instead of the first daily update check: 0.8.1, where a finished task note stays in its Custom status column on the TUI's board and `zn mcp` gives assistants the desktop's instructions again, and 0.9.0, which follows the vault's Task folders (#962) in `zn task list`, `zn task watch` and `zn mcp`, and makes the TUI's Settings save both Tasks folder lists under `tasks` where every app reads them. The pin is the release's terminal-release.json copied byte for byte after it verified against packaging/release-signing/zn-release-1.pub in ZenNotes/tui (source commit a0d335c, integration protocol 1, all four archives matching checksums.txt). Staged through `npm run terminal:stage`; the native darwin-arm64 binary reports zn v0.9.0. The in-app manual's CLI card says 0.9.0 and what 0.8.1 and 0.9.0 change.
What's New opens on the first launch after the update with four highlights. First, Task folders (#962): where to turn one on and what it does to every other checkbox, with where both folder lists live. Second, Cloud errors that say what happened and what to do, and where Details, Copy details and Report an Issue sit. Third, a device back after months away catching up by itself, keeping the edits made while it was away. Fourth, the pickers taking the keyboard (#960) and blocks staying inside a callout (#961).
A pre-release review found that the error translator (72ab890) read any 401 as Cloud refusing this device's sign-in. Signing out then revoked the device on the service: logout() sends DELETE /devices/current with the token, which was still valid. Two 401s that are not Cloud's refusal took that path. A presigned direct upload the storage host answered 401 arrives as DIRECT_UPLOAD_FAILED with the storage's status, and a proxy or captive-portal page answering 401 arrives with no Cloud JSON at all. A storage hiccup during a large upload could revoke a device whose sign-in was fine. Only Cloud's API answer signs out now: the code UNAUTHENTICATED (401, or 403 for an outdated sign-in) or ACTIVE_DEVICE_REQUIRED, or a code-less 401 or "Invalid ability provided." 403 whose body was Cloud's JSON. Any other code keeps its own words, so the upload reads as a large file that could not be uploaded and will be tried again. A body that was not Cloud's JSON (the clients' "ZenNotes Cloud request failed (401).", "invalid JSON response" and "unexpected response") reads as an answer from something other than ZenNotes Cloud, such as public Wi-Fi. Found alongside: a 429 or 5xx during the sign-in exchange said "sync will try again in a few minutes", and an unreachable service said "sync picks up where it left off". Sign-in never retries by itself, and the request it answered is already spent. cloudSignInExchangeError words those three as "choose Connect ZenNotes Cloud again" and keeps the technical line. Desktop wraps its exchange fetch with it, and the phones get it through CloudAuthFlow. A refused code keeps the existing "didn't go through" sentence. Tests: a storage 401, three proxy bodies, and the real signed-out cases (401 with and without the code, 403 UNAUTHENTICATED, ACTIVE_DEVICE_REQUIRED), and sign-in wording for 429, 503 and an unreachable service in the translator, the desktop CloudAuthManager (which also stops waiting) and the shared CloudAuthFlow. Typecheck clean; shared-domain 2,043, app-core 3,308 and desktop 1,183 pass, run with other agents' uncommitted edits present in the checkout. Not rebuilt or driven in the app, by request: the release gates run elsewhere.
…921) On macOS, with a Chinese or Japanese input method on and Vim mode on, a markdown file opened from outside the vault took the input method's text in normal mode: hjkl made the candidate window flash, and `ia` was typed into the file instead of entering insert mode. Notes in the vault were fine, because the main editor carries the input-method guard from #84 and #464, which makes the editor non-editable outside insert mode so an input method has nowhere to compose. The floating note and Quick capture carry it too. The external-file window, the pinned reference pane and the template editor built Vim without it. All three now install the guard the same way, following the same setting (Keep the input method out of normal mode). A new test reads every app-core source that turns Vim on and fails when one of them leaves the guard out, which is how three editors slipped through. Checked over CDP with a file opened from the command line outside the vault, Vim on, normal mode: on the installed 2.71.0 the editor was contenteditable and an input-method commit (Input.insertText "ia") wrote `ia` into the file; on this build the editor is not editable in normal mode and the same commit changes nothing. Reported by @tonghejiao.
A pre-release review found a way for 6db3043's rebuild to remove a note. While a device was away, Cloud renamed note Y from B.md to C.md, then note X from A.md to B.md. The rebuild cannot see that order and landed renames in tracked-item order, so X could go first, find B.md still held by Y, and queue a move conflict whose local side was Y's file at B.md. Y then moved on to C.md. With nothing left at B.md, the conflict's local side was looked up by hash and landed on Y's file at C.md, so choosing the Cloud version wrote B.md and removed C.md. The next sync sent Y's delete to Cloud and uploaded A.md again as a copy of X. A rename with an edit made the same false conflict, without the loss. Renames, with or without new bytes, now land in passes: one waits while the path it moves to is still the tracked path of a file that is itself leaving, and goes once that file has moved. A queued decision never moves its file, so it holds its path. Only a true ring (A to B, B to C, C to A) cannot be ordered without a temporary name. It lands as before: the repository refuses each move into a taken path, nothing on disk changes, each note is queued for review, and choosing Cloud on each settles the rotation. The no-overwrite promise is unchanged. Also corrects a claim in 6db3043. It said the translator's CURSOR_EXPIRED words show when a catch-up is cut short. They cannot: the coordinator rebuilds on that answer, and the pull after the rebuild starts at the manifest's cursor, which Cloud never refuses (it refuses only a cursor below changes_pruned_through). A rebuild cut short surfaces its own failure, and the next run rebuilds again. The claim is dropped, not made reachable. The comment now says a current sync never shows the words; they stay for anything else that meets the code. Three tests through the portable repository: the B to C, A to B chain in the order that used to fail (no conflict, nothing pushed, a second sync clean), a longer chain with an edit in the middle, and a three-way ring (queued, files untouched, settled on Cloud, then a clean sync). The two chain tests fail without the ordering. typecheck clean; shared-domain 2,046, app-core 3,308, desktop 1,183 pass.
Every image pasted from the clipboard landed in assets/ as image.png, then image 2.png, image 3.png and on. The naming already falls back to `Pasted Image <date> <time>` when a paste comes with no name, but Chromium, which the desktop app and Android's WebView run on, names every clipboard bitmap image.png, and that placeholder was kept as if someone had chosen it. pastedImageFilename (shared-domain/pasted-image.ts, used by desktop main, the web client and the iPhone bridge) now treats the bare stem `image` as no name, so a paste gets the timestamped one, as Obsidian does with "Pasted image <timestamp>". A file copied from the Finder or Explorer arrives under its own name and keeps it, including names that only start with image. The in-app manual says so where it describes assets/. Not done here: a prompt for the name or a naming pattern setting, which the issue mentions as options; the timestamp covers its ask for unique, meaningful names. Two tests. Driven over CDP on a built app, pasting a PNG named image.png into a note twice: the installed 2.71.0 wrote image.png and image 2.png; this build wrote Pasted Image 2026-10-19 142312.png and Pasted Image 2026-10-19 142314.png, with matching embeds. Reported by @0xFFO.
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.72.0: desktop, released with zn 0.9.0 (already out), server 2.72.0 with Docker, the boundary packages, the website release page, and both phones on core 2.72.0.
New
cc4cdb51):tasks.includedFoldersbesideexcludedFoldersin vault.json, one shared rule (isPathExcludedFromTasks(path, excluded, included)), both lists follow a folder rename, a Settings row and "Use as Task Folder" in the folder menu; the MCP server and the Node zn follow it. The Go server (ZenNotes/znserver34da661) and zn 0.9.0 (ZenNotes/tuia0d335c) mirror it.72ab890e,edea7da0): one translator words every Cloud failure, a Details disclosure keeps the technical line with Copy details and Report an Issue, a device Cloud no longer accepts signs out, a failed loopback sign-in shows its reason.Fixes
6db3043d); renames land in dependency order so a chain cannot cost a note (48a8039d, found by the pre-release review).4e2637a4, found by the pre-release review).e0967986,705f0020).5e60a4a4,aac55e5d).339f613d).image.png(Name of the image in the "assets" folder after copying/pasting #775,3792c985).Also
3e7e2356); What's New 2.72.0 (a6b96a65); version bump (5a52b484).Gates on this head (
3792c985), in a clean checkout with its ownnpm ci: typecheck 8/8 uncached; shared-domain 2,048, app-core 3,308, desktop 1,183 and quicklook 15 uncached; fresh build:prod green;npm run packand the packaged launch check pass (page target, rendered, 1.5 s, 2.72.0, Developer ID team WYY7PK57DM); vim-editor, sidebar-vim and editor-improvements pass on the first run.ci-sweep.py v2.71.0: no failed jobs.hazard-scan.py --since v2.71.0: no hazards. dash-lint clean on the notes and every commit. An Opus review of the five side-session commits found the two Cloud bugs above; both are fixed here with tests.