#103

Reload silently on external change when nothing is unsaved

Openroot1945wants to merge intofrom

Fixes #94.

When the open file changed on disk, Omawrite always asked whether to reload, even with no unsaved edits, where reloading loses nothing. Watching a file that another program is writing, such as an agent drafting Markdown, meant dismissing the dialog after every write.

Change

  • No unsaved edits: the file reloads in place. The caret and scroll position stay where they were, clamped to the new text. A view scrolled to the bottom follows a file that grows, the way tail -f does. The footer says "Reloaded doc.md".
  • Unsaved edits, or the file removed: the dialog asks, as before. Auto-save stays paused until the writer chooses, as before.

Reloading without asking makes the watcher's timing matter, so the watcher changes too:

  • Bursts of writes. A program writing in pieces raises a burst of fileChanged events and can be caught with the file half written or empty. Changes are looked at once they have been quiet for 150 ms, and no later than 500 ms after a burst starts, so a steady writer still shows up as it goes.
  • Unsaved edits are checked at once. With unsaved edits there is no wait, and the same goes for edits that start while a change is settling. A save therefore cannot write over the other program's change before it is reported.
  • Writes during a reload are no longer lost. open() read the file before re-arming the watcher, so a write in that gap went unreported and the editor stayed behind for good. A reload and the check now re-arm before reading.
  • A briefly unreadable file is still watched. Re-arming dropped the old watch first, and inotify cannot watch a file this user cannot read, so a file that was unreadable for a moment was never watched again. It now keeps the watch it has.

The check reads in Text mode and suspends auto-save, the same as the watcher it replaces. open() now returns bool, so a reload that fails does not claim "Reloaded". The dialog drops its text for an unmodified file, since it is no longer shown for one.

Tests

Three new tests drive the real Main.qml and Backend, with real file writes picked up by QFileSystemWatcher:

  • reloadsAnUnmodifiedDocumentWithoutAsking covers caret and scroll kept, clamped to a shorter file, a short document that grows staying at the top, near-bottom staying put, the bottom following, an atomic save of identical bytes followed by a change, and an unreadable spell with recovery.
  • keepsUpWithAFileWrittenInPieces uses a separate process appending a line every 2 ms, then a truncate with the write 100 ms later, then a writer every 50 ms that must show up within 1.2 s, before it stops.
  • asksBeforeAnExternalChangeCanReplaceUnsavedEdits checks that unsaved edits are asked about within 60 ms, that a removed file still asks, and that typing while a change settles asks at once.

ignoresFileWatcherEventsForSavedContents now waits 500 ms instead of 100 ms, so it still checks something now that changes are looked at later.

Results, on Qt 6.11.2 (Arch/Omarchy), offscreen:

  • Master: 70 passed.
  • This branch: 73 passed in each of several sequential runs.
  • Stress: the watcher and auto-save tests passed every one of 240 runs, with up to 72 in parallel and CPU burners.
  • Red on master: the new tests fail for the #94 reason. The text is not reloaded because the dialog is shown, and the editor never catches up with the writer.
  • Mutation testing: across rounds, mutants of Main.qml and backend.cpp covered each condition, the timers and their intervals, re-arm order, the unreadable guard, clamping, the follow tolerance and the status text. The remaining survivors are equivalent or defence in depth, except two low-value gaps: dropping Text mode from the check (only a touched, unchanged CRLF file tells) and dropping suspendAutoSave() from it (autoSave() checks the file again before writing anyway).
  • Auto-save probes: in 9 scenarios, including an agent writing while the writer is idle, typing while another program writes at offsets around the 500 ms auto-save, own saves on LF and CRLF files, a recovered draft, and the close prompt open, nothing ever wrote over another program's change.

The pre-existing QWARN in showsLinkUrlOnlyOverTheLinkText shows on master too.

Known limitations

  • A writer that empties the file and refills it more than 150 ms later, or that the 500 ms deadline catches partway, can move the caret and view back to the shorter text. The text itself is never stale.
  • A silent reload clears the undo history, as the dialog's Reload already did.
  • These are not new: a file atomically replaced by one this user cannot read stops being watched, and a file that omawrite new.md has not created yet is not loaded live if another program creates it.

🤖 Generated with Claude Code

Activity

2
root19456d ago

Heads-up: this overlaps #101, which has since been merged. Both rework the file watcher and open() in src/backend.cpp, so this PR now conflicts with master.

I'll rebase it onto master, moving the debounce and the immediate check for unsaved edits into #101's reportExternalChange() / diskMatchesKnownContents() rather than replacing them. A silent reload will also keep #101's "Auto-save is off to keep its encoding and line endings" status for CRLF and non-UTF-8 files instead of overwriting it with "Reloaded …". I'll push the update once the tests pass again.