From 373a717589177d16ab29ef4fe7578b02728dc1e7 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 23 Aug 2026 20:54:35 -0400 Subject: [PATCH 1/2] Add an in-panel settings page The dashboard's everyday options were only reachable through Omarchy's bar widget settings or the CLI. A gear button in the panel header now flips the card to a settings page carrying the open-links behaviour, repository scope, refresh interval, and the archived, forked, and unlit-icon toggles. Open links returns as a setting rather than a hardcoded launcher, so machines without a Chromium-based browser can go back to a browser tab. Settings are written to the widget's entry in shell.json through updateEntryInline, which replaces the entry whole, so every persist merges the current settings forward first. --- Panel.qml | 327 ++++++++++++++++++++++++++++++++++- README.md | 8 +- Service.qml | 3 + manifest.json | 4 +- tests/panel-source-test.sh | 25 ++- tests/service-source-test.sh | 3 + 6 files changed, 357 insertions(+), 13 deletions(-) diff --git a/Panel.qml b/Panel.qml index a321e7c..2c2e3d2 100644 --- a/Panel.qml +++ b/Panel.qml @@ -27,6 +27,25 @@ Panel { property bool issuesExpanded: false property bool actionsExpanded: false property bool failuresExpanded: false + // settingsOpen is the page on screen; pendingSettingsOpen is the page the + // in-flight flip will land on, since the swap happens edge-on at 90 degrees. + property bool settingsOpen: false + property bool pendingSettingsOpen: false + readonly property var linkBehaviorOptions: [ + { value: "Web app window", label: "Web app window" }, + { value: "Browser tab", label: "Browser tab" } + ] + readonly property var repositoryScopeOptions: [ + { value: "Owned", label: "Owned repositories" }, + { value: "Owned and organizations", label: "Owned and organizations" } + ] + readonly property var refreshIntervalOptions: [ + { value: "300", label: "Every 5 minutes" }, + { value: "600", label: "Every 10 minutes" }, + { value: "900", label: "Every 15 minutes" }, + { value: "1800", label: "Every 30 minutes" }, + { value: "3600", label: "Every hour" } + ] // Carry sub-notch wheel deltas between events. Touchpads emit many small // angleDeltas; mice often emit a fake 1–2px pixelDelta that would otherwise // crawl the dashboard a couple of pixels per click. @@ -147,10 +166,41 @@ Panel { function openUrl(url) { var value = String(url || "") if (value === "") return - Quickshell.execDetached(["omarchy-launch-webapp", value]) + // omarchy-launch-webapp gives GitHub its own window; omarchy-launch-browser + // hands the URL to the default browser for those without a Chromium-based one. + if (github.linkBehavior === "Browser tab") Quickshell.execDetached(["omarchy-launch-browser", value]) + else Quickshell.execDetached(["omarchy-launch-webapp", value]) close() } + // Settings live on this widget's entry in shell.json; the shell hot-reloads + // the file and every instance sees the new value. Applied locally first so + // the control moves on the click, and the entry is merged from the current + // settings because updateEntryInline replaces it whole. + function persistSettings(values) { + var entry = { id: root.moduleName } + for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing] + for (var key in values) { + if (values[key] === undefined) delete entry[key] + else entry[key] = values[key] + } + root.settings = entry + if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function") + root.bar.shell.updateEntryInline(root.moduleName, entry) + } + + function showSettings(open) { + var next = open === true + if (settingsOpen === next || pageFlip.running) return + pendingSettingsOpen = next + // A popup left open would float over the card while it flips. + linkBehaviorDropdown.close() + repositoryScopeDropdown.close() + refreshIntervalDropdown.close() + if (sortPicker) sortPicker.popup.close() + pageFlip.restart() + } + function filteredRepositories() { var needle = String(query || "").trim().toLowerCase() var rows = [] @@ -192,6 +242,13 @@ Panel { // A pending confirmation must never survive the panel closing, or the next // open would run a destructive action on a single click. if (notificationsSection) notificationsSection.disarmAction() + if (!opened) { + // Never reopen mid-flip or on a page the user cannot see themselves onto. + pageFlip.stop() + settingsOpen = false + pendingSettingsOpen = false + cardRotation.angle = 0 + } if (opened) { cursorActive = false cursorIndex = 0 @@ -235,30 +292,69 @@ Panel { open: root.opened focusTarget: keyCatcher contentWidth: panel.fittedContentWidth(Style.space(430)) - contentHeight: panel.fittedContentHeight(content.implicitHeight, Style.space(680)) + contentHeight: panel.fittedContentHeight(root.settingsOpen + ? settingsHeader.implicitHeight + settingsContent.implicitHeight + Style.space(24) + : content.implicitHeight, Style.space(680)) PanelKeyCatcher { id: keyCatcher anchors.fill: parent - blocked: search.activeFocus || sortPicker.popup.visible - onMoveRequested: function(dx, dy) { if (dy !== 0) root.moveCursor(dy) } - onActivateRequested: root.activateCursor() - onCloseRequested: root.close() + // Settings controls own their native focus chain and keys. The settings + // page carries its own Escape handler to return to the dashboard. + blocked: root.settingsOpen || search.activeFocus || sortPicker.popup.visible + onMoveRequested: function(dx, dy) { if (root.settingsOpen) return; if (dy !== 0) root.moveCursor(dy) } + onActivateRequested: if (!root.settingsOpen) root.activateCursor() + onCloseRequested: if (root.settingsOpen) root.showSettings(false); else root.close() // Tab enters the native control chain so search, filters, sorting, and // section controls remain keyboard-accessible. onTabRequested: function(direction) { + if (root.settingsOpen) return if (direction < 0) sortPicker.forceActiveFocus() else search.forceActiveFocus() } onTextKey: function(text) { + if (root.settingsOpen) return if (text === "r" || text === "R") github.refresh() else if (text === "/") Qt.callLater(function() { search.forceActiveFocus() }) else if (text === "m" || text === "M") root.markSelectedRead() } + // Rotating the key catcher flips both pages together as one card. + transform: Rotation { + id: cardRotation + origin.x: keyCatcher.width / 2 + origin.y: keyCatcher.height / 2 + axis.x: 0 + axis.y: 1 + axis.z: 0 + } + + SequentialAnimation { + id: pageFlip + + NumberAnimation { target: cardRotation; property: "angle"; from: 0; to: 90; duration: 130; easing.type: Easing.InQuad } + ScriptAction { + script: { + root.settingsOpen = root.pendingSettingsOpen + cardRotation.angle = -90 + if (root.settingsOpen && settingsFlick) settingsFlick.contentY = 0 + } + } + NumberAnimation { target: cardRotation; property: "angle"; from: -90; to: 0; duration: 170; easing.type: Easing.OutQuad } + ScriptAction { + // Focus lands on the first setting so Tab walks forward through the + // form, and Qt.callLater waits for the visibility pass to finish. + script: Qt.callLater(function() { + if (root.settingsOpen) linkBehaviorDropdown.forceActiveFocus() + else keyCatcher.forceActiveFocus() + }) + } + } + Flickable { id: panelFlick anchors.fill: parent + visible: !root.settingsOpen contentWidth: width contentHeight: content.implicitHeight clip: true @@ -292,6 +388,17 @@ Panel { + (github.failingPullRequestCount > 0 ? " · " + github.failingPullRequestCount + " failing" : "") : github.message) foreground: root.foreground fontFamily: root.fontFamily + // The hero reserves the trailing space and centres the control + // against the labels, so the gear needs no geometry of its own. + trailingControl: Component { + PanelActionButton { + iconText: "󰒓" + tooltipText: "GitHub settings" + foreground: root.foreground + fontFamily: root.fontFamily + onClicked: root.showSettings(true) + } + } iconComponent: Component { Text { text: "" @@ -531,6 +638,214 @@ Panel { } } } + + ColumnLayout { + id: settingsPage + anchors.fill: parent + visible: root.settingsOpen + spacing: Style.space(12) + // AfterItem so an open dropdown consumes the first Escape to close + // itself, and only the next one returns to the dashboard. + Keys.priority: Keys.AfterItem + Keys.onEscapePressed: function(event) { + root.showSettings(false) + event.accepted = true + } + + Column { + id: settingsHeader + Layout.fillWidth: true + spacing: Style.space(12) + + Item { + width: parent.width + implicitHeight: Math.max(settingsBackButton.implicitHeight, settingsLabels.implicitHeight) + + PanelActionButton { + id: settingsBackButton + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + iconText: "\U000F004D" + tooltipText: "Back to the dashboard" + foreground: root.foreground + focusable: true + fontFamily: root.fontFamily + onClicked: root.showSettings(false) + } + + Column { + id: settingsLabels + anchors.left: settingsBackButton.right + anchors.leftMargin: Style.space(10) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(3) + + Text { + text: "GITHUB SETTINGS" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.title + font.bold: true + } + } + } + + PanelSeparator { + foreground: root.foreground + } + } + + Flickable { + id: settingsFlick + Layout.fillWidth: true + Layout.fillHeight: true + contentWidth: width + contentHeight: settingsContent.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: settingsContent + width: settingsFlick.width + spacing: Style.space(20) + + Column { + width: parent.width + spacing: Style.space(6) + + Text { + text: "OPEN LINKS" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + } + + Dropdown { + id: linkBehaviorDropdown + width: parent.width + showLabel: false + options: root.linkBehaviorOptions + foreground: root.foreground + background: Color.popups.background + accent: Color.accent + fontFamily: root.fontFamily + onChanged: function(value) { root.persistSettings({ linkBehavior: value }) } + + // Binding element (not an inline binding) so it survives the + // imperative `value` write Dropdown makes on selection. + Binding on value { value: github.linkBehavior } + } + } + + Column { + width: parent.width + spacing: Style.space(6) + + Text { + text: "REPOSITORY SCOPE" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + } + + Dropdown { + id: repositoryScopeDropdown + width: parent.width + showLabel: false + options: root.repositoryScopeOptions + foreground: root.foreground + background: Color.popups.background + accent: Color.accent + fontFamily: root.fontFamily + onChanged: function(value) { root.persistSettings({ repositoryScope: value }) } + + Binding on value { value: String(root.setting("repositoryScope", "Owned")) } + } + } + + Column { + width: parent.width + spacing: Style.space(6) + + Text { + text: "REFRESH INTERVAL" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + } + + Dropdown { + id: refreshIntervalDropdown + width: parent.width + showLabel: false + options: root.refreshIntervalOptions + foreground: root.foreground + background: Color.popups.background + accent: Color.accent + fontFamily: root.fontFamily + onChanged: function(value) { root.persistSettings({ refreshIntervalSec: parseInt(value, 10) }) } + + // Dropdown values are strings, so the integer round-trips. + Binding on value { value: String(root.setting("refreshIntervalSec", 900)) } + } + } + + PanelSeparator { + width: parent.width + foreground: root.foreground + } + + Toggle { + width: parent.width + label: "Keep the bar icon unlit" + description: "Leave the Octocat dim even when notifications, reviews, or failing actions are waiting." + checked: github.iconAlwaysUnlit + foreground: root.foreground + accent: Color.accent + fontFamily: root.fontFamily + onClicked: root.persistSettings({ iconAlwaysUnlit: !github.iconAlwaysUnlit }) + } + + Toggle { + width: parent.width + label: "Include archived repositories" + description: "Show repositories that have been archived on GitHub." + checked: root.setting("includeArchived", false) === true + foreground: root.foreground + accent: Color.accent + fontFamily: root.fontFamily + onClicked: root.persistSettings({ includeArchived: !(root.setting("includeArchived", false) === true) }) + } + + Toggle { + width: parent.width + label: "Include forked repositories" + description: "Show repositories you forked from someone else." + checked: root.setting("includeForks", false) === true + foreground: root.foreground + accent: Color.accent + fontFamily: root.fontFamily + onClicked: root.persistSettings({ includeForks: !(root.setting("includeForks", false) === true) }) + } + + Text { + width: parent.width + text: "The remaining options — Actions scanning, review request filters, and display limits — stay in Omarchy's bar widget settings." + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + } + } } } diff --git a/README.md b/README.md index 8452434..926f1d8 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ omarchy plugin remove robzolkos.github | --- | --- | | Left click Octocat | Open or close the dashboard | | Right or middle click Octocat | Refresh | -| Click a row | Open it on GitHub in a web app window; notification rows are also marked read | +| Click a row | Open it on GitHub; notification rows are also marked read | +| Gear button in the panel header | Open the settings page | | Check button on a notification | Mark the thread read immediately, then confirm with GitHub | | **Mark all read** in the notifications footer | Arm the bulk mark-as-read | | **Confirm?** on the armed button | Mark every notification on screen read | @@ -112,7 +113,7 @@ omarchy plugin remove robzolkos.github | `Escape` in search | Clear search and return to row navigation | | `Escape` elsewhere | Close the panel | -Rows open through `omarchy-launch-webapp`, so GitHub gets a dedicated app window rather than a tab in an already-crowded browser. That helper targets Chromium-based default browsers and falls back to `chromium.desktop`, so a machine without any Chromium-based browser installed will not open links. +Rows open through `omarchy-launch-webapp` by default, so GitHub gets a dedicated app window rather than a tab in an already-crowded browser. That helper targets Chromium-based default browsers and falls back to `chromium.desktop`; if you have no Chromium-based browser, switch **Open links** to **Browser tab** and rows open through `omarchy-launch-browser` instead. Activity sections show five items initially and expand to a bounded list of 25. **Open in GitHub** takes you to the corresponding complete GitHub view where one is available. @@ -132,11 +133,14 @@ Use the filter chips to show all repositories or only repositories with a non-ze ## Settings +The everyday options — **Open links**, **Repository scope**, **Refresh interval**, and the archived, forked, and unlit-icon toggles — are also editable in the panel itself through the gear button in the header. Changes are written to the widget's entry in `shell.json` and apply immediately. The remaining options stay in Omarchy's bar widget settings. + Configure the widget through Omarchy's bar widget settings. Existing installations retain the narrower repository scope and bounded Actions scan: | Setting | Default | | --- | --- | | Refresh interval | 900 seconds (15 minutes) | +| Open links | **Web app window** | | Include archived repositories | Off | | Include forks | Off | | Repository scope | **Owned** | diff --git a/Service.qml b/Service.qml index ace97b5..9fe1315 100644 --- a/Service.qml +++ b/Service.qml @@ -54,6 +54,9 @@ Item { return !item.draft && root.isBrokenCheck(item.checks); }).length readonly property bool iconAlwaysUnlit: boolSetting("iconAlwaysUnlit", false) + // An unrecognised value falls back to the web app window rather than the + // browser, so a stale entry cannot silently revert the default behaviour. + readonly property string linkBehavior: String(setting("linkBehavior", "Web app window")).toLowerCase() === "browser tab" ? "Browser tab" : "Web app window" readonly property bool alarming: !iconAlwaysUnlit && (unreadCount > 0 || actionCount > 0 || reviewRequests.length > 0 || failingPullRequestCount > 0) // StatusCheckRollup groupings live here so the alarming count, the row label diff --git a/manifest.json b/manifest.json index 4b6219e..5e857d8 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "robzolkos.github", "name": "GitHub", - "version": "0.2.2", + "version": "0.3.0", "author": "Rob Zolkos", "license": "MIT", "description": "A keyboard-friendly GitHub inbox for notifications, reviews, your own pull requests, assigned issues, Actions, and repositories.", @@ -20,6 +20,7 @@ "defaultSection": "right", "defaults": { "refreshIntervalSec": 900, + "linkBehavior": "Web app window", "includeArchived": false, "includeForks": false, "repositoryScope": "Owned", @@ -35,6 +36,7 @@ }, "schema": [ { "key": "refreshIntervalSec", "type": "integer", "label": "Refresh interval (seconds)", "min": 60, "max": 3600, "step": 60, "defaultValue": 900 }, + { "key": "linkBehavior", "type": "enum", "label": "Open links", "options": ["Browser tab", "Web app window"], "defaultValue": "Web app window", "description": "Open links in a browser tab, or in a dedicated web app window. The web app window requires a Chromium-based default browser." }, { "key": "includeArchived", "type": "boolean", "label": "Include archived repositories", "defaultValue": false }, { "key": "includeForks", "type": "boolean", "label": "Include forked repositories", "defaultValue": false }, { "key": "repositoryScope", "type": "enum", "label": "Repository scope", "options": ["Owned", "Owned and organizations"], "defaultValue": "Owned", "description": "List only repositories you own, or include organization repositories. This scope also supplies candidates for Actions scanning." }, diff --git a/tests/panel-source-test.sh b/tests/panel-source-test.sh index df09cc0..ac65aa4 100755 --- a/tests/panel-source-test.sh +++ b/tests/panel-source-test.sh @@ -36,10 +36,27 @@ assert_contains $'function markSelectedRead() {\n if (selectedTarget && selec "keyboard notification marking is blocked during refresh" assert_contains $'onClicked: root.openRow(linkRow.rowKind, linkRow.notificationId || linkRow.rowId, linkRow.url)' \ "clicking a notification does not open and mark it read" -assert_contains 'Quickshell.execDetached(["omarchy-launch-webapp", value])' \ - "links do not open in a web app window" -assert_not_contains '["omarchy-launch-browser"' \ - "links still open in a browser tab" +assert_contains $'if (github.linkBehavior === "Browser tab") Quickshell.execDetached(["omarchy-launch-browser", value])\n else Quickshell.execDetached(["omarchy-launch-webapp", value])' \ + "the open-links setting does not choose between the browser and the web app window" + +# updateEntryInline rewrites the shell.json entry whole, so a persist that does +# not carry the current settings forward silently drops every other setting. +assert_contains $'var entry = { id: root.moduleName }\n for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]' \ + "persisting a setting does not merge the entry from the current settings" +assert_contains 'root.bar.shell.updateEntryInline(root.moduleName, entry)' \ + "settings changes are not written back to shell.json" +# Dropdown writes `value` imperatively on selection, which destroys a plain +# inline binding the first time a row is picked. +assert_contains $'Binding on value { value: github.linkBehavior }' \ + "the open-links dropdown does not re-assert the persisted value" +assert_contains 'blocked: root.settingsOpen || search.activeFocus' \ + "the key catcher steals keys from the settings controls" +assert_contains 'visible: !root.settingsOpen' \ + "the dashboard stays visible behind the settings page" +assert_contains 'visible: root.settingsOpen' \ + "the settings page is always visible" +assert_contains $'pageFlip.stop()\n settingsOpen = false' \ + "closing the panel leaves it on the settings page" assert_contains $'PanelActionButton {\n visible: linkRow.showReadAction\n enabled: github.markingNotificationId !== linkRow.notificationId' \ "notification row marking is disabled during refresh" diff --git a/tests/service-source-test.sh b/tests/service-source-test.sh index 5c45d35..74312ff 100755 --- a/tests/service-source-test.sh +++ b/tests/service-source-test.sh @@ -31,6 +31,9 @@ assert_contains 'notifications = [item].concat(notifications);' \ "failed notification marking does not restore the hidden row" assert_not_contains $'if (value === "" || loading || fetchProcess.running || markProcess.running)' \ "single-notification marking is still blocked during refresh" +assert_contains 'String(setting("linkBehavior", "Web app window")).toLowerCase() === "browser tab" ? "Browser tab" : "Web app window"' \ + "an unrecognised open-links value does not fall back to the web app window" + assert_contains $'function canonicalNotificationTimestamp(value) {\n var text = String(value || "");\n if (!/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$/.test(text))\n return "";' \ "notification boundaries are not shape validated" assert_contains 'return milliseconds <= Date.now() ? text : "";' \ From b45d5f122a89bd95b655ece751f84519492fec3c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 23 Aug 2026 21:18:32 -0400 Subject: [PATCH 2/2] Write the back arrow as a character, not an escape QML string literals have no \U escape, so the back button rendered the literal text instead of the arrow. --- Panel.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Panel.qml b/Panel.qml index 2c2e3d2..f9edc9c 100644 --- a/Panel.qml +++ b/Panel.qml @@ -665,7 +665,7 @@ Panel { id: settingsBackButton anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - iconText: "\U000F004D" + iconText: "󰁍" tooltipText: "Back to the dashboard" foreground: root.foreground focusable: true