Skip to content

fix(documents): stop partial buffers and external changes from destroying work - #374

Merged
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/truncated-buffer-and-reload-guards
Aug 2, 2026
Merged

fix(documents): stop partial buffers and external changes from destroying work#374
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/truncated-buffer-and-reload-guards

Conversation

@PathGao

@PathGao PathGao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

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 — isDirty=false, nothing marking it incomplete. The background full read is abandoned if the tab changes mode or gets edited meanwhile, and toggleSplitView only 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:

Route What the user did
Split view Ctrl+\ during the ~2 s window, then typed — auto-save wrote the partial buffer
Task checkbox ticked a checkbox from reading mode on a large document
Front matter edited a property from reading mode
reloadFromDisk / F5 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 completes it too. TransferableTab has no field for the flag and validateTransferPayload rebuilds 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, with canTransfer/canDetach as 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 — 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 / 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+L overwriting 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 warnings
  • npm test — 226/226

Baseline 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 tabManager and createDocumentSession, and stub the Tauri bridge at window.__TAURI_INTERNALS__. Only four assertions that live inside .svelte markup are source checks.

Note

Four i18n keys are added for the conflict bar and the partial-document toast, filled for en and zh-CN; the other locales fall back to English, which is the existing pattern in this file for recently added strings.

…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>
@PathGao
PathGao merged commit 41c7965 into sftwrdotdev:master Aug 2, 2026
4 checks passed
@PathGao
PathGao deleted the fix/truncated-buffer-and-reload-guards branch August 2, 2026 19:56
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