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
@@ -8,6 +8,12 @@
// snapshots one tab over in System Snapshots, which put the whole filesystem
// back -- the card says so, because the two used to share the word "Snapshots"
// and nothing distinguished them.
//
// Everything on this page that cannot be undone asks twice. Restoring a backup
// and restoring defaults both used to happen on a single press, which is the
// one interaction on this page nobody can take back: the two-stage confirm is
// the same one Snapshots and Privacy use, where the first press only arms the
// second and the second is the one wearing the danger colour.
import QtQuick
import qs.config
@@ -16,8 +22,35 @@ import qs.services
SettingsPage {
id: root
objectName: "sync"
title: "Sync & Backup"
lede: "Carry your settings to another machine, keep copies of them, or start over."
lede: "Carry this desktop with you, and come back from anything."
// "restore:<name>", "delete:<name>", or "reset". One at a time: two armed
// destructive actions on screen at once is how the wrong one gets pressed.
property string confirming: ""
function arm(token: string): void {
root.confirming = root.confirming === token ? "" : token;
}
// Diff values come from a JSON file somebody may have edited by hand, so
// they are whatever they are: booleans, numbers, objects, missing. Long
// ones are cut rather than allowed to push the row off the card.
function shortValue(value: var): string {
if (value === null || value === undefined)
return "not set";
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
return text.length > 44 ? text.slice(0, 43) + "…" : text;
}
// The helper already caps what it hands over and reports the true total
// separately; this caps again for the rows, so the count in the copy is
// the real one either way.
readonly property int previewLimit: 8
readonly property var shownChanges:
(SettingsSync.changes ?? []).slice(0, root.previewLimit)
// Distinct from the backups below, which put THIS machine back as it was.
// This carries settings to a different one, and deliberately leaves behind
@@ -41,32 +74,122 @@ SettingsPage {
ActionRow {
label: "See what an import would change"
detail: SettingsSync.previewed
? SettingsSync.changes.length + " would change, "
? SettingsSync.changeCount + " would change, "
+ SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview"
enabled: !SettingsSync.busy
divider: !SettingsSync.previewed
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
}
// Key by key, old value and new. "14 would change" is a number nobody
// can act on; this is the same information in the form somebody can
// actually read before agreeing to it.
Item {
width: parent.width
visible: SettingsSync.previewed && SettingsSync.changeCount > 0
implicitHeight: visible ? diffFrame.implicitHeight + 18 : 0
Rectangle {
id: diffFrame
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 4
implicitHeight: diffRows.implicitHeight + 20
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
Column {
id: diffRows
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 10
spacing: 0
Repeater {
model: root.shownChanges
Column {
required property var modelData
width: parent.width
spacing: 0
bottomPadding: 6
Text {
width: parent.width
text: " " + String(modelData.key ?? "")
+ " " + root.shortValue(modelData.from)
color: Theme.danger
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
width: parent.width
text: "+ " + String(modelData.key ?? "")
+ " " + root.shortValue(modelData.to)
color: Theme.ok
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
Text {
width: parent.width
visible: SettingsSync.changeCount > root.shownChanges.length
text: "· " + (SettingsSync.changeCount - root.shownChanges.length)
+ " more would change"
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: SettingsSync.skipped.length > 0
text: "· " + SettingsSync.skipped.length
+ " skipped — they describe this machine rather than your taste"
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
// Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their
// desktop changed.
ActionRow {
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
label: "Apply those " + SettingsSync.changes.length + " changes"
visible: SettingsSync.previewed && SettingsSync.changeCount > 0
label: "Apply those " + SettingsSync.changeCount + " changes"
detail: "Settings the file does not mention are left alone"
action: "Import"
enabled: !SettingsSync.busy
divider: false
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
}
Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : []
delegate: TextRow {
TextRow {
required property var modelData
width: parent.width
label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "")
value: ""
@@ -84,51 +207,187 @@ SettingsPage {
}
}
// ── Backups ──────────────────────────────────────────────────────────────
SettingsCard {
title: "Settings backups"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Copies of your preferences, not of the filesystem — System Snapshots keeps the btrfs ones. Your whole desktop configuration is a single file, so a backup is a copy of it. Restoring also backs up what it replaces, so it is itself undoable."
ActionRow {
// Naming one is optional and worth it. "Before the displays
// experiment" is findable a week later; a timestamp is not.
FieldActionRow {
id: nameField
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No backups yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept") + ", newest first"
action: "Back up now"
: SettingsBackup.snapshots.length
+ (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept")
+ ", newest first"
placeholder: "Name (optional)"
action: SettingsBackup.busy ? "Working…" : "Back up"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
onTriggered: SettingsBackup.save()
onSubmitted: value => {
// An unnamed backup is the plain save the automatic path uses;
// naming one goes through create, which is the verb that knows
// how to sanitise a name.
if (value === "")
SettingsBackup.save();
else
SettingsBackup.create(value);
nameField.clear();
}
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
ActionRow {
SettingRow {
id: backupRow
required property var modelData
required property int index
label: modelData.when
detail: modelData.keys + " settings"
action: "Restore"
enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1
onTriggered: SettingsBackup.restore(modelData.name)
readonly property string backupName: String(backupRow.modelData.name ?? "")
readonly property bool confirmingRestore:
root.confirming === "restore:" + backupRow.backupName
readonly property bool confirmingDelete:
root.confirming === "delete:" + backupRow.backupName
// The helper names a file by its timestamp with whatever the
// person called it appended, so what is left after the
// timestamp is the name they gave it. An unnamed backup falls
// back to when it was taken rather than showing a filename.
readonly property string displayName: {
const given = String(backupRow.modelData.label ?? "");
if (given !== "")
return given;
const stem = backupRow.backupName.replace(/\.json$/, "");
const named = stem
.replace(/^[0-9][0-9T:_.\-]*/, "")
.replace(/^[-_]+/, "")
.replace(/[-_]+/g, " ")
.trim();
return named === "" ? String(backupRow.modelData.when ?? stem) : named;
}
width: parent.width
label: backupRow.displayName
detail: {
const when = String(backupRow.modelData.when ?? "");
const parts = when === backupRow.displayName ? [] : [when];
if (backupRow.modelData.keys)
parts.push(backupRow.modelData.keys + " settings");
const bytes = Number(backupRow.modelData.bytes ?? 0);
if (bytes > 0)
parts.push(root.formatBytes(bytes));
if (backupRow.confirmingRestore)
parts.push("Restoring replaces every setting on this machine, and reloads the desktop");
if (backupRow.confirmingDelete)
parts.push("Deleting a backup cannot be undone");
return parts.filter(part => part !== "").join(" · ");
}
controlWidth: 250
divider: backupRow.index < snapshotRows.count - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
visible: !backupRow.confirmingDelete
text: backupRow.confirmingRestore ? "Cancel" : "Restore…"
enabled: !SettingsBackup.busy
onClicked: root.arm("restore:" + backupRow.backupName)
}
SettingsButton {
visible: backupRow.confirmingRestore
text: "Restore it"
tone: "danger"
enabled: !SettingsBackup.busy
onClicked: {
root.confirming = "";
SettingsBackup.restore(backupRow.backupName);
}
}
SettingsButton {
visible: !backupRow.confirmingRestore
text: backupRow.confirmingDelete ? "Cancel" : "Delete…"
enabled: !SettingsBackup.busy
onClicked: root.arm("delete:" + backupRow.backupName)
}
SettingsButton {
visible: backupRow.confirmingDelete
text: "Delete it"
tone: "danger"
enabled: !SettingsBackup.busy
onClicked: {
root.confirming = "";
SettingsBackup.deleteBackup(backupRow.backupName);
}
}
}
}
}
}
// ── Reset ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Reset"
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
subtitle: "Restores the appearance and theme, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
SettingRow {
id: resetRow
readonly property bool armed: root.confirming === "reset"
ActionRow {
label: "Restore defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
detail: resetRow.armed
? "Every setting goes back to how Panama ships, immediately and including the compositor. A backup is taken first, so this is itself undoable from the card above."
: "Applies immediately, including to the compositor. A backup is taken first."
controlWidth: 240
divider: false
onTriggered: SystemSettings.restoreDefaults()
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: resetRow.armed ? "Cancel" : "Reset…"
onClicked: root.arm("reset")
}
SettingsButton {
visible: resetRow.armed
text: "Restore defaults"
tone: "danger"
onClicked: {
root.confirming = "";
SystemSettings.restoreDefaults();
}
}
}
}
}
// Decimal units, the way both the Storage page and About report sizes.
function formatBytes(bytes: real): string {
if (!(bytes > 0))
return "";
if (bytes >= 1e6)
return (bytes / 1e6).toFixed(1) + " MB";
if (bytes >= 1e3)
return Math.round(bytes / 1e3) + " KB";
return Math.round(bytes) + " B";
}
}