Repository navigation
fix(editor): stop clearing the dirty flag for edits made during a save - #315
Merged
petertzy merged 4 commits intoOct 2, 2026
Merged
Conversation
karaaslanz
approved these changes
Oct 1, 2026
harsh-thakkar7
force-pushed
the
fix/savefile-dirty-compare-and-clear
branch
from
October 2, 2026 09:00
67113c2 to
c00a951
Compare
saveFile() read activeTab.content synchronously, awaited an HTTP round trip to POST /api/files/write, then cleared dirty unconditionally. Anything typed while that write was in flight was never written to disk, yet the tab and StatusBar reported saved, and there is no unsaved-changes prompt on close -- so the edits were silently lost. A deletion during the same window was equally invisible. Snapshot what is written, then decide dirty by comparing it against the buffer read from the latest state after the await, via a setTabs updater rather than updateTab (which closed over the pre-await value). The path and label are still adopted either way, so tab identity is unchanged; only dirty is conditional. Also fixes Save-As, which had the same shape. The rule lives in save-settle.mjs so the Node suite runs the real code; 7 cases in tests/save-settle.test.mjs, 3 of which fail if dirty goes back to an unconditional false.
harsh-thakkar7
force-pushed
the
fix/savefile-dirty-compare-and-clear
branch
from
October 2, 2026 09:03
c00a951 to
82b085a
Compare
`saveFile` captured `activeTab.content`, awaited `Files.write`, then applied a patch built from that stale snapshot. `updateTab` merged the patch into whatever the tab held *by then*, so `dirty: false` was stamped onto a buffer that had advanced during the round trip — the title bar claimed "saved" while the edits were still only in memory. Settle the tab from the buffer observed after the write, and only clear `dirty` when it still holds exactly what reached disk. Two related changes fall out of this: - The snapshot patch is replaced by a functional `setTabs`, so the comparison happens against live state. That removes `updateTab`'s last two call sites, making it dead: it is dropped rather than left as an unused helper. - `previewBaseDir` is re-pointed at the destination folder when a save moves the document, preserving the behaviour from petertzy#313. It moves inside `settleSavedTab` instead of being inlined at the two call sites, so the rule stays covered by the Node suite instead of sitting untested in TypeScript. frontend/tests/save-settle.test.mjs covers the compare-after-write rule and the base-directory move; 6 of 6 mutants killed, including "previewBaseDir only updated when clean" and "previewBaseDir never updated".
harsh-thakkar7
force-pushed
the
fix/savefile-dirty-compare-and-clear
branch
from
October 2, 2026 10:22
82b085a to
dbb1774
Compare
Owner
|
Approved. This PR fixes a genuine data-loss race during asynchronous saves. It snapshots the content sent to disk and compares it with the latest editor buffer after the write completes, ensuring that edits or deletions made during the request remain marked as unsaved. The fix applies to both regular Save and Save As, while preserving the saved path, filename, and preview base directory. The added regression tests cover unchanged content, edits, deletions, equal-length buffers, empty files, Save As, and POSIX/Windows paths. All 61 frontend tests pass, so this change is necessary and ready to merge. |
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
Ctrl+Scould mark unsaved edits as saved, losing them silently.Root cause
saveFile()snapshotted the content, awaited an HTTP round trip, then cleareddirtyunconditionally:activeTab.contentis read synchronously at the call site, butdirtyiscleared after the await, with no check that the buffer still holds what was
written.
Files.writeis a real network call:against the backend's
/api/files/write. So the window is a genuine round trip,longest for large files.
Effect
Ctrl+S, and keep typing while the write is in flight.dirty: falseis applied.The disk holds the pre-save text, but:
TabBar.tsxdisappearsStatusBarstops reporting unsaved changesThe extra words are unrecoverable.
The same inversion applies to a deletion during the window — comparing
lengths or a flag would miss it, so the comparison is on the whole buffer.
Changes
frontend/src/lib/save-settle.mjs(new) —settleSavedTab()holds the rule in aplain ESM module so the Node suite can exercise the exact code the app runs
(same pattern as
http-timeout.mjs):frontend/src/hooks/useEditor.ts— both save paths (known path, and Save-As viathe native dialog) now:
writtenContentbefore the callsetTabsupdater, socurrentContentis read fromthe latest state rather than the value captured before the await
The path and label are still adopted either way, so tab identity behaviour is
unchanged. Only
dirtybecomes conditional.Two follow-on changes
updateTabis removed. It existed for exactly these two call sites, and bothare now
setTabsupdaters, so it became dead — an assigned-but-never-readwarning rather than something worth keeping for a hypothetical future caller.
previewBaseDirmoves into the helper. Saving under a new name moves thedocument, so its relative image paths then belong to the destination folder
rather than the one they were authored in. That was
previewBaseDir: parentDirOf(path)inlined at both call sites; it is now part of the settledpatch, so the rule stays covered by the Node suite instead of sitting untested
in TypeScript where a future conflict resolution can silently drop it.
Testing
frontend/tests/save-settle.test.mjs, 12 cases:dirty: falsedirty, but still adopts the pathdirtyfileNameOffor POSIX and Windows separatorspreviewBaseDirat the destination folderpreviewBaseDirfollows the save even when edits are still unsaveddirOffor both separators, and for a path with no directory component6 of 6 mutants killed, including the two that would have reintroduced the
merged-from-#313 behaviour: "never updates
previewBaseDir" and "only updates itwhen clean".
Worth noting: the first draft of the helper compared in the wrong direction
(
dirty: written === current), and these tests caught it immediately — 6 failedrather than passing silently. The
previewBaseDircases went the same way: afirst draft closed the converted tab instead of the plain one, so it asserted
the wrong tab's folder and passed under a mutation that deleted the field.
Verification
npm test— 61 passed (49 onmain; this PR adds 12)npm run lint— clean, no warningsnpm run build— cleanpytest— unchanged