Finish the wonderland: System told truthfully, in eight tabs instead of ten

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 23:31:52 -04:00
parent 9ffaf45a4d
commit be0e55214b
57 changed files with 5040 additions and 925 deletions
+158
View File
@@ -48,6 +48,40 @@ Singleton {
readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true
readonly property bool everChecked: root.checkedAt > 0
// How much there is to fetch, when every pending item was priced. The
// helper omits the figure for a source it could only partly price, and a
// partial total presented as the whole download understates it -- which is
// the direction that surprises somebody on a metered connection.
readonly property int downloadBytes: Number(root.dnf?.downloadBytes ?? 0)
+ Number(root.flatpak?.downloadBytes ?? 0)
+ Number(root.firmware?.downloadBytes ?? 0)
readonly property string downloadSize: root.formatBytes(root.downloadBytes)
// Decimal units, matching the Storage page and About's disk row, and the
// way both dnf and flatpak report sizes themselves.
function formatBytes(bytes: int): string {
const value = Number(bytes ?? 0);
if (!(value > 0))
return "";
const units = ["B", "KB", "MB", "GB", "TB"];
let scaled = value;
let index = 0;
while (scaled >= 1000 && index < units.length - 1) {
scaled /= 1000;
index += 1;
}
return (scaled < 10 && index > 1 ? scaled.toFixed(1) : Math.round(scaled))
+ " " + units[index];
}
function sourceDownloadSize(source: string): string {
const record = source === "dnf" ? root.dnf
: source === "flatpak" ? root.flatpak
: source === "firmware" ? root.firmware : null;
return root.formatBytes(Number(record?.downloadBytes ?? 0));
}
// What has actually been installed, newest first, from both sources at
// once. Loaded on demand rather than with the snapshot: it asks dnf and
// flatpak for their whole transaction log, which is not worth doing every
@@ -155,6 +189,100 @@ Singleton {
applyProcess.running = true;
}
// One application rather than all of them. The ID is checked against the
// last scan by the helper, so a page that has gone stale cannot ask for
// something that is not actually waiting.
function applyFlatpakApp(id: string): void {
if (applyProcess.running || !id)
return;
root.lastError = "";
root.lastApplied = null;
applyProcess.command = [root.helperPath, "apply", "flatpak", id];
applyProcess.running = true;
}
// ── Changelogs, fetched once per item ───────────────────────────────────
//
// Asking dnf what changed costs a metadata load, so an expander that
// re-asked every time it opened would spend seconds re-learning the same
// answer. Records are keyed "source/name" and kept for the life of the
// shell; the update they describe cannot change while it is pending.
property var changelogs: ({})
// Bumped whenever a record lands, and read at the top of changelogFor() so
// a binding built on that call has something to invalidate. A bare
// function call captures no dependency and every reader would go stale --
// the same reason DesktopPreferences.get() reads its revision.
property int changelogRevision: 0
// [{source, name}] waiting their turn. One at a time rather than one
// process per expander: each fetch loads repository metadata, and running
// several concurrently would multiply that work for no benefit -- nobody
// reads two changelogs at once.
property var changelogQueue: []
readonly property bool loadingChangelog: changelogProcess.running
// Returns the record for an item, or null while one is being fetched.
// Starting the fetch is a side effect on purpose: the page asks for a
// changelog by rendering one, and there is nothing else to ask.
function changelogFor(source: string, name: string): var {
root.changelogRevision;
const key = source + "/" + name;
const known = root.changelogs[key];
if (known !== undefined)
return known;
if (!source || !name)
return null;
if (changelogProcess.key === key
|| root.changelogQueue.some(entry => entry.source + "/" + entry.name === key))
return null;
root.changelogQueue = root.changelogQueue.concat([{ source: source, name: name }]);
root.pumpChangelogs();
return null;
}
function pumpChangelogs(): void {
if (changelogProcess.running || root.changelogQueue.length === 0)
return;
const next = root.changelogQueue[0];
root.changelogQueue = root.changelogQueue.slice(1);
changelogProcess.key = next.source + "/" + next.name;
changelogProcess.outputText = "";
changelogProcess.exited = false;
changelogProcess.streamFinished = false;
changelogProcess.command = [root.helperPath, "changelog", next.source, next.name];
changelogProcess.running = true;
}
function settleChangelog(): void {
if (!changelogProcess.exited || !changelogProcess.streamFinished
|| changelogProcess.key === "")
return;
root.absorbChangelog(changelogProcess.key, changelogProcess.outputText);
changelogProcess.key = "";
root.pumpChangelogs();
}
function absorbChangelog(key: string, text: string): void {
let record = { kind: "none", text: "", error: "The changelog could not be read." };
try {
const parsed = JSON.parse(text);
record = {
kind: String(parsed.kind ?? "none"),
text: String(parsed.text ?? ""),
error: String(parsed.error ?? "")
};
} catch (error) {
console.warn("Updates: could not parse changelog output:", error);
}
const next = Object.assign({}, root.changelogs);
next[key] = record;
root.changelogs = next;
root.changelogRevision += 1;
}
function setAutomaticDnf(enabled: bool): void {
if (applyProcess.running)
return;
@@ -220,4 +348,34 @@ Singleton {
id: historyProcess
stdout: StdioCollector { onStreamFinished: root.absorbHistory(this.text) }
}
Process {
id: changelogProcess
// Which record the output belongs to. Held on the process rather than
// read back from the payload so a reply that failed to name itself
// still lands under the key that was asked for, instead of silently
// going nowhere and leaving the expander spinning forever.
property string key: ""
// Exit and stream-close arrive in either order. Settling on both --
// the same pair Health.qml waits on -- is what stops a reply being
// filed under an already-cleared key, which would leave the expander
// waiting on an answer that had in fact already arrived.
property string outputText: ""
property bool exited: false
property bool streamFinished: false
stdout: StdioCollector {
onStreamFinished: {
changelogProcess.outputText = this.text;
changelogProcess.streamFinished = true;
root.settleChangelog();
}
}
onExited: (exitCode, exitStatus) => {
changelogProcess.exited = true;
root.settleChangelog();
}
}
}