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 wallTint: Plasmoid.configuration.wallTint
readonly property int wallTintHue: Plasmoid.configuration.wallTintHue
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 setWallTint(on) {
Plasmoid.configuration.wallTint = 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")
}
}
}
}
}
}
neiam
/gitgud-plasmoid
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
48.6 KiB · text
History
49b9ecf