Add a Storage page
Nothing showed what was using the drive, and removable media was handled by a tray helper with no surface in Settings at all. One scroll rather than tabs: space above, the device below. Every other settings page is a scrolling card stack, and a tab would not be deep-linkable from the launcher command or from search. Three things the page has to get right, each now pinned by a contract, because each is a way it could quietly lie. / and /home are one btrfs filesystem sharing one pool of free space, and a page that copies df shows double the free space that exists. zram is a block device and is not storage; counting it as a drive overstates this machine by 8 GB. Unmount and eject refuse anything not on a removable drive, because the UI is what asks and a UI can be wrong. The cheap read -- layout, usage, health -- runs when the page opens, at around 90ms. Measuring what is filling the drive means walking every file, so it happens on request and says so rather than showing an empty list that reads as "nothing here". Partitioning and formatting are deliberately absent. A settings pane is the wrong place to put erasing a disk two clicks deep; the page opens GNOME Disks for that. Adding the page found a fourth hard-coded page list in ShellState. A page missing from it does not error -- openSettings() falls back to "home", so the launcher opens the wrong page and logs nothing. A registry contract now holds the three lists together. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -120,6 +120,7 @@ Rectangle {
|
||||
case "power": return powerPage;
|
||||
case "datetime": return dateTimePage;
|
||||
case "applications": return applicationsPage;
|
||||
case "storage": return storagePage;
|
||||
case "services": return healthPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
@@ -158,6 +159,7 @@ Rectangle {
|
||||
|
||||
Component { id: homePage; HomePage {} }
|
||||
Component { id: applicationsPage; ApplicationsPage {} }
|
||||
Component { id: storagePage; StoragePage {} }
|
||||
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||
Component { id: powerPage; PowerPage {} }
|
||||
Component { id: dateTimePage; DateTimePage {} }
|
||||
|
||||
@@ -40,6 +40,7 @@ Rectangle {
|
||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
|
||||
{ page: "services", label: "System Health", icon: "\u{F0493}" },
|
||||
{ page: "about", label: "About", icon: "\u{F02FD}" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// 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".
|
||||
//
|
||||
// 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: "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;
|
||||
}
|
||||
|
||||
readonly property var removableDrives: Disks.drives.filter(entry => entry.removable)
|
||||
|
||||
Component.onCompleted: Disks.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Disks.lastError !== ""
|
||||
label: "Storage needs attention"
|
||||
detail: Disks.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Space ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Free space"
|
||||
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."
|
||||
|
||||
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: "What is using it"
|
||||
subtitle: Disks.foldersMeasured
|
||||
? "Measured by walking each folder."
|
||||
: "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)
|
||||
|
||||
// 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)
|
||||
: 0
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: Theme.accent
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Disks.foldersMeasured && Disks.folderScanTruncated
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ SettingsWindow 1.0 SettingsWindow.qml
|
||||
ShortcutsPage 1.0 ShortcutsPage.qml
|
||||
SoundPage 1.0 SoundPage.qml
|
||||
SettingsPage 1.0 SettingsPage.qml
|
||||
StoragePage 1.0 StoragePage.qml
|
||||
ToggleRow 1.0 ToggleRow.qml
|
||||
SliderRow 1.0 SliderRow.qml
|
||||
ChoiceRow 1.0 ChoiceRow.qml
|
||||
|
||||
Reference in New Issue
Block a user