aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/notes/NotesPage.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 20:46:50 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 20:46:50 +0200
commit53a6051c1d74e48d9a065ce0765dc49a75c9d711 (patch)
treedd95954a5328b7d5bcc97c1722ff96366b8b545b /desktop/modules/notes/NotesPage.qml
parentfe7dcb389e0df86aadbe481646ca9202dfcca9dc (diff)
downloadquickshell-53a6051c1d74e48d9a065ce0765dc49a75c9d711.tar.gz
quickshell-53a6051c1d74e48d9a065ce0765dc49a75c9d711.zip
notes: in-drawer editor with commit-on-save and git pull
The editor, and the write and git path behind it. Save is two clicks and each does one thing: the first writes the note and only then arms the button as 'Commit & push?', so a quick second click cannot commit a note the first has not written yet. The write path refuses when db.json cannot be read as the index. That is not hypothetical: git pull --rebase --autostash resolves a remote db.json change against a local uncommitted one, the stash pop conflicts, git still exits 0, and the file is left full of merge markers. Reading that as 'no notes' and writing it back deleted every title in a probe; Notes.valid now distinguishes it from an empty store and the write reports instead. parse stays tolerant for display. Stamping lastpull also only follows a pull that worked, and only when git is on at all. Db reads that a write is based on go through reload() plus waitForJob(): blockLoading does not make reload() blocking, contrary to the note in AGENTS.md, and reading text() straight after reload() returns the old contents. A body is written with a plain setText, which needs no prior load; only reading text() before a load would.
Diffstat (limited to 'desktop/modules/notes/NotesPage.qml')
-rw-r--r--desktop/modules/notes/NotesPage.qml382
1 files changed, 320 insertions, 62 deletions
diff --git a/desktop/modules/notes/NotesPage.qml b/desktop/modules/notes/NotesPage.qml
index d73bea2..a3d11f6 100644
--- a/desktop/modules/notes/NotesPage.qml
+++ b/desktop/modules/notes/NotesPage.qml
@@ -9,27 +9,148 @@
// 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 list, newest first, with a title search. Reads the module's parsed
-// db.json; nothing here touches the store or git.
+// 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
+// commits and pushes and returns to the list. Arming the second click only
+// after the write completes is what stops a quick second click from committing
+// a note the first click has 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 and the writes.
+ // The notes module: the store, the resolved paths, the writes and git.
required property QtObject owner
- Row {
- id: bar
- anchors { top: parent.top; left: parent.left; right: parent.right }
- spacing: 8
+ 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
+
+ function open(note) {
+ page.bodyError = "";
+ page.preview = note !== null;
+ confirm.stop();
+ confirmDel.stop();
+ 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() {
+ confirm.stop();
+ confirmDel.stop();
+ page.busy = false;
+ page.editing = null;
+ page.bodyError = "";
+ page.view = "list";
+ }
+
+ function save() {
+ if (page.busy) return;
+ 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; }
+ confirm.start();
+ };
+ if (confirm.running) {
+ confirm.stop();
+ const kind = page.editing ? "edit" : "create";
+ const id = page.editing ? page.editing.id : page.owner.lastCreatedId;
+ page.busy = true;
+ page.owner.commitPush(kind, id, (ok, msg) => {
+ page.busy = false;
+ if (!ok) page.owner.lastError = msg;
+ page.toList();
+ });
+ return;
+ }
+ page.owner.lastError = "";
+ page.busy = true;
+ 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;
+ const done = (ok, msg) => {
+ page.busy = false;
+ if (!ok) { page.owner.lastError = msg; return; }
+ if (!page.owner.gitEnabled) { page.toList(); return; }
+ confirmDel.start();
+ };
+ if (confirmDel.running) {
+ confirmDel.stop();
+ page.busy = true;
+ page.owner.commitPush("delete", id, (ok, msg) => {
+ page.busy = false;
+ if (!ok) page.owner.lastError = msg;
+ page.toList();
+ });
+ return;
+ }
+ page.owner.lastError = "";
+ page.busy = true;
+ page.owner.remove(id, done);
+ }
+
+ 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.notesDir + "/" + 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: confirm; interval: 3000 }
+ Timer { id: confirmDel; interval: 3000 }
+ Timer { id: updated; interval: 2000 }
+
+ // --- list --------------------------------------------------------------
+
+ Item {
+ id: listView
+ anchors.fill: parent
+ visible: page.view === "list"
TextField {
id: search
- width: parent.width
+ anchors { top: parent.top; left: parent.left; right: newBtn.left; rightMargin: 8 }
height: 32
placeholderText: "Search"
color: Theme.text
@@ -40,82 +161,219 @@ Item {
border.width: 1
border.color: Qt.alpha(Theme.text, 0.15)
}
- Component.onCompleted: forceActiveFocus()
onTextChanged: list.currentIndex = 0
}
- }
- ListView {
- id: list
- anchors {
- top: bar.bottom; topMargin: 10
- left: parent.left; right: parent.right
- bottom: errorLine.top; bottomMargin: 6
+ 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)
}
- clip: true
- spacing: 4
- model: Notes.filter(page.owner.notes, search.text)
- boundsBehavior: Flickable.StopAtBounds
- ScrollBar.vertical: ScrollBar {}
Text {
- anchors.centerIn: parent
+ 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 }
}
- delegate: Rectangle {
- id: row
- required property var modelData
- required property int index
+ 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 {}
- width: list.width
- height: 36
- radius: 8
- color: rowArea.containsMouse || ListView.isCurrentItem
- ? Qt.alpha(Theme.accent, 0.18) : Qt.alpha(Theme.surface, 0.35)
+ delegate: Rectangle {
+ id: row
+ required property var modelData
+ required property int index
- MouseArea {
- id: rowArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- }
+ width: list.width
+ height: 36
+ radius: 8
+ color: rowArea.containsMouse || ListView.isCurrentItem
+ ? Qt.alpha(Theme.accent, 0.18) : Qt.alpha(Theme.surface, 0.35)
- Text {
- anchors {
- left: parent.left; leftMargin: 10
- right: when.left; rightMargin: 8
- verticalCenter: parent.verticalCenter
+ MouseArea {
+ id: rowArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: page.open(row.modelData)
}
- elide: Text.ElideRight
- maximumLineCount: 1
- textFormat: Text.PlainText
- text: row.modelData.title
- color: Theme.text
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+
+ 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 || confirm.running
+ text: page.busy && !confirm.running ? "Saving…"
+ : confirm.running ? "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 || confirmDel.running
+ text: confirmDel.running ? "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: when
- anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
- text: Notes.formatDate(row.modelData.file)
- color: Theme.overlay
+ 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: page.preview = checked
+ }
+ }
+
+ 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: errorLine
- 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 }
+ 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 }
+ }
}
}