Files
Panama/config/dot/quickshell/modules/settings/StoragePage.qml
T

507 lines
20 KiB
QML

// Storage: what is using the drive, then what the drive is.
//
// The page is one scroll with a rule behind it: everything above the divider
// answers "how much space do I have and what took it", everything below answers
// "what is this device and is it healthy". A card that answers neither does not
// belong here.
//
// Measuring what is using the space is expensive -- a Steam library alone can
// 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.
import Quickshell
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "storage"
title: "Storage"
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
// 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)
// Unguarded on purpose, unlike the other refresh-on-open pages: this is the
// cheap snapshot, not the walk. Free space moves while Settings is open --
// that is most of the reason someone comes back to this page -- and
// disks-contract pins the bare call so a later sweep cannot quietly turn it
// into a once-a-session read. The expensive walks are asked for by hand.
Component.onCompleted: Disks.refresh()
ErrorRow {
label: "Storage needs attention"
message: Disks.lastError
}
// ── Space ────────────────────────────────────────────────────────────────
SettingsCard {
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
Row {
width: parent.width
spacing: 12
Text {
text: root.rootFs ? Disks.formatBytes(root.rootFs.availBytes) + " free" : "Reading…"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.rootFs !== null
text: root.rootFs
? "of " + Disks.formatBytes(root.rootFs.sizeBytes)
+ " · " + String(root.rootFs.fstype ?? "")
: ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Rectangle {
width: parent.width
height: 10
radius: 5
color: Theme.alpha(Theme.fg, 0.09)
Rectangle {
width: Math.max(parent.width * Disks.usedFraction(root.rootFs), parent.height)
height: parent.height
radius: parent.radius
// Color is the only warning this bar gives, so it changes
// at the point where free space starts to be a problem
// rather than at a round number.
color: Disks.usedFraction(root.rootFs) > 0.92
? Theme.danger
: (Disks.usedFraction(root.rootFs) > 0.8 ? Theme.warn : Theme.accent)
}
}
Text {
text: root.rootFs
? Disks.formatBytes(root.rootFs.usedBytes) + " used · "
+ Math.round(Disks.usedFraction(root.rootFs) * 100) + "%"
: ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingsCard {
title: "Folders"
subtitle: Disks.foldersMeasured
? "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 {
visible: !Disks.foldersMeasured || Disks.scanning
label: Disks.scanning ? "Measuring…" : "Measure folders"
detail: Disks.scanning
? "Walking the largest folders. This can take a minute."
: "Walk the usual suspects and report what is biggest."
action: "Measure"
enabled: !Disks.scanning
divider: Disks.foldersMeasured
onTriggered: Disks.scan()
}
Repeater {
model: Disks.foldersMeasured ? Disks.folders : []
delegate: Column {
id: folderBlock
required property var modelData
required property int index
width: parent.width
TextRow {
width: folderBlock.width
label: String(folderBlock.modelData.label ?? "")
detail: String(folderBlock.modelData.path ?? "")
value: Disks.formatBytes(folderBlock.modelData.bytes ?? 0)
divider: false
}
Rectangle {
width: folderBlock.width
height: 4
radius: 2
color: Theme.alpha(Theme.fg, 0.08)
Rectangle {
width: root.diskBytes > 0
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.diskBytes), 2)
: 0
height: parent.height
radius: parent.radius
color: Theme.accent
}
}
Item { width: 1; height: 10 }
}
}
NotMeasuredRow {
visible: Disks.foldersMeasured && Disks.folderScanTruncated
label: "Some folders were not measured"
because: "The walk ran out of time before reaching them, so this list is incomplete"
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.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
}
}
// ── The device ───────────────────────────────────────────────────────────
SettingsCard {
title: "Drive"
subtitle: root.drive
? String(root.drive.model) + (root.drive.encrypted ? " · encrypted" : "")
: "Reading…"
TextRow {
visible: root.drive !== null
label: "Health"
detail: root.drive && root.drive.selfTest !== ""
? "Last self-test: " + String(root.drive.selfTest)
: "Reported by the drive itself"
value: Disks.healthSummary(root.drive)
}
TextRow {
visible: root.drive !== null && root.drive.temperatureC !== null
label: "Temperature"
detail: "Measured by the drive controller"
value: root.drive && root.drive.temperatureC !== null
? String(root.drive.temperatureC) + " °C" : ""
}
TextRow {
visible: root.drive !== null && root.drive.powerOnHours !== null
label: "Powered on"
detail: "Total hours this drive has been running"
value: root.drive && root.drive.powerOnHours !== null
? String(root.drive.powerOnHours) + " hours" : ""
}
TextRow {
visible: root.drive !== null && root.drive.encrypted
label: "Encryption"
detail: "The filesystem is encrypted and unlocked at boot"
value: "LUKS"
}
ActionRow {
label: "Partitioning and formatting"
detail: "Deliberately not here. Disks is the tool that owns erasing a drive."
action: "Open Disks"
divider: false
onTriggered: Quickshell.execDetached(["gapplication", "launch", "org.gnome.DiskUtility"])
}
}
SettingsCard {
title: "Filesystems"
subtitle: "Every mounted filesystem, grouped by the device behind it."
Repeater {
model: Disks.filesystems
delegate: TextRow {
id: filesystemRow
required property var modelData
required property int index
width: parent.width
label: Disks.mountLabel(filesystemRow.modelData)
detail: String(filesystemRow.modelData.fstype ?? "")
+ " on " + String(filesystemRow.modelData.device ?? "")
value: Disks.formatBytes(filesystemRow.modelData.availBytes) + " free of "
+ Disks.formatBytes(filesystemRow.modelData.sizeBytes)
divider: filesystemRow.index < Disks.filesystems.length - 1
}
}
}
// 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)
ActionRow {
label: "Images and volumes nothing references"
detail: Containers.unusedImages.length + " image"
+ (Containers.unusedImages.length === 1 ? "" : "s") + " and "
+ Containers.unusedVolumes.length + " volume"
+ (Containers.unusedVolumes.length === 1 ? "" : "s")
action: "Review"
divider: false
onTriggered: ShellState.settingsPage = "containers"
}
}
SettingsCard {
title: "Removable drives"
subtitle: "USB drives and memory cards."
Repeater {
model: root.removableDrives
delegate: ActionRow {
id: removableRow
required property var modelData
width: parent.width
label: String(removableRow.modelData.model ?? removableRow.modelData.name)
detail: Disks.formatBytes(removableRow.modelData.sizeBytes)
+ " · " + String(removableRow.modelData.path)
action: removableRow.modelData.ejectable ? "Eject" : "Unmount"
divider: false
onTriggered: removableRow.modelData.ejectable
? Disks.eject(String(removableRow.modelData.path))
: Disks.unmount(String(removableRow.modelData.path))
}
}
TextRow {
visible: root.removableDrives.length === 0
label: "None connected"
detail: "Drives you plug in appear here, with a way to unmount them safely"
value: ""
divider: false
}
}
SettingsCard {
visible: Disks.swap.length > 0
title: "Swap"
subtitle: "Compressed swap lives in memory, not on the drive."
Repeater {
model: Disks.swap
delegate: TextRow {
id: swapRow
required property var modelData
required property int index
width: parent.width
label: String(swapRow.modelData.name ?? "")
detail: String(swapRow.modelData.kind) === "zram"
? "Compressed swap in RAM" : "Swap"
value: Disks.formatBytes(swapRow.modelData.sizeBytes)
divider: swapRow.index < Disks.swap.length - 1
}
}
}
}