// Copyright (C) 2026 Danilo M. // // 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 property string loadError: "" // 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 // The section whose add row is open, and what is typed in it. The text // lives here rather than in the fields because every reload rebuilds the // rows, and a failed add should keep what was typed. property string adding: "" property string newKey: "" property string newValue: "" onAddingChanged: { newKey = ""; newValue = ""; errors = ({}); } function add() { if (page.newKey.trim() === "") return; page.write(["add", page.adding + "." + page.newKey.trim(), page.newValue], "add", () => page.adding = ""); } 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 => { // gitcfg reports a file git cannot parse as one error= line; // without it the page would draw an empty config. page.loadError = out.startsWith("error=") ? out.slice(6).trim() : ""; page.entries = page.loadError ? [] : 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"]) } Text { width: parent.width visible: page.loadError !== "" text: page.loadError wrapMode: Text.Wrap color: Theme.red font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } // The add row under a section's heading: key and value. Enter in the key // moves to the value, Enter there adds, Escape closes the row. Component { id: addRow Column { spacing: 4 Item { width: parent.width height: 34 Rectangle { id: keyBox anchors.left: parent.left width: parent.width * 0.32 height: parent.height radius: 8 color: Theme.surface TextInput { activeFocusOnTab: true id: keyIn 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: page.newKey onTextEdited: page.newKey = text onAccepted: valIn.forceActiveFocus() Keys.onEscapePressed: page.adding = "" Component.onCompleted: forceActiveFocus() Text { anchors.verticalCenter: parent.verticalCenter visible: parent.text === "" text: "key" color: Theme.subtext font: parent.font } } } Rectangle { anchors { left: keyBox.right; leftMargin: 8; right: addBtn.left; rightMargin: 8 } height: parent.height radius: 8 color: Theme.surface TextInput { activeFocusOnTab: true id: valIn 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: page.newValue onTextEdited: page.newValue = text onAccepted: page.add() Keys.onEscapePressed: page.adding = "" 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: page.newKey.trim() !== "" onClicked: page.add() } } 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 } } } } 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, with + to add a key // to it. git config --add writes into the existing section, so // the new key lands with its neighbours in the file. Item { id: heading visible: row.index === 0 || page.entries[row.index - 1].section !== row.modelData.section width: parent.width height: 40 Text { anchors { left: parent.left; bottom: parent.bottom; bottomMargin: 6 } text: row.modelData.section font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } color: Theme.text } Button { anchors { right: parent.right; bottom: parent.bottom; bottomMargin: 2 } text: "+" onClicked: page.adding = page.adding === row.modelData.section ? "" : row.modelData.section } } Loader { width: parent.width active: heading.visible && page.adding === row.modelData.section visible: active sourceComponent: addRow } // An Item with anchors, not a Row: a Row sizes to its children // and fixed widths in one overflow the page (AGENTS.md). Item { // What a search hit scrolls to (GitModule's searchItems key). objectName: row.tag 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 { activeFocusOnTab: true 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 } } } } }