Skip to content

v2.28.2: notes and Cloud sync stop losing to their own save - #593

Merged
adibhanna merged 20 commits into
mainfrom
v2.28.2
Aug 14, 2026
Merged

v2.28.2: notes and Cloud sync stop losing to their own save#593
adibhanna merged 20 commits into
mainfrom
v2.28.2

Conversation

@adibhanna

@adibhanna adibhanna commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Release branch for 2.28.2. Cycle is open: more fixes may land here before the tag.

Fixes

A note being edited can no longer be wiped by its own save echo (376245e + 8926e34, closes #585)

Four defects had to line up: non-atomic truncate-then-write note saves; the watcher's delayed echo of save N reading the file inside save N+1's truncate window; the store pushing that empty read over a dirty buffer with no guard (the resync path had the guard, the live path did not); and the editor applying external content as a non-undoable doc swap (#247), with persistNote clearing the dirty flag so the healing save bailed. Fixed at every link: writeNote rides writeFileAtomic, the watcher ignores the scratch files, applyChange never replaces a dirty buffer, and the dirty flag survives a save when keystrokes landed mid-write.

The follow-up commit takes the same fix to the Go server and repairs what a rename quietly changes:

  • Server saves are atomic too. A new atomicwrite.go replaces os.WriteFile in WriteNote. Its test reads the file concurrently and fails against the old implementation with "a reader saw 0 bytes", the same empty read that erased notes on the desktop.
  • Atomic writes no longer replace symlinks. A rename swaps the directory entry, so a symlinked note became a regular file and detached from its target. This also hit config.toml, which predates this cycle: a stow/chezmoi-symlinked ~/.config/zennotes/config.toml was replaced by a regular file on startup and the dotfiles copy stopped updating (verified by rebuilding with the old behavior). Both writers resolve the link and write at the target now, matching what workflow-apply.ts had already worked out.
  • File modes survive a save. os.WriteFile/fs.writeFile only applied a mode when creating a file, so a note chmod'ed to 0600 came back at the default. An existing file's mode is reproduced exactly; created files stay umasked as before.
  • Event kinds are honest again. Linux folds a rename into place into Create, so an atomic save arrives as add, not change; the renderer ignored add and would have shown content that no longer existed. It now treats add for an open note as content to read, which also repairs edits by git/rsync/Syncthing/vim that never refreshed an open tab. macOS reports the rename as delete-then-create, so the watcher now stats the path and never reports an existing note as unlinked (which used to close the tab of the note being saved).

Cloud sign-in stores its credential on compositors Chromium does not recognize (c804038, reported by @uNyanda on Discord, confirmed fixed by the reporter)

Chromium picks the safeStorage keyring backend from XDG_CURRENT_DESKTOP instead of probing the bus, so on Niri, Hyprland and Sway it falls back to plaintext and cloud sign-in died with "could not store the cloud credential securely" despite a healthy gnome-keyring. ZenNotes now appends --password-store=gnome-libsecret when the session variables identify no desktop Chromium recognizes and the user did not pass the switch themselves. Safe by construction: with no Secret Service present Chromium falls back to plaintext, the same outcome as today. The reporter confirmed the flag fixes sign-in on Niri, which is exactly what this automates.

Pickers with a list are tappable one-handed on touch devices (3c6b5f0)

A prompt autofocused its input on open, so on a phone the soft keyboard covered the list the user was about to tap (worst in the move-note folder picker). On a coarse pointer a prompt with suggestions now opens tap-first: no autofocus, no hint line about keys a phone cannot press, and taller rows. Mouse and trackpad behavior is unchanged.

One file can no longer stop Cloud sync forever, and vault settings ask first (46152f7, reported by @uNyanda on Discord)

Every sync ended with "Cloud sync stopped because .zennotes/vault.json has unsynced local edits", on a file that had not changed in days. assertUnchanged threw whenever the change feed carried an item this device had never tracked and the path existed on disk, without ever comparing content, and throwing stopped the run before the cursor was saved, so every later run replayed the same change and died in the same place. vault.json was not special: scan() sorts by path and a dot sorts first, so it was simply first in line.

Three rules replace the refusal: a file already holding exactly what the change carries is adopted; a file that differs is kept while the incoming version lands beside it as Note (cloud conflict).md; a delete or move over an unvouchable file keeps the local file. All three report a conflict and let the run continue, so one file can never wedge sync again. Two smaller wedges went with it (a remote delete for an already-deleted file, and a move with a missing source, both used to fail with a raw ENOENT).

Vault settings are answered rather than merged: the cloud's version waits at .zennotes/vault.cloud-conflict.json (never synced, replaced rather than numbered) while Settings, Cloud offers "Keep this device's" or "Use the cloud's". Local settings stay in use until answered, and applying the cloud's runs them through the vault's normalizer so an unreadable file is refused rather than half-applied.

Both CloudSyncRepository implementations change, desktop and the shared portable one behind mobile and web. The portable one already adopted identical content, which is why this only ever bit desktop.

Home and End reach the real edge of a wrapped display row (d1f8fd2, #591, reported by @corpserot)

The same defect a16300f fixed for $, surviving because Home/End were never our bindings: they fell through to CodeMirror's cursorLineBoundaryBackward/Forward, which hit-test an x coordinate at the editor's edge. ZenNotes gives that probe further to travel than plain CodeMirror (it uses view.dom while the text column is centered inside it, 87px past the text on the machine measured), and codemirror-vim maps neither key, so Vim users were on the CodeMirror path too. Both keys are now bound ahead of defaultKeymap to commands built on displayRowEdge, the helper $/g0/A/I already use, so no x coordinate is resolved. Shift extends; Mod-Home/Mod-End still fall through. displayRowEdge moved to a new cm-display-row.ts now that it is shared by Vim motions and non-Vim keys.

Not reproducible on a pixel-accurate display (as with #575) and emulated fractional device scale factors did not provoke it, so verification is structural: tests assert exact landings under jittered coordinates and that posAtCoords is never called, plus an in-app run measuring true wrap points from DOM text rects with Home/End landing exactly on all 5 rows in both modes. Left open pending the reporter's confirmation.

]] / [[ jump to the next and previous heading (d7acdc6, closes #578, requested by @corpserot)

A real Vim motion, so d]], v]], 3]] and Ctrl+O all work without extra code. Headings come from parseOutline, so fences and frontmatter are skipped and the motion agrees with the outline panel. Registered in all three renderers.

Getting the keys to arrive needed a fix underneath: VimNav's global [b/]b fallback consumed the first bracket with preventDefault + stopImmediatePropagation, so codemirror-vim never saw it and no Vim sequence starting with [ or ] could run. A bare ]] did nothing while 2]] worked, because a pending count was already excepted (patched narrowly once for f[). The fallback now stands down for the whole focused editor, where codemirror-vim already owns [b, ]b, gt, gT; those are additionally mapped in visual context so nothing regresses with a selection standing. Verified in-app that buffer switching still works.

Two suites: the motion directly, and the keys pressed for real through codemirror-vim (which matters, since the built-in ]<character> also matches ]]).

LaTeX command completion in math regions (#594 by @flokchvtr, merged 8cd147d, follow-up 5eb6d5c)

Merged as sent, verified in the app first (13 options inside $$…$$ for \su, \sum boosted, Enter lands the snippet, inert in prose). Review findings in their own commit per the contributor flow: gated on the math_renderer facet so Typst notes are left alone; $/$$ counts now skip anything the syntax tree calls code (a note with echo $$ in a shell block used to flip parity for everything below it); the block scan is bounded instead of slicing the whole prefix on every \; and KaTeX previews are cached between popups.

Frontmatter tag autocomplete and clickable tags (#595 by @junereycasuga, merged 80a90a9, follow-up a342da2)

Merged as sent, verified in the app first (chips for inline lists/scalars/block lists, click opens the tag view). After the follow-up, all four completion paths were exercised in the running app: tags: and Tags: both suggest vault tags, # inside frontmatter stays silent, and # in the body still completes. Chips went from [beta] to [alpha, beta] on a note carrying both key casings. It extracts frontmatterRange and reuses it in the existing decoration builder rather than adding a second frontmatter scan, and its click handler matches the inline hashtag one exactly.

Follow-up finding: parseFrontmatterFields lowercases keys, so Tags:/TAGS: are the tags field everywhere the vault indexes, but the editor's inline branch matched case-sensitively (its own block-list branch already lowercased, so the two halves disagreed). Both now share one frontmatterTagsValue. Since two parsers now decide what a frontmatter tag is (the shared one gets indexed; the editor's scan exists because decorations need positions), a matrix test asserts the chips a note renders equal frontmatterTags(body) across inline lists, scalars, block lists, quoted, #-prefixed, capitalized keys, and lookalike fields.

Verification

  • npm run typecheck clean; full npm run test:run green (1451 shared-domain, 1526 app-core, 577 desktop)
  • go vet ./... clean and the full Go suite green, on macOS and inside a golang:1.25 Linux container
  • New regression tests: atomicwrite_test.go (concurrent reader, symlink, mode, scratch files), watcher_test.go (scratch files ignored, replaced note never reported deleted), vault.test.ts (atomic-save fidelity), store-note-integrity.test.ts (ZenNotes clears all text from a note while editing and undo does not restore it #585 block, plus the add path). The desktop and Go tests were each confirmed to fail against the pre-fix code.
  • End-to-end: the real server binary driven over its /watch feed the way a web client subscribes (zero scratch events, symlink intact and written through, 0600 preserved, no leftovers), an inotify probe on Linux, and the ZenNotes clears all text from a note while editing and undo does not restore it #585 reproductions replayed over CDP against the rebuilt desktop app.

adibhanna and others added 3 commits August 14, 2026 08:18
…recognize

On Linux, Chromium picks the safeStorage keyring backend from
XDG_CURRENT_DESKTOP instead of probing the bus for a Secret Service. On
compositors it does not recognize (Niri, Hyprland, Sway) it falls back to
the plaintext basic_text backend, safeStorage reports encryption as
unavailable, and cloud sign-in dies with "could not store the cloud
credential securely" even though gnome-keyring is running and healthy.
Reported on Discord by a Niri user whose secret-tool round trip worked,
which is the confusing part: nothing the user can check locally is
consulted by Chromium's detection.

The fix appends --password-store=gnome-libsecret before app ready, only
when the session variables identify no desktop Chromium recognizes and
the user did not pass the switch themselves. Safe by construction: when
the Secret Service is genuinely absent, Chromium falls back to
basic_text, the same outcome as today. On recognized desktops, including
XFCE and LXQt where Chromium chose plaintext deliberately, ZenNotes
defers to Chromium's own choice.

The storage failure dialog and the remote-workspace console warning now
point at the switch instead of leaving a dead end.
…ve echo (#585)

Typing a multi-line mermaid block (or editing bullet lists) could erase
the entire note, with undo unable to restore it. Four small defects had
to line up, and all four are fixed here.

writeNote was truncate-then-write, so for a moment every save leaves an
empty file on disk. The watcher's awaitWriteFinish delays the echo of
one save into exactly that moment of the next, and the echoed readNote
comes back empty. The store's live change path then pushed that read
over the open buffer without checking the dirty flag (the resync path
has the check, with a comment explaining why; the live path did not).
The editor applies external content as a non-undoable doc swap (#247),
which is why undo could not bring anything back. And persistNote cleared
the dirty flag even when keystrokes landed during the write, so the
follow-up save that would have healed the disk bailed on its dirty
check, making the wipe permanent. Mermaid and bullet lists are only
amplifiers: per-line save cycles plus renderer stalls from the first
mermaid render push the echo into the truncate window.

Note saves now go through writeFileAtomic so no reader can observe a
half-written note; the watcher ignores the atomic-write scratch files
(which also stops every database save from firing an asset refresh); a
dirty buffer is never replaced by a watcher read, the pending save
reconciles disk instead; and the dirty flag survives a save whenever
the buffer has moved past what hit disk.

Verified by driving the built app over CDP: forcing the empty read
mid-edit wiped the editor and disk with undo dead before the change,
and leaves buffer, disk, and undo history intact after it. Regression
tests cover both store defects in store-note-integrity.test.ts. The Go
server's WriteNote shares the truncate-then-write shape; the renderer
guard already protects web buffers, and the Go write gets its own
follow-up once its watcher's rename semantics are verified.
Typing `\su` inside `$…$`, `$$…$$`, or a ```math fence pops KaTeX
commands with the rendered symbol in the completion row's icon slot.
~250 curated commands (greek, operators, accents, fonts, relations,
arrows, functions, delimiters, environments); argument-taking commands
insert as snippets — accepting `\frac` lands the cursor in the
numerator and Tab moves to the denominator — and big operators
scaffold their usual bounds (`\sum` → `\sum_{i=1}^{n}` with each bound
selectable in turn).

Math detection counts unmatched `$`/`$$` delimiters rather than closed
pairs, so a formula still being typed — the moment completion matters —
already counts as math. Escaped dollars and non-math code regions are
excluded; a fence with the `math` info string is a math region in its
own right, matching how remark-math renders it.

One more source in the editor's existing autocompletion stack; no new
dependencies (katex and @codemirror/autocomplete are already there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adibhanna adibhanna changed the title v2.28.2: Cloud sign-in works on Niri, Hyprland, and Sway v2.28.2: a note can no longer be wiped while you edit it, plus Linux cloud sign-in Aug 14, 2026
junereycasuga and others added 5 commits August 14, 2026 21:04
…symlink (#585)

The desktop stopped wiping notes in 376245e by writing them atomically, but
the self-hosted server still wrote truncate-then-write, so a browser or a
second device could read a note in the moment its own save had emptied it. A
Go test that reads the file while WriteNote runs proves it: against the old
implementation a reader sees 0 bytes, the same empty read that erased notes
on the desktop. The server writes atomically now.

Verifying that turned up three things a rename does that a plain write did
not, two of them regressions the desktop change had already shipped.

A rename replaces the directory entry, not the file. Pointed at a symlinked
note it leaves a regular file where the link was, so a note kept in one place
and linked into the vault silently becomes two files. The same call writes
config.toml, so a stow or chezmoi managed ~/.config/zennotes/config.toml was
being replaced by a regular file on startup with the dotfiles copy never
seeing another change, and that one predates this cycle. Both writers now
resolve the link and do the atomic dance at the target, which is what
workflow-apply.ts had already worked out for workflow edits. A rename also
carries the temp file's permissions, so a note chmod'ed to 0600 came back at
the default: an existing file's mode is now reproduced exactly, while files
these calls create are left umasked exactly as they were.

The event kinds changed too, and clients read them. Linux maps a rename into
place to IN_MOVED_TO, which fsnotify folds into Create, so an atomic save
reaches clients as "add" rather than "change", and the renderer ignored "add"
and would have gone on showing content that no longer existed on disk. It now
treats "add" for a note it holds open as content to read, which also repairs
edits made by git, rsync, Syncthing or vim, none of which ever refreshed an
open tab. macOS reports the same rename as a delete followed by a create, and
a client told its open note was deleted closes the tab, so the watcher stats
the path and never calls an existing file unlinked. Both watchers drop the
scratch file itself, which otherwise made every keystroke-driven save re-list
the whole asset tree.

Verified past the unit tests: the real server binary driven over its change
feed the way a web client subscribes (no scratch events, symlink intact and
written through, 0600 kept), the full Go suite and an inotify probe inside a
Linux container, and the #585 reproductions replayed against the rebuilt
desktop app.
…ices

A prompt autofocused its input as soon as it opened, which on a phone summons
the soft keyboard over the very list the user is about to tap. The folder
picker was the worst of it: the suggestions it exists to offer were hidden
behind the keyboard the moment it appeared.

On a coarse pointer, a prompt that actually has suggestions now opens
tap-first: no autofocus, no hint line about arrow keys and Ctrl+J that a
phone has no way to press, and taller rows to aim at. Typing is still one tap
away in the input itself, and a prompt with nothing to tap keeps its
autofocus. Nothing changes for a mouse or a trackpad.
Extracts the decision from 3c6b5f0 into shouldAutofocusPrompt() so it can
be asserted the way activeSuggestionAfterInput() is, and pins the three
cases that matter: touch with a list opts out, touch with nothing to tap
keeps its keyboard, and a fine pointer is never affected. Behavior is
unchanged.
…k first

A vault could reach a state where every sync ended with "Cloud sync stopped
because .zennotes/vault.json has unsynced local edits", naming a file that
had not been touched in days and whose hash never changed. Reported on
Discord with the diagnostics that made it findable: no write events on the
file, the same error after every restart, and a stack pointing at
assertUnchanged.

Sync refuses to write over a file it cannot vouch for, which is the right
instinct, but it never asked whether the file it was refusing to overwrite
was already the same file. When the change feed carried an item this device
had never tracked and the path existed on disk, it threw without comparing
content, and throwing stopped the run before the cursor was saved, so the
next run fetched the same change and stopped in the same place, forever. The
file it names is not special: the scan sorts by path and a dot sorts first,
so .zennotes/vault.json simply got there ahead of every note.

Three rules replace the refusal. A file already holding exactly what the
change carries is adopted, because both sides agree and there is nothing to
resolve. A file that differs is kept where it is while the incoming version
lands beside it as "Note (cloud conflict).md", numbered when that name is
taken. A delete or a move whose file cannot be vouched for keeps the local
file instead, since nothing arrives with those to park and the local file is
the version being preserved. Each of those reports a conflict and lets the
run continue, so one file can never wedge sync again.

Vault settings are answered rather than merged. A conflict copy inside a
hidden folder is not something anyone can act on, so the cloud's settings
wait at .zennotes/vault.cloud-conflict.json, never synced themselves and
replaced rather than numbered when a newer one arrives, while Cloud settings
asks which side to keep. This device's settings stay in use until the
question is answered, so doing nothing keeps what already works, and taking
the cloud's writes them through the vault's own normalizer, which refuses a
file it cannot read instead of applying half of it.

Two smaller wedges went with it: a remote delete for a file already deleted
locally used to fail the run with a raw ENOENT, and so did a move whose
source had gone.

Both repositories change, the desktop one and the shared portable one behind
mobile and web, since they are a deliberately synced pair. The portable one
already adopted identical content, which is why this only ever bit desktop.
@adibhanna adibhanna changed the title v2.28.2: a note can no longer be wiped while you edit it, plus Linux cloud sign-in v2.28.2: notes and Cloud sync stop losing to their own save Aug 14, 2026
#591)

On a soft-wrapped line, Home and End could land short of the row edge or on
a neighboring row, exactly as a bare $ did before a16300f. The reporter
spotted that it was the same bug wearing different keys, and they were
right: Home and End were never our bindings at all. They fell through to
CodeMirror's cursorLineBoundaryBackward/Forward, which locate the row edge
by hit-testing an x coordinate at the editor's edge, the same resolution
that #575 removed from $ because it walks sub-pixel glyph rects under
fractional display scaling.

ZenNotes gives that hit-test further to travel than plain CodeMirror does.
It probes view.dom, the whole editor element, while the text column is
centered inside it, so the probed x sits well outside the text: 87 pixels
past it on the machine this was measured on. codemirror-vim maps neither
key, so Vim users were on the same CodeMirror path rather than a motion.

Both keys are now bound ahead of defaultKeymap to commands that compute the
boundary from row geometry through displayRowEdge, the helper $, g0, A and I
already use, so no x coordinate is resolved anywhere. Shift extends the
selection the same way, and Mod-Home/Mod-End still fall through to document
start and end. A caret resting exactly on a wrap point belongs to two rows
at once, so, like CodeMirror's own boundary motion, the character before a
backward-associated caret is what gets measured; without that a second End
press walked on to the next row.

displayRowEdge moves out of cm-vim-display-line.ts into cm-display-row.ts,
since it is now shared by the Vim motions and by keys that have nothing to
do with Vim, and this repo would rather move a function than keep a second
copy of it.

The mislanding does not reproduce on a pixel-accurate display, as in #575,
and emulating fractional device scale factors did not provoke it either, so
the verification is structural: unit tests assert exact landings under
jittered row coordinates and that posAtCoords is never called at all, and in
the running app the true wrap points were measured from DOM text rects, with
Home and End landing exactly on every display row in both modes.
Requested after Zed, which maps the same keys, and the keys Vim itself uses
to move between sections. In a note the sections are the headings, so `]]`
goes to the next one and `[[` to the one before.

It is a motion rather than a command, which is what makes it worth having:
`d]]` deletes to the next heading, `v]]` selects to it, `3]]` skips three,
and `Ctrl+O` comes back, none of which needs writing. The headings come from
the parser the outline panel and `Space p` already use, so a `# comment`
inside a code fence or a `#` line in frontmatter is never a destination, and
what the motion stops on is exactly what the outline lists. With no heading
left that way the cursor runs to the end or start of the note, like Vim's
section motions, so the key never sits there doing nothing.

The keys turned out to be unreachable before they could work. VimNav carries
`[b`/`]b` buffer switching as a global fallback for when focus is outside the
editor, and it consumed the first `[` or `]` to arm that sequence, with
preventDefault and stopImmediatePropagation, so codemirror-vim never saw
either press. A bare `]]` did nothing while `2]]` worked, because a pending
count already had an exception carved out for it: the same problem was found
once before for `f[` and patched narrowly. That fallback now stands down for
the whole focused editor, which is where it never belonged: codemirror-vim
has `[b`, `]b`, `gt` and `gT` mapped itself. Any future `[x` or `]x` motion
would have hit the same wall. Buffer and tab keys are mapped in visual
context too, so nothing that used to reach the fallback from a standing
selection loses its binding.

Two suites cover it: the motion directly, and the keys pressed for real
through codemirror-vim, which is the one that matters because `]<character>`
is a built-in Vim motion that `]]` also matches, and only a real keypress
proves which mapping wins. `d]]` leaves a blank line behind, which is Vim's
own rule for an exclusive motion ending in column one, so the test asserts
that rather than pretending otherwise.
…kchvtr)

Typing `\su` inside `$…$`, `$$…$$`, or a ```math fence now completes KaTeX
commands with the rendered symbol in the row, and argument-taking commands
land as snippets: accepting `\frac` puts the cursor in the numerator, `\sum`
scaffolds its bounds a Tab apart.

Merged as sent. Review findings follow in their own commit.
…s in context

Review follow-up to #594, kept out of the contributor's commit.

A note set to the Typst typesetter takes different syntax entirely, so
offering `\frac{}{}` in one was wrong every time. The completion source now
reads the renderer the pane is configured with and stays silent unless it is
KaTeX. The facet already carried the answer; it just had no accessor.

Dollars inside code were being counted as delimiters. A note with `echo $$`
in a shell block flipped the parity for everything below it, so `\` popped
LaTeX commands in plain prose from there on, and a `$5` in inline code did
the same for the rest of its line. Both counts now skip anything the syntax
tree says is code, which is the same question the cursor position was
already being asked.

The block scan also ran from the start of the document on every `\` typed,
allocating the whole prefix as a string. It now looks back a bounded window:
a display block opened further up than that is not a formula anyone is still
typing.

The rendered previews are cached between popups. A bare `\` opens the entire
table at once, and typesetting every visible row through KaTeX on each open
was the one cost this feature could be felt through.
…tags (by @junereycasuga)

Frontmatter `tags:` values now behave like inline `#tags`: typing in one
suggests tags already in the vault, and each value renders as a chip that
opens the tag view. Inline `#tag` completion stands down inside frontmatter
so the two never compete.

Merged as sent. Review findings follow in their own commit.
…r too

Review follow-up to #595, kept out of the contributor's commit.

`parseFrontmatterFields` lowercases keys, so `Tags:` and `TAGS:` are the tags
field everywhere the vault is indexed: those notes show up under their tags
in the Tags view, in search, in the CLI. The editor read the key
case-sensitively for an inline `tags: a, b` line, so a note written with a
capital T had tags the rest of the app knew about and the editor would
neither chip nor complete. Its own block-list branch already lowercased,
which is how the two halves of one feature came to disagree.

Both places now ask one question, `frontmatterTagsValue`, phrased the way the
shared parser phrases it.

The deeper risk is that two parsers now decide what a frontmatter tag is:
the shared one, which is what gets indexed, and the editor's own scan, which
exists because the shared one returns tags without positions and decorations
need offsets. They cannot be collapsed, so a test pins them together instead:
a matrix of frontmatter shapes (inline list, comma scalar, space scalar,
block list, quoted, `#`-prefixed, capitalized key, and two fields that only
look like tags) asserts the chips a note renders are exactly the tags
`frontmatterTags` returns for it. Whichever side moves next, that test fails.
Two saves for one note could overlap, and the filesystem was free to finish the older body after the newer one. The newer completion had already marked the buffer clean, leaving memory ahead of disk with no retry scheduled.

Queue writes per path while keeping different notes independent. Each queued turn snapshots the latest dirty buffer only after the prior write finishes, so disk order now matches edit order.
A first sync classified differing vault settings as an ordinary bootstrap conflict, so it never parked the cloud copy and repeated forever. Established syncs had the opposite problem: they parked the copy, then immediately uploaded local settings before showing the question.

Treat settings specially during bootstrap and expose durable pending paths from each filesystem repository. Mutation planning now leaves those paths untouched while continuing to sync every unrelated file.
Windows can deny a destination rename while a watcher, indexer, or antivirus scanner has the file open. The atomic writer treated that transient sharing window as a permanent save failure, and the new concurrent-reader test exposed it in CI.

Retry only permission and sharing failures with a short bounded backoff. Both the Go server and Electron writer keep the old file visible until a complete replacement can land, and all other errors still fail immediately.
The display-math scan started at an arbitrary 20,000-character boundary. If that boundary split a long formula from its opening delimiter, completion either disappeared inside the formula or appeared in prose after its closing delimiter.

Count display delimiters from the stable start of the document. Long closed and still-open formulas now preserve the same parity as short ones, while code-region filtering remains unchanged.
vscode-textmate includes one-time scanner compilation in its per-line deadline. Slower Windows runners could therefore mark a tiny valid preview as stopped before any token was emitted.

Retry only a stopped first line once, after compilation has completed, while charging both attempts to the existing wall-clock caps. A deterministic cold-start regression now covers the behavior.
@adibhanna
adibhanna merged commit c105fe4 into main Aug 14, 2026
6 checks passed
@adibhanna
adibhanna deleted the v2.28.2 branch August 14, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants