Skip to content

ZenNotes 2.55.0 - #845

Merged
adibhanna merged 15 commits into
mainfrom
v2.55.0
Sep 23, 2026
Merged

adibhanna merged 15 commits into
mainfrom
v2.55.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

ZenNotes 2.55.0, cut from branch v2.55.0. main fast-forwards to this branch once the checks pass; the tag v2.55.0 goes on the release commit.

Feature

  • Workflow event triggers fire for the edits made in the app: trigger: on note-saved, on note-created, on note-moved and on tag-added run a workflow for the note that changed, about a second and a half after it settles, with a receipt that carries Undo and a kill switch under Settings → Workflows ([Bug] Workflow triggers do not work #843).

Fixes

Docs

Release notes, demo clips and the verification log live in docs/releases/v2.55.0/ (local).

…838)

From @uNyanda (#838, Niri on Wayland, 2.54.1): on a Kanban board wider
than the window, dragging a card to the board's left or right edge never
scrolled it, so a card could not reach an off-screen column in one drag.
Released at the edge, it landed in whatever column was under the
pointer. Reproduced in the built app with eight custom-status columns: a
card held at the right edge left the board at scrollLeft 0 of 1736, and
the release wrote @status:review. The same wall stood at the left edge,
at the bottom of a long column, and for a dragged column header.

The board moves cards and headers with pointer events rather than HTML5
drag-and-drop, so it gets none of the browser's built-in drag
autoscroll. lib/drag-autoscroll.ts adds it: a pointer held in a 64 px
band along a scroller's edge scrolls it, easing up to 1200 px/s at the
edge and holding that speed past it, so an overshoot into the sidebar
keeps going. A frame loop does the scrolling, because a resting mouse
sends no pointermove, and it carries sub-pixel steps so the slow end of
the band still moves. A card drag scrolls the board sideways and the
column under the pointer up and down; a header drag scrolls the board.

A scroll under a still pointer slides a different column beneath it, so
a capture scroll listener on the board re-aims the drop highlight and
the insertion line. That also fixes a highlight that already went stale
when the board was wheel-scrolled mid-drag.

A drop now puts the cursor on the dropped card, and a dropped column
takes the cursor with it, the way a click, a right-click, Shift+H/L and
`<` / `>` already do. That is load-bearing, not polish: the focus-follow
effect scrolls the cursor's card into view, and with the cursor still in
the first column the board jumped from 1736 back to 21 the moment a card
landed on the far side. The cursor is set before the move, because the
move flushes the board rebuild synchronously and that commit already
runs the scroll.

The Tasks Kanban entry in the manual now covers the edge scroll and
where the cursor lands after a drop.
…ame says why (#839)

From @vinivinoo (#839, a root-mode vault): New note from the sidebar,
type "test" in the title field, Enter, and the note stayed "Untitled".
Every retry failed the same way, and nothing on screen said why.
Reproduced in the built app: a creation date left at
.zennotes/note-metadata/test.md.metadata.json with no note beside it
refused every rename onto "test" with "The destination folder or its
comments already exist", and the store's renameNote only wrote that to
the console.

Since creation dates moved into sidecars (b6c3b9d), every note saved in
the app has one, and anything that moves a note without it leaves the
old one behind: a workflow run (the applier moves the Markdown file
only, and the reporter runs a "Move Topics" workflow), the MCP rename, a
file manager, git, sync. Creating a note at that path already discarded
the stale date; renaming or moving onto it refused, and a test pinned
that refusal.

Renames and moves onto a path with no note now discard a leftover
creation date the same way creating a note there does. One relocateNote
in desktop main backs rename, move, archive, trash and restore, and the
MCP copy's moveWithCreationMetadata does the same for a single note. A
folder moved onto a leftover tree of dates still refuses: dropping a
whole tree is a bigger call than one date. Leftover comments still
refuse, since adopting another note's discussion and deleting it are
both wrong, but the error now names the file to move aside. A name that
belongs to a real note says "A note named “test” already exists in this
folder".

Every rename in the UI goes through the store's renameNote, which now
shows the reason in a toast instead of failing silently. The refusal
test flips to expect the rename (desktop and MCP), and new tests cover a
renamed note never inheriting a stale date, a move and a trash onto one,
both refusal messages, and the toast.

Not done here: making the workflow applier and the MCP rename carry the
sidecars, so they stop leaving them behind. That needs the workflow
journal to cover the sidecars for undo, and lands separately.
…d undo puts them back (#839)

Follow-up to #839. The workflow applier moved a note with a bare
fs.rename, so a move, rename, archive or trash step left the note's
comments (.zennotes/comments/<note>.comments.json) and creation date
(.zennotes/note-metadata/<note>.metadata.json) behind under the old
name: the discussion no longer showed on the note, the moved note took
the run's time as its creation date, and the leftovers blocked or
polluted the next rename onto that name, which is how #839's reporter
got there. The MCP server carried the date but not the comments, and
never removed comments on delete, Empty Trash or folder delete.

relocateNote, relocateFolderTrees and the comments paths move from
vault.ts into an Electron-free note-sidecars.ts, with the refusal
message shared, so desktop main, the MCP server and the applier follow
one policy. The MCP server now moves, trashes, restores and renames
folders through them and removes comments where the desktop does.

In the applier, a path op reads both sidecars before it journals
anything, records them with the note, then moves them after it.
Leftover comments at the destination fail the run, which rolls it
back, with the file named; a leftover date is journalled and replaced.
The entries name the note, not the sidecar file, which is derived at
restore time after resolveVaultPath, so a ledger still cannot aim a
write anywhere but a note's own sidecar. They carry no `path` and sit
in a separate `sidecars` ledger array: an older ZenNotes or znserver
reading the ledger resolves every journal path as a note and would
fail the undo on a .zennotes one; this way it skips them and still
undoes the notes.

Undo writes a moved note back as a new file, so a note that never had
a date file came back with the undo's time as its date; the run now
writes the date down before moving such a note (not in a temporary
folder session). Journalling up to six files per move with a sync each
made a 400-note move three times slower, so a path op now appends its
entries with one sync, down from two before sidecars, and undo renames
an untouched sidecar back once its bytes match instead of rewriting
it. Undo prunes the sidecar folders it empties, because a folder
rename refuses any existing destination tree. A rollback cause that
already ends in a full stop no longer doubles it.

Tests: note-sidecars.test.ts runs the same rename, move, Trash, folder
and delete cases against desktop and MCP (the 8 MCP ones fail on the
old code), and workflow-apply.test.ts gains 20 cases for each path op,
undo, rollback, leftovers, the crash journal, old and forged ledgers,
drift, the sync count and the rename on undo (19 fail on the old
applier; the old-ledger case passes on both by design).

Not done here: znserver applies prepared change sets that cannot
express sidecars, so a workflow move on a remote vault still leaves the
comments behind. Two older applier bugs found on the way are separate:
text ops reset the creation date of a note with no date file, and
undoing a move of a symlinked note deletes the link's target.
…, not a copy, and keeps the file behind it

A note can be a symlink to a file kept elsewhere, outside the vault
included; the app lists and edits it like any other note. A workflow
move, rename, archive or trash renames the link itself (fs.rename in
movePathInVault), so the file behind it never moves. Undo did not know
that. Its journal held only the bytes read through the link, so for the
destination (before: null) it took the link for a file the run had
created, followed it and deleted the target, a file the vault may never
have owned; for the source it wrote the bytes back as a plain file; and
the link itself stayed at the destination, pointing at nothing. Found
while giving the applier sidecars (1cdb0e8) and reproduced in the built
app before this change: after Undo the outside file was gone.

A note's journal entry now records the text of the symlink its path was
before the run, read at first touch like the bytes, in the crash journal
and the ledger alike. Undo moves that same link back: it finds it by its
text among the paths the run filled, renames it home, and only then
compares the bytes. A path the run filled is emptied by removing what
sits there as itself, never through a link. The one target undo still
removes is behind a recorded link that pointed at nothing before the
run, whose target the run created by writing through it, and only while
that link still has the same text.

A link is never created from what a ledger says. An edited or synced
ledger could otherwise plant a link to anywhere and write through it,
which is the write primitive resolveVaultPath exists to rule out; a test
pins that. Ledgers from before the text was recorded still undo, and
without it the note comes back as a copy while the file behind the link
is left alone, where the old code deleted it. uniqueRel now treats a link
that points at nothing as taken, since the rename would have replaced it
with nothing to put back.

Nine tests in the symlinked-notes block cover undo, rollback, an edit
through the link before the move, a note moved twice, a relative link,
the crash journal and its recovery, an old ledger, a forged one and a
dead link at the destination; eight fail on 1cdb0e8.

Not done here: moving a relative link to a different folder depth breaks
it in the forward direction, in the app's own move as much as here.
… creation date alone

Since creation dates moved into sidecars (b6c3b9d, 2.51), a note the
app has never saved has no date file and shows its file's birth time.
Saving replaces the file (temp file plus rename), so writeNote writes
the date down first. The applier's text ops (add-tag, remove-tag,
set-frontmatter, append, prepend, write-section, write, apply-template)
replaced the file the same way without doing that: every such note they
touched took the run's time as its creation date, and undo, which writes
the bytes back as a new file, reset it again to the undo's time. Found
while giving the applier sidecars (1cdb0e8), confirmed in a throwaway
test (the date moved by exactly the wait before the run) and reproduced
in the built app before this change.

keepCreationDate writes the date down through prepareNoteCreation when
the note has no date file, and both writers call it before they journal:
applyTextOpToVault for an existing note, and sidecarsToCarry for a move,
where the block that did this inline moves into the helper. A date file
already there is left alone, readable or not, so a corrupt one cannot
fail a run the way it fails a save; nothing is written in a temporary
folder session, which writeNote never writes app state into either. It
is the one write not journalled first, safely: it records a date the
note already had, where the app already looks for it.

A note the run creates keeps its own birth time. A date file left behind
by an earlier note of that name is journalled as a sidecar entry and
removed, as createNote removes it, so undo puts it back with the rest.
journalPathOp becomes journalOp and absorbs journalTouch, since a text
op now journals a sidecar too.

Eleven tests cover six text ops through the run and the undo, the date
written down before the note, a date file left alone, a temporary folder
session, a created note over a leftover and its undo, and a created note
with no date file of its own; eight fail on 25f4040.
On a vault served by znserver, the app plans a workflow run and sends
the server the file changes to make; a move arrives there as a delete
and a write. The server carries a note's comments on its own renames
and moves, but a workflow move left them behind under the old name,
detached from the note, the way the desktop applier did before 1cdb0e8:
the changes name only notes, and a note's comments live in .zennotes,
which a change may not name.

The prepared run now also lists its moves, one per path op as it lands,
suffix and all, in order. The matching change in ZenNotes/znserver
carries each note's comments with its move, refuses a destination where
an earlier note's comments still sit, and records what it moved in the
desktop's ledger format so undo puts it back. The web bridge and the
desktop's remote client pass the prepared run through unchanged, so
nothing else moves here. A server from before the field ignores it and
moves the Markdown alone; an older app sends no moves and gets what it
always got.
…t its file

A link's relative text is read from the link's own folder, so a note
that is a link like ../sources/Real.md, moved from inbox/ into
inbox/Topics/, named inbox/sources/Real.md from there and opened empty.
That is what mv and Finder do, and it was what every move in ZenNotes
did: the app's own moves and the MCP server's (renameDirectory in
note-sidecars.ts, a plain fs.rename) and the workflow applier's. Seen
while fixing the applier's undo of a moved symlink (25f4040).

rebaseMovedLink runs after every such rename: a relative text is
re-based on the file it named from the old folder, so it names that
file from the new one, written as a new link renamed over the old so
there is no moment without one; an absolute text needs nothing. Inside
renameDirectory it also covers every rollback that renames a link back.
The applier's undo matches a moved link by the file it resolves to
rather than by its text, since the text changed on the way out, and
re-spells it with the recorded text only when that text names the very
file the link now names: the same reach, spelled as it was. Links inside
a moved folder move with their folder and are not followed; a folder
moved to another depth still breaks the ones that pointed out of it.

Tests: the depth-changing move and its undo, a text path.relative would
not have produced, and the same move on the desktop and the MCP server.
On a vault whose primary notes live at the root, the Workflows "Notes in
folder" source could not start from those notes. The engine matched a
folder by directory, and the root's own directory is the empty name it
refuses on purpose (`folder ""` once matched the whole vault, Trash and
Archive included); `folder inbox` matched nothing there, because no note
has an `inbox` directory; and the folder picker offered only the
directories notes sit in, the buckets deliberately left out. The reporter
typed `inbox`, got "0 notes", and had no way to say "all my notes".

The engine already read `folder trash` and `folder archive` as the Trash
and the Archive wherever the vault keeps them. Now all four system names
work that way, for the `folder` source and the `in` filter alike: a note
matches by its directory, or by the bucket the reader classified it into.
`folder inbox` is the primary notes area, the root itself on a root-mode
vault and the inbox wherever it was mapped on a remapped one; a directory
name still narrows to that directory, and `where folder = ...` still
compares the directory.

The picker leads with the four names, each hinted with the sidebar's label
for it, and `inbox` reads "Vault root" on a root-mode vault, so typing
"root" finds it: the combobox options grew a hint that is rendered and
searched. The step descriptions, the manual's grammar card and the website
docs say so.

Deliberately not changed: the `move` step's destination is still a
directory name.
… shells (#842)

The New note form that the note search opens from its Create row shows
hardware-keyboard hints: "↑↓ pick", "↵ create" and "esc back" in the footer
beside Back and Create, and a "Shift+↵" chip inside the Open it button when
the name already exists. On a phone they are noise, in a footer that sits
right above the soft keyboard. The phone shells hide every other palette
footer with one CSS rule, but this footer also holds the buttons, so that
rule must not match it, and the hints had no hook of their own.

Now the hint group and the chip carry `data-keyboard-hints`. The shells hide
whatever carries that marker on the phone layout (one rule in each
`mobile.css`, landing as a PR in each shell); a tablet with a keyboard keeps
the hints, and nothing changes on the desktop. The alternative, dropping the
hints when the host is ios or android, would have taken them from those
tablets too.

Not changed: the key handling. Enter, Escape and the arrows keep working
wherever there is a keyboard.
)

Since 2.20 a workflow's `trigger:` line accepted `on note-saved` and its
siblings, and the manual said what they did: nothing. They parsed, and the
workflow ran when you ran it. A user wrote `all | contains type: topic |
move Topics` under `trigger: on note-saved` and, reasonably, expected the
note to move when they saved it.

Now they fire, in a deliberately narrow form. Only edits made in this app
fire: the store rings a small bus (`lib/note-events`) from its own write
paths (a save, a creation, a rename or move, an archive, trash or restore,
and a save that gave the note a tag), and a dispatcher (`lib/workflow-
events`) installed at vault open turns that into a run a moment after the
note settles. A change that arrives by sync or from another device does
not fire: two synced desktops both firing on each other's writes would run
every step once per device and bounce an `append` between them forever,
which is why the design document wants one executor per vault; that model
stays a design, and this shape can grow into it without changing what a
workflow file means. What a run writes never fires either: the host
applies it, and nothing about it passes through the store's save path.

A run sees only the note that fired: every source resolves over it, so the
reporter's line means "file this note under Topics when it says so" and
nothing is read across the vault on every autosave. `on <event> where
<field> <op> <value>` is planned by the engine as `current | where ...`
over the same reader. There is no confirmation: a run that changed nothing
says nothing, a note that is dirty again waits for its next save, another
dirty note the run would write is left alone and named, a condition the
engine cannot read is reported once, and the receipt is the palette's
toast with its Undo.

An editor open on a note the run moves follows it, the way it follows a
rename made in the app: `followWorkflowMoves` shields the promised paths
from the watcher's unlink echo while the host applies the run, then
carries the tab, the buffer and the undo history to where the note landed,
checked against the disk byte for byte so a suffixed landing is never
mistaken for the note. The run record remembers the moves, so Undo carries
the editor back; the canvas's own Run and Undo do the same.

Settings, Workflows gains "Event triggers", a portable pref
(`workflow_event_triggers` under `[view]`), so every trigger on a device
can be silenced at once; `status: draft` and `trigger: manual` still
silence one. Schedules still parse without firing, and a folder rename
does not fire for the notes inside it.
On a vault whose notes live at the root, `:mv` (and Move... from the
context menu, the note list, the Archive view, the palette and a
multi-selection) opened on `inbox` and offered `inbox/Areas`,
`inbox/Projects`, folders the vault does not have. The prompt spoke in
the bucket's terms: a destination had to start with `inbox` or `archive`,
suggestions were built as `<bucket>/<subpath>`, and the initial value came
from the path's first segment, which on a root vault is a real folder
name and so fell back to `inbox`. Every folder of the notes area is filed
under the inbox bucket whatever the layout, so the words were right for
the host and wrong for the person reading them.

Now the prompt speaks the way the sidebar does. Destinations are folders
of the notes area as it shows them (`Areas`, `Work/Research`, nothing in
front), empty for the notes root, labelled "Vault root" when the notes
live there and with the inbox's label otherwise, and `archive/...` for the
Archive, by that name even when the archive folder is called something
else on disk. The prompt opens on the note's current folder in that
spelling, an empty field moves to the root, and the reserved names
(Quick Notes, Trash, their directory names) are refused with one message
instead of being turned into folders. The older `inbox/Work` spelling the
manual taught still lands in Work; on a root vault too, unless a real
folder named `inbox` sits at the root, in which case it means that
folder. Folder and database moves use the same vocabulary.

`moveNoteVocabulary(settings, labels, folders)` carries these facts, every
caller passes it, and only null is the Cancel now that empty is an
answer. The manual's three mentions and the website were reworded.
A user with Vim mode off clicked a folder in the sidebar and got a pane
ring around the whole sidebar and an `m` chip on the row, for a key that
did nothing. The same shape was everywhere a surface hints at its keys:
the note list's ring, the connections panel's strip and cursor card
(`j/k`, `p`), the comments panel's strip and the badges under a comment
(`n a e r d`), and tooltips promising `(a)`, `(o)`, `(x)`, `(c)`, `(3)`,
`(or m)` and `:w`. Each was gated on "panel focused and cursor row" but
never on Vim mode, while every one of those keys lives in VimNav, which
stands down entirely with Vim off. To someone who never asked for Vim it
read as a terminal.

Every such hint now reads `vimMode` next to where it renders (or takes
it as a prop where the component has no store: TasksRow, TaskStateBox
and the Kanban card, whose checkbox tooltip now names the list's real
toggle binding through `toggleKey` and nothing with Vim off). The
comments strip is no longer mounted with Vim off, so it does not hold a
blank row open under the header. The cursor styling stays in both
modes: the highlighted row is the row you clicked; only what names a
key goes. With Vim on nothing changes.

Two Tasks surfaces were the exception to the rule the lists follow
(with Vim off, single-letter keys are disabled and only arrows, Enter,
Escape, Tab and Space navigate): the Kanban board and the Tasks
calendar kept h/l, j/k, x, g, the bracket and angle keys and the
calendar's e, m, dd, T and a live with Vim off, and their hint lines
said so. They now get the same one-line guard the lists have, and their
lines name what is live: arrows, Space, Enter, Tab, drag and
right-click. The calendar panel beside a note does the same, since its
`m` grab mode showed an "h/j/k/l pick a day" banner. TasksCalendar's
handler effect also lists `vimMode` in its dependencies, which the
existing `i`/`c` gating had missed.

Verified over CDP in the built app with both stores isolated
(docs/releases/v2.55.0/tools/check-vim-off-hints.mjs, local): two
launches, Vim off then on, 28 checks reading the DOM and, for the
board, the note file after `x` and Space. app-core suite and
`npm run typecheck` clean.
The "Tasks, tags, and trash views" section of the manual listed j/k,
x, i, c and friends with no word on Vim mode, while every one of them
is Vim mode's: with it off the lists keep only the arrows, Enter and
Escape, plus Shift+J/K in the list, Tab on the calendar and Space on
the board and the calendar, and the rest is in the right-click menu.
Now the section says so, and "Switch Tasks to Kanban (button or `3`)"
names Vim mode for the digit, which was always gated on it. Follows
cdccd55, which stopped the app naming those keys with Vim off.
Every workspace package moves to 2.55.0 together, so the desktop app,
the web client, the share viewer and the shared packages report one
version, and the lockfile follows.
Three assertions in workflow-apply.test.ts compared the text of a
symlink a run had put back with `../sources/Real.md`, and failed on the
Windows runner with `..\sources\Real.md`: Windows stores a symlink's
target with backslashes whatever text created it, so `readlink` there
never returns the forward slashes the test wrote. The run itself is
right, it wrote the recorded text as is. A `linkText` helper reads the
target with forward slashes on every platform and the three assertions
use it. Found by the 2.55.0 release PR's Windows job; no app code
changes.
@adibhanna
adibhanna merged commit 00db703 into main Sep 23, 2026
9 checks passed
@adibhanna
adibhanna deleted the v2.55.0 branch September 23, 2026 19:29
@adibhanna
adibhanna restored the v2.55.0 branch September 23, 2026 19:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant