diff --git a/Panel.qml b/Panel.qml index f9edc9c..cf19e45 100644 --- a/Panel.qml +++ b/Panel.qml @@ -21,7 +21,7 @@ Panel { property string sortMode: "updated" property bool cursorActive: false property int cursorIndex: 0 - property bool notificationsExpanded: false + property int notificationsPage: 0 property bool reviewsExpanded: false property bool myPullsExpanded: false property bool issuesExpanded: false @@ -70,12 +70,23 @@ Panel { return rows.slice(0, expanded ? activityExpandedCount : activityPreviewCount) } + function notificationPageCount() { + return Math.max(1, Math.ceil(github.notifications.length / activityPreviewCount)) + } + + function notificationRows() { + var page = Math.max(0, Math.min(notificationsPage, notificationPageCount() - 1)) + if (page !== notificationsPage) notificationsPage = page + var start = page * activityPreviewCount + return github.notifications.slice(start, start + activityPreviewCount) + } + function buildCursorTargets() { var targets = [] function add(kind, rows) { for (var i = 0; i < rows.length; i++) targets.push({ key: kind + ":" + String(rows[i].id || rows[i].url || i), kind: kind, row: rows[i] }) } - add("notification", sectionRows(github.notifications, notificationsExpanded)) + add("notification", notificationRows()) add("review", sectionRows(github.reviewRequests, reviewsExpanded)) add("mypull", sectionRows(github.myPullRequests, myPullsExpanded)) add("issue", sectionRows(github.assignedIssues, issuesExpanded)) @@ -456,10 +467,14 @@ Panel { title: "UNREAD NOTIFICATIONS" count: github.notifications.length emptyText: github.state === "ready" ? "You're all caught up." : "No notifications loaded." - model: root.sectionRows(github.notifications, root.notificationsExpanded) - expanded: root.notificationsExpanded + model: root.notificationRows() + showExpansionControl: false + footerButtonsBordered: true + page: root.notificationsPage + pageCount: root.notificationPageCount() openUrl: "https://github.com/notifications" - onToggleExpanded: root.notificationsExpanded = !root.notificationsExpanded + onPreviousPage: root.notificationsPage = Math.max(0, root.notificationsPage - 1) + onNextPage: root.notificationsPage = Math.min(root.notificationPageCount() - 1, root.notificationsPage + 1) delegateComponent: notificationDelegate actionText: "Mark all read" actionBusyText: "Marking…" @@ -501,6 +516,7 @@ Panel { count: github.assignedIssues.length model: root.sectionRows(github.assignedIssues, root.issuesExpanded) expanded: root.issuesExpanded + footerButtonsBordered: true openUrl: "https://github.com/issues/assigned" onToggleExpanded: root.issuesExpanded = !root.issuesExpanded delegateComponent: issueDelegate @@ -863,6 +879,7 @@ Panel { detail: modelData.repository + " · " + modelData.reason + " · " + root.relativeTime(modelData.updatedAt) url: modelData.url showReadAction: true + showTrailingIndicator: false notificationId: String(modelData.id || "") } } @@ -967,7 +984,11 @@ Panel { property var model: [] property Component delegateComponent: null property bool expanded: false + property bool showExpansionControl: true + property bool footerButtonsBordered: false property string openUrl: "" + property int page: 0 + property int pageCount: 1 // Optional destructive action. It arms on the first click and only runs on // the second, so a stray click cannot clear the section. property string actionText: "" @@ -980,6 +1001,8 @@ Panel { property var actionPrepare: null property string preparedAction: "" signal toggleExpanded() + signal previousPage() + signal nextPage() signal actionTriggered(string prepared) function disarmAction() { @@ -1028,10 +1051,11 @@ Panel { id: sectionFooter // Expanding is only offered once the section is truncated; below that // threshold the remaining controls render unbordered on their own line. - readonly property bool expandable: section.count > root.activityPreviewCount + readonly property bool expandable: section.showExpansionControl && section.count > root.activityPreviewCount + readonly property bool paginated: section.pageCount > 1 readonly property bool showOpen: section.count > 0 && section.openUrl !== "" - readonly property bool showAction: section.count > 0 && section.actionEnabled && section.actionText !== "" - visible: expandable || showOpen || showAction + readonly property bool showAction: section.count > 0 && section.actionText !== "" + visible: expandable || paginated || showOpen || showAction anchors.horizontalCenter: parent.horizontalCenter spacing: Style.space(12) Button { @@ -1053,9 +1077,9 @@ Panel { onImplicitWidthChanged: reservedWidth = Math.max(reservedWidth, implicitWidth) width: Math.max(reservedWidth, implicitWidth) visible: sectionFooter.showAction - enabled: !section.actionBusy + enabled: section.actionEnabled && !section.actionBusy text: section.actionBusy ? section.actionBusyText : (section.actionArmed ? section.actionConfirmText : section.actionText) - bordered: sectionFooter.expandable + bordered: sectionFooter.expandable || section.footerButtonsBordered foreground: section.actionArmed ? root.urgent : root.foreground fontFamily: root.fontFamily fontSize: Style.font.caption @@ -1075,10 +1099,44 @@ Panel { section.actionTriggered(confirmed) } } + Button { + id: previousPageButton + visible: sectionFooter.paginated + text: "󰅁" + tooltipText: "Previous notifications" + bordered: true + foreground: root.foreground + fontFamily: root.fontFamily + fontSize: Style.font.caption + verticalPadding: Style.spacing.controlPaddingY + enabled: section.page > 0 + onClicked: section.previousPage() + } + Text { + visible: sectionFooter.paginated + text: (section.page + 1) + " / " + section.pageCount + height: previousPageButton.height + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + verticalAlignment: Text.AlignVCenter + } + Button { + visible: sectionFooter.paginated + text: "󰅂" + tooltipText: "Next notifications" + bordered: true + foreground: root.foreground + fontFamily: root.fontFamily + fontSize: Style.font.caption + verticalPadding: Style.spacing.controlPaddingY + enabled: section.page + 1 < section.pageCount + onClicked: section.nextPage() + } Button { visible: sectionFooter.showOpen text: "Open in GitHub 󰅂" - bordered: sectionFooter.expandable + bordered: sectionFooter.expandable || section.footerButtonsBordered foreground: root.foreground fontFamily: root.fontFamily fontSize: Style.font.caption @@ -1097,6 +1155,7 @@ Panel { property bool pulse: false property bool danger: false property bool showReadAction: false + property bool showTrailingIndicator: true property string notificationId: "" property string rowKind: "" property int rowIndex: 0 @@ -1117,10 +1176,10 @@ Panel { RowLayout { id: row anchors.left: parent.left - anchors.right: parent.right + anchors.right: readActionStrip.visible ? readActionStrip.left : parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.space(9) - anchors.rightMargin: Style.space(9) + anchors.rightMargin: readActionStrip.visible ? 0 : Style.space(9) spacing: Style.space(9) Text { text: linkRow.glyph @@ -1157,17 +1216,49 @@ Panel { elide: Text.ElideRight } } + Text { + visible: linkRow.showTrailingIndicator + text: "󰅂" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + } + BorderSurface { + id: readActionStrip + visible: linkRow.showReadAction + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: Style.space(32) + radius: 0 + color: "transparent" + borderSpec: Border.none() + + HoverHandler { + onHoveredChanged: if (hovered) root.selectKey(linkRow.cursorKey) + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: Style.normalBorderWidth + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.16) + } + PanelActionButton { - visible: linkRow.showReadAction + id: readAction + anchors.fill: parent enabled: github.markingNotificationId !== linkRow.notificationId iconText: github.markingNotificationId === linkRow.notificationId ? "󰑐" : "󰄬" tooltipText: "Mark this notification read (M)" foreground: root.foreground + hoverColor: Color.accent fontFamily: root.fontFamily - Layout.alignment: Qt.AlignVCenter + bordered: false onClicked: github.markNotificationRead(linkRow.notificationId) } - Text { text: "󰅂"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.body } } } diff --git a/Service.qml b/Service.qml index 9fe1315..7a5ed70 100644 --- a/Service.qml +++ b/Service.qml @@ -31,6 +31,7 @@ Item { property bool refreshQueued: false property string markingNotificationId: "" property bool markingAllNotifications: false + property var markingAllNotificationIds: [] property string notificationActionStatus: "" property string _markStdout: "" property string _markStderr: "" @@ -171,6 +172,34 @@ Item { notificationsRevision++; } + function hideAllNotifications() { + var ids = []; + var hidden = copyMap(hiddenNotifications); + var remaining = []; + for (var i = 0; i < notifications.length; i++) { + var item = notifications[i]; + var id = String(item.id || ""); + if (id !== "") { + ids.push(id); + hidden[id] = item; + } else { + remaining.push(item); + } + } + if (ids.length > 0) { + hiddenNotifications = hidden; + notifications = remaining; + notificationsRevision++; + } + return ids; + } + + function restoreHiddenNotifications(ids) { + var values = Array.isArray(ids) ? ids : []; + for (var i = values.length - 1; i >= 0; i--) + restoreHiddenNotification(values[i]); + } + function visibleNotifications(rows) { var incoming = Array.isArray(rows) ? rows : []; var hidden = hiddenNotifications || {}; @@ -341,6 +370,7 @@ Item { actionStatusTimer.stop(); markingAllNotifications = true; + markingAllNotificationIds = hideAllNotifications(); notificationActionStatus = "Marking all notifications read…"; _markStdout = ""; _markStderr = ""; @@ -427,11 +457,14 @@ Item { } else { var fallback = all ? "Could not mark all notifications read." : "Could not mark notification read."; root.notificationActionStatus = response && response.message ? String(response.message) : String(markErrors.text || root._markStderr || fallback).trim(); - if (!all && markedId !== "") + if (all) + root.restoreHiddenNotifications(root.markingAllNotificationIds); + else if (markedId !== "") root.restoreHiddenNotification(markedId); } root.markingNotificationId = ""; root.markingAllNotifications = false; + root.markingAllNotificationIds = []; actionStatusTimer.restart(); if (root.startQueuedMark()) return ; diff --git a/tests/panel-source-test.sh b/tests/panel-source-test.sh index ac65aa4..2f7fb6a 100755 --- a/tests/panel-source-test.sh +++ b/tests/panel-source-test.sh @@ -57,8 +57,34 @@ 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" +assert_contains $'id: readActionStrip\n visible: linkRow.showReadAction\n anchors.right: parent.right\n anchors.top: parent.top\n anchors.bottom: parent.bottom\n width: Style.space(32)' \ + "notification read target does not fill the row height at its right edge" +assert_contains $'anchors.right: readActionStrip.visible ? readActionStrip.left : parent.right\n anchors.verticalCenter: parent.verticalCenter\n anchors.leftMargin: Style.space(9)\n anchors.rightMargin: readActionStrip.visible ? 0 : Style.space(9)' \ + "notification content does not meet the full-height read target" +assert_contains $'borderSpec: Border.none()\n\n HoverHandler {\n onHoveredChanged: if (hovered) root.selectKey(linkRow.cursorKey)\n }\n\n Rectangle {\n anchors.left: parent.left\n anchors.top: parent.top\n anchors.bottom: parent.bottom' \ + "notification read target does not use a left-only divider" +assert_contains $'anchors.fill: parent\n enabled: github.markingNotificationId !== linkRow.notificationId' \ + "notification read target does not fill its action strip" +assert_contains $'function notificationRows() {\n var page = Math.max(0, Math.min(notificationsPage, notificationPageCount() - 1))' \ + "notifications are not paged in five-item windows" +assert_contains $'onPreviousPage: root.notificationsPage = Math.max(0, root.notificationsPage - 1)\n onNextPage: root.notificationsPage = Math.min(root.notificationPageCount() - 1, root.notificationsPage + 1)' \ + "notification page controls do not clamp their range" +assert_contains $'model: root.notificationRows()\n showExpansionControl: false\n footerButtonsBordered: true\n page: root.notificationsPage' \ + "notification pagination still shows an inactive expansion control" +assert_contains $'showReadAction: true\n showTrailingIndicator: false\n notificationId: String(modelData.id || "")' \ + "notification rows retain an open-link indicator beside their read action" +assert_contains $'title: "ASSIGNED ISSUES"\n count: github.assignedIssues.length\n model: root.sectionRows(github.assignedIssues, root.issuesExpanded)\n expanded: root.issuesExpanded\n footerButtonsBordered: true\n openUrl: "https://github.com/issues/assigned"' \ + "assigned-issues open control does not retain its matching border" +assert_contains 'readonly property bool showAction: section.count > 0 && section.actionText !== ""' \ + "bulk notification action disappears while data is loading" +assert_contains 'enabled: section.actionEnabled && !section.actionBusy' \ + "bulk notification action is not disabled until it is ready" +assert_contains $'text: "󰅁"\n tooltipText: "Previous notifications"' \ + "previous notification page control is missing" +assert_contains $'text: "󰅂"\n tooltipText: "Next notifications"' \ + "next notification page control is missing" +assert_contains $'text: (section.page + 1) + " / " + section.pageCount\n height: previousPageButton.height\n color: root.dim' \ + "notification page number is not vertically centered with its controls" assert_contains $'function applyPanelWheel(event) {\n if (!panelFlick || (sortPicker && sortPicker.popup.visible)) return false' \ "the panel still uses Flickable's default wheel distance" diff --git a/tests/service-source-test.sh b/tests/service-source-test.sh index 74312ff..00faac0 100755 --- a/tests/service-source-test.sh +++ b/tests/service-source-test.sh @@ -50,6 +50,16 @@ assert_contains $'if (confirmed !== prepareMarkAllNotificationsRead()) {\n "bulk marking does not verify the confirmed snapshot" assert_contains $'var commandLine = [helperPath(), "--mark-all-read-before", String(snapshot.boundary || "")];\n for (var i = 0; i < snapshot.boundaryIds.length; i++)\n commandLine.push("--mark-boundary-notification", String(snapshot.boundaryIds[i]));' \ "bulk marking does not protect same-second arrivals" +assert_contains $'function hideAllNotifications() {\n var ids = [];\n var hidden = copyMap(hiddenNotifications);\n var remaining = [];\n for (var i = 0; i < notifications.length; i++) {' \ + "bulk marking does not batch its optimistic removal" +assert_contains $'hiddenNotifications = hidden;\n notifications = remaining;\n notificationsRevision++;' \ + "bulk marking repeatedly updates the notification model" +assert_contains $'function restoreHiddenNotifications(ids) {\n var values = Array.isArray(ids) ? ids : [];\n for (var i = values.length - 1; i >= 0; i--)' \ + "failed bulk marking does not preserve notification order" +assert_contains $'markingAllNotifications = true;\n markingAllNotificationIds = hideAllNotifications();\n notificationActionStatus = "Marking all notifications read…";' \ + "bulk marking does not provide immediate visible feedback" +assert_contains $'if (all)\n root.restoreHiddenNotifications(root.markingAllNotificationIds);\n else if (markedId !== "")' \ + "failed bulk marking does not restore its displayed notifications" assert_contains $'// GitHub is authoritative after every attempt. This reconciles\n // successful, failed, and partially completed bulk operations.\n root.refreshQueued = false;\n Qt.callLater(root.refresh);' \ "notification marking does not reconcile every result with an authoritative refresh" assert_not_contains 'root.notifications = root.notifications.filter' \