diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-21 10:08:38 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-21 10:08:38 +0200 |
| commit | 8938788f1f107dd72b1e2996a9b1e16238ff1820 (patch) | |
| tree | fdd12e3895c744239c3930bc3ec4d44b7ee23906 /docs | |
| parent | cfb7b22b6754b8315a249af6f367f4ab5871fe21 (diff) | |
| download | quickshell-8938788f1f107dd72b1e2996a9b1e16238ff1820.tar.gz quickshell-8938788f1f107dd72b1e2996a9b1e16238ff1820.zip | |
docs: plan the sb-builder settings pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-10-21-sb-builder-settings.md | 1157 |
1 files changed, 1157 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-21-sb-builder-settings.md b/docs/superpowers/plans/2026-10-21-sb-builder-settings.md new file mode 100644 index 0000000..4caddcc --- /dev/null +++ b/docs/superpowers/plans/2026-10-21-sb-builder-settings.md @@ -0,0 +1,1157 @@ +# sb-builder Settings Pages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Two settings pages under "SB Builder" in the `desktop/` settings window: sb-builder's GUI config (local) and its helper config (on the VM, via `sbb-remote config-get`/`config-set`). + +**Architecture:** `Toml.js` edits TOML a line at a time (comments survive). `Store.qml` is one file's draft, typing field text after the default's type. `Sbb.qml` singleton holds two Stores: the desktop one fed by a watched `FileView`, the VM one fed by ssh. qtmaildir's `Setting`/`BoolSetting` gain a `store` property so both groups share them. + +**Tech Stack:** Quickshell 0.3.1 QML, `.pragma library` JS checked under node, ssh, sb-builder's helper (Python, built in that repo by its own session). + +Spec: `docs/superpowers/specs/2026-10-21-sb-builder-settings-design.md`. + +--- + +## File map + +- Create `desktop/modules/sbbuilder/Toml.js`: pure line editor: `entries`, `get`, `set`, `remove`. +- Create `desktop/modules/sbbuilder/test_toml.js`: node check. +- Create `desktop/modules/sbbuilder/Store.qml`: one draft, `get/set/value/put/entries/remove/markInvalid/discard/loaded`. +- Create `desktop/modules/sbbuilder/Sbb.qml`: singleton, `desktop` and `vm` Stores, FileView, ssh Processes. +- Create `desktop/modules/sbbuilder/SbbDesktopModule.qml`, `SbbVmModule.qml`: modules + pages. +- Create `desktop/modules/sbbuilder/README.md`. +- Modify `desktop/modules/qtmaildir/Setting.qml`, `BoolSetting.qml`: `property var store: Qtm`. +- Modify `desktop/shell.qml`: register the two modules (and force the qmldir rescan). +- Modify `desktop/README.md`: module list line. + +--- + +### Task 1: Hand the sb-builder half to its session + +- [ ] **Step 1: Send the message** + +`SendMessage` to `sb-builder-9a`: + +``` +From quickshell-86: Keystone's settings window is getting sb-builder pages +(spec: ~/Programming/GIT/quickshell/docs/superpowers/specs/2026-10-21-sb-builder-settings-design.md, +section "sb-builder's side"). Please build in sb-builder, after your current +Repository work is committed: + +1. sbb-remote config-get: prints {"text": <config file text, "" if missing>, + "defaults": DEFAULTS, "path": <config path>}. +2. sbb-remote config-set: new file text on stdin; tomllib parse, unknown key, + wrong type ([manifests] values str) all refused as {"error": ...} with the + file untouched; else old file copied to <path>.bak, new written to a temp + file in the same dir and renamed over. Both commands must work when the + current file fails load_config (that's how a broken config gets fixed), so + main() must not call load_config / makedirs state_dir before them. + Reuse load_config's checks for validation (e.g. split a check(data) out). +3. test_remote.py: accepted + .bak, bad TOML, unknown key, wrong type, + config-get with missing and with broken file. +4. GUI: QFileSystemWatcher on config.path() -> reload_config; re-add the path + in the handler (atomic writes replace the inode). Keep File > Reload config. +5. README: both configs have settings pages in Keystone; files stay hand-editable. +6. VERSION bump in remote/sbb-remote and sb_builder/__init__.py; ask the user + before ./install.sh --remote. + +Reply to quickshell-86 when deployed, with the exact config-get JSON shape. +``` + +No commit. Tasks 2-6 do not wait for the reply; Task 7 does. + +--- + +### Task 2: Toml.js line editor + +**Files:** +- Create: `desktop/modules/sbbuilder/test_toml.js` +- Create: `desktop/modules/sbbuilder/Toml.js` + +- [ ] **Step 1: Write the failing test** + +`desktop/modules/sbbuilder/test_toml.js`: + +```js +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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. + +// Run: node desktop/modules/sbbuilder/test_toml.js +// Toml.js is a QML library, so its .pragma line is stripped before node +// evaluates it. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/Toml.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const T = new Function(src + "\nreturn { entries, get, set, remove };")(); + +const file = [ + "# helper config", + "# Every key is optional.", + "", + 'state_dir = "/var/lib/x"', + 'overlays = ["one", "two"] # searched in order', + "# upstream_branch = 'current'", + "", + "# bundled deps: package = its deps list URL", + "[manifests]", + 'neovim = "https://example.org/v{VERSION}/deps.txt"', + '"gtk+3" = "https://example.org/gtk"', + "", + "[refresh]", + "jobs = 5", + "on = true", + "", +].join("\n"); + +// Top-level keys are section "". +assert.strictEqual(T.get(file, "", "state_dir"), "/var/lib/x"); +assert.deepStrictEqual(T.get(file, "", "overlays"), ["one", "two"]); +assert.strictEqual(T.get(file, "", "upstream_branch"), undefined); +assert.strictEqual(T.get(file, "refresh", "jobs"), 5); +assert.strictEqual(T.get(file, "refresh", "on"), true); +// A quoted key reads unquoted; a commented line with = is not a key. +assert.deepStrictEqual(T.entries(file, "manifests").map(e => e.key), ["neovim", "gtk+3"]); +assert.deepStrictEqual(T.entries(file, "").map(e => e.key), ["state_dir", "overlays"]); + +// Replacing keeps the padding-free form, the inline comment and the line count. +let t = T.set(file, "", "overlays", ["a"]); +assert.strictEqual(t.split("\n")[4], 'overlays = ["a"] # searched in order'); +assert.strictEqual(t.split("\n").length, file.split("\n").length); +// Setting a commented key activates its line, single-quoted or not. +t = T.set(file, "", "upstream_branch", "main"); +assert.strictEqual(t.split("\n")[5], 'upstream_branch = "main"'); +// Unsetting comments a key out in place, and it reads back as unset. +t = T.set(file, "refresh", "jobs", null); +assert.strictEqual(t.split("\n")[13], "# jobs = 5"); +assert.strictEqual(T.get(t, "refresh", "jobs"), undefined); +// ...and setting it again reactivates that line. +assert.strictEqual(T.set(t, "refresh", "jobs", 9).split("\n")[13], "jobs = 9"); +// A new top-level key lands after the last top-level key, not after the +// [manifests] comment, which belongs to its section. +t = T.set(file, "", "genreprc", "/root/.genreprc"); +assert.strictEqual(t.split("\n")[5], 'genreprc = "/root/.genreprc"'); +assert.strictEqual(T.get(t, "", "genreprc"), "/root/.genreprc"); +// A new key in a file with only comments goes after the leading comments. +t = T.set("# head\n# more\n", "", "x", 1); +assert.strictEqual(t, "# head\n# more\nx = 1\n"); +// A new key in a section lands after that section's last key. +t = T.set(file, "manifests", "foo.bar", "u"); +assert.strictEqual(t.split("\n")[11], '"foo.bar" = "u"'); +assert.strictEqual(T.get(t, "manifests", "foo.bar"), "u"); +// A missing section is appended, and an empty file gets no blank lead. +t = T.set(file, "vm", "domain", "x"); +assert.ok(t.endsWith('\n\n[vm]\ndomain = "x"\n'), JSON.stringify(t)); +assert.strictEqual(T.set("", "vm", "autostart", false), "[vm]\nautostart = false\n"); +assert.strictEqual(T.set("", "", "target", "h"), 'target = "h"\n'); +// Strings are escaped and read back the same. +t = T.set(file, "", "state_dir", 'a "b" \\ c'); +assert.strictEqual(T.get(t, "", "state_dir"), 'a "b" \\ c'); +// The same key in another section is untouched. +assert.strictEqual(T.set("[a]\nx = 1\n\n[b]\nx = 2\n", "b", "x", 3), "[a]\nx = 1\n\n[b]\nx = 3\n"); +// A value spanning lines is refused, never duplicated. +assert.throws(() => T.set("o = [\n \"a\",\n]\n", "", "o", ["b"]), /cannot edit/); + +// remove deletes the line outright. +t = T.remove(file, "manifests", "gtk+3"); +assert.deepStrictEqual(T.entries(t, "manifests").map(e => e.key), ["neovim"]); +assert.strictEqual(T.remove(file, "manifests", "nope"), file); + +console.log("test_toml: ok"); +``` + +- [ ] **Step 2: Run it, expect failure** + +Run: `node desktop/modules/sbbuilder/test_toml.js` +Expected: `ENOENT ... Toml.js`. + +- [ ] **Step 3: Write Toml.js** + +`desktop/modules/sbbuilder/Toml.js`: + +```js +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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. + +.pragma library + +// Pure helpers for sb-builder's two config.toml files, tested by +// test_toml.js under node. Walked a line at a time like Ini.js, so comments +// and order survive: a key unset is commented out with #, a commented key +// set again is activated. Top-level keys are section "". +// +// ponytail: the TOML these files use, one key per line: strings, integers, +// booleans, one-line arrays. A value spanning lines makes set() throw rather +// than write a duplicate key; such a file is edited by hand. + +const HEADER = /^\s*\[([A-Za-z0-9_.-]+)\]\s*(#.*)?$/; +const KEY = /^(\s*)(#\s*)?("(?:[^"\\]|\\.)*"|[A-Za-z0-9_-]+)\s*=\s*(.*)$/; + +function unkey(k) { + if (!k.startsWith('"')) return k; + try { return JSON.parse(k); } catch (e) { return k; } +} +function enkey(k) { return /^[A-Za-z0-9_-]+$/.test(k) ? k : enc(k); } + +// JSON's escapes are a subset of TOML basic strings', bar a raw DEL. +function enc(v) { + if (Array.isArray(v)) return "[" + v.map(enc).join(", ") + "]"; + if (typeof v === "string") return JSON.stringify(v).replace(/\x7f/g, "\\u007f"); + return String(v); +} + +function skip(s, i) { while (s[i] === " " || s[i] === "\t") i++; return i; } + +// {v, i}: the value at s[i] and the index after it, or null. +function val(s, i) { + const c = s[i]; + if (c === '"') { + let j = i + 1; + while (j < s.length && s[j] !== '"') j += s[j] === "\\" ? 2 : 1; + if (j >= s.length) return null; + try { return { v: JSON.parse(s.slice(i, j + 1)), i: j + 1 }; } catch (e) { return null; } + } + if (c === "'") { + const j = s.indexOf("'", i + 1); + return j < 0 ? null : { v: s.slice(i + 1, j), i: j + 1 }; + } + if (c === "[") { + const out = []; + i = skip(s, i + 1); + while (s[i] !== "]") { + const e = val(s, i); + if (!e) return null; + out.push(e.v); + i = skip(s, e.i); + if (s[i] === ",") i = skip(s, i + 1); + else if (s[i] !== "]") return null; + } + return { v: out, i: i + 1 }; + } + const m = /^(true|false|[+-]?[0-9][0-9_]*)(?=[\s#,\]]|$)/.exec(s.slice(i)); + if (!m) return null; + const v = m[1] === "true" ? true : m[1] === "false" ? false : Number(m[1].replace(/_/g, "")); + return { v: v, i: i + m[1].length }; +} + +// [{name, h, end}]: the header's line and the line after the section. The +// top-level keys are a section "" with its header at -1. +function ranges(lines) { + const out = [{ name: "", h: -1, end: lines.length }]; + lines.forEach((l, i) => { + const m = HEADER.exec(l); + if (!m) return; + out[out.length - 1].end = i; + out.push({ name: m[1], h: i, end: lines.length }); + }); + return out; +} + +// [{key, value}] for the active keys of a section, in file order. +function entries(text, section) { + const lines = text.split("\n"); + const r = ranges(lines).find(x => x.name === section); + if (!r) return []; + const out = []; + for (let i = r.h + 1; i < r.end; i++) { + const m = KEY.exec(lines[i]); + const p = m && !m[2] ? val(m[4], 0) : null; + if (p) out.push({ key: unkey(m[3]), value: p.v }); + } + return out; +} + +function get(text, section, key) { + const e = entries(text, section).find(x => x.key === key); + return e ? e.value : undefined; +} + +// v null unsets: the key is commented out where it stands. +function set(text, section, key, v) { + const lines = text.split("\n"); + const r = ranges(lines).find(x => x.name === section); + const line = enkey(key) + " = " + (v === null ? "" : enc(v)); + if (!r) { + if (v === null) return text; + return text.replace(/\n*$/, "") + (text.trim() ? "\n\n" : "") + "[" + section + "]\n" + line + "\n"; + } + let commented = -1; + let last = r.h; + for (let i = r.h + 1; i < r.end; i++) { + const m = KEY.exec(lines[i]); + if (!m) continue; + if (!m[2]) last = i; + if (unkey(m[3]) !== key) continue; + const p = val(m[4], 0); + if (!m[2]) { + if (!p) throw new Error(key + ": a value this page cannot edit; change it in the file"); + lines[i] = v === null ? m[1] + "# " + lines[i].slice(m[1].length) + : m[1] + line + m[4].slice(p.i); + return lines.join("\n"); + } + if (p && commented < 0) commented = i; + } + if (v === null) return text; + if (commented >= 0) { + const m = KEY.exec(lines[commented]); + lines[commented] = m[1] + line + m[4].slice(val(m[4], 0).i); + return lines.join("\n"); + } + // No key yet at the top: after the leading comments, not above them. + if (last < 0) while (last + 1 < r.end && lines[last + 1].trim().startsWith("#")) last++; + lines.splice(last + 1, 0, line); + return lines.join("\n"); +} + +// Deletes an active key's line outright, for [manifests] rows. +function remove(text, section, key) { + const lines = text.split("\n"); + const r = ranges(lines).find(x => x.name === section); + if (!r) return text; + for (let i = r.h + 1; i < r.end; i++) { + const m = KEY.exec(lines[i]); + if (m && !m[2] && unkey(m[3]) === key) { lines.splice(i, 1); return lines.join("\n"); } + } + return text; +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `node desktop/modules/sbbuilder/test_toml.js` +Expected: `test_toml: ok`. Fix the code, not the assertions, unless an assertion contradicts the spec. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/sbbuilder/Toml.js desktop/modules/sbbuilder/test_toml.js +git commit -m "sbbuilder: a line editor for sb-builder's TOML configs + +Same shape as qtmaildir's Ini.js, so a file the user annotated keeps its +comments: unset comments a key out, set reactivates it. A value spanning +lines throws instead of gaining a duplicate key, which TOML rejects. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 3: Setting and BoolSetting take a store + +**Files:** +- Modify: `desktop/modules/qtmaildir/Setting.qml` +- Modify: `desktop/modules/qtmaildir/BoolSetting.qml` + +- [ ] **Step 1: Setting.qml** + +Add after `property bool colour: false`: + +```qml + // The draft it edits: get, set, markInvalid, invalid. qtmaildir's by + // default; sbbuilder passes its own. + property var store: Qtm +``` + +Then replace every `Qtm.` in the file with `s.store.` and the Connections target: + +```qml + Connections { + target: s.store + function onInvalidChanged() { if (s.problem !== "" && !s.store.invalid[s.objectName]) { s.problem = ""; ti.text = s.value; } } + } +``` + +Update the header comment's first line to `// One labelled key of a settings file: a text field that edits the draft as`. + +- [ ] **Step 2: BoolSetting.qml** + +Add `property var store: Qtm` after `property string hint: ""`; `readonly property var raw: s.store.get(s.section, s.key)`; `onToggled: s.store.set(s.section, s.key, checked ? "true" : "false")`. + +- [ ] **Step 3: Check nothing else in qtmaildir broke** + +Run: `grep -n "Qtm\." desktop/modules/qtmaildir/Setting.qml desktop/modules/qtmaildir/BoolSetting.qml` +Expected: no output. +Run: `qs log -p ./desktop --log-times | tail -5` +Expected: a `Reloading configuration` line after the edit, no new warnings. Ask the user to open Settings, Mail and confirm fields still show values. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/qtmaildir/Setting.qml desktop/modules/qtmaildir/BoolSetting.qml +git commit -m "qtmaildir: Setting and BoolSetting take the draft as a store + +The sb-builder pages need the same fields over another file. The store +defaults to Qtm, so the Mail pages are unchanged. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: Store.qml and Sbb.qml + +**Files:** +- Create: `desktop/modules/sbbuilder/Store.qml` +- Create: `desktop/modules/sbbuilder/Sbb.qml` + +- [ ] **Step 1: Store.qml** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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 QtQuick +import "Toml.js" as T + +// One config.toml's draft, the store qtmaildir's Setting and BoolSetting +// edit. Fields hand it text; it writes the type of the key's default, the +// type sb-builder checks on load: a list is words split on spaces, a number +// is a number, a switch is true or false. +QtObject { + id: st + // The file as last read or written, the draft's start, and the draft. + property string text: "" + property string base: "" + property string draft: "" + readonly property bool dirty: st.draft !== st.base + // {section: {key: value}}, top-level keys under "". + property var defaults: ({}) + property var invalid: ({}) + readonly property bool valid: Object.keys(st.invalid).length === 0 + property string error: "" + // Assigned only when the set changes, as Qtm does: a Repeater bound to a + // fresh array per keystroke rebuilds the row being typed in. + property var manifests: [] + onDraftChanged: { + const k = T.entries(st.draft, "manifests").map(e => e.key); + if (JSON.stringify(k) !== JSON.stringify(st.manifests)) st.manifests = k; + } + + function fallback(section, key) { return (st.defaults[section] || {})[key]; } + function show(v) { return v === undefined ? undefined : Array.isArray(v) ? v.join(" ") : String(v); } + function placeholder(section, key) { return st.show(st.fallback(section, key)) ?? ""; } + + function get(section, key) { return st.show(T.get(st.draft, section, key)); } + // The effective value, typed: the file's, else the default. + function value(section, key) { return T.get(st.draft, section, key) ?? st.fallback(section, key); } + function entries(section) { return T.entries(st.draft, section); } + function put(section, key, v) { + try { st.draft = T.set(st.draft, section, key, v); } catch (e) { st.error = e.message; } + } + // A field's text; null or "" unsets. + function set(section, key, t) { + if (t === null || t === "") return st.put(section, key, null); + const d = st.fallback(section, key); + st.put(section, key, Array.isArray(d) ? t.split(/\s+/).filter(x => x !== "") + : typeof d === "number" ? Number(t) + : typeof d === "boolean" ? t === "true" : t); + } + function remove(section, key) { st.draft = T.remove(st.draft, section, key); } + + function markInvalid(id, bad) { + if (!!st.invalid[id] === bad) return; + const next = Object.assign({}, st.invalid); + if (bad) next[id] = true; else delete next[id]; + st.invalid = next; + } + + // The file as read: a clean draft follows it, a dirty one is kept and + // its save is refused. + function loaded(t) { + st.text = t; + if (!st.dirty) { st.base = t; st.draft = t; } + } + function discard() { + st.error = ""; + st.invalid = ({}); + st.base = st.text; + st.draft = st.text; + } +} +``` + +- [ ] **Step 2: Sbb.qml** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import "Toml.js" as T + +// sb-builder's two configs. The desktop one is a local file, watched; the +// GUI watches it too, so a save applies there at once. The VM one is root's +// file on the build VM, read and written only through the helper's +// config-get and config-set, which validate it: the page never writes it +// over ssh by hand. +Singleton { + id: root + + readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + + "/sb-builder/config.toml" + + // ponytail: mirrors DEFAULTS in sb-builder's sb_builder/config.py; a key + // added there needs adding here. + readonly property Store desktop: Store { + defaults: ({ + remote: { target: "buildsystem", ssh_options: [] }, + refresh: { dashboard: 30, jobs: 5 }, + notify: { enabled: true, events: ["finished", "failed", "killed", "lost", "vm-up"] }, + vm: { libvirt_uri: "qemu:///system", domain: "buildsystem", autostart: false }, + builds: { publish: true }, + hints: { nvchecker_keyfile: "~/.config/nvchecker/keys.toml" }, + repository: { path: "/repo" }, + }) + } + readonly property Store vm: Store {} + + // The VM side: reachable once a config-get answered; busy while one runs. + property bool reachable: false + property bool busy: false + property string vmPath: "" + + // From the desktop file as saved, not its draft: an unsaved target is + // not where the GUI connects. + function ssh(cmd) { + const r = T.get(root.desktop.text, "remote", "target") ?? root.desktop.defaults.remote.target; + const o = T.get(root.desktop.text, "remote", "ssh_options") ?? []; + return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", ...o, r, "sbb-remote", cmd]; + } + + function refresh() { + fv.reload(); + root.fetch(false); + } + + function saveDesktop() { + const d = root.desktop; + if (d.text !== d.base) { d.error = "changed on disk: Discard to load it, then redo"; return; } + if (d.draft === "") return; // FileView.setText("") writes nothing (AGENTS.md) + d.error = ""; + d.text = d.draft; + d.base = d.draft; + fv.setText(d.draft); + } + + // A save first re-reads the VM file: one changed since the draft began + // is refused rather than written over. + function saveVm() { + root.vm.error = ""; + root.fetch(true); + } + + function fetch(thenSave) { + get.thenSave = thenSave; + root.busy = true; + get.running = false; + get.running = true; + } + + FileView { + id: fv + path: root.path + atomicWrites: true + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.desktop.loaded(text()) + onLoadFailed: root.desktop.loaded("") + onSaveFailed: err => root.desktop.error = "cannot write: " + FileViewError.toString(err) + } + + Process { + id: get + property bool thenSave: false + command: root.ssh("config-get") + stdout: StdioCollector { id: getOut } + stderr: StdioCollector { id: getErr } + onExited: code => { + let d = null; + try { d = JSON.parse(getOut.text); } catch (e) {} + if (!d || d.error !== undefined) { + root.busy = false; + root.reachable = false; + root.vm.error = d ? d.error : (getErr.text.trim() || `ssh exited ${code}`); + return; + } + const defs = Object.assign({}, d.defaults); + const top = {}; + for (const k in defs) if (k !== "manifests") top[k] = defs[k]; + root.vm.defaults = { "": top, manifests: defs.manifests || {} }; + root.vmPath = d.path; + root.reachable = true; + if (!get.thenSave) { root.busy = false; root.vm.loaded(d.text); return; } + if (d.text !== root.vm.base) { + root.busy = false; + root.vm.text = d.text; + root.vm.error = "changed on the VM: Discard to load it, then redo"; + return; + } + put.running = false; + put.running = true; + } + } + + Process { + id: put + command: root.ssh("config-set") + stdinEnabled: true + stdout: StdioCollector { id: putOut } + stderr: StdioCollector { id: putErr } + onStarted: { put.write(root.vm.draft); put.stdinEnabled = false; } + onExited: code => { + root.busy = false; + put.stdinEnabled = true; + let d = null; + try { d = JSON.parse(putOut.text); } catch (e) {} + if (code === 0 && d && d.error === undefined) { + root.vm.text = root.vm.draft; + root.vm.base = root.vm.draft; + } else root.vm.error = d && d.error ? d.error : (putErr.text.trim() || `ssh exited ${code}`); + } + } +} +``` + +Note the `""` key of `vm.defaults`: `Store.fallback("", key)` reads `defaults[""][key]`. The desktop defaults have no `""` section, so desktop fields always name a section. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/sbbuilder/Store.qml desktop/modules/sbbuilder/Sbb.qml +git commit -m "sbbuilder: the two config drafts + +The desktop config is a watched local file; the VM one goes through the +helper's config-get and config-set over ssh, so the helper validates what +lands in root's file. Field text is typed after the key's default, the +same check sb-builder makes on load. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +(Loaded and checked in Task 6, once a module uses it.) + +--- + +### Task 5: Desktop page + +**Files:** +- Create: `desktop/modules/sbbuilder/SbbDesktopModule.qml` + +- [ ] **Step 1: Write the module** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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 QtQuick +import "../.." +import "../qtmaildir" + +// sb-builder's GUI config on this desktop. The GUI watches the file, so a +// save applies to a running sb-builder at once. +Module { + name: "sbb-desktop" + label: "Desktop" + // nf-fa-desktop, present in Inconsolata Nerd Font. + icon: "" + settings: true + group: "SB Builder" + dirty: Sbb.desktop.dirty + keywords: ["sb-builder", "slackbuild", "ssh", "refresh", "notifications", "libvirt", "publish", + "nvchecker", "repository"] + readonly property var fields: [ + ["remote", "target", "Target"], ["remote", "ssh_options", "ssh options"], + ["refresh", "dashboard", "Dashboard refresh"], ["refresh", "jobs", "Jobs refresh"], + ["notify", "enabled", "Notifications"], ["vm", "libvirt_uri", "libvirt URI"], + ["vm", "domain", "Domain"], ["vm", "autostart", "Start the VM"], + ["builds", "publish", "Publish"], ["hints", "nvchecker_keyfile", "nvchecker keyfile"], + ["repository", "path", "Repository"]] + searchItems: fields.map(f => ({ text: f[2], key: f[0] + "/" + f[1] })) + function refresh() { Sbb.refresh(); } + + page: Component { + Page { + title: "SB Builder: desktop" + actions: Component { + SaveBar { + dirty: Sbb.desktop.dirty; valid: Sbb.desktop.valid; error: Sbb.desktop.error + onSave: Sbb.saveDesktop(); onDiscard: Sbb.desktop.discard() + } + } + Column { + id: col + width: parent.width + spacing: 6 + readonly property var st: Sbb.desktop + Text { + width: col.width + wrapMode: Text.Wrap + text: Sbb.path + ". A running sb-builder rereads it on save." + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Heading { text: "Build VM connection" } + Setting { store: col.st; section: "remote"; key: "target"; label: "Target" + placeholder: col.st.placeholder("remote", "target"); hint: "Any ssh destination: a host alias or user@host." } + Setting { store: col.st; section: "remote"; key: "ssh_options"; label: "ssh options" + hint: "Extra ssh arguments, separated by spaces: -p 2222." } + + Heading { text: "Refresh" } + Setting { store: col.st; section: "refresh"; key: "dashboard"; label: "Dashboard refresh" + placeholder: col.st.placeholder("refresh", "dashboard"); kind: "int"; min: 1; max: 3600 + hint: "Seconds, while the Dashboard tab is visible." } + Setting { store: col.st; section: "refresh"; key: "jobs"; label: "Jobs refresh" + placeholder: col.st.placeholder("refresh", "jobs"); kind: "int"; min: 1; max: 3600 + hint: "Seconds; doubles up to 30 while the VM is down." } + + Heading { text: "Notifications" } + BoolSetting { store: col.st; section: "notify"; key: "enabled"; label: "Notifications"; fallback: true } + Repeater { + model: col.st.fallback("notify", "events") + delegate: Item { + id: ev + required property string modelData + objectName: "notify/events/" + modelData + width: col.width + height: 34 + Text { + id: evLabel + anchors { left: parent.left; leftMargin: 16; verticalCenter: parent.verticalCenter } + width: 144 + text: ev.modelData + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Switch { + anchors { left: evLabel.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + checked: col.st.value("notify", "events").includes(ev.modelData) + onToggled: { + const all = col.st.fallback("notify", "events"); + const on = col.st.value("notify", "events").filter(e => e !== ev.modelData); + if (checked) on.push(ev.modelData); + col.st.put("notify", "events", all.filter(e => on.includes(e))); + } + } + } + } + + Heading { text: "VM control" } + Setting { store: col.st; section: "vm"; key: "libvirt_uri"; label: "libvirt URI" + placeholder: col.st.placeholder("vm", "libvirt_uri") } + Setting { store: col.st; section: "vm"; key: "domain"; label: "Domain" + placeholder: col.st.placeholder("vm", "domain") } + BoolSetting { store: col.st; section: "vm"; key: "autostart"; label: "Start the VM"; fallback: false + hint: "without asking when it is off" } + + Heading { text: "Builds and hints" } + BoolSetting { store: col.st; section: "builds"; key: "publish"; label: "Publish"; fallback: true + hint: "the initial state of the publish box next to Run it" } + Setting { store: col.st; section: "hints"; key: "nvchecker_keyfile"; label: "nvchecker keyfile" + placeholder: col.st.placeholder("hints", "nvchecker_keyfile") + hint: "A GitHub token for nvchecker; optional." } + Setting { store: col.st; section: "repository"; key: "path"; label: "Repository" + placeholder: col.st.placeholder("repository", "path") + hint: "Local mount of slackrepo's package repository, for the Repository tab." } + } + } + } +} +``` + +Replace the Domain `Setting` above with a switch row plus the field: `domain = ""` disables VM control, but an emptied field unsets the key (default `buildsystem`). + +```qml + Item { + objectName: "vm/control" + width: col.width + height: 34 + Text { + id: ctlLabel + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "VM control" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Switch { + anchors { left: ctlLabel.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + // "" is how sb-builder spells no VM control; an + // emptied field means the default instead. + checked: col.st.value("vm", "domain") !== "" + onToggled: col.st.put("vm", "domain", checked ? null : "") + } + } + Setting { store: col.st; section: "vm"; key: "domain"; label: "Domain" + visible: col.st.value("vm", "domain") !== "" + placeholder: col.st.placeholder("vm", "domain"); hint: "The VM's libvirt domain." } +``` + +Add `["vm", "control", "VM control"]` to `fields`. + +- [ ] **Step 2: Commit after Task 6 checks it** (registered there). + +--- + +### Task 6: Register, VM page, check both pages load + +**Files:** +- Create: `desktop/modules/sbbuilder/SbbVmModule.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: Write SbbVmModule.qml** + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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 QtQuick +import "../.." +import "../qtmaildir" + +// sbb-remote's config on the build VM, through the helper's config-get and +// config-set. The helper reads it on every call, so a save applies to the +// next one. +Module { + name: "sbb-vm" + label: "Build VM" + // nf-fa-server, present in Inconsolata Nerd Font. + icon: "" + settings: true + group: "SB Builder" + dirty: Sbb.vm.dirty + keywords: ["sb-builder", "sbb-remote", "slackrepo", "hints", "overlays", "manifests", "phantom", "gpg"] + readonly property var fields: [ + ["state_dir", "State"], ["repo_dir", "SlackBuilds repo"], ["upstream_branch", "Upstream branch"], + ["slackrepo_db", "slackrepo database"], ["sbpull_script", "sbpull script"], ["overlays", "Overlays"], + ["slackrepo_repo", "slackrepo repo"], ["hintdir", "Hints"], ["default_hintdir", "Default hints"], + ["genreprc", "genreprc"], ["slackrepo_logdir", "slackrepo logs"], ["phantom_deps_file", "Phantom deps"]] + searchItems: fields.map(f => ({ text: f[1], key: "/" + f[0] })) + .concat(Sbb.vm.manifests.map(p => ({ text: p + " manifest", key: "manifests/" + p }))) + function refresh() { Sbb.refresh(); } + + page: Component { + Page { + title: "SB Builder: build VM" + actions: Component { + SaveBar { + dirty: Sbb.vm.dirty; valid: Sbb.vm.valid && Sbb.reachable; error: Sbb.vm.error + busy: Sbb.busy + onSave: Sbb.saveVm(); onDiscard: Sbb.vm.discard() + } + } + Column { + id: col + width: parent.width + spacing: 6 + readonly property var st: Sbb.vm + property string newPkg: "" + property string confirming: "" + Text { + width: col.width + wrapMode: Text.Wrap + text: Sbb.busy ? "Asking the VM..." + : Sbb.reachable ? Sbb.vmPath + " on the VM. The helper checks a save and keeps the old file as .bak." + : "The VM did not answer, so nothing here can change. Start it, then reopen this page." + color: Sbb.reachable || Sbb.busy ? Theme.subtext : Theme.yellow + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Column { + width: col.width + spacing: 6 + enabled: Sbb.reachable + opacity: Sbb.reachable ? 1 : 0.5 + Repeater { + model: [ + ["Paths"], + ["state_dir", "State", "Job logs and the lock."], + ["repo_dir", "SlackBuilds repo", ""], + ["slackrepo_db", "slackrepo database", ""], + ["slackrepo_logdir", "slackrepo logs", ""], + ["sbpull_script", "sbpull script", ""], + ["genreprc", "genreprc", "Its REPOSOWNERGPG names the signing key."], + ["Repository"], + ["upstream_branch", "Upstream branch", ""], + ["overlays", "Overlays", "Separated by spaces, in search order."], + ["slackrepo_repo", "slackrepo repo", ""], + ["Hints"], + ["hintdir", "Hints", ""], + ["default_hintdir", "Default hints", "Searched after Hints when set."], + ["phantom_deps_file", "Phantom deps", "Deps -current ships, one per line."]] + delegate: Loader { + id: row + required property var modelData + width: col.width + sourceComponent: row.modelData.length === 1 ? heading : field + Component { id: heading; Heading { text: row.modelData[0] } } + Component { + id: field + Setting { store: col.st; section: ""; key: row.modelData[0]; label: row.modelData[1] + hint: row.modelData[2]; placeholder: col.st.placeholder("", row.modelData[0]) } + } + } + } + + Heading { text: "Bundled deps" } + Text { + width: col.width + wrapMode: Text.Wrap + text: "A package's upstream deps list; {VERSION} is the new version. A hint update moves the package's extra downloads to the versions listed there." + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Repeater { + model: col.st.manifests + delegate: Item { + id: mrow + required property string modelData + objectName: "manifests/" + modelData + width: col.width + height: url.height + Setting { + id: url + width: parent.width - del.width - 8 + store: col.st; section: "manifests"; key: mrow.modelData; label: mrow.modelData + } + Button { + id: del + anchors { right: parent.right; top: parent.top; topMargin: 4 } + danger: true + text: col.confirming === mrow.modelData ? "Confirm" : "Remove" + onClicked: { + if (col.confirming === mrow.modelData) { col.confirming = ""; col.st.remove("manifests", mrow.modelData); } + else col.confirming = mrow.modelData; + } + } + } + } + Item { + width: col.width + height: 34 + Rectangle { + id: pkgBox + anchors { left: parent.left; right: add.left; rightMargin: 8; top: parent.top; bottom: parent.bottom } + radius: 8 + color: Theme.surface + TextInput { + id: pkg + activeFocusOnTab: true + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + onAccepted: add.clicked(null) + Text { + anchors.verticalCenter: parent.verticalCenter + visible: parent.text === "" + text: "package name, then its URL in the new row" + color: Theme.overlay + font: parent.font + } + } + } + Button { + id: add + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: "Add" + enabled: /^[A-Za-z0-9_.+-]+$/.test(pkg.text) && !col.st.manifests.includes(pkg.text) + onClicked: { + if (!add.enabled) return; + col.st.put("manifests", pkg.text, "https://"); + pkg.text = ""; + } + } + } + } + } + } + } +} +``` + +Check that `Button.qml` emits `clicked(var mouse)`; `add.clicked(null)` relies on that signature. If `Button` ignores clicks while `enabled` is false itself, the guard in `onClicked` is still kept for the Enter path. + +- [ ] **Step 2: Register both modules in desktop/shell.qml** + +Run: `grep -n "Module {" desktop/shell.qml | head -40` and find where qtmaildir's `MailGeneralModule {}` etc. are listed, plus the import of `modules/qtmaildir`. Add, next to them: + +```qml +import "modules/sbbuilder" +``` + +and in the module list, after the last Mail module: + +```qml + SbbDesktopModule {} + SbbVmModule {} +``` + +Editing `shell.qml` is also what makes the running shell rescan the new directory (AGENTS.md: hot reload does not pick up a new file otherwise). + +- [ ] **Step 3: Check it loads** + +Run: `stat -c %y desktop/shell.qml; qs log -p ./desktop --log-times | tail -20` +Expected: a `Reloading configuration` line after the mtime, no `ReferenceError`, `TypeError` or `Unable to assign` for sbbuilder files. If no reload line followed, change another line in `shell.qml` (AGENTS.md: touch alone does not reload); if still nothing, ask the user to run `./reload.sh desktop`. + +Run: `qs -p ./desktop ipc call settings open sbb-desktop` +Ask the user: do the fields show the current config.toml values (or placeholders when unset), and do Save, Discard and the event switches behave? Then `qs -p ./desktop ipc call settings open sbb-vm`: until the helper commands are deployed it should show the yellow "did not answer" line with ssh's or the helper's usage error in the SaveBar. That is the expected state before Task 7. + +- [ ] **Step 4: Desktop save, end to end** + +With the user's go-ahead, change Jobs refresh to 6 in the page, Save, then: + +Run: `grep -n "jobs" ~/.config/sb-builder/config.toml` +Expected: `jobs = 6` (or the line updated in place with its comment kept). Set it back by emptying the field and saving; expect `# jobs = 6`. Confirm with the user that a running sb-builder picked it up once the peer session's watcher is in. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/sbbuilder/SbbDesktopModule.qml desktop/modules/sbbuilder/SbbVmModule.qml desktop/shell.qml +git commit -m "sbbuilder: Desktop and Build VM settings pages + +Under SB Builder in the settings window. The desktop page edits +sb-builder's config.toml in place; VM control is a switch because an +emptied field means the default domain, not \"\". The VM page is +read-only until the helper answers config-get. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 7: VM page end to end (after sb-builder-9a reports the helper deployed) + +- [ ] **Step 1: Check the JSON shape matches** + +Run: `ssh -o BatchMode=yes buildsystem sbb-remote config-get | python3 -c 'import json,sys; d=json.load(sys.stdin); print(sorted(d), sorted(d["defaults"]))'` +Expected: `['defaults', 'path', 'text']` and the thirteen keys plus `manifests`. If the peer's shape differs, adjust `Sbb.qml`'s `get.onExited`, not the helper. + +- [ ] **Step 2: Round trip, with the user** + +Open `sbb-vm`. Ask the user to change Default hints to a test value and Save, then: +Run: `ssh buildsystem 'grep -n default_hintdir /etc/sb-builder/config.toml; ls -l /etc/sb-builder/config.toml.bak'` +Expected: the new value, and a `.bak`. Then empty the field, Save, expect `# default_hintdir = ...`. + +- [ ] **Step 3: Refusals** + +Manifest row: ask the user to add `testpkg`, set its URL, Save; run `ssh buildsystem sbb-remote config-get` and confirm the row is in `text`; Remove it, Save again. + +Changed on the VM: with a dirty draft open, ask before editing the VM file, then run `ssh buildsystem "sed -i '1i # touched' /etc/sb-builder/config.toml"` and Save in the page. Expected: "changed on the VM" in the SaveBar and no write. Undo with `ssh buildsystem "sed -i '1{/^# touched$/d}' /etc/sb-builder/config.toml"`, then Discard. + +- [ ] **Step 4: Commit any fixes** with a message saying what the round trip found. + +--- + +### Task 8: Docs + +**Files:** +- Create: `desktop/modules/sbbuilder/README.md` +- Modify: `desktop/README.md` (module list) + +- [ ] **Step 1: README.md** + +```markdown +# sbbuilder + +Two settings pages, under SB Builder, for sb-builder's two configs: + +- **Desktop**: the GUI's `$XDG_CONFIG_HOME/sb-builder/config.toml`, edited + in place. sb-builder watches the file, so a save applies to a running GUI. + VM control is a switch: sb-builder spells "no VM control" as + `domain = ""`, and an emptied field means the default instead. +- **Build VM**: the helper's `/etc/sb-builder/config.toml`, read with + `ssh <target> sbb-remote config-get` and written with `config-set`, the + draft on stdin. The helper validates it and keeps the old file as `.bak`; + the page never writes it over ssh by hand. `<target>` and the ssh options + come from the desktop config as saved. With the VM down the page is + read-only. + +Both follow the settings window's draft rules (`desktop/README.md`). A field +is typed after its default: lists are words split on spaces (a list entry +holding a space is a `ponytail:` ceiling: edit the file), numbers are +range-checked. The desktop defaults are copied from sb-builder's +`config.py`, a `ponytail:` ceiling in `Sbb.qml`; the VM ones come from the +helper. + +`Toml.js` edits a line at a time so comments survive, as qtmaildir's +`Ini.js` does: unset comments a key out with `#`, set reactivates it. It +reads one-line values only and refuses to edit one that spans lines. The +fields are qtmaildir's `Setting` and `BoolSetting`, given a `store`. + +Check: `node desktop/modules/sbbuilder/test_toml.js`. +``` + +- [ ] **Step 2: desktop/README.md** + +After the `modules/qtmaildir/` line add: + +``` + modules/sbbuilder/ sb-builder's GUI config and the build VM helper's config +``` + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/sbbuilder/README.md desktop/README.md +git commit -m "docs: the sbbuilder settings pages + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` |
