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:
@@ -0,0 +1,402 @@
|
||||
// One installed application: a line you can read, unfolding in place into
|
||||
// everything this desktop can honestly say about it.
|
||||
//
|
||||
// Built like NotificationAppRow rather than out of SettingRow, for the same
|
||||
// reason -- the head carries the application's own icon and a badge saying
|
||||
// where it came from, and neither fits a row whose trailing slot is one fixed
|
||||
// width. What is different here is that the body's contents depend on where the
|
||||
// application came from: a Flatpak can be inspected and removed, a system
|
||||
// package can only be described.
|
||||
//
|
||||
// Nothing in here writes. Every control leaves through a signal so the page
|
||||
// owns the service calls, which is what keeps the uninstall confirmation a
|
||||
// single piece of page state rather than something each row remembers for
|
||||
// itself.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// { entryId, name, icon, kind: "flatpak"|"system", flatpakId, size }
|
||||
required property var app
|
||||
|
||||
property bool expanded: false
|
||||
property bool divider: true
|
||||
property bool busy: false
|
||||
|
||||
// Whether ~/.config/autostart currently starts this application.
|
||||
property bool autostart: false
|
||||
|
||||
// The helper's curated permission summary, as a list of human strings.
|
||||
// Empty while it is still being read, which the row says rather than
|
||||
// showing a blank line.
|
||||
property var permissionSummary: []
|
||||
property bool permissionsKnown: false
|
||||
property bool flatsealAvailable: false
|
||||
|
||||
// Jump chips appear only where a rule for this application actually
|
||||
// exists somewhere else. A chip that lands on a page with nothing about
|
||||
// this application on it is worse than no chip.
|
||||
property bool hasNotificationRule: false
|
||||
property bool hasSoundRule: false
|
||||
property bool hasPrivacyRule: false
|
||||
|
||||
// Set by the page while this row's uninstall is one press from happening.
|
||||
property bool confirmingUninstall: false
|
||||
|
||||
signal activated
|
||||
signal autostartToggled(bool value)
|
||||
signal jumped(string page)
|
||||
signal flatsealRequested
|
||||
signal uninstallArmed
|
||||
signal uninstallCancelled
|
||||
signal uninstallConfirmed
|
||||
|
||||
readonly property string appName: String(root.app?.name ?? "")
|
||||
readonly property string entryId: String(root.app?.entryId ?? "")
|
||||
readonly property string flatpakId: String(root.app?.flatpakId ?? "")
|
||||
readonly property bool flatpak: String(root.app?.kind ?? "") === "flatpak"
|
||||
|
||||
// The dnf package name, for the refusal row's exact command. The desktop
|
||||
// entry id is the closest thing to it this desktop can know without asking
|
||||
// rpm about every application on the machine, so the row says "the package
|
||||
// that provides" rather than claiming the name is the package.
|
||||
readonly property string systemName: {
|
||||
const id = root.entryId.replace(/\.desktop$/, "");
|
||||
const short = id.split(".").pop();
|
||||
return short !== "" ? short : id;
|
||||
}
|
||||
|
||||
// `sizeBytes` when the helper could turn flatpak's answer into a number and
|
||||
// `size` -- flatpak's own "412.5 MB" -- when it could not. Whichever
|
||||
// arrived is shown as it arrived, rather than one being coerced into the
|
||||
// other and rounded to nothing.
|
||||
readonly property string subtitle: {
|
||||
if (!root.flatpak)
|
||||
return "Installed by the system package manager";
|
||||
const bytes = Number(root.app?.sizeBytes ?? 0);
|
||||
if (Number.isFinite(bytes) && bytes > 0)
|
||||
return root.flatpakId + " · " + root.formatBytes(bytes);
|
||||
const text = String(root.app?.size ?? "").trim();
|
||||
return text === "" || text === "0" ? root.flatpakId : root.flatpakId + " · " + text;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
readonly property string iconSource: {
|
||||
const name = String(root.app?.icon ?? "");
|
||||
return name === "" ? "" : Quickshell.iconPath(name, true);
|
||||
}
|
||||
|
||||
// A stable colour per application, so the tile keeps its identity as the
|
||||
// list re-sorts or the search narrows it.
|
||||
readonly property color tileColor: {
|
||||
const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan,
|
||||
Theme.green, Theme.orange, Theme.pink];
|
||||
let hash = 0;
|
||||
for (let index = 0; index < root.entryId.length; index++)
|
||||
hash = (hash * 31 + root.entryId.charCodeAt(index)) % 9973;
|
||||
return palette[hash % palette.length];
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
id: head
|
||||
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(56, copy.implicitHeight + 20)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.bottomMargin: 1
|
||||
radius: 9
|
||||
z: -1
|
||||
visible: headHover.hovered
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 30
|
||||
height: 30
|
||||
radius: 8
|
||||
color: root.iconSource === "" ? root.tileColor : "transparent"
|
||||
border.width: 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.iconSource === ""
|
||||
text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : "?"
|
||||
color: Theme.bgDark
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
implicitSize: 24
|
||||
asynchronous: true
|
||||
visible: root.iconSource !== ""
|
||||
source: root.iconSource
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
anchors.left: tile.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: chevron.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.min(implicitWidth, Math.max(0, parent.width - badge.width - 8))
|
||||
text: root.appName
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Where it came from, said once and in the same place on every
|
||||
// row -- the difference decides what the body can offer.
|
||||
Rectangle {
|
||||
id: badge
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: badgeText.implicitWidth + 14
|
||||
height: 17
|
||||
radius: Theme.pillRadius
|
||||
color: root.flatpak
|
||||
? Theme.alpha(Theme.cyan, 0.09)
|
||||
: Theme.alpha(Theme.fgMuted, 0.12)
|
||||
border.width: 1
|
||||
border.color: root.flatpak
|
||||
? Theme.alpha(Theme.cyan, 0.28)
|
||||
: Theme.alpha(Theme.fgMuted, 0.3)
|
||||
|
||||
Text {
|
||||
id: badgeText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: root.flatpak ? "FLATPAK" : "SYSTEM"
|
||||
color: root.flatpak ? Theme.cyan : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.subtitle
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: chevron
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: copy.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider && !root.expanded
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: headHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.activated()
|
||||
}
|
||||
}
|
||||
|
||||
// Indented under the head, so what is inside reads as belonging to the
|
||||
// application above it rather than to the card.
|
||||
Column {
|
||||
id: body
|
||||
|
||||
x: 42
|
||||
width: Math.max(0, parent.width - 42)
|
||||
visible: root.expanded
|
||||
|
||||
// ── What it can reach ────────────────────────────────────────────────
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.flatpak
|
||||
label: "Permissions"
|
||||
detail: !root.permissionsKnown
|
||||
? "Reading what this application is allowed to reach…"
|
||||
: (root.permissionSummary.length > 0
|
||||
? root.permissionSummary.join(" · ")
|
||||
: "Nothing beyond its own sandbox")
|
||||
controlWidth: root.flatsealAvailable ? 130 : 0
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.flatsealAvailable
|
||||
text: "Open Flatseal"
|
||||
onClicked: root.flatsealRequested()
|
||||
}
|
||||
}
|
||||
|
||||
// The honest refusal, with the command that does the thing this page
|
||||
// will not. Removing a system package can take the desktop with it, so
|
||||
// it happens where the consequences are visible.
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !root.flatpak
|
||||
label: "Managed by dnf"
|
||||
detail: "Settings does not remove system packages — removing one can take the desktop"
|
||||
+ " with it. To remove the package that provides this: sudo dnf remove "
|
||||
+ root.systemName
|
||||
value: ""
|
||||
}
|
||||
|
||||
// ── What it does at sign-in ──────────────────────────────────────────
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: "Start with the session"
|
||||
detail: root.autostart
|
||||
? "An entry in ~/.config/autostart starts this when you sign in"
|
||||
: "Nothing starts this automatically"
|
||||
controlWidth: 48
|
||||
divider: root.flatpak || root.hasNotificationRule
|
||||
|| root.hasSoundRule || root.hasPrivacyRule
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.autostart
|
||||
onToggled: value => root.autostartToggled(value)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Where its other settings live ────────────────────────────────────
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.hasNotificationRule || root.hasSoundRule || root.hasPrivacyRule
|
||||
label: "Elsewhere in Settings"
|
||||
detail: "This application already has a rule on these pages"
|
||||
controlWidth: 270
|
||||
divider: root.flatpak
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
SettingsChip {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.hasNotificationRule
|
||||
text: "Notifications"
|
||||
onClicked: root.jumped("notifications")
|
||||
}
|
||||
|
||||
SettingsChip {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.hasSoundRule
|
||||
text: "Sound"
|
||||
onClicked: root.jumped("sound")
|
||||
}
|
||||
|
||||
SettingsChip {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.hasPrivacyRule
|
||||
text: "Privacy"
|
||||
onClicked: root.jumped("privacy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Removing it ──────────────────────────────────────────────────────
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.flatpak
|
||||
label: "Uninstall"
|
||||
detail: root.confirmingUninstall
|
||||
? "This removes the application. Data it kept under ~/.var/app stays behind."
|
||||
: "Removes the application, and its runtime if nothing else needs it"
|
||||
controlWidth: 210
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.confirmingUninstall
|
||||
text: "Uninstall it"
|
||||
tone: "danger"
|
||||
enabled: !root.busy
|
||||
onClicked: root.uninstallConfirmed()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.confirmingUninstall ? "Keep" : "Uninstall…"
|
||||
enabled: !root.busy
|
||||
onClicked: root.confirmingUninstall
|
||||
? root.uninstallCancelled()
|
||||
: root.uninstallArmed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user