394 lines
16 KiB
QML
394 lines
16 KiB
QML
// 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:<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
|
||
// 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";
|
||
}
|
||
}
|