diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 11:19:09 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 11:19:09 +0200 |
| commit | 80c9d85f2bc45ce8991c4fb17cbb2739bbdcdad0 (patch) | |
| tree | 57231052ce12809d0522ba07711e23f6e97ed964 /desktop/modules | |
| parent | 31a6293e982e212ea5136659dcccce271fe37be1 (diff) | |
| download | quickshell-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/modules')
| -rw-r--r-- | desktop/modules/git/GitModule.qml | 32 | ||||
| -rw-r--r-- | desktop/modules/git/GitPage.qml | 255 |
2 files changed, 287 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 } + } +} |
