Files
Panama/config/dot/quickshell/modules/settings/SnapshotsPage.qml
T
Gabriel Brown 1aa1324083 Lead Appearance with light and dark, and stop pages listing whole datasets
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
2026-08-19 22:36:41 -04:00

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
}
}
}