Skip to content

Resolve Cloud conflicts without duplicate notes (#683) - #733

Merged
adibhanna merged 11 commits into
mainfrom
v2.44.0
Sep 4, 2026
Merged

Resolve Cloud conflicts without duplicate notes (#683)#733
adibhanna merged 11 commits into
mainfrom
v2.44.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

Summary

  • replaces generated cloud-conflict notes with a durable, private conflict queue
  • automatically three-way merges edits made in different parts of a note
  • adds a plain-language resolver for overlapping edits, complete-version choices, first-sync decisions, moves, deletes, binaries, and filename collisions
  • makes resolution reachable from the workspace status bar as well as Cloud settings
  • preserves drafts for Finish Later, advances to the next unresolved file, and keeps unrelated files syncing
  • isolates tasks from unresolved notes across Tasks, Calendar, and Kanban
  • retains legacy conflict copies until the person explicitly moves them to Trash
  • includes the v2.44.0 release notes and captioned real-app demo

Closes #683

Cross-repository release pieces

The Cloud endpoint should be deployed before clients using historical revision recovery are released. Older or expired revision data still falls back to a safe two-version choice.

Verification

  • complete monorepo test suite passed
  • workspace typecheck passed
  • desktop, web, and server production builds passed
  • Cloud: 621 tests / 4,221 assertions passed
  • iOS: 30 shell tests, typecheck, Vite production build, Capacitor sync, and Xcode simulator build passed
  • Android: 35 shell tests, typecheck, Vite production build, Capacitor sync, and Gradle debug APK build passed
  • dependency audits report zero known vulnerabilities on all four release branches
  • captioned H.264 demo decoded successfully: https://github.com/ZenNotes/zennotes/blob/v2.44.0/docs/releases/v2.44.0/media/cloud-conflict-resolution-683.mp4

UX and safety

Whole-version replacement and delete choices require a separate confirmation. Stale local or Cloud versions cannot be overwritten, failed multi-file writes roll back, first sync never pretends to know which version is newer, and unresolved data remains recoverable outside the visible vault.

Add guarded local and Cloud comparison with explicit device, Cloud, keep-both, and manual-merge outcomes so first sync no longer depends on a rename workaround.\n\nRefs #683.
Document the first-sync conflict resolver and include the verified captioned Electron demo with its VTT sidecar.
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff'
})
response.end(JSON.stringify(body))
…nds a note

Three findings from the review of the 2.44.0 resolver, each reproduced
before it was fixed, and each one a way for the new conflict queue to
do the damage it was built to prevent.

The three-way merge glued lines together whenever the last agreed
version ended without a newline. Lines were compared with their
terminators stripped, so "b" and "b\n" were equal, but equal regions
were emitted from the base's own bytes. Editing line one here while the
other device appended a line turned "a\nb" into "A\nbc", reported the
merge as clean, wrote it to disk and pushed it as the agreed revision.
Both devices appending to an unterminated note conflicted the same way,
and Keep both glued the pair. A note without a trailing newline is
common (quick capture, MCP, other editors) and appending at the bottom
is the most common concurrent edit. The merge now strips one final line
break from each side before diffing, joins any run of lines to an
unterminated one with a line break, and decides the final newline
separately: the side that changed it wins, and a side that is empty has
no opinion. Text parts that are followed by anything now always end
with a line break, so the renderer's own concatenation stays exact.

A move conflict could never be resolved. The Cloud snapshot of a move
was filled from `base_content`, a tracked-item field this branch
declared and read but never wrote, so the snapshot had no content and
the freshness check compared the manifest hash against undefined. Every
choice threw "The Cloud version changed", a second sync did not clear
it, and both paths stayed paused for good. Loosening the check alone
would have been worse: Use other device would then have handed the
repository an empty file list, which means "remove the local note". The
phantom field is gone. A move now stores a metadata snapshot (hash,
size, media type) that is enough to verify freshness; small text is
fetched right away from the retained revision so the resolver can offer
a merge, and the bytes for Use other device or Keep both are fetched at
decision time from the manifest already loaded for the freshness check,
then from the retained revision, and must match the snapshot hash. A
Cloud file whose bytes cannot be had is refused, never applied as a
delete.

Resolving a file above the 5 MB inline limit wrote it back as 0 bytes.
A scan snapshot of such a file carries no bytes (the upload streams from
disk), and the resolver handed that snapshot to the writer, which
decoded it as empty and renamed it over the attachment. Keep both wrote
the copy empty and replaced the original with the Cloud version, so the
local version was gone with no copy anywhere. Use this device now
changes nothing on disk, because the file already is that version, and
the desktop decoder reads a snapshot's bytes back from the file it was
taken from, checking size and hash, before writing them anywhere else.

Around those: conflict snapshots above 256 KB keep only metadata in
state, since the local bytes stay on disk untouched and the Cloud bytes
can be fetched again, so a conflicted attachment no longer lands in the
state file whole on every save. An automatic merge whose write loses to
a save queues the conflict instead of failing the whole run. A same-path
conflict on an untracked item is no longer relabelled as a move when the
other device saves again. Legacy conflict-copy detection sees multi-dot
extensions. And the cloud sync writer follows a symlinked note to its
target and keeps the file mode, the two rules the vault writer learned
from #585, through a small electron-free module both writers share.

Claude-Session: https://claude.ai/code/session_015HNdWonTE8g6dPY2SkdsRS
… from anywhere

The review of the 2.44.0 conflict queue found that its dialog was
invisible to the app's global key handlers: it was not in the overlay
bail lists in VimNav, App and overlay-open, so with Vim on, j and k
moved the note list behind the modal and Space armed leader chords under
it. Nothing received focus when the dialog opened either, and ui/Modal,
which the design system describes as trapping and restoring focus, did
neither. Every modal now claims focus on open (an explicit target, else
the first control, else the panel), keeps Tab inside the panel, and
hands focus back to its opener on close. Content that focuses itself,
like a palette focusing its input, keeps that focus.

Two state bugs in the resolver itself. The Settings path rendered it
without a key, so after auto-advancing to the next file the keep-both
filename and resolved path still belonged to the previous one, and Keep
both would have written note B under note A's name. It is keyed per
conflict now. Save combined note also unlocked as soon as the textarea
was touched, with unanswered hunks silently rendered as the last-synced
wording; every hunk must now be answered, and the reminder stays up
until it is. A failed save refetches fresh hashes so a retry can
succeed, and the status bar opens the queue even while a sync is
running.

The queue had no keyboard route: the status bar click and Settings were
the only ways in. It gains the leader binding Space r and the palette
command Review Cloud Sync Conflicts, both shown only while files are
waiting. The dialog moved out of the status bar into an app-wide host,
because zen mode hides the status bar and that is exactly where those
routes matter. The binding is documented in the in-app manual; the
website manual gets the same row in its own repository.

The coordinator no longer emits first-sync conflicts (they join the
durable queue), so the separate bootstrap resolver panel was
unreachable; it is deleted along with its status bar and Settings
plumbing. The bridge methods stay, because the iOS and Android shells
implement the contract.

Hygiene from the same review: the em dashes in the resolver copy, the
spec and the tweet draft; two arbitrary text sizes that the text-2xs
token already covers; the first-sync label that disagreed with the
whole-version label; tasks re-inserted by forward and move that skipped
the pending-conflict filter; and the release note that claimed task
withholding for every surface, when MCP, the zn CLI and the self-hosted
server read the local note from disk and still see its tasks.

Claude-Session: https://claude.ai/code/session_015HNdWonTE8g6dPY2SkdsRS
ZenNotes had no answer for typos and grammar slips on desktop and web:
the browser's spellcheck is off in the editor, and #453 (native
spellcheck) is parked. Harper (writewithharper.com) is an offline,
Rust-built grammar and spell checker with a WebAssembly build, which
fits a local-first editor: nothing leaves the machine.

Settings, Editor, Writing gains "Grammar and spelling with Harper" and
an English dialect choice, both portable through config.toml and both
off by default. With it on, the editor underlines problems as you
write, and one card serves mouse and keyboard alike: hover an
underline, or press z= in Vim mode, for the fixes; a digit or Enter
applies one, j and k move the highlight, ]s and [s walk the problems,
zg teaches the vault's dictionary a word, zG ignores one suggestion
(Vim's temporary sibling of zg). The palette carries the same actions
and :harper on|off toggles the setting. The dictionary and the ignored
suggestions live in vault.json, so they travel and sync with the vault,
mirrored in the desktop and Go settings normalizers; the ignore hashes
are unsigned 64-bit integers carried as digit strings because JSON.parse
would round them.

Delivery keeps the boot path clean. The 15.6 MB wasm is a hashed asset
emitted by one shared Vite plugin (harper.js hides its dist behind an
exports map), fetched by Harper's worker through a new zen-harper://
scheme that shares the Typst asset handler, and loaded only once the
toggle is on. Harper's session lives in one dynamically imported chunk
that the entry chunk never imports statically.

Choices worth knowing. @codemirror/lint keeps the diagnostics, but its
next/previous commands select the range and drop Vim into visual mode,
so ]s and [s move the cursor instead. Hover goes through the same
tooltip field as z=, not hoverTooltip, whose host wraps content in
stock light chrome that ignores the theme; the card is styled as
.cm-tooltip.cm-harper-popup because CodeMirror's scoped base theme beats
a single class. The card owns its keys with a capture-phase listener
while open, since an earlier extension consumes Enter before any keymap,
and it finds its problem by rule, text and position across the
background re-lint that a fix elsewhere schedules. Harper raises two
rules on a word like "teh" and shows one; an ignore is per rule, so the
second surfaces after the first, exactly as in Harper's own editors.
Heading lints keep the # marker out of the underline and the fix.

The phones do not run Harper: the system keyboard already does this
job. A new optional host capability, supportsHarper, is true on desktop
and web and absent on the mobile bridges, which hides the setting, the
commands and the editor extension there. The desktop cloneVaultSettings
and Go cloneSettings now return every optional field they wrote, since
the renderer keeps that value; both silently dropped new fields before.

Verified with real-engine tests under Node (UTF-16 spans, code and link
targets skipped, dictionary and ignore round trips), a CDP drive of the
built app through every keyboard flow, and the card checked in all
eleven theme families in light and dark.

Claude-Session: https://claude.ai/code/session_015HNdWonTE8g6dPY2SkdsRS
})
}

function openPopup(view: EditorView): PopupController | null {
@adibhanna
adibhanna merged commit 4835a24 into main Sep 4, 2026
5 of 7 checks passed
@adibhanna
adibhanna deleted the v2.44.0 branch September 4, 2026 17:39
@adibhanna
adibhanna restored the v2.44.0 branch September 4, 2026 17:39
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.

Provide A Dedicated Cloud Conflict Resolution View

2 participants