// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell.Io import QtQuick import QtQuick.Controls import "../.." import "Notes.js" as Notes // The notes page: a list, and an editor over it. // // Save is two clicks and each click does one thing. The first writes the note // and, when that has finished, arms the button as "Commit & push?"; the second // writes whatever the editor holds now, then commits and pushes, then returns // to the list. The second click writes as well because the editor stays // editable in between: pushing without writing would throw away an edit made // after the first click. Arming only after the first write completes is what // stops a quick second click from committing a note that is not written yet. // // Nothing is remembered between visits: leaving the editor with text that was // not saved drops it, which is the clipboard page's own rule. Item { id: page // The notes module: the store, the resolved paths, the writes and git. required property QtObject owner property string view: "list" // "list" | "editor" property var editing: null // the note being edited, null for new property bool preview: false // editor shows rendered Markdown property string bodyError: "" // A write or git command is in flight; the buttons stand down meanwhile. property bool busy: false // "create" | "edit" once that write has landed and the commit and push are // still owed. It outlives any timer, so a click after a pause pushes the // note that was already written instead of writing it again. property string awaiting: "" // Delete is confirmed before anything is destroyed: the first click only // arms the button, the second removes and pushes. property bool confirmDelete: false // The shared Switch writes `checked` itself, which drops the binding // above; StatusRow.qml documents the same and resyncs the same way. onPreviewChanged: prevSwitch.checked = page.preview function open(note) { page.bodyError = ""; page.preview = note !== null; page.awaiting = ""; page.confirmDelete = false; titleField.text = note ? note.title : ""; // Clear before the assignment below: that assignment is what retargets // the body FileView, and its load may land synchronously, so clearing // after would wipe the body it just read. bodyEdit.text = ""; page.view = "editor"; page.editing = note; Qt.callLater(() => titleField.forceActiveFocus()); } function toList() { page.awaiting = ""; page.confirmDelete = false; page.busy = false; page.editing = null; page.bodyError = ""; page.view = "list"; } function save() { if (page.busy) return; // The note is already on disk and only the push is owed, so this click // pushes rather than writing a second note. if (page.awaiting !== "") { // The editor stays editable after the first click, so whatever was // typed since is written before it is pushed: the push alone would // leave that edit behind. const kind = page.awaiting; const id = kind === "create" ? page.owner.lastCreatedId : page.editing.id; page.busy = true; page.owner.update(id, titleField.text, bodyEdit.text, (ok, msg) => { if (!ok) { page.busy = false; page.owner.lastError = msg; return; } page.owner.commitPush(kind, id, (ok2, msg2) => { page.busy = false; if (!ok2) { page.owner.lastError = msg2; return; } page.toList(); }); }); return; } page.owner.lastError = ""; page.busy = true; const done = (ok, msg) => { page.busy = false; if (!ok) { page.owner.lastError = msg; return; } // Git off: there is no second half, so this was the whole save. if (!page.owner.gitEnabled) { page.toList(); return; } page.awaiting = page.editing ? "edit" : "create"; }; if (page.editing) page.owner.update(page.editing.id, titleField.text, bodyEdit.text, done); else page.owner.create(titleField.text, bodyEdit.text, done); } function remove() { if (page.busy || page.editing === null) return; const id = page.editing.id; // Nothing is destroyed until the second click: a note lost to one // stray click is only recoverable out of git history by hand. if (!page.confirmDelete) { page.confirmDelete = true; return; } page.confirmDelete = false; page.owner.lastError = ""; page.busy = true; page.owner.remove(id, (ok, msg) => { page.busy = false; if (!ok) { page.owner.lastError = msg; return; } if (!page.owner.gitEnabled) { page.toList(); return; } // The removal is confirmed, so the push follows it rather than // asking for a third click. page.busy = true; page.owner.commitPush("delete", id, (ok2, msg2) => { page.busy = false; if (!ok2) page.owner.lastError = msg2; page.toList(); }); }); } function updateList() { if (page.busy) return; page.owner.lastError = ""; page.busy = true; page.owner.pull((ok, msg) => { page.busy = false; if (ok) updated.restart(); else page.owner.lastError = msg; }); } // The open note's body. Nothing watches it: it is read when the editor // opens and written on save. FileView { id: bodyRead path: (page.view === "editor" && page.editing) ? page.owner.bodyPath(page.editing.file) : "" printErrors: false onLoaded: { bodyEdit.text = text(); page.bodyError = ""; } onLoadFailed: { bodyEdit.text = ""; page.bodyError = page.editing ? "the note's file is missing" : ""; } } Timer { id: updated; interval: 2000 } // --- list -------------------------------------------------------------- Item { id: listView anchors.fill: parent visible: page.view === "list" TextField { id: search anchors { top: parent.top; left: parent.left; right: newBtn.left; rightMargin: 8 } height: 32 placeholderText: "Search" color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } background: Rectangle { radius: 6 color: Qt.alpha(Theme.surface, 0.9) border.width: 1 border.color: Qt.alpha(Theme.text, 0.15) } onTextChanged: list.currentIndex = 0 } Button { id: updateBtn anchors { top: parent.top; right: parent.right } visible: page.owner.gitEnabled enabled: !page.busy text: updated.running ? "Updated" : "Update list" onClicked: page.updateList() } Button { id: newBtn anchors { top: parent.top; right: updateBtn.visible ? updateBtn.left : parent.right rightMargin: updateBtn.visible ? 8 : 0 } enabled: page.owner.ready text: "New note" onClicked: page.open(null) } Text { id: listEmpty anchors.centerIn: list visible: list.count === 0 text: page.owner.ready ? "No notes" : "Loading…" color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } ListView { id: list anchors { top: search.bottom; topMargin: 10 left: parent.left; right: parent.right bottom: listError.top; bottomMargin: 6 } clip: true spacing: 4 model: Notes.filter(page.owner.notes, search.text) boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar {} delegate: Rectangle { id: row required property var modelData required property int index width: list.width height: 36 radius: 8 color: rowArea.containsMouse || ListView.isCurrentItem ? Qt.alpha(Theme.accent, 0.18) : Qt.alpha(Theme.surface, 0.35) MouseArea { id: rowArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: page.open(row.modelData) } Text { anchors { left: parent.left; leftMargin: 10 right: when.left; rightMargin: 8 verticalCenter: parent.verticalCenter } elide: Text.ElideRight maximumLineCount: 1 textFormat: Text.PlainText text: row.modelData.title color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } Text { id: when anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter } text: Notes.formatDate(row.modelData.file) color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } } } Text { id: listError anchors { left: parent.left; right: parent.right; bottom: parent.bottom } height: visible ? implicitHeight : 0 visible: page.owner.lastError !== "" text: page.owner.lastError wrapMode: Text.WordWrap color: Theme.red font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } } // --- editor ------------------------------------------------------------ Item { id: editorView anchors.fill: parent visible: page.view === "editor" Button { id: cancelBtn anchors { top: parent.top; left: parent.left } text: "Cancel" onClicked: page.toList() } Button { id: saveBtn anchors { top: parent.top; right: parent.right } enabled: !page.busy text: page.busy ? "Saving…" : page.awaiting !== "" ? "Commit & push?" : "Save" onClicked: page.save() } Button { id: delBtn anchors { top: parent.top; right: saveBtn.left; rightMargin: 8 } visible: page.editing !== null danger: true enabled: !page.busy text: page.busy ? "Deleting…" : page.confirmDelete ? "Confirm?" : "Delete" onClicked: page.remove() } // Edit (raw Markdown) on the left, Preview (rendered) on the right. // The switch that owns the state is Switch.qml, as elsewhere. Item { id: previewToggle anchors { top: parent.top; right: delBtn.visible ? delBtn.left : saveBtn.left rightMargin: 12 } width: prevLabel.implicitWidth + 8 + prevSwitch.implicitWidth height: Math.max(prevLabel.implicitHeight, prevSwitch.implicitHeight) Text { id: prevLabel anchors { left: parent.left; verticalCenter: parent.verticalCenter } text: "Preview" color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } Switch { id: prevSwitch anchors { right: parent.right; verticalCenter: parent.verticalCenter } checked: page.preview onToggled: on => page.preview = on } } TextField { id: titleField anchors { top: parent.top; topMargin: 44; left: parent.left; right: parent.right } height: 32 placeholderText: "Title" color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } background: Rectangle { radius: 6 color: Qt.alpha(Theme.surface, 0.6) border.width: 1 border.color: Qt.alpha(Theme.text, 0.15) } } Text { id: bodyErr anchors { top: titleField.bottom; topMargin: 6; left: parent.left; right: parent.right } height: visible ? implicitHeight : 0 visible: page.bodyError !== "" text: page.bodyError color: Theme.yellow font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } TextEdit { id: bodyEdit anchors { top: bodyErr.bottom; topMargin: 6 left: parent.left; right: parent.right bottom: editError.top; bottomMargin: 6 } readOnly: page.preview selectByMouse: true textFormat: page.preview ? TextEdit.MarkdownText : TextEdit.PlainText wrapMode: TextEdit.Wrap color: Theme.text selectionColor: Qt.alpha(Theme.accent, 0.4) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } // Web and mail links only, as Segments.qml does: a note is the // user's own, but a file: or custom scheme link would still reach // xdg-open. onLinkActivated: link => { if (/^(https?|mailto):/i.test(link)) Qt.openUrlExternally(link); } HoverHandler { cursorShape: bodyEdit.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor } } Text { id: editError anchors { left: parent.left; right: parent.right; bottom: parent.bottom } height: visible ? implicitHeight : 0 visible: page.owner.lastError !== "" text: page.owner.lastError wrapMode: Text.WordWrap color: Theme.red font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } } }