16.9 KiB · text History 49b9ecf
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kcmutils as KCM

import "Status.js" as Status

KCM.SimpleKCM {
    id: configGeneral

    property alias cfg_instanceUrl: instanceUrlField.text
    property alias cfg_accessToken: accessTokenField.text
    property string cfg_watched: "[]"

    // Connection test
    property bool isTestingConnection: false
    property string connectionTestResult: ""
    property bool connectionTestSuccess: false

    // Server listing
    property bool isLoadingRepos: false
    property string loadReposError: ""
    property var namespaces: []
    property var repositories: []

    readonly property var watched: {
        try {
            var parsed = JSON.parse(cfg_watched || "[]")
            return Array.isArray(parsed) ? parsed : []
        } catch (e) {
            return []
        }
    }

    readonly property bool canConnect: instanceUrlField.text.trim() !== "" && accessTokenField.text.trim() !== ""

    GitGudAPI {
        id: testAPI
        baseUrl: instanceUrlField.text
        accessToken: accessTokenField.text
    }

    function testConnection() {
        if (!canConnect) {
            connectionTestResult = i18n("Please enter both URL and access token")
            connectionTestSuccess = false
            return
        }
        isTestingConnection = true
        connectionTestResult = ""

        testAPI.testConnection(function(success, message) {
            isTestingConnection = false
            connectionTestResult = message
            connectionTestSuccess = success
            if (success) loadRepositories()
        })
    }

    function loadRepositories() {
        if (!canConnect) return
        isLoadingRepos = true
        loadReposError = ""
        testAPI.listRepos(function(success, data) {
            isLoadingRepos = false
            if (!success) {
                loadReposError = data
                return
            }
            namespaces = data.namespaces || []
            var repos = (data.repositories || []).slice()
            repos.sort(function(a, b) { return (a.full_name || "").localeCompare(b.full_name || "") })
            repositories = repos
        })
    }

    // ── watched list editing ────────────────────────────────────────────

    function saveWatched(list) {
        cfg_watched = JSON.stringify(list)
    }

    function isWatchedRepo(fullName) {
        return watched.some(function(w) { return w.kind === "repo" && w.fullName === fullName })
    }

    function isWatchedNamespace(handle) {
        return watched.some(function(w) { return w.kind === "namespace" && w.handle === handle })
    }

    function addRepo(fullName) {
        if (!fullName || isWatchedRepo(fullName)) return
        saveWatched(watched.concat([{ kind: "repo", fullName: fullName }]))
    }

    function addNamespace(handle) {
        if (!handle || isWatchedNamespace(handle)) return
        saveWatched(watched.concat([{ kind: "namespace", handle: handle, grouped: false }]))
    }

    function removeAt(index) {
        var list = watched.slice()
        list.splice(index, 1)
        saveWatched(list)
    }

    function setGrouped(index, grouped) {
        var list = watched.slice()
        list[index] = Object.assign({}, list[index], { grouped: grouped })
        saveWatched(list)
    }

    function move(index, delta) {
        var target = index + delta
        if (target < 0 || target >= watched.length) return
        var list = watched.slice()
        var item = list.splice(index, 1)[0]
        list.splice(target, 0, item)
        saveWatched(list)
    }

    function addManual() {
        var text = manualField.text.trim().replace(/^\/+|\/+$/g, "")
        if (!text) return
        if (text.indexOf("/") !== -1) addRepo(text)
        else addNamespace(text)
        manualField.text = ""
    }

    function repoCount(handle) {
        return repositories.filter(function(r) { return r.owner === handle }).length
    }

    readonly property var filteredRepos: {
        var q = searchField.text.trim().toLowerCase()
        if (!q) return repositories
        return repositories.filter(function(r) {
            return (r.full_name || "").toLowerCase().indexOf(q) !== -1
                || (r.description || "").toLowerCase().indexOf(q) !== -1
        })
    }

    Component.onCompleted: if (canConnect) Qt.callLater(loadRepositories)

    ColumnLayout {
        spacing: Kirigami.Units.largeSpacing

        // ── connection ──────────────────────────────────────────────────
        Kirigami.FormLayout {
            Layout.fillWidth: true

            QQC2.TextField {
                id: instanceUrlField
                Kirigami.FormData.label: i18n("GitGud URL:")
                placeholderText: i18n("https://git.example.com")
                Layout.fillWidth: true
                onTextChanged: connectionTestResult = ""
            }

            RowLayout {
                Kirigami.FormData.label: i18n("Access Token:")
                Layout.fillWidth: true

                QQC2.TextField {
                    id: accessTokenField
                    Layout.fillWidth: true
                    placeholderText: i18n("ggp_…")
                    echoMode: showTokenButton.checked ? TextInput.Normal : TextInput.Password
                    onTextChanged: connectionTestResult = ""
                }

                QQC2.ToolButton {
                    id: showTokenButton
                    checkable: true
                    icon.name: checked ? "password-show-off" : "password-show-on"
                    QQC2.ToolTip.text: checked ? i18n("Hide token") : i18n("Show token")
                    QQC2.ToolTip.visible: hovered
                }
            }

            RowLayout {
                Kirigami.FormData.label: i18n("Connection:")

                QQC2.Button {
                    text: i18n("Test Connection")
                    icon.name: "network-connect"
                    enabled: !isTestingConnection && canConnect
                    onClicked: testConnection()
                }

                QQC2.BusyIndicator {
                    visible: isTestingConnection
                    Layout.preferredWidth: Kirigami.Units.iconSizes.small
                    Layout.preferredHeight: Kirigami.Units.iconSizes.small
                }
            }

            QQC2.Label {
                visible: connectionTestResult !== ""
                text: connectionTestResult
                color: connectionTestSuccess ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.negativeTextColor
                wrapMode: Text.WordWrap
                Layout.fillWidth: true
                font: Kirigami.Theme.smallFont
            }

            QQC2.Label {
                Layout.fillWidth: true
                wrapMode: Text.WordWrap
                font: Kirigami.Theme.smallFont
                color: Kirigami.Theme.disabledTextColor
                textFormat: Text.StyledText
                onLinkActivated: function(link) { Qt.openUrlExternally(link) }
                text: {
                    var base = testAPI.normalizedBase()
                    var where = base ? "<a href=\"" + base + "/users/settings/tokens\">" + base + "/users/settings/tokens</a>"
                                     : i18n("Settings → Access tokens")
                    return i18n("Create a token at %1 and tick <b>Desktop widget</b> (repo:read, ci:read, ci:write). Without ci:write the widget is read-only.", where)
                }
            }
        }

        // ── watched ─────────────────────────────────────────────────────
        Kirigami.Heading {
            level: 3
            text: i18n("Watching")
        }

        QQC2.Label {
            visible: watched.length === 0
            Layout.fillWidth: true
            wrapMode: Text.WordWrap
            opacity: 0.7
            text: i18n("Nothing watched yet. Add repositories or whole users/organizations below.")
        }

        Repeater {
            model: watched

            delegate: RowLayout {
                required property var modelData
                required property int index
                readonly property bool isNamespace: modelData.kind === "namespace"

                Layout.fillWidth: true
                spacing: Kirigami.Units.smallSpacing

                Kirigami.Icon {
                    source: isNamespace ? "group" : "folder-git"
                    Layout.preferredWidth: Kirigami.Units.iconSizes.small
                    Layout.preferredHeight: Kirigami.Units.iconSizes.small
                }

                QQC2.Label {
                    Layout.fillWidth: true
                    elide: Text.ElideRight
                    text: isNamespace
                          ? i18n("%1/* (every repository)", modelData.handle)
                          : modelData.fullName
                }

                QQC2.CheckBox {
                    visible: isNamespace
                    text: i18n("One circle")
                    checked: !!modelData.grouped
                    onToggled: setGrouped(index, checked)
                    QQC2.ToolTip.text: i18n("Fold every repository in this namespace into a single circle showing the most urgent status")
                    QQC2.ToolTip.visible: hovered
                }

                QQC2.ToolButton {
                    icon.name: "go-up"
                    enabled: index > 0
                    onClicked: move(index, -1)
                }

                QQC2.ToolButton {
                    icon.name: "go-down"
                    enabled: index < watched.length - 1
                    onClicked: move(index, 1)
                }

                QQC2.ToolButton {
                    icon.name: "list-remove"
                    onClicked: removeAt(index)
                    QQC2.ToolTip.text: i18n("Stop watching")
                    QQC2.ToolTip.visible: hovered
                }
            }
        }

        RowLayout {
            Layout.fillWidth: true

            QQC2.TextField {
                id: manualField
                Layout.fillWidth: true
                placeholderText: i18n("owner/repository, or just owner for everything they own")
                onAccepted: addManual()
            }

            QQC2.Button {
                text: i18n("Add")
                icon.name: "list-add"
                enabled: manualField.text.trim() !== ""
                onClicked: addManual()
            }
        }

        // ── server listing ──────────────────────────────────────────────
        RowLayout {
            Layout.fillWidth: true

            Kirigami.Heading {
                Layout.fillWidth: true
                level: 3
                text: i18n("Available on the server")
            }

            QQC2.BusyIndicator {
                visible: isLoadingRepos
                Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
                Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
            }

            QQC2.Button {
                text: repositories.length > 0 ? i18n("Reload") : i18n("List Repositories")
                icon.name: "view-refresh"
                enabled: canConnect && !isLoadingRepos
                onClicked: loadRepositories()
            }
        }

        QQC2.Label {
            visible: loadReposError !== ""
            Layout.fillWidth: true
            wrapMode: Text.WordWrap
            color: Kirigami.Theme.negativeTextColor
            text: loadReposError
        }

        QQC2.Label {
            visible: !canConnect
            Layout.fillWidth: true
            wrapMode: Text.WordWrap
            opacity: 0.7
            text: i18n("Enter a URL and token to browse repositories.")
        }

        // Namespaces: umbrella watches
        QQC2.Label {
            visible: namespaces.length > 0
            text: i18n("Users & organizations")
            font.bold: true
        }

        Repeater {
            model: namespaces

            delegate: RowLayout {
                required property var modelData
                Layout.fillWidth: true
                spacing: Kirigami.Units.smallSpacing

                Kirigami.Icon {
                    source: modelData.kind === "org" ? "group" : "user"
                    Layout.preferredWidth: Kirigami.Units.iconSizes.small
                    Layout.preferredHeight: Kirigami.Units.iconSizes.small
                }

                QQC2.Label {
                    Layout.fillWidth: true
                    elide: Text.ElideRight
                    textFormat: Text.StyledText
                    text: "<b>" + modelData.handle + "</b>"
                          + (modelData.name && modelData.name !== modelData.handle ? " — " + modelData.name : "")
                          + " <font color=\"" + Kirigami.Theme.disabledTextColor + "\">"
                          + i18np("(%1 repository)", "(%1 repositories)", repoCount(modelData.handle)) + "</font>"
                }

                QQC2.Button {
                    text: isWatchedNamespace(modelData.handle) ? i18n("Watching") : i18n("Watch all")
                    icon.name: isWatchedNamespace(modelData.handle) ? "checkmark" : "list-add"
                    enabled: !isWatchedNamespace(modelData.handle)
                    onClicked: addNamespace(modelData.handle)
                }
            }
        }

        // Individual repositories
        QQC2.Label {
            visible: repositories.length > 0
            text: i18n("Repositories")
            font.bold: true
        }

        Kirigami.SearchField {
            id: searchField
            visible: repositories.length > 0
            Layout.fillWidth: true
            placeholderText: i18n("Filter repositories…")
        }

        QQC2.ScrollView {
            visible: repositories.length > 0
            Layout.fillWidth: true
            Layout.preferredHeight: Kirigami.Units.gridUnit * 14

            ListView {
                id: repoList
                clip: true
                model: filteredRepos

                delegate: QQC2.ItemDelegate {
                    id: repoDelegate
                    required property var modelData
                    readonly property bool explicit: isWatchedRepo(modelData.full_name)
                    readonly property bool viaNamespace: isWatchedNamespace(modelData.owner)

                    width: repoList.width

                    contentItem: RowLayout {
                        spacing: Kirigami.Units.smallSpacing

                        StatusCircle {
                            status: repoDelegate.modelData.ci_status || "unknown"
                            Layout.preferredWidth: Kirigami.Units.iconSizes.small
                            Layout.preferredHeight: Kirigami.Units.iconSizes.small
                        }

                        ColumnLayout {
                            Layout.fillWidth: true
                            spacing: 0

                            QQC2.Label {
                                Layout.fillWidth: true
                                elide: Text.ElideRight
                                text: repoDelegate.modelData.full_name
                                      + (repoDelegate.modelData.archived ? " " + i18n("(archived)") : "")
                            }

                            QQC2.Label {
                                visible: !!repoDelegate.modelData.description
                                Layout.fillWidth: true
                                elide: Text.ElideRight
                                font: Kirigami.Theme.smallFont
                                opacity: 0.7
                                text: repoDelegate.modelData.description || ""
                            }
                        }

                        QQC2.Label {
                            visible: repoDelegate.viaNamespace && !repoDelegate.explicit
                            font: Kirigami.Theme.smallFont
                            opacity: 0.7
                            text: i18n("via %1", repoDelegate.modelData.owner)
                        }

                        QQC2.Button {
                            text: repoDelegate.explicit ? i18n("Watching") : i18n("Add")
                            icon.name: repoDelegate.explicit ? "checkmark" : "list-add"
                            enabled: !repoDelegate.explicit
                            onClicked: addRepo(repoDelegate.modelData.full_name)
                        }
                    }
                }

                QQC2.Label {
                    anchors.centerIn: parent
                    visible: repoList.count === 0
                    opacity: 0.7
                    text: i18n("No matching repositories")
                }
            }
        }
    }
}