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,328 @@
|
||||
pragma Singleton
|
||||
|
||||
// What is installed, what Panama offers to install, and what a flatpak may do.
|
||||
//
|
||||
// DesktopEntries knows every application that put a launcher on this machine
|
||||
// and nothing about where it came from. flatpak knows its own applications and
|
||||
// nothing about the rest. Neither answers "what is installed", which is the
|
||||
// question the Applications page asks, so this joins them: a desktop entry
|
||||
// whose id matches an installed flatpak id is that flatpak, because flatpak
|
||||
// exports its launcher as <app-id>.desktop and always has.
|
||||
//
|
||||
// Everything else is a system package. That is stated rather than acted on --
|
||||
// there is no dnf removal here or in the helper. A settings page that
|
||||
// uninstalls system packages is one mis-click from removing the compositor it
|
||||
// is drawn by, and dnf will take half the desktop with it. The page names the
|
||||
// command instead.
|
||||
//
|
||||
// Permissions are cached per application id. `flatpak info --show-permissions`
|
||||
// is a process launch, the page shows them per expanded row, and re-reading
|
||||
// them on every repaint would launch one per frame.
|
||||
//
|
||||
// Nothing here loads on its own. `flatpak list` and the catalog cost a process
|
||||
// each and only the Applications page wants them, so the page calls refresh()
|
||||
// and refreshCatalog() when it opens rather than every shell start paying for a
|
||||
// page nobody opened.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// The seam: contracts point this at a stub that answers from fixtures, so
|
||||
// "install refuses an id that is not in the catalog" can be proven without
|
||||
// installing anything.
|
||||
readonly property string helperPath: Quickshell.env("PANAMA_APPLICATIONS_HELPER")
|
||||
|| Quickshell.shellDir + "/scripts/panama-applications"
|
||||
|
||||
// [{ id, name, size, sizeBytes, origin }]
|
||||
property var flatpaks: []
|
||||
// [{ name, label, entries: [{ id, ref, label, kind, installed }] }]
|
||||
property var categories: []
|
||||
property var permissionCache: ({})
|
||||
|
||||
property bool flatpaksLoaded: false
|
||||
property bool catalogLoaded: false
|
||||
property string lastError: ""
|
||||
// Which row is mid-change, so the page can disable exactly that one rather
|
||||
// than greying out the whole card.
|
||||
property string busyEntryId: ""
|
||||
|
||||
// Guards read the Process objects directly rather than this binding; a
|
||||
// binding is stale inside the handler that changes it. See DefaultApps.qml.
|
||||
readonly property bool busy: flatpakQuery.running || catalogQuery.running || mutation.running
|
||||
|
||||
readonly property var flatpakIndex: {
|
||||
const index = {};
|
||||
for (const entry of root.flatpaks)
|
||||
index[String(entry.id ?? "").toLowerCase()] = entry;
|
||||
return index;
|
||||
}
|
||||
|
||||
// [{ entryId, name, icon, kind, flatpakId, size, sizeBytes, entry }]
|
||||
//
|
||||
// `entry` is the DesktopEntry itself when there is one, so the page can
|
||||
// launch it, read its categories, or match it against a notification rule
|
||||
// without looking it up a second time.
|
||||
readonly property var apps: {
|
||||
const out = [];
|
||||
const claimed = {};
|
||||
for (const entry of DesktopEntries.applications.values) {
|
||||
if (entry.noDisplay)
|
||||
continue;
|
||||
const entryId = root.desktopId(entry);
|
||||
const key = entryId.replace(/\.desktop$/, "").toLowerCase();
|
||||
const flatpak = root.flatpakIndex[key] ?? null;
|
||||
if (flatpak)
|
||||
claimed[key] = true;
|
||||
out.push({
|
||||
entryId: entryId,
|
||||
name: String(entry.name || entry.genericName || entryId),
|
||||
icon: String(entry.icon ?? ""),
|
||||
kind: flatpak ? "flatpak" : "system",
|
||||
flatpakId: flatpak ? String(flatpak.id) : "",
|
||||
size: flatpak ? String(flatpak.size ?? "") : "",
|
||||
sizeBytes: flatpak ? Number(flatpak.sizeBytes ?? 0) : 0,
|
||||
entry: entry
|
||||
});
|
||||
}
|
||||
// A flatpak with no launcher is still installed and still takes space.
|
||||
// Dropping it would make the list disagree with `flatpak list`, and the
|
||||
// uninstall row is the only place someone can get rid of it.
|
||||
for (const flatpak of root.flatpaks) {
|
||||
const key = String(flatpak.id ?? "").toLowerCase();
|
||||
if (claimed[key])
|
||||
continue;
|
||||
out.push({
|
||||
entryId: String(flatpak.id) + ".desktop",
|
||||
name: String(flatpak.name ?? flatpak.id),
|
||||
icon: "",
|
||||
kind: "flatpak",
|
||||
flatpakId: String(flatpak.id),
|
||||
size: String(flatpak.size ?? ""),
|
||||
sizeBytes: Number(flatpak.sizeBytes ?? 0),
|
||||
entry: null
|
||||
});
|
||||
}
|
||||
out.sort((left, right) => left.name.localeCompare(right.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
readonly property int flatpakCount: root.flatpaks.length
|
||||
|
||||
function desktopId(entry: var): string {
|
||||
const entryId = String(entry?.id ?? "");
|
||||
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
|
||||
}
|
||||
|
||||
// Name and id both, because someone searching for "obs" and someone
|
||||
// searching for "com.obsproject" are looking for the same row.
|
||||
function matches(app: var, query: string): bool {
|
||||
const needle = String(query ?? "").trim().toLowerCase();
|
||||
if (needle === "")
|
||||
return true;
|
||||
return (String(app.name) + " " + String(app.entryId) + " " + String(app.flatpakId))
|
||||
.toLowerCase().indexOf(needle) >= 0;
|
||||
}
|
||||
|
||||
function entriesFor(category: string): var {
|
||||
for (const entry of root.categories) {
|
||||
if (String(entry.name) === String(category))
|
||||
return entry.entries ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function categoryLabel(category: string): string {
|
||||
for (const entry of root.categories) {
|
||||
if (String(entry.name) === String(category))
|
||||
return String(entry.label ?? entry.name);
|
||||
}
|
||||
return String(category);
|
||||
}
|
||||
|
||||
// Cached, and null while the answer is on its way. Reading this inside a
|
||||
// binding is safe: the read of permissionCache is what makes the binding
|
||||
// re-evaluate when the answer lands.
|
||||
function permissionsFor(flatpakId: string): var {
|
||||
const key = String(flatpakId ?? "");
|
||||
if (key === "")
|
||||
return null;
|
||||
const cached = root.permissionCache[key];
|
||||
if (cached !== undefined)
|
||||
return cached;
|
||||
root.requestPermissions(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestPermissions(flatpakId: string): void {
|
||||
if (permissionQuery.pending.indexOf(flatpakId) >= 0)
|
||||
return;
|
||||
permissionQuery.pending = permissionQuery.pending.concat([flatpakId]);
|
||||
permissionQuery.pump();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (flatpakQuery.running)
|
||||
return;
|
||||
flatpakQuery.command = [root.helperPath, "flatpaks"];
|
||||
flatpakQuery.running = true;
|
||||
}
|
||||
|
||||
function refreshCatalog(): void {
|
||||
if (catalogQuery.running)
|
||||
return;
|
||||
catalogQuery.command = [root.helperPath, "catalog"];
|
||||
catalogQuery.running = true;
|
||||
}
|
||||
|
||||
// Only ids the catalog already listed. The helper refuses off-catalog ids
|
||||
// too -- this is the near guard, not the only one, because the page is a
|
||||
// caller that can be wrong.
|
||||
function install(category: string, entryId: string): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
const known = root.entriesFor(category).some(entry => String(entry.id) === String(entryId));
|
||||
if (!known) {
|
||||
root.lastError = "That application is not in the catalog.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.busyEntryId = String(entryId);
|
||||
mutation.mode = "catalog";
|
||||
mutation.command = [root.helperPath, "install", String(category), String(entryId)];
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
function uninstall(flatpakId: string): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
const known = root.flatpaks.some(entry => String(entry.id) === String(flatpakId));
|
||||
if (!known) {
|
||||
root.lastError = "That application is not installed.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.busyEntryId = String(flatpakId);
|
||||
mutation.mode = "flatpaks";
|
||||
mutation.command = [root.helperPath, "uninstall", String(flatpakId)];
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
function absorbFlatpaks(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.flatpaks = Array.isArray(parsed) ? parsed : [];
|
||||
root.flatpaksLoaded = true;
|
||||
} catch (error) {
|
||||
root.lastError = "The list of installed applications could not be read.";
|
||||
console.warn("AppLibrary: could not parse flatpaks output:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function absorbCatalog(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.categories = Array.isArray(parsed.categories) ? parsed.categories : [];
|
||||
root.catalogLoaded = true;
|
||||
} catch (error) {
|
||||
root.lastError = "The application catalog could not be read.";
|
||||
console.warn("AppLibrary: could not parse catalog output:", error);
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: flatpakQuery
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbFlatpaks(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: catalogQuery
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbCatalog(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mutation
|
||||
|
||||
// Which fresh state the helper answers with, so the reply lands in the
|
||||
// right property instead of being parsed twice and guessed at.
|
||||
property string mode: "flatpaks"
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (mutation.mode === "catalog")
|
||||
root.absorbCatalog(this.text);
|
||||
else
|
||||
root.absorbFlatpaks(this.text);
|
||||
}
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: {
|
||||
root.busyEntryId = "";
|
||||
// Uninstalling changes what the catalog says is installed, and
|
||||
// installing changes what is installed. Both sides are re-read
|
||||
// rather than assumed.
|
||||
if (mutation.mode === "catalog")
|
||||
root.refresh();
|
||||
else
|
||||
root.refreshCatalog();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: permissionQuery
|
||||
|
||||
property var pending: []
|
||||
property string current: ""
|
||||
|
||||
function pump(): void {
|
||||
if (permissionQuery.running || permissionQuery.pending.length === 0)
|
||||
return;
|
||||
permissionQuery.current = String(permissionQuery.pending[0]);
|
||||
permissionQuery.command = [root.helperPath, "permissions", permissionQuery.current];
|
||||
permissionQuery.running = true;
|
||||
}
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const key = permissionQuery.current;
|
||||
let value = { summary: [], raw: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
value = {
|
||||
summary: Array.isArray(parsed.summary) ? parsed.summary : [],
|
||||
raw: parsed.raw ?? ({})
|
||||
};
|
||||
} catch (error) {
|
||||
// Cached as an honest empty answer rather than left absent,
|
||||
// or every repaint asks again for something that failed.
|
||||
value = { summary: ["Permissions could not be read"], raw: {} };
|
||||
}
|
||||
const next = Object.assign({}, root.permissionCache);
|
||||
next[key] = value;
|
||||
root.permissionCache = next;
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0 && root.permissionCache[permissionQuery.current] === undefined) {
|
||||
const next = Object.assign({}, root.permissionCache);
|
||||
next[permissionQuery.current] = { summary: ["Permissions could not be read"], raw: {} };
|
||||
root.permissionCache = next;
|
||||
}
|
||||
permissionQuery.pending = permissionQuery.pending.filter(
|
||||
id => String(id) !== permissionQuery.current);
|
||||
permissionQuery.current = "";
|
||||
permissionQuery.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,16 @@ Singleton {
|
||||
property var luaAutostartEntries: []
|
||||
property string lastError: ""
|
||||
|
||||
// One file type at a time: the escape hatch for when a role's family is too
|
||||
// broad, which is a real case -- SVG belongs in an editor while every other
|
||||
// image belongs in a viewer. Kept separate from the role state because it is
|
||||
// a search, not a setting: it is whatever was last asked for and nothing is
|
||||
// remembered between visits.
|
||||
property var typeMatches: []
|
||||
property string typeQuery: ""
|
||||
property bool typeSearchTruncated: false
|
||||
readonly property bool searchingTypes: typeSearch.running
|
||||
|
||||
// For the UI, which wants one answer to "is anything happening".
|
||||
//
|
||||
// Guards inside this file do NOT use it. `busy` is a binding, and a binding
|
||||
@@ -56,15 +66,48 @@ Singleton {
|
||||
Process {
|
||||
id: mutationProcess
|
||||
|
||||
// A set-type write changes the answer the open search is showing, so
|
||||
// the search is re-run rather than left displaying the old handler.
|
||||
property string repeatQuery: ""
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
mutationProcess.repeatQuery = ""
|
||||
root.lastError = "That application setting could not be changed."
|
||||
return;
|
||||
}
|
||||
if (mutationProcess.repeatQuery !== "") {
|
||||
const query = mutationProcess.repeatQuery;
|
||||
mutationProcess.repeatQuery = "";
|
||||
root.searchTypes(query);
|
||||
}
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: typeSearch
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const payload = JSON.parse(this.text);
|
||||
root.typeMatches = Array.isArray(payload.types) ? payload.types : [];
|
||||
root.typeSearchTruncated = payload.truncated === true;
|
||||
} catch (error) {
|
||||
root.typeMatches = [];
|
||||
root.lastError = "File types returned an unreadable response."
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.typeMatches = [];
|
||||
root.lastError = "File types could not be searched."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapshot(text: string): void {
|
||||
try {
|
||||
const payload = JSON.parse(text);
|
||||
@@ -135,6 +178,47 @@ Singleton {
|
||||
mutationProcess.exec([root.helper, "remove-autostart", desktopId]);
|
||||
}
|
||||
|
||||
// Searching is over the type name and its file extensions -- "svg", "heic",
|
||||
// ".mkv". Matching the human descriptions would mean reading the whole
|
||||
// shared-mime-info database, some thousands of small files, per keystroke.
|
||||
function searchTypes(query: string): void {
|
||||
const trimmed = String(query ?? "").trim();
|
||||
root.typeQuery = trimmed;
|
||||
if (trimmed.length < 2) {
|
||||
root.typeMatches = [];
|
||||
root.typeSearchTruncated = false;
|
||||
return;
|
||||
}
|
||||
if (typeSearch.running)
|
||||
return;
|
||||
typeSearch.exec([root.helper, "search-types", trimmed]);
|
||||
}
|
||||
|
||||
function clearTypeSearch(): void {
|
||||
root.typeQuery = "";
|
||||
root.typeMatches = [];
|
||||
root.typeSearchTruncated = false;
|
||||
}
|
||||
|
||||
// One type, one application: the override that leaves the rest of the
|
||||
// family where it is. The helper validates the type against what this
|
||||
// system knows and the application against what is installed.
|
||||
function setType(mime: string, desktopId: string): void {
|
||||
if (mutationProcess.running)
|
||||
return;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test(mime)) {
|
||||
root.lastError = "Choose a file type from the list."
|
||||
return;
|
||||
}
|
||||
if (!root.knownDesktopId(desktopId)) {
|
||||
root.lastError = "Choose an application from the available list."
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
mutationProcess.repeatQuery = root.typeQuery;
|
||||
mutationProcess.exec([root.helper, "set-type", mime, desktopId]);
|
||||
}
|
||||
|
||||
function addAutostart(desktopId: string): void {
|
||||
if (mutationProcess.running)
|
||||
return;
|
||||
|
||||
@@ -40,6 +40,32 @@ Singleton {
|
||||
// are different answers.
|
||||
property bool foldersMeasured: false
|
||||
|
||||
// What the used space is made of, and what could be freed.
|
||||
//
|
||||
// { segments: { home, applications, caches, system, free }, usedBytes,
|
||||
// totalBytes, freeBytes, complete, exceedsUsed }
|
||||
//
|
||||
// `system` is the remainder -- used bytes minus the three measured segments
|
||||
// -- and the page calls it "System & everything else" for that reason. It
|
||||
// is not a measurement of the system; it is everything the walk did not
|
||||
// reach. The segments never sum past used, and when a measurement would
|
||||
// overshoot, `exceedsUsed` says so instead of a number being scaled to make
|
||||
// the bar look tidy.
|
||||
property var breakdown: null
|
||||
property bool breakdownMeasured: false
|
||||
property bool measuringBreakdown: false
|
||||
|
||||
// [{ id, label, detail, bytes, privileged }]
|
||||
//
|
||||
// Nothing here is selected, ordered by urgency, or acted on. Each row is
|
||||
// freed only by its own id being passed to clean(), which is what keeps a
|
||||
// mis-wired button from emptying four things at once.
|
||||
property var cleanables: []
|
||||
property bool cleanablesMeasured: false
|
||||
property bool measuringCleanables: false
|
||||
// Which row is mid-clean, so the page can disable that one row.
|
||||
property string cleaningId: ""
|
||||
|
||||
readonly property var primaryDrive: root.drives.length > 0 ? root.drives[0] : null
|
||||
|
||||
// The filesystem the user means when they ask how full the machine is.
|
||||
@@ -110,6 +136,49 @@ Singleton {
|
||||
folderScan.running = true;
|
||||
}
|
||||
|
||||
// The same walk `scan` does, so it costs the same and is asked for on
|
||||
// demand rather than when the page opens.
|
||||
function measureBreakdown(): void {
|
||||
if (root.measuringBreakdown)
|
||||
return;
|
||||
root.measuringBreakdown = true;
|
||||
breakdownScan.running = true;
|
||||
}
|
||||
|
||||
function measureCleanables(): void {
|
||||
if (root.measuringCleanables)
|
||||
return;
|
||||
root.measuringCleanables = true;
|
||||
cleanableScan.running = true;
|
||||
}
|
||||
|
||||
// Exactly one, named. An id this service has not been told about is
|
||||
// refused here and refused again by the helper.
|
||||
function clean(identifier: string): void {
|
||||
if (cleaner.running || root.measuringCleanables)
|
||||
return;
|
||||
const known = root.cleanables.some(item => String(item.id) === String(identifier));
|
||||
if (!known) {
|
||||
root.lastError = "There is nothing by that name to clean up.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.cleaningId = String(identifier);
|
||||
cleaner.command = [root.helperPath, "clean", String(identifier)];
|
||||
cleaner.running = true;
|
||||
}
|
||||
|
||||
function absorbCleanables(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.cleanables = Array.isArray(parsed) ? parsed : [];
|
||||
root.cleanablesMeasured = true;
|
||||
} catch (error) {
|
||||
root.lastError = "Could not measure what could be cleaned up.";
|
||||
console.warn("Disks: could not parse cleanables output:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function unmount(devicePath: string): void {
|
||||
root.runMedia(["unmount", devicePath]);
|
||||
}
|
||||
@@ -171,6 +240,54 @@ Singleton {
|
||||
onExited: root.scanning = false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: breakdownScan
|
||||
command: [root.helperPath, "breakdown"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
root.breakdown = JSON.parse(this.text);
|
||||
root.breakdownMeasured = true;
|
||||
} catch (error) {
|
||||
root.lastError = "Could not measure what is using the drive.";
|
||||
console.warn("Disks: could not parse breakdown output:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: root.measuringBreakdown = false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: cleanableScan
|
||||
command: [root.helperPath, "cleanables"]
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: root.measuringCleanables = false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: cleaner
|
||||
// The helper answers with the fresh list, so the sizes on screen are
|
||||
// what is there now rather than what was there before the clean.
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbCleanables(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: {
|
||||
root.cleaningId = "";
|
||||
// Freeing space changes the drive's usage and its breakdown, and
|
||||
// both are on screen while this happens.
|
||||
root.refresh();
|
||||
if (root.breakdownMeasured)
|
||||
root.measureBreakdown();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: media
|
||||
stderr: StdioCollector {
|
||||
|
||||
@@ -92,6 +92,16 @@ Singleton {
|
||||
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
|
||||
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
|
||||
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
|
||||
// The Applications tab manages applications now, rather than only
|
||||
// pointing file types at them, so the things people come looking for —
|
||||
// what is installed, removing one, what it is allowed to reach — are
|
||||
// findable by their own names.
|
||||
{ label: "Installed applications", detail: "Everything installed here, where it came from, and how much room it takes", page: "applications" },
|
||||
{ label: "Uninstall an application", detail: "Remove a Flatpak application, or see the command for a system package", page: "applications" },
|
||||
{ label: "Application permissions", detail: "What a Flatpak application may reach: files, camera, microphone, network", page: "applications" },
|
||||
{ label: "Install applications", detail: "Browse the catalog Panama curates, by category", page: "applications" },
|
||||
{ label: "Autostart", detail: "Which applications start with your session", page: "applications" },
|
||||
{ label: "File associations", detail: "Which application opens each kind of file", page: "applications" },
|
||||
{ label: "User account", detail: "Your name, picture, and password", page: "users" },
|
||||
{ label: "Profile picture", detail: "The avatar shown on the lock screen and in the Control Center", page: "users" },
|
||||
{ label: "Change password", detail: "Set a new password for signing in", page: "users" },
|
||||
@@ -133,11 +143,14 @@ Singleton {
|
||||
{ label: "Backups", detail: "Automatic snapshots of the system and your home folder", page: "snapshots" },
|
||||
{ label: "File history", detail: "Earlier versions of your files", page: "snapshots" },
|
||||
{ label: "Undo a change", detail: "Put back a file as it was at an earlier point", page: "snapshots" },
|
||||
{ label: "Snapshot retention", detail: "How many hourly, daily, and weekly snapshots to keep", page: "snapshots" },
|
||||
{ label: "Free space", detail: "How full each drive and filesystem is", page: "storage" },
|
||||
{ label: "Disk usage", detail: "What is using the space on this machine", page: "storage" },
|
||||
{ label: "Drive health", detail: "Temperature, hours powered on, and reported warnings", page: "storage" },
|
||||
{ label: "Removable drives", detail: "Unmount a USB drive or memory card safely", page: "storage" },
|
||||
{ label: "Encryption", detail: "Whether the filesystem is encrypted", page: "storage" },
|
||||
{ label: "Clean up storage", detail: "Caches, trash, and unused runtimes, each itemized and sized before you remove it", page: "storage" },
|
||||
{ label: "Application caches", detail: "What applications have left in your cache folder, and clearing it", page: "storage" },
|
||||
{ label: "Output volume", detail: "Choose the output device and its level", page: "sound" },
|
||||
{ label: "Input volume", detail: "Choose the microphone and its level", page: "sound" },
|
||||
{ label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" },
|
||||
|
||||
@@ -44,6 +44,26 @@ Singleton {
|
||||
// inside the handler that changes it. See DefaultApps.qml.
|
||||
readonly property bool busy: query.running || mutation.running
|
||||
|
||||
// How many snapshots a horizon may keep. The page offers a dropdown rather
|
||||
// than a number field, and this is what fills it; the helper refuses
|
||||
// anything above it too. 999 hourly snapshots is not a retention policy,
|
||||
// it is a typo that fills a drive.
|
||||
//
|
||||
// The monthly and yearly limits are not editable here and are never
|
||||
// written, so a configuration with longer horizons keeps them.
|
||||
readonly property int retentionMax: 50
|
||||
readonly property var retentionChoices: {
|
||||
const values = [];
|
||||
for (let index = 0; index <= root.retentionMax; index += 1)
|
||||
values.push(index);
|
||||
return values;
|
||||
}
|
||||
|
||||
// The card the browser lives in is not the card it was opened from: the
|
||||
// browse state below is independent of any config row's expanded state, so
|
||||
// opening a snapshot from a collapsed row still shows the files.
|
||||
readonly property bool browserOpen: root.browsingConfig !== ""
|
||||
|
||||
function labelFor(config: var): string {
|
||||
const subvolume = String(config?.subvolume ?? "");
|
||||
if (subvolume === "/")
|
||||
@@ -128,8 +148,21 @@ Singleton {
|
||||
root.run(["delete", config, String(number)]);
|
||||
}
|
||||
|
||||
// Three horizons, each a whole number the dropdown offered. Checked here as
|
||||
// well as in the helper because the caller is a page that can be wrong, and
|
||||
// a keep count that arrives as "5.5" or "-1" would be written straight into
|
||||
// snapper's config file.
|
||||
function setRetention(config: string, hourly: int, daily: int, weekly: int): void {
|
||||
root.run(["set-retention", config, String(hourly), String(daily), String(weekly)]);
|
||||
const values = [hourly, daily, weekly];
|
||||
for (const value of values) {
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < 0 || number > root.retentionMax) {
|
||||
root.lastError = "Keep counts go from 0 to " + root.retentionMax + ".";
|
||||
return;
|
||||
}
|
||||
}
|
||||
root.run(["set-retention", config,
|
||||
String(values[0]), String(values[1]), String(values[2])]);
|
||||
}
|
||||
|
||||
function setTimeline(config: string, enabled: bool): void {
|
||||
|
||||
Reference in New Issue
Block a user