Conversation
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 { |
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.
Summary
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
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.