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")
}
}
}
}
}
neiam
/gitgud-plasmoid
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
34.6 KiB · text
History
4f26744