Appearance was six cards deep and Light/Dark was the third of them, below the wallpaper grid and the entire lock screen -- so the control reached most often was the last one you got to. It is five tabs now, Theme first. The mock showed four; the page turned out to have eleven cards, so Titlebars and Windows became Windows, and Clock and vitals became Shell, rather than pretending four would hold them. Region, Date & Time and Displays each rendered a complete dataset as rows: every installed locale, the whole tz database, every mode the monitor advertises. The chooser was never the problem -- SearchPicker already existed and worked. It was simply rendered always-expanded, so the one line saying what is currently set sat under hundreds that were not. PickerRow collapses each behind its current value and closes again once something is picked. The avatar never appeared to change because accountsservice writes every picture to the same path, leaving the URL byte-identical while Qt served its cached image. cache:false was already set and could not have helped: an unchanged source is never re-read at all. avatarUrl now carries a revision fragment, bumped only when a write actually succeeds. Pictures are cropped before they are set, in the picture's own pixel coordinates so the result does not depend on the size it happened to be displayed at, and written out at 512x512 through GdkPixbuf -- already a dependency here, so nothing new is required. Snapshots listed nothing. The timeline and its Delete buttons existed the whole time, behind a row labelled "Browse...", a word that promises a file browser. The three most recent points are shown inline now, with the rest one press away. qmldir-registration-contract exists because an unregistered component is not a quiet problem: Quickshell fails the entire configuration on it, so the settings window dies and the bar and dock go with it. That happened twice while writing this, both times on a machine somebody was using. It is pure file inspection, so it runs before a change ever reaches the running shell. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
373 lines
16 KiB
QML
373 lines
16 KiB
QML
// Snapshots: what is protected, and how to get something back.
|
|
//
|
|
// Per volume, because on this machine the news was that one volume was covered
|
|
// and the important one was not -- six hundred snapshots of the system, none of
|
|
// anyone's documents. A timeline that opened on all those snapshots would have
|
|
// buried that.
|
|
//
|
|
// Inside a volume, the timeline is the Time Machine view: points in time,
|
|
// newest first, each one openable as a folder tree you can take a file out of.
|
|
//
|
|
// Rollback is deliberately absent. snapper's rollback changes the btrfs default
|
|
// subvolume, and this system's fstab pins subvol= explicitly, which overrides
|
|
// it -- so it would report success and change nothing after a reboot.
|
|
|
|
import Quickshell
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
objectName: "snapshots"
|
|
title: "Snapshots"
|
|
lede: "Points in time you can go back to, taken automatically for each volume."
|
|
|
|
// Points in time shown without asking. The page used to show none: the
|
|
// timeline and its Delete buttons sat behind a row labelled "Browse…", a
|
|
// word that promises a file browser, so a page about points in time
|
|
// appeared to list only how many there were.
|
|
readonly property int previewCount: 3
|
|
|
|
property string openConfig: ""
|
|
property string confirmingDelete: ""
|
|
property string confirmingRestore: ""
|
|
|
|
readonly property bool browsingOpen: Snapshots.browsingConfig !== ""
|
|
|
|
Component.onCompleted: Snapshots.refresh()
|
|
|
|
TextRow {
|
|
visible: Snapshots.lastError !== ""
|
|
label: "Snapshots need attention"
|
|
detail: Snapshots.lastError
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
// What just happened to the file that was already there.
|
|
TextRow {
|
|
visible: Snapshots.lastRestore !== null
|
|
label: "Restored"
|
|
detail: Snapshots.lastRestore
|
|
? String(Snapshots.lastRestore.restored ?? "")
|
|
+ (String(Snapshots.lastRestore.keptAs ?? "") !== ""
|
|
? " — the version that was there was kept as "
|
|
+ String(Snapshots.lastRestore.keptAs).split("/").pop()
|
|
: "")
|
|
: ""
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
// ── Anything unprotected is the headline ─────────────────────────────────
|
|
|
|
SettingsCard {
|
|
visible: Snapshots.unprotected.length > 0
|
|
title: "Not protected"
|
|
subtitle: "These volumes have no snapshot configuration, so nothing on them can be recovered."
|
|
|
|
Repeater {
|
|
model: Snapshots.unprotected
|
|
|
|
delegate: TextRow {
|
|
required property var modelData
|
|
width: parent.width
|
|
label: String(modelData.path ?? "")
|
|
detail: "btrfs subvolume " + String(modelData.subvolume ?? "")
|
|
+ " · needs a configuration, which takes a password once"
|
|
value: "Unprotected"
|
|
divider: false
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── One card per volume ──────────────────────────────────────────────────
|
|
|
|
Repeater {
|
|
model: Snapshots.configs
|
|
|
|
delegate: SettingsCard {
|
|
id: volumeCard
|
|
|
|
required property var modelData
|
|
|
|
readonly property string configName: String(volumeCard.modelData.name ?? "")
|
|
readonly property var snapshots: volumeCard.modelData.snapshots ?? []
|
|
readonly property bool open: root.openConfig === volumeCard.configName
|
|
readonly property int shownCount: volumeCard.open
|
|
? volumeCard.snapshots.length
|
|
: Math.min(root.previewCount, volumeCard.snapshots.length)
|
|
|
|
title: Snapshots.labelFor(volumeCard.modelData)
|
|
subtitle: String(volumeCard.modelData.subvolume ?? "")
|
|
|
|
SwitchRow {
|
|
label: "Take snapshots automatically"
|
|
detail: volumeCard.modelData.timelineEnabled
|
|
? Snapshots.describe(volumeCard.modelData)
|
|
: "Nothing is being taken for this volume"
|
|
checked: volumeCard.modelData.timelineEnabled === true
|
|
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
|
|
onToggled: value => Snapshots.setTimeline(volumeCard.configName, value)
|
|
}
|
|
|
|
TextRow {
|
|
label: "Keep"
|
|
detail: "Older points are removed automatically once these counts are exceeded"
|
|
value: Snapshots.retentionSummary(volumeCard.modelData)
|
|
}
|
|
|
|
ActionRow {
|
|
label: "Take one now"
|
|
detail: "Kept until you remove it, unlike the automatic ones"
|
|
action: "Take snapshot"
|
|
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
|
|
onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings")
|
|
}
|
|
|
|
TextRow {
|
|
visible: volumeCard.snapshots.length === 0
|
|
label: "No points in time yet"
|
|
detail: "One is taken automatically on the schedule above."
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
// ── The timeline ─────────────────────────────────────────────────
|
|
|
|
Column {
|
|
width: parent.width
|
|
visible: !root.browsingOpen
|
|
|
|
Repeater {
|
|
model: volumeCard.open
|
|
? volumeCard.snapshots
|
|
: volumeCard.snapshots.slice(0, root.previewCount)
|
|
|
|
delegate: SettingRow {
|
|
id: pointRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property string token: volumeCard.configName + ":" + pointRow.modelData.number
|
|
readonly property bool confirming: root.confirmingDelete === pointRow.token
|
|
|
|
width: parent.width
|
|
// A kept snapshot is one the timeline will not remove,
|
|
// which is the distinction that matters when choosing
|
|
// what to rely on later.
|
|
icon: pointRow.modelData.kept ? "\u{F0A22}" : "\u{F0954}"
|
|
label: String(pointRow.modelData.date ?? "")
|
|
detail: String(pointRow.modelData.description ?? "")
|
|
+ " · #" + pointRow.modelData.number
|
|
+ (pointRow.modelData.kept ? " · kept" : "")
|
|
controlWidth: 250
|
|
divider: pointRow.index < volumeCard.shownCount - 1
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
|
|
SettingsButton {
|
|
text: "Open"
|
|
enabled: !Snapshots.browsing
|
|
onClicked: Snapshots.browse(volumeCard.configName,
|
|
Number(pointRow.modelData.number), "")
|
|
}
|
|
|
|
SettingsButton {
|
|
text: pointRow.confirming ? "Keep" : "Delete"
|
|
enabled: !Snapshots.busy
|
|
onClicked: root.confirmingDelete =
|
|
pointRow.confirming ? "" : pointRow.token
|
|
}
|
|
|
|
SettingsButton {
|
|
visible: pointRow.confirming
|
|
text: "Delete it"
|
|
tone: "danger"
|
|
enabled: !Snapshots.busy
|
|
onClicked: {
|
|
root.confirmingDelete = "";
|
|
Snapshots.remove(volumeCard.configName,
|
|
Number(pointRow.modelData.number));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
visible: volumeCard.snapshots.length > root.previewCount
|
|
activatable: true
|
|
divider: false
|
|
label: volumeCard.open
|
|
? "Showing all " + volumeCard.snapshots.length + " points in time"
|
|
: (volumeCard.snapshots.length - root.previewCount)
|
|
+ " older point"
|
|
+ (volumeCard.snapshots.length - root.previewCount === 1 ? "" : "s")
|
|
+ " in time"
|
|
detail: volumeCard.open
|
|
? ""
|
|
: "Oldest is " + String(volumeCard.snapshots[volumeCard.snapshots.length - 1]?.date ?? "")
|
|
controlWidth: 110
|
|
onActivated: {
|
|
root.confirmingDelete = "";
|
|
root.openConfig = volumeCard.open ? "" : volumeCard.configName;
|
|
}
|
|
|
|
Text {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: volumeCard.open ? "Show fewer \u25B4" : "Show all \u25BE"
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Inside one point in time ─────────────────────────────────────
|
|
|
|
Column {
|
|
width: parent.width
|
|
visible: volumeCard.open && root.browsingOpen
|
|
&& Snapshots.browsingConfig === volumeCard.configName
|
|
|
|
ActionRow {
|
|
width: parent.width
|
|
label: Snapshots.browsingPath === ""
|
|
? "Snapshot #" + Snapshots.browsingSnapshot
|
|
: "…/" + Snapshots.browsingPath
|
|
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
|
|
action: "Back"
|
|
enabled: !Snapshots.browsing
|
|
onTriggered: Snapshots.browseUp()
|
|
}
|
|
|
|
TextRow {
|
|
width: parent.width
|
|
visible: Snapshots.browsing
|
|
label: "Reading the snapshot…"
|
|
detail: "Listing a folder from a point in time"
|
|
value: ""
|
|
}
|
|
|
|
Repeater {
|
|
model: Snapshots.browsing ? [] : Snapshots.browseEntries
|
|
|
|
delegate: SettingRow {
|
|
id: entryRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property string entryPath: Snapshots.browsingPath === ""
|
|
? String(entryRow.modelData.name)
|
|
: Snapshots.browsingPath + "/" + String(entryRow.modelData.name)
|
|
readonly property bool confirming: root.confirmingRestore === entryRow.entryPath
|
|
|
|
width: parent.width
|
|
icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}"
|
|
label: String(entryRow.modelData.name ?? "")
|
|
detail: entryRow.modelData.directory
|
|
? "Folder"
|
|
: Snapshots.formatBytes(entryRow.modelData.bytes ?? 0)
|
|
controlWidth: 230
|
|
divider: entryRow.index < Snapshots.browseEntries.length - 1
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
|
|
SettingsButton {
|
|
visible: entryRow.modelData.directory === true
|
|
text: "Open"
|
|
enabled: !Snapshots.browsing
|
|
onClicked: Snapshots.browse(Snapshots.browsingConfig,
|
|
Snapshots.browsingSnapshot,
|
|
entryRow.entryPath)
|
|
}
|
|
|
|
SettingsButton {
|
|
text: entryRow.confirming ? "Cancel" : "Restore"
|
|
enabled: !Snapshots.busy
|
|
onClicked: root.confirmingRestore =
|
|
entryRow.confirming ? "" : entryRow.entryPath
|
|
}
|
|
|
|
SettingsButton {
|
|
visible: entryRow.confirming
|
|
text: "Put it back"
|
|
tone: "danger"
|
|
enabled: !Snapshots.busy
|
|
onClicked: {
|
|
root.confirmingRestore = "";
|
|
Snapshots.restore(Snapshots.browsingConfig,
|
|
Snapshots.browsingSnapshot,
|
|
entryRow.entryPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
width: parent.width
|
|
visible: !Snapshots.browsing && Snapshots.browseTruncated
|
|
label: "Only the first entries are shown"
|
|
detail: "This folder holds more than this list can usefully show"
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
TextRow {
|
|
width: parent.width
|
|
visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0
|
|
label: "Nothing here"
|
|
detail: "This folder was empty at that point in time"
|
|
value: ""
|
|
divider: false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── What it costs ────────────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "Space"
|
|
subtitle: "A snapshot shares its data with the live filesystem and grows only as files change afterwards."
|
|
|
|
TextRow {
|
|
label: "Free space"
|
|
detail: "Snapshots are removed oldest-first when this runs low"
|
|
value: Snapshots.formatBytes(Snapshots.space?.freeBytes ?? 0)
|
|
}
|
|
|
|
TextRow {
|
|
label: "Automatic snapshots"
|
|
detail: Snapshots.timelineRunning
|
|
? "The hourly timer is running"
|
|
: "The hourly timer is not running, so nothing new is being taken"
|
|
value: Snapshots.timelineRunning ? "Running" : "Stopped"
|
|
}
|
|
|
|
// Honest about what cannot be measured: per-snapshot size needs btrfs
|
|
// quota groups, which cost performance on every write. Reporting a
|
|
// made-up number would be worse than saying so.
|
|
TextRow {
|
|
label: "Space used by snapshots"
|
|
detail: "Measuring this per snapshot needs btrfs quotas, which slow down every write. Free space above is the number that matters."
|
|
value: "Not measured"
|
|
divider: false
|
|
}
|
|
}
|
|
}
|