Add a snapshot manager, and start covering home

The machine already had snapper running hourly on btrfs, so the tool was
never missing. What was missing is that snapper's only configuration
covered / -- and /home is a separate subvolume with no configuration at
all. Six hundred and forty-three snapshots existed and not one of them
contained a document. Anyone reaching for file history would have found
their system and none of their files.

/home now has a configuration on the same hourly timeline, with
deliberately conservative retention: Steam's 1.2 TB lives on that
subvolume and churns on every game update, so keeping five hourly and
seven daily bounds what those updates can pin.

Per volume, because on this machine "one is covered and the important
one is not" was the news, and a timeline opening on system snapshots
would have buried it. Inside a volume the timeline is the familiar view:
points in time, newest first, each openable as a folder tree to take a
file out of.

Restoring sets the current version aside as .before-restore-N rather
than overwriting it. A restore that destroys the thing you were about to
compare against is how someone loses the work they were trying to save.

Rollback is deliberately absent. snapper's rollback changes the btrfs
default subvolume, and this system's fstab pins subvol= explicitly,
which overrides it -- so a rollback would report success and change
nothing after a reboot. A recovery feature that silently does nothing is
worse than not having one, and making it work means editing fstab and
the bootloader, whose failure cannot be repaired from inside the
desktop.

Per-snapshot size is reported as not measured, because measuring it
needs btrfs quotas that cost performance on every write. Free space is
shown instead, which is the number that decides anything.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-19 16:11:50 -04:00
parent e9d567aa72
commit 1f40f8e136
11 changed files with 1093 additions and 1 deletions
@@ -121,6 +121,7 @@ Rectangle {
case "datetime": return dateTimePage;
case "applications": return applicationsPage;
case "storage": return storagePage;
case "snapshots": return snapshotsPage;
case "users": return usersPage;
case "sharing": return sharingPage;
case "printers": return printersPage;
@@ -163,6 +164,7 @@ Rectangle {
Component { id: homePage; HomePage {} }
Component { id: applicationsPage; ApplicationsPage {} }
Component { id: storagePage; StoragePage {} }
Component { id: snapshotsPage; SnapshotsPage {} }
Component { id: usersPage; UsersPage {} }
Component { id: sharingPage; SharingPage {} }
Component { id: printersPage; PrintersPage {} }
@@ -43,6 +43,7 @@ Rectangle {
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
{ page: "users", label: "Users", icon: "\u{F0004}" },
{ page: "services", label: "System Health", icon: "\u{F0493}" },
{ page: "about", label: "About", icon: "\u{F02FD}" }
@@ -0,0 +1,339 @@
// 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."
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
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")
}
ActionRow {
label: "History"
detail: volumeCard.snapshots.length === 0
? "Nothing taken yet"
: volumeCard.snapshots.length + " point"
+ (volumeCard.snapshots.length === 1 ? "" : "s") + " in time"
action: volumeCard.open ? "Hide" : "Browse…"
enabled: volumeCard.snapshots.length > 0
divider: volumeCard.open
onTriggered: {
root.confirmingDelete = "";
Snapshots.closeBrowser();
root.openConfig = volumeCard.open ? "" : volumeCard.configName;
}
}
// ── The timeline ─────────────────────────────────────────────────
Column {
width: parent.width
visible: volumeCard.open && !root.browsingOpen
Repeater {
model: volumeCard.snapshots
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.snapshots.length - 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));
}
}
}
}
}
}
// ── 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
}
}
}
@@ -26,6 +26,7 @@ SettingsToggle 1.0 SettingsToggle.qml
SettingsWindow 1.0 SettingsWindow.qml
SharingPage 1.0 SharingPage.qml
ShortcutsPage 1.0 ShortcutsPage.qml
SnapshotsPage 1.0 SnapshotsPage.qml
SoundPage 1.0 SoundPage.qml
SettingsPage 1.0 SettingsPage.qml
StoragePage 1.0 StoragePage.qml