Make the Dock editable and add settings snapshots
The Dock's pinned applications were a sixteen-entry literal in Settings.qml, so changing what sits in the Dock meant editing QML. They are now an ordered list in the shared store, with move up, move down, unpin, and a filtered picker for adding installed applications. Keeping them in the shared store rather than a file of their own means they are covered by Restore defaults like everything else. This needed a "json" schema type for values the schema stores and resets but does not validate field by field. It exists so structured settings can live in the one file rather than growing a fourth preference store; the owning service validates the contents. Snapshots make the settings app safe to experiment with. The whole configuration is one file, so a backup is a copy and a restore is an overwrite, and restoring snapshots what it replaces so it is itself undoable. A snapshot is validated as JSON before it can be restored over a working configuration, and a name that is not a plain snapshot filename from the backup directory is refused. Snapshot names carry milliseconds. At one-second resolution a save followed promptly by a restore produced the same filename twice, and the restore's own safety snapshot overwrote the file it was about to read -- found by the contract, which restores immediately after saving. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -79,6 +79,14 @@ Singleton {
|
||||
persistTimer.restart();
|
||||
}
|
||||
|
||||
// Re-read the file from disk. Used after something outside the shell has
|
||||
// rewritten it -- restoring a snapshot, or a hand edit -- so the running
|
||||
// desktop reflects the new contents without waiting for the next change.
|
||||
function reload(): void {
|
||||
preferencesFile.reload();
|
||||
root.load();
|
||||
}
|
||||
|
||||
function load(): void {
|
||||
let parsed = {};
|
||||
try {
|
||||
|
||||
@@ -15,7 +15,7 @@ pragma Singleton
|
||||
//
|
||||
// Entry fields
|
||||
// key unique identifier; also the JSON key on disk
|
||||
// type "bool" | "int" | "real" | "string" | "enum"
|
||||
// type "bool" | "int" | "real" | "string" | "enum" | "json"
|
||||
// def shipped default, used when the file is absent or a value is invalid
|
||||
// min/max inclusive bounds for int and real; values outside are clamped
|
||||
// step UI increment for int and real
|
||||
@@ -25,6 +25,13 @@ pragma Singleton
|
||||
// detail one line explaining what changing it does
|
||||
// internal true for state the shell keeps but the user never edits directly
|
||||
// pattern for "string": a regular expression the value must match in full
|
||||
//
|
||||
// "json" holds a structured value -- a list or an object -- that the schema
|
||||
// stores and resets but does not validate field by field. It exists so that
|
||||
// settings like the dock's pinned applications live in the same file, and are
|
||||
// covered by the same reset, as everything else rather than growing a fourth
|
||||
// preference store. The service that owns such a value is responsible for
|
||||
// validating it; see services/Dock-related consumers.
|
||||
// hypr present when the setting maps onto an Hyprland option:
|
||||
// path the hl.config table path, e.g. ["decoration","blur","size"]
|
||||
// option the getoption path used to read the value back
|
||||
@@ -465,6 +472,26 @@ Singleton {
|
||||
]
|
||||
},
|
||||
|
||||
// ── Dock contents ───────────────────────────────────────────────────
|
||||
// A "json" value: the ordered list of desktop entry ids pinned to the
|
||||
// dock. Kept in the shared store so that reordering the dock is covered
|
||||
// by Restore defaults like everything else, rather than living in its
|
||||
// own file. The shipped order is the GNOME dash it replaced.
|
||||
{
|
||||
key: "dockPinned", type: "json", group: "dock",
|
||||
label: "Pinned applications",
|
||||
detail: "Applications that stay in the Dock whether or not they are running",
|
||||
def: [
|
||||
"org.gnome.Settings", "kitty", "org.gnome.Nautilus",
|
||||
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||
"io.podman_desktop.PodmanDesktop", "claude-desktop",
|
||||
"codex-desktop", "md.obsidian.Obsidian",
|
||||
"com.obsproject.Studio", "steam"
|
||||
]
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||
@@ -542,6 +569,13 @@ Singleton {
|
||||
case "enum":
|
||||
return entry.options.some(option => option.value === value) ? value : undefined;
|
||||
|
||||
case "json":
|
||||
// Accepted as-is. Anything JSON.parse produced is representable,
|
||||
// and per-field meaning belongs to the owning service rather than
|
||||
// here. A scalar is rejected so a corrupt file falls back to the
|
||||
// default instead of handing a list-shaped consumer a number.
|
||||
return (typeof value === "object") ? value : undefined;
|
||||
|
||||
case "string": {
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
// A constrained string is rejected rather than sanitised. Several
|
||||
|
||||
@@ -63,24 +63,7 @@ Singleton {
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────────
|
||||
// Pinned apps, in order, taken from the GNOME dash favourites.
|
||||
readonly property list<string> dockPinned: [
|
||||
"org.gnome.Settings",
|
||||
"kitty",
|
||||
"org.gnome.Nautilus",
|
||||
"com.bitwarden.desktop",
|
||||
"org.gnome.Software",
|
||||
"helium",
|
||||
"org.mozilla.thunderbird_esr",
|
||||
"com.slack.Slack",
|
||||
"app.bluebubbles.BlueBubbles",
|
||||
"rustdesk",
|
||||
"io.podman_desktop.PodmanDesktop",
|
||||
"claude-desktop",
|
||||
"codex-desktop",
|
||||
"md.obsidian.Obsidian",
|
||||
"com.obsproject.Studio",
|
||||
"steam"
|
||||
]
|
||||
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||
|
||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||
// when any window would overlap it, and comes back on hover.
|
||||
|
||||
@@ -25,6 +25,26 @@ SettingsPage {
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Pinned applications"
|
||||
subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
|
||||
|
||||
DockPinsEditor {
|
||||
id: pins
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Pin another application"
|
||||
|
||||
DockAppPicker {
|
||||
width: parent.width
|
||||
pinned: pins.pinned
|
||||
onPicked: id => pins.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Window layout"
|
||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
||||
@@ -54,6 +74,41 @@ SettingsPage {
|
||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Snapshots"
|
||||
subtitle: SettingsBackup.lastError !== ""
|
||||
? SettingsBackup.lastError
|
||||
: "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable."
|
||||
|
||||
ActionRow {
|
||||
label: "Back up current settings"
|
||||
detail: SettingsBackup.snapshots.length === 0
|
||||
? "No snapshots yet"
|
||||
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first"
|
||||
action: "Back up now"
|
||||
enabled: !SettingsBackup.busy
|
||||
divider: SettingsBackup.snapshots.length > 0
|
||||
onTriggered: SettingsBackup.save()
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: snapshotRows
|
||||
model: SettingsBackup.snapshots
|
||||
|
||||
ActionRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: modelData.when
|
||||
detail: modelData.keys + " settings"
|
||||
action: "Restore"
|
||||
enabled: !SettingsBackup.busy
|
||||
divider: index < snapshotRows.count - 1
|
||||
onTriggered: SettingsBackup.restore(modelData.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Reset"
|
||||
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Adds an installed application to the Dock.
|
||||
//
|
||||
// Filtered rather than a full list: there are several hundred desktop entries
|
||||
// on a normal system, and rendering them all into a page that is already
|
||||
// scrolling is both slow and useless. Typing narrows; nothing shows until you
|
||||
// do, which also keeps the card short when you are not using it.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
required property var pinned
|
||||
|
||||
signal picked(string id)
|
||||
|
||||
readonly property var matches: {
|
||||
const needle = search.text.trim().toLowerCase();
|
||||
if (needle === "")
|
||||
return [];
|
||||
const out = [];
|
||||
for (const entry of DesktopEntries.applications.values) {
|
||||
if (entry.noDisplay)
|
||||
continue;
|
||||
if (root.pinned.indexOf(entry.id) >= 0)
|
||||
continue;
|
||||
if (String(entry.name).toLowerCase().indexOf(needle) >= 0)
|
||||
out.push(entry);
|
||||
if (out.length >= 8)
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
SearchField {
|
||||
id: search
|
||||
width: parent.width
|
||||
placeholder: "Search installed applications"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.matches
|
||||
|
||||
SettingRow {
|
||||
id: candidate
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: candidate.modelData.name
|
||||
detail: candidate.modelData.id
|
||||
divider: candidate.index < root.matches.length - 1
|
||||
controlWidth: 86
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Pin"
|
||||
onClicked: {
|
||||
root.picked(candidate.modelData.id);
|
||||
search.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// The Dock's pinned applications: reorder, remove, and add.
|
||||
//
|
||||
// The list was a sixteen-entry literal in Settings.qml, so changing what sits
|
||||
// in the Dock meant editing a QML file and reloading the shell. It is a plain
|
||||
// ordered list of desktop entry ids, stored in the shared settings file, which
|
||||
// means it is covered by Restore defaults like everything else.
|
||||
//
|
||||
// Move up / move down rather than drag-and-drop. Dragging inside a Flickable
|
||||
// that is itself inside a scrolling page is a genuinely hard interaction to get
|
||||
// right, and it fails in a way the user reads as the app being broken; two
|
||||
// buttons are unambiguous and keyboard-reachable.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
readonly property var pinned: {
|
||||
const stored = DesktopPreferences.get("dockPinned");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
|
||||
// DesktopEntries populates asynchronously, so this must be read as a
|
||||
// binding rather than looked up inside one -- byId() called during
|
||||
// evaluation registers no dependency and answers from an empty list.
|
||||
readonly property var entriesById: {
|
||||
const index = {};
|
||||
for (const entry of DesktopEntries.applications.values)
|
||||
index[entry.id] = entry;
|
||||
return index;
|
||||
}
|
||||
|
||||
function nameFor(id: string): string {
|
||||
const entry = root.entriesById[id];
|
||||
return entry ? entry.name : id;
|
||||
}
|
||||
|
||||
function commit(next: var): void {
|
||||
DesktopPreferences.set("dockPinned", next);
|
||||
}
|
||||
|
||||
function move(from: int, to: int): void {
|
||||
if (to < 0 || to >= root.pinned.length)
|
||||
return;
|
||||
const next = root.pinned.slice();
|
||||
const moved = next.splice(from, 1)[0];
|
||||
next.splice(to, 0, moved);
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function remove(index: int): void {
|
||||
const next = root.pinned.slice();
|
||||
next.splice(index, 1);
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function add(id: string): void {
|
||||
if (root.pinned.indexOf(id) >= 0)
|
||||
return;
|
||||
root.commit(root.pinned.concat([id]));
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.pinned
|
||||
|
||||
SettingRow {
|
||||
id: pin
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: root.nameFor(pin.modelData)
|
||||
detail: pin.modelData
|
||||
divider: pin.index < root.pinned.length - 1
|
||||
controlWidth: 132
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
SettingsButton {
|
||||
text: "↑"
|
||||
enabled: pin.index > 0
|
||||
onClicked: root.move(pin.index, pin.index - 1)
|
||||
}
|
||||
SettingsButton {
|
||||
text: "↓"
|
||||
enabled: pin.index < root.pinned.length - 1
|
||||
onClicked: root.move(pin.index, pin.index + 1)
|
||||
}
|
||||
SettingsButton {
|
||||
text: "Unpin"
|
||||
onClicked: root.remove(pin.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: root.pinned.length === 0
|
||||
label: "Nothing is pinned"
|
||||
detail: "The Dock will only show running applications"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -32,3 +32,5 @@ DateTimePage 1.0 DateTimePage.qml
|
||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||
DockAppPicker 1.0 DockAppPicker.qml
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Snapshots of the Panama settings store.
|
||||
#
|
||||
# The whole desktop configuration is one JSON file, which makes a backup a copy
|
||||
# and a restore an overwrite. That is worth exposing: the settings app now
|
||||
# changes real things -- compositor geometry, idle timeouts, the dock -- and
|
||||
# being able to get back to a known-good state without hunting through git is
|
||||
# the difference between experimenting freely and being cautious.
|
||||
#
|
||||
# panama-settings-backup save snapshot the current settings
|
||||
# panama-settings-backup list JSON list of snapshots, newest first
|
||||
# panama-settings-backup restore <name> replace settings with a snapshot
|
||||
#
|
||||
# Snapshots are validated as JSON on the way in and on the way out, so a
|
||||
# truncated file can never be restored over a working configuration.
|
||||
#
|
||||
# Names carry milliseconds. At one-second resolution a save followed promptly by
|
||||
# a restore produced the same filename twice, and the restore's own safety
|
||||
# snapshot overwrote the very file it was about to read.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups"
|
||||
keep=15
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
save)
|
||||
[[ -r "$settings" ]] || fail "No settings file to back up."
|
||||
jq -e . "$settings" >/dev/null 2>&1 || fail "The current settings file is not valid JSON."
|
||||
mkdir -p "$backup_dir"
|
||||
stamp="$(date +%Y%m%d-%H%M%S%3N)"
|
||||
cp "$settings" "$backup_dir/settings-$stamp.json"
|
||||
# Keep the most recent few. A snapshot per change would otherwise grow
|
||||
# without bound in a directory nobody ever looks at.
|
||||
ls -1t "$backup_dir"/settings-*.json 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
|
||||
rm -f "$old"
|
||||
done
|
||||
printf '{"saved":"settings-%s.json"}\n' "$stamp"
|
||||
;;
|
||||
|
||||
list)
|
||||
mkdir -p "$backup_dir"
|
||||
first=true
|
||||
printf '['
|
||||
for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do
|
||||
name="$(basename "$file")"
|
||||
# settings-20260818-004512.json -> 2026-08-18 00:45
|
||||
raw="${name#settings-}"; raw="${raw%.json}"
|
||||
pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}"
|
||||
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
|
||||
[[ "$first" == true ]] || printf ','
|
||||
first=false
|
||||
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
|
||||
done
|
||||
printf ']\n'
|
||||
;;
|
||||
|
||||
restore)
|
||||
name="${2:-}"
|
||||
[[ -n "$name" ]] || fail "Which snapshot?"
|
||||
# Only a bare filename from the backup directory, so a caller cannot
|
||||
# walk out of it with a path.
|
||||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "Not a snapshot name."
|
||||
source_file="$backup_dir/$name"
|
||||
[[ -r "$source_file" ]] || fail "That snapshot is missing."
|
||||
jq -e . "$source_file" >/dev/null 2>&1 || fail "That snapshot is not valid JSON."
|
||||
|
||||
# Snapshot what is being replaced, so restore is itself undoable.
|
||||
if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then
|
||||
mkdir -p "$backup_dir"
|
||||
cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
cp "$source_file" "$settings.tmp"
|
||||
mv "$settings.tmp" "$settings"
|
||||
printf '{"restored":"%s"}\n' "$name"
|
||||
;;
|
||||
|
||||
*)
|
||||
fail "usage: panama-settings-backup [save|list|restore <name>]"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,90 @@
|
||||
pragma Singleton
|
||||
|
||||
// Snapshots of the settings store.
|
||||
//
|
||||
// The whole desktop configuration is one JSON file, so a backup is a copy and a
|
||||
// restore is an overwrite. Worth exposing now that the settings app changes
|
||||
// real things -- compositor geometry, idle timeouts, the dock -- because being
|
||||
// able to return to a known-good state is what makes experimenting feel safe.
|
||||
//
|
||||
// Restoring rewrites the file underneath the running shell, so the store is
|
||||
// told to re-read afterwards rather than waiting for the next change.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-backup"
|
||||
|
||||
property var snapshots: []
|
||||
property string lastError: ""
|
||||
property string lastAction: ""
|
||||
|
||||
readonly property bool busy: listQuery.running || actionRun.running
|
||||
|
||||
Process {
|
||||
id: listQuery
|
||||
command: [root.helperPath, "list"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the list of snapshots.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: actionRun
|
||||
property bool restoring: false
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.lastError = actionRun.restoring
|
||||
? "That snapshot could not be restored."
|
||||
: "The settings could not be backed up.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||
if (actionRun.restoring)
|
||||
DesktopPreferences.reload();
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
function refresh(): void {
|
||||
if (!listQuery.running)
|
||||
listQuery.running = true;
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (actionRun.running)
|
||||
return;
|
||||
actionRun.restoring = false;
|
||||
actionRun.exec([root.helperPath, "save"]);
|
||||
}
|
||||
|
||||
// The name is matched against the snapshot list rather than trusted, so no
|
||||
// caller-supplied path reaches the helper even though it validates as well.
|
||||
function restore(name: string): bool {
|
||||
if (actionRun.running)
|
||||
return false;
|
||||
if (!root.snapshots.some(snapshot => snapshot.name === name)) {
|
||||
root.lastError = "That snapshot is not in the list.";
|
||||
return false;
|
||||
}
|
||||
actionRun.restoring = true;
|
||||
actionRun.exec([root.helperPath, "restore", name]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user