aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 11:19:09 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 11:19:09 +0200
commit80c9d85f2bc45ce8991c4fb17cbb2739bbdcdad0 (patch)
tree57231052ce12809d0522ba07711e23f6e97ed964 /desktop
parent31a6293e982e212ea5136659dcccce271fe37be1 (diff)
downloadquickshell-80c9d85f2bc45ce8991c4fb17cbb2739bbdcdad0.tar.gz
quickshell-80c9d85f2bc45ce8991c4fb17cbb2739bbdcdad0.zip
feat(git): the global git config page
Every global value as a row under its section, in file order: Enter saves, Escape undoes, Remove drops that one value, and an Add row takes section.key and a value. Each write goes through gitcfg and is followed by a fresh list, so a row shows what git holds; a failure shows git's reason under its row. "Edit in editor" opens ~/.gitconfig in Sublime Text. No tile: up is "user", so back returns to the user page it opens from. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop')
-rw-r--r--desktop/modules/git/GitModule.qml32
-rw-r--r--desktop/modules/git/GitPage.qml255
-rw-r--r--desktop/shell.qml2
3 files changed, 289 insertions, 0 deletions
diff --git a/desktop/modules/git/GitModule.qml b/desktop/modules/git/GitModule.qml
new file mode 100644
index 0000000..7b1cecf
--- /dev/null
+++ b/desktop/modules/git/GitModule.qml
@@ -0,0 +1,32 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// The global git config. No tile: the user page's Git section opens it, and
+// back returns there.
+Module {
+ id: gm
+ name: "git"
+ label: "Git config"
+ // nf-dev-git, present in Inconsolata Nerd Font.
+ icon: ""
+ tiled: false
+ up: "user"
+
+ page: Component {
+ Page {
+ title: "Git config"
+ GitPage { width: parent.width; owner: gm }
+ }
+ }
+}
diff --git a/desktop/modules/git/GitPage.qml b/desktop/modules/git/GitPage.qml
new file mode 100644
index 0000000..c1bffb5
--- /dev/null
+++ b/desktop/modules/git/GitPage.qml
@@ -0,0 +1,255 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "../.."
+import "Git.js" as G
+
+// Every global git value, one row each, grouped by section in file order.
+// Enter on a row saves it, Escape restores it, Remove drops that one value.
+// Every write goes through gitcfg and is followed by a fresh list, so a row
+// always shows what git holds. A failed write shows git's reason under the
+// row it came from.
+Column {
+ id: page
+
+ spacing: 6
+
+ readonly property string ctl: Qt.resolvedUrl("gitcfg").toString().replace(/^file:\/\//, "")
+ property var entries: []
+ // Keyed by tag(): name and value, which survive the reload after a failed
+ // write because the value did not change.
+ property var errors: ({})
+ property bool busy: false
+ // The module, which outlives the page: a write in flight finishes even if
+ // the page is closed. Not named `gm`, the module's id (it would bind to itself).
+ required property QtObject owner
+
+ function tag(e) { return e.name + "=" + e.value; }
+
+ // One Process per call, so reads and writes never share one (a running
+ // Process ignores a second start, AGENTS.md). cb gets stdout.
+ function run(cmd, cb) {
+ op.createObject(page.owner, { command: cmd, cb: cb }).running = true;
+ }
+ Component {
+ id: op
+ Process {
+ id: proc
+ property var cb: null
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { proc.cb(text); }
+ finally { proc.destroy(); }
+ }
+ }
+ }
+ }
+
+ function load() {
+ page.run([page.ctl, "list"], out => {
+ page.entries = G.parse(out);
+ page.busy = false;
+ });
+ }
+
+ // errKey: where a failure is shown. ok: run on success only.
+ function write(args, errKey, ok) {
+ if (page.busy) return;
+ page.busy = true;
+ page.run([page.ctl].concat(args), out => {
+ const e = {};
+ if (out.trim() !== "") e[errKey] = out.trim();
+ else if (ok) ok();
+ page.errors = e;
+ page.load();
+ });
+ }
+
+ Component.onCompleted: page.load()
+
+ // Detached, so the editor outlives the drawer. ponytail: ~/.gitconfig is
+ // the one global file here; ~/.config/git/config would be missed.
+ Button {
+ text: "Edit in editor"
+ onClicked: Quickshell.execDetached(["sublime_text", Quickshell.env("HOME") + "/.gitconfig"])
+ }
+
+ Repeater {
+ model: page.entries
+
+ delegate: Column {
+ id: row
+ required property var modelData
+ required property int index
+ readonly property string tag: page.tag(modelData)
+ width: page.width
+ spacing: 4
+ enabled: !page.busy
+
+ // A section's heading, above its first row.
+ Text {
+ visible: row.index === 0 || page.entries[row.index - 1].section !== row.modelData.section
+ topPadding: 10
+ bottomPadding: 2
+ text: row.modelData.section
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+
+ // An Item with anchors, not a Row: a Row sizes to its children
+ // and fixed widths in one overflow the page (AGENTS.md).
+ Item {
+ width: parent.width
+ height: 34
+
+ Text {
+ id: keyLabel
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: parent.width * 0.32
+ elide: Text.ElideRight
+ text: row.modelData.key
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+
+ Rectangle {
+ anchors { left: keyLabel.right; leftMargin: 8; right: del.left; rightMargin: 8 }
+ height: parent.height
+ radius: 8
+ color: Theme.surface
+
+ // ponytail: one line; a value holding a newline (a long
+ // alias) shows joined and is best edited in the editor.
+ TextInput {
+ id: input
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ text: row.modelData.value
+ onAccepted: if (text !== row.modelData.value)
+ page.write(["set", row.modelData.name, row.modelData.value, text], row.tag)
+ // An edit in progress is undone first; an unedited
+ // field lets Escape through, to leave the page.
+ Keys.onEscapePressed: event => {
+ if (text !== row.modelData.value) text = row.modelData.value;
+ else event.accepted = false;
+ }
+ }
+ }
+
+ Button {
+ id: del
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Remove"
+ danger: true
+ onClicked: page.write(["unset", row.modelData.name, row.modelData.value], row.tag)
+ }
+ }
+
+ Text {
+ width: parent.width
+ visible: !!page.errors[row.tag]
+ text: page.errors[row.tag] ?? ""
+ wrapMode: Text.Wrap
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+ }
+
+ // Add: section.key and a value. The fields clear only when git took it.
+ Text {
+ topPadding: 10
+ bottomPadding: 2
+ text: "Add"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+
+ Item {
+ width: parent.width
+ height: 34
+ enabled: !page.busy
+
+ Rectangle {
+ id: newKeyBox
+ anchors { left: parent.left }
+ width: parent.width * 0.32
+ height: parent.height
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: newKey
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: "section.key"
+ color: Theme.subtext
+ font: parent.font
+ }
+ }
+ }
+
+ Rectangle {
+ anchors { left: newKeyBox.right; leftMargin: 8; right: addBtn.left; rightMargin: 8 }
+ height: parent.height
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: newValue
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ onAccepted: if (addBtn.enabled) addBtn.clicked()
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: parent.text === ""
+ text: "value"
+ color: Theme.subtext
+ font: parent.font
+ }
+ }
+ }
+
+ Button {
+ id: addBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Add"
+ enabled: newKey.text.trim() !== ""
+ onClicked: page.write(["add", newKey.text.trim(), newValue.text], "add",
+ () => { newKey.text = ""; newValue.text = ""; })
+ }
+ }
+
+ Text {
+ width: parent.width
+ visible: !!page.errors.add
+ text: page.errors.add ?? ""
+ wrapMode: Text.Wrap
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+}
diff --git a/desktop/shell.qml b/desktop/shell.qml
index 35a7a14..f1fb64e 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -21,6 +21,7 @@ import "modules/ai"
import "modules/appearance"
import "modules/bluetooth"
import "modules/clipboard"
+import "modules/git"
import "modules/kdeconnect"
import "modules/mail"
import "modules/network"
@@ -69,6 +70,7 @@ ShellRoot {
AiModule {},
AppearanceModule {},
UserModule {},
+ GitModule {},
]
}