neiam
/gitgud-plasmoid
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Pin the popup, open runs in their own window, notify when a run finishes
4f26744 · Gabriel Morell · 2026-10-19 16:58
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