Skip to content

fix(editor): stop clearing the dirty flag for edits made during a save - #315

Merged
petertzy merged 4 commits into
petertzy:mainfrom
harsh-thakkar7:fix/savefile-dirty-compare-and-clear
Oct 2, 2026
Merged

petertzy merged 4 commits into
petertzy:mainfrom
harsh-thakkar7:fix/savefile-dirty-compare-and-clear

Conversation

@harsh-thakkar7

@harsh-thakkar7 harsh-thakkar7 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Ctrl+S could mark unsaved edits as saved, losing them silently.

Root cause

saveFile() snapshotted the content, awaited an HTTP round trip, then cleared
dirty unconditionally:

await Files.write(path, activeTab.content);
updateTab(activeTabId, {
  dirty: false,          // <- cleared regardless of what happened during the write
  filePath: path,
  ...
});

activeTab.content is read synchronously at the call site, but dirty is
cleared after the await, with no check that the buffer still holds what was
written.

Files.write is a real network call:

write: (path, content) =>
  apiFetch(`/api/files/write`, { method: "POST", body: JSON.stringify({ path, content }) }),

against the backend's /api/files/write. So the window is a genuine round trip,
longest for large files.

Effect

  1. Open a large Markdown file, so the write takes a noticeable round trip.
  2. Type, press Ctrl+S, and keep typing while the write is in flight.
  3. The write completes and dirty: false is applied.

The disk holds the pre-save text, but:

  • the blue ● in TabBar.tsx disappears
  • StatusBar stops reporting unsaved changes
  • there is no unsaved-changes prompt on tab close, so nothing warns you

The 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 a
plain ESM module so the Node suite can exercise the exact code the app runs
(same pattern as http-timeout.mjs):

export function settleSavedTab({ writtenContent, currentContent, savedPath }) {
  return {
    // Equal means the buffer still holds exactly what reached disk, so the
    // document is saved. Any difference means edits are still unsaved.
    dirty: currentContent !== writtenContent,
    filePath: savedPath,
    label: fileNameOf(savedPath),
    previewBaseDir: dirOf(savedPath),
  };
}

frontend/src/hooks/useEditor.ts — both save paths (known path, and Save-As via
the native dialog) now:

  • snapshot writtenContent before the call
  • apply the patch through a setTabs updater, so currentContent is read from
    the 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 dirty becomes conditional.

Two follow-on changes

updateTab is removed. It existed for exactly these two call sites, and both
are now setTabs updaters, so it became dead — an assigned-but-never-read
warning rather than something worth keeping for a hypothetical future caller.

previewBaseDir moves into the helper. Saving under a new name moves the
document, 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 settled
patch, 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:

  • no intervening edits → dirty: false
  • text typed during the write → stays dirty, but still adopts the path
  • a deletion during the write → stays dirty
  • two different buffers of equal length are not confused
  • an empty buffer saved as empty is clean
  • Save-As to a new path still renames the tab
  • fileNameOf for POSIX and Windows separators
  • a save re-points previewBaseDir at the destination folder
  • previewBaseDir follows the save even when edits are still unsaved
  • a Windows save yields a Windows base directory
  • dirOf for both separators, and for a path with no directory component

6 of 6 mutants killed, including the two that would have reintroduced the
merged-from-#313 behaviour: "never updates previewBaseDir" and "only updates it
when clean".

Worth noting: the first draft of the helper compared in the wrong direction
(dirty: written === current), and these tests caught it immediately — 6 failed
rather than passing silently. The previewBaseDir cases went the same way: a
first 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 on main; this PR adds 12)
  • npm run lint — clean, no warnings
  • npm run build — clean
  • backend pytest — unchanged

@harsh-thakkar7
harsh-thakkar7 force-pushed the fix/savefile-dirty-compare-and-clear branch from 67113c2 to c00a951 Compare October 2, 2026 09:00
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
harsh-thakkar7 force-pushed the fix/savefile-dirty-compare-and-clear branch from c00a951 to 82b085a Compare October 2, 2026 09:03
petertzy and others added 2 commits October 2, 2026 12:20
`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
harsh-thakkar7 force-pushed the fix/savefile-dirty-compare-and-clear branch from 82b085a to dbb1774 Compare October 2, 2026 10:22
@petertzy

petertzy commented Oct 2, 2026

Copy link
Copy Markdown
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.

@petertzy
petertzy merged commit 41b4065 into petertzy:main Oct 2, 2026
2 checks passed
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.

3 participants