Conversation
Restoring a Cloud backup on the Mac (2026-10-05) brought a trashed note back to inbox/ and left its Trash copy in place. The next scan found a file this device had never tracked and uploaded it as a new note, so every device ended up with two. A restore does not send a move. It sends an upsert whose previous_path is the note's old location, and the desktop wrote the new path without ever looking back at the old one. It now retires the previous path once the new one holds the Cloud bytes, on every way out: bytes already there, written inline, or published from a staged download. The old file goes only when this device tracked it at that path and still holds exactly the bytes it tracked. An edit made here since is reported as a local edit conflict, a path this device never tracked is left alone, and two spellings that reach one file on a case-insensitive volume (same device and inode, compared as bigints for Windows file ids) are never treated as two files, since removing the "old" spelling there removes the note just written. The coordinator settled an upsert whose bytes already sat at the target without asking the repository (a replay after a crash lands there), which skipped that cleanup. It now takes the shortcut only when the item keeps its case-folded path. The phones' portable repository already removed a moved file's old path, but on a case-insensitive volume (Android's default vault storage among them) a case-only rename deleted the only copy. It now asks the directory listing whether both spellings exist. When they are one file it writes in place and gives the file its new spelling through a temporary name, because a rename that overwrites its destination would remove the file itself first. What this does not do: change how the server plans a restore, or touch an untracked file at a previous_path. Folder-name case changes and case-only moves can still bounce or raise a false conflict, without losing data. Desktop 1,044 tests (19 new), shared-domain 1,870 (9 new), typecheck clean. Verified in the built app on a Cloud vault: the restore brought the trashed note back to inbox/, the Trash copy was gone, and Cloud held exactly the backup's 99 items.
Cloud acceptance testing (2026-10-05) turned up backup copy that left the person guessing. A backup the service refuses for a plan limit said only "This backup would exceed your plan limits." The service names the limit and both counts, but only in the details of BACKUP_QUOTA_EXCEEDED, and desktop errors cross IPC as message text, so the details were gone before the window could read them. Both hosts (desktop main, and the shared host service the phones use) now reword that refusal while the details are still attached: - Size: "This vault holds 54 MB, and backups on your plan hold up to 52 MB. Remove large files, or contact support to raise the limit." When both sizes round to the same figure it says "just over 52 MB". - Files: "This vault has 12,345 files, and backups on your plan hold up to 10,000", with the same advice. - Count: "This vault already keeps 30 backups, the most your plan allows. Delete an older backup to make room." Over the limit, it says how many to delete. Any other failure, and details this version cannot read, keep the service's own message. formatCloudBytes moves to shared-domain so the hosts can word sizes before IPC; app-core re-exports it from its old path. Restoring a backup labeled "Restore QA baseline" asked "Restore Restore QA baseline?". The prompt now quotes the label. The result read "Restored 97 items and removed 1 newer items"; it now counts in the singular when it should and leaves the removal out when there was none. shared-domain 1,879 tests, app-core 2,949, desktop 1,026, typecheck clean.
…f current Four rough edges on the Cloud page, found in device testing (2026-10-02) and the restore run (2026-10-05). Review and Set up land on This vault. The status bar's Review (kept-both copies, rejected changes, capacity), its Set up, the removed-vault Review, and Space r or the palette after a removal all open Settings on the Cloud page, which then sat at its top with the keyboard in the settings search. The page now scrolls This vault into view once the account and link have loaded, and focuses the first thing in it that waits on the person (a removed-vault notice, the vault settings card, a sync failure, files needing attention), or the section itself when nothing does. It moves focus only while Settings still holds it, and only once per open. A file conflict's Review is unchanged: it opens the conflict queue, not Settings. The usage card stays current. It read usage when the page opened and after the page's own actions, but most sync runs are not the page's. A run that moved files now asks for a fresh read once runs settle (3 s), a hidden window waits until it is shown, and an answer that a newer sign-in has superseded is dropped. Failed automatic backups are shown. The service sends `last_failure` with the schedule; the app never declared it, so a skipped daily backup was silent. While automatic backups are on, the row now says what happened: a plan limit is worded from its numbers with the same sentence a refused manual backup uses, anything else says the backup failed and will be tried again. The restore result drops "This vault is synced to cursor 18" for "This vault now matches the backup", and says nothing when the sync after the restore left something for This vault to list. app-core 2,965 tests, shared-domain 1,889, desktop 1,046, typecheck clean.
A file too large for Cloud stays on the device that has it, while its note syncs everywhere. Every other device then showed `![[clip.mp4]]` as an empty video player, and images and PDFs as broken frames. Found in Cloud device testing (2026-10-02) with a 151 MB video. The resolver never said a file was absent. When no listed file answered an href it guessed a URL beside the note and returned it like any other, and both the reading view and the editor built a player on it. An empty vault also read as "not listed yet", so a vault whose only file was held back left the embed as raw source. Now the store records when the vault's own listing has arrived, and an image, PDF, audio or video embed whose name no listed file carries draws a one-line card instead: "clip.mp4 isn't on this device.", worded and coloured like the "Not synced to Cloud" notice the source device shows. The editor's card keeps the `</>` action and the reading view's keeps its source line, so editing the line works as on any block. Imports index the new file before the embed goes in, so a drop or paste never flashes the card. A list seeded by hand (the share viewer, PDF export) never calls a file missing, and neither does a name two listed files share. Desktop's asset listing now includes a file reached through a symlink (zen-asset serves it), or every embed of a linked file would read as missing; a broken link stays out. The web app needs the same from znserver's listing, done there. Not changed: attachment chips and pictures inside a sentence, which the listing cannot vouch for (notes, drawings and dotfiles are not in it).
On the phones a folder showed its notes and subfolders but none of its attachments (PDFs, pictures, audio, video), which the desktop's folder view shows. Found in Cloud device testing (2026-10-02). The phones hide app-core's sidebar and note list and build their folder view from the public Browse API, and `getBrowseDirectory` returned only folders, databases and notes. Browse now carries `files`, in the snapshot and in each directory listing, placed exactly as the desktop sidebar places them (assetBelongsToFolderView, assetFolderSubpath): the root asset folders, the other system folders, `.zennotes` and files inside a database stay out. Each row's `path` is an opaque assetTabPath for the existing openNote action, the way database rows open; files follow the note sort, by last change where notes sort by creation. Re-reading an unchanged file index keeps the delivered rows. A folder rename, move or delete through the Browse actions re-reads the file index, since the phones have no watcher to catch up for them. Additive: the desktop and web apps do not use the Browse API. The phones show the rows once they adopt a core with this change.
…s off (#892) With Blinking cursor turned off, a note opened in a floating window, the Quick Capture window and the external-file window still blinked. The main editor builds `drawSelection({ cursorBlinkRate })` from the preference, and the drawn caret and the Vim block cursor both read that one value; these windows build their own editor from their own prefs reader, which never read `cursorBlink`, and called a bare `drawSelection()`, which blinks at CodeMirror's default. The reference pane and the template editor had the same bare call. Every editor now builds its cursor layer through cm-cursor-blink.ts. The windows that seed from the stored prefs blob also follow a change made in Settings while they are open, through the `storage` event (the only signal another window gets), reconfiguring only when the value really changed. Quick Capture is hidden rather than closed, so it no longer keeps its first value all session. A test fails if any other file calls drawSelection(). Not changed: other preferences these windows still never apply (custom themes, completed-task style, math scale, line-number position) need more than this one value and are left for their own change.
…#894) Space h labelled every control on screen and the rendered links in Preview, but none of the links in the Edit view: the page-wide scan collects real controls (`a[href]`, buttons, `[role=link]`), and the editor draws a wikilink as a span, a Markdown link as text spans and a bare URL as plain text. Hint mode now also asks every visible note editor for its links (bare URLs, `[[wikilinks]]` with an alias or heading, Markdown links and images), from the lines CodeMirror has drawn. A label sits on the link's first character that is actually drawn, so hidden `[[` or `[` is skipped and a link hidden behind an embed gets none; a link must be inside the visible part of the editor, links in code get none, and labels run down the note in document order with the page's other targets. Following a label makes that pane active and does what a click does: a wikilink through the same path as a rendered wikilink (so `[[2024.01.15]]` opens the note, not a browser), any other link through followLinkTarget. The link patterns now live in one table that Cmd-click, `gd` and hint mode all read. Unchanged: Space h still labels every control too, and stays behind the Vim leader.
Settings is a dialog inside the main window, and the macOS menu has no Close item, so ⌘W reached the renderer's close-tab branch, which never looked for overlays: it closed the tab behind Settings (or the whole window when no tab was left), and did the same behind palettes, dialogs and menus. Quick Capture handled ⌘↩, ⌘N, ⌘P, ⇧⌘P and Esc but not ⌘W, though it shows a normal close button. The floating note and external-file windows had a hard-coded ⌘W/Ctrl+W check that compared the typed character, so it failed on non-Latin layouts, ignored a rebind or unbind of Close active tab, and fired on Ctrl+W on macOS. ⌘W now closes the frontmost window, the macOS rule, and Settings counts as one: it closes Settings and leaves the tabs alone. Dialogs, palettes and menus are not windows: while one is open ⌘W does nothing (Esc dismisses it), which also keeps ⌘W from throwing away a nested draft such as the template editor inside Settings. The floating note, external-file and Quick Capture windows close on the Close active tab binding through their close button's own path (a pending save still flushes; Quick Capture hides and keeps its draft), and follow a rebind made while they are open. resolveCloseShortcut keeps the main window's rules: Ctrl+W as the Vim pane prefix on Linux and Windows, and closing the window when no tab is left (#192). The in-app manual describes the binding and the new Quick Capture row.
A wikilink names a note, but every path that followed one by its name read it as an href first, and the bare-domain guess that lets `[site](google.com)` open the web (#201) took a dotted name for a domain. So `gd`, `gD`, Cmd-click on the link's source, a dead link clicked in Preview, a link in a table cell and a database relation chip all sent `[[2024.01.15]]` to https://2024.01.15 even with the note right there, and `gy` and the link menu offered to copy or open that address. A plain click on a rendered wikilink and hint mode resolve the name first, so they were right. Found while fixing #894. linkRangeAtCursor now reports which kind of link it found, from the same shape table, and externalUrlForLink(target, kind) lets a wikilink reach the browser only with an explicit scheme (`[[https://…]]`). followLinkTarget, gd, gD, Cmd-click, the copy menu, table cells, relation chips and Preview's dead links pass the kind. extractLinkAtCursor, which dropped it, is gone. Not changed: a Markdown href or a bare URL keeps the bare-domain guess, so `[day](2024.01.15)` still reads as a web address, and a wikilink written with a scheme still opens in the browser.
In the Edit view a rendered wikilink followed on mousedown with any mouse button. A right-click opened the linked note and then drew the editor's context menu over that note, a right-click on a link to a missing note asked to create it, and a middle click followed as well. Only the primary button follows now. A right-click leaves the note where it is and opens its menu; Cmd or Ctrl with the primary button still creates a missing note at once (#768).
GHSA-vfj7-8cjw-p6xm (braces, stack exhaustion on deeply nested patterns, no fixed release) failed `npm audit --omit=dev --audit-level=high`. Braces reached production through @excalidraw/excalidraw, which lists sass 1.51.0 as a dependency; that sass pulls in chokidar 3, which pulls in braces. Excalidraw never loads sass at runtime: nothing in its build output imports it. Excalidraw's sass is overridden to 1.103.1, the copy the tree already has for Vite, which watches with a chokidar that has no braces. braces stays only under build tools. No package changes version. The packaged app trades one unused native module for another: the old chain's fsevents is gone (the app's own chokidar 4 never used it) and sass's optional @parcel/watcher arrives, signed with hardened runtime and a timestamp like the rest. Checked on an electron-builder --dir build: deep codesign verify passes and the packaged launch check finds its page target.
…ve between steps survives On storage that ignores case (Android's default, the iOS simulator), a Cloud change that only respells a note's name takes two renames through a temporary name; renaming straight onto the new spelling removes the file as its own destination (7679d21). Two things could go wrong between the steps: - The app stopping there left the note as `Note.md.<uuid>.tmp`. Sync ignores that name, and the run that follows replays the change (it was never saved as applied) and writes the note again from Cloud, so nothing was lost, but the parked copy stayed on the phone for good. - A save landing there, from an editor still holding the old spelling, was removed by the second rename as an existing destination. The typed text was gone everywhere. matchCase now writes a small record under .zennotes/zennotes-cloud-sync/respellings (a local-only folder that scans never list) before the first step, and checks the name is still free before the second. A taken name means a save landed: the save stays, it syncs as the newest version, and the record waits for the next run. Each sync run first settles any records: a parked note whose name is free goes back under it, one whose name holds the same bytes again is a duplicate and goes, and anything else stays, since it may be the only copy of one side. Recovery never fails a run; a record it cannot settle yet waits. Not changed: a save already in flight inside the second rename call can still be removed, a much narrower window. Desktop never takes this path, and the phones reach it only with a core built from this release.
…otes way Select a .md file in Finder and press Space: Quick Look shows the note rendered by the app's own Preview, in the user's theme and fonts from config.toml (callouts, tasks, tables, highlighted code, KaTeX, Mermaid, the images it embeds), with an Open in ZenNotes button. Enter opens it too; with Vim mode on, `o` opens it and `j` / `k`, Ctrl+D / Ctrl+U, `gg` and `G` move through it. Three pieces ship in ZenNotes.app/Contents/PlugIns: - apps/quicklook/src: the page, a small build beside the share viewer that borrows its approach (a bridge shim, the full app stylesheet) with its own payload: the note, the asset references the extension resolved, and the raw config.toml, read with smol-toml. Typst math and the plot renderers stay out (KaTeX typesets math), as do the Excalidraw fonts: 11 MB instead of the share viewer's 55. The typography variables App.tsx sets moved to lib/typography-variables.ts so both read one source. - ZenNotesQuickLook.appex: the sandboxed extension (Quick Look loads no other kind). It serves the page and the note's embedded files over its own zenql:// scheme, behind a Content-Security-Policy that allows nothing else, so a preview never reaches the network or a remote image. WebKit's helper processes refuse to start without the network-client entitlement even so. A read-only absolute-path exception lets it find embedded images beside the note and the theme in ~/.config. - ZenNotesQuickLookOpener.xpc, inside the extension: Quick Look's sandbox lets a preview launch nothing (NSWorkspace, a URL, even its own app all fail), so this unsandboxed service opens the note in the ZenNotes that contains it, the route of a Finder double-click (a note in a known vault opens there), and outside links in the browser. It checks every request again: an existing Markdown file, this ZenNotes, web or mail links only. electron-builder never signs anything under Contents/PlugIns, so afterPack builds the extension per architecture and signs it, opener first, with the identity and timestamp server electron-builder chose for the app. Not included: editing, thumbnails, Typst math, plots, TikZ and pictures hosted on the web. A missing embedded image shows as a broken image. Checked with a packed build registered under separate bundle ids (so the installed app kept .md and zennotes://) and launching only on an isolated profile: the showcase note rendered in the user's Gruvbox theme with images by wikilink and by path, KaTeX and Mermaid; the self-test build pressed the button and ZenNotes opened the note; `j j j`, `G` and `gg` scrolled the real panel 120 px, to the bottom and back. Tests: payload, prefs and keys (15).
The Kanban board's key handler runs ahead of everything else and never asked whether a menu or dialog was open. A card's right-click menu filters as you type, and the in-app manual suggests typing "prio" to narrow it to the priority entries; on the board that `i` reached the board first and marked the card in progress, leaving the menu filtering on "pro". An `x` or a `c` in a filter would check the card off or cancel it the same way. Behind Settings the Tasks view's own keys fired as well: `1`, `2` and `3` switched the view, and `a` opened the new-task prompt on top of Settings. Both handlers now stand down while a menu, a prompt or any dialog is open, Settings included. isOverlayOrDialogOpen adds the aria-modal panels that the shared Modal shell and Settings draw to what isAppOverlayOpen already covered. The close shortcut keeps its own rule, since Mod+W closes Settings itself, and the other list views keep isAppOverlayOpen for now.
A Kanban column was built in due-date order, but the first drag saved an arrangement for every column on the board, and from then on the saved order won: a task whose date moved up stayed where it had been dragged, so a custom status board could no longer show what is due first. The board's toolbar now has Order next to Group by: Manual, as before, or Due date. By due date every column on every board, custom status boards included, sorts earliest first with undated cards last, ties broken by note and line. A card sent to another column with Shift+H / Shift+L or a drag lands in its date slot, and the insertion line shows that slot, counting any date the drop itself sets (Today, Upcoming). A drag inside its own column moves nothing. The dragged arrangement is never written while ordered by date, so Manual brings it back exactly. The order is one global preference, portable as `kanban_card_sort = "manual" | "due"` under [view] in config.toml, and every way in is a key away: `s` on the board in Vim mode (the cursor stays on its card), `:order due | manual` on the Tasks ex line (bare `:order` switches), the palette's "Kanban: Order Cards by Due Date" or "...Manually", and Settings > Tasks > Card order. It is deliberately not a per-vault view override, which would need the Go server's vault.json mirror to learn it. The column builders' three copies of the due comparator are now one, compareCardsByDue. Documented in the in-app manual (the Kanban card, the :order ex command, the Settings reference) and on the website.
The config.toml watcher skipped any file text the app itself had written among its last sixteen writes. That guard is for a stale read: with two writes in a row the watcher can read the file around the second one and see the first (a Windows CI flake), and taking that for an edit reverted settings. But the guard never expired. A user who changed a setting in the app and then set it back by hand left the file byte for byte as the app had written it before, so the edit was ignored: the app stayed on the newer value while the file said otherwise, and its next save would write that value back over the edit. An earlier own write now counts as a stale read only while the app's writes are landing: during a write, or within 1.5 s of the last one. A read skipped in that window is checked again once the writes settle, and a text still in the file then applies, since nothing the app wrote explains it. A read that outlives its watcher is dropped. The race test used to write the old text back after both writes had finished, which is exactly the hand edit the guard swallowed; it now holds the second write mid-flight. Two new tests put an earlier version back, after the window and inside it.
| const view = await mount() | ||
| savePrefs({ keymapOverrides: { 'global.closeActiveTab': 'Shift+Mod+W' } }) | ||
| await act(async () => { | ||
| window.dispatchEvent(new StorageEvent('storage', { key: PREFS_KEY })) |
| localStorage.setItem(PREFS_KEY, newValue) | ||
| await act(async () => { | ||
| window.dispatchEvent( | ||
| new StorageEvent('storage', { key: PREFS_KEY, newValue, storageArea: localStorage }) |
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.62.0.
Features
.mdfile in Finder renders it the ZenNotes way, with Open in ZenNotes (newapps/quicklookworkspace, built and signed inafter-pack.js).s,:order, the palette, Settings andkanban_card_sort.Fixes
[[2024.01.15]]opens its note; a right-click on a wikilink stays put.Local gates on fb3d13f: typecheck 8/8 and test:run 6/6 with no cache (shared-domain 1,896, app-core 3,096, quicklook 15, desktop 1,049),
npm run packsigned with the Quick Look extension inside, deep strict codesign OK, packaged launch check: page target in 1.5 s, version 2.62.0.