Pin the popup, open runs in their own window, notify when a run finishes

4f26744 · Gabriel Morell · 2026-10-19 16:58

13 files +746 -137

Files changed

modified README.md
+26 −2
@@ -8,6 +8,9 @@ A Plasma 6 panel widget that shows [GitGud](https://gg.neiam.co) CI runs as a ro
8 8
9 9 - **Logo**: opens a popup with every recent run across everything you watch.
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 +- **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 +- **Run windows**: break a run out of the popup into its own window. See [Run windows](#run-windows).
13 +- **Notify when finished**: ring the bell on a run and get a desktop notification when it ends. See [Notifications](#notifications).
11 14
12 15 ## Statuses
13 16
@@ -47,6 +50,26 @@ Runs expand the same way as in the Android app:
47 50
48 51 Open runs keep refreshing while they're in flight. Re-run and cancel are only shown when the server says you can manage the repository and the token has `ci:write`.
49 52
53 +Hover a run's row for its bell and window buttons, or right-click it for the same actions plus *Open in Browser*.
54 +
55 +## Run windows
56 +
57 +Click the window button on a run (on hover, or the one next to **Open** once it's expanded), or right-click → **Open in Window**. The run opens in a normal, resizable window titled `owner/repo #12 — Build`, with its own taskbar entry:
58 +
59 +- The header shows the workflow, repository, ref, commit, trigger, who started it, its status, age and duration, with the bell, a refresh button and *Open in Browser*.
60 +- Below it are the same jobs, steps, logs, artifacts and re-run/cancel buttons as in the popup. Jobs and steps opened in the window are open in the popup too, and the other way round.
61 +- 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 +- 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 +## Notifications
65 +
66 +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.
67 +
68 +- Watched runs are refreshed at the fast interval until they finish, whatever else the widget is doing, and are fetched by number so they're still followed after newer runs push them off the list.
69 +- The watched list is saved in the widget's config (`watchedRuns`), so it survives a Plasma restart or a reboot. A run that finished while Plasma wasn't running is announced the next time it starts. A run the server no longer has (404) is quietly dropped.
70 +- Notifications go through Plasma's own notification event, so they follow *System Settings → Notifications → Plasma Workspace*, including Do Not Disturb.
71 +- **Notify when any run I triggered fails** (*Behavior*, off by default) also notifies about failed or timed-out runs started by the token's user. It covers runs in watched repositories that the widget saw while they were still running. A run that was already failed when the widget first saw it is not announced.
72 +
50 73 ## Configuration
51 74
52 75 ### General
@@ -70,6 +93,7 @@ Open runs keep refreshing while they're in flight. Re-run and cancel are only sh
70 93 | While a run is in flight | 5 s | Fast poll; only refetches repos whose latest status moved or is still active |
71 94 | Runs listed per repository | 10 | |
72 95 | Hide repositories that have never run CI | on | |
96 +| Notify when any run I triggered fails | off | See [Notifications](#notifications) |
73 97
74 98 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.
75 99
@@ -86,8 +110,8 @@ kpackagetool6 --type Plasma/Applet --install .
86 110 kpackagetool6 --type Plasma/Applet --upgrade .
87 111
88 112 # Or build a release archive
89 −./package.sh 0.2.0
90 −kpackagetool6 --type Plasma/Applet --install gitgud-plasmoid-0.2.0.tar.gz
113 +./package.sh 0.3.0
114 +kpackagetool6 --type Plasma/Applet --install gitgud-plasmoid-0.3.0.tar.gz
91 115 ```
92 116
93 117 Test without installing:
modified contents/config/main.xml
+18 −0
@@ -30,6 +30,24 @@
30 30 <label>Runs fetched per repository</label>
31 31 <default>10</default>
32 32 </entry>
33 +
34 + <entry name="pin" type="Bool">
35 + <label>Keep the popup open when it loses focus</label>
36 + <default>false</default>
37 + </entry>
38 + </group>
39 +
40 + <group name="Notifications">
41 + <!-- "owner/name#number" of each run to announce once it finishes -->
42 + <entry name="watchedRuns" type="StringList">
43 + <label>Runs to notify about when they finish</label>
44 + <default></default>
45 + </entry>
46 +
47 + <entry name="notifyMyFailures" type="Bool">
48 + <label>Notify when any run the token's user triggered fails</label>
49 + <default>false</default>
50 + </entry>
33 51 </group>
34 52
35 53 <group name="Repositories">
modified contents/ui/GitGudAPI.qml
+10 −9
@@ -45,9 +45,10 @@ QtObject {
45 45 for (var i = 0; i < pending.length; i++) pending[i].cancel()
46 46 }
47 47
48 − // callback(ok, dataOrMessage, network): `network` is true when the
49 − // server was never reached (offline, DNS, timeout) rather than when it
50 − // answered with an error, so callers can retry sooner.
48 + // callback(ok, dataOrMessage, network, httpStatus): `network` is true
49 + // when the server was never reached (offline, DNS, timeout) rather
50 + // than when it answered with an error, so callers can retry sooner;
51 + // `httpStatus` is the server's answer when it gave one (0 otherwise).
51 52 function request(method, path, callback) {
52 53 if (!configured) {
53 54 var msg = "GitGud URL and access token must be configured"
@@ -81,11 +82,11 @@ QtObject {
81 82 if (callback) callback(true, data, false)
82 83 }
83 84
84 − function fail(message, network) {
85 + function fail(message, network, httpStatus) {
85 86 if (!settle()) return
86 87 console.log("GitGud API error:", method, path, message)
87 88 error(message)
88 − if (callback) callback(false, message, !!network)
89 + if (callback) callback(false, message, !!network, httpStatus || 0)
89 90 }
90 91
91 92 timer.triggered.connect(function() {
@@ -118,13 +119,13 @@ QtObject {
118 119 if (xhr.status === 0) {
119 120 fail("Could not reach " + normalizedBase(), true)
120 121 } else if (xhr.status === 401) {
121 − fail(serverMessage || "Authentication failed — check your access token")
122 + fail(serverMessage || "Authentication failed — check your access token", false, 401)
122 123 } else if (xhr.status === 403) {
123 − fail(serverMessage || "Token is not allowed to do that")
124 + fail(serverMessage || "Token is not allowed to do that", false, 403)
124 125 } else if (xhr.status === 404) {
125 − fail(serverMessage || "Not found: " + path)
126 + fail(serverMessage || "Not found: " + path, false, 404)
126 127 } else {
127 − fail(serverMessage || ("Request failed: HTTP " + xhr.status))
128 + fail(serverMessage || ("Request failed: HTTP " + xhr.status), false, xhr.status)
128 129 }
129 130 }
130 131
added contents/ui/RunBody.qml
+141 −0
@@ -0,0 +1,141 @@
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 +// What a run shows once it's open: the approval notice, its actions, its
10 +// jobs (each with steps and logs) and its artifacts. Shared by the
11 +// popup's accordion and the standalone run window, so both read the same
12 +// `store` state and stay in step.
13 +ColumnLayout {
14 + id: runBody
15 +
16 + required property var store
17 + required property string fullName
18 + required property int runNumber
19 + // The freshest copy of the run: the detail's once it's loaded.
20 + required property var run
21 + required property var detail
22 + // The run window has its own header with these; don't repeat them.
23 + property bool inWindow: false
24 +
25 + readonly property bool active: Status.isActive(run.status)
26 +
27 + spacing: Kirigami.Units.smallSpacing
28 +
29 + PlasmaComponents3.BusyIndicator {
30 + visible: !runBody.detail
31 + Layout.alignment: Qt.AlignHCenter
32 + Layout.preferredHeight: Kirigami.Units.iconSizes.medium
33 + }
34 +
35 + // A fork's run waits for someone with write access to approve
36 + // it, and that happens on the pull request — say so, link there.
37 + ColumnLayout {
38 + visible: !!runBody.run.awaiting_approval
39 + Layout.fillWidth: true
40 + spacing: 0
41 +
42 + PlasmaComponents3.Label {
43 + Layout.fillWidth: true
44 + color: Kirigami.Theme.neutralTextColor
45 + text: i18n("Waiting for approval")
46 + }
47 +
48 + PlasmaComponents3.Label {
49 + Layout.fillWidth: true
50 + wrapMode: Text.WordWrap
51 + font: Kirigami.Theme.smallFont
52 + opacity: 0.7
53 + text: Status.approvalNotice(runBody.run)
54 + }
55 +
56 + PlasmaComponents3.ToolButton {
57 + visible: !!runBody.run.pull_request_number
58 + icon.name: "internet-web-browser"
59 + text: i18n("Open pull request #%1", runBody.run.pull_request_number)
60 + display: QQC2.AbstractButton.TextBesideIcon
61 + onClicked: Qt.openUrlExternally(runBody.store.api.pullUrl(runBody.fullName, runBody.run.pull_request_number))
62 + }
63 + }
64 +
65 + RowLayout {
66 + visible: !!runBody.detail
67 + Layout.fillWidth: true
68 + spacing: Kirigami.Units.smallSpacing
69 +
70 + PlasmaComponents3.ToolButton {
71 + visible: !!runBody.detail && runBody.detail.can_manage
72 + enabled: !runBody.store.busy
73 + icon.name: "view-refresh"
74 + text: i18n("Re-run all")
75 + display: QQC2.AbstractButton.TextBesideIcon
76 + onClicked: runBody.store.rerunRun(runBody.fullName, runBody.runNumber)
77 + }
78 +
79 + PlasmaComponents3.ToolButton {
80 + visible: !!runBody.detail && runBody.detail.can_manage && runBody.active
81 + enabled: !runBody.store.busy
82 + icon.name: "dialog-cancel"
83 + text: i18n("Cancel")
84 + display: QQC2.AbstractButton.TextBesideIcon
85 + onClicked: runBody.store.cancelRun(runBody.fullName, runBody.runNumber)
86 + }
87 +
88 + Item { Layout.fillWidth: true }
89 +
90 + PlasmaComponents3.ToolButton {
91 + visible: !runBody.inWindow
92 + icon.name: "window-new"
93 + text: i18n("Open in Window")
94 + display: QQC2.AbstractButton.IconOnly
95 + onClicked: runBody.store.openRunWindow(runBody.fullName, runBody.runNumber)
96 + PlasmaComponents3.ToolTip { text: i18n("Open in Window") }
97 + }
98 +
99 + PlasmaComponents3.ToolButton {
100 + visible: !runBody.inWindow
101 + icon.name: "internet-web-browser"
102 + text: i18n("Open")
103 + display: QQC2.AbstractButton.TextBesideIcon
104 + onClicked: Qt.openUrlExternally(runBody.store.api.webUrl(runBody.fullName, runBody.runNumber))
105 + }
106 + }
107 +
108 + Repeater {
109 + model: runBody.detail ? runBody.detail.jobs : []
110 +
111 + delegate: JobItem {
112 + required property var modelData
113 + Layout.fillWidth: true
114 + store: runBody.store
115 + fullName: runBody.fullName
116 + runNumber: runBody.runNumber
117 + job: modelData
118 + canManage: !!runBody.detail && runBody.detail.can_manage
119 + }
120 + }
121 +
122 + PlasmaComponents3.Label {
123 + visible: !!runBody.detail && runBody.detail.jobs.length === 0
124 + text: i18n("No jobs")
125 + opacity: 0.7
126 + }
127 +
128 + Repeater {
129 + model: runBody.detail ? runBody.detail.artifacts : []
130 +
131 + delegate: PlasmaComponents3.Label {
132 + required property var modelData
133 + Layout.fillWidth: true
134 + elide: Text.ElideRight
135 + font: Kirigami.Theme.smallFont
136 + opacity: 0.7
137 + text: "📦 " + (modelData.name || "artifact")
138 + + (modelData.size ? " · " + Math.round(modelData.size / 1024) + " KiB" : "")
139 + }
140 + }
141 +}
modified contents/ui/RunItem.qml
+64 −106
@@ -10,7 +10,9 @@ import "Status.js" as Status
10 10 // toggles its steps, and each step toggles its log slice — the same
11 11 // run → job → step drill-down the Android client uses. Expansion state
12 12 // and fetched details live on `store` (the root item) so they survive
13 −// the list refreshing underneath.
13 +// the list refreshing underneath. The header's bell asks for a
14 +// notification when the run finishes; the window button (or the context
15 +// menu) breaks the run out into its own window.
14 16 ColumnLayout {
15 17 id: runItem
16 18
@@ -24,6 +26,7 @@ ColumnLayout {
24 26 readonly property var detail: store.runDetails[runKey] || null
25 27 readonly property string status: Status.runStatus(run)
26 28 readonly property bool active: Status.isActive(run.status)
29 + readonly property bool watching: store.watchingRun(runKey)
27 30 // The detail's copy is fresher than the list row's once it's loaded.
28 31 readonly property var current: detail ? detail.run : run
29 32
@@ -31,9 +34,38 @@ ColumnLayout {
31 34
32 35 // ── header ──────────────────────────────────────────────────────────
33 36 PlasmaComponents3.ItemDelegate {
37 + id: header
34 38 Layout.fillWidth: true
35 39 onClicked: runItem.store.toggleRun(runItem.fullName, runItem.run.number)
36 40
41 + TapHandler {
42 + acceptedButtons: Qt.RightButton
43 + onTapped: contextMenu.popup()
44 + }
45 +
46 + PlasmaComponents3.Menu {
47 + id: contextMenu
48 +
49 + PlasmaComponents3.MenuItem {
50 + icon.name: "window-new"
51 + text: i18n("Open in Window")
52 + onClicked: runItem.store.openRunWindow(runItem.fullName, runItem.run.number)
53 + }
54 +
55 + PlasmaComponents3.MenuItem {
56 + enabled: runItem.active || runItem.watching
57 + icon.name: runItem.watching ? "notifications-disabled" : "notifications"
58 + text: runItem.watching ? i18n("Don't Notify When Finished") : i18n("Notify When Finished")
59 + onClicked: runItem.store.toggleWatchRun(runItem.fullName, runItem.run.number)
60 + }
61 +
62 + PlasmaComponents3.MenuItem {
63 + icon.name: "internet-web-browser"
64 + text: i18n("Open in Browser")
65 + onClicked: Qt.openUrlExternally(runItem.store.api.webUrl(runItem.fullName, runItem.run.number))
66 + }
67 + }
68 +
37 69 contentItem: RowLayout {
38 70 spacing: Kirigami.Units.smallSpacing
39 71
@@ -90,119 +122,45 @@ ColumnLayout {
90 122 .filter(function(s) { return s !== "" }).join(" · "))
91 123 }
92 124 }
93 − }
94 − }
95 −
96 − // ── body ────────────────────────────────────────────────────────────
97 − ColumnLayout {
98 − visible: runItem.expanded
99 − Layout.fillWidth: true
100 − Layout.leftMargin: Kirigami.Units.gridUnit
101 − Layout.bottomMargin: Kirigami.Units.smallSpacing
102 − spacing: Kirigami.Units.smallSpacing
103 −
104 − PlasmaComponents3.BusyIndicator {
105 − visible: runItem.expanded && !runItem.detail
106 − Layout.alignment: Qt.AlignHCenter
107 − Layout.preferredHeight: Kirigami.Units.iconSizes.medium
108 − }
109 −
110 − // A fork's run waits for someone with write access to approve
111 − // it, and that happens on the pull request — say so, link there.
112 − ColumnLayout {
113 − visible: !!runItem.current.awaiting_approval
114 − Layout.fillWidth: true
115 − spacing: 0
116 −
117 − PlasmaComponents3.Label {
118 − Layout.fillWidth: true
119 − color: Kirigami.Theme.neutralTextColor
120 − text: i18n("Waiting for approval")
121 − }
122 −
123 − PlasmaComponents3.Label {
124 − Layout.fillWidth: true
125 − wrapMode: Text.WordWrap
126 − font: Kirigami.Theme.smallFont
127 − opacity: 0.7
128 − text: Status.approvalNotice(runItem.current)
129 − }
130 −
131 − PlasmaComponents3.ToolButton {
132 − visible: !!runItem.current.pull_request_number
133 − icon.name: "internet-web-browser"
134 − text: i18n("Open pull request #%1", runItem.current.pull_request_number)
135 − display: QQC2.AbstractButton.TextBesideIcon
136 − onClicked: Qt.openUrlExternally(runItem.store.api.pullUrl(runItem.fullName, runItem.current.pull_request_number))
137 − }
138 − }
139 −
140 − RowLayout {
141 − visible: !!runItem.detail
142 − Layout.fillWidth: true
143 − spacing: Kirigami.Units.smallSpacing
144 125
126 + // Shown while hovered, and always once ringing so you can
127 + // see which runs you're waiting on.
145 128 PlasmaComponents3.ToolButton {
146 − visible: !!runItem.detail && runItem.detail.can_manage
147 − enabled: !runItem.store.busy
148 − icon.name: "view-refresh"
149 − text: i18n("Re-run all")
150 − display: QQC2.AbstractButton.TextBesideIcon
151 − onClicked: runItem.store.rerunRun(runItem.fullName, runItem.run.number)
152 − }
153 −
154 − PlasmaComponents3.ToolButton {
155 − visible: !!runItem.detail && runItem.detail.can_manage && runItem.active
156 − enabled: !runItem.store.busy
157 − icon.name: "dialog-cancel"
158 − text: i18n("Cancel")
159 − display: QQC2.AbstractButton.TextBesideIcon
160 − onClicked: runItem.store.cancelRun(runItem.fullName, runItem.run.number)
129 + visible: runItem.watching || (header.hovered && runItem.active)
130 + checkable: true
131 + checked: runItem.watching
132 + icon.name: runItem.watching ? "notifications" : "notification-inactive"
133 + text: i18n("Notify When Finished")
134 + display: QQC2.AbstractButton.IconOnly
135 + onToggled: runItem.store.toggleWatchRun(runItem.fullName, runItem.run.number)
136 + PlasmaComponents3.ToolTip {
137 + text: runItem.watching ? i18n("You'll be notified when this run finishes")
138 + : i18n("Notify When Finished")
139 + }
161 140 }
162 141
163 − Item { Layout.fillWidth: true }
164 −
165 142 PlasmaComponents3.ToolButton {
166 − icon.name: "internet-web-browser"
167 − text: i18n("Open")
168 − display: QQC2.AbstractButton.TextBesideIcon
169 − onClicked: Qt.openUrlExternally(runItem.store.api.webUrl(runItem.fullName, runItem.run.number))
170 − }
171 − }
172 −
173 − Repeater {
174 − model: runItem.detail ? runItem.detail.jobs : []
175 −
176 − delegate: JobItem {
177 − required property var modelData
178 − Layout.fillWidth: true
179 − store: runItem.store
180 − fullName: runItem.fullName
181 − runNumber: runItem.run.number
182 − job: modelData
183 − canManage: !!runItem.detail && runItem.detail.can_manage
143 + visible: header.hovered
144 + icon.name: "window-new"
145 + text: i18n("Open in Window")
146 + display: QQC2.AbstractButton.IconOnly
147 + onClicked: runItem.store.openRunWindow(runItem.fullName, runItem.run.number)
148 + PlasmaComponents3.ToolTip { text: i18n("Open in Window") }
184 149 }
185 150 }
151 + }
186 152
187 − PlasmaComponents3.Label {
188 − visible: !!runItem.detail && runItem.detail.jobs.length === 0
189 − text: i18n("No jobs")
190 − opacity: 0.7
191 − }
192 −
193 − Repeater {
194 − model: runItem.detail ? runItem.detail.artifacts : []
195 −
196 − delegate: PlasmaComponents3.Label {
197 − required property var modelData
198 − Layout.fillWidth: true
199 − elide: Text.ElideRight
200 − font: Kirigami.Theme.smallFont
201 − opacity: 0.7
202 − text: "📦 " + (modelData.name || "artifact")
203 − + (modelData.size ? " · " + Math.round(modelData.size / 1024) + " KiB" : "")
204 − }
205 − }
153 + // ── body ────────────────────────────────────────────────────────────
154 + RunBody {
155 + visible: runItem.expanded
156 + Layout.fillWidth: true
157 + Layout.leftMargin: Kirigami.Units.gridUnit
158 + Layout.bottomMargin: Kirigami.Units.smallSpacing
159 + store: runItem.store
160 + fullName: runItem.fullName
161 + runNumber: runItem.run.number
162 + run: runItem.current
163 + detail: runItem.detail
206 164 }
207 165
208 166 Kirigami.Separator {
added contents/ui/RunWindow.qml
+185 −0
@@ -0,0 +1,185 @@
1 +import QtQuick
2 +import QtQuick.Window
3 +import QtQuick.Layouts
4 +import QtQuick.Controls as QQC2
5 +import org.kde.plasma.components as PlasmaComponents3
6 +import org.kde.kirigami as Kirigami
7 +
8 +import "Status.js" as Status
9 +
10 +// One run broken out of the popup into its own top-level window, so it
11 +// can sit beside the editor while it builds. It reads the same `store`
12 +// state as the popup (details, open jobs, logs), and the store keeps it
13 +// refreshing at the fast pace until the run finishes. Created by
14 +// store.openRunWindow(); closing it destroys it.
15 +Window {
16 + id: runWindow
17 +
18 + required property var store
19 + required property string fullName
20 + required property int runNumber
21 +
22 + readonly property string runKey: Status.runKey(fullName, runNumber)
23 + readonly property var detail: store.runDetails[runKey] || null
24 + // Until the detail lands, the popup's list row is the best we have.
25 + readonly property var run: detail ? detail.run : (store.findRun(fullName, runNumber) || { number: runNumber })
26 + readonly property string status: Status.runStatus(run)
27 + readonly property bool inFlight: Status.isActive(status)
28 + readonly property bool watching: store.watchingRun(runKey)
29 +
30 + // Not a child of the panel: a normal window with its own taskbar entry.
31 + transientParent: null
32 + flags: Qt.Window
33 + width: Kirigami.Units.gridUnit * 36
34 + height: Kirigami.Units.gridUnit * 32
35 + minimumWidth: Kirigami.Units.gridUnit * 20
36 + minimumHeight: Kirigami.Units.gridUnit * 12
37 + title: run.workflow_name || run.workflow_path
38 + ? i18n("%1 #%2 — %3", fullName, runNumber, Status.runTitle(run))
39 + : i18n("%1 #%2", fullName, runNumber)
40 +
41 + Kirigami.Theme.colorSet: Kirigami.Theme.Window
42 + Kirigami.Theme.inherit: false
43 + color: Kirigami.Theme.backgroundColor
44 +
45 + onClosing: {
46 + store.forgetRunWindow(runWindow.runKey, runWindow)
47 + runWindow.destroy()
48 + }
49 +
50 + function present() {
51 + if (visibility === Window.Minimized) showNormal()
52 + else show()
53 + raise()
54 + requestActivate()
55 + }
56 +
57 + ColumnLayout {
58 + anchors.fill: parent
59 + anchors.margins: Kirigami.Units.largeSpacing
60 + spacing: Kirigami.Units.smallSpacing
61 +
62 + // ── header ──────────────────────────────────────────────────────
63 + RowLayout {
64 + Layout.fillWidth: true
65 + spacing: Kirigami.Units.largeSpacing
66 +
67 + StatusCircle {
68 + status: runWindow.status
69 + Layout.alignment: Qt.AlignTop
70 + Layout.preferredWidth: Kirigami.Units.iconSizes.medium
71 + Layout.preferredHeight: Kirigami.Units.iconSizes.medium
72 + }
73 +
74 + ColumnLayout {
75 + Layout.fillWidth: true
76 + spacing: 0
77 +
78 + Kirigami.Heading {
79 + Layout.fillWidth: true
80 + level: 2
81 + elide: Text.ElideRight
82 + text: Status.runTitle(runWindow.run) + " #" + runWindow.runNumber
83 + }
84 +
85 + PlasmaComponents3.Label {
86 + Layout.fillWidth: true
87 + elide: Text.ElideRight
88 + opacity: 0.7
89 + text: runWindow.fullName
90 + }
91 +
92 + PlasmaComponents3.Label {
93 + Layout.fillWidth: true
94 + wrapMode: Text.WordWrap
95 + font: Kirigami.Theme.smallFont
96 + opacity: 0.7
97 + text: Status.runMeta(runWindow.run)
98 + }
99 +
100 + PlasmaComponents3.Label {
101 + Layout.fillWidth: true
102 + font: Kirigami.Theme.smallFont
103 + textFormat: Text.StyledText
104 + // `store.now` ticks so relative times stay fresh.
105 + text: (runWindow.store.now, ["<font color=\"" + Status.color(runWindow.status, Kirigami.Theme) + "\">"
106 + + Status.label(runWindow.status) + "</font>",
107 + Status.relativeTime(runWindow.run.inserted_at),
108 + Status.duration(runWindow.run.started_at, runWindow.run.completed_at)]
109 + .filter(function(s) { return s !== "" }).join(" · "))
110 + }
111 + }
112 +
113 + PlasmaComponents3.BusyIndicator {
114 + visible: runWindow.store.busy
115 + Layout.alignment: Qt.AlignTop
116 + Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
117 + Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
118 + }
119 +
120 + PlasmaComponents3.ToolButton {
121 + id: bellButton
122 + Layout.alignment: Qt.AlignTop
123 + visible: runWindow.inFlight || runWindow.watching
124 + checkable: true
125 + checked: runWindow.watching
126 + icon.name: runWindow.watching ? "notifications" : "notification-inactive"
127 + text: i18n("Notify When Finished")
128 + display: QQC2.AbstractButton.IconOnly
129 + onToggled: runWindow.store.toggleWatchRun(runWindow.fullName, runWindow.runNumber)
130 + PlasmaComponents3.ToolTip {
131 + text: runWindow.watching ? i18n("You'll be notified when this run finishes")
132 + : i18n("Notify When Finished")
133 + }
134 + }
135 +
136 + PlasmaComponents3.ToolButton {
137 + Layout.alignment: Qt.AlignTop
138 + icon.name: "view-refresh"
139 + text: i18n("Refresh")
140 + display: QQC2.AbstractButton.IconOnly
141 + onClicked: runWindow.store.loadRunDetail(runWindow.fullName, runWindow.runNumber)
142 + PlasmaComponents3.ToolTip { text: i18n("Refresh") }
143 + }
144 +
145 + PlasmaComponents3.ToolButton {
146 + Layout.alignment: Qt.AlignTop
147 + icon.name: "internet-web-browser"
148 + text: i18n("Open in Browser")
149 + display: QQC2.AbstractButton.IconOnly
150 + onClicked: Qt.openUrlExternally(runWindow.store.api.webUrl(runWindow.fullName, runWindow.runNumber))
151 + PlasmaComponents3.ToolTip { text: i18n("Open in Browser") }
152 + }
153 + }
154 +
155 + PlasmaComponents3.Label {
156 + visible: runWindow.store.lastError !== ""
157 + Layout.fillWidth: true
158 + wrapMode: Text.WordWrap
159 + color: Kirigami.Theme.negativeTextColor
160 + font: Kirigami.Theme.smallFont
161 + text: runWindow.store.lastError
162 + }
163 +
164 + Kirigami.Separator { Layout.fillWidth: true }
165 +
166 + // ── jobs ────────────────────────────────────────────────────────
167 + // A desktop window, so the desktop style's scroll view.
168 + QQC2.ScrollView {
169 + id: scroll
170 + Layout.fillWidth: true
171 + Layout.fillHeight: true
172 + contentWidth: availableWidth
173 +
174 + RunBody {
175 + width: scroll.availableWidth
176 + store: runWindow.store
177 + fullName: runWindow.fullName
178 + runNumber: runWindow.runNumber
179 + run: runWindow.run
180 + detail: runWindow.detail
181 + inWindow: true
182 + }
183 + }
184 + }
185 +}
modified contents/ui/Status.js
+49 −0
@@ -229,3 +229,52 @@ function jobLabel(job) {
229 229 }
230 230 return label
231 231 }
232 +
233 +// ── following runs ────────────────────────────────────────────────────
234 +
235 +// "owner/name#12" — the key runs go by everywhere: accordion state,
236 +// details, run windows, and the watched list kept in the config.
237 +function runKey(fullName, number) {
238 + return fullName + "#" + number
239 +}
240 +
241 +// {fullName, number} for a well-formed key, else null.
242 +function parseRunKey(key) {
243 + var m = /^([^\/#\s]+\/[^\/#\s]+)#(\d+)$/.exec(String(key || ""))
244 + if (!m) return null
245 + var number = parseInt(m[2], 10)
246 + return number > 0 ? { fullName: m[1], number: number } : null
247 +}
248 +
249 +// How a finished run ended, as the word a notification uses; "" while
250 +// it is still going.
251 +function outcome(status) {
252 + switch (status) {
253 + case "success": return "succeeded"
254 + case "failure": return "failed"
255 + case "cancelled": return "cancelled"
256 + case "timed_out": return "timed_out"
257 + case "skipped": return "skipped"
258 + case "superseded": return "superseded"
259 + default: return isFinished(status) ? "finished" : ""
260 + }
261 +}
262 +
263 +// Runs in `next` that `handle` triggered and that failed (or timed out)
264 +// since `prev`, where `prev` saw them still going. A run first seen
265 +// already failed isn't news: it may have failed hours ago.
266 +function newlyFailed(prev, next, handle) {
267 + if (!handle) return []
268 + var before = {}
269 + for (var i = 0; i < (prev || []).length; i++) before[prev[i].number] = prev[i]
270 + var out = []
271 + for (var j = 0; j < (next || []).length; j++) {
272 + var run = next[j]
273 + var was = before[run.number]
274 + if (!was || !isActive(runStatus(was))) continue
275 + if (!run.triggered_by || run.triggered_by.handle !== handle) continue
276 + var o = outcome(runStatus(run))
277 + if (o === "failed" || o === "timed_out") out.push(run)
278 + }
279 + return out
280 +}
modified contents/ui/configBehavior.qml
+15 −0
@@ -11,6 +11,7 @@ KCM.SimpleKCM {
11 11 property alias cfg_activeInterval: activeIntervalSpinBox.value
12 12 property alias cfg_runsPerRepo: runsPerRepoSpinBox.value
13 13 property alias cfg_hideIdle: hideIdleCheckBox.checked
14 + property alias cfg_notifyMyFailures: notifyMyFailuresCheckBox.checked
14 15
15 16 Kirigami.FormLayout {
16 17 QQC2.SpinBox {
@@ -50,5 +51,19 @@ KCM.SimpleKCM {
50 51 Kirigami.FormData.label: i18n("Circles:")
51 52 text: i18n("Hide repositories that have never run CI")
52 53 }
54 +
55 + QQC2.CheckBox {
56 + id: notifyMyFailuresCheckBox
57 + Kirigami.FormData.label: i18n("Notifications:")
58 + text: i18n("Notify when any run I triggered fails")
59 + }
60 +
61 + QQC2.Label {
62 + Layout.fillWidth: true
63 + wrapMode: Text.WordWrap
64 + font: Kirigami.Theme.smallFont
65 + color: Kirigami.Theme.disabledTextColor
66 + text: i18n("\"I\" is the user the access token belongs to. Only runs in watched repositories that the widget saw in flight count. To hear about one particular run whatever its outcome, use the bell on that run.")
67 + }
53 68 }
54 69 }
modified contents/ui/main.qml
+237 −19
@@ -5,6 +5,7 @@ import org.kde.plasma.plasmoid
5 5 import org.kde.plasma.core as PlasmaCore
6 6 import org.kde.plasma.components as PlasmaComponents3
7 7 import org.kde.kirigami as Kirigami
8 +import org.kde.notification
8 9
9 10 import "Status.js" as Status
10 11
@@ -22,6 +23,10 @@ PlasmoidItem {
22 23
23 24 Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground | PlasmaCore.Types.ConfigurableBackground
24 25
26 + // The header's pin, as in the system tray: a pinned popup stays open
27 + // when focus moves elsewhere.
28 + hideOnWindowDeactivate: !Plasmoid.configuration.pin
29 +
25 30 // Each circle carries its own tooltip; the applet-wide one would
26 31 // just sit on top of them.
27 32 toolTipMainText: ""
@@ -36,6 +41,10 @@ PlasmoidItem {
36 41 property int runsPerRepo: Math.max(1, Plasmoid.configuration.runsPerRepo)
37 42 property string watchedJson: Plasmoid.configuration.watched
38 43 property bool hideIdle: Plasmoid.configuration.hideIdle
44 + property bool notifyMyFailures: Plasmoid.configuration.notifyMyFailures
45 + // "owner/name#n" of each run to announce once it finishes. A plain
46 + // array copy, so the usual Array methods work on it.
47 + readonly property var watchedRuns: Array.prototype.slice.call(Plasmoid.configuration.watchedRuns || [])
39 48
40 49 property var watched: []
41 50 property bool hasValidConfig: api.configured
@@ -58,6 +67,24 @@ PlasmoidItem {
58 67 property double lastFullRefresh: 0
59 68 property double now: Date.now()
60 69
70 + // Following runs outside the list: windows and notifications.
71 + property var runWindows: ({}) // "owner/name#n" -> RunWindow
72 + property var notified: ({}) // "owner/name#n@completed_at" -> true, once per ending
73 + property string myHandle: "" // the token's user, for "runs I triggered"
74 +
75 + // A watched or windowed run still in flight keeps the fast poll
76 + // going, even when nothing in the circles would — and even if it
77 + // has dropped out of the list. One not loaded yet counts too, so its
78 + // first answer comes quickly.
79 + readonly property bool followingActive: {
80 + var keys = watchedRuns.concat(Object.keys(runWindows))
81 + for (var i = 0; i < keys.length; i++) {
82 + var d = runDetails[keys[i]]
83 + if (!d || Status.pollsFast(Status.runStatus(d.run))) return true
84 + }
85 + return false
86 + }
87 +
61 88 // View state — kept here, not in delegates, so it survives refreshes.
62 89 property string focusKey: "" // "" = every run; else a circle key
63 90 property var expandedRuns: ({})
@@ -99,9 +126,12 @@ PlasmoidItem {
99 126 onAccessTokenChanged: resetAndRefresh()
100 127 onFocusKeyChanged: rebuildRuns()
101 128 onHideIdleChanged: rebuild()
129 + onNotifyMyFailuresChanged: loadMe()
102 130
103 131 Component.onCompleted: {
104 132 parseWatched()
133 + pruneWatchedRuns()
134 + loadMe()
105 135 refresh(true)
106 136 }
107 137
@@ -110,10 +140,20 @@ PlasmoidItem {
110 140 runsByRepo = {}
111 141 runDetails = {}
112 142 jobLogs = {}
143 + myHandle = ""
113 144 rebuild()
145 + Qt.callLater(loadMe)
114 146 Qt.callLater(refresh, true)
115 147 }
116 148
149 + // Who "runs I triggered" means. Only asked for when that's wanted.
150 + function loadMe() {
151 + if (!notifyMyFailures || !hasValidConfig) return
152 + api.me(function(ok, data) {
153 + if (ok && data.user) myHandle = data.user.handle || ""
154 + })
155 + }
156 +
117 157 // ── resolution: watched entries → circles → repos ───────────────────
118 158
119 159 // [{key, label, repos: [full_name]}], in the order the user listed them.
@@ -159,6 +199,14 @@ PlasmoidItem {
159 199 return list
160 200 }
161 201
202 + function findRun(fullName, number) {
203 + var runs = runsByRepo[fullName] || []
204 + for (var i = 0; i < runs.length; i++) {
205 + if (runs[i].number === number) return runs[i]
206 + }
207 + return null
208 + }
209 +
162 210 function latestRun(fullName) {
163 211 var runs = runsByRepo[fullName]
164 212 return runs && runs.length > 0 ? runs[0] : null
@@ -338,8 +386,17 @@ PlasmoidItem {
338 386 pending++
339 387 ;(function(fullName) {
340 388 api.listRuns(fullName, root.runsPerRepo, function(ok, data) {
341 − if (ok) updated[fullName] = data.runs || []
342 − else lastError = fullName + ": " + data
389 + if (ok) {
390 + var runs = data.runs || []
391 + if (root.myHandle !== "") {
392 + Status.newlyFailed(updated[fullName], runs, root.myHandle).forEach(function(run) {
393 + root.notifyRun(fullName, run)
394 + })
395 + }
396 + updated[fullName] = runs
397 + } else {
398 + lastError = fullName + ": " + data
399 + }
343 400 done()
344 401 })
345 402 })(name)
@@ -349,20 +406,25 @@ PlasmoidItem {
349 406 })
350 407 }
351 408
352 − // Open accordions follow the list: refetch any expanded run that is
353 − // in flight or whose summary changed since its detail was loaded.
409 + // Open accordions, run windows and watched runs follow the list:
410 + // refetch any that is in flight or whose summary changed since its
411 + // detail was loaded. A watched run is fetched by number, so it keeps
412 + // being followed after newer runs push it out of the list.
354 413 function refreshExpanded() {
355 − for (var key in expandedRuns) {
356 − if (!expandedRuns[key]) continue
357 − var parts = key.split("#")
358 − var fullName = parts[0]
359 − var number = parseInt(parts[1])
414 + var keys = {}
415 + for (var k in expandedRuns) if (expandedRuns[k]) keys[k] = true
416 + for (var w in runWindows) keys[w] = true
417 + for (var j = 0; j < watchedRuns.length; j++) keys[watchedRuns[j]] = true
418 +
419 + for (var key in keys) {
420 + var parsed = Status.parseRunKey(key)
421 + if (!parsed) continue
422 + var fullName = parsed.fullName
423 + var number = parsed.number
360 424 var detail = runDetails[key]
361 − var summary = null
362 − var runs = runsByRepo[fullName] || []
363 − for (var i = 0; i < runs.length; i++) {
364 − if (runs[i].number === number) { summary = runs[i]; break }
365 − }
425 + var summary = findRun(fullName, number)
426 + // Already known finished: nothing to fetch, just announce it.
427 + if (detail) checkWatched(fullName, detail.run)
366 428 if (!detail || Status.isActive(detail.run.status)
367 429 || (summary && (summary.status !== detail.run.status || summary.conclusion !== detail.run.conclusion
368 430 || !!summary.awaiting_approval !== !!detail.run.awaiting_approval))) {
@@ -375,7 +437,7 @@ PlasmoidItem {
375 437 // Offline (e.g. just woke, Wi-Fi not back yet): retry soon rather
376 438 // than sitting on an error for a whole idle interval.
377 439 interval: (root.offline ? Math.min(10, root.updateInterval)
378 − : root.anyActive ? root.activeInterval : root.updateInterval) * 1000
440 + : root.anyActive || root.followingActive ? root.activeInterval : root.updateInterval) * 1000
379 441 running: root.hasValidConfig && root.hasWatched
380 442 repeat: true
381 443 onTriggered: root.refresh(Date.now() - root.lastFullRefresh >= root.updateInterval * 1000)
@@ -414,10 +476,11 @@ PlasmoidItem {
414 476 refresh(true)
415 477 }
416 478
417 − // Ticks relative times and running durations while the popup is open.
479 + // Ticks relative times and running durations while the popup or a
480 + // run window is open.
418 481 Timer {
419 482 interval: 1000
420 − running: root.expanded
483 + running: root.expanded || Object.keys(root.runWindows).length > 0
421 484 repeat: true
422 485 onTriggered: root.now = Date.now()
423 486 }
@@ -435,9 +498,15 @@ PlasmoidItem {
435 498
436 499 function loadRunDetail(fullName, number) {
437 500 var key = fullName + "#" + number
438 − api.getRun(fullName, number, function(ok, data) {
439 − if (!ok) { lastError = data; return }
501 + api.getRun(fullName, number, function(ok, data, network, httpStatus) {
502 + if (!ok) {
503 + // Gone (deleted, or a repo the token lost): stop waiting on it.
504 + if (httpStatus === 404) unwatchRun(key)
505 + lastError = data
506 + return
507 + }
440 508 runDetails = setKey(runDetails, key, data)
509 + checkWatched(fullName, data.run)
441 510 // Keep open jobs' logs following the run. A matrix
442 511 // placeholder has no logs: its row is replaced by its legs
443 512 // (new ids) once it expands, and this re-read shows them.
@@ -531,6 +600,145 @@ PlasmoidItem {
531 600 runDetails = setKey(runDetails, key, {
532 601 run: data.run, jobs: data.jobs || [], artifacts: prev.artifacts, can_manage: prev.can_manage
533 602 })
603 + if (data.run) checkWatched(fullName, data.run)
604 + }
605 +
606 + // ── run windows ─────────────────────────────────────────────────────
607 +
608 + Component {
609 + id: runWindowComponent
610 + RunWindow {}
611 + }
612 +
613 + // One window per run: asking again brings the existing one forward.
614 + function openRunWindow(fullName, number) {
615 + var key = Status.runKey(fullName, number)
616 + var win = runWindows[key]
617 + if (!win) {
618 + win = runWindowComponent.createObject(root, { store: root, fullName: fullName, runNumber: number })
619 + if (!win) {
620 + lastError = i18n("Could not open a window for %1", key)
621 + return
622 + }
623 + runWindows = setKey(runWindows, key, win)
624 + loadRunDetail(fullName, number)
625 + }
626 + win.present()
627 + }
628 +
629 + function forgetRunWindow(key, win) {
630 + if (runWindows[key] === win) runWindows = setKey(runWindows, key, undefined)
631 + }
632 +
633 + // ── notify when finished ────────────────────────────────────────────
634 +
635 + function watchingRun(key) {
636 + return watchedRuns.indexOf(key) !== -1
637 + }
638 +
639 + function setWatchedRuns(list) {
640 + Plasmoid.configuration.watchedRuns = list
641 + }
642 +
643 + function unwatchRun(key) {
644 + if (watchingRun(key)) setWatchedRuns(watchedRuns.filter(function(k) { return k !== key }))
645 + }
646 +
647 + function toggleWatchRun(fullName, number) {
648 + var key = Status.runKey(fullName, number)
649 + if (watchingRun(key)) {
650 + unwatchRun(key)
651 + return
652 + }
653 + setWatchedRuns(watchedRuns.concat([key]))
654 + // Find out where it stands now; if it has already finished, the
655 + // answer rings straight away.
656 + loadRunDetail(fullName, number)
657 + }
658 +
659 + // Drop entries that aren't run keys (hand-edited, or an older format).
660 + function pruneWatchedRuns() {
661 + var kept = watchedRuns.filter(function(k, i, all) {
662 + return Status.parseRunKey(k) !== null && all.indexOf(k) === i
663 + })
664 + if (kept.length !== watchedRuns.length) setWatchedRuns(kept)
665 + }
666 +
667 + // A watched run that has ended: announce it once, then stop watching.
668 + function checkWatched(fullName, run) {
669 + if (!run) return
670 + var key = Status.runKey(fullName, run.number)
671 + if (!watchingRun(key) || !Status.isFinished(Status.runStatus(run))) return
672 + unwatchRun(key)
673 + notifyRun(fullName, run)
674 + }
675 +
676 + function notificationTitle(fullName, run) {
677 + var n = run.number
678 + switch (Status.outcome(Status.runStatus(run))) {
679 + case "succeeded": return i18n("%1 #%2 succeeded", fullName, n)
680 + case "failed": return i18n("%1 #%2 failed", fullName, n)
681 + case "cancelled": return i18n("%1 #%2 cancelled", fullName, n)
682 + case "timed_out": return i18n("%1 #%2 timed out", fullName, n)
683 + case "skipped": return i18n("%1 #%2 skipped", fullName, n)
684 + case "superseded": return i18n("%1 #%2 superseded", fullName, n)
685 + default: return i18n("%1 #%2 finished", fullName, n)
686 + }
687 + }
688 +
689 + function notifyRun(fullName, run) {
690 + var key = Status.runKey(fullName, run.number)
691 + // Once per ending: a re-run job can end the same run again later.
692 + var once = key + "@" + (run.completed_at || Status.runStatus(run))
693 + if (notified[once]) return
694 + notified = setKey(notified, once, true)
695 +
696 + var status = Status.runStatus(run)
697 + var outcome = Status.outcome(status)
698 + var note = notificationComponent.createObject(root, {
699 + fullName: fullName,
700 + runNumber: run.number,
701 + title: notificationTitle(fullName, run),
702 + text: [Status.runTitle(run), Status.shortRef(run.head_ref),
703 + Status.duration(run.started_at, run.completed_at)]
704 + .filter(function(s) { return s !== "" }).join(" · "),
705 + iconName: Status.icon(status),
706 + urgency: outcome === "failed" || outcome === "timed_out" ? Notification.HighUrgency
707 + : Notification.NormalUrgency
708 + })
709 + if (note) (note as Notification).sendEvent()
710 + }
711 +
712 + Component {
713 + id: notificationComponent
714 +
715 + // Sent through Plasma's own notification event, so it needs no
716 + // notifyrc of its own. Clicking it opens the run's window.
717 + Notification {
718 + id: note
719 +
720 + property string fullName
721 + property int runNumber
722 +
723 + componentName: "plasma_workspace"
724 + eventId: "notification"
725 + // Owned by QML: destroyed here once Plasma closes it.
726 + autoDelete: false
727 +
728 + defaultAction: NotificationAction {
729 + label: i18n("Show Run")
730 + onActivated: root.openRunWindow(note.fullName, note.runNumber)
731 + }
732 +
733 + actions: [
734 + NotificationAction {
735 + label: i18n("Open in Browser")
736 + onActivated: Qt.openUrlExternally(root.api.webUrl(note.fullName, note.runNumber))
737 + }
738 + ]
739 +
740 + onClosed: note.destroy()
741 + }
534 742 }
535 743
536 744 // ── panel interactions ──────────────────────────────────────────────
@@ -611,6 +819,16 @@ PlasmoidItem {
611 819 onClicked: Plasmoid.internalAction("configure").trigger()
612 820 PlasmaComponents3.ToolTip { text: i18n("Configure") }
613 821 }
822 +
823 + PlasmaComponents3.ToolButton {
824 + checkable: true
825 + checked: Plasmoid.configuration.pin
826 + onToggled: Plasmoid.configuration.pin = checked
827 + icon.name: "window-pin"
828 + text: i18n("Keep Open")
829 + display: QQC2.AbstractButton.IconOnly
830 + PlasmaComponents3.ToolTip { text: i18n("Keep Open") }
831 + }
614 832 }
615 833
616 834 // The same circles as the panel, as a filter bar.
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.2.0",
14 + "Version": "0.3.0",
15 15 "Website": "http://neiam.org"
16 16 },
17 17 "KPackageStructure": "Plasma/Applet",

Parents: 010513e