From ef3b7c8904204a503a54451ccb609090f8d82caf Mon Sep 17 00:00:00 2001 From: RobeTech Date: Mon, 21 Sep 2026 11:10:22 -0300 Subject: [PATCH] feat: open a commit from recent activity Each Recent Activity row links to its commit on GitHub, by click or by keyboard: the card is now the Overview's bottom region, reached with j past the bottom of the grid. The URL is built from the repository URL, so cached years open it too. --- gitframe/backend.py | 1 + gitframe/github.py | 8 ++++- gitframe/models.py | 1 + qml/ActivityList.qml | 77 ++++++++++++++++++++++++++++++++++++++++++-- qml/Heatmap.qml | 12 ++++--- qml/Overview.qml | 26 +++++++++++---- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/gitframe/backend.py b/gitframe/backend.py index f6d64e4..741c84c 100644 --- a/gitframe/backend.py +++ b/gitframe/backend.py @@ -95,6 +95,7 @@ def _rows(snapshot: Snapshot) -> list[dict[str, str]]: "sha": commit.short_oid, "repo": commit.repository, "when": github.relative_time(commit.committed_at), + "url": commit.url, } for commit in snapshot.commits ] diff --git a/gitframe/github.py b/gitframe/github.py index 135c720..af05264 100644 --- a/gitframe/github.py +++ b/gitframe/github.py @@ -205,7 +205,12 @@ def _history(repo: dict[str, Any]) -> dict[str, Any]: def _repo_commits(repo: dict[str, Any]) -> list[CommitEntry]: - """Every commit the history carried, in the order the API returned it.""" + """Every commit the history carried, in the order the API returned it. + + The commit's page is built from the repository's URL rather than asked + for, so a snapshot cached before the rows were links still opens them. + """ + repo_url = repo.get("url") or "" entries: list[CommitEntry] = [] for commit in _history(repo).get("nodes") or []: author = (commit.get("author") or {}).get("user") or {} @@ -217,6 +222,7 @@ def _repo_commits(repo: dict[str, Any]) -> list[CommitEntry]: committed_at=commit["committedDate"], author_login=author.get("login") or "", author_avatar_url=author.get("avatarUrl") or "", + url=f"{repo_url}/commit/{commit['oid']}" if repo_url else "", ) ) return entries diff --git a/gitframe/models.py b/gitframe/models.py index 1baf737..cb961b3 100644 --- a/gitframe/models.py +++ b/gitframe/models.py @@ -64,6 +64,7 @@ class CommitEntry: committed_at: str author_login: str = "" author_avatar_url: str = "" + url: str = "" @property def short_oid(self) -> str: diff --git a/qml/ActivityList.qml b/qml/ActivityList.qml index 3908d75..6134521 100644 --- a/qml/ActivityList.qml +++ b/qml/ActivityList.qml @@ -4,15 +4,56 @@ import "." // The commit rows: headline, short sha, the repository it landed in, and how // long ago it was committed. `Config.activityRows` sets how many there are; // the taller window is what paid for them. +// +// The rows are links to their commit on GitHub, and the card is the last of +// the Overview column's keyboard regions, below the grid. The cursor and the +// pointer draw the same mark, the grey `Config.hover` fill, and the last +// device to speak owns it - the Repositories list's rule. Card { id: root required property var api + // False while the window's cursor sits outside this card. + property bool focused: false + property int cursor: 0 + property bool mouseLeads: false + + // Clamped here too: the card is sized for exactly this many rows, and a + // cache written before the count changed is longer. + readonly property var rows: root.api.commits.slice(0, Config.activityRows) + title: "Recent Activity" glyph: Config.glyphCommits height: Config.activityHeight + // A refresh can bring fewer rows than the cursor was walking. + onRowsChanged: if (root.rows.length > 0) + root.cursor = Math.min(root.cursor, root.rows.length - 1) + + // False when `k` leaves the first row: the grid above takes it over. + function moveVertical(delta) { + const next = root.cursor + delta; + if (next < 0) + return false; + root.cursor = Math.min(next, Math.max(root.rows.length - 1, 0)); + return true; + } + + function activate() { + root.open(root.rows[root.cursor]); + } + + // Same as the Overview avatar: the browser takes over from here, so the + // window closes behind it, and the open goes through the backend so it + // outlives the quit. + function open(commit) { + if (commit && commit.url) { + root.api.openUrl(commit.url); + Qt.quit(); + } + } + Column { anchors.fill: parent spacing: 0 @@ -27,16 +68,33 @@ Card { } Repeater { - // Clamped here too: the card is sized for exactly this many - // rows, and a cache written before the count changed is longer. - model: root.api.commits.slice(0, Config.activityRows) + model: root.rows Item { + id: row + + required property int index required property var modelData width: root.width - 2 * Config.cardPad height: Config.activityRowHeight + // Bleeds `gap` into the card's padding on both sides, so the + // text does not sit flush against the edge of its own fill. + Rectangle { + anchors.fill: parent + anchors.leftMargin: -Config.gap + anchors.rightMargin: -Config.gap + color: (root.mouseLeads && area.containsMouse) + || (root.focused && !root.mouseLeads + && row.index === root.cursor) + ? Config.hover : Config.clear + + Behavior on color { + ColorAnimation { duration: Config.dur(Config.animFast) } + } + } + Row { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter @@ -87,6 +145,19 @@ Card { font.family: Config.font font.pixelSize: Config.fontSize } + + MouseArea { + id: area + + anchors.fill: parent + anchors.leftMargin: -Config.gap + anchors.rightMargin: -Config.gap + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.mouseLeads = true + onPositionChanged: root.mouseLeads = true + onClicked: root.open(row.modelData) + } } } } diff --git a/qml/Heatmap.qml b/qml/Heatmap.qml index b1a2991..c0c53c1 100644 --- a/qml/Heatmap.qml +++ b/qml/Heatmap.qml @@ -9,7 +9,8 @@ import "." // The card holds two of the Overview column's keyboard regions, stacked the // way they are drawn: the year control on top and the grid below it. // `Overview.qml` calls the move functions below; leaving the year upwards is -// its business, since the avatar above belongs to another card. +// its business, since the avatar above belongs to another card, and so is +// leaving the grid downwards, onto Recent Activity. Card { id: root @@ -134,21 +135,24 @@ Card { return true; } + // False when the cursor would step below its week: the grid's bottom + // edge, where Recent Activity takes the cursor over. function moveVertical(delta) { if (root.region < 0 || !root.cursorCell) { if (delta > 0 && root.cursorIso !== "") root.region = 0; - return; + return true; } const week = root.weeks[root.cursorCol]; const row = root.cursorRow + delta; if (row < week[0].row) { root.region = -1; - return; + return true; } if (row > week[week.length - 1].row) - return; + return false; root.setCursor(root.cursorCol, row); + return true; } // -- year -------------------------------------------------------------- diff --git a/qml/Overview.qml b/qml/Overview.qml index 7358822..5ff14c2 100644 --- a/qml/Overview.qml +++ b/qml/Overview.qml @@ -3,8 +3,8 @@ import "." // The content column of the Overview screen: the four blocks stacked with the // same gap. It also owns its own chain of keyboard regions - the avatar on -// top, then the year control, then the grid - the way the heatmap owns the two -// it draws. `Main.qml` still handles every key; this only exposes the moves. +// top, then the year control, then the grid, then Recent Activity - the way +// the heatmap owns the two it draws. `Main.qml` still handles every key; this only exposes the moves. Item { id: root @@ -16,18 +16,21 @@ Item { // The topmost region. It starts on the avatar, so the first `l` out of the // sidebar lands there; after that the position is simply kept. property bool onAvatar: true + // The bottom region, below the grid. + property bool onActivity: false // Part of the interface `Main.qml` calls on whichever column is on show: // a key takes the highlight back from the pointer. function releaseMouse() { heatmap.mouseLeads = false; + activity.mouseLeads = false; } // Returns false when the cursor is already at this column's left edge and // the key belongs to the sidebar instead. function moveHorizontal(delta) { - if (root.onAvatar) - return delta > 0; // nothing sits right of the avatar either + if (root.onAvatar || root.onActivity) + return delta > 0; // nothing sits beside either of them return heatmap.moveHorizontal(delta); } @@ -37,16 +40,24 @@ Item { root.onAvatar = false; return; } + if (root.onActivity) { + if (!activity.moveVertical(delta)) + root.onActivity = false; + return; + } if (delta < 0 && heatmap.region < 0) { root.onAvatar = true; return; } - heatmap.moveVertical(delta); + if (!heatmap.moveVertical(delta) && activity.rows.length > 0) + root.onActivity = true; } function activate() { if (root.onAvatar) header.open(); + else if (root.onActivity) + activity.activate(); } Column { @@ -77,12 +88,15 @@ Item { width: Config.heatmapCardWidth api: root.api - focused: root.focused && !root.onAvatar + focused: root.focused && !root.onAvatar && !root.onActivity } ActivityList { + id: activity + width: Config.heatmapCardWidth api: root.api + focused: root.focused && root.onActivity } }