github ZenNotes/zennotes v2.39.0
ZenNotes v2.39.0

3 hours ago

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 |width hint 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 (gy does the same from the keyboard). The wikilink picker keeps a link's #section and |alias when 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 rename on a record page keeps its database row in step. The MCP server and zn now work on the vault the app has open, a self-hosted server included, with vault_info saying 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]], ![caption|300](pic.png), 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 live 450 px readout. 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, and u (or Cmd/Ctrl+Z) takes it back in one step. The caret never moves, the caption and an existing |caption| label survive, an explicit WxH hint 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 or auto strips the hint) for the image under the cursor, and in Vim mode :imgwidth 480 (:imgw 480) writes it directly, :imgw auto resets, :imgw alone 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 |300 yourself. After: the picture resizes as you drag and the line reads ![[pic.png|450]]; u restores it; 4G then :imgw 520 sizes 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.com copies as https://google.com, the way it would open), Write email / Copy email address for a mailto: (the address itself, without mailto: 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, gy in 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 puts https://zennotes.org/docs on the clipboard, Copy email address puts sam@example.com; with the caret in the link, gy does 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+D on 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, where Ctrl+D already closes the highlighted buffer. How to test locally: Cmd/Ctrl+P, type part of a note's name, press Ctrl+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 rename on 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's page pointer at the old path, with the title column still reading the old name; zn rename reported 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, for zn rename and the MCP's rename_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, then zn base rows notes-db --json. Before: page still says old-name.md. After: page is inbox/notes-db.base/new-name.md and Name is new-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).md appears 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/k keep 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: paste https://www.youtube.com/watch?v=dQw4w9WgXcQ on an empty line, Enter (Embed), Esc, then k a few times and j back 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 that j/k walk over instantly; click play and keep pressing j/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 #…|alias for headings and block ids.

  • The MCP server and a flag-less zn follow 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_info kept answering with the last local vault, and every other tool read and wrote that folder too: the MCP only ever looked at the config's vaultRoot and never at workspaceMode. Both now resolve the workspace the app has open. On a server, vault_info reports kind: "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, then ZENNOTES_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 from ZENNOTES_REMOTE_TOKEN in the MCP client's environment (or --token for zn), 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 run zn mcp (or zn list) with ZENNOTES_REMOTE_TOKEN set. Before: vault_info prints the local folder and list_notes lists it. After: vault_info prints the server and its vault, list_notes lists the server's notes, and create_note lands 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, open Daily.md containing [[Untitled]], type a character without waiting for the autosave, then rename Untitled to Groceries from the sidebar. Before: Daily.md could 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>.tmp next 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 odd Daily.md.3252272.1787800172047252.tmp attachment could appear, or be migrated into the assets folder. All four now skip the atomic-save pattern specifically; a file you named report.2024.01.tmp yourself still lists. How to test locally: drop a file named Daily.md.123.456.tmp into inbox/ and open Assets. Before: it is listed. After: hidden, while report.2024.01.tmp still shows.

🧰 For contributors

  • cli/backend.ts followRecordPageRename(ops, oldRel, meta): formDirContaining + csvPathForFormDir find the database, pages[rowId] is matched by normalized path, setCell updates the title field, and writeDatabaseSchema persists 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) in cloud-auto-sync.ts turns a run summary into rows (kinds bootstrap, kept-both, kept-local, settings, rejected; capacity codes excluded); the status store keeps lastSummary, and CloudSettings renders summary ?? lastSummary with Open / Open copy going through openNoteInTab.
  • Store: trashNote(path) is the general form of trashActive (confirm, moveNoteToTrash, tabs and buffers dropped, refresh) and returns whether the note moved; trashActive delegates to it. SearchPalette binds Ctrl+D to it with a success toast; search never lists trash, so the row leaves the results on refresh.
  • cloud-sync-coordinator.ts pullChanges: an onDisk map remembers the tracked item as it was before the first coalesced (skipped) upsert of an item and hands that to repository.apply for the change that finally lands; acknowledged (own) changes clear it. Tests drive the real PortableCloudSyncRepository on an in-memory filesystem with real sha256s, so the vouching itself is under test.
  • cm-marker-hop.ts: markerHopTarget(text, col, dir, { quotes }) and markerHopCommands({ quotesAreMarkers }); the module stays pure and store-free, EditorPane supplies autoPairQuotesInProse || 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; closedLinkTail finds 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.ts handlers take a backend instead of a root; resolveDefaultTarget (cli/vault-target) is the shared default (env, then readActiveWorkspaceFromConfig, then local), VaultBackend gained describe(), listAssets(), emptyTrash(), insertAtLine(), replaceInNote() (remote ones composed from /api/vault, /api/vault/settings, /api/assets, /api/notes/empty-trash and the pure insertAtLineInBody / replaceInBody transforms), callTool / listToolNames / describeToolError are exported for tests, and a 401/403 maps to the ZENNOTES_REMOTE_TOKEN hint. 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 through externalLinkUrl to { kind: 'url' | 'email' | 'phone', value, url }; linkMenuItems(link) is the Open / Copy pair (plus separator) shared by the editor menu (buildEditorContextItems now takes the link under the pointer, or under the caret when the menu is opened from the keyboard) and the reading view's new linkMenu; copyLinkAtCursor(view) backs gy (zenCopyLink action) and palette id editor.copy-link. lib/clipboard-text.ts is the bridge-first clipboard write that code-block-copy.ts used privately, now shared.
  • @shared/embed-size gains withEmbedWidth(lineText, width | null) and readEmbedWidth(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) holds setImageWidthOnLine / setImageWidthAtCursor (one transaction, userEvent: input.image-resize), parseImageWidthInput, promptImageWidth, setImageWidthFromInput, and attachImageResizeHandle (pointer capture, live width on the element only, commit on release). The live-preview LocalImageWidget wraps the <img> in .local-image-embed-sizer so the handle rides on the picture's edge while the frame keeps its full width; commitWidth re-reads the line under the figure at release time. Palette id editor.resize-image; ex command imgwidth / imgw registered in Editor.tsx.
  • lib/embed-renderers.ts: parseEmbed now returns provider, playSrc (autoplay URL) and poster; renderEmbedElement(el, url, { onActivate }) renders the poster button and mounts the iframe on click; escapeEmbedFrame() moves focus out of an embed iframe to the hosting EditorView (via EditorView.findFromDOM on the .cm-editor root) or the nearest [tabindex] container. Styles under .zen-embed-frame .zen-embed-poster/-play/-provider in index.css.
  • Escape relay: installFrameEscape(win) in desktop main (before-input-event + webContents.focusedFrame.parent) sends IPC APP_FRAME_ESCAPE (app:frame-escape); preload exposes onFrameEscape, optional on the ZenBridge contract so the web and mobile bridges are unaffected; App.tsx subscribes once.
  • vault.ts (desktop), store.ts migration and the watcher share isAtomicWriteTempPath; the Go server mirrors it as IsAtomicWriteTempPath in ListAssets. store.renameNote calls flushDirtyNotes() and throws if anything is still dirty; planCloudSyncMutations covers the linked-note rename with a move plus one update.
  • Dependabot (.github/dependabot.yml) opens weekly grouped PRs for npm production and development dependencies and for GitHub Actions; CI gained a production-dependency-audit job running npm audit --omit=dev --audit-level=high, and the lockfile was pruned of unused chevrotain entries.
  • Both docs surfaces updated: help.ts gains the "Embed videos and link cards" card, a resize sentence on the Links card and an :imgwidth row; the website gets a ```embed syntax entry, the resize sentence on the image-embed entry, and the :imgwidth row in the ex-command table (docs-sections.json + docs.blade.php).

Local-first and keyboard-first, as always.

Don't miss a new zennotes release

NewReleases is sending notifications on new releases.