A live CI wall: a window, and wall mode on the desktop

9cdb602 · gmorell · 2026-11-03 19:38

13 files +822 -124
Message
{commit_body(@commit)}

Files changed

modified README.md
+19 −0
@@ -10,6 +10,7 @@ A Plasma 6 panel widget that shows [GitGud](https://gg.neiam.co) CI runs as a ro
10 10 - **Circles**: one per watched repository, or one per organization/user if you fold it into a single circle. The colour shows the latest run (see [Statuses](#statuses)). Hover a circle for the workflow, run number, ref, SHA and age. Click it to open just that circle's runs.
11 11 - **Pin** (the pin button in the popup's header, *Keep Open*): the popup stays open when you click elsewhere, as the system tray's does. Click it again to unpin. The setting is remembered.
12 12 - **Run windows**: break a run out of the popup into its own window. See [Run windows](#run-windows).
13 +- **Live CI wall**: every job running right now as a grid of cards, in its own window or as the widget itself on the desktop. See [Live CI wall](#live-ci-wall).
13 14 - **Notify when finished**: ring the bell on a run or a single job and get a desktop notification when it ends, or ask to hear about every job of a run as it finishes. See [Notifications](#notifications).
14 15
15 16 ## Statuses
@@ -61,6 +62,23 @@ Click the window button on a run (on hover, or the one next to **Open** once it'
61 62 - While the run is in flight the window refreshes at the fast interval (*While a run is in flight*), even if the popup is closed and the run has dropped out of the list. Once the run finishes it stops.
62 63 - Each run gets one window. Opening it again brings the existing window forward. Open as many runs as you like. Closing a window discards it.
63 64
65 +## Live CI wall
66 +
67 +Every job running right now, a card each, tiled to fill the space, the same as GitGud's `/users/ci/live` page and the Android app's wall:
68 +
69 +- Up to six cards side by side, one column per ~13 grid units of width. After that the columns fill up from the right in pairs: the 7th card shares the rightmost column, the 8th the next one in, and so on. The first cards in order keep the most room. When cards would get too short to read, the wall uses more columns instead (still at most six).
70 +- Each card shows the repository, job, a running clock, the run number, workflow and `ref@sha`, a bar with a segment per step, the step it's on, the end of its log and the runner. Smaller cards drop the log first, then the details, and finally become one line with the job and its clock.
71 +- **Mine / @org** (the drop-down in the header): your jobs, meaning repos you own or reach through an org or team plus runs you triggered, or one organization's.
72 +- **Oldest first / Newest first**, or **R**: the order. Both settings are remembered.
73 +- It polls every 3 s, and only while a wall is on screen. If a poll fails, the cards stay up and the header says *offline · retrying*.
74 +
75 +There are two ways to show it:
76 +
77 +- **A window**: the grid button in the popup's header, or right-click the widget → **Open Live CI Wall**. **F11** (or the button) toggles full screen and **Esc** leaves it. Put it on a spare monitor.
78 +- **Wall mode on the desktop**: *Behavior → Live CI wall → Wall mode*. A widget on the desktop (not in a panel) then *is* the wall. Resize it as large as you like. In a panel the widget stays the circles.
79 +
80 +It needs a GitGud server with the `/api/mobile/v1/ci/live` endpoint and a token with `ci:read`.
81 +
64 82 ## Notifications
65 83
66 84 Ring the **bell** on a run that is still going (on hover in the popup, in the run's window, or right-click → **Notify When Finished**). The bell stays lit while you're waiting. When the run ends you get a notification such as *neiam/ppl #12 failed*, with the workflow, ref and duration under it and an icon for the outcome. Click it to open the run's window, or use **Open in Browser**. A run is notified once and then stops being watched. Click a lit bell to stop waiting.
@@ -104,6 +122,7 @@ Ring the **bell** on a run that is still going (on hover in the popup, in the ru
104 122 | Runs listed per repository | 10 | |
105 123 | Hide repositories that have never run CI | on | |
106 124 | Notify when any run I triggered fails | off | See [Notifications](#notifications) |
125 +| Wall mode: on the desktop, show the wall | off | See [Live CI wall](#live-ci-wall) |
107 126
108 127 Each poll makes one `/repos` call, which carries every repo's latest CI status. It then calls `/repos/:owner/:name/runs` only for repos that need it, so the fast poll stays cheap even when you watch a large organization.
109 128
modified contents/config/main.xml
+16 −0
@@ -31,6 +31,22 @@
31 31 <default>10</default>
32 32 </entry>
33 33
34 + <!-- The live CI wall: "" for the token owner's own, else an org handle -->
35 + <entry name="wallScope" type="String">
36 + <label>Whose running jobs the live wall shows</label>
37 + <default></default>
38 + </entry>
39 +
40 + <entry name="wallNewestFirst" type="Bool">
41 + <label>Live wall: newest-started job first</label>
42 + <default>false</default>
43 + </entry>
44 +
45 + <entry name="desktopWall" type="Bool">
46 + <label>On the desktop, the widget is the live CI wall</label>
47 + <default>false</default>
48 + </entry>
49 +
34 50 <entry name="pin" type="Bool">
35 51 <label>Keep the popup open when it loses focus</label>
36 52 <default>false</default>
modified contents/ui/GitGudAPI.qml
+12 −0
@@ -171,6 +171,18 @@ QtObject {
171 171
172 172 // ── CI ──────────────────────────────────────────────────────────────
173 173
174 + // The live CI wall: every running job the token's owner cares about,
175 + // or one organization's (`org`), each with the plain tail of its log.
176 + // {jobs: [{job, run, repository, runner, log: [line]}], polled_at}
177 + function ciWall(org, lines, callback) {
178 + var path = org ? "/orgs/" + encodeURIComponent(org) + "/ci/live" : "/ci/live"
179 + request("GET", path + "?lines=" + (lines || 80), callback)
180 + }
181 +
182 + function wallUrl(org) {
183 + return normalizedBase() + (org ? "/orgs/" + org + "/actions/live" : "/users/ci/live")
184 + }
185 +
174 186 function listRuns(fullName, limit, callback) {
175 187 request("GET", repoPath(fullName) + "/runs?limit=" + (limit || 10), callback)
176 188 }
modified contents/ui/Status.js
+53 −0
@@ -377,3 +377,56 @@ function worstOf(statuses) {
377 377 }
378 378 return best
379 379 }
380 +
381 +// ── the live CI wall ──────────────────────────────────────────────────
382 +
383 +// How many cards each column of the wall holds, left to right: the
384 +// web's and the Android app's rule. Up to `maxCols` cards, a column
385 +// each; past that the columns fill up from the right — the 7th (of 6)
386 +// pairs the rightmost, the 8th the next one in — so the first cards in
387 +// order keep the most room.
388 +function wallColumns(n, maxCols) {
389 + if (n <= 0 || maxCols <= 0) return []
390 + var cols = Math.min(n, maxCols)
391 + var base = Math.floor(n / cols)
392 + var extra = n % cols
393 + var counts = []
394 + for (var i = 0; i < cols; i++) counts.push(i < cols - extra ? base : base + 1)
395 + return counts
396 +}
397 +
398 +// `items` split into those columns, in order.
399 +function wallSplit(items, maxCols) {
400 + var counts = wallColumns(items.length, maxCols)
401 + var out = []
402 + var from = 0
403 + for (var i = 0; i < counts.length; i++) {
404 + out.push(items.slice(from, from + counts[i]))
405 + from += counts[i]
406 + }
407 + return out
408 +}
409 +
410 +// A running job's clock: "4:07", or "1:02:09" past the hour.
411 +function clock(startIso, now) {
412 + if (!startIso) return "–"
413 + var start = new Date(startIso).getTime()
414 + if (isNaN(start)) return "–"
415 + var s = Math.max(0, Math.floor(((now || Date.now()) - start) / 1000))
416 + function two(n) { return (n < 10 ? "0" : "") + n }
417 + var h = Math.floor(s / 3600)
418 + var m = Math.floor((s % 3600) / 60)
419 + return h > 0 ? h + ":" + two(m) + ":" + two(s % 60) : m + ":" + two(s % 60)
420 +}
421 +
422 +// The step a running job is on: the one running, else the last done.
423 +function currentStep(steps) {
424 + steps = steps || []
425 + for (var i = 0; i < steps.length; i++) {
426 + if (steps[i].status === "running" || steps[i].status === "in_progress") return steps[i]
427 + }
428 + for (var j = steps.length - 1; j >= 0; j--) {
429 + if (isFinished(effective(steps[j].status, steps[j].conclusion))) return steps[j]
430 + }
431 + return null
432 +}
added contents/ui/WallCard.qml
+200 −0
@@ -0,0 +1,200 @@
1 +import QtQuick
2 +import QtQuick.Layouts
3 +import org.kde.plasma.components as PlasmaComponents3
4 +import org.kde.kirigami as Kirigami
5 +
6 +import "Status.js" as Status
7 +
8 +// One running job on the wall. What fits stays: a card stacked four high
9 +// drops its log, then its step bar and details, before its name and
10 +// clock — the order the web wall drops them in.
11 +Rectangle {
12 + id: cardItem
13 +
14 + required property var card
15 + property double now: Date.now()
16 +
17 + readonly property var job: card.job || {}
18 + readonly property var run: card.run || {}
19 + readonly property var steps: job.steps || []
20 + readonly property real gu: Kirigami.Units.gridUnit
21 + readonly property bool roomForLog: height >= gu * 13
22 + readonly property bool roomForMeta: height >= gu * 8
23 + readonly property bool roomForSteps: height >= gu * 6
24 + // Too short for two lines: just the job and its clock.
25 + readonly property bool tiny: height < gu * 3
26 +
27 + radius: Kirigami.Units.cornerRadius
28 + color: Kirigami.Theme.alternateBackgroundColor
29 + border.width: 1
30 + border.color: Qt.alpha(Kirigami.Theme.neutralTextColor, 0.35)
31 + clip: true
32 +
33 + // A light sweeping along the top edge: this one is moving.
34 + Rectangle {
35 + id: shimmerTrack
36 + anchors { top: parent.top; left: parent.left; right: parent.right; margins: 1 }
37 + height: 2
38 + color: Qt.alpha(Kirigami.Theme.neutralTextColor, 0.25)
39 + clip: true
40 +
41 + Rectangle {
42 + width: parent.width * 0.4
43 + height: parent.height
44 + gradient: Gradient {
45 + orientation: Gradient.Horizontal
46 + GradientStop { position: 0; color: "transparent" }
47 + GradientStop { position: 0.5; color: Kirigami.Theme.neutralTextColor }
48 + GradientStop { position: 1; color: "transparent" }
49 + }
50 + NumberAnimation on x {
51 + from: -shimmerTrack.width * 0.4
52 + to: shimmerTrack.width
53 + duration: 1800
54 + loops: Animation.Infinite
55 + }
56 + }
57 + }
58 +
59 + ColumnLayout {
60 + anchors.fill: parent
61 + anchors.margins: Kirigami.Units.smallSpacing * 2
62 + anchors.topMargin: Kirigami.Units.smallSpacing * 2 + 2
63 + spacing: Kirigami.Units.smallSpacing
64 +
65 + RowLayout {
66 + Layout.fillWidth: true
67 + PlasmaComponents3.Label {
68 + Layout.fillWidth: true
69 + elide: Text.ElideRight
70 + font.pointSize: cardItem.tiny ? Kirigami.Theme.defaultFont.pointSize
71 + : Kirigami.Theme.smallFont.pointSize
72 + font.bold: cardItem.tiny
73 + opacity: cardItem.tiny ? 1 : 0.7
74 + text: cardItem.tiny ? Status.jobLabel(cardItem.job)
75 + : (cardItem.card.repository || {}).full_name || ""
76 + }
77 + PlasmaComponents3.Label {
78 + font.family: "monospace"
79 + font.bold: true
80 + color: Kirigami.Theme.neutralTextColor
81 + text: Status.clock(cardItem.job.started_at, cardItem.now)
82 + }
83 + }
84 +
85 + PlasmaComponents3.Label {
86 + visible: !cardItem.tiny
87 + Layout.fillWidth: true
88 + elide: Text.ElideRight
89 + font.bold: true
90 + text: Status.jobLabel(cardItem.job)
91 + }
92 +
93 + PlasmaComponents3.Label {
94 + visible: cardItem.roomForMeta
95 + Layout.fillWidth: true
96 + elide: Text.ElideRight
97 + font: Kirigami.Theme.smallFont
98 + opacity: 0.7
99 + text: ["#" + cardItem.run.number,
100 + Status.runTitle(cardItem.run),
101 + [Status.shortRef(cardItem.run.head_ref), Status.shortSha(cardItem.run.head_sha)]
102 + .filter(function(s) { return s !== "" }).join("@")]
103 + .filter(function(s) { return s !== "" }).join(" · ")
104 + }
105 +
106 + // A segment per step, coloured by how it went.
107 + RowLayout {
108 + visible: cardItem.roomForSteps && cardItem.steps.length > 0
109 + Layout.fillWidth: true
110 + // Its segments fill its height; it mustn't fill the card's.
111 + Layout.fillHeight: false
112 + Layout.preferredHeight: 4
113 + Layout.maximumHeight: 4
114 + spacing: 2
115 + Repeater {
116 + model: cardItem.steps
117 + Rectangle {
118 + required property var modelData
119 + readonly property string status: Status.effective(modelData.status, modelData.conclusion)
120 + Layout.fillWidth: true
121 + Layout.fillHeight: true
122 + radius: 2
123 + color: status === "running" || status === "in_progress"
124 + ? Kirigami.Theme.neutralTextColor
125 + : Status.isFinished(status) ? Status.color(status, Kirigami.Theme)
126 + : Qt.alpha(Kirigami.Theme.disabledTextColor, 0.3)
127 + }
128 + }
129 + }
130 +
131 + PlasmaComponents3.Label {
132 + readonly property var step: Status.currentStep(cardItem.steps)
133 + visible: cardItem.roomForSteps && step !== null
134 + Layout.fillWidth: true
135 + elide: Text.ElideRight
136 + font: Kirigami.Theme.smallFont
137 + text: step ? (step.name || ("Step " + step.number)) : ""
138 + }
139 +
140 + // The newest lines, bottom-aligned; older ones run off the top
141 + // under a fade.
142 + Item {
143 + id: logBox
144 + visible: cardItem.roomForLog
145 + Layout.fillWidth: true
146 + Layout.fillHeight: true
147 + clip: true
148 +
149 + readonly property real lineHeight: logText.font.pixelSize * 1.3
150 + readonly property int fits: Math.ceil(height / Math.max(1, lineHeight)) + 1
151 +
152 + Text {
153 + id: logText
154 + anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
155 + font.family: "monospace"
156 + font.pixelSize: Math.round(Kirigami.Theme.smallFont.pixelSize > 0
157 + ? Kirigami.Theme.smallFont.pixelSize * 0.95 : 10)
158 + lineHeight: 1.3
159 + lineHeightMode: Text.ProportionalHeight
160 + wrapMode: Text.NoWrap
161 + elide: Text.ElideNone
162 + textFormat: Text.PlainText
163 + color: Kirigami.Theme.textColor
164 + opacity: 0.85
165 + text: (cardItem.card.log || []).slice(-logBox.fits).join("\n")
166 + }
167 +
168 + Rectangle {
169 + anchors { left: parent.left; right: parent.right; top: parent.top }
170 + height: parent.height * 0.3
171 + gradient: Gradient {
172 + GradientStop { position: 0; color: cardItem.color }
173 + GradientStop { position: 1; color: Qt.alpha(cardItem.color, 0) }
174 + }
175 + }
176 + }
177 +
178 + Item { visible: !cardItem.roomForLog; Layout.fillHeight: true }
179 +
180 + RowLayout {
181 + visible: cardItem.roomForMeta
182 + Layout.fillWidth: true
183 + spacing: Kirigami.Units.smallSpacing
184 + Kirigami.Icon {
185 + source: "cpu"
186 + Layout.preferredWidth: Kirigami.Units.iconSizes.small
187 + Layout.preferredHeight: Kirigami.Units.iconSizes.small
188 + opacity: 0.6
189 + }
190 + PlasmaComponents3.Label {
191 + Layout.fillWidth: true
192 + elide: Text.ElideRight
193 + font: Kirigami.Theme.smallFont
194 + opacity: 0.6
195 + text: [(cardItem.card.runner || {}).name || "runner", (cardItem.card.runner || {}).architecture || ""]
196 + .filter(function(s) { return s !== "" }).join(" · ")
197 + }
198 + }
199 + }
200 +}
added contents/ui/WallView.qml
+218 −0
@@ -0,0 +1,218 @@
1 +import QtQuick
2 +import QtQuick.Layouts
3 +import QtQuick.Controls as QQC2
4 +import org.kde.plasma.components as PlasmaComponents3
5 +import org.kde.kirigami as Kirigami
6 +
7 +import "Status.js" as Status
8 +
9 +// The live CI wall: every job running right now, a card each, tiled to
10 +// fill the space — GitGud's /users/ci/live, as a window of its own or as
11 +// the widget itself on the desktop (wall mode).
12 +//
13 +// Up to six cards side by side (fewer when they'd get too narrow to read
14 +// a log line in); past that the columns fill up from the right in pairs,
15 +// as on the web and in the Android app. The order is the operator's:
16 +// oldest-started first, or newest (the header's buttons, or R). So is
17 +// what it shows: your own jobs, or one organization's.
18 +//
19 +// All state lives in `store` (main.qml): it polls /ci/live while any
20 +// wall is on screen, so a window and a desktop wall share one feed.
21 +Item {
22 + id: wall
23 +
24 + required property var store
25 + // Leave the header's window-only buttons out on the desktop.
26 + property bool inWindow: false
27 + property bool fullScreen: false
28 + signal toggleFullScreen()
29 +
30 + readonly property var cards: store.wallCards
31 + readonly property var ordered: cards ? (store.wallNewestFirst ? cards.slice().reverse() : cards) : null
32 + // One column per 13 grid units of width, at most six; more (up to six)
33 + // when a column would stack more cards than fit at a readable height
34 + // — a small desktop widget would otherwise draw slivers.
35 + readonly property int widthCols: Math.max(1, Math.min(6, Math.floor(grid.width / (Kirigami.Units.gridUnit * 13))))
36 + readonly property int rowsFit: Math.max(1, Math.floor(grid.height / (Kirigami.Units.gridUnit * 3.5)))
37 + readonly property int maxCols: cards ? Math.min(6, Math.max(widthCols, Math.ceil(cards.length / rowsFit))) : widthCols
38 + readonly property var columns: ordered ? Status.wallSplit(ordered, maxCols) : []
39 + // A small desktop widget: the header keeps only what it must.
40 + readonly property bool narrow: width < Kirigami.Units.gridUnit * 34
41 +
42 + Component.onCompleted: store.wallViewers += 1
43 + Component.onDestruction: store.wallViewers -= 1
44 +
45 + ColumnLayout {
46 + anchors.fill: parent
47 + anchors.margins: Kirigami.Units.smallSpacing
48 + spacing: Kirigami.Units.smallSpacing
49 +
50 + // ── header ──────────────────────────────────────────────────────
51 + RowLayout {
52 + Layout.fillWidth: true
53 + spacing: Kirigami.Units.smallSpacing
54 +
55 + Rectangle {
56 + id: liveDot
57 + Layout.preferredWidth: Kirigami.Units.smallSpacing * 2.5
58 + Layout.preferredHeight: width
59 + radius: width / 2
60 + color: wall.store.wallError === "" ? Kirigami.Theme.neutralTextColor
61 + : Kirigami.Theme.negativeTextColor
62 + SequentialAnimation on opacity {
63 + loops: Animation.Infinite
64 + NumberAnimation { from: 1; to: 0.3; duration: 900 }
65 + NumberAnimation { from: 0.3; to: 1; duration: 900 }
66 + }
67 + }
68 +
69 + Kirigami.Heading {
70 + level: 3
71 + text: i18n("Live CI")
72 + }
73 +
74 + PlasmaComponents3.ComboBox {
75 + id: scopeBox
76 + // "" is the token owner's own wall; otherwise an org handle.
77 + readonly property var scopes: [""].concat(wall.store.wallOrgs.map(function(o) { return o.handle }))
78 + model: scopes.map(function(s) { return s === "" ? i18n("Mine") : "@" + s })
79 + currentIndex: Math.max(0, scopes.indexOf(wall.store.wallScope))
80 + onActivated: function(index) { wall.store.setWallScope(scopes[index]) }
81 + PlasmaComponents3.ToolTip { text: i18n("Whose running jobs to show") }
82 + }
83 +
84 + PlasmaComponents3.Label {
85 + visible: wall.cards !== null && !wall.narrow
86 + opacity: 0.7
87 + text: wall.cards ? i18np("1 job", "%1 jobs", wall.cards.length) : ""
88 + }
89 +
90 + // The tiling at a glance: a bar per column, split by its cards.
91 + Row {
92 + visible: wall.columns.length > 0 && !wall.narrow
93 + spacing: 2
94 + Repeater {
95 + model: wall.columns
96 + Column {
97 + id: glyphColumn
98 + required property var modelData
99 + readonly property int cards: modelData.length
100 + spacing: 1
101 + Repeater {
102 + model: glyphColumn.cards
103 + Rectangle {
104 + width: 5
105 + height: (14 - (glyphColumn.cards - 1)) / glyphColumn.cards
106 + radius: 1
107 + color: Kirigami.Theme.disabledTextColor
108 + }
109 + }
110 + }
111 + }
112 + }
113 +
114 + PlasmaComponents3.Label {
115 + visible: wall.store.wallError !== "" && wall.cards !== null
116 + color: Kirigami.Theme.negativeTextColor
117 + font: Kirigami.Theme.smallFont
118 + text: i18n("offline · retrying")
119 + PlasmaComponents3.ToolTip { text: wall.store.wallError }
120 + }
121 +
122 + Item { Layout.fillWidth: true }
123 +
124 + QQC2.ButtonGroup { id: orderGroup }
125 +
126 + PlasmaComponents3.ToolButton {
127 + checkable: true
128 + checked: !wall.store.wallNewestFirst
129 + QQC2.ButtonGroup.group: orderGroup
130 + icon.name: "view-sort-ascending"
131 + text: i18n("Oldest first")
132 + display: wall.narrow ? QQC2.AbstractButton.IconOnly : QQC2.AbstractButton.TextOnly
133 + onClicked: wall.store.setWallNewestFirst(false)
134 + PlasmaComponents3.ToolTip { text: i18n("Oldest first (R flips the order)") }
135 + }
136 +
137 + PlasmaComponents3.ToolButton {
138 + checkable: true
139 + checked: wall.store.wallNewestFirst
140 + QQC2.ButtonGroup.group: orderGroup
141 + icon.name: "view-sort-descending"
142 + text: i18n("Newest first")
143 + display: wall.narrow ? QQC2.AbstractButton.IconOnly : QQC2.AbstractButton.TextOnly
144 + onClicked: wall.store.setWallNewestFirst(true)
145 + PlasmaComponents3.ToolTip { text: i18n("Newest first (R flips the order)") }
146 + }
147 +
148 + PlasmaComponents3.ToolButton {
149 + visible: wall.inWindow
150 + icon.name: wall.fullScreen ? "view-restore" : "view-fullscreen"
151 + text: wall.fullScreen ? i18n("Exit Full Screen") : i18n("Full Screen")
152 + display: QQC2.AbstractButton.IconOnly
153 + onClicked: wall.toggleFullScreen()
154 + PlasmaComponents3.ToolTip { text: wall.fullScreen ? i18n("Exit Full Screen (Esc)") : i18n("Full Screen (F11)") }
155 + }
156 +
157 + PlasmaComponents3.ToolButton {
158 + icon.name: "internet-web-browser"
159 + text: i18n("Open in Browser")
160 + display: QQC2.AbstractButton.IconOnly
161 + onClicked: Qt.openUrlExternally(wall.store.wallWebUrl())
162 + PlasmaComponents3.ToolTip { text: i18n("Open in Browser") }
163 + }
164 + }
165 +
166 + // ── cards ───────────────────────────────────────────────────────
167 + Item {
168 + id: grid
169 + Layout.fillWidth: true
170 + Layout.fillHeight: true
171 +
172 + PlasmaComponents3.Label {
173 + anchors.centerIn: parent
174 + width: parent.width - Kirigami.Units.gridUnit * 2
175 + visible: wall.ordered === null || wall.ordered.length === 0
176 + horizontalAlignment: Text.AlignHCenter
177 + wrapMode: Text.WordWrap
178 + opacity: 0.7
179 + text: !wall.store.wallConfigured
180 + ? i18n("Configure the widget to connect to your GitGud instance")
181 + : wall.ordered === null
182 + ? (wall.store.wallError !== "" ? wall.store.wallError : i18n("Loading…"))
183 + : i18n("Nothing running. Jobs appear here the moment a runner picks them up.")
184 + }
185 +
186 + RowLayout {
187 + anchors.fill: parent
188 + spacing: Kirigami.Units.smallSpacing
189 +
190 + Repeater {
191 + model: wall.columns
192 +
193 + ColumnLayout {
194 + id: column
195 + required property var modelData
196 + Layout.fillWidth: true
197 + Layout.fillHeight: true
198 + Layout.preferredWidth: 1 // equal shares
199 + spacing: Kirigami.Units.smallSpacing
200 +
201 + Repeater {
202 + model: column.modelData
203 +
204 + WallCard {
205 + required property var modelData
206 + Layout.fillWidth: true
207 + Layout.fillHeight: true
208 + Layout.preferredHeight: 1 // equal shares
209 + card: modelData
210 + now: wall.store.now
211 + }
212 + }
213 + }
214 + }
215 + }
216 + }
217 + }
218 +}
added contents/ui/WallWindow.qml
+55 −0
@@ -0,0 +1,55 @@
1 +import QtQuick
2 +import QtQuick.Window
3 +import org.kde.kirigami as Kirigami
4 +
5 +// The live CI wall in a window of its own, for a second screen: F11 for
6 +// full screen, Esc to leave it, R to flip the order. Created by
7 +// store.openWallWindow(); there's one, and closing it destroys it.
8 +Window {
9 + id: wallWindow
10 +
11 + required property var store
12 +
13 + transientParent: null
14 + flags: Qt.Window
15 + width: Kirigami.Units.gridUnit * 64
16 + height: Kirigami.Units.gridUnit * 36
17 + minimumWidth: Kirigami.Units.gridUnit * 20
18 + minimumHeight: Kirigami.Units.gridUnit * 12
19 + title: i18n("Live CI")
20 +
21 + Kirigami.Theme.colorSet: Kirigami.Theme.Window
22 + Kirigami.Theme.inherit: false
23 + color: Kirigami.Theme.backgroundColor
24 +
25 + readonly property bool fullScreen: visibility === Window.FullScreen
26 +
27 + onClosing: {
28 + store.forgetWallWindow(wallWindow)
29 + wallWindow.destroy()
30 + }
31 +
32 + function present() {
33 + if (visibility === Window.Minimized) showNormal()
34 + else show()
35 + raise()
36 + requestActivate()
37 + }
38 +
39 + function toggleFullScreen() {
40 + if (fullScreen) showNormal()
41 + else showFullScreen()
42 + }
43 +
44 + Shortcut { sequences: [StandardKey.FullScreen]; onActivated: wallWindow.toggleFullScreen() }
45 + Shortcut { sequence: "Escape"; enabled: wallWindow.fullScreen; onActivated: wallWindow.showNormal() }
46 + Shortcut { sequence: "R"; onActivated: wallWindow.store.setWallNewestFirst(!wallWindow.store.wallNewestFirst) }
47 +
48 + WallView {
49 + anchors.fill: parent
50 + store: wallWindow.store
51 + inWindow: true
52 + fullScreen: wallWindow.fullScreen
53 + onToggleFullScreen: wallWindow.toggleFullScreen()
54 + }
55 +}
modified contents/ui/configBehavior.qml
+15 −0
@@ -12,6 +12,7 @@ KCM.SimpleKCM {
12 12 property alias cfg_runsPerRepo: runsPerRepoSpinBox.value
13 13 property alias cfg_hideIdle: hideIdleCheckBox.checked
14 14 property alias cfg_notifyMyFailures: notifyMyFailuresCheckBox.checked
15 + property alias cfg_desktopWall: desktopWallCheckBox.checked
15 16
16 17 Kirigami.FormLayout {
17 18 QQC2.SpinBox {
@@ -52,6 +53,20 @@ KCM.SimpleKCM {
52 53 text: i18n("Hide repositories that have never run CI")
53 54 }
54 55
56 + QQC2.CheckBox {
57 + id: desktopWallCheckBox
58 + Kirigami.FormData.label: i18n("Live CI wall:")
59 + text: i18n("Wall mode: on the desktop, show the wall")
60 + }
61 +
62 + QQC2.Label {
63 + Layout.fillWidth: true
64 + wrapMode: Text.WordWrap
65 + font: Kirigami.Theme.smallFont
66 + color: Kirigami.Theme.disabledTextColor
67 + text: i18n("Every job running right now, a card each, tiled to fill the widget. Resize it, or give it a screen of its own. In a panel the widget stays the circles; open the wall from the popup or the right-click menu instead.")
68 + }
69 +
55 70 QQC2.CheckBox {
56 71 id: notifyMyFailuresCheckBox
57 72 Kirigami.FormData.label: i18n("Notifications:")
modified contents/ui/main.qml
+233 −123
@@ -17,9 +17,11 @@ PlasmoidItem {
17 17
18 18 // In a panel always show the circles, however much room we're given;
19 19 // on the desktop let Plasma pick by size.
20 + // In wall mode on the desktop, the wall whatever the size.
20 21 preferredRepresentation: Plasmoid.formFactor === PlasmaCore.Types.Horizontal
21 22 || Plasmoid.formFactor === PlasmaCore.Types.Vertical
22 − ? compactRepresentation : null
23 + ? compactRepresentation
24 + : Plasmoid.configuration.desktopWall ? fullRepresentation : null
23 25
24 26 Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground | PlasmaCore.Types.ConfigurableBackground
25 27
@@ -74,6 +76,20 @@ PlasmoidItem {
74 76 property double lastFullRefresh: 0
75 77 property double now: Date.now()
76 78
79 + // The live CI wall (WallView): polled only while one is on screen.
80 + property var wallCards: null // [{job, run, repository, runner, log}], or null before the first answer
81 + property string wallError: ""
82 + property var wallOrgs: [] // namespaces of kind "org", for the wall's scope picker
83 + property int wallViewers: 0
84 + property int wallGeneration: 0
85 + property var wallWindow: null
86 + readonly property string wallScope: Plasmoid.configuration.wallScope
87 + readonly property bool wallNewestFirst: Plasmoid.configuration.wallNewestFirst
88 + readonly property bool wallConfigured: api.configured
89 + readonly property bool onDesktop: Plasmoid.formFactor !== PlasmaCore.Types.Horizontal
90 + && Plasmoid.formFactor !== PlasmaCore.Types.Vertical
91 + readonly property bool wallMode: onDesktop && Plasmoid.configuration.desktopWall
92 +
77 93 // Following runs outside the list: windows and notifications.
78 94 property var runWindows: ({}) // "owner/name#n" -> RunWindow
79 95 property var notified: ({}) // "<run or job key>@completed_at" -> true, once per ending
@@ -352,6 +368,7 @@ PlasmoidItem {
352 368 if (rows[i].full_name) index[rows[i].full_name] = rows[i]
353 369 }
354 370 repoIndex = index
371 + wallOrgs = (data.namespaces || []).filter(function(n) { return n.kind === "org" })
355 372 lastError = ""
356 373 // Only a refresh that reached the server counts as full.
357 374 if (full) lastFullRefresh = Date.now()
@@ -489,7 +506,7 @@ PlasmoidItem {
489 506 // run window is open.
490 507 Timer {
491 508 interval: 1000
492 − running: root.expanded || Object.keys(root.runWindows).length > 0
509 + running: root.expanded || Object.keys(root.runWindows).length > 0 || root.wallViewers > 0
493 510 repeat: true
494 511 onTriggered: root.now = Date.now()
495 512 }
@@ -647,6 +664,76 @@ PlasmoidItem {
647 664 if (runWindows[key] === win) runWindows = setKey(runWindows, key, undefined)
648 665 }
649 666
667 + // ── the live CI wall ────────────────────────────────────────────────
668 +
669 + Timer {
670 + interval: 3000
671 + repeat: true
672 + triggeredOnStart: true
673 + running: root.wallViewers > 0 && root.hasValidConfig
674 + onTriggered: root.pollWall()
675 + }
676 +
677 + function pollWall() {
678 + var generation = ++wallGeneration
679 + // Enough log for a full-height card on a tall screen.
680 + api.ciWall(wallScope, 160, function(ok, data) {
681 + if (generation !== root.wallGeneration) return
682 + if (ok) {
683 + wallCards = data.jobs || []
684 + wallError = ""
685 + } else {
686 + // Keep the last cards up: a wall across the room going
687 + // blank says less than one saying it's retrying.
688 + wallError = data
689 + }
690 + })
691 + }
692 +
693 + function setWallScope(scope) {
694 + if (scope === wallScope) return
695 + Plasmoid.configuration.wallScope = scope
696 + wallCards = null
697 + wallError = ""
698 + Qt.callLater(pollWall)
699 + }
700 +
701 + function setWallNewestFirst(newest) {
702 + Plasmoid.configuration.wallNewestFirst = newest
703 + }
704 +
705 + function wallWebUrl() {
706 + return api.wallUrl(wallScope)
707 + }
708 +
709 + Component {
710 + id: wallWindowComponent
711 + WallWindow {}
712 + }
713 +
714 + function openWallWindow() {
715 + if (!wallWindow) {
716 + wallWindow = wallWindowComponent.createObject(root, { store: root })
717 + if (!wallWindow) {
718 + lastError = i18n("Could not open the live CI wall")
719 + return
720 + }
721 + }
722 + wallWindow.present()
723 + }
724 +
725 + function forgetWallWindow(win) {
726 + if (wallWindow === win) wallWindow = null
727 + }
728 +
729 + Plasmoid.contextualActions: [
730 + PlasmaCore.Action {
731 + text: i18n("Open Live CI Wall")
732 + icon.name: "view-grid"
733 + onTriggered: root.openWallWindow()
734 + }
735 + ]
736 +
650 737 // ── notify when finished ────────────────────────────────────────────
651 738 //
652 739 // Three lists in the config, so they survive a restart:
@@ -984,151 +1071,174 @@ PlasmoidItem {
984 1071 inPanel: true
985 1072 }
986 1073
987 − fullRepresentation: ColumnLayout {
988 − Layout.minimumWidth: Kirigami.Units.gridUnit * 22
989 − Layout.minimumHeight: Kirigami.Units.gridUnit * 18
990 − Layout.preferredWidth: Kirigami.Units.gridUnit * 30
991 − Layout.preferredHeight: Kirigami.Units.gridUnit * 30
992 − spacing: Kirigami.Units.smallSpacing
1074 + fullRepresentation: root.wallMode ? wallRepresentation : popupRepresentation
993 1075
994 − // Header
995 − RowLayout {
996 − Layout.fillWidth: true
997 − spacing: Kirigami.Units.smallSpacing
1076 + Component {
1077 + id: wallRepresentation
1078 + WallView {
1079 + Layout.minimumWidth: Kirigami.Units.gridUnit * 16
1080 + Layout.minimumHeight: Kirigami.Units.gridUnit * 10
1081 + Layout.preferredWidth: Kirigami.Units.gridUnit * 48
1082 + Layout.preferredHeight: Kirigami.Units.gridUnit * 27
1083 + store: root
1084 + }
1085 + }
998 1086
999 − PlasmaComponents3.ToolButton {
1000 − visible: root.focusKey !== ""
1001 − icon.name: "go-previous"
1002 − onClicked: root.focusKey = ""
1003 − PlasmaComponents3.ToolTip { text: i18n("All runs") }
1004 − }
1087 + Component {
1088 + id: popupRepresentation
1089 + ColumnLayout {
1090 + Layout.minimumWidth: Kirigami.Units.gridUnit * 22
1091 + Layout.minimumHeight: Kirigami.Units.gridUnit * 18
1092 + Layout.preferredWidth: Kirigami.Units.gridUnit * 30
1093 + Layout.preferredHeight: Kirigami.Units.gridUnit * 30
1094 + spacing: Kirigami.Units.smallSpacing
1005 1095
1006 − Kirigami.Heading {
1096 + // Header
1097 + RowLayout {
1007 1098 Layout.fillWidth: true
1008 − level: 3
1009 − elide: Text.ElideRight
1010 − text: root.focusKey === "" ? i18n("All runs") : root.focusLabel
1011 − }
1099 + spacing: Kirigami.Units.smallSpacing
1012 1100
1013 − PlasmaComponents3.BusyIndicator {
1014 − visible: root.loading || root.busy
1015 − Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
1016 − Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
1017 − }
1101 + PlasmaComponents3.ToolButton {
1102 + visible: root.focusKey !== ""
1103 + icon.name: "go-previous"
1104 + onClicked: root.focusKey = ""
1105 + PlasmaComponents3.ToolTip { text: i18n("All runs") }
1106 + }
1018 1107
1019 − PlasmaComponents3.ToolButton {
1020 − visible: root.focusKey !== "" && root.focusKey.indexOf("repo:") === 0
1021 − icon.name: "internet-web-browser"
1022 − onClicked: Qt.openUrlExternally(api.webUrl(root.focusKey.substring(5)) + "/actions")
1023 − PlasmaComponents3.ToolTip { text: i18n("Open in browser") }
1024 − }
1108 + Kirigami.Heading {
1109 + Layout.fillWidth: true
1110 + level: 3
1111 + elide: Text.ElideRight
1112 + text: root.focusKey === "" ? i18n("All runs") : root.focusLabel
1113 + }
1025 1114
1026 − PlasmaComponents3.ToolButton {
1027 − icon.name: "view-refresh"
1028 − enabled: root.hasValidConfig && !root.loading
1029 − onClicked: root.refresh(true)
1030 − PlasmaComponents3.ToolTip { text: i18n("Refresh") }
1031 − }
1115 + PlasmaComponents3.BusyIndicator {
1116 + visible: root.loading || root.busy
1117 + Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
1118 + Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
1119 + }
1032 1120
1033 − PlasmaComponents3.ToolButton {
1034 − icon.name: "configure"
1035 − onClicked: Plasmoid.internalAction("configure").trigger()
1036 − PlasmaComponents3.ToolTip { text: i18n("Configure") }
1037 − }
1121 + PlasmaComponents3.ToolButton {
1122 + visible: root.focusKey !== "" && root.focusKey.indexOf("repo:") === 0
1123 + icon.name: "internet-web-browser"
1124 + onClicked: Qt.openUrlExternally(api.webUrl(root.focusKey.substring(5)) + "/actions")
1125 + PlasmaComponents3.ToolTip { text: i18n("Open in browser") }
1126 + }
1038 1127
1039 − PlasmaComponents3.ToolButton {
1040 − checkable: true
1041 − checked: Plasmoid.configuration.pin
1042 − onToggled: Plasmoid.configuration.pin = checked
1043 − icon.name: "window-pin"
1044 − text: i18n("Keep Open")
1045 − display: QQC2.AbstractButton.IconOnly
1046 − PlasmaComponents3.ToolTip { text: i18n("Keep Open") }
1047 − }
1048 − }
1128 + PlasmaComponents3.ToolButton {
1129 + icon.name: "view-refresh"
1130 + enabled: root.hasValidConfig && !root.loading
1131 + onClicked: root.refresh(true)
1132 + PlasmaComponents3.ToolTip { text: i18n("Refresh") }
1133 + }
1049 1134
1050 − // The same circles as the panel, as a filter bar.
1051 − CircleStrip {
1052 − visible: root.hasValidConfig && root.hasWatched && circlesModel.count > 0
1053 − Layout.fillWidth: true
1054 − store: root
1055 − inPanel: false
1056 − }
1135 + PlasmaComponents3.ToolButton {
1136 + icon.name: "view-grid"
1137 + enabled: root.hasValidConfig
1138 + onClicked: root.openWallWindow()
1139 + PlasmaComponents3.ToolTip { text: i18n("Open Live CI Wall") }
1140 + }
1057 1141
1058 − PlasmaComponents3.Label {
1059 − visible: root.lastError !== ""
1060 − Layout.fillWidth: true
1061 − wrapMode: Text.WordWrap
1062 − color: Kirigami.Theme.negativeTextColor
1063 − font: Kirigami.Theme.smallFont
1064 − text: root.lastError
1065 − }
1142 + PlasmaComponents3.ToolButton {
1143 + icon.name: "configure"
1144 + onClicked: Plasmoid.internalAction("configure").trigger()
1145 + PlasmaComponents3.ToolTip { text: i18n("Configure") }
1146 + }
1066 1147
1067 − Kirigami.Separator { Layout.fillWidth: true }
1148 + PlasmaComponents3.ToolButton {
1149 + checkable: true
1150 + checked: Plasmoid.configuration.pin
1151 + onToggled: Plasmoid.configuration.pin = checked
1152 + icon.name: "window-pin"
1153 + text: i18n("Keep Open")
1154 + display: QQC2.AbstractButton.IconOnly
1155 + PlasmaComponents3.ToolTip { text: i18n("Keep Open") }
1156 + }
1157 + }
1068 1158
1069 − // Setup prompts
1070 − ColumnLayout {
1071 − visible: !root.hasValidConfig || !root.hasWatched
1072 − Layout.fillWidth: true
1073 − Layout.fillHeight: true
1074 − spacing: Kirigami.Units.largeSpacing
1075 −
1076 − Item { Layout.fillHeight: true }
1077 −
1078 − Kirigami.Icon {
1079 − source: root.logoSource
1080 − isMask: true
1081 − color: Kirigami.Theme.disabledTextColor
1082 − Layout.alignment: Qt.AlignHCenter
1083 − Layout.preferredWidth: Kirigami.Units.iconSizes.huge
1084 − Layout.preferredHeight: Kirigami.Units.iconSizes.huge
1159 + // The same circles as the panel, as a filter bar.
1160 + CircleStrip {
1161 + visible: root.hasValidConfig && root.hasWatched && circlesModel.count > 0
1162 + Layout.fillWidth: true
1163 + store: root
1164 + inPanel: false
1085 1165 }
1086 1166
1087 1167 PlasmaComponents3.Label {
1168 + visible: root.lastError !== ""
1088 1169 Layout.fillWidth: true
1089 − horizontalAlignment: Text.AlignHCenter
1090 1170 wrapMode: Text.WordWrap
1091 − text: !root.hasValidConfig
1092 − ? i18n("Configure the widget to connect to your GitGud instance")
1093 − : i18n("Pick repositories or namespaces to watch")
1094 − }
1095 −
1096 − PlasmaComponents3.Button {
1097 − Layout.alignment: Qt.AlignHCenter
1098 − icon.name: "configure"
1099 − text: !root.hasValidConfig ? i18n("Configure Connection") : i18n("Choose Repositories")
1100 − onClicked: Plasmoid.internalAction("configure").trigger()
1171 + color: Kirigami.Theme.negativeTextColor
1172 + font: Kirigami.Theme.smallFont
1173 + text: root.lastError
1101 1174 }
1102 1175
1103 − Item { Layout.fillHeight: true }
1104 − }
1176 + Kirigami.Separator { Layout.fillWidth: true }
1105 1177
1106 − PlasmaComponents3.ScrollView {
1107 − visible: root.hasValidConfig && root.hasWatched
1108 − Layout.fillWidth: true
1109 − Layout.fillHeight: true
1110 −
1111 − contentWidth: availableWidth
1112 −
1113 − ListView {
1114 − id: runList
1115 − clip: true
1116 − model: runsModel
1117 − reuseItems: false
1118 −
1119 − delegate: RunItem {
1120 − required property string runJson
1121 − width: runList.width
1122 − store: root
1123 − run: JSON.parse(runJson)
1124 − showRepo: root.focusKey === "" || root.focusKey.indexOf("ns:") === 0
1178 + // Setup prompts
1179 + ColumnLayout {
1180 + visible: !root.hasValidConfig || !root.hasWatched
1181 + Layout.fillWidth: true
1182 + Layout.fillHeight: true
1183 + spacing: Kirigami.Units.largeSpacing
1184 +
1185 + Item { Layout.fillHeight: true }
1186 +
1187 + Kirigami.Icon {
1188 + source: root.logoSource
1189 + isMask: true
1190 + color: Kirigami.Theme.disabledTextColor
1191 + Layout.alignment: Qt.AlignHCenter
1192 + Layout.preferredWidth: Kirigami.Units.iconSizes.huge
1193 + Layout.preferredHeight: Kirigami.Units.iconSizes.huge
1125 1194 }
1126 1195
1127 1196 PlasmaComponents3.Label {
1128 − anchors.centerIn: parent
1129 − visible: runList.count === 0 && !root.loading
1130 − opacity: 0.7
1131 − text: i18n("No runs yet")
1197 + Layout.fillWidth: true
1198 + horizontalAlignment: Text.AlignHCenter
1199 + wrapMode: Text.WordWrap
1200 + text: !root.hasValidConfig
1201 + ? i18n("Configure the widget to connect to your GitGud instance")
1202 + : i18n("Pick repositories or namespaces to watch")
1203 + }
1204 +
1205 + PlasmaComponents3.Button {
1206 + Layout.alignment: Qt.AlignHCenter
1207 + icon.name: "configure"
1208 + text: !root.hasValidConfig ? i18n("Configure Connection") : i18n("Choose Repositories")
1209 + onClicked: Plasmoid.internalAction("configure").trigger()
1210 + }
1211 +
1212 + Item { Layout.fillHeight: true }
1213 + }
1214 +
1215 + PlasmaComponents3.ScrollView {
1216 + visible: root.hasValidConfig && root.hasWatched
1217 + Layout.fillWidth: true
1218 + Layout.fillHeight: true
1219 +
1220 + contentWidth: availableWidth
1221 +
1222 + ListView {
1223 + id: runList
1224 + clip: true
1225 + model: runsModel
1226 + reuseItems: false
1227 +
1228 + delegate: RunItem {
1229 + required property string runJson
1230 + width: runList.width
1231 + store: root
1232 + run: JSON.parse(runJson)
1233 + showRepo: root.focusKey === "" || root.focusKey.indexOf("ns:") === 0
1234 + }
1235 +
1236 + PlasmaComponents3.Label {
1237 + anchors.centerIn: parent
1238 + visible: runList.count === 0 && !root.loading
1239 + opacity: 0.7
1240 + text: i18n("No runs yet")
1241 + }
1132 1242 }
1133 1243 }
1134 1244 }
modified metadata.json
+1 −1
@@ -11,7 +11,7 @@
11 11 "Icon": "run-build",
12 12 "Id": "org.neiam.kde.gitgud",
13 13 "Name": "GitGud CI",
14 − "Version": "0.4.0",
14 + "Version": "0.5.0",
15 15 "Website": "http://neiam.org"
16 16 },
17 17 "KPackageStructure": "Plasma/Applet",

Parents: 0fc3e53