26.0 KiB · text History 010513e
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.plasma.plasmoid
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.components as PlasmaComponents3
import org.kde.kirigami as Kirigami

import "Status.js" as Status

PlasmoidItem {
    id: root

    switchWidth: Kirigami.Units.gridUnit * 20
    switchHeight: Kirigami.Units.gridUnit * 16

    // In a panel always show the circles, however much room we're given;
    // on the desktop let Plasma pick by size.
    preferredRepresentation: Plasmoid.formFactor === PlasmaCore.Types.Horizontal
                             || Plasmoid.formFactor === PlasmaCore.Types.Vertical
                             ? compactRepresentation : null

    Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground | PlasmaCore.Types.ConfigurableBackground

    // Each circle carries its own tooltip; the applet-wide one would
    // just sit on top of them.
    toolTipMainText: ""
    toolTipSubText: ""

    readonly property url logoSource: Qt.resolvedUrl("../icons/gitgud.svg")

    property string instanceUrl: Plasmoid.configuration.instanceUrl
    property string accessToken: Plasmoid.configuration.accessToken
    property int updateInterval: Math.max(10, Plasmoid.configuration.updateInterval)
    property int activeInterval: Math.max(2, Plasmoid.configuration.activeInterval)
    property int runsPerRepo: Math.max(1, Plasmoid.configuration.runsPerRepo)
    property string watchedJson: Plasmoid.configuration.watched
    property bool hideIdle: Plasmoid.configuration.hideIdle

    property var watched: []
    property bool hasValidConfig: api.configured
    property bool hasWatched: watched.length > 0

    // Server state
    property var repoIndex: ({})        // full_name -> repository row from /repos
    property var runsByRepo: ({})       // full_name -> [run]
    property var runDetails: ({})       // "owner/name#n" -> {run, jobs, artifacts, can_manage}
    property var jobLogs: ({})          // job row id -> {job, steps, truncated}
    property var circleRepos: ({})      // circle key -> [full_name]
    property bool anyActive: false
    property bool loading: false
    property bool refreshQueued: false  // a full refresh asked for mid-poll
    property double loadingSince: 0
    property int refreshGeneration: 0   // bumped to orphan an in-flight refresh
    property bool offline: false        // last /repos never reached the server
    property bool busy: false
    property string lastError: ""
    property double lastFullRefresh: 0
    property double now: Date.now()

    // View state — kept here, not in delegates, so it survives refreshes.
    property string focusKey: ""        // "" = every run; else a circle key
    property var expandedRuns: ({})
    property var expandedJobs: ({})
    property var expandedSteps: ({})    // explicit toggles; failed steps default open

    readonly property string focusLabel: {
        for (var i = 0; i < circlesModel.count; i++) {
            if (circlesModel.get(i).key === focusKey) return circlesModel.get(i).label
        }
        return ""
    }

    ListModel { id: circlesModel }   // {key, label, status, tooltip}
    ListModel { id: runsModel }      // {key, fullName, runJson}
    readonly property ListModel circlesModelRef: circlesModel

    GitGudAPI {
        id: gitgudApi
        baseUrl: root.instanceUrl
        accessToken: root.accessToken
    }
    property alias api: gitgudApi

    // ── config ──────────────────────────────────────────────────────────

    function parseWatched() {
        try {
            var parsed = JSON.parse(watchedJson || "[]")
            watched = Array.isArray(parsed) ? parsed : []
        } catch (e) {
            console.log("Error parsing watched list:", e)
            watched = []
        }
    }

    onWatchedJsonChanged: { parseWatched(); refresh(true) }
    onInstanceUrlChanged: resetAndRefresh()
    onAccessTokenChanged: resetAndRefresh()
    onFocusKeyChanged: rebuildRuns()
    onHideIdleChanged: rebuild()

    Component.onCompleted: {
        parseWatched()
        refresh(true)
    }

    function resetAndRefresh() {
        repoIndex = {}
        runsByRepo = {}
        runDetails = {}
        jobLogs = {}
        rebuild()
        Qt.callLater(refresh, true)
    }

    // ── resolution: watched entries → circles → repos ───────────────────

    // [{key, label, repos: [full_name]}], in the order the user listed them.
    function resolveCircles() {
        var circles = []
        var seen = {}
        function add(key, label, repos) {
            if (seen[key]) return
            seen[key] = true
            circles.push({ key: key, label: label, repos: repos })
        }

        for (var i = 0; i < watched.length; i++) {
            var w = watched[i]
            if (w.kind === "repo" && w.fullName) {
                add("repo:" + w.fullName, w.fullName, [w.fullName])
            } else if (w.kind === "namespace" && w.handle) {
                var members = []
                for (var name in repoIndex) {
                    if (repoIndex[name].owner === w.handle && !repoIndex[name].archived) members.push(name)
                }
                members.sort()
                if (w.grouped) {
                    add("ns:" + w.handle, w.handle, members)
                } else {
                    for (var j = 0; j < members.length; j++) add("repo:" + members[j], members[j], [members[j]])
                }
            }
        }
        return circles
    }

    function allWatchedRepos() {
        var circles = resolveCircles()
        var set = {}
        var list = []
        for (var i = 0; i < circles.length; i++) {
            for (var j = 0; j < circles[i].repos.length; j++) {
                var name = circles[i].repos[j]
                if (!set[name]) { set[name] = true; list.push(name) }
            }
        }
        return list
    }

    function latestRun(fullName) {
        var runs = runsByRepo[fullName]
        return runs && runs.length > 0 ? runs[0] : null
    }

    function repoStatus(fullName) {
        var run = latestRun(fullName)
        if (run) return Status.runStatus(run)
        var row = repoIndex[fullName]
        return row && row.ci_status ? row.ci_status : "unknown"
    }

    function tooltipFor(repos) {
        var lines = []
        var shown = 0
        for (var i = 0; i < repos.length; i++) {
            var run = latestRun(repos[i])
            if (!run && root.hideIdle && repos.length > 1) continue
            if (shown === 8) { lines.push(i18n("…and more")); break }
            shown++
            var prefix = repos.length > 1 ? "<b>" + repos[i] + "</b>: " : ""
            if (!run) {
                lines.push(prefix + i18n("no runs"))
                continue
            }
            var status = Status.runStatus(run)
            var meta = [Status.shortRef(run.head_ref), Status.shortSha(run.head_sha), Status.relativeTime(run.inserted_at)]
                .filter(function(s) { return s !== "" }).join(" · ")
            lines.push(prefix + Status.runTitle(run) + " #" + run.number
                       + " — <font color=\"" + Status.color(status, Kirigami.Theme) + "\">" + Status.label(status) + "</font>"
                       + (meta ? "<br/><small>" + meta + "</small>" : ""))
        }
        if (repos.length === 0) lines.push(i18n("No repositories found in this namespace"))
        return lines.join("<br/>")
    }

    // Upsert `items` into a ListModel in order, keyed on `key`, touching
    // only rows that changed so open delegates (and tooltips) survive.
    function syncModel(model, items) {
        for (var i = 0; i < items.length; i++) {
            var item = items[i]
            var found = -1
            for (var j = i; j < model.count; j++) {
                if (model.get(j).key === item.key) { found = j; break }
            }
            if (found === -1) {
                model.insert(i, item)
                continue
            }
            if (found !== i) model.move(found, i, 1)
            var row = model.get(i)
            for (var prop in item) {
                if (row[prop] !== item[prop]) model.setProperty(i, prop, item[prop])
            }
        }
        while (model.count > items.length) model.remove(model.count - 1)
    }

    function rebuild() {
        var circles = resolveCircles()
        var items = []
        var mapping = {}
        var active = false

        for (var i = 0; i < circles.length; i++) {
            var c = circles[i]
            var statuses = []
            for (var j = 0; j < c.repos.length; j++) {
                var s = repoStatus(c.repos[j])
                statuses.push(s)
                // A run held for approval is still watched every poll,
                // but only at the idle pace: it waits on a person.
                if (Status.pollsFast(s)) active = true
            }
            var status = Status.aggregate(statuses)
            if (hideIdle && status === "unknown") continue

            mapping[c.key] = c.repos
            items.push({
                key: c.key,
                label: c.repos.length > 1 || c.key.indexOf("ns:") === 0
                       ? i18n("%1 (%2 repos)", c.label, c.repos.length) : c.label,
                status: status,
                tooltip: tooltipFor(c.repos)
            })
        }

        circleRepos = mapping
        anyActive = active
        syncModel(circlesModel, items)
        rebuildRuns()
    }

    function rebuildRuns() {
        var repos = focusKey && circleRepos[focusKey] ? circleRepos[focusKey] : allWatchedRepos()
        var rows = []
        for (var i = 0; i < repos.length; i++) {
            var runs = runsByRepo[repos[i]] || []
            for (var j = 0; j < runs.length; j++) rows.push({ fullName: repos[i], run: runs[j] })
        }
        rows.sort(function(a, b) {
            return (b.run.inserted_at || "").localeCompare(a.run.inserted_at || "")
        })
        rows = rows.slice(0, 100)

        syncModel(runsModel, rows.map(function(r) {
            return { key: r.fullName + "#" + r.run.number, fullName: r.fullName, runJson: JSON.stringify(r.run) }
        }))
    }

    // ── polling ─────────────────────────────────────────────────────────

    // `full` refetches every repo's runs. Otherwise only repos whose
    // latest status moved (per /repos' batch-loaded ci_status) or is in
    // flight — which keeps the fast active-run poll cheap.
    function refresh(full) {
        if (!hasValidConfig || !hasWatched) return
        if (loading) {
            if (full) refreshQueued = true
            return
        }
        loading = true
        loadingSince = Date.now()
        // Callbacks from a cycle that recover() abandoned must not land.
        var generation = ++refreshGeneration
        function superseded() { return generation !== root.refreshGeneration }

        api.listRepos(function(ok, data, network) {
            if (superseded()) return
            offline = !ok && network
            if (ok) {
                var index = {}
                var rows = data.repositories || []
                for (var i = 0; i < rows.length; i++) {
                    if (rows[i].full_name) index[rows[i].full_name] = rows[i]
                }
                repoIndex = index
                lastError = ""
                // Only a refresh that reached the server counts as full.
                if (full) lastFullRefresh = Date.now()
            } else {
                lastError = data
            }

            // Offline: every per-repo call would fail the same way.
            var repos = offline ? [] : allWatchedRepos()
            var pending = 0
            var updated = Object.assign({}, runsByRepo)

            function done() {
                if (superseded()) return
                pending--
                if (pending > 0) return
                runsByRepo = updated
                finish()
            }

            function finish() {
                loading = false
                rebuild()
                if (!offline) refreshExpanded()
                if (refreshQueued) {
                    refreshQueued = false
                    Qt.callLater(refresh, true)
                }
            }

            for (var k = 0; k < repos.length; k++) {
                var name = repos[k]
                var latest = latestRun(name)
                var serverStatus = repoIndex[name] ? repoIndex[name].ci_status : null
                var needsFetch = full || !latest || !repoIndex[name]
                                 || Status.isActive(latest.status)
                                 || (serverStatus && serverStatus !== latest.status)
                if (!needsFetch) continue

                pending++
                ;(function(fullName) {
                    api.listRuns(fullName, root.runsPerRepo, function(ok, data) {
                        if (ok) updated[fullName] = data.runs || []
                        else lastError = fullName + ": " + data
                        done()
                    })
                })(name)
            }

            if (pending === 0) finish()
        })
    }

    // Open accordions follow the list: refetch any expanded run that is
    // in flight or whose summary changed since its detail was loaded.
    function refreshExpanded() {
        for (var key in expandedRuns) {
            if (!expandedRuns[key]) continue
            var parts = key.split("#")
            var fullName = parts[0]
            var number = parseInt(parts[1])
            var detail = runDetails[key]
            var summary = null
            var runs = runsByRepo[fullName] || []
            for (var i = 0; i < runs.length; i++) {
                if (runs[i].number === number) { summary = runs[i]; break }
            }
            if (!detail || Status.isActive(detail.run.status)
                || (summary && (summary.status !== detail.run.status || summary.conclusion !== detail.run.conclusion
                                || !!summary.awaiting_approval !== !!detail.run.awaiting_approval))) {
                loadRunDetail(fullName, number)
            }
        }
    }

    Timer {
        // Offline (e.g. just woke, Wi-Fi not back yet): retry soon rather
        // than sitting on an error for a whole idle interval.
        interval: (root.offline ? Math.min(10, root.updateInterval)
                   : root.anyActive ? root.activeInterval : root.updateInterval) * 1000
        running: root.hasValidConfig && root.hasWatched
        repeat: true
        onTriggered: root.refresh(Date.now() - root.lastFullRefresh >= root.updateInterval * 1000)
    }

    // Wall-clock watchdog. Timers don't fire during suspend, so a tick
    // that arrives far later than scheduled means the machine slept: any
    // request open across that is suspect, so drop them all and start
    // over. It also unsticks a refresh that has somehow outlived every
    // per-request timeout.
    Timer {
        id: wakeWatch
        property double lastTick: Date.now()
        interval: 5000
        running: true
        repeat: true
        onTriggered: {
            var now = Date.now()
            var gap = now - lastTick
            lastTick = now
            if (gap > interval * 6) {
                root.recover("resumed after " + Math.round(gap / 1000) + "s")
            } else if (root.loading && now - root.loadingSince > api.timeoutMs * 3) {
                root.recover("refresh stuck for " + Math.round((now - root.loadingSince) / 1000) + "s")
            }
        }
    }

    function recover(reason) {
        console.log("GitGud CI: recovering —", reason)
        api.abortAll()
        refreshGeneration++
        loading = false
        busy = false
        refreshQueued = false
        refresh(true)
    }

    // Ticks relative times and running durations while the popup is open.
    Timer {
        interval: 1000
        running: root.expanded
        repeat: true
        onTriggered: root.now = Date.now()
    }

    onExpandedChanged: function() { if (root.expanded) refresh(false) }

    // ── accordion state + detail loading ────────────────────────────────

    function setKey(obj, key, value) {
        var copy = Object.assign({}, obj)
        if (value === undefined) delete copy[key]
        else copy[key] = value
        return copy
    }

    function loadRunDetail(fullName, number) {
        var key = fullName + "#" + number
        api.getRun(fullName, number, function(ok, data) {
            if (!ok) { lastError = data; return }
            runDetails = setKey(runDetails, key, data)
            // Keep open jobs' logs following the run. A matrix
            // placeholder has no logs: its row is replaced by its legs
            // (new ids) once it expands, and this re-read shows them.
            var jobs = data.jobs || []
            for (var i = 0; i < jobs.length; i++) {
                if (jobs[i].placeholder) continue
                if (expandedJobs[jobs[i].id] && (Status.isActive(jobs[i].status) || !jobLogs[jobs[i].id]
                        || jobLogs[jobs[i].id].job.status !== jobs[i].status)) {
                    loadJobLogs(fullName, jobs[i].id)
                }
            }
        })
    }

    function loadJobLogs(fullName, jobId) {
        // Max budget: the server spends it in step order, so a small one
        // starves the late step that actually failed.
        api.getJobLogs(fullName, jobId, 10000, function(ok, data) {
            if (!ok) { lastError = data; return }
            jobLogs = setKey(jobLogs, jobId, data)
        })
    }

    function toggleRun(fullName, number) {
        var key = fullName + "#" + number
        var open = !expandedRuns[key]
        expandedRuns = setKey(expandedRuns, key, open ? true : undefined)
        if (open) loadRunDetail(fullName, number)
    }

    function toggleJob(fullName, job) {
        var open = !expandedJobs[job.id]
        expandedJobs = setKey(expandedJobs, job.id, open ? true : undefined)
        if (open && !job.placeholder) loadJobLogs(fullName, job.id)
    }

    function stepOpen(stepKey, status) {
        var explicit = expandedSteps[stepKey]
        if (explicit !== undefined) return explicit
        return Status.category(status) === "failure" && status !== "cancelled"
    }

    function toggleStep(stepKey, status) {
        expandedSteps = setKey(expandedSteps, stepKey, !stepOpen(stepKey, status))
    }

    // ── actions ─────────────────────────────────────────────────────────

    function act(label, fn) {
        busy = true
        fn(function(ok, data) {
            busy = false
            if (!ok) lastError = label + ": " + data
            else lastError = ""
            refresh(true)
        })
    }

    function rerunRun(fullName, number) {
        act(i18n("Re-run"), function(cb) {
            api.rerunRun(fullName, number, function(ok, data) {
                // Re-running a whole run creates a new one; open it.
                if (ok && data.run) expandedRuns = setKey(expandedRuns, fullName + "#" + data.run.number, true)
                cb(ok, data)
            })
        })
    }

    function rerunJob(fullName, number, jobKey) {
        act(i18n("Re-run job"), function(cb) {
            api.rerunJob(fullName, number, jobKey, function(ok, data) {
                if (ok) mergeDetail(fullName, number, data)
                cb(ok, data)
            })
        })
    }

    function cancelRun(fullName, number) {
        act(i18n("Cancel"), function(cb) {
            api.cancelRun(fullName, number, function(ok, data) {
                if (ok) mergeDetail(fullName, number, data)
                cb(ok, data)
            })
        })
    }

    // Re-run-job and cancel answer with {run, jobs}; keep what they don't carry.
    function mergeDetail(fullName, number, data) {
        var key = fullName + "#" + number
        var prev = runDetails[key] || { artifacts: [], can_manage: true }
        runDetails = setKey(runDetails, key, {
            run: data.run, jobs: data.jobs || [], artifacts: prev.artifacts, can_manage: prev.can_manage
        })
    }

    // ── panel interactions ──────────────────────────────────────────────

    function openAll() {
        if (root.expanded && focusKey === "") {
            root.expanded = false
        } else {
            focusKey = ""
            root.expanded = true
        }
    }

    function openCircle(key) {
        if (root.expanded && focusKey === key) {
            root.expanded = false
        } else {
            focusKey = key
            root.expanded = true
        }
    }

    // ── representations ─────────────────────────────────────────────────

    compactRepresentation: CircleStrip {
        store: root
        inPanel: true
    }

    fullRepresentation: ColumnLayout {
        Layout.minimumWidth: Kirigami.Units.gridUnit * 22
        Layout.minimumHeight: Kirigami.Units.gridUnit * 18
        Layout.preferredWidth: Kirigami.Units.gridUnit * 30
        Layout.preferredHeight: Kirigami.Units.gridUnit * 30
        spacing: Kirigami.Units.smallSpacing

        // Header
        RowLayout {
            Layout.fillWidth: true
            spacing: Kirigami.Units.smallSpacing

            PlasmaComponents3.ToolButton {
                visible: root.focusKey !== ""
                icon.name: "go-previous"
                onClicked: root.focusKey = ""
                PlasmaComponents3.ToolTip { text: i18n("All runs") }
            }

            Kirigami.Heading {
                Layout.fillWidth: true
                level: 3
                elide: Text.ElideRight
                text: root.focusKey === "" ? i18n("All runs") : root.focusLabel
            }

            PlasmaComponents3.BusyIndicator {
                visible: root.loading || root.busy
                Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
                Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
            }

            PlasmaComponents3.ToolButton {
                visible: root.focusKey !== "" && root.focusKey.indexOf("repo:") === 0
                icon.name: "internet-web-browser"
                onClicked: Qt.openUrlExternally(api.webUrl(root.focusKey.substring(5)) + "/actions")
                PlasmaComponents3.ToolTip { text: i18n("Open in browser") }
            }

            PlasmaComponents3.ToolButton {
                icon.name: "view-refresh"
                enabled: root.hasValidConfig && !root.loading
                onClicked: root.refresh(true)
                PlasmaComponents3.ToolTip { text: i18n("Refresh") }
            }

            PlasmaComponents3.ToolButton {
                icon.name: "configure"
                onClicked: Plasmoid.internalAction("configure").trigger()
                PlasmaComponents3.ToolTip { text: i18n("Configure") }
            }
        }

        // The same circles as the panel, as a filter bar.
        CircleStrip {
            visible: root.hasValidConfig && root.hasWatched && circlesModel.count > 0
            Layout.fillWidth: true
            store: root
            inPanel: false
        }

        PlasmaComponents3.Label {
            visible: root.lastError !== ""
            Layout.fillWidth: true
            wrapMode: Text.WordWrap
            color: Kirigami.Theme.negativeTextColor
            font: Kirigami.Theme.smallFont
            text: root.lastError
        }

        Kirigami.Separator { Layout.fillWidth: true }

        // Setup prompts
        ColumnLayout {
            visible: !root.hasValidConfig || !root.hasWatched
            Layout.fillWidth: true
            Layout.fillHeight: true
            spacing: Kirigami.Units.largeSpacing

            Item { Layout.fillHeight: true }

            Kirigami.Icon {
                source: root.logoSource
                isMask: true
                color: Kirigami.Theme.disabledTextColor
                Layout.alignment: Qt.AlignHCenter
                Layout.preferredWidth: Kirigami.Units.iconSizes.huge
                Layout.preferredHeight: Kirigami.Units.iconSizes.huge
            }

            PlasmaComponents3.Label {
                Layout.fillWidth: true
                horizontalAlignment: Text.AlignHCenter
                wrapMode: Text.WordWrap
                text: !root.hasValidConfig
                      ? i18n("Configure the widget to connect to your GitGud instance")
                      : i18n("Pick repositories or namespaces to watch")
            }

            PlasmaComponents3.Button {
                Layout.alignment: Qt.AlignHCenter
                icon.name: "configure"
                text: !root.hasValidConfig ? i18n("Configure Connection") : i18n("Choose Repositories")
                onClicked: Plasmoid.internalAction("configure").trigger()
            }

            Item { Layout.fillHeight: true }
        }

        PlasmaComponents3.ScrollView {
            visible: root.hasValidConfig && root.hasWatched
            Layout.fillWidth: true
            Layout.fillHeight: true

            contentWidth: availableWidth

            ListView {
                id: runList
                clip: true
                model: runsModel
                reuseItems: false

                delegate: RunItem {
                    required property string runJson
                    width: runList.width
                    store: root
                    run: JSON.parse(runJson)
                    showRepo: root.focusKey === "" || root.focusKey.indexOf("ns:") === 0
                }

                PlasmaComponents3.Label {
                    anchors.centerIn: parent
                    visible: runList.count === 0 && !root.loading
                    opacity: 0.7
                    text: i18n("No runs yet")
                }
            }
        }
    }
}