diff --git a/src/ExternalChangeDialog.qml b/src/ExternalChangeDialog.qml index 2bf59c5..d20b972 100644 --- a/src/ExternalChangeDialog.qml +++ b/src/ExternalChangeDialog.qml @@ -5,7 +5,12 @@ Dialog { id: root property bool deleted: false + // A file turned up on a path this document took while it was empty and + // never read. Unlike the other two cases there is no copy of this text on + // disk, so Reload is the destructive answer here, not the safe one. + property bool appeared: false property bool locallyModified: false + readonly property bool keepIsSafer: deleted || appeared property bool darkMode: true property color textColor: darkMode ? "#d0d0d0" : "#42464c" property color strongTextColor: darkMode ? "#eeeeee" : "#222324" @@ -25,7 +30,7 @@ Dialog { y: Math.round((containerHeight - height) / 2) padding: 20 - onOpened: (deleted ? keepButton : reloadButton).forceActiveFocus() + onOpened: (keepIsSafer ? keepButton : reloadButton).forceActiveFocus() background: Rectangle { color: root.darkMode ? "#1a1a1a" : "#ffffff" @@ -37,7 +42,10 @@ Dialog { spacing: 12 Label { - text: root.deleted ? "File removed" : "File changed" + objectName: "externalChangeHeading" + text: root.deleted + ? "File removed" + : (root.appeared ? "File appeared" : "File changed") color: root.strongTextColor font.family: "iA Writer Mono S" font.pixelSize: Math.round(16 * root.textScale) @@ -45,12 +53,15 @@ Dialog { } Label { + objectName: "externalChangeMessage" width: parent.width text: root.deleted ? "This file was removed outside Omawrite. Keep your text as an unsaved document?" - : (root.locallyModified - ? "This file changed outside Omawrite. Reloading will discard your changes." - : "This file changed outside Omawrite.") + : (root.appeared + ? "Something else created this file after Omawrite took the name. None of your text has been written yet, so reloading will discard everything you have typed." + : (root.locallyModified + ? "This file changed outside Omawrite. Reloading will discard your changes." + : "This file changed outside Omawrite.")) color: root.textColor wrapMode: Text.Wrap font.family: "iA Writer Mono S" @@ -70,11 +81,12 @@ Dialog { SquareDialogButton { id: keepButton + objectName: "keepMineButton" text: "Keep Mine" darkMode: root.darkMode textScale: root.textScale - labelColor: root.deleted ? "#ffffff" : root.textColor - primary: root.deleted + labelColor: root.keepIsSafer ? "#ffffff" : root.textColor + primary: root.keepIsSafer activeColor: root.activeButtonColor KeyNavigation.left: reloadButton KeyNavigation.right: reloadButton @@ -88,9 +100,10 @@ Dialog { SquareDialogButton { id: reloadButton + objectName: "reloadButton" text: "Reload" enabled: !root.deleted - primary: true + primary: !root.keepIsSafer darkMode: root.darkMode textScale: root.textScale activeColor: root.activeButtonColor diff --git a/src/Main.qml b/src/Main.qml index cdcbc3e..cc8e3f8 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -262,6 +262,19 @@ ApplicationWindow { function onExternalChangeDetected(deleted, locallyModified) { externalChangeDialog.deleted = deleted; + externalChangeDialog.appeared = false; + externalChangeDialog.locallyModified = locallyModified; + externalChangeDialog.open(); + } + + function onExternalFileAppeared(locallyModified) { + // This save is not going to happen, so whatever it was for cannot + // follow it. Leaving the intent standing lets an unrelated save + // minutes later close the window or open another document. + win.awaitingPendingSave = false; + win.pendingAction = ""; + externalChangeDialog.deleted = false; + externalChangeDialog.appeared = true; externalChangeDialog.locallyModified = locallyModified; externalChangeDialog.open(); } diff --git a/src/backend.cpp b/src/backend.cpp index 90e279e..d37e985 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -199,6 +199,10 @@ void Backend::openDialog() { } void Backend::open(const QUrl &url) { + openPath(url, true); +} + +void Backend::openPath(const QUrl &url, bool mayStartNewFile) { if (!url.isLocalFile()) { setStatus(QStringLiteral("Only local files can be opened.")); return; @@ -206,7 +210,37 @@ void Backend::open(const QUrl &url) { const QString targetName = QFileInfo(url.toLocalFile()).fileName(); QFile file(url.toLocalFile()); + // A path that is not there yet is a file the writer means to start, so + // take the name for a blank document. The first save then lands where + // they said it should, instead of asking them again. + if (mayStartNewFile && !file.exists()) { + // Only where it could be written: a name under a directory that is not + // there leaves the first save with nowhere to land and no dialog. + const QFileInfo parentDirectory(QFileInfo(url.toLocalFile()).absolutePath()); + if (!parentDirectory.isDir() || !parentDirectory.isWritable()) { + setStatus(QStringLiteral("Could not open %1.").arg(targetName)); + return; + } + + loadDocumentText(QString()); + clearRecovery(); + m_lastKnownFileContents.clear(); + m_hasKnownFileContents = false; + m_pathNeverRead = true; + setFileUrl(url); + setModified(false); + setStatus(QStringLiteral("New file %1").arg(fileName())); + return; + } + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + // A reload with nothing left to read leaves this document holding a + // name and no file, and the watcher let the path go when it went. + // That is the state a new file starts in, so say so: if the file + // comes back, the next save asks rather than replacing it unseen. + if (!mayStartNewFile && !file.exists()) + m_pathNeverRead = true; + setStatus(QStringLiteral("Could not open %1.").arg(targetName)); return; } @@ -216,6 +250,7 @@ void Backend::open(const QUrl &url) { clearRecovery(); m_lastKnownFileContents = contents; m_hasKnownFileContents = true; + m_pathNeverRead = false; setFileUrl(url); watchCurrentFile(); setModified(false); @@ -228,6 +263,20 @@ void Backend::save() { return; } + // Nothing can watch a file that is not there, so a name taken for a file + // that has yet to be written is unguarded until this save: a `git pull` or + // a sync client can put something on that path in the meantime and + // QSaveFile::commit() would replace it without a word. Ask once, and only + // once -- the flag is cleared by every answer the dialog can give, so a + // file that turns out to be unreadable cannot leave the writer trapped in + // a question they have already answered. + if (m_pathNeverRead && m_fileUrl.isLocalFile() + && QFileInfo::exists(m_fileUrl.toLocalFile())) { + m_closeAfterSave = false; + emit externalFileAppeared(m_modified); + return; + } + saveTo(m_fileUrl); } @@ -258,8 +307,13 @@ void Backend::discardRecovery() { } void Backend::reloadFromDisk() { + // Reload is asked for a file we already have, so it must not go down the + // path that takes an absent name for a new document: if the file goes away + // between the "File changed" dialog opening and the click, blanking the + // editor and clearing recovery would throw away the only copy left. Say + // it could not be opened and leave the text where it is. if (m_fileUrl.isLocalFile()) - open(m_fileUrl); + openPath(m_fileUrl, false); } void Backend::keepExternalVersion() { @@ -271,6 +325,11 @@ void Backend::keepExternalVersion() { m_lastKnownFileContents.clear(); m_hasKnownFileContents = false; } + // Answered, whether or not the file could be read. Failing to read it is + // not a reason to ask again: the writer said to keep their version, and + // the next save must be allowed to try, so the filesystem gets to give + // the answer instead of the dialog asking the same question forever. + m_pathNeverRead = false; setModified(true); scheduleRecovery(); watchCurrentFile(); @@ -500,6 +559,7 @@ void Backend::saveTo(const QUrl &url) { m_closeAfterSave = false; m_lastKnownFileContents = contents; m_hasKnownFileContents = true; + m_pathNeverRead = false; setFileUrl(url); watchCurrentFile(); QSettings().setValue(lastSaveDirectorySetting, @@ -532,6 +592,7 @@ void Backend::writeRecovery() { if (!file.open(QIODevice::WriteOnly)) return; const QJsonObject recovery{{QStringLiteral("fileUrl"), m_fileUrl.toString()}, + {QStringLiteral("pathNeverRead"), m_pathNeverRead}, {QStringLiteral("text"), currentDocumentText()}}; file.write(QJsonDocument(recovery).toJson(QJsonDocument::Compact)); file.commit(); @@ -551,9 +612,17 @@ void Backend::restoreRecovery() { if (recoveredUrl.isLocalFile() && diskFile.open(QIODevice::ReadOnly)) { m_lastKnownFileContents = diskFile.readAll(); m_hasKnownFileContents = true; + // Reading it now says what is on the path, not that this document ever + // looked: the file can have arrived while Omawrite was gone. Only the + // snapshot knows, so a snapshot without the key predates the flag and + // names a path something was written to. + m_pathNeverRead = recovery.value(QStringLiteral("pathNeverRead")).toBool(); } else { m_lastKnownFileContents.clear(); m_hasKnownFileContents = false; + // A snapshot can name a file that was never written -- the crash came + // first. That is the same unverified path a new file starts on. + m_pathNeverRead = true; } setFileUrl(recoveredUrl); setModified(true); diff --git a/src/backend.h b/src/backend.h index 2429590..31b8b7d 100644 --- a/src/backend.h +++ b/src/backend.h @@ -88,8 +88,10 @@ class Backend : public QObject { void saveDialogRequested(const QUrl &suggestedUrl); void saveSucceeded(); void externalChangeDetected(bool deleted, bool locallyModified); + void externalFileAppeared(bool locallyModified); private: + void openPath(const QUrl &url, bool mayStartNewFile); void loadDocumentText(const QString &text); void setFileUrl(const QUrl &url); void setModified(bool modified); @@ -132,6 +134,12 @@ class Backend : public QObject { QString m_lastDocumentText; QByteArray m_lastKnownFileContents; bool m_hasKnownFileContents = false; + // Set where this document takes a name without having read what is on it, + // and cleared the moment anything settles the question -- a read, a write, + // or the writer answering the dialog. It is not the same question as + // m_hasKnownFileContents, which asks whether we hold a copy to compare + // against; a path we have never looked at is one nothing can watch. + bool m_pathNeverRead = false; QString m_recoveryPath; std::unique_ptr m_recoveryLock; diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 5c3306a..a2e9d01 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -162,6 +162,431 @@ private slots: QCOMPARE(editor->property("wrappedSelectionEnd").toInt(), 12); } + void startsANewFileFromAPathThatIsNotThereYet() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString newPath = directory.filePath(QStringLiteral("new_document.md")); + const QString existingPath = directory.filePath(QStringLiteral("already-there.md")); + QFile existing(existingPath); + QVERIFY(existing.open(QIODevice::WriteOnly | QIODevice::Text)); + existing.write("on disk already"); + existing.close(); + + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(editor); + + // A name from the command line that is not on disk yet is still this + // document's name, blank as the document is. + QSignalSpy saveDialogSpy(&backend, &Backend::saveDialogRequested); + backend.open(QUrl::fromLocalFile(newPath)); + QCOMPARE(backend.fileUrl(), QUrl::fromLocalFile(newPath)); + QCOMPARE(backend.fileName(), QStringLiteral("new_document.md")); + QCOMPARE(backend.status(), QStringLiteral("New file new_document.md")); + QCOMPARE(editor->property("text").toString(), QString()); + QVERIFY(!backend.modified()); + + // Opening it wrote nothing: the file appears when the writer saves. + QVERIFY(!QFileInfo::exists(newPath)); + + editor->setProperty("text", QStringLiteral("first words")); + QVERIFY(backend.modified()); + backend.save(); + QCOMPARE(saveDialogSpy.count(), 0); + QVERIFY(!backend.modified()); + + QFile written(newPath); + QVERIFY(written.open(QIODevice::ReadOnly | QIODevice::Text)); + QCOMPARE(written.readAll(), QByteArray("first words")); + written.close(); + + // A file that is there still opens and reads. + backend.open(QUrl::fromLocalFile(existingPath)); + QCOMPARE(backend.fileName(), QStringLiteral("already-there.md")); + QCOMPARE(editor->property("text").toString(), QStringLiteral("on disk already")); + + // A path that is there but cannot be read is still an error, and + // leaves the document it could not replace alone. + backend.open(QUrl::fromLocalFile(directory.path())); + QCOMPARE(backend.status(), + QStringLiteral("Could not open %1.") + .arg(QFileInfo(directory.path()).fileName())); + QCOMPARE(backend.fileUrl(), QUrl::fromLocalFile(existingPath)); + QCOMPARE(editor->property("text").toString(), QStringLiteral("on disk already")); + + // A name under a directory that is not there is not a file anyone can + // start, so it stays an error rather than a document that cannot save. + backend.open(QUrl::fromLocalFile( + directory.filePath(QStringLiteral("not-there/child.md")))); + QCOMPARE(backend.status(), QStringLiteral("Could not open child.md.")); + QCOMPARE(backend.fileUrl(), QUrl::fromLocalFile(existingPath)); + QCOMPARE(editor->property("text").toString(), QStringLiteral("on disk already")); + } + + void asksBeforeAFirstSaveReplacesAFileThatAppeared() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("arriving.md")); + + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(editor); + + backend.open(QUrl::fromLocalFile(path)); + QCOMPARE(backend.status(), QStringLiteral("New file arriving.md")); + editor->setProperty("text", QStringLiteral("my draft")); + QVERIFY(backend.modified()); + + // A file that is not there cannot be watched, so nothing tells us when + // a `git pull` or a sync client puts one on that path. The first save + // is the first look, and it must not replace a file it has never read. + QFile arrived(path); + QVERIFY(arrived.open(QIODevice::WriteOnly | QIODevice::Text)); + arrived.write("arrived from elsewhere"); + arrived.close(); + + QSignalSpy appearedSpy(&backend, &Backend::externalFileAppeared); + QSignalSpy saveDialogSpy(&backend, &Backend::saveDialogRequested); + backend.save(); + QCOMPARE(appearedSpy.count(), 1); + QCOMPARE(appearedSpy.takeFirst().constFirst().toBool(), true); + + // Asked, not answered: the file on disk is whole and the draft is + // still unsaved. The name is not in question, so no Save As dialog. + QCOMPARE(saveDialogSpy.count(), 0); + QVERIFY(backend.modified()); + QFile untouched(path); + QVERIFY(untouched.open(QIODevice::ReadOnly | QIODevice::Text)); + QCOMPARE(untouched.readAll(), QByteArray("arrived from elsewhere")); + untouched.close(); + + // Keeping your version is what the dialog offers, and the save that + // follows it goes through: the guard asks once, it does not lock the + // writer out of the name they gave. + backend.keepExternalVersion(); + backend.save(); + QVERIFY(!backend.modified()); + QFile written(path); + QVERIFY(written.open(QIODevice::ReadOnly | QIODevice::Text)); + QCOMPARE(written.readAll(), QByteArray("my draft")); + written.close(); + } + + void asksOnlyOnceWhenWhatAppearedCannotBeRead() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("blocked.md")); + + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(editor); + + backend.open(QUrl::fromLocalFile(path)); + editor->setProperty("text", QStringLiteral("my draft")); + QVERIFY(backend.modified()); + + // What turns up on the path need not be a readable file. A directory + // is the plainest case: keepExternalVersion() cannot read it, so it + // has no contents to remember afterwards. + QVERIFY(QDir().mkpath(path)); + + QSignalSpy appearedSpy(&backend, &Backend::externalFileAppeared); + backend.save(); + QCOMPARE(appearedSpy.count(), 1); + + // Keeping your version answers the question, and an answer that could + // not be read is still an answer. Asking again would put the writer in + // a dialog with no way out of it, every Ctrl+S for the rest of the + // session. The second save goes to the filesystem and reports what the + // filesystem says, which is the only thing that can end this. + backend.keepExternalVersion(); + QCOMPARE(backend.status(), QStringLiteral("Kept your version")); + backend.save(); + QCOMPARE(appearedSpy.count(), 1); + QCOMPARE(backend.status(), QStringLiteral("Could not save blocked.md.")); + } + + void dropsThePendingCloseWhenTheSaveIsRefused() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("closing.md")); + + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(editor); + + backend.open(QUrl::fromLocalFile(path)); + editor->setProperty("text", QStringLiteral("my draft")); + QVERIFY(backend.modified()); + + QFile arrived(path); + QVERIFY(arrived.open(QIODevice::WriteOnly | QIODevice::Text)); + arrived.write("arrived from elsewhere"); + arrived.close(); + + // Closing the window with unsaved changes leaves "close" standing + // while the unsaved-changes dialog's Save runs. The guard turns that + // save into a question, so the close it was for cannot follow. + window->setProperty("pendingAction", QStringLiteral("close")); + window->setProperty("awaitingPendingSave", true); + QSignalSpy appearedSpy(&backend, &Backend::externalFileAppeared); + backend.save(); + QCOMPARE(appearedSpy.count(), 1); + QCOMPARE(window->property("pendingAction").toString(), QString()); + QVERIFY(!window->property("awaitingPendingSave").toBool()); + + // Otherwise the next successful save -- this one, minutes later and + // asked for on its own -- closes the window on the earlier request. + backend.keepExternalVersion(); + backend.save(); + QVERIFY(!backend.modified()); + QVERIFY(!window->property("closeConfirmed").toBool()); + } + + void putsKeepMineForwardWhenAFileAppeared() { + const QString dialogPath = QFINDTESTDATA("../src/ExternalChangeDialog.qml"); + QVERIFY(!dialogPath.isEmpty()); + + QQmlEngine engine; + QQmlComponent component(&engine, QUrl::fromLocalFile(dialogPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer dialog(component.create()); + QVERIFY2(dialog, qPrintable(component.errorString())); + + QObject *keep = dialog->findChild(QStringLiteral("keepMineButton")); + QObject *reload = dialog->findChild(QStringLiteral("reloadButton")); + QObject *message = dialog->findChild(QStringLiteral("externalChangeMessage")); + QObject *heading = dialog->findChild(QStringLiteral("externalChangeHeading")); + QVERIFY(keep); + QVERIFY(reload); + QVERIFY(message); + QVERIFY(heading); + + // For an ordinary outside edit the file on disk is a second copy of + // the work, so Reload is the safe answer and leads, as it always has. + QVERIFY(!dialog->property("keepIsSafer").toBool()); + QVERIFY(reload->property("primary").toBool()); + QVERIFY(!keep->property("primary").toBool()); + + // For a file that appeared there is no second copy: every word the + // writer has is in the editor, and reloading throws all of it away, + // recovery snapshot included. The button that does that must not be + // the one Enter presses, and the text must say what is at stake. + dialog->setProperty("appeared", true); + QVERIFY(dialog->property("keepIsSafer").toBool()); + QVERIFY(keep->property("primary").toBool()); + QVERIFY(!reload->property("primary").toBool()); + QCOMPARE(heading->property("text").toString(), QStringLiteral("File appeared")); + const QString message_ = message->property("text").toString(); + QVERIFY2(message_.contains(QStringLiteral("created this file")), qPrintable(message_)); + QVERIFY2(message_.contains(QStringLiteral("discard everything")), qPrintable(message_)); + } + + void keepsTheDocumentWhenReloadRacesADeletion() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("racing.md")); + QFile onDisk(path); + QVERIFY(onDisk.open(QIODevice::WriteOnly | QIODevice::Text)); + onDisk.write("what was there"); + onDisk.close(); + + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(editor); + + backend.open(QUrl::fromLocalFile(path)); + QCOMPARE(editor->property("text").toString(), QStringLiteral("what was there")); + editor->setProperty("text", QStringLiteral("words only I have")); + QVERIFY(backend.modified()); + + // The "File changed" dialog leaves Reload enabled for a file that was + // still there when it opened. If the file goes away before the click, + // the reload has nothing to read: it must say so, not take the missing + // path for a new document and blank the only copy of this text. + QVERIFY(QFile::remove(path)); + backend.reloadFromDisk(); + QCOMPARE(backend.status(), QStringLiteral("Could not open racing.md.")); + QCOMPARE(editor->property("text").toString(), QStringLiteral("words only I have")); + QCOMPARE(backend.fileUrl(), QUrl::fromLocalFile(path)); + QVERIFY(backend.modified()); + + // Refusing the reload leaves this document where a new file starts: + // a name, nothing behind it, and a watcher that let the path go when + // the file did. So the same thing can happen again from here -- the + // pull that removed the file landing the next commit -- and the save + // has to ask about it just the same. + QFile returned(path); + QVERIFY(returned.open(QIODevice::WriteOnly | QIODevice::Text)); + returned.write("came back different"); + returned.close(); + + QSignalSpy appearedSpy(&backend, &Backend::externalFileAppeared); + backend.save(); + QCOMPARE(appearedSpy.count(), 1); + QFile intact(path); + QVERIFY(intact.open(QIODevice::ReadOnly | QIODevice::Text)); + QCOMPARE(intact.readAll(), QByteArray("came back different")); + intact.close(); + } + + void writesTheNeverReadPathIntoTheSnapshot() { + QTemporaryDir homeDirectory; + QVERIFY(homeDirectory.isValid()); + const QByteArray originalHome = qgetenv("HOME"); + struct HomeRestorer { + QByteArray value; + ~HomeRestorer() { qputenv("HOME", value); } + } restoreHome{originalHome}; + QVERIFY(qputenv("HOME", homeDirectory.path().toUtf8())); + + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("fresh.md")); + + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(editor); + + backend.open(QUrl::fromLocalFile(path)); + editor->setProperty("text", QStringLiteral("words only I have")); + QVERIFY(backend.modified()); + + // The snapshot the next run reads is the one this run wrote, so the + // flag has to survive the write as well as the read. Hand-writing the + // JSON proves only half of that, and it is the half that cannot lose + // a file. + const QString snapshotPath = + QDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)) + .filePath(QStringLiteral("recovery-0.json")); + QTRY_VERIFY(QFile::exists(snapshotPath)); + + QFile snapshot(snapshotPath); + QVERIFY(snapshot.open(QIODevice::ReadOnly)); + const QJsonObject recovery = QJsonDocument::fromJson(snapshot.readAll()).object(); + snapshot.close(); + QVERIFY(recovery.contains(QStringLiteral("pathNeverRead"))); + QVERIFY(recovery.value(QStringLiteral("pathNeverRead")).toBool()); + } + + void remembersANeverReadPathAcrossRecovery() { + QTemporaryDir homeDirectory; + QVERIFY(homeDirectory.isValid()); + const QByteArray originalHome = qgetenv("HOME"); + struct HomeRestorer { + QByteArray value; + ~HomeRestorer() { qputenv("HOME", value); } + } restoreHome{originalHome}; + QVERIFY(qputenv("HOME", homeDirectory.path().toUtf8())); + + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = directory.filePath(QStringLiteral("fresh.md")); + + // The snapshot a crash leaves behind, for a new file whose first save + // never happened. + const QString stateDirectory = + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + QVERIFY(QDir().mkpath(stateDirectory)); + QFile snapshot(QDir(stateDirectory).filePath(QStringLiteral("recovery-0.json"))); + QVERIFY(snapshot.open(QIODevice::WriteOnly)); + const QJsonObject recovery{ + {QStringLiteral("fileUrl"), QUrl::fromLocalFile(path).toString()}, + {QStringLiteral("pathNeverRead"), true}, + {QStringLiteral("text"), QStringLiteral("words only I have")}}; + snapshot.write(QJsonDocument(recovery).toJson(QJsonDocument::Compact)); + snapshot.close(); + + // A file turns up on the path while Omawrite is not running to see it. + QFile arrived(path); + QVERIFY(arrived.open(QIODevice::WriteOnly | QIODevice::Text)); + arrived.write("arrived while we were down"); + arrived.close(); + + // Reading it back on restore says what is on the path now, which is + // not the same as this document having read it. Without the flag the + // first save takes the guard's silence for permission. + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QCOMPARE(backend.status(), QStringLiteral("Recovered unsaved changes")); + QSignalSpy appearedSpy(&backend, &Backend::externalFileAppeared); + backend.save(); + QCOMPARE(appearedSpy.count(), 1); + QFile untouched(path); + QVERIFY(untouched.open(QIODevice::ReadOnly | QIODevice::Text)); + QCOMPARE(untouched.readAll(), QByteArray("arrived while we were down")); + untouched.close(); + } + void savesAndOpensFromFooterButtons() { const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); QVERIFY(!mainQmlPath.isEmpty());