Merged
Conversation
… is followed (#820) From the Android app (#820): in Reading mode, tapping a `[[Note#Heading]]` wikilink opened the target, but the status bar's left slot kept showing `Note#Heading` on the new note, and kept it until the next tap put a different target there. On desktop the same slot behaves like a browser: the target shows while the mouse rests on a link and goes away when it leaves. A tap reaches the page as `touchstart, touchend, mouseover, mousemove, mousedown, mouseup, click`, with no `mouseleave` ever. The `mousemove` puts the target in the shared hovered-link store, and the only things that cleared it were the article's `onMouseLeave` and the preview root's unmount cleanup. A finger never leaves, and since android#74 the preview stays mounted when a heading link is followed in Reading mode, so neither fired. Nothing else guarded a note changing under a resting pointer. Two clears, the two the report asked for. Following a link clears the slot: the preview root's click handler does it for every anchor (wikilink, note link, hashtag, web, in-page), the editor does it on the rendered-wikilink mousedown and in both `followLinkTarget` branches. And the status bar drops the target whenever the active note's path changes, keyed on the path rather than the note object so typing in a note does not blank a live hover. A real pointer that is still over a link puts its target back on the next move, so desktop hover reads the same as before. Deliberately not done: gating the slot behind `pointer: coarse`. The report accepted the brief flash during a tap, and the two clears above are what made the stale target go away. The wikilink hover card is a separate surface the Android shell already hides with its own `pointer: coarse` rule and is left alone. Verification: `StatusBar.test.ts` gains a "hovered link slot" pair (a same-path re-render keeps the target, a path change drops it; the second is red on the old code and the first guards the keying), and the new `Preview-hovered-link.test.ts` drives a rendered wikilink through mousemove and click in jsdom (target set, then cleared, and `openWikilinkTarget` called with `Alpha plan.md` and `Alpha plan#Milestones`; red on the old code). Reproduced and re-checked in the built app over CDP with the same synthetic tap sequence: before, the footer read `Alpha plan#Milestones` on the opened note; after, it is empty in Reading mode and in edit mode, and desktop hover still shows on enter, clears on leave, and is empty after a click. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…change (#829) From @uNyanda on Linux (#829): words taught to Harper with `zg` were underlined again after updating and restarting ZenNotes, and had to be added a second time. The update never touched them on disk; vault.json keeps `harper.words` and `harper.ignoredLints` through every normalizer. The loss happened at runtime, in the one live Harper session, and the next `zg` or `zG` then wrote that session's export over the vault's list, which is when the words left vault.json for good. Two ways a session ended up without the vault's words. A dialect change: harper.js answers `setDialect` by freeing the Linter and building a new one, and the dictionary and the ignore list live inside the Linter, so `configure()` (which set the dialect and never imported them back) emptied the session on the spot, from Settings or from an edit to `harper_dialect` in config.toml. Reproduced live: after the switch every vault word was underlined, and a `zg` on a third word left vault.json with only that word. And the boot race: an enabled setting warms the session 1.5 s after mount, at idle, and when the vault was still loading (the store sets `vault` and `vaultSettings` together at the end of `init()`) the session was built from empty settings; the old reconcile then recorded the words that landed during the 15 MB compile as already seen, without importing them. That is the shape of the report, a restart with the words underlined and vault.json intact until the next `zg`, and the first launch after an update is a slow one. Which of the two the reporter hit cannot be told from the report; both are closed here. Three changes. `configure()` compares the linter's dialect with the requested one and, only on a change, exports the dictionary and the ignore list, switches, and imports them into the new Linter. The runtime records what a session is built from the moment the build starts, before the compile is awaited, so anything the store loads meanwhile reads as a change to import; the first-pass "only record what it already holds" special case is gone, and a failed load resets both markers. `persist()` writes the union of what vault.json holds and what the session exports (`mergeHarperVaultState`, base order first), never the shorter list, and when the vault knew more it teaches the session the difference so the re-lint after a `zg` clears every word the vault has. The union is safe because nothing in the app removes a dictionary word or un-ignores a suggestion; a "remove word" feature would have to revisit that helper. `reconcile()` also waits in a loop on the pass ahead of it and lets that pass's failure belong to its own caller. Deliberately not done: no recovery of words already lost (they are gone from vault.json; added once more, they now stay), and no change to where or how the words are stored. Verification: `harper-lint.test.ts` gains a dialect-change case on a real `LocalLinter` (red before: no words, no ignores after the switch); `harper-settings.test.ts` covers the merge (order kept, never shrinks); the new `harper-runtime.test.ts` fakes the store and a deferred `loadHarper` to force the boot ordering (the vault landing mid-compile is imported and a `zg` saves all three words; a session that forgot its words never writes a shorter list and learns the difference; an outside change is imported once and the echo of an own write never; a failed load leaves a clean slate), the first two red before. Reproduced and re-checked in the built app over CDP with isolated stores: a vault knowing two words, a dialect switch through config.toml, then `zg` on a third. Before: all three underlined after the switch, vault.json left with `["Glorpish"]`, the two vault words underlined at the end. After: only the unknown word underlined after the switch, vault.json holds all three, nothing underlined. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…ading (#822) From @saran-ncsu (#822): since #543, switching into Preview opens the reading view at the line you were editing, but the way back never followed. Read on through a long note, scroll to a later section, press Cmd+4, and the editor came back at the line the cursor had when you left (the top of the note, for one you had just opened), so you scrolled the whole way down again to fix the sentence you had just read. The reading view was never consulted: Preview hides the editor with its caret in place, and the mode switch only showed it again. Now every route out of Preview (Cmd+4 and Cmd+5, the Edit / Split toggle in the pane header, the palette, `:view`, `:editmode` and `:splitmode`; they all end in `applyPaneMode`) reads what the reading view has on screen off the rendered blocks' source-line stamps, while that view is still in the DOM. If the cursor's line is inside that range nothing moves: the "peek at the render, come back to the cursor" contract from #543 holds. Otherwise the editor opens with the first block still in view at its top, 24 px in like an outline jump, and the cursor on that line, one frame after the switch so CodeMirror has measured the freshly shown scroller. Into Split the reading view reflows to half its width and that block moves, so the landing holds the split scroll sync and scrolls the reading view back to the same block beside the editor's copy. Explicit jumps that leave Preview with a target of their own (a comment jump, an "open at this line" request) pass `landing: 'caller'` and keep it. Also from the report: double-click a paragraph, heading, list item, quote or table in Preview and the note opens in Edit with the cursor on that block's source line, kept at the height the block had on screen (clamped so it neither hugs the top edge nor drops off the bottom); in Split the double-click brings the editor to the block and the reading view stays put. Links, buttons and form controls, image, PDF and Excalidraw embeds, diagrams (whose double-click resets pan and zoom) and transcluded `![[notes]]` (their stamps count the expanded markdown's lines, not this file's) keep their own double-click. The image embed's "Edit this block" button, which switched to Edit and left the cursor wherever it was, now opens on the image's line too; the figure inherits the stamp of the paragraph it replaces. One more change was needed for the landing to hold. With the above in place the Split route still landed on line 1 about two runs in three. CodeMirror (6.43.8) mirrors a new selection into the DOM only while the editor has focus; dispatched into the editor Preview had just hidden, unfocused, the state moved but the DOM caret stayed where the last click left it, and the DOM observer's next flush, some 3 ms later, read that stale caret back as a user selection and snapped the cursor to the old line, a frame before the deferred focus arrived. `landEditorOnLine` now focuses the view before it dispatches. Deliberately not done: Cmd/Ctrl-click to edit (the reading view already gives that chord a meaning, creating a linked note, and a modifier click on plain prose is not discoverable; the double-click is what the VS Code markdown preview does), single-click to edit (it would take selecting and copying text away from the reading view), any change to how Preview lands when you enter it, and a landing for renders without source-line stamps (nothing on screen with a line means the cursor stays put, as before). Verification: `preview-outline-jump.test.ts` gains cases for `previewVisibleSourceLines` (a partly visible top block counts, one whose bottom edge merely touches the top does not, open end at the end of the note, unstamped blocks skipped, half-open range), `previewEditRequestForTarget` (inline content resolves to its block; links, controls, embeds, diagrams and transcluded notes are inert) and `editorLandingTopMargin` (height kept, both clamps, the fallback); the new `local-assets-image-edit-block.test.ts` checks the stamp moves onto the figure and reaches the button's request. Re-checked in the built app over CDP with isolated stores, five scenarios on a 20-section note: scrolled (before: back at line 1; after: the Section 20 heading at the top, cursor on line 157), peek (cursor unchanged), double-click (Edit, cursor on line 97, editor focused), split (9 of 9 after the focus change, about 1 in 3 before) and split double-click (cursor within 5 px of the paragraph's height, reading view unmoved). The in-app manual (help.ts) describes the new behaviour on the modes card and in the Mod+4/5/6 entry. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…ive Preview off (#827) From @vinivinoo (#827), with three screenshots: a note that opens with `---`, `type: topic`, `parent: [[Informatik]]`, `created: 2026-09-20`, `---` shows the three property lines big and bold in Edit mode with Live Preview off; adding a `tags:` line leaves them big; adding ` - todo` under it makes the block normal again. CommonMark is the cause. The editor parsed the whole note as markdown, and markdown knows nothing about frontmatter: a paragraph of `key: value` lines followed by a line of `---` is a setext H2, so the block wore heading typography end to end, and the two fences read as horizontal rules. The `- todo` line looked like a cure because a list item interrupts the paragraph, which turns the closing `---` into a plain rule; that is the third screenshot. The properties card (#616, Live Preview on only) carried CSS that suppressed heading typography inside the card, so the bug hid there and showed with Live Preview off, where the card does not load and the raw text was unprotected. The fix is at the parser, not in CSS. Every ZenNotes editor now parses with a small note grammar (`cm-markdown-language.ts`): a Lezer parser that re-scans the frontmatter range from the raw input on every parse, emits `Document > Frontmatter(FrontmatterMark, FrontmatterMark) Body`, and mounts the markdown parser onto `Body` through `parseMixed`, so the body tree is the same markdown tree every existing extension walks and no heading, list or rule token can come out of the frontmatter in any mode. The block's range is the one the card computes: `isFrontmatterFence` (shared-domain `markdown-lines.ts`) is the single predicate behind `frontmatterEndIndex`, `frontmatterRange` and the grammar. The mount hands the previous markdown tree to the inner parser as fragments, so edits in the body or inside the frontmatter reuse the untouched blocks; the two moments the body start moves (the closing fence typed for the first time, or broken) cost one full body parse, the same as opening the note, because parseMixed cannot find the old mount across that move (`StructureCursor.moveTo` in @lezer/common drops `IgnoreMounts`, same on upstream main). The frontmatter content is left untokenized on purpose: with Live Preview off a note reads as its raw text (#616), and the card styles it when on. Only the fences carry a token (`meta`). Two ergonomics markdown used to provide by accident are kept on purpose: Enter at the end of ` - todo` inside the frontmatter still inserts the next ` - ` at the same indentation, and Enter on an empty `- ` clears it. `insertNewlineContinueFrontmatterList` (cm-frontmatter.ts) does that for lines strictly between the fences and returns false for everything else; the vim-aware keymap registers it ahead of markdown's Enter, deferred to Vim in normal mode like the rest. `Document` carries `indentNodeProp` `() => null` like the markdown Document, otherwise the default Enter would drop the indentation of a nested frontmatter line. `noteMarkdown()` replaces the `markdown({ base, codeLanguages, addKeymap: false })` setup in EditorPane, ExternalFileApp, FloatingNoteApp, QuickCaptureApp and PinnedReferencePane. cm-wysiwyg-blocks drops its frontmatter HR skip (the fences are no longer HorizontalRule nodes); index.css drops the setext suppression and the token-reset list inside the card, keeping only the rule that lets the fence mark inherit the card's transparent fence rows. On screen: with Live Preview off the property lines are body text and the two fences read as plain `---` in body color instead of accent rules; with Live Preview on the card looks as before and `[[Informatik]]` still renders as a wikilink chip. Deliberately not done: `yamlFrontmatter` from @codemirror/lang-yaml (an unclosed `---` turns the entire note into YAML, and it disagrees with the card about a fence with stray whitespace); a markdown block-parser extension (Lezer reuses the old rule node when the closing fence is typed lines below an existing `---`, so the stale heading stays until the note is reopened); YAML syntax coloring in the raw view (#616); the template editor, which keeps its own `yamlFrontmatter` setup where the block is the point; and inline markdown inside the card (`**bold**` in a frontmatter value used to render bold because the value was parsed as markdown; YAML values are strings, and now they read as such). Verification: `cm-markdown-language.test.ts` (13: the reported note parses without a heading, agrees with `frontmatterRange`, an unclosed fence stays a rule, only line 1 starts a block, longer dash runs and fences with text are not fences, empty and frontmatter-only documents, a closing fence straddling the 4096 read boundary, language activity per region, nested code fence languages, fragment reuse for body and frontmatter edits, the block recognised the moment the closing fence is typed and dropped the moment it is broken) and `cm-frontmatter-enter.test.ts` (10, a real EditorView dispatch through the full keymap: list continuation, empty item cleared, nested key copies indentation, top-level key plain newline, closing fence left to the default Enter, cursor before the marker, a body list still continues, an unclosed block is not frontmatter, Vim normal mode Enter is a motion, Vim insert mode continues). Re-checked in the built app over CDP with isolated stores (`check-827-frontmatter-heading.mjs`, release tools): before, with Live Preview off the three property lines carried `tok-heading2` and stood taller than body lines, and with it on the token was still there under the card's CSS; after, no heading token in either mode, the lines at body height, the body's `# InfSec` still an H1 and its real setext heading still an H2, the five-line card with exactly one rule widget and the wikilink chip when on. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
) From @vinivinoo (#825): in visual mode the Vim motions Ctrl+D and Ctrl+U do nothing useful. Reproduced in the built app on a 200-line note. Where Mod is Ctrl (Linux, Windows) `v` then Ctrl+D added a new cursor on every press instead of growing the selection, and Ctrl+U undid the selection; nothing scrolled. On macOS the keys fell to codemirror-vim's stock `moveByScroll`, which happens to work on a plain note but is the motion ZenNotes replaced in normal mode because its pixel math can snap the cursor back to line 1 under live-preview decorations and folded headings. Two things were wrong. The half-page keys were mapped as Vim actions in normal mode only (`zenHalfPageDown` / `zenHalfPageUp`), and an action cannot extend a visual selection at all: it moves the CodeMirror selection but leaves Vim's own `vim.sel` alone. With the visual context unmapped, the key never reached Vim there. CodeMirror's keymap handler runs before the Vim plugin sees a key (the `keymap` facet registers its handler where the first `keymap.of` sits in the extension list, and the snippet keymap precedes `vim()`), so the search keymap's Mod-d (select next occurrence) and the history keymap's Mod-u (undo selection) took the chords. `vimHalfPageKeymap`, which exists to hand these chords to Vim ahead of those keymaps, bailed out in visual mode on purpose, since the action would not have done anything useful there. The half-page move is now a Vim motion, `zenMoveByHalfPage` (`cm-vim-half-page-motion.ts`). Vim moves the head itself, so `v` plus Ctrl+D grows the selection exactly as far as Ctrl+D moves the cursor in normal mode, `V` grows it by whole lines, Ctrl+U shrinks it back, and Escape leaves the cursor where the selection ended. The cursor walks display lines through `moveVertically` and stops at the first or last line instead of wrapping; the viewport scrolls by a fixed half of the visible editor, clamped to the note, which is what the old action did in normal mode. The goal column survives consecutive presses through a short line (`vim.lastHSPos`, reused while the last motion was this one). A count is Vim's: `N<C-d>` moves N lines and scrolls N line heights (`explicitRepeat`, so a bare press still reads as half a page). The pixel path runs inside `pixelMotionFallback`, the #574 safety net now exported from `cm-vim-display-line.ts`: if a coordinate query throws, the cursor still moves by logical lines and the viewport still scrolls. The main editor's keymap sync maps the user's configured `nav.halfPageDown` / `nav.halfPageUp` bindings to the motion in normal and visual context (`VimKeymapMapping` gains a `{ motion, motionArgs }` shape next to `{ action }`), and `vimHalfPageKeymap` now defers only in insert mode, where Vim's own Ctrl+D (unindent) keeps its place. The floating note, Quick Note and external-file windows build their own editor and never had the half-page keys mapped, so they had the stock motion on macOS and, where Mod is Ctrl, lost the chords to the search and history keymaps in normal mode as well: Ctrl+D selected the word under the cursor, visual Ctrl+D stacked cursors. Each now lists `vimHalfPageKeymap(prefs.vimMode, {})` first in its keymap and calls `registerHalfPageMotion()` plus `mapDefaultHalfPageKeys()`, which maps the default Ctrl+D / Ctrl+U to the motion in both contexts. Those windows consult no keymap overrides for any other binding either, so a remapped half-page key still applies to the main editor only. Deliberately not done: operator-pending `d<C-d>` stays on Vim's default motion, like j/k; insert-mode Ctrl+D is untouched; and the in-app manual already says Vim keeps Ctrl+D / Ctrl+U for half-page scrolling, which is now true in every mode, so its wording stands. Verification: `cm-vim-half-page-motion.test.ts` (12, on a fake view with known geometry: half-page distance in lines and pixels, the typed count, at least one line and no division by a zero line height, forward and back with the scroll clamped at both ends, no wrap at the first or last line, a count that moves and scrolls N lines, a leftover repeat ignored, the goal column kept through a short line, the logical fallback when the pixel path throws, one line per step without a view); `vim-half-page-keymap.test.ts` now mounts with a keymap before `vim()` as EditorPane does and gains the visual-mode case, red against the old visual-mode bail (two ranges) and red for three of five cases when the keymap is left out of the mount; `cm-vim-half-page-default-keys.test.ts` (4, jsdom, the secondary windows' wiring with the real motion: normal Ctrl+D moves instead of selecting the word, a visual selection grows as one range and shrinks back, a count moves N lines, insert mode is left alone; three of four red when either the mapping or the keymap entry is removed). `npx turbo run typecheck --force` 7 of 7; app-core 2603 passed and 1 skipped. Re-checked in the built app over CDP with isolated stores (`check-825-visual-half-page.mjs`, `check-825-floating.mjs`, release tools), each in a macOS run and a run with `navigator.platform` overridden to Linux: main editor 13 of 13 (normal Ctrl+D lands on line 11 with the scroll to match, `v` plus Ctrl+D selects lines 1 to 11 as one range with the same scroll, a second press reaches 21, Ctrl+U back to 11, `V` selects whole lines, a selection started mid-window stays on screen, insert-mode Ctrl+D does not page); floating note and Quick Note windows all green (1 to 13 to 25 in the floating window, 1 to 8 to 15 in the smaller Quick Note window, one range throughout, cursor count unchanged). Before the fix the Linux runs showed three stacked cursors and no scroll in both the main editor and the floating window. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
From @diazkev314 (#831): with vault A open in the desktop app, `zn mcp --vault <vault-B>` served vault A. `vault_info` named the app's workspace, and `--vault /tmp` still answered with the real vault. The same path left `--server` and `--token` unused, even though both are advertised as global flags and the MCP authentication error itself suggests `--token`. Reproduced against the built `cli.js` over stdio with isolated config and user-data directories: every flag scenario answered vault A, and a fake server named with `--server` was never contacted. The CLI dispatcher parsed the flags and then called `cmdMcp()` without them, and `runMcpServer()` had exactly one way to find a vault: `resolveDefaultTarget()`, the environment first and then the workspace the desktop app has open (#688). That is the right answer for the legacy `out/main/mcp.js` entry, which has no flags at all, and it was the only answer `zn mcp` could give. `ZENNOTES_VAULT` and `ZENNOTES_SERVER` worked because they sit inside that default path, which is how the flags' failure stayed hidden when both were set. `runMcpServer` now takes a `resolveTarget` function and `cmdMcp` hands it the parsed arguments through the same `resolveTarget()` every other command uses, so `--vault <name or path>`, `--server` and `--token` mean the same thing in `zn mcp` as in `zn list`. Without flags the server follows the environment and then the app, as before, and the legacy entry is unchanged. The vault resolves once at startup and stays pinned for the session; a failure is retried on every tool call rather than repeated. A `--vault` that names nothing does not kill the server. The CLI's usual "resolve, then fail the command" would leave an MCP client with a dead process and no message, so `zn mcp` resolves at startup, prints the error on stderr (`[zennotes-mcp] No vault named "...". Known vaults: ... The MCP server is running anyway; every tool call returns this error until a vault resolves.`) and keeps serving; each tool call returns the same error text so the assistant can relay it, and the next call tries again. stdout is the protocol channel and stays clean; a healthy start prints nothing. The in-app manual's MCP card now tells people to pass `zn mcp --vault work` or `zn mcp --server home --token <token>` in the client config, next to the existing environment variables. Deliberately not done: the legacy stdio entry gains no flags (its clients launch it with none); the flag shape is not mirrored in the Go `zn`, which already passed its flags into the MCP server. Verification: `server.test.ts` gains five cases that run the real server over an in-memory transport pair against a scratch config directory with two known vaults: no flags follows the active vault with an empty stderr; `--vault` by path and by known name serves the other vault, `list_notes` included; a bad `--vault` writes exactly one stderr line naming the known vaults and `vault_info` still answers with the error; `--server 127.0.0.1:<port> --token secret-831` reaches a fake HTTP server with `Bearer secret-831` on every request and reports `kind: remote`; a resolver that fails twice and then succeeds is called three times, the first success stays pinned. Four of the five were red with the resolver ignored. `npx turbo run typecheck --force` 7 of 7; desktop 891 passed and 4 skipped. Re-checked with the built `cli.js` over stdio (`check-831-mcp-flags.mjs`, release tools): before the fix 5 of 7 checks failed; after, 11 of 11 pass, including the stderr warning arriving before the first request and the legacy `mcp.js` entry still following the active vault. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
From @uNyanda (#828), Vim mode with the insert escape set to `jk` (Settings, Editor, Vim, "Exit insert mode with"): leaving insert mode with `jk` without changing any text updated the note's `{{modified_time}}`, `{{modified_date}}` and `{{modified_datetime}}` tokens, while a plain Escape did not. Reproduced in the built app on a note carrying `Updated: {{modified_time:HH:mm:ss}}`: `jk` moved the file's mtime and the rendered stamp, and so did typing a character and deleting it again before Escape. The cause is in how codemirror-vim (6.3.0) handles a two-key insert mapping. On the `j` it cannot yet know whether `k` follows, so the `j` IS typed into the document; when `k` arrives inside the 200 ms window the `j` is removed again. Two document changes, identical final text. The editor's update listener calls `updateNoteBody` on each, and the store marked the note dirty on the insert and never un-marked it on the removal, so the 350 ms debounced `persistNote` wrote the same bytes back to disk. The mtime moved with the write, and the tokens render from the note's `updatedAt`, which follows mtime. Nothing in that chain is vim-specific: type-a-char-then- backspace and undo-back-to-the-saved-text took the same path. The store now remembers what is on disk for every dirty note. The moment a clean buffer first drifts from disk, `updateNoteBody` snapshots the previous body (a clean buffer equals disk); each completed `persistNote` moves the snapshot to the bytes it wrote. An edit that brings the buffer back to the snapshot is not a change: the note is marked clean, the pending debounced write is cancelled, and no write happens. Real typing that ends in `jk` still saves once, because the buffer does not return to the saved bytes. Two choices in the comparison. It is skipped while a write is in flight (`pathSaveQueues` holds the path), because the bytes on disk are changing at that moment; a revert then just saves normally, so an edit is never lost to the shortcut, and the completion records what actually landed for the next comparison. And a buffer that returns to the saved bytes is clean even if another program has changed the file underneath in the meantime: the app keeps showing its own text and no longer clobbers the external change with a pending save, where before it did. A visible side effect is that the "Unsaved changes" dot clears on a revert, which is what VS Code does. Deliberately not done: skipping editor updates while a Vim key buffer is pending (a lone `j` followed by the timeout would lose the character if the app quit in between), and comparing bytes in the main process before writing (an extra read on every save, desktop only, and the web client shares this store). The watcher, vault resets and `withoutNoteInWorkspace` are unchanged; a snapshot left behind for a clean note is replaced on that note's next edit and is never read while the note is clean. The in-app manual's wording, that the tokens show when the file was last saved, stands. Verification: `store-note-integrity.test.ts` gains five cases under "#828: a buffer back on its saved bytes is not rewritten": an inserted character removed again cancels the save (no write, dirty false, `activeDirty` false); real typing ending in the escape sequence saves once; a revert is measured against the last save and not the body the note opened with; a revert while a write is in flight still lands on disk; typing ahead of a write and returning to the written body is clean. Three of the five were red on the old store. `npx turbo run typecheck --force` 7 of 7; `test:run` green on every package (shared-domain 1685, app-core 2608 passed and 1 skipped, desktop 891 passed and 4 skipped). Re-checked over CDP in the built app with both stores isolated and `insert_escape = "jk"` (`check-828-escape-mtime.mjs`, release tools): before the fix 4 of 12 checks failed, `jk` moved the mtime and the stamp from 09:19:37 to 09:19:40 and the dirty dot lit up; after, 12 of 12 pass, mtime and stamp unchanged through `jk` and through type-then-backspace, the dot stays off, and a real edit still reaches disk. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…826) From @vinivinoo (#826): in note search (Space f, Mod+P), a name that matches no note ended in "No matches." and a trip through New Note to type the same name again. Obsidian's quick switcher offers to create the note right there with Shift+Enter; the request was the same move in ZenNotes. The palette now reads the free text of the query as the name of a note to create. When no live note carries that exact name, a Create row follows the results ("Create "Meeting notes"" with a badge that names the destination, INBOX or INBOX/projects), the arrow keys reach it, Enter on it creates, a click creates, and Shift+Enter from anywhere in the palette creates without moving the highlight. The new note opens in the editor with the focus there, the same landing as `:e name` and the dead-wikilink flow, because its name is already settled. Tags in the query keep filtering the results and are never written into the new note. Where the note lands follows `:e`: a bare name goes to the Inbox root, `projects/roadmap` nests under Inbox, a leading top folder (`archive/old`, `quick/x`) picks that folder, and `/name` forces the Inbox root. The parsing is `parseCreateNotePath`, so the same names are rejected as everywhere else (`# ^ [ ] \ : * ? " < > |`, `.` and `..`), and a path into the Trash is never offered. An exact match hides the row. A live note at the same path, or, for a bare name, any live note with the same title (case-insensitive), counts as "already there": the row disappears and Shift+Enter opens that note instead, so a Shift+Enter typed a moment too late cannot mint a same-named twin. Fuzzy and partial matches do not count ("Road" still offers to create "Road" next to Roadmap), and a trashed note never counts. `/Name` is the escape hatch for a real duplicate in the Inbox root. The footer gained `Shift+↵ create`. To keep it on one line at the palette's width, the `Ctrl+J/K` badge is gone (the sibling palettes list only `↑↓ Ctrl+N/P move`; the keys still work through the shared palette navigation and the in-app manual still documents them) and "move to trash" reads "trash". Deliberately not done: tags from the query are not added to the new note (a `#tag` in the search is a filter, and a note created from a search with a tag that then gains that tag would be a surprise both ways); no other palette gained a create row; and the destination is not the "New notes go to" setting, since `:e` and the dead-wikilink flow already read a bare name as Inbox and the row names the target before anything is written. Verification: `npx turbo run typecheck` 7 of 7. New unit tests: `search-create.test.ts` (6: bare name to Inbox root, slash paths and top folders, nothing for empty, unfileable or Trash names, exact match found case-insensitively, fuzzy and partial matches not counted, trashed notes never counted) and a describe in `SearchPalette.test.ts` (7, jsdom: the row for an unknown name and Shift+Enter creating in Inbox, the row after fuzzy matches with the arrows reaching it and Enter creating, plain Enter on a match never creating, a typed path nesting the note with the badge naming it, the row hidden for an exact name with Shift+Enter opening that note, nothing for an empty or tag-only query or an unfileable name, tags filtering but never naming the note); 16 of 16. The full app-core suite: 2621 passed and 1 skipped. Live in the built desktop app over CDP with both stores isolated and Vim mode on (three runs, 23 of 23 checks each): Mod+P and `Space f` open the palette; "Meeting notes" shows the row and Shift+Enter writes `inbox/Meeting notes.md` with `# Meeting notes` and the editor focused; "projects/Q4 plan" shows INBOX/projects and writes `inbox/projects/Q4 plan.md`; "roadmap" with `projects/Roadmap.md` present hides the row and Shift+Enter opens Roadmap; a tag-only query and Escape create nothing. Demo clip recorded from the same build for the release notes. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
…and tags (#826 follow-up) The first cut of #826 created the note in one keystroke: Shift+Enter in note search wrote `inbox/<query>.md` and opened it. Trying it raised three gaps. There was no say over where the note lands short of typing a path into the search box; a name that already existed elsewhere in the vault was created again with nothing said; and tags had to be typed into the body afterwards, even when the search already carried `#tag` words. The Create row now stands for any free text in the query and reads `Create "Meeting notes"…`, the ellipsis saying a step follows. Enter or a click on it, or Shift+Enter from anywhere in the palette, opens a New note form inside the same palette modal, with the query kept so Escape returns to the results and the search input. The form has three fields. Name is prefilled from the query and selected, so retyping replaces it and Enter creates right away: the fast path costs one Enter more than before, in exchange for seeing the target before anything is written. Folder speaks the `:e` dialect (empty is the Inbox root, `projects/x` nests under Inbox, `archive/x` and `quick/x` pick that area) and, once focused, lists every folder of the vault below the fields, roots first: Inbox, Quick Notes, Archive and their subfolders, never the Trash and never a database folder. Typing narrows the list and preselects the first match, Enter takes it and moves on to Tags; ArrowUp above the first row keeps the typed value, which is how a folder that does not exist yet gets created. Tags takes chips: the query's `#words` arrive as chips, the vault's tags are listed most used first, typing narrows them with a "new tag" row last, Enter picks, Space or comma commits the typed text, Backspace on an empty field removes the last chip, and text that cannot be a tag blocks Create with the reason. Before writing, the form checks the name against the live notes. A same-titled note in the chosen folder blocks Create and says `"Weekly review" already exists in Inbox. Change the name, or open it.`, with Shift+Enter (or the Open it button) opening that note instead. A same-titled note in another folder only warns, since the folder was chosen on purpose. Comparison is by title, ignoring case, and never counts a trashed note or a partial match. The status line under the fields always names the outcome in words: `Creates "Q4 plan" in Inbox › projects with #planning #q4`. Ctrl+Enter or Cmd+Enter creates from any field. Tags are written as one `#a #b` line under the heading, the way `zn create --tag` and `zn capture --tag` write them, not as frontmatter; a vault with no frontmatter habit should not gain one from this form. `createAndOpen` takes the tags and writes the body after the vault has settled the title, so a name the vault adjusted still heads the note. `countVaultTags` and `rankTagCompletions` moved to `lib/tags` so the form and the two editor completions share one ranking. Deliberately not done: no frontmatter; no folder creation UI beyond typing the path; no create rows in the other palettes; and the default destination stays the Inbox root rather than the "New notes go to" setting, matching `:e` and the dead-wikilink flow. Verification: `npx turbo run typecheck` 7 of 7; `npm run test:run` all green (app-core 2644 passed, desktop 891). New unit tests: `search-create.test.ts` (21: draft splitting in the `:e` dialect, destination parsing and the Trash and database refusals, name checks, collisions by folder and by title, the folder picker's order and filtering, tag normalization and ranking, the body the CLI would write), `SearchPalette.test.ts` (13 in the form describe, jsdom: Shift+Enter opens the form with the name selected and Enter creates in Inbox; the row follows fuzzy matches and Enter on it opens the form; plain Enter on a match never does; a typed path fills Folder and the picker filters, picks and moves to Tags; ArrowUp keeps a typed folder that does not exist; a same-folder name blocks Create and Shift+Enter opens the note; a name elsewhere only warns; query `#words` become chips and the picker offers vault tags; leftover Tags text counts on Create and a non-tag blocks; Ctrl+Enter and Cmd+Enter create from any field; an unfileable name opens the form with the reason and Escape goes back with the query kept; a Trash path is refused; nothing for an empty or tag-only query), and `store.test.ts` (2: the tag line under the settled title, and the body untouched without tags). Live in the built desktop app over CDP with both stores isolated and Vim on, 44 of 44 checks: the form opens from Shift+Enter, Enter on the row and a click; Enter in Name writes `inbox/Meeting notes.md`; Tab into Folder lists every folder, "proj" narrows to projects, Enter picks it and lands in Tags, "pl" + Enter takes planning, "q4" + comma adds a new one, Enter writes `inbox/projects/Q4 plan.md` with `#planning #q4` under the heading; "Weekly review" blocks Create and Shift+Enter opens the existing note with no twin on disk; Escape returns to the results with the query kept. Demo clip recorded from the same build for the release notes. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
The release PR's macOS run failed one new #828 test, "typing ahead of a write and then returning to the written body is clean", with a second write the test never made: `INDEX_BODY with unsaved edits`. That body belongs to an older test in the same file, "still refuses to let an add event overwrite unsaved edits". It edits the open note, which arms the store's real 350 ms autosave timer, checks the buffer, and returns with the timer still running. The timer fires into whichever test is on the clock 350 ms later and writes through that test's `window.zen`, so the stray write lands in its `writeCalls`. Nothing sat after that test before this cycle, so the leak always fired after the file was done and stayed invisible. The #828 group is the first downstream of it that counts writes, and on the slow macOS runner the timer landed inside its last test; the other platforms and a local run were fast enough to fall through a harmless gap. A scratch copy of the file with a 400 ms pause in the #828 setup reproduces the failure on every run and passes with this change. The watcher test now settles its own save through `persistNote` and asserts the edit reached disk, so no timer outlives it. The #828 test also installs fake timers before its first edit instead of halfway through: it used to arm one real timer and then hand the real id to the fake `clearTimeout`, which cannot cancel it. Test-only change; the store is untouched. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0c45f-844c-77b8-846b-3e25b2447dab
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.54.0
One feature and seven fixes from the issue tracker. Three from @vinivinoo (#827, #825, #826), two from @uNyanda (#829, #828), one each from @saran-ncsu (#822) and @diazkev314 (#831), and one filed by the maintainer from the Android app (#820). Every issue is closed with a commit-linked comment.
Features
Fixes
zn mcphonours--vault,--serverand--token([Bug] JavaScript zn mcp ignores --vault, --server and --token flags #831)Verified before this PR
npm run typecheck7 of 7, nothing from cache;npm run test:run: shared-domain 1,685, app-core 2,644 (1 skipped), desktop 891 (4 skipped).cd apps/desktop && npm run build:prodfresh, thennpm run pack; the packaged app launched with both stores isolated: CDP page target in 1.5 s, version 2.54.0.test:vim-editor,test:sidebar-vim,test:editor-improvements, all green with no renderer console errors.Docs: in-app help updated in the commits; the website mirror is the branch
website/docs-2.54, merged with the release.