507 lines
20 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|