34.6 KiB · text History 4f26744
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 org.kde.notification

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

    // The header's pin, as in the system tray: a pinned popup stays open
    // when focus moves elsewhere.
    hideOnWindowDeactivate: !Plasmoid.configuration.pin

    // 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 bool notifyMyFailures: Plasmoid.configuration.notifyMyFailures
    // "owner/name#n" of each run to announce once it finishes. A plain
    // array copy, so the usual Array methods work on it.
    readonly property var watchedRuns: Array.prototype.slice.call(Plasmoid.configuration.watchedRuns || [])

    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()

    // Following runs outside the list: windows and notifications.
    property var runWindows: ({})       // "owner/name#n" -> RunWindow
    property var notified: ({})         // "owner/name#n@completed_at" -> true, once per ending
    property string myHandle: ""        // the token's user, for "runs I triggered"

    // A watched or windowed run still in flight keeps the fast poll
    // going, even when nothing in the circles would — and even if it
    // has dropped out of the list. One not loaded yet counts too, so its
    // first answer comes quickly.
    readonly property bool followingActive: {
        var keys = watchedRuns.concat(Object.keys(runWindows))
        for (var i = 0; i < keys.length; i++) {
            var d = runDetails[keys[i]]
            if (!d || Status.pollsFast(Status.runStatus(d.run))) return true
        }
        return false
    }

    // 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()
    onNotifyMyFailuresChanged: loadMe()

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

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

    // Who "runs I triggered" means. Only asked for when that's wanted.
    function loadMe() {
        if (!notifyMyFailures || !hasValidConfig) return
        api.me(function(ok, data) {
            if (ok && data.user) myHandle = data.user.handle || ""
        })
    }

    // ── 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 findRun(fullName, number) {
        var runs = runsByRepo[fullName] || []
        for (var i = 0; i < runs.length; i++) {
            if (runs[i].number === number) return runs[i]
        }
        return null
    }

    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) {
                            var runs = data.runs || []
                            if (root.myHandle !== "") {
                                Status.newlyFailed(updated[fullName], runs, root.myHandle).forEach(function(run) {
                                    root.notifyRun(fullName, run)
                                })
                            }
                            updated[fullName] = runs
                        } else {
                            lastError = fullName + ": " + data
                        }
                        done()
                    })
                })(name)
            }

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

    // Open accordions, run windows and watched runs follow the list:
    // refetch any that is in flight or whose summary changed since its
    // detail was loaded. A watched run is fetched by number, so it keeps
    // being followed after newer runs push it out of the list.
    function refreshExpanded() {
        var keys = {}
        for (var k in expandedRuns) if (expandedRuns[k]) keys[k] = true
        for (var w in runWindows) keys[w] = true
        for (var j = 0; j < watchedRuns.length; j++) keys[watchedRuns[j]] = true

        for (var key in keys) {
            var parsed = Status.parseRunKey(key)
            if (!parsed) continue
            var fullName = parsed.fullName
            var number = parsed.number
            var detail = runDetails[key]
            var summary = findRun(fullName, number)
            // Already known finished: nothing to fetch, just announce it.
            if (detail) checkWatched(fullName, detail.run)
            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.followingActive ? 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 or a
    // run window is open.
    Timer {
        interval: 1000
        running: root.expanded || Object.keys(root.runWindows).length > 0
        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, network, httpStatus) {
            if (!ok) {
                // Gone (deleted, or a repo the token lost): stop waiting on it.
                if (httpStatus === 404) unwatchRun(key)
                lastError = data
                return
            }
            runDetails = setKey(runDetails, key, data)
            checkWatched(fullName, data.run)
            // 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
        })
        if (data.run) checkWatched(fullName, data.run)
    }

    // ── run windows ─────────────────────────────────────────────────────

    Component {
        id: runWindowComponent
        RunWindow {}
    }

    // One window per run: asking again brings the existing one forward.
    function openRunWindow(fullName, number) {
        var key = Status.runKey(fullName, number)
        var win = runWindows[key]
        if (!win) {
            win = runWindowComponent.createObject(root, { store: root, fullName: fullName, runNumber: number })
            if (!win) {
                lastError = i18n("Could not open a window for %1", key)
                return
            }
            runWindows = setKey(runWindows, key, win)
            loadRunDetail(fullName, number)
        }
        win.present()
    }

    function forgetRunWindow(key, win) {
        if (runWindows[key] === win) runWindows = setKey(runWindows, key, undefined)
    }

    // ── notify when finished ────────────────────────────────────────────

    function watchingRun(key) {
        return watchedRuns.indexOf(key) !== -1
    }

    function setWatchedRuns(list) {
        Plasmoid.configuration.watchedRuns = list
    }

    function unwatchRun(key) {
        if (watchingRun(key)) setWatchedRuns(watchedRuns.filter(function(k) { return k !== key }))
    }

    function toggleWatchRun(fullName, number) {
        var key = Status.runKey(fullName, number)
        if (watchingRun(key)) {
            unwatchRun(key)
            return
        }
        setWatchedRuns(watchedRuns.concat([key]))
        // Find out where it stands now; if it has already finished, the
        // answer rings straight away.
        loadRunDetail(fullName, number)
    }

    // Drop entries that aren't run keys (hand-edited, or an older format).
    function pruneWatchedRuns() {
        var kept = watchedRuns.filter(function(k, i, all) {
            return Status.parseRunKey(k) !== null && all.indexOf(k) === i
        })
        if (kept.length !== watchedRuns.length) setWatchedRuns(kept)
    }

    // A watched run that has ended: announce it once, then stop watching.
    function checkWatched(fullName, run) {
        if (!run) return
        var key = Status.runKey(fullName, run.number)
        if (!watchingRun(key) || !Status.isFinished(Status.runStatus(run))) return
        unwatchRun(key)
        notifyRun(fullName, run)
    }

    function notificationTitle(fullName, run) {
        var n = run.number
        switch (Status.outcome(Status.runStatus(run))) {
        case "succeeded": return i18n("%1 #%2 succeeded", fullName, n)
        case "failed": return i18n("%1 #%2 failed", fullName, n)
        case "cancelled": return i18n("%1 #%2 cancelled", fullName, n)
        case "timed_out": return i18n("%1 #%2 timed out", fullName, n)
        case "skipped": return i18n("%1 #%2 skipped", fullName, n)
        case "superseded": return i18n("%1 #%2 superseded", fullName, n)
        default: return i18n("%1 #%2 finished", fullName, n)
        }
    }

    function notifyRun(fullName, run) {
        var key = Status.runKey(fullName, run.number)
        // Once per ending: a re-run job can end the same run again later.
        var once = key + "@" + (run.completed_at || Status.runStatus(run))
        if (notified[once]) return
        notified = setKey(notified, once, true)

        var status = Status.runStatus(run)
        var outcome = Status.outcome(status)
        var note = notificationComponent.createObject(root, {
            fullName: fullName,
            runNumber: run.number,
            title: notificationTitle(fullName, run),
            text: [Status.runTitle(run), Status.shortRef(run.head_ref),
                   Status.duration(run.started_at, run.completed_at)]
                  .filter(function(s) { return s !== "" }).join(" · "),
            iconName: Status.icon(status),
            urgency: outcome === "failed" || outcome === "timed_out" ? Notification.HighUrgency
                                                                     : Notification.NormalUrgency
        })
        if (note) (note as Notification).sendEvent()
    }

    Component {
        id: notificationComponent

        // Sent through Plasma's own notification event, so it needs no
        // notifyrc of its own. Clicking it opens the run's window.
        Notification {
            id: note

            property string fullName
            property int runNumber

            componentName: "plasma_workspace"
            eventId: "notification"
            // Owned by QML: destroyed here once Plasma closes it.
            autoDelete: false

            defaultAction: NotificationAction {
                label: i18n("Show Run")
                onActivated: root.openRunWindow(note.fullName, note.runNumber)
            }

            actions: [
                NotificationAction {
                    label: i18n("Open in Browser")
                    onActivated: Qt.openUrlExternally(root.api.webUrl(note.fullName, note.runNumber))
                }
            ]

            onClosed: note.destroy()
        }
    }

    // ── 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") }
            }

            PlasmaComponents3.ToolButton {
                checkable: true
                checked: Plasmoid.configuration.pin
                onToggled: Plasmoid.configuration.pin = checked
                icon.name: "window-pin"
                text: i18n("Keep Open")
                display: QQC2.AbstractButton.IconOnly
                PlasmaComponents3.ToolTip { text: i18n("Keep Open") }
            }
        }

        // 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")
                }
            }
        }
    }
}