pragma Singleton // Snapshots: points in time you can go back to, per btrfs subvolume. // // snapper was already running on this machine when this was written, hourly, // for / only -- and /home is a separate subvolume with no configuration, so six // hundred snapshots existed and not one contained a document. The page leads // with what is protected and what is not for that reason. // // Deliberately absent: rollback. snapper's rollback changes the btrfs default // subvolume, and an fstab that pins subvol= overrides it, so a rollback would // report success and change nothing after a reboot. Restoring files and folders // needs no reboot and cannot affect booting. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-snapshots" property var configs: [] property var unprotected: [] property bool timelineRunning: false property var space: ({}) property bool scanned: false property string lastError: "" // The last restore, so the page can say what happened to the file that was // already there rather than leaving someone to wonder. property var lastRestore: null // Browsing state: which snapshot is open, where inside it, and what is there. property string browsingConfig: "" property int browsingSnapshot: 0 property string browsingPath: "" property var browseEntries: [] property bool browseTruncated: false property bool browsing: false // Guards read the Process objects directly; a derived binding is stale // inside the handler that changes it. See DefaultApps.qml. readonly property bool busy: query.running || mutation.running function labelFor(config: var): string { const subvolume = String(config?.subvolume ?? ""); if (subvolume === "/") return "System"; if (subvolume === "/home") return "Home"; return subvolume; } function describe(config: var): string { const snapshots = config?.snapshots ?? []; if (!config?.readable) return "This account cannot read this configuration"; if (snapshots.length === 0) return config?.timelineEnabled ? "Protected — the first snapshot is taken on the hour" : "Configured, but automatic snapshots are off"; const oldest = snapshots[snapshots.length - 1]; return snapshots.length + " snapshot" + (snapshots.length === 1 ? "" : "s") + " · oldest " + String(oldest.date ?? "").replace(/^\w{3} /, ""); } function retentionSummary(config: var): string { const limits = config?.limits ?? ({}); const parts = []; if (Number(limits.hourly ?? 0) > 0) parts.push(limits.hourly + " hourly"); if (Number(limits.daily ?? 0) > 0) parts.push(limits.daily + " daily"); if (Number(limits.weekly ?? 0) > 0) parts.push(limits.weekly + " weekly"); return parts.length > 0 ? parts.join(" · ") : "Nothing kept automatically"; } function formatBytes(bytes: real): string { if (!(bytes > 0)) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let value = bytes; let index = 0; while (value >= 1000 && index < units.length - 1) { value /= 1000; index += 1; } return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index]; } function refresh(): void { if (query.running) return; query.command = [root.helperPath, "snapshot"]; query.running = true; } function absorb(text: string): void { try { const parsed = JSON.parse(text); root.configs = Array.isArray(parsed.configs) ? parsed.configs : []; root.unprotected = Array.isArray(parsed.unprotected) ? parsed.unprotected : []; root.timelineRunning = parsed.timelineRunning === true; root.space = parsed.space ?? ({}); root.lastError = String(parsed.error ?? ""); if (parsed.restored) root.lastRestore = parsed.restored; } catch (error) { root.lastError = "Could not read the snapshot service's answer."; console.warn("Snapshots: could not parse helper output:", error); } root.scanned = true; } function run(arguments: var): void { if (mutation.running) return; root.lastError = ""; mutation.command = [root.helperPath].concat(arguments); mutation.running = true; } function take(config: string, description: string): void { root.run(["create", config, description]); } function remove(config: string, number: int): void { root.run(["delete", config, String(number)]); } function setRetention(config: string, hourly: int, daily: int, weekly: int): void { root.run(["set-retention", config, String(hourly), String(daily), String(weekly)]); } function setTimeline(config: string, enabled: bool): void { root.run(["set-timeline", config, enabled ? "true" : "false"]); } function restore(config: string, number: int, path: string): void { root.lastRestore = null; root.run(["restore", config, String(number), path]); } // Browsing is a separate process from the snapshot, because a directory // listing inside a terabyte-scale subvolume is not something to do while // opening a page. function browse(config: string, number: int, path: string): void { if (browseProcess.running) return; root.browsingConfig = config; root.browsingSnapshot = number; root.browsingPath = path; root.browsing = true; browseProcess.command = [root.helperPath, "browse", config, String(number), path]; browseProcess.running = true; } function closeBrowser(): void { root.browsingConfig = ""; root.browsingSnapshot = 0; root.browsingPath = ""; root.browseEntries = []; root.browseTruncated = false; } // One level up, or out of the browser at the top. function browseUp(): void { const trimmed = String(root.browsingPath).replace(/\/+$/, ""); if (trimmed === "") { root.closeBrowser(); return; } const parent = trimmed.indexOf("/") < 0 ? "" : trimmed.slice(0, trimmed.lastIndexOf("/")); root.browse(root.browsingConfig, root.browsingSnapshot, parent); } Process { id: query stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } } Process { id: mutation // Answers with the fresh state, so the page updates from the change // itself rather than asking again afterwards. stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } } Process { id: browseProcess stdout: StdioCollector { onStreamFinished: { try { const parsed = JSON.parse(this.text); root.browseEntries = Array.isArray(parsed.entries) ? parsed.entries : []; root.browseTruncated = parsed.truncated === true; if (String(parsed.error ?? "") !== "") { root.lastError = String(parsed.error); root.browseEntries = []; } } catch (error) { root.lastError = "Could not read that folder from the snapshot."; } } } onExited: root.browsing = false } }