diff --git a/README.md b/README.md index c44ade3..1717bb0 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,15 @@ 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. +- `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`. @@ -24,6 +29,43 @@ 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. 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 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. + +## 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/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/FileSidebar.qml b/src/FileSidebar.qml new file mode 100644 index 0000000..50f3b4d --- /dev/null +++ b/src/FileSidebar.qml @@ -0,0 +1,422 @@ +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 url folderUrl + property string folderName: "" + property bool folderHasParent: false + 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 + + // 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() + onFolderUrlChanged: selectedEntryName = "" + // Deferred: the list also resets its own index when the model is replaced. + onEntriesChanged: Qt.callLater(restoreSelection) + + function focusList() { + list.forceActiveFocus(); + var open = indexOfUrl(root.currentFileUrl); + if (open >= 0) + selectIndex(open); + else + restoreSelection(); + } + + 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) + 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; + selectIndex(Math.min(root.entries.length - 1, list.currentIndex + 1)); + } + + function selectPrevious() { + if (root.entries.length === 0) + return; + selectIndex(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 { + 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: resizeHandle.containsMouse || resizeHandle.pressed ? 0.6 : 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.goUp() + } + } + + 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 && !root.creating + text: "Nothing here yet" + color: root.mutedColor + opacity: 0.7 + font.family: "iA Writer Mono S" + font.pixelSize: Math.round(13 * root.textScale) + } + + ListView { + id: list + anchors.left: parent.left + anchors.right: parent.right + 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 + + readonly property bool current: + !row.modelData.isDir + && row.modelData.url.toString() === root.currentFileUrl.toString() + + Rectangle { + anchors.fill: parent + color: root.selectionFill + // 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 { + 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: { + root.selectIndex(row.index); + if (row.modelData.isDir) + root.folderRequested(row.modelData.url); + else + root.fileRequested(row.modelData.url); + } + } + } + } + + 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/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..33ed356 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 @@ -28,52 +28,108 @@ 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))) - property bool closeConfirmed: false + Math.max(360, width - fileSidebar.width + - Math.round(writerFontMetrics.averageCharacterWidth * 20))) property bool searchOpen: false + property bool sidebarOpen: false + property int sidebarLogicalWidth: 240 property bool searchUpdating: false property var searchMatches: [] property int searchMatchIndex: -1 - property url pendingOpenUrl - property string pendingAction: "" property bool replaceOpen: false - property bool awaitingPendingSave: false + property bool keyboardWaitingForDialog: false + property bool closeAnyway: false Material.theme: darkMode ? Material.Dark : Material.Light Material.accent: backend.themeAccent color: pageColor + // 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 (closeConfirmed || !backend.modified) + if (closeAnyway || backend.saveBeforeClosing()) return; - close.accepted = false; - pendingAction = "close"; - if (!unsavedChangesDialog.opened) - unsavedChangesDialog.open(); + closeAnyway = true; + } + onActiveChanged: if (!active) backend.saveNow() + + 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); + // 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); + } + + // A closing modal hands focus back to whatever held it before it opened, + // so wait it out rather than race it. + function handKeyboardToEditor() { + if (externalChangeDialog.visible) { + keyboardWaitingForDialog = true; 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); + 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" @@ -178,6 +234,12 @@ ApplicationWindow { onActivated: shortcutsDialog.open() } + Shortcut { + sequence: "Ctrl+E" + context: Qt.ApplicationShortcut + onActivated: win.toggleSidebar() + } + Shortcut { sequence: "Ctrl+O" context: Qt.ApplicationShortcut @@ -249,15 +311,12 @@ 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; + win.settleCaret(); + caretSettleWindow.restart(); + win.handKeyboardToEditor(); } function onExternalChangeDetected(deleted, locallyModified) { @@ -281,38 +340,12 @@ 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 - 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 = "" + onRejected: backend.fileDialogCanceled() } ExternalChangeDialog { id: externalChangeDialog + objectName: "externalChangeDialog" darkMode: win.darkMode textScale: win.textScale textColor: win.textColor @@ -322,6 +355,7 @@ ApplicationWindow { onKeepRequested: backend.keepExternalVersion() onReloadRequested: backend.reloadFromDisk() + onClosed: win.releaseKeyboardAfterDialog() } Dialog { @@ -331,16 +365,63 @@ 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\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 } } + 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 + folderUrl: backend.folderUrl + folderName: backend.folderName + 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 { anchors.fill: parent + anchors.leftMargin: fileSidebar.width Flickable { id: editorFlick + objectName: "editorFlick" anchors.fill: parent anchors.leftMargin: 24 anchors.rightMargin: 24 @@ -532,7 +613,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) @@ -554,11 +639,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); @@ -774,6 +857,13 @@ ApplicationWindow { if (win.searchUpdating) return; var contentChanged = backend.editorTextChanged(); + 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(); } @@ -821,6 +911,14 @@ ApplicationWindow { onClicked: backend.openDialog() } + FooterIconButton { + objectName: "filesButton" + iconName: "files" + iconColor: win.mutedColor + tooltip: "Files" + onClicked: win.setSidebarOpen(!win.sidebarOpen) + } + Label { text: backend.status color: win.mutedColor @@ -1001,6 +1099,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/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 90e279e..d40bd0a 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -35,6 +35,8 @@ 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(); @@ -94,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()) @@ -114,9 +116,20 @@ 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, + [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 +211,70 @@ 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); +} + +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.")); @@ -212,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; @@ -231,6 +311,57 @@ void Backend::save() { saveTo(m_fileUrl); } +void Backend::saveNow() { + m_persistTimer.stop(); + persistDocument(); +} + +// 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()) + return saveTo(m_fileUrl); + + const QString text = currentDocumentText(); + if (text.trimmed().isEmpty()) { + clearRecovery(); + setModified(false); + return true; + } + + 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(); @@ -258,11 +389,21 @@ void Backend::discardRecovery() { } void Backend::reloadFromDisk() { - 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() { + m_externalChangePending = false; QFile file(m_fileUrl.toLocalFile()); if (file.open(QIODevice::ReadOnly)) { m_lastKnownFileContents = file.readAll(); @@ -272,7 +413,7 @@ void Backend::keepExternalVersion() { m_hasKnownFileContents = false; } setModified(true); - scheduleRecovery(); + schedulePersist(); watchCurrentFile(); setStatus(QStringLiteral("Kept your version")); } @@ -356,8 +497,7 @@ bool Backend::editorTextChanged() { scheduleWordCount(); setModified(true); - setStatus(QStringLiteral("Unsaved")); - scheduleRecovery(); + schedulePersist(); return true; } @@ -436,6 +576,7 @@ void Backend::loadDocumentText(const QString &text) { applyDocumentTypography(); m_wordCountTimer.stop(); setWordCount(countWords(text)); + emit documentLoaded(); } void Backend::setFileUrl(const QUrl &url) { @@ -445,6 +586,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) { @@ -463,11 +606,19 @@ void Backend::setStatus(const QString &status) { emit statusChanged(); } -void Backend::saveTo(const QUrl &url) { +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.")); - return; + return false; } const QString targetName = QFileInfo(url.toLocalFile()).fileName(); @@ -475,7 +626,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(); @@ -493,7 +644,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; @@ -511,30 +662,46 @@ void Backend::saveTo(const QUrl &url) { if (shouldClose) emit closeAfterSave(); + + return true; +} + +void Backend::schedulePersist() { + m_persistTimer.start(); } -void Backend::scheduleRecovery() { - m_recoveryTimer.start(); +// 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)) + return; + + writeRecovery(); } 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() { @@ -561,7 +728,7 @@ void Backend::restoreRecovery() { } void Backend::clearRecovery() { - m_recoveryTimer.stop(); + m_persistTimer.stop(); QFile::remove(recoveryPath()); } @@ -573,6 +740,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"); @@ -662,16 +880,38 @@ 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; + 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 { @@ -690,13 +930,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 2429590..9dba295 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,14 +54,28 @@ 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); + 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 saveNow(); + Q_INVOKABLE bool saveBeforeLeaving(); + Q_INVOKABLE bool saveBeforeClosing(); Q_INVOKABLE void saveForClose(); Q_INVOKABLE void saveAsDialog(); Q_INVOKABLE void saveAs(const QUrl &url); @@ -88,22 +107,30 @@ class Backend : public QObject { void saveDialogRequested(const QUrl &suggestedUrl); void saveSucceeded(); void externalChangeDetected(bool deleted, bool locallyModified); + void folderChanged(); + void documentLoaded(); private: void loadDocumentText(const QString &text); 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); + void watchCurrentFolder(); QString currentDocumentText() const; void setWordCount(int words); void refreshWordCount(); void scheduleWordCount(); void applyDocumentTypography(); void reapplyTypographyToChange(); - void scheduleRecovery(); - void writeRecovery(); + void schedulePersist(); + void persistDocument(); + bool saveToItsOwnFile(); + bool writeRecovery(); + QUrl unusedDocumentUrl(const QString &fileName) const; void restoreRecovery(); void clearRecovery(); QString recoveryPath() const; @@ -124,14 +151,17 @@ 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; QPointer m_document; QPointer m_parentWindow; QPointer m_highlighter; 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/src/resources.qrc b/src/resources.qrc index 91d9a86..32e9748 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -3,8 +3,8 @@ Main.qml SearchIconButton.qml 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 5c3306a..ab16728 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include #include @@ -112,6 +114,107 @@ 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 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()); @@ -246,6 +349,969 @@ 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); + } + + + 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()); + 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 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())); + + 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); + + // 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()); + + // 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()); + + // 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()); + + // 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")); + + // 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(backend.fileName(), QStringLiteral("two.md")); + 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 + // 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 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 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()); + + // 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)); + + // 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); + // 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, + // 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); + QVERIFY(kept.open(QIODevice::ReadOnly)); + 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()); + 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()); + + 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 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()); + 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)); + + QVERIFY2(!backend.saveBeforeClosing(), + "with nowhere to write, the work reached nowhere"); + + // 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()); + + 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()); + + 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()); + 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))); + + 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")); + } + + 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 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; };