Make Applications a real app manager, and clean up storage without the racket

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 17:18:14 -04:00
parent b30bf40407
commit 5a0643357f
29 changed files with 5024 additions and 314 deletions
@@ -9,6 +9,13 @@
// be a terabyte -- so it happens on request rather than on open. The page says
// so plainly instead of showing an empty list that reads as "nothing here".
//
// The cleanup card has rules, and they are the whole reason it can exist at
// all. Every row names what it is and what is lost by clearing it. Every row
// carries its size. Nothing is selected in advance, nothing is recommended,
// nothing counts down, and a row with nothing in it says so and offers no
// button. A "clean up your PC" panel that nags is a racket; this one is a list
// of facts with a button next to each.
//
// Partitioning and formatting are deliberately absent; GNOME Disks is one row
// away for that.
@@ -22,19 +29,25 @@ SettingsPage {
objectName: "storage"
title: "Storage"
lede: "How much space is left, what is using it, and whether the drive is healthy."
lede: root.rootFs
? Disks.formatBytes(root.rootFs.usedBytes) + " used of "
+ Disks.formatBytes(root.rootFs.sizeBytes) + " — "
+ Disks.formatBytes(root.rootFs.availBytes) + " free."
: "How much space is left, what is using it, and whether the drive is healthy."
readonly property var rootFs: Disks.rootFilesystem
readonly property var drive: Disks.primaryDrive
// The measured folders, as a share of the largest one, so the bars compare
// against each other rather than against a total they do not sum to.
readonly property real largestFolder: {
let largest = 0;
for (const folder of Disks.folders)
largest = Math.max(largest, Number(folder.bytes ?? 0));
return largest;
}
// Clearing something deletes it, so it never happens on a first press.
// Holds the id of the cleanable that is one press away from running.
property string pendingClean: ""
// Folder bars are drawn against the DISK, not against the largest folder.
// Against the largest, the top row is always full and the bar says nothing
// except which row is biggest -- which the sizes beside them already say.
// Against the disk, the bar means the same thing as the free-space bar
// above it, and a 212 GB folder on a 2 TB disk looks like what it is.
readonly property real diskBytes: Number(root.rootFs?.sizeBytes ?? 0)
readonly property var removableDrives: Disks.drives.filter(entry => entry.removable)
@@ -51,12 +64,74 @@ SettingsPage {
// ── Space ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Free space"
title: "What is on this disk"
subtitle: root.rootFs && (root.rootFs.mountpoints ?? []).length > 1
? "One filesystem is mounted at " + Disks.mountLabel(root.rootFs)
+ ", so they share the same space."
: "The filesystem this session runs from."
// Measuring the segments walks the same folders the Folders card walks,
// so it costs the same and is asked for the same way.
ActionRow {
visible: !Disks.breakdownMeasured || Disks.measuringBreakdown
label: Disks.measuringBreakdown ? "Measuring…" : "Measure what is on this disk"
detail: Disks.measuringBreakdown
? "Walking your home folder, the flatpak installations, and the cache."
: "Three of the four segments are measured by walking folders, which takes a minute."
action: "Measure"
enabled: !Disks.measuringBreakdown
divider: false
onTriggered: Disks.measureBreakdown()
}
StorageBreakdownBar {
visible: Disks.breakdownMeasured
width: parent.width
}
// The caption belongs to the bar's arithmetic, so it lives with the
// card rather than inside the component: home, applications and caches
// are measured, and the fourth segment is what is left of the used
// space once those three are subtracted. Calling that "System" would
// blame the desktop for the user's own unscanned files.
Text {
width: parent.width
visible: Disks.breakdownMeasured
topPadding: 12
text: "Home, applications and caches are measured. \"System & everything else\" is"
+ " the remainder — the packages this machine runs on, plus anything those"
+ " three measurements did not reach."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// What the walk could not finish, said rather than absorbed into the
// remainder without comment.
TextRow {
visible: Disks.breakdownMeasured && Disks.breakdown
&& Disks.breakdown.complete === false
label: "The walk ran out of time"
detail: "Home, applications and caches are floors rather than totals, so the remainder is larger than it should be."
value: ""
divider: false
}
TextRow {
visible: Disks.breakdownMeasured && Disks.breakdown
&& Disks.breakdown.exceedsUsed === true
label: "The measured parts overlap"
detail: "They add up to more than the used space, which means something was counted twice. The remainder is shown as zero rather than as a negative number."
value: ""
divider: false
}
}
SettingsCard {
title: "Free space"
subtitle: "The number the rest of this page is about."
Column {
width: parent.width
spacing: 10
@@ -118,9 +193,9 @@ SettingsPage {
}
SettingsCard {
title: "What is using it"
title: "Folders"
subtitle: Disks.foldersMeasured
? "Measured by walking each folder."
? "Measured by walking each folder. Bars are drawn against the whole disk, so they mean the same thing as the free-space bar above."
: "Measuring means walking every file, so it is not done automatically."
ActionRow {
@@ -160,12 +235,9 @@ SettingsPage {
radius: 2
color: Theme.alpha(Theme.fg, 0.08)
// Relative to the LARGEST folder, not to the drive. At this
// scale one folder holds almost everything, so bars drawn
// against the total would leave every other row invisible.
Rectangle {
width: root.largestFolder > 0
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.largestFolder), 2)
width: root.diskBytes > 0
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.diskBytes), 2)
: 0
height: parent.height
radius: parent.radius
@@ -182,26 +254,98 @@ SettingsPage {
label: "Some folders were not measured"
detail: "The walk ran out of time before reaching them, so this list is incomplete."
value: ""
divider: Disks.containers !== null
divider: false
}
}
// ── Reclaiming space, without the racket ─────────────────────────────────
SettingsCard {
title: "Clean up, honestly"
subtitle: "Each row says what it is and what clearing it costs. Nothing here is chosen for you, and nothing on this page needs doing."
ActionRow {
visible: Disks.containers !== null
&& Number(Disks.containers?.reclaimableBytes ?? 0) > 0
label: "Unused container images"
detail: Disks.containers
? Disks.formatBytes(Disks.containers.reclaimableBytes)
+ " of " + Disks.formatBytes(Disks.containers.totalBytes)
+ " is not used by any container"
: ""
action: "Show"
visible: !Disks.cleanablesMeasured || Disks.measuringCleanables
label: Disks.measuringCleanables ? "Measuring…" : "Measure what could be cleared"
detail: "Measuring only counts bytes. Every row below is cleared by its own button, one at a time."
action: "Measure"
enabled: !Disks.measuringCleanables
divider: Disks.cleanablesMeasured
onTriggered: Disks.measureCleanables()
}
Repeater {
model: Disks.cleanables ?? []
delegate: SettingRow {
id: cleanRow
required property var modelData
required property int index
readonly property string cleanId: String(cleanRow.modelData.id ?? "")
readonly property real bytes: Number(cleanRow.modelData.bytes ?? 0)
readonly property bool empty: cleanRow.bytes <= 0
readonly property bool privileged: cleanRow.modelData.privileged === true
readonly property bool confirming: root.pendingClean !== ""
&& root.pendingClean === cleanRow.cleanId
width: parent.width
label: String(cleanRow.modelData.label ?? "")
detail: cleanRow.empty
? String(cleanRow.modelData.detail ?? "") + " · empty, nothing to do"
: (cleanRow.confirming
? "This deletes it now. Nothing here is recoverable from the trash afterwards."
: String(cleanRow.modelData.detail ?? "")
+ (cleanRow.privileged ? " · the system will ask for your password" : ""))
value: cleanRow.empty ? Disks.formatBytes(0) : ""
controlWidth: cleanRow.empty ? 90 : 210
divider: cleanRow.index < (Disks.cleanables ?? []).length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: cleanRow.bytes > 0
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: Disks.formatBytes(cleanRow.bytes)
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: cleanRow.confirming
text: "Clear it"
tone: "danger"
enabled: Disks.cleaningId === ""
onClicked: {
root.pendingClean = "";
Disks.clean(cleanRow.cleanId);
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: cleanRow.confirming ? "Keep" : "Clear…"
enabled: Disks.cleaningId === ""
onClicked: root.pendingClean =
cleanRow.confirming ? "" : cleanRow.cleanId
}
}
}
}
TextRow {
visible: Disks.cleanablesMeasured && (Disks.cleanables ?? []).length === 0
label: "Nothing itemized"
detail: "Caches, the trash, unused Flatpak runtimes and the package download cache were all measured and none of them holds anything."
value: ""
divider: false
// Reclaiming is not offered here on purpose: pruning images can
// destroy work that lives outside this desktop, and a settings pane
// should not put that one click deep. This opens a terminal showing
// what would be reclaimed, and leaves the decision there.
onTriggered: Quickshell.execDetached(
["kitty", "--hold", "-e", "podman", "system", "df"])
}
}
@@ -281,6 +425,11 @@ SettingsPage {
// Named here because this is the page somebody opens when space is short,
// and container images are usually the largest thing nobody remembers
// having. Only shown when there is actually something to reclaim.
//
// This is the ONLY container affordance on the page. There used to be a
// second one above that opened a terminal running `podman system df`, which
// meant two rows about the same gigabytes leading two different places --
// and the terminal one could not actually reclaim anything.
SettingsCard {
visible: Containers.available && Containers.reclaimable > 0
title: "Containers are holding " + Containers.formatBytes(Containers.reclaimable)