Take the name of a file that is not there yet - #13
Conversation
Backend::open only reads: a path with nothing behind it fails the ReadOnly open, reports "Could not open", and returns before setFileUrl, so the document keeps no name at all. Opening a file that has yet to be written -- `omawrite new_document.md`, the way every editor is asked to start one -- therefore left Ctrl+S with nothing to save to, and the portal's Save As dialog asked for a name the writer had already given. Claim the URL for a blank document when the path is not there, and let the first save land on it. A path that exists but cannot be read is still an error, as it should be: this is only about the file that is not there yet. Fixes omacom#6 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC
Taking any absent path meant a name under a directory that is not there was accepted too, and the failure moved from the open to the first save: `omawrite ~/notes/2026/draft.md` with no `2026/` directory opened a document titled `draft.md`, and Ctrl+S then answered with `Could not save draft.md.` in the footer and no dialog, because the URL was valid enough to skip the Save As fallback that used to catch this. Same for a name in a directory the writer cannot write, and for a trailing-slash path, which is a directory name that `fileName()` reports as `Untitled.md`. Claim the name only where the file could actually be created. Everything else keeps master's answer: `Could not open`, and the first save still offers somewhere to put the text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed against Pushed Two things I did not change, for the maintainer to decide:
Worth knowing before merge: #9 rewrites the same function. It routes every write of |
reloadFromDisk() calls open(), and open() now takes a path with nothing behind it as a file the writer means to start. Reload is not that: it is asked for a file we already read. The "File changed" dialog leaves the Reload button enabled whenever the file was still there when the watcher fired, so a deletion in the seconds between the dialog opening and the click reaches reloadFromDisk() with the file gone -- and the new-file branch answered by blanking the editor, clearing the recovery snapshot and reporting "New file". That is the writer's only remaining copy, and master reported "Could not open" and left it alone. Route both callers through a private openPath() that carries whether an absent path may become a new document. open() still says yes; reload says no and falls through to the ReadOnly open that fails, which is master's answer, word for word. Checking existence in reloadFromDisk() first would leave the same race one function further along; the flag closes it rather than narrowing it. keepsTheDocumentWhenReloadRacesADeletion drives the real document: open a file, type over it, remove the file, reload. Without the flag the status reads "New file racing.md" and the typed text is gone; with it the status is "Could not open racing.md.", the text, the URL and the modified flag are all untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
Taking the name of a file that is not there leaves the path unguarded until the first save. watchCurrentFile() can only watch a file that exists and no directory is watched, so nothing can report a file appearing on that path -- and save() went straight to QSaveFile, whose commit() replaces whatever it finds. A `git pull` or a sync client landing that file between the open and Ctrl+S was overwritten in silence. Before this branch the same case had no name to save to and went through the Save As dialog, which at least asks. The state that says so needs its own flag. m_hasKnownFileContents answers a different question -- whether we hold a copy to compare a watcher event against -- and reading "we have never looked at this path" out of it does not work, because keepExternalVersion() sets it back to false whenever the disk read fails. Keying the guard on it therefore trapped the writer: if what turned up could not be read, a directory or a file they may write but not read, Keep Mine reported "Kept your version", changed nothing, and every Ctrl+S after it raised the same dialog again, with no answer that ended it. m_pathNeverRead is set only where this document takes a name without having read what is on it -- the new-file open, and a recovery snapshot naming a file that was never written -- and cleared by every outcome that settles the question: a successful read in openPath(), a successful write in saveTo(), a recovery snapshot whose file did read, and keepExternalVersion() on both of its branches, the failing one included. Failing to read is still an answer. The next save then goes to the filesystem and the filesystem's answer ends it, which is the only thing that can. The guard reports the appeared file with its own signal rather than externalChangeDetected. The two cases need different words and, more than that, different defaults: ExternalChangeDialog focuses and highlights Reload for an ordinary outside edit, which is right when the file on disk is a second copy of the work, and catastrophic here, where the writer's only copy is in the editor and reloadFromDisk() clears the recovery snapshot on its way past. Enter on that dialog would have taken the whole draft. Keep Mine now leads whenever the safe answer is to keep, deleted and appeared alike, and the text says a file appeared and that reloading discards everything typed. The guard is in save() rather than saveTo() on purpose. saveAs() reaches saveTo() from the portal's Save File dialog, which has already asked about replacing whatever the writer picked; asking a second time in a different dialog would be worse than not asking at all. Three tests. asksBeforeAFirstSaveReplacesAFileThatAppeared: without the guard the file on disk reads "my draft" where "arrived from elsewhere" was. asksOnlyOnceWhenWhatAppearedCannotBeRead puts a directory on the path; without the clear in keepExternalVersion() the dialog is raised twice instead of once and the readable case never saves at all. putsKeepMineForwardWhenAFileAppeared holds the button rule; under the old one Keep Mine is not primary for an appeared file, so Reload takes the focus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
Refusing the reload keeps the writer's text, which is what that change was for, but it leaves the document in a state nothing is looking after: a name, no file behind it, and a watcher that dropped the path when the file went. If the file comes back -- the pull that removed it landing the next commit, the sync client finishing what it started -- the save had nothing to stop it, and QSaveFile replaced the returned file unread. That is the same hazard the appeared-file guard exists to close, one step further along the same flow. It is the same state, so it gets the same flag. A read that fails on a path with nothing on it, reached from reloadFromDisk() rather than open(), sets m_pathNeverRead: this document has a name it has not looked behind. The open() side is deliberately left out. Its failure leaves a different document loaded under a different URL, and marking that one unread would be a lie about a file we did read. This is master's hazard too, not something the branch introduced, but the reload path is one this branch rewrote and it is a line away from the guard that closes it. keepsTheDocumentWhenReloadRacesADeletion now runs on past the refused reload: the file returns with different contents and the save must ask. Without the flag no dialog is raised at all, and the returned file is overwritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
The two guards you left for a decision, and what checking them turned upThank you for Both of the things you left for the maintainer are fixed here rather than accepted. Neither is a cost of the feature; both are guards master had, and taking the name early is what dropped them. The unwatched pathFixed in Getting there took two attempts, and the first one is the interesting part. It keyed the guard on So there is now a separate The second thing the first attempt got wrong: it reused the external-change dialog as-is, and The reload raceFixed in That closed half of it. After the refused reload the path is still unwatched and the document still holds a name, so if the file comes back — a The close latch, filed separately as #23Worth knowing about, since this PR makes it easier to reach. When the unsaved-changes dialog's Save leads to a save that does not happen, It is not this PR's bug: I reproduced it on On #9Your note was right that it rewrites this function, and wrong about which line fixes it — through no fault of yours, since That commit added But the merge does introduce the opposite problem — // the new-file branch: the baseline is known, and it is empty
setKnownFileContents(QByteArray(), true);
m_pathNeverRead = true;
// and in #9's status branch, so a file never written is not called "Saved"
if (m_hasKnownFileContents && !m_pathNeverRead)
setStatus(QStringLiteral("Saved %1").arg(fileName()));That is only safe because the save guard no longer reads Still the maintainer's callFour things I decided one way and would change on request: the appeared-file wording; parameterising the existing dialog rather than adding a second one; leaving Reload enabled in the appeared case, on the grounds that taking what the sync client brought is a legitimate choice even here; and the underlying decision to raise the external-change dialog rather than fall back to Save As on that first save. 17 passing, |
Greptile SummaryThis PR lets an initially absent command-line path become the target of a new blank document while preserving unreadable-path errors and reload safety.
Confidence Score: 4/5The PR should not merge until first-save conflict detection prevents a file created during the check-to-commit window from being silently replaced. The appeared-file guard still checks target existence separately from the later save commit, so another process can create the target between those operations and have its contents overwritten without confirmation. Files Needing Attention: src/backend.cpp
|
| Filename | Overview |
|---|---|
| src/backend.cpp | Adds absent-path document initialization, first-save conflict handling, reload safeguards, and recovery persistence for never-read paths. |
| src/Main.qml | Connects appeared-file events to the external-change dialog and clears interrupted pending actions. |
| src/ExternalChangeDialog.qml | Adds appeared-file messaging and makes Keep Mine the primary focused action for that state. |
| src/backend.h | Declares the appeared-file signal, guarded open helper, and never-read path state. |
| tests/tst_omawrite.cpp | Adds coverage for absent targets, appeared files, reload deletion races, pending-action cleanup, dialog focus, and recovery. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Open local path] --> B{Path exists?}
B -- Yes --> C[Read file and watch path]
B -- No --> D{Parent writable?}
D -- No --> E[Report open failure]
D -- Yes --> F[Start blank named document]
F --> G[First save]
G --> H{Target now exists?}
H -- Yes --> I[Ask Keep Mine or Reload]
H -- No --> J[Save document]
I -- Keep Mine --> J
I -- Reload --> C
Reviews (2): Last reviewed commit: "Carry the never-read path through a reco..." | Re-trigger Greptile
| if (m_pathNeverRead && m_fileUrl.isLocalFile() | ||
| && QFileInfo::exists(m_fileUrl.toLocalFile())) { | ||
| m_closeAfterSave = false; | ||
| emit externalFileAppeared(m_modified); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Closing a window with unsaved changes leaves `pendingAction` at "close" while the unsaved-changes dialog's Save runs, and `onSaveSucceeded` is what completes it. The appeared-file guard ends that save with a question rather than a write, so nothing completes it and the intent stays stored: the window the writer asked to close stays open, and then the next successful save -- an ordinary Ctrl+S minutes later, asked for on its own -- closes it on the earlier request. The question is raised where the save would have gone, so that is where the intent is dropped. It covers both answers and Escape, which leaves the question unanswered on purpose and must not carry a close along with it. This is issue omacom#23's shape rather than its fix. A save that fails for any other reason still leaves the close standing, and the general repair belongs there; this closes the one route into it that taking the name early opens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A snapshot names a path and holds the text; restoring it reads whatever is on that path now and takes that for the baseline. For a path this document had never read, what is there now can be a file that arrived while Omawrite was not running, and clearing m_pathNeverRead on the strength of that read told the first save it had already looked. Ctrl+S then replaced the arrived file without asking -- the same loss the guard was added for, reached over a crash instead of over a `git pull`. So the snapshot carries the flag, and only the snapshot can answer the question. One written before this key existed reads as false, which is what it meant: before the branch, nothing could hold a name it had not written to. The unreadable branch keeps setting it true rather than consulting the key. A path that cannot be read is unverified whatever the snapshot said about it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Codex XHigh <noreply@openai.com>
|
Re-reviewed at The guard does what it claims, and it does not fire on the ordinary case
The TOCTOU is real, and not a reason to hold the PRI checked what What makes it a residual is the size of what it replaced. Without the guard the window is the whole span between taking the name and the first save, which can be an afternoon; with it, the window is the existence check to the rename. It is also not closable inside The close strand is real, and worse than the bot described — fixed in
|
`remembersANeverReadPathAcrossRecovery` hand-writes the recovery JSON, so it exercises `restoreRecovery()` and nothing else. Deleting the `pathNeverRead` key from `writeRecovery()` leaves the whole suite green while the data-loss path it was added for reopens: crash on a never-read path, let something create the file while Omawrite is down, and the first save after the restart replaces it without asking, because a snapshot missing the key restores as false. The new test drives the real writer -- open an absent path, type, wait for the recovery timer -- and reads the file back. Removing the key fails it, and so does writing a constant false; nothing else in the suite moves either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Codex XHigh <noreply@openai.com>
|
Re-reviewed at The recovery fix was untested in the direction that matters — fixed in
|
Fixes #6. The analysis and the shape of this fix are @Vyrnexis's, from the issue.
Backend::openonly reads. A path with nothing behind it fails theReadOnlyopen, reports "Could not open", and returns beforesetFileUrl, so the document ends up with no name at all — andBackend::savefalls back tosaveAsDialog(). Starting a file the way every editor is asked to start one,omawrite new_document.md, therefore met the portal's Save As dialog asking for a name that had already been given on the command line.opennow claims the URL for a blank document when the path is not there yet, so the first save lands where the writer said it should. A path that exists but cannot be read — a directory, a file without read permission — is still an error, and still leaves the document it could not replace alone.The status line says
New file new_document.md, and nothing is written until the writer saves.Only where the file could actually be created: a name under a directory that is not there, or one the writer cannot write into, stays on master's
Could not open. Otherwise the failure moves from the open to the first save, where there is no longer a Save As dialog to land in.The guards this removes, put back
Taking the name early gives up two things master got for free, and both are restored here rather than left as the cost of the feature.
Nothing can watch a file that is not there.
watchCurrentFile()only watches a path that exists, so between the open and the first save the path is unguarded: agit pullor a sync client can put a file there, andQSaveFile::commit()would replace it without a word. On master that case went through the Save As dialog, which asks before overwriting. The first save onto a path this document has never read now asks instead of writing.The question it asks is the one the app already asks about outside changes, so it reuses that dialog — with its own wording, since nothing changed, a file appeared, and with Keep Mine leading rather than Reload. Reloading here discards everything the writer has typed against a file they have never seen, and the recovery snapshot with it, so it must not be the button that Enter presses.
A reload that loses its file must not start a new one.
reloadFromDisk()went throughopen(), so if the file vanished between the "File changed" dialog opening and the Reload click, Reload took the absent path as a new file: document blanked, recovery cleared.open()now takes a flag; reload passes false and gets master'sCould not openback.Both guards key on a dedicated
m_pathNeverReadrather than onm_hasKnownFileContents, which answers a different question — whether there is a copy to compare a watcher event against. Overloading that one turned out to be how a first attempt at this locked the writer out of their own filename: the dialog's Keep Mine could fail to clear it, and every later save asked the same question again.m_pathNeverReadis cleared by every answer the dialog can give, including the ones that fail.Test
startsANewFileFromAPathThatIsNotThereYetdrives the realMain.qmldocument:QSignalSpyonsaveDialogRequestedstays at 0);Could not open, keeping the previous document and its URL.asksBeforeAFirstSaveReplacesAFileThatAppeared,asksOnlyOnceWhenWhatAppearedCannotBeRead,keepsTheDocumentWhenReloadRacesADeletionandputsKeepMineForwardWhenAFileAppearedcover the guards: that the first save onto a file that turned up asks rather than writes, that answering it once is enough even when what turned up cannot be read at all, that a refused reload keeps the document and leaves the path guarded for when the file comes back, and that Keep Mine is the button carrying focus.Each fails with its own fix backed out, on the assertion it is named for. The unreadable case uses a directory on the path rather than a permission bit, since permissions invert under root.
Full suite in
build-tests: 17 passed, 0 failed.🤖 Generated with Claude Code
https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC