Files
Panama/config/dot/quickshell/modules/settings/SyncPage.qml
T

410 lines
18 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 color.
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"
// Every claim here is checked against PreferenceSchema.defaults(), since
// a reset is exactly the schema store going back to it. The dock's
// pinned applications ARE a schema key (dockPinned), so the shipped
// sixteen come back — this sentence used to promise the opposite. Saved
// themes (themeProfiles) are a schema key too, and go with them. Files
// and Bluetooth pairings are not in the schema and not in the Home
// store, and backups live in their own state directory, so all three
// genuinely survive.
subtitle: "Restores the appearance and theme, the dock and the applications pinned to it, the clock, focus, and display policy, removes any themes you saved, and clears your Home accessory arrangement. Your files, paired devices, and settings backups are not changed."
SettingRow {
id: resetRow
// Stays armed while the safety backup is still being written, so
// the confirmation does not collapse into a page that looks
// untouched during the one window where nothing has happened yet.
readonly property bool armed: root.confirming === "reset" || SystemSettings.resetPending
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…"
enabled: !SystemSettings.resetPending
onClicked: root.arm("reset")
}
SettingsButton {
visible: resetRow.armed
// Nothing is wiped until the backup lands, so the button
// stays occupied for as long as that takes rather than
// looking like it did nothing.
text: SystemSettings.resetPending ? "Backing up…" : "Restore defaults"
tone: "danger"
enabled: !SystemSettings.resetPending && !SettingsBackup.busy
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";
}
}