48.4 KiB · text History 495b6f2
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.
    // In wall mode on the desktop, the wall whatever the size.
    preferredRepresentation: Plasmoid.formFactor === PlasmaCore.Types.Horizontal
                             || Plasmoid.formFactor === PlasmaCore.Types.Vertical
                             ? compactRepresentation
                             : Plasmoid.configuration.desktopWall ? fullRepresentation : 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 || [])
    readonly property var eachJobRuns: Array.prototype.slice.call(Plasmoid.configuration.eachJobRuns || [])
    readonly property var watchedJobs: Array.prototype.slice.call(Plasmoid.configuration.watchedJobs || [])
    // Run keys of every belled job's run.
    readonly property var watchedJobRuns: watchedJobs.map(function(k) {
        var p = Status.parseJobKey(k)
        return p ? p.runKey : ""
    }).filter(function(k) { return k !== "" })

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

    // The live CI wall (WallView): polled only while one is on screen.
    property var wallCards: null        // [{job, run, repository, runner, log}], or null before the first answer
    property string wallError: ""
    property var wallOrgs: []           // namespaces of kind "org", for the wall's scope picker
    property int wallViewers: 0
    property int wallGeneration: 0
    property var wallWindow: null
    readonly property string wallScope: Plasmoid.configuration.wallScope
    readonly property bool wallNewestFirst: Plasmoid.configuration.wallNewestFirst
    readonly property bool wallTranslucent: Plasmoid.configuration.wallTranslucent
    readonly property bool wallConfigured: api.configured
    readonly property bool onDesktop: Plasmoid.formFactor !== PlasmaCore.Types.Horizontal
                                      && Plasmoid.formFactor !== PlasmaCore.Types.Vertical
    readonly property bool wallMode: onDesktop && Plasmoid.configuration.desktopWall

    // Following runs outside the list: windows and notifications.
    property var runWindows: ({})       // "owner/name#n" -> RunWindow
    property var notified: ({})         // "<run or job key>@completed_at" -> true, once per ending
    property var seenActiveJobs: ({})   // "owner/name#n" -> {job row id: true} seen in flight
    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(watchedJobRuns, 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
                wallOrgs = (data.namespaces || []).filter(function(n) { return n.kind === "org" })
                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 b = 0; b < watchedJobRuns.length; b++) keys[watchedJobRuns[b]] = 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) followRun(fullName, detail)
            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 || root.wallViewers > 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) dropRun(key)
                lastError = data
                return
            }
            runDetails = setKey(runDetails, key, data)
            followRun(fullName, 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
        })
        followRun(fullName, runDetails[key])
    }

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

    Component {
        id: runWindowComponent
        RunWindow {}
    }

    // One window per run: asking again brings the existing one forward.
    // With `jobRowId`, that job is opened and scrolled to.
    function openRunWindow(fullName, number, jobRowId) {
        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()
        if (jobRowId !== undefined && jobRowId !== null && String(jobRowId) !== "") {
            if (!expandedJobs[jobRowId]) {
                expandedJobs = setKey(expandedJobs, jobRowId, true)
                loadJobLogs(fullName, jobRowId)
            }
            win.focusJob(String(jobRowId))
        }
    }

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

    // ── the live CI wall ────────────────────────────────────────────────

    Timer {
        interval: 3000
        repeat: true
        triggeredOnStart: true
        running: root.wallViewers > 0 && root.hasValidConfig
        onTriggered: root.pollWall()
    }

    function pollWall() {
        var generation = ++wallGeneration
        // Enough log for a full-height card on a tall screen.
        api.ciWall(wallScope, 160, function(ok, data) {
            if (generation !== root.wallGeneration) return
            if (ok) {
                wallCards = data.jobs || []
                wallError = ""
            } else {
                // Keep the last cards up: a wall across the room going
                // blank says less than one saying it's retrying.
                wallError = data
            }
        })
    }

    function setWallScope(scope) {
        if (scope === wallScope) return
        Plasmoid.configuration.wallScope = scope
        wallCards = null
        wallError = ""
        Qt.callLater(pollWall)
    }

    function setWallNewestFirst(newest) {
        Plasmoid.configuration.wallNewestFirst = newest
    }

    function setWallTranslucent(on) {
        Plasmoid.configuration.wallTranslucent = on
    }

    function wallWebUrl() {
        return api.wallUrl(wallScope)
    }

    Component {
        id: wallWindowComponent
        WallWindow {}
    }

    function openWallWindow() {
        if (!wallWindow) {
            wallWindow = wallWindowComponent.createObject(root, { store: root })
            if (!wallWindow) {
                lastError = i18n("Could not open the live CI wall")
                return
            }
        }
        wallWindow.present()
    }

    function forgetWallWindow(win) {
        if (wallWindow === win) wallWindow = null
    }

    Plasmoid.contextualActions: [
        PlasmaCore.Action {
            text: i18n("Open Live CI Wall")
            icon.name: "view-grid"
            onTriggered: root.openWallWindow()
        }
    ]

    // ── notify when finished ────────────────────────────────────────────
    //
    // Three lists in the config, so they survive a restart:
    //   watchedRuns  "owner/name#n"            ring when the run ends
    //   eachJobRuns  "owner/name#n"            …and as each of its jobs ends
    //   watchedJobs  "owner/name#n/id/job_id"  ring when that one job ends
    // Every entry goes once it has rung, or once the server has no such run.

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

    function notifyingEachJob(key) {
        return eachJobRuns.indexOf(key) !== -1
    }

    // The belled jobs of one run, as {key, id, jobId}.
    function watchedJobsOf(runKey) {
        var out = []
        for (var i = 0; i < watchedJobs.length; i++) {
            var p = Status.parseJobKey(watchedJobs[i])
            if (p && p.runKey === runKey) out.push({ key: watchedJobs[i], id: p.id, jobId: p.jobId })
        }
        return out
    }

    function watchingJob(fullName, number, jobRowId) {
        var mine = watchedJobsOf(Status.runKey(fullName, number))
        for (var i = 0; i < mine.length; i++) {
            if (mine[i].id === String(jobRowId)) return true
        }
        return false
    }

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

    function setEachJobRuns(list) {
        Plasmoid.configuration.eachJobRuns = list
    }

    function setWatchedJobs(list) {
        Plasmoid.configuration.watchedJobs = list
    }

    function without(list, key) {
        return list.filter(function(k) { return k !== key })
    }

    function unwatchRun(key) {
        if (watchingRun(key)) setWatchedRuns(without(watchedRuns, key))
        if (notifyingEachJob(key)) setEachJobRuns(without(eachJobRuns, key))
    }

    // Replace one run's belled jobs with `keys`.
    function setWatchedJobsOf(runKey, keys) {
        var others = watchedJobs.filter(function(k) {
            var p = Status.parseJobKey(k)
            return !p || p.runKey !== runKey
        })
        var next = others.concat(keys)
        if (next.join("\n") !== watchedJobs.join("\n")) setWatchedJobs(next)
    }

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

    // "Notify for each job" implies watching the run: its own ending
    // still rings, last.
    function toggleEachJob(fullName, number) {
        var key = Status.runKey(fullName, number)
        if (notifyingEachJob(key)) {
            setEachJobRuns(without(eachJobRuns, key))
            return
        }
        setEachJobRuns(eachJobRuns.concat([key]))
        if (!watchingRun(key)) setWatchedRuns(watchedRuns.concat([key]))
        loadRunDetail(fullName, number)
    }

    function toggleWatchJob(fullName, number, job) {
        var key = Status.runKey(fullName, number)
        if (watchingJob(fullName, number, job.id)) {
            setWatchedJobsOf(key, watchedJobsOf(key)
                .filter(function(w) { return w.id !== String(job.id) })
                .map(function(w) { return w.key }))
            return
        }
        setWatchedJobs(watchedJobs.concat([Status.jobKey(fullName, number, job)]))
        loadRunDetail(fullName, number)
    }

    // Drop entries that aren't keys (hand-edited, or an older format),
    // duplicates, and each-job flags whose run isn't watched.
    function pruneWatchedRuns() {
        function unique(k, i, all) { return all.indexOf(k) === i }
        var runs = watchedRuns.filter(function(k, i, all) { return Status.parseRunKey(k) !== null && unique(k, i, all) })
        if (runs.length !== watchedRuns.length) setWatchedRuns(runs)
        var each = eachJobRuns.filter(function(k, i, all) { return runs.indexOf(k) !== -1 && unique(k, i, all) })
        if (each.length !== eachJobRuns.length) setEachJobRuns(each)
        var jobs = watchedJobs.filter(function(k, i, all) { return Status.parseJobKey(k) !== null && unique(k, i, all) })
        if (jobs.length !== watchedJobs.length) setWatchedJobs(jobs)
    }

    // Forget everything waiting on a run the server no longer has.
    function dropRun(key) {
        unwatchRun(key)
        setWatchedJobsOf(key, [])
        seenActiveJobs = setKey(seenActiveJobs, key, undefined)
    }

    // Called with every fresh detail ({run, jobs}) of a run: rings for
    // belled jobs that ended, for each job of an each-job run that ended
    // since the last look, and for the run itself once it ends. Whatever
    // ended in this one look goes out together — folded into the run's
    // notification if the run ended too, else one per job, or a single
    // summary when more than two finished at once.
    function followRun(fullName, data) {
        if (!data || !data.run) return
        var run = data.run
        var key = Status.runKey(fullName, run.number)
        var jobs = data.jobs || []
        var runDone = Status.isFinished(Status.runStatus(run))
        var ended = []
        function addEnded(job) {
            for (var i = 0; i < ended.length; i++) if (ended[i].id === job.id) return
            ended.push(job)
        }

        // Belled jobs, following placeholders to their legs and re-runs
        // to their new rows. An empty job list says nothing yet.
        var belled = watchedJobsOf(key)
        if (belled.length > 0 && jobs.length > 0) {
            var still = []
            Status.resolveJobWatches(belled, jobs).forEach(function(job) {
                if (Status.isFinished(Status.jobStatus(job))) addEnded(job)
                else still.push(Status.jobKey(fullName, run.number, job))
            })
            setWatchedJobsOf(key, runDone ? [] : still)
        } else if (belled.length > 0 && runDone) {
            setWatchedJobsOf(key, [])
        }

        // Every job seen in flight is remembered, so a run switched to
        // each-job later still knows which endings are news.
        var events = Status.jobEvents(seenActiveJobs[key], jobs)
        seenActiveJobs = setKey(seenActiveJobs, key, runDone ? undefined : events.active)
        if (notifyingEachJob(key)) events.finished.forEach(addEnded)

        // Once per ending: a re-run can end the same job or run again.
        ended = ended.filter(function(job) {
            var once = Status.jobKey(fullName, run.number, job) + "@" + (job.completed_at || Status.jobStatus(job))
            if (notified[once]) return false
            notified = setKey(notified, once, true)
            return true
        })

        if (runDone && watchingRun(key)) {
            unwatchRun(key)
            notifyRun(fullName, run, jobs, ended)
        } else if (ended.length > 2) {
            notifyJobs(fullName, run, ended)
        } else {
            ended.forEach(function(job) { notifyJob(fullName, run, job) })
        }
    }

    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 jobNotificationTitle(fullName, run, job) {
        var n = run.number
        var name = Status.jobLabel(job)
        switch (Status.outcome(Status.jobStatus(job))) {
        case "succeeded": return i18n("%1 #%2 · %3 succeeded", fullName, n, name)
        case "failed": return i18n("%1 #%2 · %3 failed", fullName, n, name)
        case "cancelled": return i18n("%1 #%2 · %3 cancelled", fullName, n, name)
        case "timed_out": return i18n("%1 #%2 · %3 timed out", fullName, n, name)
        case "skipped": return i18n("%1 #%2 · %3 skipped", fullName, n, name)
        case "superseded": return i18n("%1 #%2 · %3 superseded", fullName, n, name)
        default: return i18n("%1 #%2 · %3 finished", fullName, n, name)
        }
    }

    // "build ✓, test (x86_64) ✗"
    function jobMarks(jobs) {
        return jobs.map(function(j) { return Status.jobLabel(j) + " " + Status.jobMark(Status.jobStatus(j)) }).join(", ")
    }

    function urgencyFor(status) {
        var o = Status.outcome(status)
        return o === "failed" || o === "timed_out" ? Notification.HighUrgency : Notification.NormalUrgency
    }

    function joined(parts) {
        return parts.filter(function(s) { return !!s }).join(" · ")
    }

    function send(props) {
        props.jobRowId = props.jobRowId === undefined ? "" : String(props.jobRowId)
        var note = notificationComponent.createObject(root, props)
        if (note) (note as Notification).sendEvent()
    }

    // `jobs`: the run's jobs, to count the skipped ones. `ended`: jobs
    // that ended in the same look, named here instead of separately.
    function notifyRun(fullName, run, jobs, ended) {
        var key = Status.runKey(fullName, run.number)
        var once = key + "@" + (run.completed_at || Status.runStatus(run))
        if (notified[once]) return
        notified = setKey(notified, once, true)

        var status = Status.runStatus(run)
        var skipped = Status.countJobs(jobs || [], "skipped")
        send({
            fullName: fullName,
            runNumber: run.number,
            title: notificationTitle(fullName, run),
            text: joined([Status.runTitle(run), Status.shortRef(run.head_ref),
                          Status.duration(run.started_at, run.completed_at),
                          skipped > 0 ? i18np("1 skipped", "%1 skipped", skipped) : "",
                          ended && ended.length > 0 ? jobMarks(ended) : ""]),
            iconName: Status.icon(status),
            urgency: urgencyFor(status)
        })
    }

    function notifyJob(fullName, run, job) {
        var status = Status.jobStatus(job)
        send({
            fullName: fullName,
            runNumber: run.number,
            jobRowId: job.id,
            title: jobNotificationTitle(fullName, run, job),
            text: joined([Status.runTitle(run), Status.duration(job.started_at, job.completed_at), Status.jobReason(job)]),
            iconName: Status.icon(status),
            urgency: urgencyFor(status)
        })
    }

    // Several jobs at once: one notification listing them. Clicking it
    // opens the worst of them.
    function notifyJobs(fullName, run, jobs) {
        var worst = Status.worstOf(jobs.map(function(j) { return Status.jobStatus(j) }))
        var first = jobs.filter(function(j) { return Status.jobStatus(j) === worst })[0]
        send({
            fullName: fullName,
            runNumber: run.number,
            jobRowId: first ? first.id : "",
            title: i18np("%2 #%3 · 1 job finished", "%2 #%3 · %1 jobs finished", jobs.length, fullName, run.number),
            text: joined([jobMarks(jobs), Status.runTitle(run)]),
            iconName: Status.icon(worst),
            urgency: urgencyFor(worst)
        })
    }

    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, at the
        // job if it's about one.
        Notification {
            id: note

            property string fullName
            property int runNumber
            property string jobRowId

            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, note.jobRowId)
            }

            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: root.wallMode ? wallRepresentation : popupRepresentation

    Component {
        id: wallRepresentation
        WallView {
            Layout.minimumWidth: Kirigami.Units.gridUnit * 16
            Layout.minimumHeight: Kirigami.Units.gridUnit * 10
            Layout.preferredWidth: Kirigami.Units.gridUnit * 48
            Layout.preferredHeight: Kirigami.Units.gridUnit * 27
            store: root
        }
    }

    Component {
        id: popupRepresentation
        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: "view-grid"
                    enabled: root.hasValidConfig
                    onClicked: root.openWallWindow()
                    PlasmaComponents3.ToolTip { text: i18n("Open Live CI Wall") }
                }

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