ZenNotes 2.39.0: resize pictures by hand, copy links from a right-click, MCP follows a connected server, video embeds stop hijacking the keyboard
Drag the handle on a picture's right edge to resize it (or
:imgw 480), and the width lands in the note as the|widthhint every other surface already reads. Right-click a web link or an email address, in the editor or the reading view, to open it or copy it (gydoes the same from the keyboard). The wikilink picker keeps a link's#sectionand|aliaswhen you change its note. The marker-hop shortcuts step over auto-paired quotes. Cloud sync no longer turns a multi-save catch-up into conflict copies. Ctrl+D in Search notes trashes the highlighted note. Sync incomplete lists the files that need attention, with a reason and an Open button.zn renameon a record page keeps its database row in step. The MCP server andznnow work on the vault the app has open, a self-hosted server included, withvault_infosaying which. A pasted YouTube or Vimeo link is now a click-to-play poster instead of a live player, so the cursor walks straight over it, nothing takes the keyboard away from the note, and Escape brings you back out of a running video. Renaming a note under Cloud sync saves your open buffers first so the wikilink rewrite cannot be overwritten by stale editor contents, in-flight atomic saves never show up as attachments, and CI now rejects high-severity production advisories.
✨ New
-
Resize a picture by dragging it. (#684, requested by @scorpionsoft7) Pictures could be sized with the Obsidian hint (
![[pic.png|300]],, since 2.27), but only by typing the number. Hover a picture in the editor and a handle appears on its right edge, Logseq-style; drag it and the picture follows, with a live450 pxreadout. On release the width is written into the note as that same hint, so the reading view, the Word and email exports, the server, and Obsidian all show the picture at the size you chose, andu(or Cmd/Ctrl+Z) takes it back in one step. The caret never moves, the caption and an existing|caption|label survive, an explicitWxHhint is replaced by the bare width so the picture keeps its aspect ratio instead of stretching into the old height, and the picture cannot go wider than the pane or narrower than the handle. The keyboard gets the same edit: Resize Image… in the command palette asks for a width (empty orautostrips the hint) for the image under the cursor, and in Vim mode:imgwidth 480(:imgw 480) writes it directly,:imgw autoresets,:imgwalone opens the prompt. Off an image line both say so instead of doing nothing. How to test locally: put![[pic.png]]on its own line, move the caret elsewhere, hover the picture and drag the pill on its right edge. Before: no handle; the only way to size a picture was to type|300yourself. After: the picture resizes as you drag and the line reads![[pic.png|450]];urestores it;4Gthen:imgw 520sizes it from the keyboard. -
Right-click a link to copy it. (asked by Adib) A right-click on a rendered web link or an email address, in the editor or the reading view, now leads with the link itself: Open link / Copy link for a URL (a bare
google.comcopies ashttps://google.com, the way it would open), Write email / Copy email address for amailto:(the address itself, withoutmailto:and its?subject=tail). The rest of the editor menu follows as before; wikilinks, note links, in-page anchors and local files keep their own menus. From the keyboard,gyin normal mode (or Copy Link Under Cursor in the palette) copies the link under the caret, and says so when there is none. How to test locally: a note with[docs](https://zennotes.org/docs)and[Sam](mailto:sam@example.com?subject=Hi), right-click each rendered link. Before: the generic Cut/Copy/Paste menu, and copying meant revealing the source and selecting the URL by hand. After: Copy link putshttps://zennotes.org/docson the clipboard, Copy email address putssam@example.com; with the caret in the link,gydoes the same. -
Marker hop steps over auto-paired quotes. (#685, requested by @uNyanda) With Auto-pair quotes on, typing
"gives"|"and, once the word is typed, leaving the pair meant the arrow keys: the hop shortcuts from 2.19 (#490) crossed**,*,~~,==, backticks,$and brackets, but not quotes. They now cross straight quotes too,"This|"to"This"|and'This|'to'This'|, in both directions and one pair at a time like every other marker. Quotes count as markers exactly where they auto-pair: with the setting on, or inside code (where auto-pair always closes them), so the hop is unchanged for everyone else. An apostrophe inside a word (don't,it's) is never a stop. How to test locally: Settings → Editor → Auto-pair quotes on, type"This, press the hop shortcut (Ctrl+. on macOS, Alt+] elsewhere). Before: the caret stays inside the quotes. After: it lands after the closing quote;'don't'hops straight to the end. -
Trash a note straight from the search palette. (Discord, asked by unyanda) Finding a note through Search notes and deciding it has to go meant opening it and trashing it from there, or hunting it down in the sidebar.
Ctrl+Don the highlighted result now moves it to Trash, the same confirmation and clean-up as every other Move to Trash (open tabs and buffers for it are dropped), and the palette stays open with the highlight on the next row, so a clean-up pass is search,Ctrl+D, search,Ctrl+D. The footer says so, and it mirrors the Open buffers palette, whereCtrl+Dalready closes the highlighted buffer. How to test locally: Cmd/Ctrl+P, type part of a note's name, pressCtrl+D, confirm. Before: nothing. After: the note is in Trash, the row is gone, a toast confirms, and the palette is still open.
🐛 Fixes
-
Sync incomplete now says which files, and why. (Discord, reported by unyanda) "Cloud sync needs attention: 1 change could not be applied" led to Settings → Cloud and stopped there: the server reports a rejected change by item id, and the count was all the app had. Each file now gets its own row under the summary: the path, what happened in plain words (changed in Cloud after this device last synced; another Cloud file already uses this name, with the clashing path; deleted in Cloud; edited on both sides and the Cloud version parked beside it as a named copy; differs on both sides during first sync), and an Open button (plus Open copy for a parked Cloud version) that closes Settings and opens the note. The status bar's Review shows the last run's list straight away instead of asking for another sync first. Capacity rejections stay a single "waiting to upload" line, as before. How to test locally: with a file rejected by Cloud (edit the same note on two devices and sync both), press Review in the status bar. Before: the count only. After: the file, the reason, and Open.
-
zn renameon a record page keeps its database row pointing at it. (#691, reported by @lawtj) Renaming a note inside a<Name>.base/folder from the CLI moved the file and left the row'spagepointer at the old path, with the title column still reading the old name;zn renamereported success, and the break only surfaced when the base view could not open the note. The app's grid renames in the other direction (title cell, then file) and keeps both in step, so the CLI backend now does the same from the file side: after the rename, the database's sidecar points at the new file and the title cell reads the new name, forzn renameand the MCP'srename_note, on local and self-hosted vaults alike. A note that is not a record page is untouched, and a database that cannot be read leaves the rename standing. How to test locally: with a base that has a record page,zn rename "inbox/notes-db.base/old-name.md" --to new-name, thenzn base rows notes-db --json. Before:pagestill saysold-name.md. After:pageisinbox/notes-db.base/new-name.mdandNameisnew-name. -
Cloud sync: catching up on a file another device saved several times no longer produces a conflict copy, and no longer re-uploads your stale copy over it. (Discord, reported by unyanda) With the phone offline while the desktop edited notes, the next phone sync parked every one of those notes as
Note (cloud conflict).md, those copies then synced back to the desktop, and worse, the phone re-uploaded its old bytes over the desktop's new revision, so the newer text survived only inside the conflict copy. Cause: 2.35's catch-up coalescing (#661) applies only the newest of several remote revisions of one file, but it advanced the tracked state through the skipped revisions first, so the vouching check compared the file on disk with a revision the device had never written and refused to replace it. A file with one remote save was fine; anything autosaved more than once was not. The coordinator now hands the check the revision that is actually on disk, so a device that touched nothing adopts the newest revision cleanly, later moves and deletes see the real file too, and a real local edit is still parked beside the incoming version. Both apps share the coordinator, so the desktop is fixed the same way for edits made on the phone while it was closed. How to test locally: two devices on one Cloud vault; take one offline, save the same note three times on the other, bring the first back. Before:Note (cloud conflict).mdappears on the first device and the other device's note reverts. After: the note updates in place, no copy, nothing re-uploaded. -
A pasted video no longer takes the keyboard away from the note. (reported on Discord by Sasori and unyanda, both on Linux) Pasting a video link and picking Embed got "clunky", and afterwards the rest of the note could not be navigated until the embed was deleted and pasted again; the only workaround was Preview mode. Reproduced in the built app: the embed rendered a live YouTube player straight away, and the natural next click, on the video, moved keyboard focus into that cross-origin frame. From there
j,k, Ctrl-[, Escape and every app shortcut are delivered to YouTube's player (which binds j/k/space/arrows itself) and never to the note, and nothing but a mouse click on note text gets out. On top of that, every cursor pass over the block tore the widget down and rebuilt it, so the player reloaded and the layout jumped twice per pass. An embed is now click-to-play: a poster (YouTube's thumbnail; Vimeo's via the bookmark metadata bridge, a dark card when there is none) with a play badge and the provider name, in the editor and the reading view alike. No iframe exists until play is clicked, so nothing can take the keyboard, walking the cursor through the block is an image rebuild rather than a player reload, and a note with several videos no longer spawns several players on open. The poster swallows its mousedown so focus stays on the note, and play mounts the player with autoplay requested and hands focus back to the editor, caret untouched:j/kkeep working while the video plays. Clicking inside a running player still gives it the keyboard, as it must, so the desktop app now relays a bare Escape from any embedded frame and refocuses the note (or the reading surface); the web client keeps the poster but has no hook below the page for the Escape part. How to test locally: pastehttps://www.youtube.com/watch?v=dQw4w9WgXcQon an empty line, Enter (Embed), Esc, thenka few times andjback down through it. Before: a live player that reloads on every pass; click it and the keyboard is dead until you click some text. After: a poster with a play badge thatj/kwalk over instantly; click play and keep pressingj/k; click inside the running video, press Esc, and the caret is yours again. -
The wikilink picker keeps a link's section and alias. (#686, reported by @uNyanda) Editing the note inside an existing
[[daily note#notes|yesterday]]and picking a new one from the picker produced[[new note]]#notes|yesterday]]; picking a heading inside[[note#bu|yesterday]]produced[[note#bugs]]|yesterday]]. Each picker only looked for a]]right after the caret, appended its own closing brackets otherwise, and replaced nothing past the caret. All three pickers (note,#heading,^block) now replace the segment under the caret and leave what the link already carries after it, so the result is[[new note#notes|yesterday]]and[[note#bugs|yesterday]], and typing the new name over the start of the old one replaces the old name whole. A link still being typed is closed exactly as before, and a later, unrelated[[on the line never counts as this link's closing brackets. How to test locally: with a note containing[[daily note#notes|yesterday]], put the caret before#, delete the name, type the first letters of another note and press Enter on the suggestion. Before:[[new note]]#notes|yesterday]]. After:[[new note#notes|yesterday]]; the same inside#…|aliasfor headings and block ids. -
The MCP server and a flag-less
znfollow the vault the app has open, a connected server included. (#688, reported by @ShowhyT) With the desktop app connected to a self-hosted server,vault_infokept answering with the last local vault, and every other tool read and wrote that folder too: the MCP only ever looked at the config'svaultRootand never atworkspaceMode. Both now resolve the workspace the app has open. On a server,vault_inforeportskind: "remote"with the server URL, the saved profile name, the vault the server is serving and its layout mode, and every tool (notes, folders, search, tags, tasks, assets, trash, line inserts and replacements) works through the server's API, the same one the app and the web client use. Precedence is unchanged where it existed:--server/--vault, thenZENNOTES_SERVER/ZENNOTES_VAULT, then the app's workspace, then the local default. One thing this process cannot get from the app is the server token, which lives in the OS secret store; a server that requires one takes it fromZENNOTES_REMOTE_TOKENin the MCP client's environment (or--tokenforzn), and a 401 now says exactly that instead of a bare rejection. How to test locally: connect the app to a server from Settings → Vault, then runzn mcp(orzn list) withZENNOTES_REMOTE_TOKENset. Before:vault_infoprints the local folder andlist_noteslists it. After:vault_infoprints the server and its vault,list_noteslists the server's notes, andcreate_notelands on the server. -
Renaming a note under Cloud sync waits for your unsaved changes. Renaming rewrites every inbound
[[wikilink]]on disk. If one of those linking notes was open with unsaved edits, its pending save could land right after the rewrite and put the old link back, and Cloud sync then saw a note that still pointed at a name that no longer existed. The rename now flushes open buffers first and refuses to proceed if a dirty note could not be saved, and the sync engine plans a linked-note rename as one move plus one rewritten-note update instead of anything more destructive. How to test locally: with a Cloud vault, openDaily.mdcontaining[[Untitled]], type a character without waiting for the autosave, then renameUntitledtoGroceriesfrom the sidebar. Before:Daily.mdcould end up with[[Untitled]]again. After: your edit is saved, then the link reads[[Groceries]], and the sync log shows a move plus one update. -
In-flight saves never show up as attachments. Atomic saves write
Note.md.<pid>.<stamp>.tmpnext to the note and rename it into place. For that moment the asset list, the root-note migration, the file watcher, and the self-hosted server's asset listing could all see the scratch file, so an oddDaily.md.3252272.1787800172047252.tmpattachment could appear, or be migrated into the assets folder. All four now skip the atomic-save pattern specifically; a file you namedreport.2024.01.tmpyourself still lists. How to test locally: drop a file namedDaily.md.123.456.tmpintoinbox/and open Assets. Before: it is listed. After: hidden, whilereport.2024.01.tmpstill shows.
🧰 For contributors
cli/backend.tsfollowRecordPageRename(ops, oldRel, meta):formDirContaining+csvPathForFormDirfind the database,pages[rowId]is matched by normalized path,setCellupdates the title field, andwriteDatabaseSchemapersists sidecar + rows; both backends call it after their rename. Tests: a temp vault for local, the fake HTTP server (now with/api/notes/rename) for remote.CloudSyncConflict.path(bridge-contract): client-annotated, the coordinator fills it from the mutation it sent (a delete carries the path the item had locally).cloudSyncAttentionItems(summary)incloud-auto-sync.tsturns a run summary into rows (kindsbootstrap,kept-both,kept-local,settings,rejected; capacity codes excluded); the status store keepslastSummary, andCloudSettingsrenderssummary ?? lastSummarywith Open / Open copy going throughopenNoteInTab.- Store:
trashNote(path)is the general form oftrashActive(confirm,moveNoteToTrash, tabs and buffers dropped, refresh) and returns whether the note moved;trashActivedelegates to it.SearchPalettebindsCtrl+Dto it with a success toast; search never lists trash, so the row leaves the results on refresh. cloud-sync-coordinator.tspullChanges: anonDiskmap remembers the tracked item as it was before the first coalesced (skipped) upsert of an item and hands that torepository.applyfor the change that finally lands; acknowledged (own) changes clear it. Tests drive the realPortableCloudSyncRepositoryon an in-memory filesystem with real sha256s, so the vouching itself is under test.cm-marker-hop.ts:markerHopTarget(text, col, dir, { quotes })andmarkerHopCommands({ quotesAreMarkers }); the module stays pure and store-free,EditorPanesuppliesautoPairQuotesInProse || isInMarkdownCode(state, pos).WORD_CHAR_RE(Unicode letters, digits,_) is what makes an apostrophe an apostrophe.cm-wikilinks.ts:linkSegmentEdit(state, from, to, text, stops)is the one edit all three pickers make;closedLinkTailfinds the]]of the link under the caret (none when another[[opens first). Note segment stops at#^|, heading at#|, block at|.- MCP on the CLI's
VaultBackend:mcp/server.tshandlers take a backend instead of a root;resolveDefaultTarget(cli/vault-target) is the shared default (env, thenreadActiveWorkspaceFromConfig, then local),VaultBackendgaineddescribe(),listAssets(),emptyTrash(),insertAtLine(),replaceInNote()(remote ones composed from/api/vault,/api/vault/settings,/api/assets,/api/notes/empty-trashand the pureinsertAtLineInBody/replaceInBodytransforms),callTool/listToolNames/describeToolErrorare exported for tests, and a 401/403 maps to theZENNOTES_REMOTE_TOKENhint. Tested against a fake HTTP server (backend) and end to end against the real Go server over stdio JSON-RPC, both with a hand-written config and with the config the desktop app wrote after connecting (window.zen.connectRemoteWorkspace), where the MCP-created note showed up live in the app. lib/link-copy.ts:copyableLink(target)maps any link target throughexternalLinkUrlto{ kind: 'url' | 'email' | 'phone', value, url };linkMenuItems(link)is the Open / Copy pair (plus separator) shared by the editor menu (buildEditorContextItemsnow takes the link under the pointer, or under the caret when the menu is opened from the keyboard) and the reading view's newlinkMenu;copyLinkAtCursor(view)backsgy(zenCopyLinkaction) and palette ideditor.copy-link.lib/clipboard-text.tsis the bridge-first clipboard write thatcode-block-copy.tsused privately, now shared.@shared/embed-sizegainswithEmbedWidth(lineText, width | null)andreadEmbedWidth(lineText): the one place a standalone image line's hint is rewritten, for both the wikilink and markdown forms.lib/image-resize.ts(app-core) holdssetImageWidthOnLine/setImageWidthAtCursor(one transaction,userEvent: input.image-resize),parseImageWidthInput,promptImageWidth,setImageWidthFromInput, andattachImageResizeHandle(pointer capture, live width on the element only, commit on release). The live-previewLocalImageWidgetwraps the<img>in.local-image-embed-sizerso the handle rides on the picture's edge while the frame keeps its full width;commitWidthre-reads the line under the figure at release time. Palette ideditor.resize-image; ex commandimgwidth/imgwregistered inEditor.tsx.lib/embed-renderers.ts:parseEmbednow returnsprovider,playSrc(autoplay URL) andposter;renderEmbedElement(el, url, { onActivate })renders the poster button and mounts the iframe on click;escapeEmbedFrame()moves focus out of an embed iframe to the hostingEditorView(viaEditorView.findFromDOMon the.cm-editorroot) or the nearest[tabindex]container. Styles under.zen-embed-frame .zen-embed-poster/-play/-providerinindex.css.- Escape relay:
installFrameEscape(win)in desktop main (before-input-event+webContents.focusedFrame.parent) sends IPCAPP_FRAME_ESCAPE(app:frame-escape); preload exposesonFrameEscape, optional on theZenBridgecontract so the web and mobile bridges are unaffected;App.tsxsubscribes once. vault.ts(desktop),store.tsmigration and the watcher shareisAtomicWriteTempPath; the Go server mirrors it asIsAtomicWriteTempPathinListAssets.store.renameNotecallsflushDirtyNotes()and throws if anything is still dirty;planCloudSyncMutationscovers the linked-note rename with amoveplus oneupdate.- Dependabot (
.github/dependabot.yml) opens weekly grouped PRs for npm production and development dependencies and for GitHub Actions; CI gained aproduction-dependency-auditjob runningnpm audit --omit=dev --audit-level=high, and the lockfile was pruned of unused chevrotain entries. - Both docs surfaces updated:
help.tsgains the "Embed videos and link cards" card, a resize sentence on the Links card and an:imgwidthrow; the website gets a```embedsyntax entry, the resize sentence on the image-embed entry, and the:imgwidthrow in the ex-command table (docs-sections.json+docs.blade.php).
Local-first and keyboard-first, as always.