From 812a557d7c2640bbbe41e88363d2e62a9961a913 Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:58:22 +0200 Subject: [PATCH 01/10] Browse the current folder from a toggleable sidebar Moving between notes meant Ctrl+O and the portal file picker, a full-screen modal that drops you out of the writing context and shows nothing about what else lives beside the open document. Add a sidebar listing the Markdown in that document's folder, so the next note is one click away and a writing folder can be taken in at a glance. It stays out of the way: closed on launch, no remembered open state, and zero chrome until asked for. Ctrl+B is Bold and none of the shortcuts here use punctuation keys, so the panel takes Ctrl+E for Explorer, alongside a footer icon next to Save and Open. The listing lives on Backend, next to the watchers it resembles: a QFileSystemWatcher on the browsed folder refreshes the list when files appear or vanish elsewhere, and the folder follows the open document because setFileUrl points it at the file's directory. Folders are exempt from the name filter so an empty one can still be walked into, while files stay narrowed to what Omawrite can open. Opening a document goes through requestOpen, so unsaved work is guarded by the same dialog Ctrl+O already uses, and the last-save-directory fallback is now shared between the save dialog and the initial folder rather than spelled out twice. Co-Authored-By: Claude Opus 5 --- README.md | 2 + src/FileSidebar.qml | 148 +++++++++++++++++++++++++++++++++++++++ src/FooterIconButton.qml | 10 ++- src/Main.qml | 46 +++++++++++- src/backend.cpp | 85 +++++++++++++++++++++- src/backend.h | 17 +++++ src/resources.qrc | 1 + tests/tst_omawrite.cpp | 100 ++++++++++++++++++++++++++ 8 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 src/FileSidebar.qml diff --git a/README.md b/README.md index c44ade3..6badbae 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Install via the Omarchy Package Repository via the `omawrite` package. It's inst - `Ctrl+Shift+S` saves as. - `Ctrl+O` opens a Markdown file through the portal picker. - `Ctrl+P` opens the system print dialog. +- `Ctrl+E` toggles a sidebar listing the Markdown in the current document's + folder, so another note is one click away. It starts closed. - `Ctrl+N` opens a new Omawrite window. - `Ctrl+Z`, `Ctrl+Shift+Z`, and `Ctrl+Y` handle undo and redo. - `Super+F` toggles fullscreen. Qt maps this key as `Meta+F`. diff --git a/src/FileSidebar.qml b/src/FileSidebar.qml new file mode 100644 index 0000000..132ac5c --- /dev/null +++ b/src/FileSidebar.qml @@ -0,0 +1,148 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + property bool expanded: false + property bool darkMode: true + property real textScale: 1 + property color pageColor: darkMode ? "#101010" : "#ffffff" + property color textColor: darkMode ? "#eeeeee" : "#222324" + property color mutedColor: darkMode ? "#909191" : "#aeb1b5" + property color accentColor: "#428bca" + property color selectionFill: "#186a9a" + property string folderName: "" + property bool folderHasParent: false + property var entries: [] + property url currentFileUrl + + signal parentFolderRequested() + signal folderRequested(url folderUrl) + signal fileRequested(url fileUrl) + + readonly property int rowHeight: Math.round(26 * root.textScale) + + width: expanded ? Math.round(240 * root.textScale) : 0 + visible: width > 0 + clip: true + + // The editor sits behind the panel rather than beside it, so the + // background has to be opaque to keep text from showing through. + Rectangle { + anchors.fill: parent + color: root.pageColor + } + + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 1 + color: root.mutedColor + opacity: 0.25 + } + + Item { + id: header + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 12 + anchors.rightMargin: 13 + height: Math.round(40 * root.textScale) + + Label { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: (root.folderHasParent ? "‹ " : "") + root.folderName + color: headerArea.containsMouse ? root.textColor : root.mutedColor + elide: Text.ElideMiddle + font.family: "iA Writer Mono S" + font.pixelSize: Math.round(12 * root.textScale) + } + + MouseArea { + id: headerArea + anchors.fill: parent + enabled: root.folderHasParent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.parentFolderRequested() + } + } + + Label { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.leftMargin: 12 + anchors.rightMargin: 13 + height: root.rowHeight + verticalAlignment: Text.AlignVCenter + visible: root.entries.length === 0 + text: "Nothing here yet" + color: root.mutedColor + opacity: 0.7 + font.family: "iA Writer Mono S" + font.pixelSize: Math.round(13 * root.textScale) + } + + ListView { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.bottom: parent.bottom + anchors.rightMargin: 1 + anchors.bottomMargin: Math.round(32 * root.textScale) + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.entries + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Item { + id: row + + required property var modelData + + width: ListView.view.width + height: root.rowHeight + + readonly property bool current: + !row.modelData.isDir + && row.modelData.url.toString() === root.currentFileUrl.toString() + + Rectangle { + anchors.fill: parent + color: root.selectionFill + opacity: rowArea.containsMouse ? 0.25 : 0 + } + + Label { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: Text.AlignVCenter + text: row.modelData.isDir ? row.modelData.name + "/" : row.modelData.name + color: row.current ? root.accentColor + : (row.modelData.isDir ? root.mutedColor : root.textColor) + elide: Text.ElideRight + font.family: "iA Writer Mono S" + font.pixelSize: Math.round(13 * root.textScale) + } + + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (row.modelData.isDir) + root.folderRequested(row.modelData.url); + else + root.fileRequested(row.modelData.url); + } + } + } + } +} diff --git a/src/FooterIconButton.qml b/src/FooterIconButton.qml index f6b4239..a43fd27 100644 --- a/src/FooterIconButton.qml +++ b/src/FooterIconButton.qml @@ -54,7 +54,7 @@ Item { context.lineTo(4.5, 9.5); context.lineTo(11.5, 9.5); context.lineTo(11.5, 13.5); - } else { + } else if (control.iconName === "open") { context.moveTo(2.5, 13); context.lineTo(2.5, 3.5); context.lineTo(6.5, 3.5); @@ -62,6 +62,14 @@ Item { context.lineTo(13.5, 5.5); context.lineTo(13.5, 13); context.closePath(); + } else { + context.moveTo(2.5, 3.5); + context.lineTo(13.5, 3.5); + context.lineTo(13.5, 12.5); + context.lineTo(2.5, 12.5); + context.closePath(); + context.moveTo(6.5, 3.5); + context.lineTo(6.5, 12.5); } context.stroke(); } diff --git a/src/Main.qml b/src/Main.qml index cdcbc3e..81eb7b5 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -28,9 +28,11 @@ ApplicationWindow { readonly property int editorFontPixelSize: scaledSize(20) readonly property int editorWidth: Math.min( Math.round(writerFontMetrics.averageCharacterWidth * 65), - Math.max(360, width - Math.round(writerFontMetrics.averageCharacterWidth * 20))) + Math.max(360, width - fileSidebar.width + - Math.round(writerFontMetrics.averageCharacterWidth * 20))) property bool closeConfirmed: false property bool searchOpen: false + property bool sidebarOpen: false property bool searchUpdating: false property var searchMatches: [] property int searchMatchIndex: -1 @@ -178,6 +180,12 @@ ApplicationWindow { onActivated: shortcutsDialog.open() } + Shortcut { + sequence: "Ctrl+E" + context: Qt.ApplicationShortcut + onActivated: win.sidebarOpen = !win.sidebarOpen + } + Shortcut { sequence: "Ctrl+O" context: Qt.ApplicationShortcut @@ -331,13 +339,39 @@ ApplicationWindow { standardButtons: Dialog.Close anchors.centerIn: parent contentItem: Label { - text: "Ctrl+S Save\nCtrl+Shift+S Save As\nCtrl+O Open\nCtrl+N New Window\nCtrl+F Find\nCtrl+H Find and Replace\nCtrl+B Bold\nCtrl+I Italic\nCtrl+K Link\nCtrl+P Print\nF11 / Super+F Fullscreen\nCtrl+? Shortcuts" + text: "Ctrl+S Save\nCtrl+Shift+S Save As\nCtrl+O Open\nCtrl+E Files\nCtrl+N New Window\nCtrl+F Find\nCtrl+H Find and Replace\nCtrl+B Bold\nCtrl+I Italic\nCtrl+K Link\nCtrl+P Print\nF11 / Super+F Fullscreen\nCtrl+? Shortcuts" lineHeight: 1.5 } } + FileSidebar { + id: fileSidebar + objectName: "fileSidebar" + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + expanded: win.sidebarOpen + darkMode: win.darkMode + textScale: win.textScale + pageColor: win.pageColor + textColor: win.textColor + mutedColor: win.mutedColor + accentColor: backend.themeAccent + selectionFill: win.selectionFill + folderName: backend.folderName + folderHasParent: backend.folderHasParent + entries: backend.folderEntries + currentFileUrl: backend.fileUrl + + onParentFolderRequested: backend.openParentFolder() + onFolderRequested: function(folderUrl) { backend.setFolder(folderUrl); } + // requestOpen guards unsaved work with the same dialog Ctrl+O uses. + onFileRequested: function(fileUrl) { win.requestOpen(fileUrl); } + } + Item { anchors.fill: parent + anchors.leftMargin: fileSidebar.width Flickable { id: editorFlick @@ -821,6 +855,14 @@ ApplicationWindow { onClicked: backend.openDialog() } + FooterIconButton { + objectName: "filesButton" + iconName: "files" + iconColor: win.mutedColor + tooltip: "Files" + onClicked: win.sidebarOpen = !win.sidebarOpen + } + Label { text: backend.status color: win.mutedColor diff --git a/src/backend.cpp b/src/backend.cpp index 90e279e..81081a9 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -35,6 +35,7 @@ constexpr qreal typoraLineHeightPercent = 140; const QString lastSaveDirectorySetting = QStringLiteral("file/lastSaveDirectory"); +const QString browseDirectorySetting = QStringLiteral("file/browseDirectory"); QString Backend::normalizedLinkUrl(const QString &clipboardText) { QString candidate = clipboardText.trimmed(); @@ -117,6 +118,13 @@ Backend::Backend(QObject *parent) : QObject(parent) { emit externalChangeDetected(deleted, m_modified); }); + connect(&m_folderWatcher, &QFileSystemWatcher::directoryChanged, this, + [this]() { emit folderChanged(); }); + const QString remembered = QSettings().value(browseDirectorySetting).toString(); + applyFolder(QDir(remembered).exists() ? remembered + : defaultDirectory().absolutePath(), + false); + loadOmarchyTheme(); watchOmarchyTheme(); connect(&m_themeWatcher, &QFileSystemWatcher::fileChanged, this, [this]() { @@ -198,6 +206,21 @@ void Backend::openDialog() { emit openDialogRequested(); } +void Backend::setFolder(const QUrl &url) { + if (!url.isLocalFile()) + return; + + applyFolder(url.toLocalFile(), true); +} + +void Backend::openParentFolder() { + QDir directory(m_folderUrl.toLocalFile()); + if (!directory.cdUp()) + return; + + applyFolder(directory.absolutePath(), true); +} + void Backend::open(const QUrl &url) { if (!url.isLocalFile()) { setStatus(QStringLiteral("Only local files can be opened.")); @@ -445,6 +468,8 @@ void Backend::setFileUrl(const QUrl &url) { m_fileUrl = url; emit fileUrlChanged(); watchCurrentFile(); + if (m_fileUrl.isLocalFile()) + applyFolder(QFileInfo(m_fileUrl.toLocalFile()).absolutePath(), true); } void Backend::setModified(bool modified) { @@ -573,6 +598,57 @@ void Backend::watchCurrentFile() { m_fileWatcher.addPath(m_fileUrl.toLocalFile()); } +void Backend::applyFolder(const QString &path, bool remember) { + const QDir directory = QDir(path).exists() ? QDir(path) : defaultDirectory(); + const QUrl folderUrl = QUrl::fromLocalFile(directory.absolutePath()); + if (m_folderUrl == folderUrl) + return; + + m_folderUrl = folderUrl; + watchCurrentFolder(); + if (remember) + QSettings().setValue(browseDirectorySetting, directory.absolutePath()); + emit folderChanged(); +} + +void Backend::watchCurrentFolder() { + const QStringList watched = m_folderWatcher.directories(); + if (!watched.isEmpty()) + m_folderWatcher.removePaths(watched); + if (m_folderUrl.isLocalFile()) + m_folderWatcher.addPath(m_folderUrl.toLocalFile()); +} + +QString Backend::folderName() const { + const QDir directory(m_folderUrl.toLocalFile()); + // The root directory has no name of its own; show its path instead. + return directory.isRoot() ? directory.absolutePath() : directory.dirName(); +} + +bool Backend::folderHasParent() const { + return !QDir(m_folderUrl.toLocalFile()).isRoot(); +} + +QVariantList Backend::folderEntries() const { + static const QStringList markdownFilter{QStringLiteral("*.md"), + QStringLiteral("*.markdown")}; + // AllDirs exempts folders from the name filter so an empty one can still + // be walked into, while files stay narrowed to what Omawrite can open: + // this is a view of a writing folder, not a file manager. + QVariantList entries; + const QFileInfoList infos = QDir(m_folderUrl.toLocalFile()) + .entryInfoList(markdownFilter, QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot, + QDir::DirsFirst | QDir::Name | QDir::IgnoreCase); + entries.reserve(infos.size()); + for (const QFileInfo &info : infos) { + entries.append(QVariantMap{ + {QStringLiteral("name"), info.fileName()}, + {QStringLiteral("url"), QUrl::fromLocalFile(info.absoluteFilePath())}, + {QStringLiteral("isDir"), info.isDir()}}); + } + return entries; +} + void Backend::loadOmarchyTheme() { m_themeBackground = m_darkMode ? QStringLiteral("#101010") : QStringLiteral("#ffffff"); m_themeForeground = m_darkMode ? QStringLiteral("#eeeeee") : QStringLiteral("#222324"); @@ -666,12 +742,15 @@ QUrl Backend::suggestedSaveUrl() const { if (m_fileUrl.isLocalFile()) return m_fileUrl; + return QUrl::fromLocalFile( + defaultDirectory().filePath(suggestedFileName(currentDocumentText()))); +} + +QDir Backend::defaultDirectory() const { const QString savedDirectory = QSettings().value(lastSaveDirectorySetting).toString(); - const QDir directory = savedDirectory.isEmpty() || !QDir(savedDirectory).exists() + return savedDirectory.isEmpty() || !QDir(savedDirectory).exists() ? QDir::home() : QDir(savedDirectory); - return QUrl::fromLocalFile( - directory.filePath(suggestedFileName(currentDocumentText()))); } QString Backend::currentDocumentText() const { diff --git a/src/backend.h b/src/backend.h index 2429590..8c93065 100644 --- a/src/backend.h +++ b/src/backend.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,10 @@ class Backend : public QObject { Q_PROPERTY(QString themeForeground READ themeForeground NOTIFY themeColorsChanged) Q_PROPERTY(QString themeAccent READ themeAccent NOTIFY themeColorsChanged) Q_PROPERTY(QString themeSelection READ themeSelection NOTIFY themeColorsChanged) + Q_PROPERTY(QUrl folderUrl READ folderUrl NOTIFY folderChanged) + Q_PROPERTY(QString folderName READ folderName NOTIFY folderChanged) + Q_PROPERTY(bool folderHasParent READ folderHasParent NOTIFY folderChanged) + Q_PROPERTY(QVariantList folderEntries READ folderEntries NOTIFY folderChanged) public: explicit Backend(QObject *parent = nullptr); @@ -49,12 +54,18 @@ class Backend : public QObject { QString themeForeground() const { return m_themeForeground; } QString themeAccent() const { return m_themeAccent; } QString themeSelection() const { return m_themeSelection; } + QUrl folderUrl() const { return m_folderUrl; } + QString folderName() const; + bool folderHasParent() const; + QVariantList folderEntries() const; static int countWords(const QString &text); static QString normalizedLinkUrl(const QString &clipboardText); static QString suggestedFileName(const QString &text); Q_INVOKABLE void attachDocument(QObject *textDocument); Q_INVOKABLE void openDialog(); + Q_INVOKABLE void setFolder(const QUrl &url); + Q_INVOKABLE void openParentFolder(); Q_INVOKABLE void open(const QUrl &url); Q_INVOKABLE void save(); Q_INVOKABLE void saveForClose(); @@ -88,6 +99,7 @@ class Backend : public QObject { void saveDialogRequested(const QUrl &suggestedUrl); void saveSucceeded(); void externalChangeDetected(bool deleted, bool locallyModified); + void folderChanged(); private: void loadDocumentText(const QString &text); @@ -96,6 +108,9 @@ class Backend : public QObject { void setStatus(const QString &status); void saveTo(const QUrl &url); QUrl suggestedSaveUrl() const; + QDir defaultDirectory() const; + void applyFolder(const QString &path, bool remember); + void watchCurrentFolder(); QString currentDocumentText() const; void setWordCount(int words); void refreshWordCount(); @@ -126,6 +141,8 @@ class Backend : public QObject { QTimer m_wordCountTimer; QTimer m_recoveryTimer; QFileSystemWatcher m_fileWatcher; + QUrl m_folderUrl; + QFileSystemWatcher m_folderWatcher; QPointer m_document; QPointer m_parentWindow; QPointer m_highlighter; diff --git a/src/resources.qrc b/src/resources.qrc index 91d9a86..575e5fe 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -3,6 +3,7 @@ Main.qml SearchIconButton.qml FooterIconButton.qml + FileSidebar.qml SquareDialogButton.qml UnsavedChangesDialog.qml ExternalChangeDialog.qml diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 5c3306a..ae798a2 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -112,6 +112,77 @@ private slots: QTRY_COMPARE(externalChangeSpy.count(), 1); } + void listsOnlyDocumentsAndFolders() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + QVERIFY(QDir(folder.path()).mkdir(QStringLiteral("archive"))); + for (const QString &name : {QStringLiteral("second.md"), + QStringLiteral("first.markdown"), + QStringLiteral("notes.txt"), + QStringLiteral(".hidden.md")}) { + QFile file(folder.filePath(name)); + QVERIFY(file.open(QIODevice::WriteOnly)); + } + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + QCOMPARE(backend.folderName(), QDir(folder.path()).dirName()); + QVERIFY(backend.folderHasParent()); + + // Folders first, then the documents Omawrite can open, by name. + // Plain text and dotfiles are not writing in this app's sense. + const QVariantList entries = backend.folderEntries(); + QCOMPARE(entries.size(), 3); + QCOMPARE(entries.at(0).toMap().value(QStringLiteral("name")).toString(), + QStringLiteral("archive")); + QVERIFY(entries.at(0).toMap().value(QStringLiteral("isDir")).toBool()); + QCOMPARE(entries.at(1).toMap().value(QStringLiteral("name")).toString(), + QStringLiteral("first.markdown")); + QVERIFY(!entries.at(1).toMap().value(QStringLiteral("isDir")).toBool()); + QCOMPARE(entries.at(2).toMap().value(QStringLiteral("name")).toString(), + QStringLiteral("second.md")); + QCOMPARE(entries.at(2).toMap().value(QStringLiteral("url")).toUrl(), + QUrl::fromLocalFile(folder.filePath(QStringLiteral("second.md")))); + + // An empty folder still lists, so it can be walked out of again. + backend.setFolder(entries.at(0).toMap().value(QStringLiteral("url")).toUrl()); + QVERIFY(backend.folderEntries().isEmpty()); + backend.openParentFolder(); + QCOMPARE(backend.folderUrl(), QUrl::fromLocalFile(folder.path())); + } + + void browsesTheOpenDocumentsFolder() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("draft.md")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.close(); + + Backend backend; + QSignalSpy folderSpy(&backend, &Backend::folderChanged); + backend.open(QUrl::fromLocalFile(path)); + QCOMPARE(backend.folderUrl(), QUrl::fromLocalFile(folder.path())); + QCOMPARE(folderSpy.count(), 1); + } + + void noticesDocumentsWrittenElsewhere() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + QVERIFY(backend.folderEntries().isEmpty()); + + QSignalSpy folderSpy(&backend, &Backend::folderChanged); + QFile file(folder.filePath(QStringLiteral("written-elsewhere.md"))); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.close(); + + QTRY_VERIFY(folderSpy.count() > 0); + QCOMPARE(backend.folderEntries().size(), 1); + } + void keepsCursorAndSelectionStableAcrossInsertions() { const QString mutationsPath = QFINDTESTDATA("../src/EditorMutations.js"); QVERIFY(!mutationsPath.isEmpty()); @@ -246,6 +317,35 @@ private slots: QCOMPARE(QFileInfo(fallbackUrl.toLocalFile()).absolutePath(), QDir::homePath()); } + void togglesTheFileSidebar() { + 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 *sidebar = window->findChild(QStringLiteral("fileSidebar")); + QObject *filesButton = window->findChild(QStringLiteral("filesButton")); + QVERIFY(sidebar); + QVERIFY(filesButton); + + // Closed on launch: the panel takes no width from the writing area. + QVERIFY(!window->property("sidebarOpen").toBool()); + QCOMPARE(sidebar->property("width").toReal(), 0.0); + + QVERIFY(QMetaObject::invokeMethod(filesButton, "clicked")); + QVERIFY(window->property("sidebarOpen").toBool()); + QVERIFY(sidebar->property("width").toReal() > 0.0); + + QVERIFY(QMetaObject::invokeMethod(filesButton, "clicked")); + QCOMPARE(sidebar->property("width").toReal(), 0.0); + } + private: QTemporaryDir m_settingsDirectory; }; From 85afac4ecf4f0fbc6e8181ef0713bd8b157bcf3e Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:19:23 +0200 Subject: [PATCH 02/10] Drive the sidebar from the keyboard and size it to taste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar could only be reached with the mouse, which is a poor fit for a writing app: leaving the keyboard to change document undoes the point of having the panel there at all. Ctrl+E now puts focus in it, and pressing it again takes the panel away and hands the text back — it never reaches into the panel while you are writing, because the writing is what you were doing. Escape leaves the panel open but returns to writing, and the selection dims while it is not the thing being driven. Arrow keys and their vim counterparts both move, so neither habit has to be unlearned: Up/Down or j/k step through the folder, Enter or l opens a document and walks into a folder, Backspace or h goes back up. Keys are thin wrappers over named functions, which is also how the tests drive them without a window manager. Notes often need making, not only opening, so a starts a new Markdown file and A a new folder, named inline in the row where it will appear. The name is cleaned exactly as a saved document's is — the sanitizing half of suggestedFileName is now shared rather than copied — an existing name is reported instead of overwritten, a new document opens straight away, and the selection lands on whatever was just made. The right edge drags to widen the panel for longer titles. The handle sits on the edge it moves, so a width measured as a delta from it would feed the panel's own width back into the next measurement and make the panel chase the hand at half speed; the pointer's distance from the panel's fixed left edge is what the width follows instead. Widths are kept at text scale 1 like every other dimension here, so a dragged width survives a change of desktop text size, and it stops short of squeezing the writing column below its usual measure. Settings are written when the drag ends rather than every frame. The column is centred in what is left, so a pixel of panel moves it half of one: rounding that to whole pixels buys crisp glyphs only where the text is natively rendered, and costs a visible stair step everywhere else, so it is now rounded on the same condition the renderer is chosen by. Co-Authored-By: Claude Opus 5 --- README.md | 25 ++++- src/FileSidebar.qml | 242 ++++++++++++++++++++++++++++++++++++++-- src/Main.qml | 45 +++++++- src/backend.cpp | 57 +++++++++- src/backend.h | 5 + tests/tst_omawrite.cpp | 247 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 607 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6badbae..1930ae1 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ Install via the Omarchy Package Repository via the `omawrite` package. It's inst - `Ctrl+Shift+S` saves as. - `Ctrl+O` opens a Markdown file through the portal picker. - `Ctrl+P` opens the system print dialog. -- `Ctrl+E` toggles a sidebar listing the Markdown in the current document's - folder, so another note is one click away. It starts closed. +- `Ctrl+E` shows the sidebar listing the Markdown in the current document's + folder, and puts the keyboard in it. Pressing it again takes the sidebar away + and hands the keyboard back to the text — it never reaches into the panel + while you are writing. It starts closed. - `Ctrl+N` opens a new Omawrite window. - `Ctrl+Z`, `Ctrl+Shift+Z`, and `Ctrl+Y` handle undo and redo. - `Super+F` toggles fullscreen. Qt maps this key as `Meta+F`. @@ -26,6 +28,25 @@ Install via the Omarchy Package Repository via the `omawrite` package. It's inst - `Ctrl+B`, `Ctrl+I`, and `Ctrl+K` insert bold, italic, and link Markdown. - `Ctrl+?` shows the keyboard shortcut reference. +## Sidebar + +The sidebar lists the folder the open document lives in — Markdown files and +the folders beside them, nothing else. Clicking a document opens it, guarded by +the same unsaved-changes prompt as `Ctrl+O`. + +With the keyboard, once `Ctrl+E` has put focus there: + +- `Up`/`Down` or `j`/`k` move through the folder. +- `Enter`, `Right`, or `l` opens a document, or walks into a folder. +- `Backspace`, `Left`, or `h` goes up a level. +- `a` starts a new Markdown file and `A` a new folder; type the name and press + `Enter`, or `Esc` to abandon it. A new document opens straight away, and an + existing name is reported rather than overwritten. +- `Esc` returns to writing, leaving the sidebar open. + +Drag its right edge to widen it; the width is remembered, and stops short of +squeezing the writing column below its usual measure. + Unsaved drafts are recovered after an abnormal exit. Omawrite also watches open files and warns before an external change can replace local work. diff --git a/src/FileSidebar.qml b/src/FileSidebar.qml index 132ac5c..312353c 100644 --- a/src/FileSidebar.qml +++ b/src/FileSidebar.qml @@ -17,16 +17,115 @@ Item { property var entries: [] property url currentFileUrl + // Sizes are kept at text scale 1 like every other dimension in the app, + // so a dragged width survives a change of desktop text size. + property int logicalWidth: 240 + property int minimumLogicalWidth: 160 + property int maximumLogicalWidth: 640 + signal parentFolderRequested() signal folderRequested(url folderUrl) signal fileRequested(url fileUrl) + signal createDocumentRequested(string name) + signal createFolderRequested(string name) + signal widthChangeRequested(int width) + signal widthCommitted() + signal dismissed() readonly property int rowHeight: Math.round(26 * root.textScale) + readonly property bool creating: newEntryField.visible + readonly property string selectedName: + list.currentIndex >= 0 && list.currentIndex < entries.length + ? entries[list.currentIndex].name : "" + readonly property alias listHasFocus: list.activeFocus - width: expanded ? Math.round(240 * root.textScale) : 0 + width: expanded ? Math.round(logicalWidth * root.textScale) : 0 visible: width > 0 clip: true + onExpandedChanged: if (!expanded) cancelNewEntry() + onEntriesChanged: list.currentIndex = entries.length > 0 ? 0 : -1 + + function focusList() { + list.forceActiveFocus(); + if (list.currentIndex < 0 && root.entries.length > 0) + list.currentIndex = 0; + } + + // After making something, the selection sits on it rather than snapping + // back to the top of the folder. + function selectUrl(entryUrl) { + var target = entryUrl.toString(); + for (var i = 0; i < root.entries.length; i++) { + if (root.entries[i].url.toString() === target) { + list.currentIndex = i; + return; + } + } + } + + function selectNext() { + if (root.entries.length === 0) + return; + list.currentIndex = Math.min(root.entries.length - 1, list.currentIndex + 1); + } + + function selectPrevious() { + if (root.entries.length === 0) + return; + list.currentIndex = Math.max(0, list.currentIndex - 1); + } + + // Enter opens a document or walks into a folder, the one key doing what + // the row in front of you calls for. + function activateSelection() { + if (list.currentIndex < 0 || list.currentIndex >= root.entries.length) + return; + var entry = root.entries[list.currentIndex]; + if (entry.isDir) + root.folderRequested(entry.url); + else + root.fileRequested(entry.url); + } + + function goUp() { + if (root.folderHasParent) + root.parentFolderRequested(); + } + + function beginNewDocument() { + newEntryField.folderMode = false; + newEntryField.text = ""; + newEntryField.visible = true; + newEntryField.forceActiveFocus(); + } + + function beginNewFolder() { + newEntryField.folderMode = true; + newEntryField.text = ""; + newEntryField.visible = true; + newEntryField.forceActiveFocus(); + } + + function commitNewEntry() { + var name = newEntryField.text; + var folderMode = newEntryField.folderMode; + cancelNewEntry(); + if (name.trim().length === 0) + return; + if (folderMode) + root.createFolderRequested(name); + else + root.createDocumentRequested(name); + } + + function cancelNewEntry() { + newEntryField.visible = false; + newEntryField.text = ""; + if (root.expanded) + list.forceActiveFocus(); + } + // The editor sits behind the panel rather than beside it, so the // background has to be opaque to keep text from showing through. Rectangle { @@ -40,7 +139,7 @@ Item { anchors.bottom: parent.bottom width: 1 color: root.mutedColor - opacity: 0.25 + opacity: resizeHandle.containsMouse || resizeHandle.pressed ? 0.6 : 0.25 } Item { @@ -68,19 +167,71 @@ Item { enabled: root.folderHasParent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.parentFolderRequested() + onClicked: root.goUp() } } - Label { + Item { + id: newEntryRow anchors.left: parent.left anchors.right: parent.right anchors.top: header.bottom + anchors.rightMargin: 1 + height: newEntryField.visible ? root.rowHeight : 0 + + TextInput { + id: newEntryField + objectName: "newEntryField" + + property bool folderMode: false + + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: TextInput.AlignVCenter + visible: false + selectByMouse: true + color: root.textColor + selectionColor: root.selectionFill + selectedTextColor: root.textColor + font.family: "iA Writer Mono S" + font.pixelSize: Math.round(13 * root.textScale) + + Keys.onReturnPressed: function(event) { + root.commitNewEntry(); + event.accepted = true; + } + Keys.onEnterPressed: function(event) { + root.commitNewEntry(); + event.accepted = true; + } + Keys.onEscapePressed: function(event) { + root.cancelNewEntry(); + event.accepted = true; + } + + Label { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + visible: newEntryField.text.length === 0 + text: newEntryField.folderMode ? "New folder" : "New file" + color: root.mutedColor + opacity: 0.7 + font.family: newEntryField.font.family + font.pixelSize: newEntryField.font.pixelSize + } + } + } + + Label { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: newEntryRow.bottom anchors.leftMargin: 12 anchors.rightMargin: 13 height: root.rowHeight verticalAlignment: Text.AlignVCenter - visible: root.entries.length === 0 + visible: root.entries.length === 0 && !root.creating text: "Nothing here yet" color: root.mutedColor opacity: 0.7 @@ -89,21 +240,52 @@ Item { } ListView { + id: list anchors.left: parent.left anchors.right: parent.right - anchors.top: header.bottom + anchors.top: newEntryRow.bottom anchors.bottom: parent.bottom anchors.rightMargin: 1 anchors.bottomMargin: Math.round(32 * root.textScale) clip: true boundsBehavior: Flickable.StopAtBounds model: root.entries + currentIndex: -1 + highlightMoveDuration: 0 ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + + // Arrow keys and their vim counterparts both move, so neither habit + // has to be unlearned to leave the editor for a moment. + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Down || event.text === "j") { + root.selectNext(); + } else if (event.key === Qt.Key_Up || event.text === "k") { + root.selectPrevious(); + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Right || event.text === "l") { + root.activateSelection(); + } else if (event.key === Qt.Key_Backspace || event.key === Qt.Key_Left + || event.text === "h") { + root.goUp(); + } else if (event.key === Qt.Key_Escape) { + root.dismissed(); + } else if (event.text === "a") { + root.beginNewDocument(); + } else if (event.text === "A") { + root.beginNewFolder(); + } else { + return; + } + event.accepted = true; + } + delegate: Item { id: row required property var modelData + required property int index width: ListView.view.width height: root.rowHeight @@ -115,7 +297,11 @@ Item { Rectangle { anchors.fill: parent color: root.selectionFill - opacity: rowArea.containsMouse ? 0.25 : 0 + // The keyboard selection fades when focus is back in the + // editor, so the panel never looks like it is still driving. + opacity: row.ListView.isCurrentItem + ? (list.activeFocus ? 0.35 : 0.12) + : (rowArea.containsMouse ? 0.25 : 0) } Label { @@ -137,6 +323,7 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { + list.currentIndex = row.index; if (row.modelData.isDir) root.folderRequested(row.modelData.url); else @@ -145,4 +332,45 @@ Item { } } } + + MouseArea { + id: resizeHandle + objectName: "sidebarResizeHandle" + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 6 + hoverEnabled: true + cursorShape: Qt.SplitHCursor + + property real grabOffset: 0 + + onPressed: function(mouse) { + root.beginResize(mapToItem(root, mouse.x, mouse.y).x); + } + onPositionChanged: function(mouse) { + if (pressed) + root.resizeTo(mapToItem(root, mouse.x, mouse.y).x); + } + // Settings are written once the drag ends rather than every frame. + onReleased: root.widthCommitted() + } + + // The handle rides the edge it moves, so a width measured as a delta from + // it feeds the panel's own width back in and halves the tracking speed. + // Measure from the left edge, which stays put. + function beginResize(pointerX) { + resizeHandle.grabOffset = root.width - pointerX; + } + + function resizeTo(pointerX) { + requestLogicalWidth((pointerX + resizeHandle.grabOffset) / root.textScale); + } + + function requestLogicalWidth(width) { + var clamped = Math.max(root.minimumLogicalWidth, + Math.min(root.maximumLogicalWidth, Math.round(width))); + if (clamped !== root.logicalWidth) + root.widthChangeRequested(clamped); + } } diff --git a/src/Main.qml b/src/Main.qml index 81eb7b5..d822a2c 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -33,6 +33,7 @@ ApplicationWindow { property bool closeConfirmed: false property bool searchOpen: false property bool sidebarOpen: false + property int sidebarLogicalWidth: 240 property bool searchUpdating: false property var searchMatches: [] property int searchMatchIndex: -1 @@ -55,6 +56,18 @@ ApplicationWindow { unsavedChangesDialog.open(); } + function setSidebarOpen(open) { + sidebarOpen = open; + if (open) + fileSidebar.focusList(); + else + editor.forceActiveFocus(); + } + + function toggleSidebar() { + setSidebarOpen(!sidebarOpen); + } + function requestOpen(url) { if (!backend.modified) { backend.open(url); @@ -183,7 +196,7 @@ ApplicationWindow { Shortcut { sequence: "Ctrl+E" context: Qt.ApplicationShortcut - onActivated: win.sidebarOpen = !win.sidebarOpen + onActivated: win.toggleSidebar() } Shortcut { @@ -339,7 +352,7 @@ ApplicationWindow { standardButtons: Dialog.Close anchors.centerIn: parent contentItem: Label { - text: "Ctrl+S Save\nCtrl+Shift+S Save As\nCtrl+O Open\nCtrl+E Files\nCtrl+N New Window\nCtrl+F Find\nCtrl+H Find and Replace\nCtrl+B Bold\nCtrl+I Italic\nCtrl+K Link\nCtrl+P Print\nF11 / Super+F Fullscreen\nCtrl+? Shortcuts" + text: "Ctrl+S Save\nCtrl+Shift+S Save As\nCtrl+O Open\nCtrl+E Files\nCtrl+N New Window\nCtrl+F Find\nCtrl+H Find and Replace\nCtrl+B Bold\nCtrl+I Italic\nCtrl+K Link\nCtrl+P Print\nF11 / Super+F Fullscreen\nCtrl+? Shortcuts\n\nIn the sidebar: Up/Down or j/k move, Enter opens,\nBackspace or h goes up, a new file, A new folder,\nEsc returns to writing" lineHeight: 1.5 } } @@ -362,11 +375,30 @@ ApplicationWindow { folderHasParent: backend.folderHasParent entries: backend.folderEntries currentFileUrl: backend.fileUrl + logicalWidth: win.sidebarLogicalWidth + // Never let the panel squeeze the writing column below its minimum. + maximumLogicalWidth: Math.max(minimumLogicalWidth, + Math.round(win.width / win.textScale) - 420) onParentFolderRequested: backend.openParentFolder() onFolderRequested: function(folderUrl) { backend.setFolder(folderUrl); } // requestOpen guards unsaved work with the same dialog Ctrl+O uses. onFileRequested: function(fileUrl) { win.requestOpen(fileUrl); } + onCreateDocumentRequested: function(name) { + var created = backend.createDocument(name); + if (created.toString() === "") + return; + win.requestOpen(created); + fileSidebar.selectUrl(created); + } + onCreateFolderRequested: function(name) { + var created = backend.createFolder(name); + if (created.toString() !== "") + fileSidebar.selectUrl(created); + } + onWidthChangeRequested: function(width) { win.sidebarLogicalWidth = width; } + onWidthCommitted: backend.saveSidebarWidth(win.sidebarLogicalWidth) + onDismissed: editor.forceActiveFocus() } Item { @@ -566,7 +598,11 @@ ApplicationWindow { TextEdit { id: editor objectName: "sourceEditor" - x: Math.round((editorFlick.width - width) / 2) + // Whole pixels keep natively hinted glyphs crisp; pinning the + // column to them elsewhere only makes it step when dragged. + x: renderType === TextEdit.NativeRendering + ? Math.round((editorFlick.width - width) / 2) + : (editorFlick.width - width) / 2 y: Math.max(42, Math.round(win.height * 0.05)) width: win.editorWidth height: Math.max(editorFlick.height - y - 96, implicitHeight + 20) @@ -860,7 +896,7 @@ ApplicationWindow { iconName: "files" iconColor: win.mutedColor tooltip: "Files" - onClicked: win.sidebarOpen = !win.sidebarOpen + onClicked: win.setSidebarOpen(!win.sidebarOpen) } Label { @@ -1043,6 +1079,7 @@ ApplicationWindow { } Component.onCompleted: { + sidebarLogicalWidth = backend.sidebarWidth(); var geometry = backend.windowGeometry(); if (geometry.x >= 0) x = geometry.x; if (geometry.y >= 0) y = geometry.y; diff --git a/src/backend.cpp b/src/backend.cpp index 81081a9..5ea8ee4 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -36,6 +36,7 @@ constexpr qreal typoraLineHeightPercent = 140; const QString lastSaveDirectorySetting = QStringLiteral("file/lastSaveDirectory"); const QString browseDirectorySetting = QStringLiteral("file/browseDirectory"); +const QString sidebarWidthSetting = QStringLiteral("window/sidebarWidth"); QString Backend::normalizedLinkUrl(const QString &clipboardText) { QString candidate = clipboardText.trimmed(); @@ -221,6 +222,55 @@ void Backend::openParentFolder() { applyFolder(directory.absolutePath(), true); } +QUrl Backend::createDocument(const QString &name) { + const QDir directory(m_folderUrl.toLocalFile()); + const QString fileName = suggestedFileName(name); + const QString path = directory.filePath(fileName); + if (QFileInfo::exists(path)) { + setStatus(QStringLiteral("%1 already exists.").arg(fileName)); + return {}; + } + + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + setStatus(QStringLiteral("Could not create %1.").arg(fileName)); + return {}; + } + file.close(); + + setStatus(QStringLiteral("Created %1").arg(fileName)); + // The folder watcher reports this too, but not before the new row is + // wanted on screen. + emit folderChanged(); + return QUrl::fromLocalFile(path); +} + +QUrl Backend::createFolder(const QString &name) { + QDir directory(m_folderUrl.toLocalFile()); + const QString folderName = sanitizedEntryName(name); + if (directory.exists(folderName)) { + setStatus(QStringLiteral("%1 already exists.").arg(folderName)); + return {}; + } + + if (!directory.mkdir(folderName)) { + setStatus(QStringLiteral("Could not create %1.").arg(folderName)); + return {}; + } + + setStatus(QStringLiteral("Created %1").arg(folderName)); + emit folderChanged(); + return QUrl::fromLocalFile(directory.filePath(folderName)); +} + +int Backend::sidebarWidth() const { + return QSettings().value(sidebarWidthSetting, 240).toInt(); +} + +void Backend::saveSidebarWidth(int width) { + QSettings().setValue(sidebarWidthSetting, width); +} + void Backend::open(const QUrl &url) { if (!url.isLocalFile()) { setStatus(QStringLiteral("Only local files can be opened.")); @@ -769,13 +819,18 @@ int Backend::countWords(const QString &text) { return count; } -QString Backend::suggestedFileName(const QString &text) { +QString Backend::sanitizedEntryName(const QString &text) { QString name = text.section(QLatin1Char('\n'), 0, 0).trimmed(); name.replace(QRegularExpression(QStringLiteral("[/\\x00-\\x1f\\x7f]")), QStringLiteral("-")); name = name.left(120).trimmed(); if (name.isEmpty() || name == QStringLiteral(".") || name == QStringLiteral("..")) name = QStringLiteral("Untitled"); + return name; +} + +QString Backend::suggestedFileName(const QString &text) { + QString name = sanitizedEntryName(text); if (!name.endsWith(QStringLiteral(".md"), Qt::CaseInsensitive)) name += QStringLiteral(".md"); return name; diff --git a/src/backend.h b/src/backend.h index 8c93065..78fbea0 100644 --- a/src/backend.h +++ b/src/backend.h @@ -61,11 +61,16 @@ class Backend : public QObject { static int countWords(const QString &text); static QString normalizedLinkUrl(const QString &clipboardText); static QString suggestedFileName(const QString &text); + static QString sanitizedEntryName(const QString &text); Q_INVOKABLE void attachDocument(QObject *textDocument); Q_INVOKABLE void openDialog(); Q_INVOKABLE void setFolder(const QUrl &url); Q_INVOKABLE void openParentFolder(); + Q_INVOKABLE QUrl createDocument(const QString &name); + Q_INVOKABLE QUrl createFolder(const QString &name); + Q_INVOKABLE int sidebarWidth() const; + Q_INVOKABLE void saveSidebarWidth(int width); Q_INVOKABLE void open(const QUrl &url); Q_INVOKABLE void save(); Q_INVOKABLE void saveForClose(); diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index ae798a2..54f739c 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -183,6 +183,36 @@ private slots: QCOMPARE(backend.folderEntries().size(), 1); } + + void createsDocumentsAndFoldersWhereItBrowses() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + + // A typed name is cleaned the same way a saved document's is, and + // gains the extension when it is missing. + const QUrl created = backend.createDocument(QStringLiteral("Field notes")); + QCOMPARE(created, QUrl::fromLocalFile(folder.filePath(QStringLiteral("Field notes.md")))); + QVERIFY(QFileInfo::exists(created.toLocalFile())); + QCOMPARE(Backend::sanitizedEntryName(QStringLiteral("a/b")), QStringLiteral("a-b")); + + // An existing name is reported, never overwritten. + QVERIFY(backend.createDocument(QStringLiteral("Field notes.md")).isEmpty()); + QCOMPARE(backend.status(), QStringLiteral("Field notes.md already exists.")); + + backend.createFolder(QStringLiteral("archive")); + QVERIFY(QFileInfo(folder.filePath(QStringLiteral("archive"))).isDir()); + + const QVariantList entries = backend.folderEntries(); + QCOMPARE(entries.size(), 2); + QCOMPARE(entries.at(0).toMap().value(QStringLiteral("name")).toString(), + QStringLiteral("archive")); + QCOMPARE(entries.at(1).toMap().value(QStringLiteral("name")).toString(), + QStringLiteral("Field notes.md")); + } + void keepsCursorAndSelectionStableAcrossInsertions() { const QString mutationsPath = QFINDTESTDATA("../src/EditorMutations.js"); QVERIFY(!mutationsPath.isEmpty()); @@ -346,6 +376,223 @@ private slots: QCOMPARE(sidebar->property("width").toReal(), 0.0); } + + void walksTheSidebarWithTheKeyboard() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + QVERIFY(QDir(folder.path()).mkdir(QStringLiteral("archive"))); + for (const QString &name : {QStringLiteral("one.md"), QStringLiteral("two.md")}) { + QFile file(folder.filePath(name)); + QVERIFY(file.open(QIODevice::WriteOnly)); + } + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); + QVERIFY(sidebar); + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); + + // archive/, one.md, two.md — moving down twice lands on the last row + // and stays there rather than wrapping. + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectNext")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectNext")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectNext")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "activateSelection")); + QCOMPARE(backend.fileName(), QStringLiteral("two.md")); + + // Enter on a folder walks into it; going up comes back. + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectPrevious")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectPrevious")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "activateSelection")); + QCOMPARE(backend.folderUrl(), + QUrl::fromLocalFile(folder.filePath(QStringLiteral("archive")))); + QVERIFY(QMetaObject::invokeMethod(sidebar, "goUp")); + QCOMPARE(backend.folderUrl(), QUrl::fromLocalFile(folder.path())); + } + + void createsAndOpensADocumentFromTheSidebar() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); + QObject *nameField = window->findChild(QStringLiteral("newEntryField")); + QVERIFY(sidebar); + QVERIFY(nameField); + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); + + QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewDocument")); + QVERIFY(sidebar->property("creating").toBool()); + nameField->setProperty("text", QStringLiteral("Field notes")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "commitNewEntry")); + QVERIFY(!sidebar->property("creating").toBool()); + + // The new document is created and opened, ready to be written in, + // with the selection left on it rather than back at the top. + QVERIFY(QFileInfo::exists(folder.filePath(QStringLiteral("Field notes.md")))); + QCOMPARE(backend.fileName(), QStringLiteral("Field notes.md")); + QCOMPARE(sidebar->property("selectedName").toString(), + QStringLiteral("Field notes.md")); + + QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewFolder")); + nameField->setProperty("text", QStringLiteral("archive")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "commitNewEntry")); + QVERIFY(QFileInfo(folder.filePath(QStringLiteral("archive"))).isDir()); + QCOMPARE(sidebar->property("selectedName").toString(), QStringLiteral("archive")); + + // An abandoned name creates nothing. + QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewDocument")); + nameField->setProperty("text", QStringLiteral("discarded")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "cancelNewEntry")); + QVERIFY(!QFileInfo::exists(folder.filePath(QStringLiteral("discarded.md")))); + } + + void closesTheSidebarRatherThanReachingIntoIt() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + for (const QString &name : {QStringLiteral("one.md"), QStringLiteral("two.md")}) { + QFile file(folder.filePath(name)); + QVERIFY(file.open(QIODevice::WriteOnly)); + } + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(sidebar); + QVERIFY(editor); + + // Closed: the key puts the panel there and the keyboard in it. + QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); + QVERIFY(window->property("sidebarOpen").toBool()); + QVERIFY(sidebar->property("listHasFocus").toBool()); + + // Open with the keyboard back in the text — Esc does this, and so does + // opening a document. The key takes the panel away rather than + // interrupting the writing to reach into it. + QVERIFY(QMetaObject::invokeMethod(editor, "forceActiveFocus")); + QVERIFY(editor->property("activeFocus").toBool()); + QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); + QVERIFY(!window->property("sidebarOpen").toBool()); + QCOMPARE(sidebar->property("width").toReal(), 0.0); + QVERIFY(editor->property("activeFocus").toBool()); + + // And from inside the panel it closes just the same. + QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); + QVERIFY(sidebar->property("listHasFocus").toBool()); + QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); + QVERIFY(!window->property("sidebarOpen").toBool()); + QVERIFY(editor->property("activeFocus").toBool()); + } + + void followsThePointerWhenTheEdgeIsDragged() { + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); + QVERIFY(sidebar); + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); + window->setProperty("width", 1280); + QCOMPARE(sidebar->property("width").toReal(), 240.0); + + // The pointer starts on the edge, where the handle is. + QVERIFY(QMetaObject::invokeMethod(sidebar, "beginResize", Q_ARG(QVariant, 240.0))); + QVERIFY(QMetaObject::invokeMethod(sidebar, "resizeTo", Q_ARG(QVariant, 300.0))); + QCOMPARE(window->property("sidebarLogicalWidth").toInt(), 300); + + // A pointer that has not moved asks for the width it already has. The + // handle rides the edge it moves, so a width measured against it used + // to come back different every time it was asked — which is what the + // drag twitching was. + QVERIFY(QMetaObject::invokeMethod(sidebar, "resizeTo", Q_ARG(QVariant, 300.0))); + QCOMPARE(window->property("sidebarLogicalWidth").toInt(), 300); + + // One pixel of pointer, one pixel of panel, both ways. + QVERIFY(QMetaObject::invokeMethod(sidebar, "resizeTo", Q_ARG(QVariant, 360.0))); + QCOMPARE(window->property("sidebarLogicalWidth").toInt(), 360); + QVERIFY(QMetaObject::invokeMethod(sidebar, "resizeTo", Q_ARG(QVariant, 200.0))); + QCOMPARE(window->property("sidebarLogicalWidth").toInt(), 200); + + // Widths are kept at text scale 1, and the pointer is not: a drag to a + // device pixel lands on the logical width under it, once divided. + backend.setTextScale(1.25); + QCOMPARE(sidebar->property("width").toReal(), 250.0); + QVERIFY(QMetaObject::invokeMethod(sidebar, "beginResize", Q_ARG(QVariant, 250.0))); + QVERIFY(QMetaObject::invokeMethod(sidebar, "resizeTo", Q_ARG(QVariant, 500.0))); + QCOMPARE(window->property("sidebarLogicalWidth").toInt(), 400); + } + + void keepsTheWritingColumnWhenDraggedWider() { + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); + QVERIFY(sidebar); + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); + window->setProperty("width", 1280); + + QVERIFY(QMetaObject::invokeMethod(sidebar, "requestLogicalWidth", + Q_ARG(QVariant, 380))); + QCOMPARE(window->property("sidebarLogicalWidth").toInt(), 380); + + // Dragging past either end is held at the limit, and the wide end + // always leaves the editor its minimum. + QVERIFY(QMetaObject::invokeMethod(sidebar, "requestLogicalWidth", + Q_ARG(QVariant, 40))); + QCOMPARE(window->property("sidebarLogicalWidth").toInt(), + sidebar->property("minimumLogicalWidth").toInt()); + + QVERIFY(QMetaObject::invokeMethod(sidebar, "requestLogicalWidth", + Q_ARG(QVariant, 5000))); + QCOMPARE(window->property("sidebarLogicalWidth").toInt(), 1280 - 420); + + // The drag writes the width once it is let go, not on every frame. + QVERIFY(QMetaObject::invokeMethod(sidebar, "requestLogicalWidth", + Q_ARG(QVariant, 300))); + QCOMPARE(backend.sidebarWidth(), 240); + QVERIFY(QMetaObject::invokeMethod(sidebar, "widthCommitted")); + QCOMPARE(backend.sidebarWidth(), 300); + } + private: QTemporaryDir m_settingsDirectory; }; From d2ec489cd786ea37c56dcb313a7be977bdf4a7bd Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:07:00 +0200 Subject: [PATCH 03/10] Keep the writing where the document opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a document from the sidebar left three small things wrong, all of them the kind that make a panel feel bolted on rather than part of the app. The keyboard stayed in the sidebar, so a note could be chosen but not written in until the panel was closed. The document you just opened is the one you want to type in, so focus goes with it; walking into a folder is not opening anything and keeps the keyboard where it is. Focus follows the loaded document rather than the act of asking for one, so a file that cannot be read never takes the keyboard away from the browsing. When the unsaved-changes prompt stands in the way it has to be waited out rather than raced: a modal hands focus back to whatever held it before it opened, which quietly undid a handoff made while it was still closing. The caret arrived at the end of the new text, which is where writing carries on from, but it was drawn somewhere else entirely — a line above the writing in a short document, a page above it in a long one, dropping into place only at the first keystroke. The editor moves the caret item when the cursor moves, and loading a document does not move it: the text arrives with the cursor already at its end. So the caret keeps the position it was given part-way through the load, before the highlighter shrank the hidden markers and the typography pass stretched every line. Handing the delegate back builds a fresh caret against the finished text, which works in an empty document too, where there is no cursor to nudge. The selection jumped back to the top of the folder whenever the folder was re-read — and it is re-read whenever anything in it changes, so saving was enough to lose your place mid-browse. The selection is now held by name rather than by an index into a model that is rebuilt from scratch, falling back to the open document when the row it was on is gone, and coming back to the panel starts from the document being written. Co-Authored-By: Claude Opus 5 --- README.md | 13 +- src/FileSidebar.qml | 72 ++++++++-- src/Main.qml | 78 ++++++++++- src/backend.cpp | 1 + src/backend.h | 1 + tests/tst_omawrite.cpp | 291 ++++++++++++++++++++++++++++++++++------- 6 files changed, 389 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 1930ae1..a7df475 100644 --- a/README.md +++ b/README.md @@ -31,19 +31,26 @@ Install via the Omarchy Package Repository via the `omawrite` package. It's inst ## Sidebar The sidebar lists the folder the open document lives in — Markdown files and -the folders beside them, nothing else. Clicking a document opens it, guarded by -the same unsaved-changes prompt as `Ctrl+O`. +the folders beside them, nothing else. Opening a document puts the cursor after +everything already written in it, so writing carries on with the sidebar still +open, and the document you are leaving is saved on the way out rather than +asked about. With the keyboard, once `Ctrl+E` has put focus there: - `Up`/`Down` or `j`/`k` move through the folder. -- `Enter`, `Right`, or `l` opens a document, or walks into a folder. +- `Enter`, `Right`, or `l` opens a document and hands the keyboard back to the + text, or walks into a folder and stays put. - `Backspace`, `Left`, or `h` goes up a level. - `a` starts a new Markdown file and `A` a new folder; type the name and press `Enter`, or `Esc` to abandon it. A new document opens straight away, and an existing name is reported rather than overwritten. - `Esc` returns to writing, leaving the sidebar open. +The folder is re-read whenever anything in it changes, and the selection keeps +its place through that rather than snapping back to the top. Opening the panel +starts from the document being written. + Drag its right edge to widen it; the width is remembered, and stops short of squeezing the writing column below its usual measure. diff --git a/src/FileSidebar.qml b/src/FileSidebar.qml index 312353c..50f3b4d 100644 --- a/src/FileSidebar.qml +++ b/src/FileSidebar.qml @@ -12,6 +12,7 @@ Item { property color mutedColor: darkMode ? "#909191" : "#aeb1b5" property color accentColor: "#428bca" property color selectionFill: "#186a9a" + property url folderUrl property string folderName: "" property bool folderHasParent: false property var entries: [] @@ -39,41 +40,86 @@ Item { ? entries[list.currentIndex].name : "" readonly property alias listHasFocus: list.activeFocus + // Held by name: the model is rebuilt from scratch every time the folder is + // re-read, and an index into it survives nothing. + property string selectedEntryName: "" + width: expanded ? Math.round(logicalWidth * root.textScale) : 0 visible: width > 0 clip: true onExpandedChanged: if (!expanded) cancelNewEntry() - onEntriesChanged: list.currentIndex = entries.length > 0 ? 0 : -1 + onFolderUrlChanged: selectedEntryName = "" + // Deferred: the list also resets its own index when the model is replaced. + onEntriesChanged: Qt.callLater(restoreSelection) function focusList() { list.forceActiveFocus(); - if (list.currentIndex < 0 && root.entries.length > 0) - list.currentIndex = 0; + var open = indexOfUrl(root.currentFileUrl); + if (open >= 0) + selectIndex(open); + else + restoreSelection(); } - // After making something, the selection sits on it rather than snapping - // back to the top of the folder. - function selectUrl(entryUrl) { + function indexOfName(name) { + if (name.length === 0) + return -1; + for (var i = 0; i < root.entries.length; i++) { + if (root.entries[i].name === name) + return i; + } + return -1; + } + + function indexOfUrl(entryUrl) { var target = entryUrl.toString(); + if (target.length === 0) + return -1; for (var i = 0; i < root.entries.length; i++) { - if (root.entries[i].url.toString() === target) { - list.currentIndex = i; - return; - } + if (root.entries[i].url.toString() === target) + return i; } + return -1; + } + + // The one way the selection moves, so the remembered name never drifts. + function selectIndex(index) { + if (index < 0 || index >= root.entries.length) + return; + list.currentIndex = index; + root.selectedEntryName = root.entries[index].name; + } + + // The folder is re-read whenever anything in it changes — saving is enough + // — so keep the selection on the row it was on. + function restoreSelection() { + if (root.entries.length === 0) { + list.currentIndex = -1; + return; + } + var index = indexOfName(root.selectedEntryName); + if (index < 0) + index = indexOfUrl(root.currentFileUrl); + selectIndex(Math.max(0, index)); + } + + // After making something, the selection sits on it rather than snapping + // back to the top of the folder. + function selectUrl(entryUrl) { + selectIndex(indexOfUrl(entryUrl)); } function selectNext() { if (root.entries.length === 0) return; - list.currentIndex = Math.min(root.entries.length - 1, list.currentIndex + 1); + selectIndex(Math.min(root.entries.length - 1, list.currentIndex + 1)); } function selectPrevious() { if (root.entries.length === 0) return; - list.currentIndex = Math.max(0, list.currentIndex - 1); + selectIndex(Math.max(0, list.currentIndex - 1)); } // Enter opens a document or walks into a folder, the one key doing what @@ -323,7 +369,7 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - list.currentIndex = row.index; + root.selectIndex(row.index); if (row.modelData.isDir) root.folderRequested(row.modelData.url); else diff --git a/src/Main.qml b/src/Main.qml index d822a2c..95de161 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -30,17 +30,18 @@ ApplicationWindow { Math.round(writerFontMetrics.averageCharacterWidth * 65), Math.max(360, width - fileSidebar.width - Math.round(writerFontMetrics.averageCharacterWidth * 20))) - property bool closeConfirmed: false property bool searchOpen: false property bool sidebarOpen: false property int sidebarLogicalWidth: 240 property bool searchUpdating: false property var searchMatches: [] property int searchMatchIndex: -1 + property bool closeConfirmed: false property url pendingOpenUrl property string pendingAction: "" property bool replaceOpen: false property bool awaitingPendingSave: false + property bool keyboardWaitingForDialog: false Material.theme: darkMode ? Material.Dark : Material.Light Material.accent: backend.themeAccent @@ -89,6 +90,60 @@ ApplicationWindow { } } + // A closing modal hands focus back to whatever held it before it opened, + // so wait it out rather than race it. + function handKeyboardToEditor() { + if (unsavedChangesDialog.visible || externalChangeDialog.visible) { + keyboardWaitingForDialog = true; + return; + } + editor.forceActiveFocus(); + } + + function releaseKeyboardAfterDialog() { + if (!keyboardWaitingForDialog) + return; + keyboardWaitingForDialog = false; + editor.forceActiveFocus(); + } + + // The editor places the caret item when the cursor moves and never again + // while the text is re-laid out under it — which is what loading a + // document does, after the caret has been placed. + property bool settlingCaret: false + + Component { + id: caretShape + + Rectangle { + width: 1 + color: win.strongTextColor + opacity: editor.activeFocus ? 1 : 0 + x: editor.cursorRectangle.x + y: editor.cursorRectangle.y + height: editor.cursorRectangle.height + } + } + + function settleCaret() { + if (!settlingCaret) + return; + + // A fresh delegate is built against the finished text. Both + // assignments land in one turn, so no frame is drawn without a caret. + editor.cursorDelegate = null; + editor.cursorDelegate = caretShape; + editorFlick.ensureCursorVisible(); + } + + // The net for a relayout that arrives after the load is announced. + Timer { + id: caretSettleWindow + interval: 400 + + onTriggered: win.settlingCaret = false + } + FontMetrics { id: writerFontMetrics font.family: "iA Writer Mono S" @@ -281,6 +336,14 @@ ApplicationWindow { win.completePendingAction(); } + function onDocumentLoaded() { + editor.cursorPosition = editor.length; + win.settlingCaret = true; + win.settleCaret(); + caretSettleWindow.restart(); + win.handKeyboardToEditor(); + } + function onExternalChangeDetected(deleted, locallyModified) { externalChangeDialog.deleted = deleted; externalChangeDialog.locallyModified = locallyModified; @@ -311,6 +374,7 @@ ApplicationWindow { UnsavedChangesDialog { id: unsavedChangesDialog + objectName: "unsavedChangesDialog" fileName: backend.fileName darkMode: win.darkMode textScale: win.textScale @@ -330,6 +394,7 @@ ApplicationWindow { backend.save(); } onCancelRequested: win.pendingAction = "" + onClosed: win.releaseKeyboardAfterDialog() } ExternalChangeDialog { @@ -343,6 +408,7 @@ ApplicationWindow { onKeepRequested: backend.keepExternalVersion() onReloadRequested: backend.reloadFromDisk() + onClosed: win.releaseKeyboardAfterDialog() } Dialog { @@ -371,6 +437,7 @@ ApplicationWindow { mutedColor: win.mutedColor accentColor: backend.themeAccent selectionFill: win.selectionFill + folderUrl: backend.folderUrl folderName: backend.folderName folderHasParent: backend.folderHasParent entries: backend.folderEntries @@ -407,6 +474,7 @@ ApplicationWindow { Flickable { id: editorFlick + objectName: "editorFlick" anchors.fill: parent anchors.leftMargin: 24 anchors.rightMargin: 24 @@ -624,11 +692,9 @@ ApplicationWindow { // the compositor delivers the fractional scale after the // first frame). Fall back to Qt's scalable renderer there. renderType: Screen.devicePixelRatio % 1 === 0 ? TextEdit.NativeRendering : TextEdit.QtRendering - cursorDelegate: Rectangle { - width: 1 - color: win.strongTextColor - } + cursorDelegate: caretShape onCursorRectangleChanged: editorFlick.ensureCursorVisible() + onContentSizeChanged: win.settleCaret() function replaceSelectionWith(replacement) { var start = Math.min(selectionStart, selectionEnd); @@ -844,6 +910,8 @@ ApplicationWindow { if (win.searchUpdating) return; var contentChanged = backend.editorTextChanged(); + if (contentChanged) + win.settlingCaret = false; if (win.searchOpen && contentChanged) win.updateSearch(); } diff --git a/src/backend.cpp b/src/backend.cpp index 5ea8ee4..fd26546 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -509,6 +509,7 @@ void Backend::loadDocumentText(const QString &text) { applyDocumentTypography(); m_wordCountTimer.stop(); setWordCount(countWords(text)); + emit documentLoaded(); } void Backend::setFileUrl(const QUrl &url) { diff --git a/src/backend.h b/src/backend.h index 78fbea0..54f180f 100644 --- a/src/backend.h +++ b/src/backend.h @@ -105,6 +105,7 @@ class Backend : public QObject { void saveSucceeded(); void externalChangeDetected(bool deleted, bool locallyModified); void folderChanged(); + void documentLoaded(); private: void loadDocumentText(const QString &text); diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 54f739c..62920b1 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -377,6 +377,123 @@ private slots: } + void closesTheSidebarRatherThanReachingIntoIt() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + for (const QString &name : {QStringLiteral("one.md"), QStringLiteral("two.md")}) { + QFile file(folder.filePath(name)); + QVERIFY(file.open(QIODevice::WriteOnly)); + } + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(sidebar); + QVERIFY(editor); + + // Closed: the key puts the panel there and the keyboard in it. + QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); + QVERIFY(window->property("sidebarOpen").toBool()); + QVERIFY(sidebar->property("listHasFocus").toBool()); + + // Open with the keyboard back in the text — Esc does this, and so does + // opening a document. The key takes the panel away rather than + // interrupting the writing to reach into it. + QVERIFY(QMetaObject::invokeMethod(editor, "forceActiveFocus")); + QVERIFY(editor->property("activeFocus").toBool()); + QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); + QVERIFY(!window->property("sidebarOpen").toBool()); + QCOMPARE(sidebar->property("width").toReal(), 0.0); + QVERIFY(editor->property("activeFocus").toBool()); + + // And from inside the panel it closes just the same. + QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); + QVERIFY(sidebar->property("listHasFocus").toBool()); + QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); + QVERIFY(!window->property("sidebarOpen").toBool()); + QVERIFY(editor->property("activeFocus").toBool()); + } + + void putsTheCaretAtTheEndOfAnOpenedDocument() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("long.md")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("# Title\n\n"); + // Long enough that the end of it is well off the bottom of the window. + for (int i = 0; i < 200; ++i) + file.write(QStringLiteral("body line %1\n").arg(i).toUtf8()); + file.close(); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QObject *flick = window->findChild(QStringLiteral("editorFlick")); + QVERIFY(editor); + QVERIFY(flick); + + // Writing carries on where the writing stopped, so an opened + // document hands over its end rather than its beginning. + backend.open(QUrl::fromLocalFile(path)); + QCOMPARE(editor->property("cursorPosition").toInt(), + editor->property("length").toInt()); + + // And it is drawn there. The document is laid out in stages — the + // text, then the hidden markers shrinking, then the line height — + // so a caret measured too early sits against a layout that is no + // longer on screen, halfway up the page. + const QRectF caret = editor->property("cursorRectangle").toRectF(); + const qreal textHeight = editor->property("implicitHeight").toReal(); + qDebug() << "PROBE caret" << caret << "textHeight" << textHeight + << "contentY" << flick->property("contentY") + << "contentHeight" << flick->property("contentHeight"); + QVERIFY(textHeight > 0); + QVERIFY2(caret.y() > textHeight * 0.9, + qPrintable(QStringLiteral("caret at %1 of %2") + .arg(caret.y()).arg(textHeight))); + QVERIFY(flick->property("contentY").toReal() > 0); + + // And again switching between documents, which is how it is really + // met: the layout in place is the previous document's. + const QString second = folder.filePath(QStringLiteral("second.md")); + QFile secondFile(second); + QVERIFY(secondFile.open(QIODevice::WriteOnly)); + for (int i = 0; i < 60; ++i) + secondFile.write(QStringLiteral("## Heading %1\n\nwith **bold** and `code` in it\n\n").arg(i).toUtf8()); + secondFile.close(); + backend.open(QUrl::fromLocalFile(second)); + const QRectF caret2 = editor->property("cursorRectangle").toRectF(); + const qreal textHeight2 = editor->property("implicitHeight").toReal(); + qDebug() << "PROBE2 caret" << caret2 << "textHeight" << textHeight2 + << "contentY" << flick->property("contentY") + << "contentHeight" << flick->property("contentHeight"); + QVERIFY2(caret2.y() > textHeight2 * 0.9, + qPrintable(QStringLiteral("caret at %1 of %2") + .arg(caret2.y()).arg(textHeight2))); + + // Saving names the file but does not reload it, so writing is never + // interrupted by the caret jumping back to the top. + editor->setProperty("cursorPosition", 12); + backend.saveAs(QUrl::fromLocalFile(folder.filePath(QStringLiteral("copy.md")))); + QCOMPARE(editor->property("cursorPosition").toInt(), 12); + } + void walksTheSidebarWithTheKeyboard() { QTemporaryDir folder; QVERIFY(folder.isValid()); @@ -419,9 +536,14 @@ private slots: QCOMPARE(backend.folderUrl(), QUrl::fromLocalFile(folder.path())); } - void createsAndOpensADocumentFromTheSidebar() { + void handsTheKeyboardBackWhenADocumentOpens() { QTemporaryDir folder; QVERIFY(folder.isValid()); + QVERIFY(QDir(folder.path()).mkdir(QStringLiteral("archive"))); + for (const QString &name : {QStringLiteral("one.md"), QStringLiteral("two.md")}) { + QFile file(folder.filePath(name)); + QVERIFY(file.open(QIODevice::WriteOnly)); + } Backend backend; backend.setFolder(QUrl::fromLocalFile(folder.path())); @@ -434,39 +556,72 @@ private slots: QVERIFY2(window, qPrintable(component.errorString())); QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); - QObject *nameField = window->findChild(QStringLiteral("newEntryField")); + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); QVERIFY(sidebar); - QVERIFY(nameField); + QVERIFY(editor); + + // Browsing takes the keyboard, and the caret goes out with it. QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", Q_ARG(QVariant, true))); + QVERIFY(sidebar->property("listHasFocus").toBool()); + QVERIFY(!editor->property("activeFocus").toBool()); - QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewDocument")); - QVERIFY(sidebar->property("creating").toBool()); - nameField->setProperty("text", QStringLiteral("Field notes")); - QVERIFY(QMetaObject::invokeMethod(sidebar, "commitNewEntry")); - QVERIFY(!sidebar->property("creating").toBool()); + // Opening a document hands it straight back, so it can be written in + // without closing the sidebar first. + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectNext")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "activateSelection")); + QCOMPARE(backend.fileName(), QStringLiteral("one.md")); + QVERIFY(editor->property("activeFocus").toBool()); + QVERIFY(window->property("sidebarOpen").toBool()); - // The new document is created and opened, ready to be written in, - // with the selection left on it rather than back at the top. - QVERIFY(QFileInfo::exists(folder.filePath(QStringLiteral("Field notes.md")))); - QCOMPARE(backend.fileName(), QStringLiteral("Field notes.md")); - QCOMPARE(sidebar->property("selectedName").toString(), - QStringLiteral("Field notes.md")); + // Walking into a folder is not opening a document, so it keeps it. + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectPrevious")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "activateSelection")); + QCOMPARE(backend.folderName(), QStringLiteral("archive")); + QVERIFY(sidebar->property("listHasFocus").toBool()); + QVERIFY(!editor->property("activeFocus").toBool()); - QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewFolder")); - nameField->setProperty("text", QStringLiteral("archive")); - QVERIFY(QMetaObject::invokeMethod(sidebar, "commitNewEntry")); - QVERIFY(QFileInfo(folder.filePath(QStringLiteral("archive"))).isDir()); - QCOMPARE(sidebar->property("selectedName").toString(), QStringLiteral("archive")); + // Coming back up, the panel starts from the document being written. + QVERIFY(QMetaObject::invokeMethod(sidebar, "goUp")); + QTRY_COMPARE(sidebar->property("selectedName").toString(), + QStringLiteral("one.md")); - // An abandoned name creates nothing. - QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewDocument")); - nameField->setProperty("text", QStringLiteral("discarded")); - QVERIFY(QMetaObject::invokeMethod(sidebar, "cancelNewEntry")); - QVERIFY(!QFileInfo::exists(folder.filePath(QStringLiteral("discarded.md")))); + // Unsaved work gets its prompt, and the keyboard still ends up in the + // document that opens once the prompt is out of the way. + editor->setProperty("text", QStringLiteral("a draft")); + QVERIFY(backend.modified()); + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectNext")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "activateSelection")); + QCOMPARE(window->property("pendingAction").toString(), QStringLiteral("open")); + QVERIFY(!editor->property("activeFocus").toBool()); + + QObject *dialog = window->findChild( + QStringLiteral("unsavedChangesDialog")); + QVERIFY(dialog); + QVERIFY(QMetaObject::invokeMethod(dialog, "close")); + QVERIFY(QMetaObject::invokeMethod(dialog, "discardRequested")); + QCOMPARE(backend.fileName(), QStringLiteral("two.md")); + // A handoff made while the dialog is still closing is undone. + QTRY_VERIFY(!dialog->property("visible").toBool()); + QVERIFY(editor->property("activeFocus").toBool()); + + // A document that cannot be read is never opened, so the keyboard + // stays with the browsing rather than following a document that + // never arrived. + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); + const QUrl missing = QUrl::fromLocalFile( + folder.filePath(QStringLiteral("missing.md"))); + QVERIFY(QMetaObject::invokeMethod(window.data(), "requestOpen", + Q_ARG(QVariant, QVariant(missing)))); + QCOMPARE(backend.fileName(), QStringLiteral("two.md")); + QVERIFY(sidebar->property("listHasFocus").toBool()); + QVERIFY(!editor->property("activeFocus").toBool()); } - void closesTheSidebarRatherThanReachingIntoIt() { + void keepsTheSidebarSelectionWhenTheFolderIsReRead() { QTemporaryDir folder; QVERIFY(folder.isValid()); for (const QString &name : {QStringLiteral("one.md"), QStringLiteral("two.md")}) { @@ -485,31 +640,75 @@ private slots: QVERIFY2(window, qPrintable(component.errorString())); QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); - QObject *editor = window->findChild(QStringLiteral("sourceEditor")); QVERIFY(sidebar); - QVERIFY(editor); + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); - // Closed: the key puts the panel there and the keyboard in it. - QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); - QVERIFY(window->property("sidebarOpen").toBool()); - QVERIFY(sidebar->property("listHasFocus").toBool()); + QVERIFY(QMetaObject::invokeMethod(sidebar, "selectNext")); + QCOMPARE(sidebar->property("selectedName").toString(), QStringLiteral("two.md")); + + // The folder is re-read whenever anything in it changes — a save is + // enough — and the rows can move; the keyboard should stay on the + // row it was on rather than be thrown back to the top. + QVERIFY(QMetaObject::invokeMethod(&backend, "createFolder", + Q_ARG(QString, QStringLiteral("archive")))); + QTRY_COMPARE(sidebar->property("selectedName").toString(), + QStringLiteral("two.md")); + + // Reopening the panel starts from the open document instead. + backend.open(QUrl::fromLocalFile(folder.filePath(QStringLiteral("one.md")))); + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, false))); + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); + QCOMPARE(sidebar->property("selectedName").toString(), QStringLiteral("one.md")); + } - // Open with the keyboard back in the text — Esc does this, and so does - // opening a document. The key takes the panel away rather than - // interrupting the writing to reach into it. - QVERIFY(QMetaObject::invokeMethod(editor, "forceActiveFocus")); - QVERIFY(editor->property("activeFocus").toBool()); - QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); - QVERIFY(!window->property("sidebarOpen").toBool()); - QCOMPARE(sidebar->property("width").toReal(), 0.0); - QVERIFY(editor->property("activeFocus").toBool()); + void createsAndOpensADocumentFromTheSidebar() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); - // And from inside the panel it closes just the same. - QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); - QVERIFY(sidebar->property("listHasFocus").toBool()); - QVERIFY(QMetaObject::invokeMethod(window.data(), "toggleSidebar")); - QVERIFY(!window->property("sidebarOpen").toBool()); - QVERIFY(editor->property("activeFocus").toBool()); + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *sidebar = window->findChild(QStringLiteral("fileSidebar")); + QObject *nameField = window->findChild(QStringLiteral("newEntryField")); + QVERIFY(sidebar); + QVERIFY(nameField); + QVERIFY(QMetaObject::invokeMethod(window.data(), "setSidebarOpen", + Q_ARG(QVariant, true))); + + QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewDocument")); + QVERIFY(sidebar->property("creating").toBool()); + nameField->setProperty("text", QStringLiteral("Field notes")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "commitNewEntry")); + QVERIFY(!sidebar->property("creating").toBool()); + + // The new document is created and opened, ready to be written in, + // with the selection left on it rather than back at the top. + QVERIFY(QFileInfo::exists(folder.filePath(QStringLiteral("Field notes.md")))); + QCOMPARE(backend.fileName(), QStringLiteral("Field notes.md")); + QCOMPARE(sidebar->property("selectedName").toString(), + QStringLiteral("Field notes.md")); + + QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewFolder")); + nameField->setProperty("text", QStringLiteral("archive")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "commitNewEntry")); + QVERIFY(QFileInfo(folder.filePath(QStringLiteral("archive"))).isDir()); + QCOMPARE(sidebar->property("selectedName").toString(), QStringLiteral("archive")); + + // An abandoned name creates nothing. + QVERIFY(QMetaObject::invokeMethod(sidebar, "beginNewDocument")); + nameField->setProperty("text", QStringLiteral("discarded")); + QVERIFY(QMetaObject::invokeMethod(sidebar, "cancelNewEntry")); + QVERIFY(!QFileInfo::exists(folder.filePath(QStringLiteral("discarded.md")))); } void followsThePointerWhenTheEdgeIsDragged() { From 08282af23a4395cee5733869dbc7fbdae4228061 Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:07:46 +0200 Subject: [PATCH 04/10] Save as you write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every switch between documents met the same dialog: any keystroke marks the document modified, and both Ctrl+O and the sidebar stop to ask before letting go of it. With a panel that makes changing notes a keypress away, that prompt is what stands between you and the next note nearly every time, and answering it is not a decision anyone wants to make while writing. Writing apps of this kind — iA Writer, Ulysses, Bear, Obsidian, Apple Notes — do not ask. They save on a short pause and let undo be the way back. So the prompt goes rather than gets better: work is written 750ms after the typing stops, and again whenever the document is left — switching files, closing the window, or moving to another app. The debounce is the recovery timer, which already had exactly this shape and now decides where the work goes rather than only drafting it: to the document's own file if it has one, to the recovery draft if it does not. Saves still go through the same atomic write, so the watcher that guards against outside edits cannot mistake the app's own. A document that was never named has nowhere to write, and stopping to ask for a name is the prompt again by another route. It takes one from its first line when it is left, the way the sidebar names a new file, giving way to "... 2" if that name is taken — a save cannot report a clash back the way creating a document can. One with nothing written in it is not kept at all: there is nothing to name and nothing to lose, which is what every editor already does with an empty untitled buffer. With nothing left to save by hand, the indicators that counted on it only flicker. The "Unsaved" status and the asterisk in the title would appear while typing and vanish under a second later, on every pause, so both are gone. Ctrl+S and the footer button still work, and are still how a document gets a name you chose yourself. Co-Authored-By: Claude Opus 5 --- README.md | 14 +++- src/Main.qml | 81 ++-------------------- src/UnsavedChangesDialog.qml | 115 ------------------------------- src/backend.cpp | 74 +++++++++++++++++--- src/backend.h | 8 ++- src/resources.qrc | 1 - tests/tst_omawrite.cpp | 127 +++++++++++++++++++++++++++++++---- 7 files changed, 206 insertions(+), 214 deletions(-) delete mode 100644 src/UnsavedChangesDialog.qml diff --git a/README.md b/README.md index a7df475..1717bb0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Install via the Omarchy Package Repository via the `omawrite` package. It's inst ## Shortcuts -- `Ctrl+S` saves. Unsaved documents use the XDG desktop portal file picker. +- `Ctrl+S` saves, though writing is saved for you anyway. Naming a document + yourself uses the XDG desktop portal file picker. - `Ctrl+Shift+S` saves as. - `Ctrl+O` opens a Markdown file through the portal picker. - `Ctrl+P` opens the system print dialog. @@ -54,6 +55,17 @@ starts from the document being written. Drag its right edge to widen it; the width is remembered, and stops short of squeezing the writing column below its usual measure. +## Saving + +Writing is saved a moment after you stop typing, and again whenever you leave +the document — switching files, closing the window, or moving to another app. +There is no prompt to answer and nothing to remember to press; `Ctrl+Z` is the +way back rather than a discard button. + +A document you never named takes its name from its first line when you leave +it, landing in the folder the sidebar is showing; if the same name is taken +already it becomes `... 2`. One with nothing written in it is not kept. + Unsaved drafts are recovered after an abnormal exit. Omawrite also watches open files and warns before an external change can replace local work. diff --git a/src/Main.qml b/src/Main.qml index 95de161..1e87538 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -13,7 +13,7 @@ ApplicationWindow { minimumWidth: 720 minimumHeight: 520 visible: true - title: (backend.modified ? "* " : "") + backend.fileName + " - Omawrite" + title: backend.fileName + " - Omawrite" readonly property bool darkMode: backend.darkMode readonly property color pageColor: backend.themeBackground @@ -36,26 +36,15 @@ ApplicationWindow { property bool searchUpdating: false property var searchMatches: [] property int searchMatchIndex: -1 - property bool closeConfirmed: false - property url pendingOpenUrl - property string pendingAction: "" property bool replaceOpen: false - property bool awaitingPendingSave: false property bool keyboardWaitingForDialog: false Material.theme: darkMode ? Material.Dark : Material.Light Material.accent: backend.themeAccent color: pageColor - onClosing: function(close) { - if (closeConfirmed || !backend.modified) - return; - - close.accepted = false; - pendingAction = "close"; - if (!unsavedChangesDialog.opened) - unsavedChangesDialog.open(); - } + onClosing: backend.saveBeforeLeaving() + onActiveChanged: if (!active) backend.saveNow() function setSidebarOpen(open) { sidebarOpen = open; @@ -70,30 +59,14 @@ ApplicationWindow { } function requestOpen(url) { - if (!backend.modified) { - backend.open(url); - return; - } - pendingOpenUrl = url; - pendingAction = "open"; - unsavedChangesDialog.open(); - } - - function completePendingAction() { - var action = pendingAction; - pendingAction = ""; - if (action === "close") { - closeConfirmed = true; - close(); - } else if (action === "open") { - backend.open(pendingOpenUrl); - } + backend.saveBeforeLeaving(); + backend.open(url); } // A closing modal hands focus back to whatever held it before it opened, // so wait it out rather than race it. function handKeyboardToEditor() { - if (unsavedChangesDialog.visible || externalChangeDialog.visible) { + if (externalChangeDialog.visible) { keyboardWaitingForDialog = true; return; } @@ -325,17 +298,6 @@ ApplicationWindow { saveFileDialog.open(); } - function onCloseAfterSave() { - win.closeConfirmed = true; - win.close(); - } - - function onSaveSucceeded() { - win.awaitingPendingSave = false; - if (win.pendingAction !== "") - win.completePendingAction(); - } - function onDocumentLoaded() { editor.cursorPosition = editor.length; win.settlingCaret = true; @@ -365,36 +327,7 @@ ApplicationWindow { fileMode: Dialogs.FileDialog.SaveFile nameFilters: ["Markdown files (*.md *.markdown)", "All files (*)"] onAccepted: backend.saveAs(selectedFile) - onRejected: { - backend.fileDialogCanceled(); - win.awaitingPendingSave = false; - win.pendingAction = ""; - } - } - - UnsavedChangesDialog { - id: unsavedChangesDialog - objectName: "unsavedChangesDialog" - fileName: backend.fileName - darkMode: win.darkMode - textScale: win.textScale - textColor: win.textColor - strongTextColor: win.strongTextColor - activeButtonColor: backend.themeAccent - containerWidth: win.width - containerHeight: win.height - - onDiscardRequested: { - backend.discardRecovery(); - win.completePendingAction(); - } - - onSaveRequested: { - win.awaitingPendingSave = true; - backend.save(); - } - onCancelRequested: win.pendingAction = "" - onClosed: win.releaseKeyboardAfterDialog() + onRejected: backend.fileDialogCanceled() } ExternalChangeDialog { diff --git a/src/UnsavedChangesDialog.qml b/src/UnsavedChangesDialog.qml deleted file mode 100644 index caf9cf6..0000000 --- a/src/UnsavedChangesDialog.qml +++ /dev/null @@ -1,115 +0,0 @@ -import QtQuick -import QtQuick.Controls - -Dialog { - id: root - - property string fileName: "Untitled.md" - property bool darkMode: true - property color textColor: darkMode ? "#d0d0d0" : "#42464c" - property color strongTextColor: darkMode ? "#eeeeee" : "#222324" - property color activeButtonColor: "#428bca" - property int containerWidth: 420 - property int containerHeight: 320 - property real textScale: 1 - - signal saveRequested() - signal discardRequested() - signal cancelRequested() - - modal: true - focus: true - closePolicy: Popup.CloseOnEscape - onRejected: cancelRequested() - - onOpened: saveButton.forceActiveFocus() - width: Math.min(420, containerWidth - 48) - x: Math.round((containerWidth - width) / 2) - y: Math.round((containerHeight - height) / 2) - padding: 20 - - background: Rectangle { - color: root.darkMode ? "#1a1a1a" : "#ffffff" - border.color: root.darkMode ? "#343434" : "#d8d8d8" - radius: 0 - } - - contentItem: Column { - spacing: 12 - - Label { - text: "Unsaved changes" - color: root.strongTextColor - font.family: "iA Writer Mono S" - font.pixelSize: Math.round(16 * root.textScale) - font.bold: true - } - - Label { - width: parent.width - text: "Save changes to " + root.fileName + " before closing?" - color: root.textColor - wrapMode: Text.Wrap - font.family: "iA Writer Mono S" - font.pixelSize: Math.round(13 * root.textScale) - } - } - - footer: Item { - implicitHeight: dialogButtons.implicitHeight + 20 - - Row { - id: dialogButtons - anchors.right: parent.right - anchors.rightMargin: 20 - anchors.verticalCenter: parent.verticalCenter - spacing: 8 - - SquareDialogButton { - id: cancelButton - text: "Cancel" - darkMode: root.darkMode - textScale: root.textScale - labelColor: root.textColor - KeyNavigation.left: saveButton - KeyNavigation.right: discardButton - KeyNavigation.tab: discardButton - KeyNavigation.backtab: saveButton - onClicked: root.reject() - } - - SquareDialogButton { - id: discardButton - text: "Discard" - darkMode: root.darkMode - textScale: root.textScale - labelColor: root.textColor - KeyNavigation.left: cancelButton - KeyNavigation.right: saveButton - KeyNavigation.tab: saveButton - KeyNavigation.backtab: cancelButton - onClicked: { - root.close(); - root.discardRequested(); - } - } - - SquareDialogButton { - id: saveButton - text: "Save" - primary: true - darkMode: root.darkMode - textScale: root.textScale - activeColor: root.activeButtonColor - KeyNavigation.left: discardButton - KeyNavigation.right: cancelButton - KeyNavigation.tab: cancelButton - KeyNavigation.backtab: discardButton - onClicked: { - root.close(); - root.saveRequested(); - } - } - } - } -} diff --git a/src/backend.cpp b/src/backend.cpp index fd26546..e1ab808 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -96,9 +96,9 @@ Backend::Backend(QObject *parent) : QObject(parent) { m_wordCountTimer.setSingleShot(true); m_wordCountTimer.setInterval(120); connect(&m_wordCountTimer, &QTimer::timeout, this, &Backend::refreshWordCount); - m_recoveryTimer.setSingleShot(true); - m_recoveryTimer.setInterval(750); - connect(&m_recoveryTimer, &QTimer::timeout, this, &Backend::writeRecovery); + m_persistTimer.setSingleShot(true); + m_persistTimer.setInterval(750); + connect(&m_persistTimer, &QTimer::timeout, this, &Backend::persistDocument); connect(&m_fileWatcher, &QFileSystemWatcher::fileChanged, this, [this](const QString &path) { if (path != m_fileUrl.toLocalFile()) @@ -304,6 +304,30 @@ void Backend::save() { saveTo(m_fileUrl); } +void Backend::saveNow() { + m_persistTimer.stop(); + persistDocument(); +} + +void Backend::saveBeforeLeaving() { + m_persistTimer.stop(); + + if (m_fileUrl.isLocalFile()) { + if (m_modified) + saveTo(m_fileUrl); + return; + } + + const QString text = currentDocumentText(); + if (text.trimmed().isEmpty()) { + clearRecovery(); + setModified(false); + return; + } + + saveTo(unusedDocumentUrl(suggestedFileName(text))); +} + void Backend::saveForClose() { if (!m_modified) { emit closeAfterSave(); @@ -345,7 +369,7 @@ void Backend::keepExternalVersion() { m_hasKnownFileContents = false; } setModified(true); - scheduleRecovery(); + schedulePersist(); watchCurrentFile(); setStatus(QStringLiteral("Kept your version")); } @@ -429,8 +453,7 @@ bool Backend::editorTextChanged() { scheduleWordCount(); setModified(true); - setStatus(QStringLiteral("Unsaved")); - scheduleRecovery(); + schedulePersist(); return true; } @@ -589,8 +612,22 @@ void Backend::saveTo(const QUrl &url) { emit closeAfterSave(); } -void Backend::scheduleRecovery() { - m_recoveryTimer.start(); +void Backend::schedulePersist() { + m_persistTimer.start(); +} + +// A named document is written to its file; one that has never been named keeps +// a recovery draft until it is left. +void Backend::persistDocument() { + if (!m_modified) + return; + + if (m_fileUrl.isLocalFile()) { + saveTo(m_fileUrl); + return; + } + + writeRecovery(); } QString Backend::recoveryPath() const { @@ -637,7 +674,7 @@ void Backend::restoreRecovery() { } void Backend::clearRecovery() { - m_recoveryTimer.stop(); + m_persistTimer.stop(); QFile::remove(recoveryPath()); } @@ -789,6 +826,25 @@ void Backend::watchOmarchyTheme() { m_themeWatcher.addPath(colorsPath); } +// A save cannot report a clash the way creating a document does, so the name +// gives way instead. +QUrl Backend::unusedDocumentUrl(const QString &fileName) const { + const QDir directory(m_folderUrl.toLocalFile()); + if (!QFileInfo::exists(directory.filePath(fileName))) + return QUrl::fromLocalFile(directory.filePath(fileName)); + + const QFileInfo info(fileName); + const QString base = info.completeBaseName(); + const QString suffix = info.suffix().isEmpty() ? QString() + : QLatin1Char('.') + info.suffix(); + for (int n = 2; n < 1000; ++n) { + const QString candidate = QStringLiteral("%1 %2%3").arg(base).arg(n).arg(suffix); + if (!QFileInfo::exists(directory.filePath(candidate))) + return QUrl::fromLocalFile(directory.filePath(candidate)); + } + return QUrl::fromLocalFile(directory.filePath(fileName)); +} + QUrl Backend::suggestedSaveUrl() const { if (m_fileUrl.isLocalFile()) return m_fileUrl; diff --git a/src/backend.h b/src/backend.h index 54f180f..efdc26b 100644 --- a/src/backend.h +++ b/src/backend.h @@ -73,6 +73,8 @@ class Backend : public QObject { Q_INVOKABLE void saveSidebarWidth(int width); Q_INVOKABLE void open(const QUrl &url); Q_INVOKABLE void save(); + Q_INVOKABLE void saveNow(); + Q_INVOKABLE void saveBeforeLeaving(); Q_INVOKABLE void saveForClose(); Q_INVOKABLE void saveAsDialog(); Q_INVOKABLE void saveAs(const QUrl &url); @@ -123,8 +125,10 @@ class Backend : public QObject { void scheduleWordCount(); void applyDocumentTypography(); void reapplyTypographyToChange(); - void scheduleRecovery(); + void schedulePersist(); + void persistDocument(); void writeRecovery(); + QUrl unusedDocumentUrl(const QString &fileName) const; void restoreRecovery(); void clearRecovery(); QString recoveryPath() const; @@ -145,7 +149,7 @@ class Backend : public QObject { int m_lastChangePos = 0; int m_lastChangeAdded = 0; QTimer m_wordCountTimer; - QTimer m_recoveryTimer; + QTimer m_persistTimer; QFileSystemWatcher m_fileWatcher; QUrl m_folderUrl; QFileSystemWatcher m_folderWatcher; diff --git a/src/resources.qrc b/src/resources.qrc index 575e5fe..32e9748 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -5,7 +5,6 @@ FooterIconButton.qml FileSidebar.qml SquareDialogButton.qml - UnsavedChangesDialog.qml ExternalChangeDialog.qml EditorMutations.js ../fonts/iAWriterMonoS-Regular.ttf diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 62920b1..c68b8d1 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -588,24 +588,20 @@ private slots: QTRY_COMPARE(sidebar->property("selectedName").toString(), QStringLiteral("one.md")); - // Unsaved work gets its prompt, and the keyboard still ends up in the - // document that opens once the prompt is out of the way. + // Unsaved work asks nothing: the document being left is written out + // on the way, and the keyboard lands in the one that opens. editor->setProperty("text", QStringLiteral("a draft")); QVERIFY(backend.modified()); QVERIFY(QMetaObject::invokeMethod(sidebar, "selectNext")); QVERIFY(QMetaObject::invokeMethod(sidebar, "activateSelection")); - QCOMPARE(window->property("pendingAction").toString(), QStringLiteral("open")); - QVERIFY(!editor->property("activeFocus").toBool()); - - QObject *dialog = window->findChild( - QStringLiteral("unsavedChangesDialog")); - QVERIFY(dialog); - QVERIFY(QMetaObject::invokeMethod(dialog, "close")); - QVERIFY(QMetaObject::invokeMethod(dialog, "discardRequested")); QCOMPARE(backend.fileName(), QStringLiteral("two.md")); - // A handoff made while the dialog is still closing is undone. - QTRY_VERIFY(!dialog->property("visible").toBool()); QVERIFY(editor->property("activeFocus").toBool()); + QVERIFY(!backend.modified()); + + QFile left(folder.filePath(QStringLiteral("one.md"))); + QVERIFY(left.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(left.readAll()), QStringLiteral("a draft")); + left.close(); // A document that cannot be read is never opened, so the keyboard // stays with the browsing rather than following a document that @@ -621,6 +617,113 @@ private slots: QVERIFY(!editor->property("activeFocus").toBool()); } + void autosavesOnceTheTypingStops() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("note.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.close(); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + 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("written and left alone")); + QVERIFY(backend.modified()); + + // A pause in the writing is the save; nothing has to be pressed. + QTRY_VERIFY(!backend.modified()); + QFile written(path); + QVERIFY(written.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(written.readAll()), + QStringLiteral("written and left alone")); + written.close(); + + // Closing does not wait out the pause, and does not ask either. + editor->setProperty("text", QStringLiteral("one last thought")); + QVERIFY(backend.modified()); + QVERIFY(QMetaObject::invokeMethod(window.data(), "close")); + QVERIFY(!backend.modified()); + QFile onClose(path); + QVERIFY(onClose.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(onClose.readAll()), + QStringLiteral("one last thought")); + } + + void namesAnUntitledDocumentFromItsFirstLine() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(editor); + + // A document that was never named takes one from its first line when + // it is left, rather than stopping the writer to ask for it. + editor->setProperty("text", QStringLiteral("Field notes\n\nbody")); + QVERIFY(QMetaObject::invokeMethod(&backend, "saveBeforeLeaving")); + QCOMPARE(backend.fileName(), QStringLiteral("Field notes.md")); + QFile named(folder.filePath(QStringLiteral("Field notes.md"))); + QVERIFY(named.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(named.readAll()), + QStringLiteral("Field notes\n\nbody")); + named.close(); + + // The name gives way rather than the writing: a second note opening + // on the same line lands beside the first. + Backend second; + second.setFolder(QUrl::fromLocalFile(folder.path())); + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &second); + QScopedPointer secondWindow(component.create()); + QVERIFY2(secondWindow, qPrintable(component.errorString())); + QObject *secondEditor = + secondWindow->findChild(QStringLiteral("sourceEditor")); + QVERIFY(secondEditor); + secondEditor->setProperty("text", QStringLiteral("Field notes\n\nagain")); + QVERIFY(QMetaObject::invokeMethod(&second, "saveBeforeLeaving")); + QCOMPARE(second.fileName(), QStringLiteral("Field notes 2.md")); + } + + void discardsAnEmptyUntitledDocument() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + + Backend backend; + backend.setFolder(QUrl::fromLocalFile(folder.path())); + + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + QObject *editor = window->findChild(QStringLiteral("sourceEditor")); + QVERIFY(editor); + + // Nothing was written, so there is nothing to name and nothing to keep. + editor->setProperty("text", QStringLiteral(" \n\n ")); + QVERIFY(QMetaObject::invokeMethod(&backend, "saveBeforeLeaving")); + QVERIFY(!backend.modified()); + QCOMPARE(QDir(folder.path()) + .entryList(QDir::Files | QDir::NoDotAndDotDot).size(), 0); + } + void keepsTheSidebarSelectionWhenTheFolderIsReRead() { QTemporaryDir folder; QVERIFY(folder.isValid()); From f81b4576dd573ef2e64e191301f2ccad1a9fe54a Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:58:19 +0200 Subject: [PATCH 05/10] Do not lose the work when the save cannot land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving on the way out assumed the write always succeeds. It does not: a read-only file, a full disk, or a directory QSaveFile cannot put its temporary file in all return from saveTo having changed nothing, leaving the document modified. The old prompt gated the switch on saveSucceeded, and replacing it dropped that gate — so requestOpen opened the next document over unsaved work and the window closed on top of it. Nor was there a draft to fall back on, because a named document only ever went through saveTo. saveTo now reports whether the write landed. Persisting falls back to the recovery draft whenever it did not, so quitting after a failed save still comes back, and saveBeforeLeaving returns that verdict so switching documents can decline: the writer keeps looking at their own text, with the status line saying why. Closing still goes through, on the draft — a window that cannot be closed would be a worse failure than the one being guarded against. The same write could also land on top of somebody else. The file watcher raises a conflict and the dialog asks which version to keep, but nothing stopped the debounce from firing first and overwriting the external contents while that question was still on screen. A conflict is now remembered until it is answered, and until then the work waits in a draft rather than on disk. Answering it either way lets saving resume: keeping your version is a decision to write over theirs. Co-Authored-By: Claude Opus 5 --- src/Main.qml | 6 ++- src/backend.cpp | 44 ++++++++++++------- src/backend.h | 5 ++- tests/tst_omawrite.cpp | 96 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 18 deletions(-) diff --git a/src/Main.qml b/src/Main.qml index 1e87538..0634144 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -59,7 +59,11 @@ ApplicationWindow { } function requestOpen(url) { - backend.saveBeforeLeaving(); + // Refuse to swap the document out from under work that could not be + // written; the status line says why. Closing still goes through, on + // the recovery draft saveBeforeLeaving leaves behind. + if (!backend.saveBeforeLeaving()) + return; backend.open(url); } diff --git a/src/backend.cpp b/src/backend.cpp index e1ab808..45f5752 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -116,6 +116,7 @@ Backend::Backend(QObject *parent) : QObject(parent) { } } + m_externalChangePending = true; emit externalChangeDetected(deleted, m_modified); }); @@ -309,23 +310,32 @@ void Backend::saveNow() { persistDocument(); } -void Backend::saveBeforeLeaving() { +// False when the work could not be written where it belongs, so the caller can +// decline to move on and leave the writer looking at their own text. +bool Backend::saveBeforeLeaving() { m_persistTimer.stop(); if (m_fileUrl.isLocalFile()) { - if (m_modified) - saveTo(m_fileUrl); - return; + if (!m_modified) + return true; + if (!m_externalChangePending && saveTo(m_fileUrl)) + return true; + writeRecovery(); + return false; } const QString text = currentDocumentText(); if (text.trimmed().isEmpty()) { clearRecovery(); setModified(false); - return; + return true; } - saveTo(unusedDocumentUrl(suggestedFileName(text))); + if (saveTo(unusedDocumentUrl(suggestedFileName(text)))) + return true; + + writeRecovery(); + return false; } void Backend::saveForClose() { @@ -355,11 +365,13 @@ void Backend::discardRecovery() { } void Backend::reloadFromDisk() { + m_externalChangePending = false; if (m_fileUrl.isLocalFile()) open(m_fileUrl); } void Backend::keepExternalVersion() { + m_externalChangePending = false; QFile file(m_fileUrl.toLocalFile()); if (file.open(QIODevice::ReadOnly)) { m_lastKnownFileContents = file.readAll(); @@ -562,11 +574,11 @@ void Backend::setStatus(const QString &status) { emit statusChanged(); } -void Backend::saveTo(const QUrl &url) { +bool Backend::saveTo(const QUrl &url) { if (!url.isLocalFile()) { m_closeAfterSave = false; setStatus(QStringLiteral("Only local files can be saved.")); - return; + return false; } const QString targetName = QFileInfo(url.toLocalFile()).fileName(); @@ -574,7 +586,7 @@ void Backend::saveTo(const QUrl &url) { if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { m_closeAfterSave = false; setStatus(QStringLiteral("Could not save %1.").arg(targetName)); - return; + return false; } const QByteArray contents = currentDocumentText().toUtf8(); @@ -592,7 +604,7 @@ void Backend::saveTo(const QUrl &url) { watchCurrentFile(); m_closeAfterSave = false; setStatus(QStringLiteral("Could not write %1.").arg(targetName)); - return; + return false; } const bool shouldClose = m_closeAfterSave; @@ -610,22 +622,24 @@ void Backend::saveTo(const QUrl &url) { if (shouldClose) emit closeAfterSave(); + + return true; } void Backend::schedulePersist() { m_persistTimer.start(); } -// A named document is written to its file; one that has never been named keeps -// a recovery draft until it is left. +// A named document is written to its file. Anything that stops that — an +// unwritable file, or an outside change the writer has not answered yet, which +// is not ours to overwrite — falls back to the recovery draft, so quitting +// after a failed save still comes back. void Backend::persistDocument() { if (!m_modified) return; - if (m_fileUrl.isLocalFile()) { - saveTo(m_fileUrl); + if (m_fileUrl.isLocalFile() && !m_externalChangePending && saveTo(m_fileUrl)) return; - } writeRecovery(); } diff --git a/src/backend.h b/src/backend.h index efdc26b..a92389f 100644 --- a/src/backend.h +++ b/src/backend.h @@ -74,7 +74,7 @@ class Backend : public QObject { Q_INVOKABLE void open(const QUrl &url); Q_INVOKABLE void save(); Q_INVOKABLE void saveNow(); - Q_INVOKABLE void saveBeforeLeaving(); + Q_INVOKABLE bool saveBeforeLeaving(); Q_INVOKABLE void saveForClose(); Q_INVOKABLE void saveAsDialog(); Q_INVOKABLE void saveAs(const QUrl &url); @@ -114,7 +114,7 @@ class Backend : public QObject { void setFileUrl(const QUrl &url); void setModified(bool modified); void setStatus(const QString &status); - void saveTo(const QUrl &url); + bool saveTo(const QUrl &url); QUrl suggestedSaveUrl() const; QDir defaultDirectory() const; void applyFolder(const QString &path, bool remember); @@ -159,6 +159,7 @@ class Backend : public QObject { QString m_lastDocumentText; QByteArray m_lastKnownFileContents; bool m_hasKnownFileContents = false; + bool m_externalChangePending = false; QString m_recoveryPath; std::unique_ptr m_recoveryLock; diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index c68b8d1..305afda 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -658,6 +658,102 @@ private slots: QStringLiteral("one last thought")); } + void keepsTheWorkWhenTheFileCannotBeWritten() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("locked.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.write("on disk"); + seed.close(); + // A real document to switch to, so the refusal is what stops the + // switch rather than a target that was never openable. + const QString other = folder.filePath(QStringLiteral("other.md")); + QFile neighbour(other); + QVERIFY(neighbour.open(QIODevice::WriteOnly)); + neighbour.write("somewhere else"); + neighbour.close(); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + 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("work that must not vanish")); + QVERIFY(backend.modified()); + + // A directory that cannot be written to is the same to QSaveFile as any + // other failed write. + QVERIFY(QFile::setPermissions(folder.path(), QFileDevice::ReadOwner + | QFileDevice::ExeOwner)); + + QVERIFY2(!backend.saveBeforeLeaving(), "a failed save must report itself"); + + // Leaving is refused, so the writer is still looking at their own text. + QVERIFY(QMetaObject::invokeMethod(window.data(), "requestOpen", + Q_ARG(QVariant, QVariant(QUrl::fromLocalFile(other))))); + QCOMPARE(editor->property("text").toString(), + QStringLiteral("work that must not vanish")); + QVERIFY(backend.modified()); + + QVERIFY(QFile::setPermissions(folder.path(), QFileDevice::ReadOwner + | QFileDevice::WriteOwner + | QFileDevice::ExeOwner)); + } + + void doesNotAutosaveOverAnUnansweredExternalChange() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("shared.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.write("original"); + seed.close(); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + 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 version")); + QVERIFY(backend.modified()); + + QSignalSpy conflict(&backend, &Backend::externalChangeDetected); + QFile outside(path); + QVERIFY(outside.open(QIODevice::WriteOnly)); + outside.write("their version"); + outside.close(); + QTRY_COMPARE(conflict.count(), 1); + + // The file on disk is not ours to overwrite until that is answered, so + // the work waits in a draft rather than landing on top of it. + QVERIFY(QMetaObject::invokeMethod(&backend, "saveNow")); + QFile after(path); + QVERIFY(after.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(after.readAll()), QStringLiteral("their version")); + after.close(); + QVERIFY(backend.modified()); + + // Once it is answered, keeping your version saves over it as asked. + backend.keepExternalVersion(); + QVERIFY(QMetaObject::invokeMethod(&backend, "saveNow")); + QFile kept(path); + QVERIFY(kept.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(kept.readAll()), QStringLiteral("my version")); + } + void namesAnUntitledDocumentFromItsFirstLine() { QTemporaryDir folder; QVERIFY(folder.isValid()); From b3d2501707bcddeccae15f1f9c61c85851163896 Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:11:59 +0200 Subject: [PATCH 06/10] Let the conflict prompt speak for every save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard against writing over an outside change sat on the autosave path only, so Ctrl+S went straight through it: pressing save while the prompt was still asking which version to keep put the local text on top of the file it was asking about. The guard belongs where every save passes, so it moved into saveTo, which also makes it one rule to keep rather than one per caller. Saving somewhere else is still allowed — Save As to another file is not the contested one. Closing had the opposite problem. Leaving falls back to a recovery draft when the file cannot be written, but that write can fail too, and its result was thrown away; a full disk could therefore take the last copy of the work with the window. Writing a draft now reports whether it landed, and closing asks the weaker question of whether the work reached anywhere at all. Where it reached nowhere the first close is refused and the status says why, and a second one is taken as meaning it — a window that cannot be closed would be its own kind of failure. That splits the two questions leaving has to ask. Switching documents still declines unless the work reached the file it belongs in, so nothing is swapped out from under a writer who can then only recover it by restarting; closing settles for it having reached a draft. Co-Authored-By: Claude Opus 5 --- src/Main.qml | 11 ++++++- src/backend.cpp | 33 ++++++++++++++------ src/backend.h | 3 +- tests/tst_omawrite.cpp | 71 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/Main.qml b/src/Main.qml index 0634144..786fb37 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -38,12 +38,21 @@ ApplicationWindow { property int searchMatchIndex: -1 property bool replaceOpen: false property bool keyboardWaitingForDialog: false + property bool closeAnyway: false Material.theme: darkMode ? Material.Dark : Material.Light Material.accent: backend.themeAccent color: pageColor - onClosing: backend.saveBeforeLeaving() + // If the work reached neither its file nor a draft there is nowhere left + // to put it, so the first close is refused and says so; a second one is + // taken as meaning it. + onClosing: function(close) { + if (closeAnyway || backend.saveBeforeLeaving() || backend.hasRecoveredCopy()) + return; + close.accepted = false; + closeAnyway = true; + } onActiveChanged: if (!active) backend.saveNow() function setSidebarOpen(open) { diff --git a/src/backend.cpp b/src/backend.cpp index 45f5752..6a66cb2 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -310,15 +310,17 @@ void Backend::saveNow() { persistDocument(); } -// False when the work could not be written where it belongs, so the caller can -// decline to move on and leave the writer looking at their own text. +// False when the work did not reach the document's own file, so switching away +// can decline and leave the writer looking at their own text. It still lands in +// a recovery draft either way; hasRecoveredCopy() reports whether that worked, +// which is the weaker question closing has to ask. bool Backend::saveBeforeLeaving() { m_persistTimer.stop(); if (m_fileUrl.isLocalFile()) { if (!m_modified) return true; - if (!m_externalChangePending && saveTo(m_fileUrl)) + if (saveTo(m_fileUrl)) return true; writeRecovery(); return false; @@ -575,6 +577,14 @@ void Backend::setStatus(const QString &status) { } bool Backend::saveTo(const QUrl &url) { + // The prompt is on screen asking which version to keep, so the file is not + // ours to write until it is answered. Saving somewhere else is still fine. + if (m_externalChangePending && url == m_fileUrl) { + setStatus(QStringLiteral("%1 changed on disk; answer that first.") + .arg(fileName())); + return false; + } + if (!url.isLocalFile()) { m_closeAfterSave = false; setStatus(QStringLiteral("Only local files can be saved.")); @@ -638,30 +648,35 @@ void Backend::persistDocument() { if (!m_modified) return; - if (m_fileUrl.isLocalFile() && !m_externalChangePending && saveTo(m_fileUrl)) + if (m_fileUrl.isLocalFile() && saveTo(m_fileUrl)) return; writeRecovery(); } +bool Backend::hasRecoveredCopy() const { + const QString path = recoveryPath(); + return !path.isEmpty() && QFileInfo::exists(path); +} + QString Backend::recoveryPath() const { return m_recoveryPath; } -void Backend::writeRecovery() { +bool Backend::writeRecovery() { if (!m_modified) - return; + return true; const QString path = recoveryPath(); if (path.isEmpty()) - return; + return false; QDir().mkpath(QFileInfo(path).absolutePath()); QSaveFile file(path); if (!file.open(QIODevice::WriteOnly)) - return; + return false; const QJsonObject recovery{{QStringLiteral("fileUrl"), m_fileUrl.toString()}, {QStringLiteral("text"), currentDocumentText()}}; file.write(QJsonDocument(recovery).toJson(QJsonDocument::Compact)); - file.commit(); + return file.commit(); } void Backend::restoreRecovery() { diff --git a/src/backend.h b/src/backend.h index a92389f..e6722e6 100644 --- a/src/backend.h +++ b/src/backend.h @@ -75,6 +75,7 @@ class Backend : public QObject { Q_INVOKABLE void save(); Q_INVOKABLE void saveNow(); Q_INVOKABLE bool saveBeforeLeaving(); + Q_INVOKABLE bool hasRecoveredCopy() const; Q_INVOKABLE void saveForClose(); Q_INVOKABLE void saveAsDialog(); Q_INVOKABLE void saveAs(const QUrl &url); @@ -127,7 +128,7 @@ class Backend : public QObject { void reapplyTypographyToChange(); void schedulePersist(); void persistDocument(); - void writeRecovery(); + bool writeRecovery(); QUrl unusedDocumentUrl(const QString &fileName) const; void restoreRecovery(); void clearRecovery(); diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 305afda..ef08205 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -746,6 +747,22 @@ private slots: after.close(); QVERIFY(backend.modified()); + // Pressing Ctrl+S does not pre-empt the question either: the prompt is + // on screen asking which version to keep. + backend.save(); + QFile stillTheirs(path); + QVERIFY(stillTheirs.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(stillTheirs.readAll()), + QStringLiteral("their version")); + stillTheirs.close(); + + // Saving somewhere else is not the contested file, so it goes through. + const QString copy = folder.filePath(QStringLiteral("copy.md")); + backend.saveAs(QUrl::fromLocalFile(copy)); + QVERIFY(QFileInfo::exists(copy)); + backend.open(QUrl::fromLocalFile(path)); + editor->setProperty("text", QStringLiteral("my version")); + // Once it is answered, keeping your version saves over it as asked. backend.keepExternalVersion(); QVERIFY(QMetaObject::invokeMethod(&backend, "saveNow")); @@ -796,6 +813,60 @@ private slots: QCOMPARE(second.fileName(), QStringLiteral("Field notes 2.md")); } + void refusesTheFirstCloseWhenTheWorkFitsNowhere() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + QTemporaryDir state; + QVERIFY(state.isValid()); + const QString path = folder.filePath(QStringLiteral("stuck.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.close(); + + // A recovery slot of its own, so taking it away takes away the last + // place the work could go. + qputenv("XDG_DATA_HOME", state.path().toUtf8()); + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + 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("the only copy")); + QVERIFY(backend.modified()); + + const QString appData = + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + QVERIFY(appData.startsWith(state.path())); + QVERIFY(QFile::setPermissions(folder.path(), QFileDevice::ReadOwner + | QFileDevice::ExeOwner)); + QVERIFY(QFile::setPermissions(appData, QFileDevice::ReadOwner + | QFileDevice::ExeOwner)); + + QVERIFY(!backend.saveBeforeLeaving()); + QVERIFY2(!backend.hasRecoveredCopy(), + "with nowhere to write, there is no draft either"); + + // The first close is refused rather than dropping the only copy; the + // second is taken as meaning it. + QVERIFY(QMetaObject::invokeMethod(window.data(), "close")); + QVERIFY(window->property("visible").toBool()); + QVERIFY(window->property("closeAnyway").toBool()); + + QVERIFY(QFile::setPermissions(appData, QFileDevice::ReadOwner + | QFileDevice::WriteOwner + | QFileDevice::ExeOwner)); + QVERIFY(QFile::setPermissions(folder.path(), QFileDevice::ReadOwner + | QFileDevice::WriteOwner + | QFileDevice::ExeOwner)); + qunsetenv("XDG_DATA_HOME"); + } + void discardsAnEmptyUntitledDocument() { QTemporaryDir folder; QVERIFY(folder.isValid()); From 96e52a04009d3ed5e814e270846bc694c28c33df Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:21:12 +0200 Subject: [PATCH 07/10] Ask whether this close saved, not whether a save once happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing settled for a recovery draft existing on disk, which proves only that something was written at some point — not that it holds what is on screen. A draft from an earlier attempt, followed by more writing and then a disk that has stopped accepting either the document or a new draft, therefore let the window close over the newest text and restored the older version afterwards. The question closing has to ask is whether the work reached anywhere during this attempt, so it now uses the result of the write it just made. Both leaving paths share one method for putting the document in the file it belongs in, and differ only in what they will settle for: switching documents insists on that file, because the writer is about to lose sight of the text, while closing takes a fresh draft as good enough. Co-Authored-By: Claude Opus 5 --- src/Main.qml | 2 +- src/backend.cpp | 51 ++++++++++++++++++------------ src/backend.h | 3 +- tests/tst_omawrite.cpp | 70 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/Main.qml b/src/Main.qml index 786fb37..d4d8753 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -48,7 +48,7 @@ ApplicationWindow { // to put it, so the first close is refused and says so; a second one is // taken as meaning it. onClosing: function(close) { - if (closeAnyway || backend.saveBeforeLeaving() || backend.hasRecoveredCopy()) + if (closeAnyway || backend.saveBeforeClosing()) return; close.accepted = false; closeAnyway = true; diff --git a/src/backend.cpp b/src/backend.cpp index 6a66cb2..a5d7fc0 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -310,21 +310,14 @@ void Backend::saveNow() { persistDocument(); } -// False when the work did not reach the document's own file, so switching away -// can decline and leave the writer looking at their own text. It still lands in -// a recovery draft either way; hasRecoveredCopy() reports whether that worked, -// which is the weaker question closing has to ask. -bool Backend::saveBeforeLeaving() { - m_persistTimer.stop(); +// The file the document belongs in: its own if it has one, one named from its +// first line if it does not. False when the write did not land there. +bool Backend::saveToItsOwnFile() { + if (!m_modified) + return true; - if (m_fileUrl.isLocalFile()) { - if (!m_modified) - return true; - if (saveTo(m_fileUrl)) - return true; - writeRecovery(); - return false; - } + if (m_fileUrl.isLocalFile()) + return saveTo(m_fileUrl); const QString text = currentDocumentText(); if (text.trimmed().isEmpty()) { @@ -333,13 +326,36 @@ bool Backend::saveBeforeLeaving() { return true; } - if (saveTo(unusedDocumentUrl(suggestedFileName(text)))) + return saveTo(unusedDocumentUrl(suggestedFileName(text))); +} + +// Switching documents asks the strict question: the work has to have reached +// the file it belongs in, because the writer is about to lose sight of it. +bool Backend::saveBeforeLeaving() { + m_persistTimer.stop(); + + if (saveToItsOwnFile()) return true; + // Worth a draft even though the switch is declined. writeRecovery(); return false; } +// Closing asks the weaker one: anywhere at all will do. It has to be the draft +// written for this attempt, though — an older one on disk proves only that +// something was saved once, not that it holds what is on screen now. +bool Backend::saveBeforeClosing() { + m_persistTimer.stop(); + + if (saveToItsOwnFile() || writeRecovery()) + return true; + + setStatus(QStringLiteral("Could not save %1 anywhere; close again to discard.") + .arg(fileName())); + return false; +} + void Backend::saveForClose() { if (!m_modified) { emit closeAfterSave(); @@ -654,11 +670,6 @@ void Backend::persistDocument() { writeRecovery(); } -bool Backend::hasRecoveredCopy() const { - const QString path = recoveryPath(); - return !path.isEmpty() && QFileInfo::exists(path); -} - QString Backend::recoveryPath() const { return m_recoveryPath; } diff --git a/src/backend.h b/src/backend.h index e6722e6..9dba295 100644 --- a/src/backend.h +++ b/src/backend.h @@ -75,7 +75,7 @@ class Backend : public QObject { Q_INVOKABLE void save(); Q_INVOKABLE void saveNow(); Q_INVOKABLE bool saveBeforeLeaving(); - Q_INVOKABLE bool hasRecoveredCopy() const; + Q_INVOKABLE bool saveBeforeClosing(); Q_INVOKABLE void saveForClose(); Q_INVOKABLE void saveAsDialog(); Q_INVOKABLE void saveAs(const QUrl &url); @@ -128,6 +128,7 @@ class Backend : public QObject { void reapplyTypographyToChange(); void schedulePersist(); void persistDocument(); + bool saveToItsOwnFile(); bool writeRecovery(); QUrl unusedDocumentUrl(const QString &fileName) const; void restoreRecovery(); diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index ef08205..91a268b 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -813,6 +813,71 @@ private slots: QCOMPARE(second.fileName(), QStringLiteral("Field notes 2.md")); } + void doesNotSettleForAnOlderDraftWhenClosing() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + QTemporaryDir state; + QVERIFY(state.isValid()); + const QString path = folder.filePath(QStringLiteral("draft.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.close(); + + qputenv("XDG_DATA_HOME", state.path().toUtf8()); + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + 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)); + + // A draft of an older version, made when the document itself could not + // be written but the draft still could. + QVERIFY(QFile::setPermissions(folder.path(), QFileDevice::ReadOwner + | QFileDevice::ExeOwner)); + editor->setProperty("text", QStringLiteral("old version")); + QVERIFY(QMetaObject::invokeMethod(&backend, "saveNow")); + + const QString appData = + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + QVERIFY(appData.startsWith(state.path())); + QDir drafts(appData); + const QStringList written = drafts.entryList({QStringLiteral("*.json")}, QDir::Files); + QCOMPARE(written.size(), 1); + const QString draftPath = drafts.filePath(written.first()); + + // Newer work, and now nowhere at all to put it. + editor->setProperty("text", QStringLiteral("new version")); + QVERIFY(QFile::setPermissions(appData, QFileDevice::ReadOwner + | QFileDevice::ExeOwner)); + + // The old draft is still sitting there, but it holds the wrong text, so + // it is no reason to let the window take the new text with it. + QVERIFY2(!backend.saveBeforeClosing(), + "an older draft does not stand in for this attempt"); + QVERIFY(QMetaObject::invokeMethod(window.data(), "close")); + QVERIFY(window->property("visible").toBool()); + + QFile stale(draftPath); + QVERIFY(stale.open(QIODevice::ReadOnly)); + QVERIFY2(QString::fromUtf8(stale.readAll()).contains(QStringLiteral("old version")), + "the draft on disk is the older one, which is the point"); + stale.close(); + + QVERIFY(QFile::setPermissions(appData, QFileDevice::ReadOwner + | QFileDevice::WriteOwner + | QFileDevice::ExeOwner)); + QVERIFY(QFile::setPermissions(folder.path(), QFileDevice::ReadOwner + | QFileDevice::WriteOwner + | QFileDevice::ExeOwner)); + qunsetenv("XDG_DATA_HOME"); + } + void refusesTheFirstCloseWhenTheWorkFitsNowhere() { QTemporaryDir folder; QVERIFY(folder.isValid()); @@ -848,9 +913,8 @@ private slots: QVERIFY(QFile::setPermissions(appData, QFileDevice::ReadOwner | QFileDevice::ExeOwner)); - QVERIFY(!backend.saveBeforeLeaving()); - QVERIFY2(!backend.hasRecoveredCopy(), - "with nowhere to write, there is no draft either"); + QVERIFY2(!backend.saveBeforeClosing(), + "with nowhere to write, the work reached nowhere"); // The first close is refused rather than dropping the only copy; the // second is taken as meaning it. From 32586555a00a8226a174d15545d1b0c774426551 Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:44:31 +0200 Subject: [PATCH 08/10] Leave the conflict answerable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refusing to save over an outside change assumed the prompt asking about it stays until it is answered. Escape closed it instead, emitting neither answer, and the two methods that clear the flag are reachable from nowhere else — so every later save was refused for the rest of the session and the work lived only in recovery drafts. That is a worse way to lose a document than the overwrite the flag was added to prevent. Escape cannot stand in for an answer, because each meaning it could carry decides the question being asked: dropping the flag lets the next autosave overwrite their version, reloading discards yours, keeping yours discards theirs. So the prompt no longer closes on it, and the two buttons remain the only ways out, as they already were for a click outside. Two things around it were as brittle. The flag outlived the document it was about: the guard compares the write's target with the current file, and opening another document left it set, so a file that had never changed underneath anyone could not be saved either. Opening a document now ends whatever was contested about the last one. And the watcher was not re-armed after reporting a change, so a replacement — which is what an atomic save from another editor looks like — took the watched inode away and with it any second chance to ask. It is re-armed, so a file that keeps changing keeps saying so. Co-Authored-By: Claude Opus 5 --- src/ExternalChangeDialog.qml | 5 +- src/Main.qml | 1 + src/backend.cpp | 6 ++ tests/tst_omawrite.cpp | 107 +++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/ExternalChangeDialog.qml b/src/ExternalChangeDialog.qml index 2bf59c5..d022e97 100644 --- a/src/ExternalChangeDialog.qml +++ b/src/ExternalChangeDialog.qml @@ -19,7 +19,10 @@ Dialog { modal: true focus: true - closePolicy: Popup.CloseOnEscape + // Both ways out are answers. Escape would be a third, and every meaning it + // could be given — keep, reload, or neither — decides the thing being + // asked, so it is not offered. + closePolicy: Popup.NoAutoClose width: Math.min(520, containerWidth - 48) x: Math.round((containerWidth - width) / 2) y: Math.round((containerHeight - height) / 2) diff --git a/src/Main.qml b/src/Main.qml index d4d8753..0a131a3 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -345,6 +345,7 @@ ApplicationWindow { ExternalChangeDialog { id: externalChangeDialog + objectName: "externalChangeDialog" darkMode: win.darkMode textScale: win.textScale textColor: win.textColor diff --git a/src/backend.cpp b/src/backend.cpp index a5d7fc0..a06880a 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -118,6 +118,9 @@ Backend::Backend(QObject *parent) : QObject(parent) { m_externalChangePending = true; emit externalChangeDetected(deleted, m_modified); + // A replacement leaves the old inode behind, and the path with + // it, so re-arm or a second change would never be noticed. + watchCurrentFile(); }); connect(&m_folderWatcher, &QFileSystemWatcher::directoryChanged, this, @@ -273,6 +276,9 @@ void Backend::saveSidebarWidth(int width) { } void Backend::open(const QUrl &url) { + // Whatever was contested, this is a different document now. + m_externalChangePending = false; + if (!url.isLocalFile()) { setStatus(QStringLiteral("Only local files can be opened.")); return; diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 91a268b..fe44053 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -760,10 +761,44 @@ private slots: const QString copy = folder.filePath(QStringLiteral("copy.md")); backend.saveAs(QUrl::fromLocalFile(copy)); QVERIFY(QFileInfo::exists(copy)); + + // The prompt is the only way out, so it cannot be dismissed. Escape + // would have to mean keep, or reload, or neither, and each of those + // answers the question on the writer's behalf. + QObject *prompt = window->findChild( + QStringLiteral("externalChangeDialog")); + QVERIFY(prompt); + QCOMPARE(prompt->property("closePolicy").toInt(), 0); // Popup.NoAutoClose + + // A conflict is about one file. Opening another document ends it, + // rather than following the writer and refusing to save that one too. + const QString elsewhere = folder.filePath(QStringLiteral("elsewhere.md")); + QFile other(elsewhere); + QVERIFY(other.open(QIODevice::WriteOnly)); + other.close(); + backend.open(QUrl::fromLocalFile(elsewhere)); + editor->setProperty("text", QStringLiteral("a different document")); + QVERIFY(QMetaObject::invokeMethod(&backend, "saveNow")); + QFile unrelated(elsewhere); + QVERIFY(unrelated.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(unrelated.readAll()), + QStringLiteral("a different document")); + unrelated.close(); + backend.open(QUrl::fromLocalFile(path)); editor->setProperty("text", QStringLiteral("my version")); // Once it is answered, keeping your version saves over it as asked. + // A second outside change still gets through. This one replaces the + // file the way another editor's atomic save does, which takes the old + // inode — and the watched path with it — out from under the watcher. + QSignalSpy second(&backend, &Backend::externalChangeDetected); + QSaveFile replacement(path); + QVERIFY(replacement.open(QIODevice::WriteOnly)); + replacement.write("changed once more"); + QVERIFY(replacement.commit()); + QTRY_COMPARE(second.count(), 1); + backend.keepExternalVersion(); QVERIFY(QMetaObject::invokeMethod(&backend, "saveNow")); QFile kept(path); @@ -771,6 +806,78 @@ private slots: QCOMPARE(QString::fromUtf8(kept.readAll()), QStringLiteral("my version")); } + void asksAgainWhenTheFileIsReplacedTwice() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("contested.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.write("original"); + seed.close(); + + Backend backend; + backend.open(QUrl::fromLocalFile(path)); + + QSignalSpy conflict(&backend, &Backend::externalChangeDetected); + + // An atomic save from another editor replaces the file rather than + // rewriting it, which takes the watched inode away with it. + QSaveFile first(path); + QVERIFY(first.open(QIODevice::WriteOnly)); + first.write("theirs"); + QVERIFY(first.commit()); + QTRY_COMPARE(conflict.count(), 1); + + // Nothing has answered the prompt, and nothing has re-opened the file. + // A second replacement still has to reach the writer, or the one + // chance to ask went with the first inode. + QSaveFile again(path); + QVERIFY(again.open(QIODevice::WriteOnly)); + again.write("theirs, again"); + QVERIFY(again.commit()); + QTRY_COMPARE(conflict.count(), 2); + } + + void reloadingAlsoEndsTheConflict() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("shared.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.write("original"); + seed.close(); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + 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("mine")); + + QSignalSpy conflict(&backend, &Backend::externalChangeDetected); + QFile outside(path); + QVERIFY(outside.open(QIODevice::WriteOnly)); + outside.write("theirs"); + outside.close(); + QTRY_COMPARE(conflict.count(), 1); + + // Taking their version is the other answer, and saving resumes on it. + backend.reloadFromDisk(); + QCOMPARE(editor->property("text").toString(), QStringLiteral("theirs")); + editor->setProperty("text", QStringLiteral("theirs, then mine")); + QVERIFY(QMetaObject::invokeMethod(&backend, "saveNow")); + QFile after(path); + QVERIFY(after.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(after.readAll()), + QStringLiteral("theirs, then mine")); + } + void namesAnUntitledDocumentFromItsFirstLine() { QTemporaryDir folder; QVERIFY(folder.isValid()); From 2a897329a701f9ffa15f65623eba88b6524b5059 Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:52:18 +0200 Subject: [PATCH 09/10] Clear the conflict only once the new document is really open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ending a conflict when a document is opened was written as though opening always succeeds. It clears at the top of open(), before the URL is checked and before the file is read, so an open that failed took the guard with it and left the contested document loaded without one — the next autosave would then write over the change it was there to protect. The guard now falls when the new text is in hand, which is the moment the old document stops being the one on screen. Reloading no longer clears it in advance either: a reload that could not read the file has answered nothing. Because the prompt closes itself before asking for the reload, that would leave the guard standing with nothing able to clear it, so a failed reload raises the question again instead — the file may well have been deleted between the asking and the answering, which is a thing the prompt knows how to say. The Escape test that came with the previous commit asserts the policy rather than pressing the key. A synthetic Escape never reaches a popup on a window that is never shown, so the behavioural version passed whether the policy allowed it or not; it is gone, and the comment says why. Co-Authored-By: Claude Opus 5 --- src/backend.cpp | 20 ++++++++---- tests/tst_omawrite.cpp | 71 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/backend.cpp b/src/backend.cpp index a06880a..d40bd0a 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -276,9 +276,6 @@ void Backend::saveSidebarWidth(int width) { } void Backend::open(const QUrl &url) { - // Whatever was contested, this is a different document now. - m_externalChangePending = false; - if (!url.isLocalFile()) { setStatus(QStringLiteral("Only local files can be opened.")); return; @@ -292,6 +289,9 @@ void Backend::open(const QUrl &url) { } const QByteArray contents = file.readAll(); + // Only now, with the new text in hand: whatever was contested belonged to + // the document being replaced, and an open that failed replaces nothing. + m_externalChangePending = false; loadDocumentText(QString::fromUtf8(contents)); clearRecovery(); m_lastKnownFileContents = contents; @@ -389,9 +389,17 @@ void Backend::discardRecovery() { } void Backend::reloadFromDisk() { - m_externalChangePending = false; - if (m_fileUrl.isLocalFile()) - open(m_fileUrl); + if (!m_fileUrl.isLocalFile()) + return; + + open(m_fileUrl); + + // A reload that did not happen has answered nothing, and the prompt that + // asked has already closed itself. Ask again rather than leave the guard + // standing with nothing able to clear it. + if (m_externalChangePending) + emit externalChangeDetected(!QFileInfo::exists(m_fileUrl.toLocalFile()), + m_modified); } void Backend::keepExternalVersion() { diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index fe44053..1bf1a85 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -768,6 +768,9 @@ private slots: QObject *prompt = window->findChild( QStringLiteral("externalChangeDialog")); QVERIFY(prompt); + // Asserted as configuration rather than by pressing Escape: the window + // is never shown here, so a synthetic key never reaches the popup and + // the behavioural version of this passes whatever the policy says. QCOMPARE(prompt->property("closePolicy").toInt(), 0); // Popup.NoAutoClose // A conflict is about one file. Opening another document ends it, @@ -806,6 +809,74 @@ private slots: QCOMPARE(QString::fromUtf8(kept.readAll()), QStringLiteral("my version")); } + void keepsTheGuardWhenTheNextDocumentWillNotOpen() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("contested.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.write("original"); + seed.close(); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(QFINDTESTDATA("../src/Main.qml"))); + 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 version")); + + QSignalSpy conflict(&backend, &Backend::externalChangeDetected); + QFile outside(path); + QVERIFY(outside.open(QIODevice::WriteOnly)); + outside.write("their version"); + outside.close(); + QTRY_COMPARE(conflict.count(), 1); + + // An open that fails replaces nothing, so the document still on screen + // is the contested one and its guard has to stand. + backend.open(QUrl::fromLocalFile(folder.filePath(QStringLiteral("missing.md")))); + backend.save(); + QVERIFY(QMetaObject::invokeMethod(&backend, "saveNow")); + QFile after(path); + QVERIFY(after.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(after.readAll()), QStringLiteral("their version")); + } + + void asksAgainWhenTheReloadItselfFails() { + QTemporaryDir folder; + QVERIFY(folder.isValid()); + const QString path = folder.filePath(QStringLiteral("vanishing.md")); + QFile seed(path); + QVERIFY(seed.open(QIODevice::WriteOnly)); + seed.write("original"); + seed.close(); + + Backend backend; + backend.open(QUrl::fromLocalFile(path)); + + QSignalSpy conflict(&backend, &Backend::externalChangeDetected); + QSaveFile outside(path); + QVERIFY(outside.open(QIODevice::WriteOnly)); + outside.write("theirs"); + QVERIFY(outside.commit()); + QTRY_COMPARE(conflict.count(), 1); + + // Taking their version cannot be done if there is no longer a their + // version to take. That has answered nothing, and the prompt has + // already closed itself, so it is raised again rather than leaving the + // guard standing with nothing able to clear it. + QVERIFY(QFile::remove(path)); + backend.reloadFromDisk(); + QCOMPARE(conflict.count(), 2); + QCOMPARE(conflict.last().at(0).toBool(), true); // reported as deleted + } + void asksAgainWhenTheFileIsReplacedTwice() { QTemporaryDir folder; QVERIFY(folder.isValid()); From 67e6c5820f5a057ffe0e16eb7c0fca93daef353a Mon Sep 17 00:00:00 2001 From: Erik Johansson <172146456+ejuro@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:13:57 +0200 Subject: [PATCH 10/10] Retry persistence after editing past a refused close --- src/Main.qml | 7 ++++++- tests/tst_omawrite.cpp | 12 ++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Main.qml b/src/Main.qml index 0a131a3..33ed356 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -857,8 +857,13 @@ ApplicationWindow { if (win.searchUpdating) return; var contentChanged = backend.editorTextChanged(); - if (contentChanged) + if (contentChanged) { + // A failed close only confirms discarding the text that + // was on screen for that attempt. New writing must get + // its own chance to be saved. + win.closeAnyway = false; win.settlingCaret = false; + } if (win.searchOpen && contentChanged) win.updateSearch(); } diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 1bf1a85..ab16728 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -1094,8 +1094,16 @@ private slots: QVERIFY2(!backend.saveBeforeClosing(), "with nowhere to write, the work reached nowhere"); - // The first close is refused rather than dropping the only copy; the - // second is taken as meaning it. + // The first close is refused rather than dropping the only copy. + QVERIFY(QMetaObject::invokeMethod(window.data(), "close")); + QVERIFY(window->property("visible").toBool()); + QVERIFY(window->property("closeAnyway").toBool()); + + // That override belongs only to the text from the refused attempt. + // Continuing to write revokes it, so a later close tries persistence + // again instead of silently discarding the newer text. + editor->setProperty("text", QStringLiteral("newer only copy")); + QVERIFY(!window->property("closeAnyway").toBool()); QVERIFY(QMetaObject::invokeMethod(window.data(), "close")); QVERIFY(window->property("visible").toBool()); QVERIFY(window->property("closeAnyway").toBool());