diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 20:46:50 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 20:46:50 +0200 |
| commit | 53a6051c1d74e48d9a065ce0765dc49a75c9d711 (patch) | |
| tree | dd95954a5328b7d5bcc97c1722ff96366b8b545b /desktop/modules/notes/NotesModule.qml | |
| parent | fe7dcb389e0df86aadbe481646ca9202dfcca9dc (diff) | |
| download | quickshell-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/NotesModule.qml')
| -rw-r--r-- | desktop/modules/notes/NotesModule.qml | 211 |
1 files changed, 205 insertions, 6 deletions
diff --git a/desktop/modules/notes/NotesModule.qml b/desktop/modules/notes/NotesModule.qml index a2d54e5..94ef41e 100644 --- a/desktop/modules/notes/NotesModule.qml +++ b/desktop/modules/notes/NotesModule.qml @@ -9,6 +9,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +import Quickshell import Quickshell.Io import QtQuick import "../.." @@ -20,9 +21,13 @@ import "Notes.js" as Notes // seconds. The page writes notes in exactly the shape notes.sh reads, so both // tools work on the same notes. // +// Git follows notes.sh: pull before the write, then add, commit, push. The one +// departure is that a save here is two clicks, so an unconfirmed save leaves +// db.json uncommitted, and the pulls therefore carry --autostash. +// // A Module is a plain QtObject and has no default property, so the Process and -// the FileView below are declared as typed properties, as UpdatesModule does -// for its Process. +// FileView objects below are declared as typed properties, as UpdatesModule +// does for its Process. Module { id: mod @@ -42,10 +47,15 @@ Module { property string client: "" // The whole parsed db.json, so a write can replace .notes and carry - // params and git through untouched. + // params and git through untouched. Every read and write goes through this + // rather than through dbView.text(), which is stale right after a reload. property var db: ({ notes: [] }) property bool ready: false property string lastError: "" + property string gitOutput: "" + property bool syncing: false + // So the second click on a brand-new note can name it in the commit. + property string lastCreatedId: "" readonly property var notes: Notes.sorted(mod.db.notes) readonly property int count: Array.isArray(mod.db.notes) ? mod.db.notes.length : 0 @@ -76,24 +86,213 @@ Module { // The store, watched rather than read on open: the tile's count has to // move when a note is added from either side, the CLI included. A missing // file is an empty store, not an error worth logging. + // + // fileChanged means the file was replaced, and text() still holds the old + // contents until the reload lands, which is why the reparse lives on + // onLoaded and not on fileChanged (the same split Prefs.qml uses). property FileView dbView: FileView { path: mod.dbPath atomicWrites: true watchChanges: true printErrors: false - onFileChanged: mod.reparse() + onFileChanged: reload() onLoaded: mod.reparse() onLoadFailed: mod.reparse() } // Nothing to read until the config has resolved, so the load failed on the - // empty path does not mark the store ready. + // empty path does not mark the store ready. An unreadable db.json keeps + // the last good parse and says so rather than showing an empty store. function reparse() { if (mod.dbPath === "") return; - mod.db = Notes.parse(mod.dbView.text()); + const v = Notes.valid(mod.dbView.text()); + if (v !== null) mod.db = v; + else mod.lastError = mod.indexError(); mod.ready = true; } + function indexError() { + return "db.json could not be read; resolve it in " + mod.baseDir; + } + + // db.json as it is on disk right now, or null when it cannot be read. + // reload() does not block even with blockLoading set, so waitForJob() is + // what makes text() the new contents; without it a create would append to + // the db.json from before the pull. null is load-bearing: a conflicted + // db.json (merge markers an autostash pop left behind) must stop the write, + // not be replaced by an empty one. + function readDbNow() { + mod.dbView.reload(); + mod.dbView.waitForJob(); + return Notes.valid(mod.dbView.text()); + } + + // db.json, written atomically. mod.db is assigned first so the page does + // not wait for the watch to come back round. + function writeDb(db) { + mod.db = db; + mod.dbView.setText(Notes.stringify(db)); + } + + // A note body. notes.sh keeps the title out of the file, so this is the + // body and nothing else. setText writes the path directly and needs no + // prior load; reading the file back is what would need one, and nothing + // here reads it back. + property FileView bodyView: FileView { + printErrors: false + } + + function writeBody(path, text, done) { + mod.bodyView.path = path; + mod.bodyView.setText(text); + if (done) done(true, ""); + } + + function fileIn(db, id) { + for (const n of (db.notes || [])) if (n.id === id) return n.file || ""; + return ""; + } + + // --- git --------------------------------------------------------------- + // + // Both shells fold stderr into stdout with 2>&1, so one collector holds + // the reason. onExited must not read the collector directly: its + // onStreamFinished can fire after the process exits. The collector stores + // the text in mod.gitOutput and onExited reads that and the exit code. + // The two processes never run at once, so sharing gitOutput is safe. + + property Process gitPull: Process { + // "create" resumes the create this pull was for; "stamp" is Update list. + property string after: "" + property string title: "" + property string body: "" + property var done: null + + onExited: code => { + mod.lastError = code === 0 ? "" : mod.gitOutput; + const a = mod.gitPull.after; + mod.gitPull.after = ""; + if (a === "create") mod.finishCreate(code, mod.gitPull.title, mod.gitPull.body, mod.gitPull.done); + else if (a === "stamp") mod.stampPull(code, mod.gitPull.done); + } + stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text } + } + + property Process gitSync: Process { + property var done: null + + onExited: code => { + mod.syncing = false; + mod.lastError = code === 0 ? "" : mod.gitOutput; + const d = mod.gitSync.done; + mod.gitSync.done = null; + if (d) d(code === 0, code === 0 ? "" : mod.gitOutput); + } + stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text } + } + + function startPull() { + mod.lastError = ""; + mod.gitPull.command = ["sh", "-c", 'git -C "$1" pull --rebase --autostash 2>&1', "sh", mod.baseDir]; + // Reassigning running on a running Process does nothing, so clear it + // first (Status.qml does the same for its breaktimer). + mod.gitPull.running = false; + mod.gitPull.running = true; + } + + // Update list: pull, then stamp lastpull so notes.sh -l does not pull again + // straight away. Stamped only on a pull that worked: claiming to have + // pulled after a failure would suppress the CLI's own pull for an hour. + function pull(done) { + if (!mod.gitEnabled) { if (done) done(false, "git is disabled"); return; } + mod.gitPull.done = done; + mod.gitPull.after = "stamp"; + mod.startPull(); + } + + function stampPull(code, done) { + if (code !== 0) { if (done) done(false, mod.gitOutput); return; } + const db = mod.readDbNow(); + if (db === null) { if (done) done(false, mod.indexError()); return; } + mod.writeDb(Notes.setLastpull(db, Math.floor(Date.now() / 1000))); + if (done) done(true, ""); + } + + // Create writes the note after the pull, on the db.json the pull brought + // in: notes.sh reads the id after its own gitsync for the same reason. + function create(title, body, done) { + if (!mod.ready) { if (done) done(false, "notes not loaded yet"); return; } + if (!mod.gitEnabled) { mod.finishCreate(0, title, body, done); return; } + mod.gitPull.title = title; + mod.gitPull.body = body; + mod.gitPull.done = done; + mod.gitPull.after = "create"; + mod.startPull(); + } + + function finishCreate(code, title, body, done) { + const db = mod.readDbNow(); + if (db === null) { if (done) done(false, mod.indexError()); return; } + const now = Math.floor(Date.now() / 1000); + const file = String(now); + let next = Notes.addNote(db, title, file); + // Only when a pull actually happened: with git off there was none, and + // claiming one would suppress notes.sh's own pull later. + if (code === 0 && mod.gitEnabled) next = Notes.setLastpull(next, now); + mod.lastCreatedId = next.notes[next.notes.length - 1].id; + mod.writeDb(next); + mod.writeBody(mod.notesDir + "/" + file, body, () => { if (done) done(true, ""); }); + // shortcut: second-granularity filenames, as notes.sh takes date +%s, + // so two notes in one second would share a file. Upgrade to a fresh + // second or a suffix if that is ever seen. + } + + // Edit keeps the file name and the id, replacing the title in db.json and + // the body in notes/<file>. notes.sh pulls on add only. + function update(id, title, body, done) { + if (!mod.ready) { if (done) done(false, "notes not loaded yet"); return; } + const db = mod.readDbNow(); + if (db === null) { if (done) done(false, mod.indexError()); return; } + const file = mod.fileIn(db, id); + if (file === "") { if (done) done(false, "note not found"); return; } + mod.writeDb(Notes.updateNote(db, id, title)); + mod.writeBody(mod.notesDir + "/" + file, body, () => { if (done) done(true, ""); }); + } + + // Delete drops the entry and removes the body file. FileView has no + // delete, so this is the module's one filesystem call. + function remove(id, done) { + if (!mod.ready) { if (done) done(false, "notes not loaded yet"); return; } + const db = mod.readDbNow(); + if (db === null) { if (done) done(false, mod.indexError()); return; } + const file = mod.fileIn(db, id); + mod.writeDb(Notes.removeNote(db, id)); + if (file !== "") Quickshell.execDetached(["rm", "--", mod.notesDir + "/" + file]); + if (done) done(true, ""); + } + + function msg(kind, id) { + if (kind === "create") return "quickshell - adding note from " + mod.client; + if (kind === "edit") return "quickshell - " + mod.client + " note edited."; + return "quickshell - " + mod.client + " removing note ID " + id + "."; + } + + // The second click of Save or Delete: add everything, commit (a commit + // with nothing staged is not a failure), push. No pull here; creating + // already pulled. + function commitPush(kind, id, done) { + if (!mod.gitEnabled) { if (done) done(true, ""); return; } + if (mod.syncing) return; + mod.syncing = true; + mod.lastError = ""; + mod.gitSync.done = done; + mod.gitSync.command = ["sh", "-c", + 'git -C "$1" add . && { git -C "$1" commit -m "$2" || true; } && git -C "$1" push origin HEAD 2>&1', + "sh", mod.baseDir, mod.msg(kind, id)]; + mod.gitSync.running = false; + mod.gitSync.running = true; + } + tileContent: Component { Text { width: parent ? parent.width : implicitWidth |
