fix(documents): stop partial buffers and external changes from destroying work - #374
Merged
PathGao merged 1 commit intoAug 2, 2026
Conversation
…ying work **A truncated preview buffer could be written back over the whole file.** Opening a document larger than 50 KB starts with `open_markdown_preview(maxBytes: 50000)`, and `setTabRawContent` made that partial text both the buffer and the baseline, with `isDirty=false` and nothing marking it incomplete. The background full read is abandoned if the tab changes mode or gets edited in the meantime, and `toggleSplitView` only re-read when `!tab.rawContent` — a partial buffer is not empty, so it did not. Anything that then wrote the buffer truncated the file at 50 KB. Four routes reached that state, not one: entering split view, toggling a task checkbox from reading mode, editing front matter from reading mode, and `reloadFromDisk` (F5), which handed the editor itself a partial buffer. A tab now records whether its buffer is partial, every editable entry point completes it from disk first, and saving refuses a partial buffer as a backstop. Detaching a tab to another window completes it too: the transfer payload has no field for the flag and rebuilds the tab explicitly, so the destination would have inherited a short buffer that looked authoritative, with its own auto-save timer. **An external change overwrote unsaved edits silently.** The watcher listener checked live mode and the self-write grace window but never `tab.isDirty`, and `setTabRawContent` rewrites `originalContent` too, so a `git checkout`, a cloud sync, or another window saving the same file took the edits with no trace that anything had been dirty. A dirty tab now raises a conflict the user answers — reload, or keep mine — instead of reloading under them. The debounced auto-save is held back while a conflict is unanswered. Otherwise the timer would write 1.5 s later and drop the external change while the bar was still asking which version to keep. Explicit saves still go through: pressing Save *is* the answer "keep mine", and the save path clears the conflict so the bar comes down rather than re-asking. The debounce is the only thing that ever writes without being asked, so it is the only thing suppressed. Live Mode itself needed no change here — sftwrdotdev#296 already made toggling it install the watcher without reloading. The regression lock for that is kept and now points at the new tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
A truncated preview buffer could be written back over the whole file
Opening a document larger than 50 KB starts with
open_markdown_preview(maxBytes: 50000), andsetTabRawContentmade that partial text both the buffer and the baseline —isDirty=false, nothing marking it incomplete. The background full read is abandoned if the tab changes mode or gets edited meanwhile, andtoggleSplitViewonly re-read when!tab.rawContent; a partial buffer is not empty, so it did not.Four routes reached that state, not the one in the original report:
Ctrl+\during the ~2 s window, then typed — auto-save wrote the partial bufferreloadFromDisk/ F5A tab now records whether its buffer is partial, every editable entry point completes it from disk first, and saving refuses a partial buffer as a backstop.
Detaching completes it too.
TransferableTabhas no field for the flag andvalidateTransferPayloadrebuilds the tab explicitly, so an unknown field is dropped — the destination window would have inherited a short buffer that looked authoritative, with its own auto-save timer. Fixed on the source side, withcanTransfer/canDetachas a fail-closed backstop.An external change overwrote unsaved edits silently
The watcher listener checked live mode and the self-write grace window but never
tab.isDirty— andsetTabRawContentrewritesoriginalContenttoo, so agit checkout, a cloud sync, or another window saving the same file took the edits with no trace that anything had been dirty. A dirty tab now raises a conflict the user answers (reload / keep mine) instead of being reloaded under them.The debounced auto-save is held back while a conflict is unanswered. Otherwise the timer writes 1.5 s later and drops the external change while the bar is still asking which version to keep. Explicit saves still go through — pressing Save is the answer "keep mine", and the save path clears the conflict so the bar comes down rather than re-asking. The debounce is the only thing that ever writes without being asked, so it is the only thing suppressed. That rule covers the close and mode-toggle dialogs too, which reach disk through the same wrapper.
Live Mode needed no change
Cmd/Ctrl+Loverwriting the buffer was already fixed by #296 — toggling now installs the watcher without reloading. Verified on this baseline; the existing regression lock is kept and repointed at the new tests.Validation
npm run check— 0 errors, 0 warningsnpm test— 226/226Baseline counter-check: the two new files were extracted onto an untouched tree and run there — 19 of 21 assertions fail on
master. The two that pass are deliberate: a negative control ("a fully loaded buffer is not marked as incomplete") and the #296 Live Mode lock. The check was re-run after rebasing onto #371 to confirm that PR's Rust read-path changes did not accidentally satisfy any of them; every assertion that was red before is still red.These are behaviour tests, not source-pattern matching: they install a rune shim, import the real
tabManagerandcreateDocumentSession, and stub the Tauri bridge atwindow.__TAURI_INTERNALS__. Only four assertions that live inside.sveltemarkup are source checks.Note
Four i18n keys are added for the conflict bar and the partial-document toast, filled for
enandzh-CN; the other locales fall back to English, which is the existing pattern in this file for recently added strings.