aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 21:08:04 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 21:08:04 +0200
commitcf99dde4805f1792d6a56156b6a0338aa684005c (patch)
treec3c04b8e0457b855d50d2e0ca41ffe417630ed3a
parentd423ec2646d444523fd44c92864988445cf072d2 (diff)
downloadquickshell-cf99dde4805f1792d6a56156b6a0338aa684005c.tar.gz
quickshell-cf99dde4805f1792d6a56156b6a0338aa684005c.zip
notes: an empty note body gets a file, and the second click writes
Reported from use: a note created with only a title then saved left a db.json entry with no file beside it, and reopening it said the file was missing. Cause: FileView.setText("") on a path that view has not loaded creates no file at all and reports no error. The index write (always non-empty JSON) was fine, so only the body was lost, which is why it looked like a missing file rather than a failed save. Bodies are now written by a Process running cat > <file> with the text on stdin: verified against empty and non-empty payloads, consecutive writes on one Process, and a store with no db.json at all. The exit code is the real outcome, so a refused write reports it. Second report in the same flow: the editor stays editable after the first click, so typing more and then pressing Commit & push dropped the edit, the push carrying only what the first click wrote. The second click now writes the current title and body before it commits and pushes.
-rw-r--r--desktop/modules/notes/NotesModule.qml45
-rw-r--r--desktop/modules/notes/NotesPage.qml25
-rw-r--r--desktop/modules/notes/README.md15
-rw-r--r--docs/superpowers/specs/2026-10-16-notes-design.md29
4 files changed, 73 insertions, 41 deletions
diff --git a/desktop/modules/notes/NotesModule.qml b/desktop/modules/notes/NotesModule.qml
index 082f02c..992cbfa 100644
--- a/desktop/modules/notes/NotesModule.qml
+++ b/desktop/modules/notes/NotesModule.qml
@@ -150,27 +150,38 @@ Module {
if (done) done(ok, ok ? "" : mod.dbView.saveError);
}
- // 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 {
- blockWrites: true
- printErrors: false
- property string saveError: ""
- onSaved: saveError = ""
- onSaveFailed: error => {
- console.warn("notes: note body write failed:", error);
- saveError = "the note's file could not be written";
+ // A note body, written through a Process rather than a FileView.
+ // FileView.setText() with EMPTY text on a path that view has not loaded
+ // writes no file at all and reports no error, which is how a note created
+ // with an empty body ended up in db.json with no file beside it. cat reads
+ // stdin, so any content and any length works, including none, and the exit
+ // code is the real outcome. Clearing stdinEnabled after the write is what
+ // gives cat its EOF.
+ property Process bodyWriter: Process {
+ stdinEnabled: true
+ property string data: ""
+ property var done: null
+ property string err: ""
+ onStarted: {
+ mod.bodyWriter.write(mod.bodyWriter.data);
+ mod.bodyWriter.stdinEnabled = false;
+ }
+ onExited: code => {
+ const d = mod.bodyWriter.done;
+ mod.bodyWriter.done = null;
+ if (d) d(code === 0, code === 0 ? "" : (mod.bodyWriter.err || "the note's file could not be written"));
}
+ stderr: StdioCollector { onStreamFinished: mod.bodyWriter.err = this.text }
}
function writeBody(path, text, done) {
- mod.bodyView.path = path;
- mod.bodyView.saveError = "";
- mod.bodyView.setText(text);
- const ok = mod.bodyView.saveError === "";
- if (done) done(ok, ok ? "" : mod.bodyView.saveError);
+ mod.bodyWriter.done = done;
+ mod.bodyWriter.data = text;
+ mod.bodyWriter.err = "";
+ mod.bodyWriter.command = ["sh", "-c", 'cat > "$1"', "sh", path];
+ mod.bodyWriter.stdinEnabled = true;
+ mod.bodyWriter.running = false;
+ mod.bodyWriter.running = true;
}
// The path of a note's body, "" when the entry's file is not a plain
diff --git a/desktop/modules/notes/NotesPage.qml b/desktop/modules/notes/NotesPage.qml
index 585fcfb..1a82254 100644
--- a/desktop/modules/notes/NotesPage.qml
+++ b/desktop/modules/notes/NotesPage.qml
@@ -19,9 +19,11 @@ import "Notes.js" as Notes
//
// 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.
+// 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.
@@ -78,12 +80,19 @@ Item {
// 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 !== "") {
- const id = page.awaiting === "create" ? page.owner.lastCreatedId : page.editing.id;
+ // 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.commitPush(page.awaiting, id, (ok, msg) => {
- page.busy = false;
- if (!ok) { page.owner.lastError = msg; return; }
- page.toList();
+ 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;
}
diff --git a/desktop/modules/notes/README.md b/desktop/modules/notes/README.md
index 0f642b8..42f989f 100644
--- a/desktop/modules/notes/README.md
+++ b/desktop/modules/notes/README.md
@@ -14,9 +14,9 @@ per note, named by its creation time in epoch seconds.
lists, links, inline code and quotes render; tables and fenced-code
highlighting do not.
- **Save** is two clicks: the first writes the note, the second reads
- **Commit & push?** and pushes it. **Delete** asks first, then removes the
- note and pushes the removal. With git off there is no second half, so Save
- writes and returns in one.
+ **Commit & push?**, writes whatever you have typed since, and pushes it.
+ **Delete** asks first, then removes the note and pushes the removal. With git
+ off there is no second half, so Save writes and returns in one.
- Leaving the editor with **Cancel**, the back arrow, Escape or a click
outside drops unsaved text. The save is the explicit act.
- **Update list** runs `git pull` and stamps `git.lastpull`.
@@ -38,9 +38,12 @@ git to resolve instead. A change made on two machines at once conflicts here
for the same reason it conflicts for `notes.sh`: every machine appends to the
one `db.json`.
-A write that fails is reported rather than assumed: the writes block, so a
-refused or failing `setText` is known when it returns, and a failed commit or
-push shows git's own message.
+A write that fails is reported rather than assumed. A body is written by a
+`Process` running `cat > <file>` with the text on stdin, not by a `FileView`:
+`setText` with an empty string, on a path that view has not loaded, creates no
+file and reports no error, which once put an empty note into `db.json` with no
+file beside it. `cat` takes any content at any length, including none, and its
+exit code is the outcome; a failed commit or push shows git's own message.
**Git order.** `notes.sh` pulls before it writes and commits after, and the
page keeps that order: creating a note pulls first, then writes on what the
diff --git a/docs/superpowers/specs/2026-10-16-notes-design.md b/docs/superpowers/specs/2026-10-16-notes-design.md
index 459b526..b8524a2 100644
--- a/docs/superpowers/specs/2026-10-16-notes-design.md
+++ b/docs/superpowers/specs/2026-10-16-notes-design.md
@@ -91,10 +91,12 @@ Opened by a row (existing note) or **New note**.
renders the body as Markdown; Edit shows the raw Markdown in a `TextEdit`.
An existing note opens in Preview, a new one in Edit.
- **Save** is two clicks, so the note is on disk before git is involved: the
- first writes it and the button becomes **Commit & push?**; the second runs
- the git half (see Git) and returns to the list. Leaving with the second click
- unmade keeps the saved note and does not push it. With git off there is no
- git half, so Save writes and returns to the list in one click.
+ first writes it and the button becomes **Commit & push?**; the second writes
+ whatever the editor holds then, commits and pushes (see Git), and returns to
+ the list. The editor stays editable between the clicks, so the second has to
+ write as well: pushing alone would drop an edit made after the first. Leaving
+ with the second click unmade keeps the saved note and does not push it. With
+ git off there is no second half, so Save writes and returns in one click.
- **Cancel** returns to the list without saving. **Delete**, existing notes
only, asks first: the first click arms the button, the second removes the
note and pushes the removal. Nothing is destroyed until the second click, a
@@ -109,9 +111,15 @@ Opened by a row (existing note) or **New note**.
## Writes
All index writes go through the module's `db.json` `FileView` with
-`atomicWrites: true`, so a write that dies mid-way cannot leave a half
-`db.json` for `notes.sh` to trip on. A body is written with a `FileView` on
-its own path, which creates the file.
+`atomicWrites: true` and `blockWrites`, so a write that dies mid-way cannot
+leave a half `db.json` for `notes.sh` to trip on, and its outcome is known when
+the call returns.
+
+A body is written by a `Process` running `cat > <file>` with the text on stdin,
+not by a `FileView`. `setText` with an **empty** string, on a path that view has
+not loaded, creates no file at all and reports no error, which put a note with
+an empty body into `db.json` with no file beside it. `cat` takes any content at
+any length, including none, and its exit code is the outcome.
**A write is refused when `db.json` cannot be read as the index.** That is not
hypothetical: `git pull --rebase --autostash` resolves a remote `db.json`
@@ -122,9 +130,10 @@ and writing it back would delete every title, so the write instead reports
git to resolve. `Notes.valid` is the check; `Notes.parse` stays tolerant for
display, returning an empty store for the same input.
-A write that fails is reported, not assumed to have landed: both writes block
-(`blockWrites`), because `setText` is asynchronous otherwise, and a failure
-that nobody observes reports a save that never happened.
+A write that fails is reported, not assumed to have landed: the index write
+blocks (`blockWrites`) and the body write's `cat` exit code is checked, because
+`setText` is asynchronous otherwise and a failure nobody observes reports a
+save that never happened.
- **Create**: the first click pulls first (see Git), then re-reads `db.json`
so the append is based on what the pull brought in, not on what was read