Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions src/ExternalChangeDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -37,20 +42,26 @@ 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)
font.bold: true
}

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"
Expand All @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
71 changes: 70 additions & 1 deletion src/backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,48 @@ 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;
}

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;
}
Expand All @@ -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);
Expand All @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +273 to +278

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Conflict strands pending close

When a close-triggered save encounters a file that appeared at the target, this branch returns without saveSucceeded; choosing Keep Mine only records the decision, so the pending close remains unfinished until the user manually saves again.

Knowledge Base Used:


saveTo(m_fileUrl);
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<QLockFile> m_recoveryLock;

Expand Down
Loading