// Sync & Backup. // // Three answers to three different questions, which is why they sit together: // carrying settings to a different machine, putting this machine's settings // back as they were, and throwing them away. // // The backups here are copies of the preferences file. They are not the btrfs // 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 import qs.services SettingsPage { id: root objectName: "sync" title: "Sync & Backup" lede: "Carry this desktop with you, and come back from anything." // "restore:", "delete:", 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 // anything that describes hardware. SettingsCard { title: "Carry settings to another machine" subtitle: SettingsSync.lastError !== "" ? SettingsSync.lastError : "Everything except what describes this machine: the display arrangement stays here." ActionRow { label: "Export" detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0 ? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath : "Writes " + SettingsSync.defaultPath action: "Export" enabled: !SettingsSync.busy onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath) } ActionRow { label: "See what an import would change" detail: SettingsSync.previewed ? 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.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 : [] TextRow { required property var modelData label: String(modelData.key ?? "") detail: "Skipped: " + String(modelData.reason ?? "") value: "" } } TextRow { visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === "" label: SettingsSync.applied === 0 ? "Nothing needed changing" : SettingsSync.applied + " settings applied" detail: "From " + (SettingsSync.exportedFrom || "the export") value: "" divider: false } } // ── 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." // 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" placeholder: "Name (optional)" action: SettingsBackup.busy ? "Working…" : "Back up" enabled: !SettingsBackup.busy divider: SettingsBackup.snapshots.length > 0 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 SettingRow { id: backupRow required property var modelData required property int index 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 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" label: "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 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"; } }