From f83e44684139a91fadb6fa60bd91ce4a1a636474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 20:52:27 +0200 Subject: [PATCH 01/10] Read landed pull requests from history in the Graph layout GitHub records the PR in the commit that lands it ("Merge pull request #N from owner/branch", or a squash subject ending in "(#N)"). Each row now carries the PR that landed its tip, deleted PR branches are named from the merge subject instead of a short hash, and labels can reserve room for a PR chip so rows never reshuffle when PR data arrives. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/graph/landedPr.test.ts | 65 +++++++++++++++++++ src/renderer/src/components/graph/landedPr.ts | 40 ++++++++++++ .../src/components/graph/layout.test.ts | 54 +++++++++++++++ src/renderer/src/components/graph/layout.ts | 56 ++++++++++++++-- 4 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/graph/landedPr.test.ts create mode 100644 src/renderer/src/components/graph/landedPr.ts diff --git a/src/renderer/src/components/graph/landedPr.test.ts b/src/renderer/src/components/graph/landedPr.test.ts new file mode 100644 index 0000000..7b2aa47 --- /dev/null +++ b/src/renderer/src/components/graph/landedPr.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import type { Commit } from '@shared/types' +import { branchFromPrMergeSubject, landedPrOf } from './landedPr' + +function commit(subject: string, body = ''): Commit { + return { + hash: 'abc1234def', + shortHash: 'abc1234', + subject, + body, + authorName: 'Ada', + authorEmail: 'ada@example.com', + date: '2026-07-01T10:00:00+00:00', + relativeDate: 'now', + refs: '', + parents: ['p1', 'p2'] + } +} + +describe('branchFromPrMergeSubject', () => { + test('names the head branch, nested segments and all', () => { + expect( + branchFromPrMergeSubject('Merge pull request #89 from danipen/graph/squash-merged') + ).toBe('graph/squash-merged') + }) + + test('ignores anything that is not a GitHub PR merge', () => { + expect(branchFromPrMergeSubject("Merge branch 'feature'")).toBeNull() + expect(branchFromPrMergeSubject('Fix the build (#12)')).toBeNull() + }) +}) + +describe('landedPrOf', () => { + test('reads a merge commit: number from the subject, title from the body', () => { + const pr = landedPrOf( + commit('Merge pull request #89 from danipen/graph-squash', '\nDraw squash landings\n') + ) + expect(pr).toEqual({ number: 89, title: 'Draw squash landings' }) + }) + + test('falls back to the subject when a merge commit has no body', () => { + expect(landedPrOf(commit('Merge pull request #7 from o/b'))).toEqual({ + number: 7, + title: 'Merge pull request #7 from o/b' + }) + }) + + test('reads a squash commit: the trailing (#N) and the title before it', () => { + expect(landedPrOf(commit('Add PR chips to the graph (#123)'))).toEqual({ + number: 123, + title: 'Add PR chips to the graph' + }) + }) + + test('a (#N) anywhere but the end is just prose', () => { + expect(landedPrOf(commit('Revert (#12) partially'))).toBeNull() + expect(landedPrOf(commit('Plain commit'))).toBeNull() + }) + + test('tolerates CRLF bodies', () => { + expect(landedPrOf(commit('Merge pull request #3 from o/b', '\r\nTitle here\r\n'))?.title).toBe( + 'Title here' + ) + }) +}) diff --git a/src/renderer/src/components/graph/landedPr.ts b/src/renderer/src/components/graph/landedPr.ts new file mode 100644 index 0000000..1ab7ae3 --- /dev/null +++ b/src/renderer/src/components/graph/landedPr.ts @@ -0,0 +1,40 @@ +// Pull requests recorded in history: GitHub writes the PR number into the +// commit that lands it, so a merged PR is readable straight from the loaded +// log — no API call, no account, works offline. Two shapes cover GitHub's +// merge buttons: +// • "Create a merge commit": subject `Merge pull request #89 from owner/branch`, +// body = the PR title; +// • "Squash and merge" (and the default squash title): subject `Title (#89)`. +// ("Rebase and merge" records nothing — those land with no PR trace.) +// Pure — the layout and detail pane both call it. + +import type { Commit } from '@shared/types' + +/** A pull request as its landing commit records it. */ +export interface LandedPr { + number: number + /** The PR title: the merge commit's body line, or the squash subject. */ + title: string +} + +// The owner segment can't contain '/', so everything after the first slash is +// the branch — which itself may be nested (`feature/x/y`). +const MERGE_PR = /^Merge pull request #(\d+) from [^/\s]+\/(\S+)/ +const SQUASH_PR = /^(.*\S)\s+\(#(\d+)\)$/ + +/** The head branch named by a GitHub PR merge subject, or null. */ +export function branchFromPrMergeSubject(subject: string): string | null { + return subject.match(MERGE_PR)?.[2] ?? null +} + +/** The PR a commit landed, when its message records one (see file header). */ +export function landedPrOf(commit: Commit): LandedPr | null { + const merge = commit.subject.match(MERGE_PR) + if (merge) { + const title = commit.body.split('\n').find((line) => line.trim() !== '') + return { number: Number(merge[1]), title: title?.trim() ?? commit.subject } + } + const squash = commit.subject.match(SQUASH_PR) + if (squash) return { number: Number(squash[2]), title: squash[1] } + return null +} diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index 272b786..58938af 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -148,6 +148,35 @@ describe('layoutGraph', () => { expect(layout.nodeByHash.get('x2')?.row).toBe(unnamed.index) }) + test('a deleted PR branch is named from its GitHub merge and keeps the PR', () => { + const merge = commit( + 'm', + ['b', 'x2'], + 'HEAD -> main', + 'Merge pull request #42 from ada/fix/crash' + ) + merge.body = 'Fix the crash on open' + const layout = layoutGraph( + input([merge, commit('x2', ['x1']), commit('x1', ['a']), commit('b', ['a']), commit('a', [])]) + ) + const unnamed = rowNamed(layout, 'fix/crash') + expect(unnamed.kind).toBe('unnamed') + expect(unnamed.landedPr).toEqual({ number: 42, title: 'Fix the crash on open' }) + expect(rowNamed(layout, 'main').landedPr).toBeNull() + }) + + test('a branch merged without a PR trace has no landed PR', () => { + const layout = layoutGraph( + input([ + commit('m', ['b', 'f1'], 'HEAD -> main', "Merge branch 'feature'"), + commit('f1', ['a'], 'feature'), + commit('b', ['a']), + commit('a', []) + ]) + ) + expect(rowNamed(layout, 'feature').landedPr).toBeNull() + }) + test('tags never create rows', () => { const layout = layoutGraph( input([commit('b', ['a'], 'HEAD -> main, tag: v1.0'), commit('a', [], 'tag: v0.9')]) @@ -758,8 +787,33 @@ describe('squash-merged branches', () => { expect(layout.nodeByHash.has('b')).toBe(false) }) + test('the squash landing records the branch PR', () => { + const layout = layoutGraph(input(commits(), { squashLandings })) + expect(rowNamed(layout, 'feature').landedPr).toEqual({ number: 1, title: 'Feature' }) + }) + test('landings outside the window are ignored', () => { const layout = layoutGraph(input(commits(), { squashLandings: new Map([['f2', 'gone']]) })) expect(layout.edges.some((e) => e.kind === 'squash')).toBe(false) }) }) + +describe('PR chip room', () => { + // Two short-lived branches, one after the other: with room to spare they + // share a row; the chip's reserved width pushes their labels into each other. + const commits = () => [ + commit('m2', ['c', 'g1'], 'HEAD -> main'), + commit('g1', ['c'], 'second'), + commit('c', ['m1']), + commit('m1', ['a', 'f1']), + commit('f1', ['a'], 'first-branch'), + commit('a', []) + ] + + test('labels reserve room for a PR chip only on pull-request hosts', () => { + const plain = layoutGraph(input(commits())) + expect(rowNamed(plain, 'first-branch').index).toBe(rowNamed(plain, 'second').index) + const reserved = layoutGraph(input(commits(), { reservePrChips: true })) + expect(rowNamed(reserved, 'first-branch').index).not.toBe(rowNamed(reserved, 'second').index) + }) +}) diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index 5d9083d..da0a04b 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -13,7 +13,8 @@ // ("main" / "origin/main") share one chain — the walk starts at the newer tip // and passes through the older one. Commits left unclaimed (their branch was // deleted after merging) become "unnamed" chains, labelled from the merge -// commit's subject when it records the branch name. +// commit's subject when it records the branch name (git's stock merge +// message, or GitHub's "Merge pull request #N from owner/branch"). // // Rows are then PACKED (see packing.ts): the mainline keeps row 0 to itself, // release lines stack directly beneath it (newest version first — a stable @@ -30,6 +31,7 @@ import type { Commit } from '@shared/types' import { type CommitRef, parseRefs } from '@/lib/format' // Value import from geometry is safe: geometry's layout imports are type-only. import { COL_W } from './geometry' +import { branchFromPrMergeSubject, type LandedPr, landedPrOf } from './landedPr' import { type PackChain, packRows, type VerticalStub } from './packing' import { compareReleaseVersions, releaseVersionWithOverride } from './releases' @@ -73,6 +75,10 @@ export interface GraphRow { /** Inclusive column span of the row's nodes. */ startColumn: number endColumn: number + /** The pull request that landed this branch, as recorded by the commit + * that merged (or squashed) its tip — see landedPr.ts. Null when the tip + * isn't landed in the window, or landed without a PR trace. */ + landedPr: LandedPr | null } /** Identifies the row whose branch-changes view is open. A tip hash alone is @@ -186,6 +192,13 @@ export interface GraphInput { * source does, and gets a `squash` edge into its landing commit. */ squashLandings?: ReadonlyMap | null + /** + * The repo lives on a pull-request host: every branch label reserves room + * for a PR chip (render.ts) whether or not its PR is known yet. The chips + * arrive asynchronously as labels scroll into view, and a layout that only + * made room once they landed would reshuffle rows under the user's eyes. + */ + reservePrChips?: boolean } /** Estimated width of a row's label pill, in columns. The pill anchors at @@ -196,10 +209,19 @@ export interface GraphInput { * by platform font and only exist after first paint). Mirrors render.ts * labelWidthFor's 6.2 px/char fallback plus the pill's 16px padding and a * little air before the next pill. */ -function labelColumns(name: string): number { - return Math.ceil((name.length * 6.2 + 16 + 8) / COL_W) +function labelColumns(name: string, prChip: boolean): number { + return Math.ceil((name.length * 6.2 + 16 + 8 + (prChip ? PR_CHIP_RESERVE : 0)) / COL_W) } +/** Room a label reserves for its PR chip: the octicon, its gaps and a + * four-digit `#1234` (render.ts measures the real chip). */ +const PR_CHIP_RESERVE = 48 + +/** Chain kinds that can carry a PR chip: real branches, and deleted ones + * reconstructed from history (their landing commit may record the PR). */ +const canCarryPr = (kind: GraphRowKind): boolean => + kind === 'branch' || kind === 'remote' || kind === 'unnamed' + /** A branch tip: one exact ref name resolved to the commit it points at. */ interface Tip { hash: string @@ -230,10 +252,26 @@ function isHeadDecoration(refs: string): boolean { return refs.split(',').some((r) => r.trim() === 'HEAD' || r.trim().startsWith('HEAD ->')) } -/** Branch name recorded in a merge commit's subject, if git's stock message. */ +/** Branch name recorded in a merge commit's subject: git's stock message or + * GitHub's pull-request merge. */ function branchNameFromMergeSubject(subject: string): string | null { const m = subject.match(/^Merge (?:remote-tracking )?branch '([^']+)'/) - return m ? m[1] : null + return m ? m[1] : branchFromPrMergeSubject(subject) +} + +/** The PR recorded by whichever commit landed the chain's tip (a merge or a + * squash landing), if any. An empty chain's tip is another chain's commit, + * so its landing belongs to that owner, never to the empty lane. */ +function landedPrOfChain( + chain: Chain, + mergeChildrenOf: ReadonlyMap +): LandedPr | null { + if (chain.empty) return null + for (const child of mergeChildrenOf.get(chain.tipHash) ?? []) { + const pr = landedPrOf(child) + if (pr) return pr + } + return null } /** Landing commit → the branch tips squash-merged into it, keeping only @@ -615,7 +653,10 @@ export function layoutGraph(input: GraphInput): GraphLayout { end: Math.max(span[id].end + 1, mergeColumn ?? -1), capStart: span[id].start, capEnd: span[id].end, - labelEnd: span[id].start + labelColumns(chain.name) - 1, + labelEnd: + span[id].start + + labelColumns(chain.name, input.reservePrChips === true && canCarryPr(chain.kind)) - + 1, parent: parentChainOf(id) ?? null, releaseRank: releaseRank.get(id) ?? null, isHead: id === headChain, @@ -639,7 +680,8 @@ export function layoutGraph(input: GraphInput): GraphLayout { color: id === mainChain ? 0 : colorForName(chain.name), startColumn: span[id].start, endColumn: span[id].end, - empty: chain.empty === true + empty: chain.empty === true, + landedPr: landedPrOfChain(chain, mergeChildrenOf) })) if (headEmptyChain !== -1) rows[headEmptyChain].isHead = true From c8ea2f2a50f788338d4ab65fef1d5b6e7057a4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 21:10:27 +0200 Subject: [PATCH 02/10] Share the PR hovercard between the branch switcher and other features Move the hovercard, its state glyphs and a reusable PrRow link row into common/PrHoverCard.tsx, and promote .pr-card / .ci-status to primitives. The card now anchors to anything with a client rect (so a canvas-drawn chip can host it) and can line up with either anchor edge. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/common/PrHoverCard.tsx | 237 ++++++++++++++++++ .../src/components/toolbar/BranchSwitcher.tsx | 199 +-------------- src/renderer/src/styles/features/toolbar.css | 134 ---------- src/renderer/src/styles/primitives.css | 138 ++++++++++ 4 files changed, 378 insertions(+), 330 deletions(-) create mode 100644 src/renderer/src/components/common/PrHoverCard.tsx diff --git a/src/renderer/src/components/common/PrHoverCard.tsx b/src/renderer/src/components/common/PrHoverCard.tsx new file mode 100644 index 0000000..75cdc8f --- /dev/null +++ b/src/renderer/src/components/common/PrHoverCard.tsx @@ -0,0 +1,237 @@ +// The pull-request hovercard and the PR state glyphs, shared by every place a +// branch shows its PR: the branch switcher's `#123` badge (toolbar) and the +// Graph's label chips. One card, so a PR reads the same wherever it's found. +// styles: primitives.css (.pr-card, .ci-status) + +import { headPullRequestsUrl } from '@shared/git-host-urls' +import type { PullRequestChecks, PullRequestInfo } from '@shared/types' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { Icon } from '@/lib/icons' + +/** What the card positions against and tracks the pointer around: a DOM + * badge, or a canvas-drawn chip standing in with its on-screen rect. */ +export interface PrCardAnchor { + getBoundingClientRect(): DOMRect +} + +/** The CI rollup glyph inside a PR badge: a green check when passing, a red + * cross when failing, or a pulsing amber dot while checks are still running. + * styles: primitives.css (.ci-status) */ +function CiStatus({ state }: { state: PullRequestChecks }) { + if (state === 'pending') return + return ( + + {state === 'success' ? : } + + ) +} + +/** The leading state glyph for a PR, shared by the badge and the hovercard: the + * green/red/amber CI rollup for open PRs (nothing when no checks ran), or + * GitHub's merged/closed octicon (no CI dot — that CI is long settled). */ +export function PrGlyph({ pr }: { pr: PullRequestInfo }) { + if (pr.state === 'open') return pr.checks ? : null + return ( + + {pr.state === 'merged' ? : } + + ) +} + +/** The hovercard's leading state glyph: GitHub's open / merged / closed pull- + * request octicon, tinted by state (green / muted draft / purple / red). Unlike + * the badge's CI-rollup glyph, this always shows — it's the row's only state cue + * now that the text label is gone. */ +function PrStateIcon({ pr }: { pr: PullRequestInfo }) { + const state = pr.state === 'open' && pr.draft ? 'draft' : pr.state + const Glyph = + pr.state === 'merged' ? Icon.PrMerged : pr.state === 'closed' ? Icon.PrClosed : Icon.PrOpen + return ( + + + + ) +} + +/** One pull request as a link row — state octicon, title, number, and the + * "opens in the browser" cue; clicking opens it on the host. The card's rows, + * and the Graph detail pane's PR list. `onOpen` runs after the link opens. */ +export function PrRow({ + pr, + compact = false, + onOpen +}: { + pr: PullRequestInfo + /** Name the PR by number only — where its title is already on screen. */ + compact?: boolean + onOpen?: (e: React.MouseEvent) => void +}) { + return ( + + ) +} + +/** A floating card listing a branch's PRs (icon, status, number, title) — shown + * on hover of the badge, always (one PR or many) so the UX is uniform. Each row + * is clickable to open the PR; when the branch has more PRs than were fetched + * (`total > prs.length`), a footer links to the full list on the host. Stays + * open while the pointer is in the badge↔card safe zone (see the tracking + * effect), so its rows are reachable across the gap. Portal-rendered so the + * popover / row overflow can't clip it; positioned under the badge, flipped + * above near the bottom edge. styles: primitives.css */ +export function PrHoverCard({ + anchor, + prs, + total, + githubWebUrl, + keepOpen, + requestClose, + dismiss, + onActivate, + align = 'end' +}: { + anchor: PrCardAnchor | null + prs: PullRequestInfo[] + total: number + githubWebUrl?: string | null + /** Pointer is inside the badge↔card safe zone — cancel any pending close. */ + keepOpen: () => void + /** Pointer has left the safe zone — start the close countdown. */ + requestClose: () => void + /** Close just the card (leaving the switcher popover open) — Escape. */ + dismiss: () => void + /** Called after opening a PR / the list, so the switcher can dismiss itself. */ + onActivate: () => void + /** Which anchor edge the card lines up with: `end` (right) for a badge at a + * row's trailing edge, `start` (left) for a chip read left-to-right. */ + align?: 'start' | 'end' +}) { + const ref = useRef(null) + const [pos, setPos] = useState<{ top: number; left: number } | null>(null) + // biome-ignore lint/correctness/useExhaustiveDependencies: prs changes the measured height + useLayoutEffect(() => { + if (!anchor || !ref.current) return + const r = anchor.getBoundingClientRect() + const card = ref.current.getBoundingClientRect() + const m = 8 // viewport-edge margin + const gap = 6 // space between the badge and the card + // Line up with the anchor's chosen edge, clamped to the viewport. + const edge = align === 'start' ? r.left : r.right - card.width + let left = Math.min(edge, window.innerWidth - card.width - m) + left = Math.max(m, left) + let top = r.bottom + gap + if (top + card.height > window.innerHeight - m) top = r.top - gap - card.height + top = Math.max(m, Math.min(top, window.innerHeight - card.height - m)) + setPos({ top, left }) + }, [anchor, prs, align]) + // Keep the card open while the pointer is anywhere in the "safe zone" — the + // badge, the card, or the full-width corridor between them — and close once it + // has left that zone. The badge is small and sits at the card's trailing edge + // while the card is wide and drops to its left, so the pointer travels a + // diagonal to reach a row; tracking the live position (rather than relying on + // mouseenter/leave across the two elements and the gap between them) means no + // travel path, gap, or React-portal event-ordering can dismiss it mid-journey. + useEffect(() => { + const card = ref.current + if (!anchor || !card) return + const onMove = (e: PointerEvent) => { + const a = anchor.getBoundingClientRect() + const c = card.getBoundingClientRect() + const { clientX: x, clientY: y } = e + const pad = 6 // sub-pixel + a little slack so a grazing path still counts + const inRect = (rect: DOMRect) => + x >= rect.left - pad && + x <= rect.right + pad && + y >= rect.top - pad && + y <= rect.bottom + pad + // The corridor spans the card's full width across the gap between the two, + // so any descent into the card crosses it instead of a dead patch (works + // whether the card sits below the badge or, when flipped, above it). + const inCorridor = + x >= c.left - pad && + x <= c.right + pad && + y >= Math.min(a.bottom, c.bottom) - pad && + y <= Math.max(a.top, c.top) + pad + if (inRect(a) || inRect(c) || inCorridor) keepOpen() + else requestClose() + } + document.addEventListener('pointermove', onMove) + return () => document.removeEventListener('pointermove', onMove) + }, [anchor, keepOpen, requestClose]) + // Escape peels just the card, leaving the switcher popover open (a second + // Escape then closes that). Capture-phase + stopPropagation so the popover's + // own window-level Escape doesn't also fire — same layering as ContextMenu. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + e.stopPropagation() + dismiss() + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [dismiss]) + // More PRs exist than we fetched — offer the host's full, filtered list. + const more = total > prs.length + return createPortal( +
{ + e.preventDefault() + e.stopPropagation() + }} + > +
+ {total} pull request{total === 1 ? '' : 's'} +
+ {prs.map((pr) => ( + // stopPropagation: the card is portal-rendered but lives in the branch + // row's / pill's React subtree, so without it a click would also fire + // their onClick and switch branch / toggle the popover. + { + e.stopPropagation() + onActivate() + }} + /> + ))} + {more && githubWebUrl && ( + + )} +
, + document.body + ) +} diff --git a/src/renderer/src/components/toolbar/BranchSwitcher.tsx b/src/renderer/src/components/toolbar/BranchSwitcher.tsx index 5fa7f6b..78ed693 100644 --- a/src/renderer/src/components/toolbar/BranchSwitcher.tsx +++ b/src/renderer/src/components/toolbar/BranchSwitcher.tsx @@ -1,10 +1,10 @@ import { branchUrl, headPullRequestsUrl } from '@shared/git-host-urls' -import type { BranchInfo, PullRequestChecks, PullRequestInfo } from '@shared/types' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' +import type { BranchInfo, PullRequestInfo } from '@shared/types' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ClearButton } from '@/components/common/ClearButton' import { ContextMenu, type ContextMenuItem } from '@/components/common/ContextMenu' import { Popover } from '@/components/common/Popover' +import { PrGlyph, PrHoverCard } from '@/components/common/PrHoverCard' import { useVirtualScroll, VScrollbar } from '@/components/common/VirtualScroll' import { type BranchRow, buildBranchRows } from '@/lib/branch-rows' import { highlightMatch } from '@/lib/highlight' @@ -15,45 +15,6 @@ import { useListKeyNav } from '@/lib/useListKeyNav' /** Branch operations surfaced from the switcher (beyond plain checkout). */ export type BranchAction = 'new' | 'merge' | 'rename' | 'delete' -/** The CI rollup glyph inside a PR badge: a green check when passing, a red - * cross when failing, or a pulsing amber dot while checks are still running. - * styles: features/toolbar.css (.ci-status) */ -function CiStatus({ state }: { state: PullRequestChecks }) { - if (state === 'pending') return - return ( - - {state === 'success' ? : } - - ) -} - -/** The leading state glyph for a PR, shared by the badge and the hovercard: the - * green/red/amber CI rollup for open PRs (nothing when no checks ran), or - * GitHub's merged/closed octicon (no CI dot — that CI is long settled). */ -function PrGlyph({ pr }: { pr: PullRequestInfo }) { - if (pr.state === 'open') return pr.checks ? : null - return ( - - {pr.state === 'merged' ? : } - - ) -} - -/** The hovercard's leading state glyph: GitHub's open / merged / closed pull- - * request octicon, tinted by state (green / muted draft / purple / red). Unlike - * the badge's CI-rollup glyph, this always shows — it's the row's only state cue - * now that the text label is gone. */ -function PrStateIcon({ pr }: { pr: PullRequestInfo }) { - const state = pr.state === 'open' && pr.draft ? 'draft' : pr.state - const Glyph = - pr.state === 'merged' ? Icon.PrMerged : pr.state === 'closed' ? Icon.PrClosed : Icon.PrOpen - return ( - - - - ) -} - /** The `#123` pill marking a branch's most important PR: a state glyph + the * number, tinted for merged (purple) / closed (red). One badge per branch; when * the branch has more than one PR (`stacked`) a second outlined pill peeks @@ -69,160 +30,6 @@ function PrBadge({ pr, stacked }: { pr: PullRequestInfo; stacked: boolean }) { ) } -/** A floating card listing a branch's PRs (icon, status, number, title) — shown - * on hover of the badge, always (one PR or many) so the UX is uniform. Each row - * is clickable to open the PR; when the branch has more PRs than were fetched - * (`total > prs.length`), a footer links to the full list on the host. Stays - * open while the pointer is in the badge↔card safe zone (see the tracking - * effect), so its rows are reachable across the gap. Portal-rendered so the - * popover / row overflow can't clip it; positioned under the badge, flipped - * above near the bottom edge. styles: features/toolbar.css */ -function PrHoverCard({ - anchor, - prs, - total, - githubWebUrl, - keepOpen, - requestClose, - dismiss, - onActivate -}: { - anchor: HTMLElement | null - prs: PullRequestInfo[] - total: number - githubWebUrl?: string | null - /** Pointer is inside the badge↔card safe zone — cancel any pending close. */ - keepOpen: () => void - /** Pointer has left the safe zone — start the close countdown. */ - requestClose: () => void - /** Close just the card (leaving the switcher popover open) — Escape. */ - dismiss: () => void - /** Called after opening a PR / the list, so the switcher can dismiss itself. */ - onActivate: () => void -}) { - const ref = useRef(null) - const [pos, setPos] = useState<{ top: number; left: number } | null>(null) - // biome-ignore lint/correctness/useExhaustiveDependencies: prs changes the measured height - useLayoutEffect(() => { - if (!anchor || !ref.current) return - const r = anchor.getBoundingClientRect() - const card = ref.current.getBoundingClientRect() - const m = 8 // viewport-edge margin - const gap = 6 // space between the badge and the card - // Right-align to the badge (it sits at the row's trailing edge), clamped. - let left = Math.min(r.right - card.width, window.innerWidth - card.width - m) - left = Math.max(m, left) - let top = r.bottom + gap - if (top + card.height > window.innerHeight - m) top = r.top - gap - card.height - top = Math.max(m, Math.min(top, window.innerHeight - card.height - m)) - setPos({ top, left }) - }, [anchor, prs]) - // Keep the card open while the pointer is anywhere in the "safe zone" — the - // badge, the card, or the full-width corridor between them — and close once it - // has left that zone. The badge is small and sits at the card's trailing edge - // while the card is wide and drops to its left, so the pointer travels a - // diagonal to reach a row; tracking the live position (rather than relying on - // mouseenter/leave across the two elements and the gap between them) means no - // travel path, gap, or React-portal event-ordering can dismiss it mid-journey. - useEffect(() => { - const card = ref.current - if (!anchor || !card) return - const onMove = (e: PointerEvent) => { - const a = anchor.getBoundingClientRect() - const c = card.getBoundingClientRect() - const { clientX: x, clientY: y } = e - const pad = 6 // sub-pixel + a little slack so a grazing path still counts - const inRect = (rect: DOMRect) => - x >= rect.left - pad && - x <= rect.right + pad && - y >= rect.top - pad && - y <= rect.bottom + pad - // The corridor spans the card's full width across the gap between the two, - // so any descent into the card crosses it instead of a dead patch (works - // whether the card sits below the badge or, when flipped, above it). - const inCorridor = - x >= c.left - pad && - x <= c.right + pad && - y >= Math.min(a.bottom, c.bottom) - pad && - y <= Math.max(a.top, c.top) + pad - if (inRect(a) || inRect(c) || inCorridor) keepOpen() - else requestClose() - } - document.addEventListener('pointermove', onMove) - return () => document.removeEventListener('pointermove', onMove) - }, [anchor, keepOpen, requestClose]) - // Escape peels just the card, leaving the switcher popover open (a second - // Escape then closes that). Capture-phase + stopPropagation so the popover's - // own window-level Escape doesn't also fire — same layering as ContextMenu. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return - e.stopPropagation() - dismiss() - } - window.addEventListener('keydown', onKey, true) - return () => window.removeEventListener('keydown', onKey, true) - }, [dismiss]) - // More PRs exist than we fetched — offer the host's full, filtered list. - const more = total > prs.length - return createPortal( -
{ - e.preventDefault() - e.stopPropagation() - }} - > -
- {total} pull request{total === 1 ? '' : 's'} -
- {prs.map((pr) => ( - // stopPropagation: the card is portal-rendered but lives in the branch - // row's / pill's React subtree, so without it a click would also fire - // their onClick and switch branch / toggle the popover. - - ))} - {more && githubWebUrl && ( - - )} -
, - document.body - ) -} - /** A branch's PR affordance: a single badge for its most important PR, with a * hovercard (always, one PR or many) listing them all — clickable, counted, and * with a "view all" link when the host has more than we fetched. Renders nothing diff --git a/src/renderer/src/styles/features/toolbar.css b/src/renderer/src/styles/features/toolbar.css index a556adb..ec574fe 100644 --- a/src/renderer/src/styles/features/toolbar.css +++ b/src/renderer/src/styles/features/toolbar.css @@ -327,137 +327,3 @@ 3px 3px 0 0 var(--pr-stack-bg), 3px 3px 0 1px var(--pr-pill-ring); } -/* CI rollup inside the badge: ✓ passing, ✗ failing, or a pulsing dot running. - The state glyph also carries the per-state color (also used in .pr-card, where - it sits outside .branch-pr): merge purple, closed red. */ -.ci-status { - display: inline-flex; - align-items: center; -} -.ci-status--success { - color: var(--st-added); -} -.ci-status--failure { - color: var(--st-deleted); -} -.ci-status--merged { - color: var(--pr-merged); -} -.ci-status--closed { - color: var(--st-deleted); -} -/* Open / draft PR octicons in the hovercard (open is green, a draft is muted). */ -.ci-status--open { - color: var(--st-added); -} -.ci-status--draft { - color: var(--fg-muted); -} -.ci-status--pending { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--st-modified); - animation: ci-pulse 1.3s ease-in-out infinite; -} -@keyframes ci-pulse { - 50% { - opacity: 0.35; - } -} - -/* The hovercard listing a branch's PRs (icon, title, number), shown on hover of - the badge — each row a link. Portal-rendered, so it sits on the tooltip layer - (z 90, above the branch popover) and can't be clipped. - styles: BranchSwitcher.tsx (PrHoverCard) */ -.pr-card { - position: fixed; - z-index: 90; - width: 420px; - max-width: calc(100vw - 16px); - padding: 6px; - background: var(--bg-elevated); - border: 1px solid var(--border-strong); - border-radius: var(--radius); - box-shadow: var(--popover-shadow); - animation: pop-in 0.1s ease-out; -} -.pr-card__head { - padding: 3px 6px 5px; - font-size: 11px; - font-weight: 600; - color: var(--fg-muted); -} -/* Each row is a button — clicking opens that PR in the browser. */ -.pr-card__row { - display: flex; - align-items: center; - gap: 7px; - width: 100%; - padding: 4px 6px; - border: none; - border-radius: var(--radius-sm); - background: none; - color: var(--fg); - font: inherit; - font-size: 12px; - line-height: 1.3; - text-align: left; - cursor: pointer; -} -.pr-card__row:hover { - background: var(--bg-hover); -} -/* Fixed-width glyph + number columns so the titles line up across rows. */ -.pr-card__glyph { - display: inline-flex; - align-items: center; - justify-content: center; - width: 14px; - flex: none; -} -.pr-card__title { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.pr-card__num { - flex: none; - font-variant-numeric: tabular-nums; - font-size: 11px; - color: var(--fg-faint); -} -/* The trailing "open in browser" glyph: faint until the row is hovered, the cue - that the row is a link. */ -.pr-card__open { - flex: none; - color: var(--fg-muted); - opacity: 0; -} -.pr-card__row:hover .pr-card__open { - opacity: 1; -} -/* Footer link shown when the branch has more PRs than the card lists. - Left-aligned like the rows, its content vertically centered. */ -.pr-card__more { - display: flex; - align-items: center; - gap: 5px; - width: 100%; - margin-top: 4px; - padding: 8px 6px; - border: none; - border-top: 1px solid var(--border); - border-radius: 0 0 var(--radius-sm) var(--radius-sm); - background: none; - color: var(--accent); - font: inherit; - font-size: 11.5px; - line-height: 1; - cursor: pointer; -} -.pr-card__more:hover { - background: var(--bg-hover); -} diff --git a/src/renderer/src/styles/primitives.css b/src/renderer/src/styles/primitives.css index 5857122..73e5771 100644 --- a/src/renderer/src/styles/primitives.css +++ b/src/renderer/src/styles/primitives.css @@ -881,3 +881,141 @@ display: inline-flex; pointer-events: none; } + +/* PR state glyphs, shared by the branch switcher's `#123` badge (.branch-pr, + toolbar.css) and the PR hovercard below: the CI rollup (✓ passing, ✗ failing, + or a pulsing dot running) and the per-state octicon colors — merge purple, + closed red. The Graph's canvas chips mirror these colors (graph/prChip.ts). + styles: common/PrHoverCard.tsx */ +.ci-status { + display: inline-flex; + align-items: center; +} +.ci-status--success { + color: var(--st-added); +} +.ci-status--failure { + color: var(--st-deleted); +} +.ci-status--merged { + color: var(--pr-merged); +} +.ci-status--closed { + color: var(--st-deleted); +} +/* Open / draft PR octicons in the hovercard (open is green, a draft is muted). */ +.ci-status--open { + color: var(--st-added); +} +.ci-status--draft { + color: var(--fg-muted); +} +.ci-status--pending { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--st-modified); + animation: ci-pulse 1.3s ease-in-out infinite; +} +@keyframes ci-pulse { + 50% { + opacity: 0.35; + } +} + +/* The hovercard listing a branch's PRs (icon, title, number), shown on hover of + the badge — each row a link. Portal-rendered, so it sits on the tooltip layer + (z 90, above the branch popover) and can't be clipped. Shared by the branch + switcher's badge and the Graph's label chips. + styles: common/PrHoverCard.tsx */ +.pr-card { + position: fixed; + z-index: 90; + width: 420px; + max-width: calc(100vw - 16px); + padding: 6px; + background: var(--bg-elevated); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: var(--popover-shadow); + animation: pop-in 0.1s ease-out; +} +.pr-card__head { + padding: 3px 6px 5px; + font-size: 11px; + font-weight: 600; + color: var(--fg-muted); +} +/* Each row is a button — clicking opens that PR in the browser. */ +.pr-card__row { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + padding: 4px 6px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--fg); + font: inherit; + font-size: 12px; + line-height: 1.3; + text-align: left; + cursor: pointer; +} +.pr-card__row:hover { + background: var(--bg-hover); +} +/* Fixed-width glyph + number columns so the titles line up across rows. */ +.pr-card__glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + flex: none; +} +.pr-card__title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pr-card__num { + flex: none; + font-variant-numeric: tabular-nums; + font-size: 11px; + color: var(--fg-faint); +} +/* The trailing "open in browser" glyph: faint until the row is hovered, the cue + that the row is a link. */ +.pr-card__open { + flex: none; + color: var(--fg-muted); + opacity: 0; +} +.pr-card__row:hover .pr-card__open { + opacity: 1; +} +/* Footer link shown when the branch has more PRs than the card lists. + Left-aligned like the rows, its content vertically centered. */ +.pr-card__more { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + margin-top: 4px; + padding: 8px 6px; + border: none; + border-top: 1px solid var(--border); + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + background: none; + color: var(--accent); + font: inherit; + font-size: 11.5px; + line-height: 1; + cursor: pointer; +} +.pr-card__more:hover { + background: var(--bg-hover); +} From 11f0436a7977849320cfbaca157d988b38b2680a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 21:10:38 +0200 Subject: [PATCH 03/10] Show pull requests on the Graph's branch labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each branch label wears a PR chip — the switcher badge's grammar (CI rollup for open PRs, merged/closed octicon otherwise) drawn inline in the branch's own ink, with an inset chip on the accent HEAD pill. Named branches get the host's live answer, fetched only for labels on screen once the view settles (nothing at all zoomed out past the label layer); landed branches get the PR their merge or squash commit recorded, with no API call. Hovering a chip opens the shared PR card, clicking opens the PR, and the detail pane links the open branch's PRs and a landing commit's PR. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/renderer/src/App.tsx | 18 ++- .../src/components/graph/GraphCanvas.tsx | 137 +++++++++++++--- .../src/components/graph/GraphDetailPane.tsx | 37 ++++- .../src/components/graph/GraphView.tsx | 68 +++++++- .../src/components/graph/geometry.test.ts | 83 +++++++++- src/renderer/src/components/graph/geometry.ts | 73 ++++++++- src/renderer/src/components/graph/layout.ts | 3 + .../src/components/graph/prChip.test.ts | 32 ++++ src/renderer/src/components/graph/prChip.ts | 147 ++++++++++++++++++ src/renderer/src/components/graph/render.ts | 73 ++++++++- .../src/components/graph/rowPrs.test.ts | 100 ++++++++++++ src/renderer/src/components/graph/rowPrs.ts | 60 +++++++ .../src/components/graph/usePrCard.ts | 84 ++++++++++ src/renderer/src/styles/features/graph.css | 8 + src/shared/git-host-urls.test.ts | 9 +- src/shared/git-host-urls.ts | 5 + 16 files changed, 898 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/graph/prChip.test.ts create mode 100644 src/renderer/src/components/graph/prChip.ts create mode 100644 src/renderer/src/components/graph/rowPrs.test.ts create mode 100644 src/renderer/src/components/graph/rowPrs.ts create mode 100644 src/renderer/src/components/graph/usePrCard.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 47a1ac0..8c6bfc2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -42,7 +42,7 @@ import { Toast } from './components/common/Toast' import { TooltipLayer } from './components/common/TooltipLayer' import { GraphDetailPane } from './components/graph/GraphDetailPane' import { GraphView } from './components/graph/GraphView' -import type { GraphRow } from './components/graph/layout' +import { branchKey, type GraphRow } from './components/graph/layout' import { useBranchRange } from './components/graph/useBranchRange' import { CommitSummary } from './components/history/CommitSummary' import { commitMenuItems } from './components/history/commitMenuItems' @@ -58,6 +58,7 @@ import { buildCommitSelection, buildStashSelection } from './lib/commit-selectio import { Icon } from './lib/icons' import { mergeSourceFromDetail } from './lib/merge' import { usePersistentState } from './lib/persist' +import type { BranchPrs } from './lib/pr-order' import { createRepoGeneration } from './lib/repoGeneration' import { useTheme } from './lib/theme' import { useCredentialPrompts } from './lib/useCredentialPrompts' @@ -423,6 +424,11 @@ export function App() { () => new Map() ) + // Each Graph row's PRs keyed by branchKey (reported by GraphView): the + // detail pane lists the open branch's. + const [graphRowPrs, setGraphRowPrs] = useState>(() => new Map()) + const githubWebUrl = hostInfo?.provider === 'github' ? hostInfo.webUrl : null + /** Select a commit, dismissing any open branch-changes view. */ const selectCommitOnly = useCallback( (commit: Commit) => { @@ -1473,7 +1479,7 @@ export function App() { repo={repo} branch={branch} branchesLoading={branchesLoading} - githubWebUrl={hostInfo?.provider === 'github' ? hostInfo.webUrl : null} + githubWebUrl={githubWebUrl} prByBranch={prByBranch} onNeedPrs={(branches, opts) => fetchBranchPrs(repo.path, branches, opts)} busy={busy} @@ -1592,6 +1598,10 @@ export function App() { selectedCommit ? (squashedBranches.get(selectedCommit.hash) ?? []) : [] } onSelectBranch={openGraphBranch} + branchPrs={ + selectedGraphBranch ? graphRowPrs.get(branchKey(selectedGraphBranch)) : undefined + } + githubWebUrl={githubWebUrl} files={branchRange ? rangeFiles : commitFiles} filesLoading={branchRange ? rangeFilesLoading : commitFilesLoading} selectedFilePath={branchRange ? rangeSelPath : commitSelPath} @@ -1647,6 +1657,10 @@ export function App() { selectedBranch={selectedGraphBranch} onSelectBranch={openGraphBranch} onSquashedBranchesChange={setSquashedBranches} + githubWebUrl={githubWebUrl} + prByBranch={prByBranch} + onNeedPrs={(branches, opts) => fetchBranchPrs(repo.path, branches, opts)} + onRowPrsChange={setGraphRowPrs} commitMenuFor={commitMenuFor} onCheckoutBranch={checkout} onBranchAction={onBranchAction} diff --git a/src/renderer/src/components/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index 845f31f..e10381c 100644 --- a/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/src/renderer/src/components/graph/GraphCanvas.tsx @@ -4,8 +4,10 @@ // styles: styles/features/graph.css import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { PrHoverCard } from '@/components/common/PrHoverCard' import { CommitMeta } from '@/components/history/CommitSummary' import { stripCoAuthorTrailers } from '@/lib/coauthors' +import type { BranchPrs } from '@/lib/pr-order' import { reflowMessage } from '@/lib/reflow' import { subscribeAvatars } from './avatars' import { @@ -15,20 +17,24 @@ import { contentSize, HEADER_H, hitTest, + labelRect, MAX_SCALE, MIN_SCALE, NODE_R, neighborNode, nodeX, nodeY, + prChipRect, revealRowDy, rowEndpoint, + rowsWithLabelInView, toWorldX, toWorldY, type View } from './geometry' import { type BranchSelection, + branchKey, type GraphLayout, type GraphNode, type GraphRow, @@ -41,12 +47,14 @@ import { computeDayMarks, drawScene, type GraphPalette, - labelWidthFor, + labelContentWidthFor, + prChipWidthFor, readPalette, SUBJECT_FONT } from './render' import { hitKey, PING_MS, type SearchHit } from './searchGlow' import { usePanInertia } from './usePanInertia' +import { usePrCard } from './usePrCard' import { useZoomAnimation } from './useZoomAnimation' import { isDiscreteWheel, wheelZoomFactor } from './zoom' @@ -82,6 +90,13 @@ interface Props { changesCount: number /** Dashed "same change" links between backport twins (see links.ts). */ links: readonly BackportLink[] + /** Chain id → the PRs its label chip shows (rowPrs.ts). */ + rowPrs: ReadonlyMap + /** The repo's GitHub web base — the PR card's "view all" link. */ + githubWebUrl: string | null + /** The rows whose labels are on screen, reported once the view settles — + * so the host is only ever asked about branches the user can see. */ + onLabelsInView: (rows: GraphRow[]) => void /** Receives the imperative handle (zoom/fit/jump), for the toolbar. */ controls: RefObject onSelectNode: (node: GraphNode | null) => void @@ -98,6 +113,11 @@ interface Props { /** Extra world pixels the user may pan past the diagram's edge. */ const OVERSCROLL = 80 +/** How long the view must rest before the labels on screen are reported + * (onLabelsInView): a pan or zoom sweeping across the diagram asks nothing, + * only where it stops. */ +const LABELS_SETTLE_MS = 250 + interface Tooltip { /** Screen x of the caption's first glyph (the card's text aligns to it). */ x: number @@ -165,6 +185,9 @@ export function GraphCanvas({ hitTags, changesCount, links, + rowPrs, + githubWebUrl, + onLabelsInView, controls, onSelectNode, onNodeMenu, @@ -217,7 +240,8 @@ export function GraphCanvas({ hitTags, wip, dayMarks, - links + links, + rowPrs }) sceneRef.current = { layout, @@ -229,8 +253,14 @@ export function GraphCanvas({ hitTags, wip, dayMarks, - links + links, + rowPrs } + const onLabelsInViewRef = useRef(onLabelsInView) + onLabelsInViewRef.current = onLabelsInView + const labelsSettleRef = useRef | undefined>(undefined) + const prCard = usePrCard() + const closePrCard = prCard.close const draw = useCallback(() => { const canvas = canvasRef.current @@ -259,8 +289,24 @@ export function GraphCanvas({ matchPulse: matchPulseRef.current, wip: s.wip, dayMarks: s.dayMarks, - links: s.links + links: s.links, + rowPrs: s.rowPrs }) + // Every view change and data landing funnels through here, so this one + // debounce covers them all: report the on-screen labels once it rests. + clearTimeout(labelsSettleRef.current) + labelsSettleRef.current = setTimeout(() => { + const size = sizeRef.current + onLabelsInViewRef.current( + rowsWithLabelInView( + sceneRef.current.layout, + viewRef.current, + size.width, + size.height, + labelContentWidthFor + ) + ) + }, LABELS_SETTLE_MS) }, [theme]) const invalidate = useCallback(() => { @@ -274,8 +320,10 @@ export function GraphCanvas({ const clampView = useCallback(() => { const view = viewRef.current const { width, height } = sizeRef.current - const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip?.column ?? null, (row) => - labelWidthFor(row.name) + const cs = contentSize( + sceneRef.current.layout, + sceneRef.current.wip?.column ?? null, + labelContentWidthFor ) const cw = cs.width * view.scale const ch = cs.height * view.scale @@ -303,9 +351,10 @@ export function GraphCanvas({ view.scale = next clampView() setTooltip(null) + closePrCard() invalidate() }, - [clampView, invalidate] + [clampView, invalidate, closePrCard] ) // Discrete zoom steps (mouse-wheel notches, toolbar, +/- keys) glide toward @@ -361,8 +410,10 @@ export function GraphCanvas({ zoomAnim.stop() panInertia.cancel() const { width, height } = sizeRef.current - const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip?.column ?? null, (row) => - labelWidthFor(row.name) + const cs = contentSize( + sceneRef.current.layout, + sceneRef.current.wip?.column ?? null, + labelContentWidthFor ) const view = viewRef.current view.scale = Math.min( @@ -434,8 +485,7 @@ export function GraphCanvas({ // resizes never replay a stale reveal. The immediate call covers the other // orderings — the resize already landed, or none is coming because the pane // was already open (the clicked row was visible, so the pan is zero). - const selectionKey = - selectedHash ?? (selectedBranch ? `${selectedBranch.name}\0${selectedBranch.tipHash}` : null) + const selectionKey = selectedHash ?? (selectedBranch ? branchKey(selectedBranch) : null) useEffect(() => { if (selectionKey === null) return const s = sceneRef.current @@ -538,6 +588,7 @@ export function GraphCanvas({ hitTags, wip, links, + rowPrs, theme, invalidate ]) @@ -569,6 +620,7 @@ export function GraphCanvas({ useEffect( () => () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) + clearTimeout(labelsSettleRef.current) }, [] ) @@ -582,7 +634,7 @@ export function GraphCanvas({ s.layout, toWorldX(view, clientX - rect.left), toWorldY(view, clientY - rect.top), - (row) => labelWidthFor(row.name), + labelContentWidthFor, s.wip ? s.wip.column : null, s.wip ? s.wip.row : -1, // Match the renderer's sticky-label clamp so labels hit where they draw. @@ -595,7 +647,23 @@ export function GraphCanvas({ return screenWidth === undefined ? undefined : screenWidth / view.scale }, // The caption band rides a screen-fixed gap below the capsule. - view.scale + view.scale, + prChipWidthFor + ) + }, []) + + /** A row's PR chip rect in client (viewport) coordinates — the hovercard's + * anchor. Mirrors the renderer: sticky label clamp, measured widths. */ + const chipClientRect = useCallback((row: GraphRow): DOMRect => { + const view = viewRef.current + const wrap = wrapRef.current?.getBoundingClientRect() + const label = labelRect(row, labelContentWidthFor(row), toWorldX(view, 8)) + const chip = prChipRect(label, prChipWidthFor(row)) + return new DOMRect( + (wrap?.left ?? 0) + chip.x * view.scale + view.x, + (wrap?.top ?? 0) + chip.y * view.scale + view.y, + chip.w * view.scale, + chip.h * view.scale ) }, []) @@ -638,10 +706,12 @@ export function GraphCanvas({ return targets }, [tooltip, links, layout]) - /** True when the event happened inside the expanded-message card — it owns - * its own interactions (text selection, body scrolling). */ + /** True when the event happened inside the expanded-message card or the PR + * hovercard — they own their own interactions (text selection, body + * scrolling, link rows). Both are React children of the canvas wrapper + * (the PR card through a portal), so their events bubble here. */ const inTip = (e: { target: EventTarget }) => - (e.target as HTMLElement).closest?.('.graph-tip') != null + (e.target as HTMLElement).closest?.('.graph-tip, .pr-card') != null const onPointerMove = (e: React.PointerEvent) => { if (inTip(e)) return @@ -667,6 +737,7 @@ export function GraphCanvas({ wrapRef.current?.setPointerCapture(e.pointerId) setCursor('grabbing') setHover(null, null) + prCard.close() } if (pan.panned) { panBy(dx, dy) @@ -683,6 +754,8 @@ export function GraphCanvas({ return } const hit = hitAt(e.clientX, e.clientY) + if (hit?.type === 'pr') prCard.hoverChip(hit.row, () => chipClientRect(hit.row)) + else prCard.leaveChip() if (hit?.type === 'node') { // Anchor the expansion card on the caption's exact glyph position, so // the truncated text appears to complete itself in place (captions and @@ -718,10 +791,19 @@ export function GraphCanvas({ if (!hit) onSelectNode(null) else if (hit.type === 'node') onSelectNode(hit.node) else if (hit.type === 'wip') onWipClick() + // The PR chip is a link: straight to the PR it shows, on the host. + else if (hit.type === 'pr') openRowPr(hit.row) // A branch label or its container capsule opens the branch's changes. else onRowClick(hit.row) } + const openRowPr = (row: GraphRow) => { + const url = rowPrs.get(row.chain)?.prs[0]?.url + if (!url) return + prCard.close() + window.gitgrove.openExternal(url) + } + // Touch input ends an aborted gesture with pointercancel, never pointerup // (e.g. the OS claims the touch). Without this the drag state would zombie // until the next pointerdown. The gesture was taken from the user, so the @@ -741,7 +823,7 @@ export function GraphCanvas({ if (hit?.type === 'node') { onSelectNode(hit.node) onNodeMenu(hit.node, e.clientX, e.clientY) - } else if (hit?.type === 'label' || hit?.type === 'row') { + } else if (hit?.type === 'label' || hit?.type === 'row' || hit?.type === 'pr') { // Match the node behaviour: right-click selects what it targets (here, // opening the branch-changes view), so the menu always acts on the // thing the user is looking at. @@ -753,6 +835,8 @@ export function GraphCanvas({ const onDoubleClick = (e: React.MouseEvent) => { if (inTip(e)) return const hit = hitAt(e.clientX, e.clientY) + // (A PR chip's double-click already opened the PR on its first click.) + if (hit?.type === 'pr') return if (hit?.type === 'label') onRowDoubleClick(hit.row) else if (!hit) { const rect = wrapRef.current?.getBoundingClientRect() @@ -782,6 +866,7 @@ export function GraphCanvas({ zoomAnim.stop() panBy(-e.deltaX, -e.deltaY) setTooltip(null) + prCard.close() } } @@ -878,7 +963,10 @@ export function GraphCanvas({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} - onPointerLeave={() => setHover(null, null)} + onPointerLeave={() => { + setHover(null, null) + prCard.leaveChip() + }} onContextMenu={onContextMenu} onDoubleClick={onDoubleClick} onWheel={onWheel} @@ -929,6 +1017,19 @@ export function GraphCanvas({ )} )} + {prCard.card && ( + + )} ) } diff --git a/src/renderer/src/components/graph/GraphDetailPane.tsx b/src/renderer/src/components/graph/GraphDetailPane.tsx index 4466b85..8972370 100644 --- a/src/renderer/src/components/graph/GraphDetailPane.tsx +++ b/src/renderer/src/components/graph/GraphDetailPane.tsx @@ -5,12 +5,13 @@ // files panel, arranged for the graph's sidebar. // styles: styles/features/graph.css -import type { ChangedFile, Commit } from '@shared/types' +import type { ChangedFile, Commit, PullRequestInfo } from '@shared/types' import { useEffect } from 'react' import { useAiExplainCommit } from '@/components/common/AiExplainCommit' import { copyPathItems } from '@/components/common/copyPathItems' import { useFileFilter } from '@/components/common/FileFilter' import { type FileHistoryMode, fileHistoryItems } from '@/components/common/fileHistoryItems' +import { PrRow } from '@/components/common/PrHoverCard' import { WorkingFileList } from '@/components/common/WorkingFileList' import { AvatarStack } from '@/components/history/AvatarStack' import { @@ -23,8 +24,11 @@ import { import { coAuthorsOf } from '@/lib/coauthors' import { pluralize } from '@/lib/format' import { Icon } from '@/lib/icons' +import type { BranchPrs } from '@/lib/pr-order' import { useSpinDelay } from '@/lib/useSpinDelay' +import { landedPrOf } from './landedPr' import type { GraphRow } from './layout' +import { landedPrInfo } from './rowPrs' import type { BranchRange } from './useBranchRange' interface Props { @@ -38,6 +42,10 @@ interface Props { squashedBranches: readonly GraphRow[] /** Open a branch's whole-branch changes view. */ onSelectBranch: (row: GraphRow) => void + /** The open branch's PRs (its label chip's), when it has any. */ + branchPrs: BranchPrs | undefined + /** The repo's GitHub web base, or null off GitHub (no PR links). */ + githubWebUrl: string | null files: ChangedFile[] filesLoading: boolean selectedFilePath: string | null @@ -55,15 +63,20 @@ function CommitHead({ repoPath, squashedBranches, onSelectBranch, + githubWebUrl, onSetupAi }: { commit: Commit repoPath: string squashedBranches: readonly GraphRow[] onSelectBranch: (row: GraphRow) => void + githubWebUrl: string | null onSetupAi: () => void }) { const explain = useAiExplainCommit({ repoPath, hash: commit.hash, onSetupAi }) + // A commit that landed a PR links to it — one click from the graph to the + // review conversation that produced it. + const landed = githubWebUrl ? landedPrOf(commit) : null return (
@@ -78,6 +91,8 @@ function CommitHead({
+ {/* Compact: the commit's own subject and body already carry the title. */} + {landed && githubWebUrl && } {/* Keyed by hash: switching commits remounts the body, resetting its collapse state and re-probing overflow (see CommitBody). */} @@ -117,7 +132,19 @@ function SquashNote({ ) } -function RangeHead({ range }: { range: BranchRange }) { +/** A branch's (or a landing commit's) pull requests as link rows — the PR + * hovercard's rows, laid into the pane. */ +function PrList({ prs, compact }: { prs: readonly PullRequestInfo[]; compact?: boolean }) { + return ( +
+ {prs.map((pr) => ( + + ))} +
+ ) +} + +function RangeHead({ range, prs }: { range: BranchRange; prs: BranchPrs | undefined }) { return (
@@ -141,6 +168,7 @@ function RangeHead({ range }: { range: BranchRange }) { )}
+ {prs && }
) } @@ -151,6 +179,8 @@ export function GraphDetailPane({ range, squashedBranches, onSelectBranch, + branchPrs, + githubWebUrl, files, filesLoading, selectedFilePath, @@ -190,13 +220,14 @@ export function GraphDetailPane({ return (
{range ? ( - + ) : commit ? ( ) : null} diff --git a/src/renderer/src/components/graph/GraphView.tsx b/src/renderer/src/components/graph/GraphView.tsx index b130106..c9cfc3a 100644 --- a/src/renderer/src/components/graph/GraphView.tsx +++ b/src/renderer/src/components/graph/GraphView.tsx @@ -6,16 +6,18 @@ // styles: styles/features/graph.css import type { BranchInfo, Commit } from '@shared/types' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ContextMenu, type ContextMenuItem } from '@/components/common/ContextMenu' import type { BranchAction } from '@/components/toolbar/BranchSwitcher' import { filterTerms } from '@/lib/commitFilter' import { Icon } from '@/lib/icons' import { usePersistentState } from '@/lib/persist' +import type { BranchPrs } from '@/lib/pr-order' import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas' import { type AuthorOption, DATE_PRESETS, type DatePresetId, GraphToolbar } from './GraphToolbar' import { type BranchSelection, + branchKey, collectBranchNames, type GraphNode, type GraphRow, @@ -24,6 +26,7 @@ import { import { linkableChains, twinHashes } from './links' import { relatedBranches } from './related' import { releaseLineVersion, releaseVersionWithOverride } from './releases' +import { isPrLookupRow, rowPullRequests } from './rowPrs' import { computeSearchHits } from './searchGlow' import { squashedBranchesByLanding } from './squash' import { useBackportLinks } from './useBackportLinks' @@ -57,6 +60,16 @@ interface Props { /** Landing commit → branches squashed into it, re-reported per layout — * what the detail pane's "Squash of …" names. */ onSquashedBranchesChange: (byLanding: ReadonlyMap) => void + /** The repo's GitHub web base, or null off GitHub (no PR chips at all). */ + githubWebUrl: string | null + /** Head branch → its PRs, as fetched so far (usePullRequests). */ + prByBranch: ReadonlyMap + /** Ask the host for these branches' PRs (cached ones are skipped unless + * `revalidate`). */ + onNeedPrs: (branches: string[], opts?: { revalidate?: boolean }) => void + /** Each row's PRs keyed by branchKey, re-reported per layout — what the + * detail pane lists for the open branch. */ + onRowPrsChange: (byBranch: ReadonlyMap) => void onError: (e: unknown) => void } @@ -77,6 +90,10 @@ export function GraphView({ onBranchAction, onOpenChanges, onSquashedBranchesChange, + githubWebUrl, + prByBranch, + onNeedPrs, + onRowPrsChange, onError }: Props) { const [branchFilter, setBranchFilter] = useState | null>(null) @@ -140,9 +157,10 @@ export function GraphView({ detached: branch?.detached ?? false, defaultBranch: branch?.defaultBranch ?? null, releaseOverrides, - squashLandings + squashLandings, + reservePrChips: githubWebUrl !== null }), - [commits, remotes, branch, releaseOverrides, squashLandings] + [commits, remotes, branch, releaseOverrides, squashLandings, githubWebUrl] ) const branches = useMemo(() => collectBranchNames(input), [input]) const layout = useMemo( @@ -163,6 +181,47 @@ export function GraphView({ [layout, onSquashedBranchesChange] ) + // PR chips: the host's answer for named branches, history's for landed ones. + const rowPrs = useMemo( + () => rowPullRequests(layout.rows, prByBranch, githubWebUrl), + [layout, prByBranch, githubWebUrl] + ) + useEffect(() => { + const byBranch = new Map() + for (const row of layout.rows) { + const prs = rowPrs.get(row.chain) + if (prs) byBranch.set(branchKey({ name: row.name, tipHash: row.tipHash }), prs) + } + onRowPrsChange(byBranch) + }, [layout, rowPrs, onRowPrsChange]) + + // Ask the host about the branches whose labels are on screen — the canvas + // reports them once the view settles, so a 25k-branch repo only ever looks + // up what the user stops on (and nothing at all zoomed out past the labels). + // The first report after the tab opens revalidates: open PRs' state and CI + // move on while the user is elsewhere. + const revalidateRef = useRef(true) + useEffect(() => { + if (active) revalidateRef.current = true + }, [active]) + const activeRef = useRef(active) + activeRef.current = active + // Every redraw re-reports the settled view (a PR landing redraws it too), so + // an unchanged set is dropped here rather than re-filtered against the cache. + const lastAskedRef = useRef('') + const onLabelsInView = useCallback( + (rows: GraphRow[]) => { + if (!githubWebUrl || !activeRef.current) return + const names = [...new Set(rows.filter(isPrLookupRow).map((row) => row.name))] + const key = names.join('\0') + if (names.length === 0 || (key === lastAskedRef.current && !revalidateRef.current)) return + lastAskedRef.current = key + onNeedPrs(names, { revalidate: revalidateRef.current }) + revalidateRef.current = false + }, + [githubWebUrl, onNeedPrs] + ) + const authors = useMemo((): AuthorOption[] => { const byEmail = new Map() for (const commit of commits) { @@ -422,6 +481,9 @@ export function GraphView({ hitTags={hitTags} changesCount={changesCount} links={links} + rowPrs={rowPrs} + githubWebUrl={githubWebUrl} + onLabelsInView={onLabelsInView} controls={controls} onSelectNode={(node) => onSelectCommit(node ? node.commit : null)} onNodeMenu={openNodeMenu} diff --git a/src/renderer/src/components/graph/geometry.test.ts b/src/renderer/src/components/graph/geometry.test.ts index 3d48a49..7b92674 100644 --- a/src/renderer/src/components/graph/geometry.test.ts +++ b/src/renderer/src/components/graph/geometry.test.ts @@ -14,13 +14,19 @@ import { hitTest, LABEL_GAP, LABEL_H, + LABEL_MIN_SCALE, + labelContentWidth, + labelRect, NODE_R, neighborNode, nodeX, nodeY, + PR_CHIP_H, + prChipRect, ROW_H, revealRowDy, - rowEndpoint + rowEndpoint, + rowsWithLabelInView } from './geometry' import { type GraphInput, layoutGraph } from './layout' @@ -360,3 +366,78 @@ describe('graph geometry', () => { expect(contentSize(layout, null, () => 10)).toEqual(contentSize(layout, null)) }) }) + +describe('PR chips', () => { + const feature = () => { + const layout = sampleLayout() + const row = layout.rows.find((r) => r.name === 'feature') + if (!row) throw new Error('missing row') + return { layout, row } + } + + test('the chip nests in the pill right end with an even inset', () => { + const { row } = feature() + const label = labelRect(row, labelContentWidth(40, 30)) + const chip = prChipRect(label, 30) + const inset = (LABEL_H - PR_CHIP_H) / 2 + expect(chip.y - label.y).toBe(inset) + expect(label.x + label.w - (chip.x + chip.w)).toBe(inset) + // The name keeps the pill's left padding and ends before the chip. + expect(chip.x).toBeGreaterThan(label.x + 8 + 40) + }) + + test('a label without a chip is just its name', () => { + expect(labelContentWidth(40, 0)).toBe(40) + }) + + test('the chip is its own hit target, the name stays the label', () => { + const { layout, row } = feature() + const width = labelContentWidth(40, 30) + const label = labelRect(row, width) + const chip = prChipRect(label, 30) + const at = (x: number) => + hitTest( + layout, + x, + label.y + label.h / 2, + (r) => (r === row ? width : 40), + null, + -1, + Number.NEGATIVE_INFINITY, + false, + undefined, + 1, + (r) => (r === row ? 30 : 0) + ) + expect(at(chip.x + 4)).toMatchObject({ type: 'pr', row }) + expect(at(label.x + 12)).toMatchObject({ type: 'label', row }) + }) +}) + +describe('rowsWithLabelInView', () => { + const view = { x: 0, y: 0, scale: 1 } + + test('finds the labels inside the viewport', () => { + const layout = sampleLayout() + const names = rowsWithLabelInView(layout, view, 800, 600, () => 40).map((r) => r.name) + expect(names.sort()).toEqual(['feature', 'main']) + }) + + test('skips labels scrolled out of view', () => { + const layout = sampleLayout() + // Panned far right: every label sits left of the viewport — but a sticky + // label rides the edge while its row is still on screen, so pan past the + // rows' ends too. + const panned = { x: -5000, y: 0, scale: 1 } + expect(rowsWithLabelInView(layout, panned, 800, 600, () => 40)).toEqual([]) + // Scrolled below every row. + const below = { x: 0, y: -5000, scale: 1 } + expect(rowsWithLabelInView(layout, below, 800, 600, () => 40)).toEqual([]) + }) + + test('asks about nothing while zoomed out past the label layer', () => { + const layout = sampleLayout() + const far = { x: 0, y: 0, scale: LABEL_MIN_SCALE - 0.01 } + expect(rowsWithLabelInView(layout, far, 8000, 6000, () => 40)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/graph/geometry.ts b/src/renderer/src/components/graph/geometry.ts index ed18a25..dbfbfab 100644 --- a/src/renderer/src/components/graph/geometry.ts +++ b/src/renderer/src/components/graph/geometry.ts @@ -23,6 +23,26 @@ export const HEADER_H = 26 /** Branch label pill: height and its gap above the row spine. */ export const LABEL_H = 18 export const LABEL_GAP = 4 +/** Label pill: horizontal padding either side of its content. */ +export const LABEL_PAD_X = 8 +/** Below this zoom branch labels aren't drawn (an overview reads by shape, + * not by name) — and so they neither hit-test nor ask the host for PRs. */ +export const LABEL_MIN_SCALE = 0.4 +/** PR chip inside a label pill (render.ts drawPrChip): the air between the + * branch name and the chip, and the chip's own height. The chip nests in the + * pill's right end with the same inset on every side it touches — a pill in + * a pill, not a chip floating in padding. */ +export const PR_CHIP_GAP = 5 +export const PR_CHIP_H = 14 +const PR_CHIP_INSET = (LABEL_H - PR_CHIP_H) / 2 + +/** A label's content width: the branch name, plus its PR chip when it has + * one (0 = none). The chip replaces the pill's right padding with its inset, + * so labelRect's symmetric padding still frames it exactly. */ +export function labelContentWidth(nameWidth: number, chipWidth: number): number { + if (chipWidth <= 0) return nameWidth + return nameWidth + PR_CHIP_GAP + chipWidth - (LABEL_PAD_X - PR_CHIP_INSET) +} /** Branch container capsule: horizontal padding past the outer nodes, and * half its height. Shared by the renderer and hit-testing — the capsule is * itself a click target (it IS the branch). */ @@ -170,7 +190,7 @@ export function labelRect( textWidth: number, leftClampX = Number.NEGATIVE_INFINITY ): { x: number; y: number; w: number; h: number } { - const w = textWidth + 16 + const w = textWidth + 2 * LABEL_PAD_X const restX = nodeX(row.startColumn) - NODE_R const maxX = Math.max(restX, nodeX(row.endColumn) + NODE_R - w) return { @@ -205,9 +225,50 @@ export function revealRowDy(view: View, viewportHeight: number, row: number): nu return 0 } +/** The PR chip's rect inside its label pill: flush with the pill's right + * padding, vertically centered. `chipWidth` is the chip's own width (the + * label's content width already counts it plus PR_CHIP_GAP). */ +export function prChipRect( + label: { x: number; y: number; w: number; h: number }, + chipWidth: number +): { x: number; y: number; w: number; h: number } { + return { + x: label.x + label.w - PR_CHIP_INSET - chipWidth, + y: label.y + PR_CHIP_INSET, + w: chipWidth, + h: PR_CHIP_H + } +} + +/** + * Rows whose label pill is on screen at this view — the branches worth asking + * the host about. Mirrors what the renderer draws: nothing below + * LABEL_MIN_SCALE, and sticky pills where they ride the left edge. + */ +export function rowsWithLabelInView( + layout: GraphLayout, + view: View, + viewportWidth: number, + viewportHeight: number, + labelWidth: (row: GraphRow) => number +): GraphRow[] { + if (view.scale < LABEL_MIN_SCALE) return [] + const x0 = toWorldX(view, 0) + const x1 = toWorldX(view, viewportWidth) + const y0 = toWorldY(view, HEADER_H) + const y1 = toWorldY(view, viewportHeight) + const leftClamp = toWorldX(view, 8) + return layout.rows.filter((row) => { + const rect = labelRect(row, labelWidth(row), leftClamp) + return rect.x < x1 && rect.x + rect.w > x0 && rect.y < y1 && rect.y + rect.h > y0 + }) +} + export type GraphHit = | { type: 'node'; node: GraphNode } | { type: 'label'; row: GraphRow } + /** The PR chip inside a branch label — opens the row's pull request. */ + | { type: 'pr'; row: GraphRow } /** The branch container capsule — selects the branch, like its label. */ | { type: 'row'; row: GraphRow } | { type: 'wip' } @@ -235,7 +296,9 @@ export function hitTest( drawnCaptionWidth?: (node: GraphNode) => number | undefined, /** The view's zoom — captions anchor a screen-fixed gap below the capsule, * so their world-space hit band depends on it (captionCenterOffset). */ - scale = 1 + scale = 1, + /** A label's PR chip width (0 = no chip) — the chip is its own target. */ + prChipWidth: (row: GraphRow) => number = () => 0 ): GraphHit | null { const slop = 4 const row = Math.floor((wy - MARGIN_Y) / ROW_H) @@ -255,7 +318,11 @@ export function hitTest( for (const r of layout.rows) { const rect = labelRect(r, labelWidth(r), labelLeftClampX) if (wx >= rect.x && wx <= rect.x + rect.w && wy >= rect.y && wy <= rect.y + rect.h) { - return { type: 'label', row: r } + // The chip claims its half of the gap to the name, so no dead strip + // between the two targets. + const chipWidth = prChipWidth(r) + const onChip = chipWidth > 0 && wx >= prChipRect(rect, chipWidth).x - PR_CHIP_GAP / 2 + return { type: onChip ? 'pr' : 'label', row: r } } } // A node's caption acts as the node: hovering it expands the message, diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index da0a04b..86ffc57 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -90,6 +90,9 @@ export interface BranchSelection { tipHash: string } +/** A stable string key for a BranchSelection (maps can't key on the pair). */ +export const branchKey = (sel: BranchSelection): string => `${sel.name}\0${sel.tipHash}` + /** True when `row` is the branch `sel` names — see BranchSelection. */ export const rowMatchesSelection = (row: GraphRow, sel: BranchSelection | null): boolean => sel !== null && row.tipHash === sel.tipHash && row.name === sel.name diff --git a/src/renderer/src/components/graph/prChip.test.ts b/src/renderer/src/components/graph/prChip.test.ts new file mode 100644 index 0000000..3796f95 --- /dev/null +++ b/src/renderer/src/components/graph/prChip.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test' +import type { PullRequestInfo } from '@shared/types' +import { prChipGlyph } from './prChip' + +function pr(overrides: Partial): PullRequestInfo { + return { + number: 1, + state: 'open', + title: 't', + url: 'u', + draft: false, + headBranch: 'b', + baseBranch: 'main', + isCrossRepo: false, + checks: null, + ...overrides + } +} + +describe('prChipGlyph', () => { + test('an open PR leads with its CI rollup, or nothing when no checks ran', () => { + expect(prChipGlyph(pr({ checks: 'success' }))).toBe('success') + expect(prChipGlyph(pr({ checks: 'failure' }))).toBe('failure') + expect(prChipGlyph(pr({ checks: 'pending' }))).toBe('pending') + expect(prChipGlyph(pr({ checks: null, draft: true }))).toBeNull() + }) + + test('a settled PR leads with its state octicon, never stale CI', () => { + expect(prChipGlyph(pr({ state: 'merged', checks: 'failure' }))).toBe('merged') + expect(prChipGlyph(pr({ state: 'closed' }))).toBe('closed') + }) +}) diff --git a/src/renderer/src/components/graph/prChip.ts b/src/renderer/src/components/graph/prChip.ts new file mode 100644 index 0000000..56345d2 --- /dev/null +++ b/src/renderer/src/components/graph/prChip.ts @@ -0,0 +1,147 @@ +// The PR chip a branch label wears: the canvas twin of the branch switcher's +// `#123` badge (.branch-pr in toolbar.css), so a PR reads the same everywhere +// in the app — only the leading glyph carries meaning: the CI rollup for an +// open PR (✓ passing, ✗ failing, amber dot running; nothing when no checks +// ran), GitHub's merged / closed octicon otherwise. Drawn in WORLD space inside +// the label pill, so it zooms with it. +// +// Unlike the badge it wears no gray pill of its own: on a tinted branch label a +// neutral patch reads as a foreign sticker, worst at small zoom where it blurs +// into a gray smudge. The label already sits on an opaque, near-background base +// (a 15% hue tint), so the mid-luminance state glyphs contrast on it for every +// branch hue — the chip is just a hairline divider plus the glyph and a number +// in the branch's own ink. The one exception is the HEAD label: a solid accent +// fill that would swallow a green ✓, so there the chip insets a small +// label-surface pill to give the glyphs their ground back. + +import type { PullRequestInfo } from '@shared/types' +import { PR_CHIP_GAP, PR_CHIP_H } from './geometry' + +/** The chip's slice of the graph palette (render.ts readPalette). */ +export interface PrChipColors { + font: string + /** The label surface (--bg-elevated): the HEAD chip's inset pill. */ + surface: string + /** The inset chip's number ink. */ + text: string + success: string + failure: string + pending: string + merged: string +} + +const CHIP_FONT = 10 +const PAD_X = 4 +const GLYPH = 9 +const GLYPH_GAP = 2.5 + +// GitHub's merged / closed pull-request octicons on their 16-unit grid — the +// same paths as Icon.PrMerged / Icon.PrClosed (lib/icons.tsx). Built lazily: +// Path2D doesn't exist outside a browser (tests import this module). +const MERGED_D = + 'M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z' +const CLOSED_D = + 'M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm0 11a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0-9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM11.25 9.5a.75.75 0 0 1 .75.75v.378a2.251 2.251 0 1 1-1.5 0V10.25a.75.75 0 0 1 .75-.75Zm0 4a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM9.22 1.227a.75.75 0 0 1 1.06 0l.97.97.97-.97a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l.97-.97-.97-.97a.75.75 0 0 1 0-1.06Z' +let octicons: { merged: Path2D; closed: Path2D } | null = null + +type Glyph = 'success' | 'failure' | 'pending' | 'merged' | 'closed' | null + +/** The leading glyph for a PR — the badge's rule (BranchSwitcher PrGlyph). */ +export function prChipGlyph(pr: PullRequestInfo): Glyph { + if (pr.state === 'open') return pr.checks + return pr.state +} + +const chipFont = (family: string) => `500 ${CHIP_FONT}px ${family}` + +/** How the chip sits in its label: `inline` on a tinted branch pill (divider, + * branch-ink number), `inset` on the solid HEAD pill (its own surface pill). */ +export type PrChipStyle = { kind: 'inline'; ink: string; divider: string } | { kind: 'inset' } + +/** The chip's width for `pr`, text measured in the chip font. */ +export function measurePrChip(ctx: CanvasRenderingContext2D, family: string, pr: PullRequestInfo) { + ctx.font = chipFont(family) + const text = ctx.measureText(`#${pr.number}`).width + return PAD_X * 2 + text + (prChipGlyph(pr) ? GLYPH + GLYPH_GAP : 0) +} + +/** Paint the chip for `pr` into `rect` (world space, from geometry prChipRect). */ +export function drawPrChip( + ctx: CanvasRenderingContext2D, + rect: { x: number; y: number; w: number }, + pr: PullRequestInfo, + colors: PrChipColors, + style: PrChipStyle +): void { + if (style.kind === 'inset') { + ctx.beginPath() + ctx.roundRect(rect.x, rect.y, rect.w, PR_CHIP_H, PR_CHIP_H / 2 - 1) + ctx.fillStyle = colors.surface + ctx.fill() + } else { + // The hairline sits in the gap before the chip, a touch shorter than it. + const x = Math.round(rect.x - PR_CHIP_GAP / 2) + 0.5 + ctx.beginPath() + ctx.moveTo(x, rect.y + 2) + ctx.lineTo(x, rect.y + PR_CHIP_H - 2) + ctx.strokeStyle = style.divider + ctx.lineWidth = 1 + ctx.stroke() + } + + const midY = rect.y + PR_CHIP_H / 2 + let x = rect.x + PAD_X + const glyph = prChipGlyph(pr) + if (glyph) { + drawGlyph(ctx, glyph, x, midY - GLYPH / 2, colors) + x += GLYPH + GLYPH_GAP + } + ctx.font = chipFont(colors.font) + ctx.textBaseline = 'middle' + ctx.textAlign = 'left' + ctx.fillStyle = style.kind === 'inset' ? colors.text : style.ink + ctx.fillText(`#${pr.number}`, x, midY + 0.5) +} + +/** A GLYPH-sized state mark with its top-left at (x, y). */ +function drawGlyph( + ctx: CanvasRenderingContext2D, + glyph: Exclude, + x: number, + y: number, + colors: PrChipColors +): void { + ctx.save() + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.lineWidth = 1.5 + const s = GLYPH + if (glyph === 'success') { + ctx.strokeStyle = colors.success + ctx.beginPath() + ctx.moveTo(x + s * 0.12, y + s * 0.55) + ctx.lineTo(x + s * 0.4, y + s * 0.82) + ctx.lineTo(x + s * 0.9, y + s * 0.22) + ctx.stroke() + } else if (glyph === 'failure') { + ctx.strokeStyle = colors.failure + ctx.beginPath() + ctx.moveTo(x + s * 0.2, y + s * 0.2) + ctx.lineTo(x + s * 0.8, y + s * 0.8) + ctx.moveTo(x + s * 0.8, y + s * 0.2) + ctx.lineTo(x + s * 0.2, y + s * 0.8) + ctx.stroke() + } else if (glyph === 'pending') { + ctx.fillStyle = colors.pending + ctx.beginPath() + ctx.arc(x + s / 2, y + s / 2, s * 0.33, 0, Math.PI * 2) + ctx.fill() + } else { + octicons ??= { merged: new Path2D(MERGED_D), closed: new Path2D(CLOSED_D) } + ctx.fillStyle = glyph === 'merged' ? colors.merged : colors.failure + ctx.translate(x, y) + ctx.scale(s / 16, s / 16) + ctx.fill(glyph === 'merged' ? octicons.merged : octicons.closed) + } + ctx.restore() +} diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index da0cac6..223a13f 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -9,7 +9,9 @@ // SOURCE branch's color (what flowed in), fork edges the NEW branch's color // (what split off). +import type { PullRequestInfo } from '@shared/types' import { avatarColor, initials } from '@/lib/avatar' +import type { BranchPrs } from '@/lib/pr-order' import { avatarImageFor } from './avatars' import { CAPSULE_HALF_H, @@ -22,11 +24,15 @@ import { captionCenterOffset, columnsToNext, HEADER_H, + LABEL_MIN_SCALE, + LABEL_PAD_X, + labelContentWidth, labelRect, MARGIN_X, NODE_R, nodeX, nodeY, + prChipRect, toWorldX, type View } from './geometry' @@ -39,6 +45,7 @@ import { rowMatchesSelection } from './layout' import { type BackportLink, linkedHashes } from './links' +import { drawPrChip, measurePrChip, type PrChipColors } from './prChip' import { ACTIVE_GLOW, HIT_GLOW, @@ -62,6 +69,7 @@ export interface GraphPalette { subject: string labelBg: string tag: string + prChip: PrChipColors } /** Resolve the palette from the CSS design tokens on `el`'s computed style. */ @@ -81,7 +89,16 @@ export function readPalette(el: HTMLElement, dark: boolean): GraphPalette { match: token('--st-modified'), subject: token('--fg-muted'), labelBg: token('--bg-elevated'), - tag: token('--pr-merged') + tag: token('--pr-merged'), + prChip: { + font: css.fontFamily, + surface: token('--bg-elevated'), + text: token('--fg-muted'), + success: token('--st-added'), + failure: token('--st-deleted'), + pending: token('--st-modified'), + merged: token('--pr-merged') + } } } @@ -170,6 +187,9 @@ export interface SceneState { dayMarks: DayMark[] /** Dashed "same change" links between backport twins (see links.ts). */ links: readonly BackportLink[] + /** Chain id → the PRs its label chip shows (rowPrs.ts); the chip draws the + * most important one. */ + rowPrs: ReadonlyMap } const LABEL_FONT = 11 @@ -229,11 +249,25 @@ export function captionMetrics(fontFamily: string): CaptionMetrics { // Measured pill-text widths, shared with hit-testing (see labelWidthFor). const labelWidths = new Map() +// Measured PR chip widths by chain, as last drawn (absent = no chip). +const prChipWidths = new Map() + /** Width of a row's label text as last measured; an estimate before first draw. */ export function labelWidthFor(name: string): number { return labelWidths.get(name) ?? name.length * 6.2 } +/** Width of a row's PR chip as last drawn; 0 when it has none. */ +export function prChipWidthFor(row: GraphRow): number { + return prChipWidths.get(row.chain) ?? 0 +} + +/** A row's full label content — name plus PR chip — as last drawn; what + * hit-testing and pan/fit bounds must agree with. */ +export function labelContentWidthFor(row: GraphRow): number { + return labelContentWidth(labelWidthFor(row.name), prChipWidthFor(row)) +} + // Caption widths (SCREEN px) as last drawn, keyed by commit hash; 0 = culled. const captionWidths = new Map() @@ -265,20 +299,27 @@ interface LabelBox { row: GraphRow rect: { x: number; y: number; w: number; h: number } sticky: boolean + /** The PR its chip shows, with the chip's measured width. */ + pr: { info: PullRequestInfo; width: number } | null } /** Measure every label and resolve its (possibly sticky) rect for this frame. * Runs before nodes draw, so tag chips can yield to overlapping labels. */ function computeLabelBoxes(ctx: CanvasRenderingContext2D, scene: SceneState): LabelBox[] { const { palette, view } = scene - if (view.scale < 0.4) return [] + if (view.scale < LABEL_MIN_SCALE) return [] const leftClamp = toWorldX(view, 8) - ctx.font = `600 ${LABEL_FONT}px ${palette.font}` return scene.layout.rows.map((row) => { + // Per row: measuring a PR chip switches to the chip font. + ctx.font = `600 ${LABEL_FONT}px ${palette.font}` const width = ctx.measureText(row.name).width labelWidths.set(row.name, width) - const rect = labelRect(row, width, leftClamp) - return { row, rect, sticky: rect.x > nodeX(row.startColumn) - NODE_R + 0.5 } + const info = scene.rowPrs.get(row.chain)?.prs[0] + const pr = info ? { info, width: measurePrChip(ctx, palette.font, info) } : null + if (pr) prChipWidths.set(row.chain, pr.width) + else prChipWidths.delete(row.chain) + const rect = labelRect(row, labelContentWidth(width, pr?.width ?? 0), leftClamp) + return { row, rect, sticky: rect.x > nodeX(row.startColumn) - NODE_R + 0.5, pr } }) } @@ -1020,10 +1061,9 @@ function drawLabels( lit: ReadonlySet | null ): void { const { palette } = scene - ctx.font = `600 ${LABEL_FONT}px ${palette.font}` ctx.textBaseline = 'middle' ctx.textAlign = 'left' - for (const { row, rect, sticky } of labelBoxes) { + for (const { row, rect, sticky, pr } of labelBoxes) { const head = row.isHead // While a filter/search dims commits, labels of hitless branches ghost // with them — a full-strength label over dimmed commits would claim a @@ -1078,8 +1118,25 @@ function drawLabels( ctx.stroke() ctx.setLineDash([]) } + // Per label: the previous label's PR chip left its own font set. + ctx.font = `600 ${LABEL_FONT}px ${palette.font}` ctx.fillStyle = head ? palette.onAccent : branchText(palette, row.color) - ctx.fillText(row.name, rect.x + 8, rect.y + rect.h / 2 + 0.5) + ctx.fillText(row.name, rect.x + LABEL_PAD_X, rect.y + rect.h / 2 + 0.5) + if (pr) { + drawPrChip( + ctx, + prChipRect(rect, pr.width), + pr.info, + palette.prChip, + head + ? { kind: 'inset' } + : { + kind: 'inline', + ink: branchFill(palette, row.color, 0.9), + divider: branchFill(palette, row.color, 0.35) + } + ) + } if (isActiveHit) drawRectPing(ctx, scene, rect, 5) ctx.globalAlpha = 1 } diff --git a/src/renderer/src/components/graph/rowPrs.test.ts b/src/renderer/src/components/graph/rowPrs.test.ts new file mode 100644 index 0000000..75cd1c2 --- /dev/null +++ b/src/renderer/src/components/graph/rowPrs.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test' +import type { PullRequestInfo } from '@shared/types' +import type { BranchPrs } from '@/lib/pr-order' +import type { GraphRow } from './layout' +import { isPrLookupRow, rowPullRequests } from './rowPrs' + +const WEB = 'https://github.com/octocat/hello' + +function row(chain: number, name: string, overrides: Partial = {}): GraphRow { + return { + chain, + index: chain, + name, + kind: 'branch', + isHead: false, + tipHash: `tip${chain}`, + baseHash: null, + upstreamHash: null, + empty: false, + color: 1, + startColumn: 0, + endColumn: 0, + landedPr: null, + ...overrides + } +} + +function hostPr(number: number, headBranch: string): PullRequestInfo { + return { + number, + state: 'open', + title: `PR ${number}`, + url: `${WEB}/pull/${number}`, + draft: false, + headBranch, + baseBranch: 'main', + isCrossRepo: false, + checks: 'success' + } +} + +const hosted = (entries: [string, PullRequestInfo[]][]): Map => + new Map(entries.map(([name, prs]) => [name, { prs, total: prs.length }])) + +describe('rowPullRequests', () => { + test('a named branch shows what the host knows about it', () => { + const prs = rowPullRequests( + [row(1, 'feature')], + hosted([['feature', [hostPr(7, 'feature')]]]), + WEB + ) + expect(prs.get(1)?.prs.map((p) => p.number)).toEqual([7]) + }) + + test('a deleted branch shows the merged PR its landing recorded, linked', () => { + const deleted = row(2, 'fix/crash', { + kind: 'unnamed', + landedPr: { number: 42, title: 'Fix the crash' } + }) + const pr = rowPullRequests([deleted], new Map(), WEB).get(2)?.prs[0] + expect(pr).toMatchObject({ + number: 42, + state: 'merged', + title: 'Fix the crash', + url: `${WEB}/pull/42`, + checks: null + }) + }) + + test('the host answer wins over the history record', () => { + const merged = row(1, 'feature', { landedPr: { number: 3, title: 'old' } }) + const prs = rowPullRequests([merged], hosted([['feature', [hostPr(9, 'feature')]]]), WEB) + expect(prs.get(1)?.prs[0].number).toBe(9) + }) + + test('history fills in while the host has no PR for the branch', () => { + const merged = row(1, 'feature', { landedPr: { number: 3, title: 'old' } }) + expect(rowPullRequests([merged], hosted([['feature', []]]), WEB).get(1)?.prs[0].number).toBe(3) + }) + + test('an unnamed row never borrows a live branch of the same name', () => { + const deleted = row(1, 'feature', { kind: 'unnamed' }) + const prs = rowPullRequests([deleted], hosted([['feature', [hostPr(9, 'feature')]]]), WEB) + expect(prs.has(1)).toBe(false) + }) + + test('off GitHub, nothing gets a chip', () => { + const merged = row(1, 'feature', { landedPr: { number: 3, title: 'old' } }) + expect(rowPullRequests([merged], new Map(), null).size).toBe(0) + }) +}) + +describe('isPrLookupRow', () => { + test('only rows naming a real ref are looked up on the host', () => { + expect(isPrLookupRow(row(1, 'a'))).toBe(true) + expect(isPrLookupRow(row(1, 'a', { kind: 'remote' }))).toBe(true) + expect(isPrLookupRow(row(1, 'a', { kind: 'unnamed' }))).toBe(false) + expect(isPrLookupRow(row(1, 'HEAD', { kind: 'detached' }))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/graph/rowPrs.ts b/src/renderer/src/components/graph/rowPrs.ts new file mode 100644 index 0000000..567bec2 --- /dev/null +++ b/src/renderer/src/components/graph/rowPrs.ts @@ -0,0 +1,60 @@ +// Which pull requests each Graph row shows, merged from two sources: +// • the host's live answer for a named branch (open/draft/merged/closed + CI), +// fetched on demand for labels on screen (usePullRequests); +// • the PR its landing commit recorded in history (layout.ts landedPr) — how +// a deleted, merged branch still names its PR, with no API call at all. +// The host wins when it knows the branch: it's the fresher, richer answer. +// Pure, so the precedence is unit-tested directly. + +import { pullRequestUrl } from '@shared/git-host-urls' +import type { PullRequestInfo } from '@shared/types' +import type { BranchPrs } from '@/lib/pr-order' +import type { LandedPr } from './landedPr' +import type { GraphRow } from './layout' + +/** Rows the host can be asked about: those naming a real ref. Deleted + * branches (unnamed rows) have nothing left on the host to look up by. */ +export const isPrLookupRow = (row: GraphRow): boolean => + row.kind === 'branch' || row.kind === 'remote' + +/** A PR recorded in history, shaped like a host answer so the chip, the + * hovercard and the detail pane render one type. It's merged by definition — + * the commit that recorded it is the landing. */ +export function landedPrInfo(pr: LandedPr, headBranch: string, webUrl: string): PullRequestInfo { + return { + number: pr.number, + state: 'merged', + title: pr.title, + url: pullRequestUrl(webUrl, pr.number), + draft: false, + headBranch, + baseBranch: '', + isCrossRepo: false, + checks: null + } +} + +/** + * Chain id → the PRs its label chip shows. `webUrl` is the repo's GitHub web + * base (links for history-recorded PRs); null off GitHub, where no row gets a + * chip — a `#N` that can't be opened is a promise the UI can't keep. + */ +export function rowPullRequests( + rows: readonly GraphRow[], + prByBranch: ReadonlyMap, + webUrl: string | null +): Map { + const byChain = new Map() + if (!webUrl) return byChain + for (const row of rows) { + const hosted = isPrLookupRow(row) ? prByBranch.get(row.name) : undefined + if (hosted && hosted.prs.length > 0) { + byChain.set(row.chain, hosted) + continue + } + if (row.landedPr) { + byChain.set(row.chain, { prs: [landedPrInfo(row.landedPr, row.name, webUrl)], total: 1 }) + } + } + return byChain +} diff --git a/src/renderer/src/components/graph/usePrCard.ts b/src/renderer/src/components/graph/usePrCard.ts new file mode 100644 index 0000000..055ed9d --- /dev/null +++ b/src/renderer/src/components/graph/usePrCard.ts @@ -0,0 +1,84 @@ +// Hover state for the Graph's PR chips: which row's PR hovercard is open and +// where its chip sits on screen. Same timing grammar as the branch switcher's +// badge (BranchSwitcher BranchPrBadges): a short hover delay to open, and once +// open the card's own pointer tracking owns the close, so the pointer can +// travel from the chip into the card without it vanishing. + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { PrCardAnchor } from '@/components/common/PrHoverCard' +import type { GraphRow } from './layout' + +/** Delay before a hovered chip opens its card, and the grace before a card the + * pointer left closes — the switcher badge's values. */ +const OPEN_DELAY_MS = 120 +const CLOSE_GRACE_MS = 200 + +export interface OpenPrCard { + row: GraphRow + /** The chip's on-screen rect, frozen at open time (any pan or zoom closes). */ + anchor: PrCardAnchor +} + +export function usePrCard() { + const [card, setCard] = useState(null) + const openTimer = useRef | undefined>(undefined) + const closeTimer = useRef | undefined>(undefined) + /** The chip under the pointer right now (by chain), so re-hovering the same + * chip on every pointermove doesn't restart the open delay. */ + const hoveredChain = useRef(null) + + const keepOpen = useCallback(() => { + clearTimeout(closeTimer.current) + closeTimer.current = undefined + }, []) + + const requestClose = useCallback(() => { + if (closeTimer.current) return + closeTimer.current = setTimeout(() => { + closeTimer.current = undefined + setCard(null) + }, CLOSE_GRACE_MS) + }, []) + + /** Close now — the view moved, the card was used, or Escape. */ + const close = useCallback(() => { + clearTimeout(openTimer.current) + clearTimeout(closeTimer.current) + closeTimer.current = undefined + hoveredChain.current = null + setCard(null) + }, []) + + /** The pointer is over `row`'s chip, whose screen rect `rect()` resolves. */ + const hoverChip = useCallback( + (row: GraphRow, rect: () => DOMRect) => { + keepOpen() + if (hoveredChain.current === row.chain) return + hoveredChain.current = row.chain + clearTimeout(openTimer.current) + openTimer.current = setTimeout(() => { + const frozen = rect() + setCard({ row, anchor: { getBoundingClientRect: () => frozen } }) + }, OPEN_DELAY_MS) + }, + [keepOpen] + ) + + /** The pointer left every chip: cancel a not-yet-open card. An open one + * stays — its tracking effect decides (the pointer may be heading in). */ + const leaveChip = useCallback(() => { + if (hoveredChain.current === null) return + hoveredChain.current = null + clearTimeout(openTimer.current) + }, []) + + useEffect( + () => () => { + clearTimeout(openTimer.current) + clearTimeout(closeTimer.current) + }, + [] + ) + + return { card, hoverChip, leaveChip, keepOpen, requestClose, close } +} diff --git a/src/renderer/src/styles/features/graph.css b/src/renderer/src/styles/features/graph.css index e9d9a79..950e898 100644 --- a/src/renderer/src/styles/features/graph.css +++ b/src/renderer/src/styles/features/graph.css @@ -510,6 +510,14 @@ .graph-detail__squash-branch:hover { text-decoration: underline; } +/* The selection's pull requests (GraphDetailPane PrList): the PR hovercard's + link rows (.pr-card__row, primitives.css), pulled flush with the pane's text + column — the row's own padding is its hover chip, not an indent. */ +.graph-detail__prs { + display: flex; + flex-direction: column; + margin: 6px -6px 0; +} /* Shared commit widgets (history/CommitSummary.tsx): History indents body and refs under its 34px avatar column; the sidebar head has no such column, so they span the pane's full width. graph.css loads after history.css, so diff --git a/src/shared/git-host-urls.test.ts b/src/shared/git-host-urls.test.ts index 7c61dde..aee1636 100644 --- a/src/shared/git-host-urls.test.ts +++ b/src/shared/git-host-urls.test.ts @@ -4,7 +4,8 @@ import { commitUrl, compareUrl, headPullRequestsUrl, - parseOwnerRepo + parseOwnerRepo, + pullRequestUrl } from './git-host-urls' const BASE = 'https://github.com/octocat/hello' @@ -59,6 +60,12 @@ describe('headPullRequestsUrl', () => { }) }) +describe('pullRequestUrl', () => { + test('builds the pull request page url', () => { + expect(pullRequestUrl(`${BASE}/`, 89)).toBe('https://github.com/octocat/hello/pull/89') + }) +}) + describe('parseOwnerRepo', () => { test('splits owner and repo from a web url', () => { expect(parseOwnerRepo('https://github.com/octocat/hello')).toEqual({ diff --git a/src/shared/git-host-urls.ts b/src/shared/git-host-urls.ts index 26a0168..e5d5eeb 100644 --- a/src/shared/git-host-urls.ts +++ b/src/shared/git-host-urls.ts @@ -42,6 +42,11 @@ export function compareUrl(webBase: string, baseBranch: string, headBranch: stri return `${trimTrailingSlash(webBase)}/compare/${encodeRef(baseBranch)}...${encodeRef(headBranch)}?expand=1` } +/** `…/pull/` — a single pull request's page. */ +export function pullRequestUrl(webBase: string, number: number): string { + return `${trimTrailingSlash(webBase)}/pull/${number}` +} + /** * The repo's pull-request list filtered to a head branch — the "see all PRs for * this branch" escape hatch when a branch has more PRs than the badge hovercard From 96f841cb47b9a879a3aebb2547cae07a8ac38f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 21:10:38 +0200 Subject: [PATCH 04/10] Stop the Graph layout re-running every render before sync loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh [] for remotes on each render re-ran the layout, and its report-up effects set App state — a render loop ("Maximum update depth exceeded") on startup with the Graph restored. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/renderer/src/App.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8c6bfc2..6ab3ba1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -73,6 +73,11 @@ import { useUpdateBanner } from './lib/useUpdateBanner' type Tab = 'changes' | 'history' | 'graph' +/** Stable stand-in while sync status hasn't loaded: a fresh `[]` per render + * would re-run the Graph layout every render, and the layout's report-up + * effects (squashes, PRs) set App state — an update loop. */ +const NO_REMOTES: string[] = [] + export function App() { const [repo, setRepo] = useState(null) // The repo's web URL + whether its host is GitHub, for view-on-web / PR links. @@ -1642,7 +1647,7 @@ export function App() { refreshNonce={graphNonce} theme={theme} branch={branch} - remotes={sync?.remotes ?? []} + remotes={sync?.remotes ?? NO_REMOTES} changesCount={changes.length} selectedCommit={selectedCommit} onSelectCommit={(commit) => { From f41b4185e076f82a54fa03838339bd09c2d89184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 21:18:57 +0200 Subject: [PATCH 05/10] Badge the PR glyph on the current branch instead of insetting a chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accent HEAD pill's chip was a light inset patch — the one foreign surface left. It is now inline like every other label (divider and number in the pill's ink), and its state glyph becomes a disc in the state color with the mark knocked out, so a green check survives on the accent fill. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/renderer/src/components/graph/prChip.ts | 142 ++++++++++++-------- src/renderer/src/components/graph/render.ts | 8 +- 2 files changed, 87 insertions(+), 63 deletions(-) diff --git a/src/renderer/src/components/graph/prChip.ts b/src/renderer/src/components/graph/prChip.ts index 56345d2..afee26c 100644 --- a/src/renderer/src/components/graph/prChip.ts +++ b/src/renderer/src/components/graph/prChip.ts @@ -5,31 +5,37 @@ // ran), GitHub's merged / closed octicon otherwise. Drawn in WORLD space inside // the label pill, so it zooms with it. // -// Unlike the badge it wears no gray pill of its own: on a tinted branch label a -// neutral patch reads as a foreign sticker, worst at small zoom where it blurs -// into a gray smudge. The label already sits on an opaque, near-background base -// (a 15% hue tint), so the mid-luminance state glyphs contrast on it for every -// branch hue — the chip is just a hairline divider plus the glyph and a number -// in the branch's own ink. The one exception is the HEAD label: a solid accent -// fill that would swallow a green ✓, so there the chip insets a small -// label-surface pill to give the glyphs their ground back. +// Unlike the badge it wears no pill of its own: a neutral patch on a tinted +// branch label reads as a foreign sticker, worst at small zoom where it blurs +// into a smudge. The chip is a hairline divider, the glyph and the number, all +// in the label's own ink. On a tinted label the label sits on an opaque, +// near-background base, so the mid-luminance state colors contrast as bare +// marks for every branch hue. The HEAD label is a solid accent fill that would +// swallow a green ✓, so there each glyph becomes a BADGE — a disc in the state +// color with the mark knocked out in the label's ink (GitHub's check-circle-fill +// idiom): the state color survives, and the chip stays as inline as the rest. import type { PullRequestInfo } from '@shared/types' import { PR_CHIP_GAP, PR_CHIP_H } from './geometry' -/** The chip's slice of the graph palette (render.ts readPalette). */ +/** The state colors (render.ts readPalette), plus the font family. */ export interface PrChipColors { font: string - /** The label surface (--bg-elevated): the HEAD chip's inset pill. */ - surface: string - /** The inset chip's number ink. */ - text: string success: string failure: string pending: string merged: string } +/** How the chip sits in its label: its ink (number, and a badge's knocked-out + * mark), the divider's color, and whether glyphs are badged (HEAD's solid + * accent pill) or bare marks (tinted branch pills). */ +export interface PrChipStyle { + ink: string + divider: string + badged: boolean +} + const CHIP_FONT = 10 const PAD_X = 4 const GLYPH = 9 @@ -54,10 +60,6 @@ export function prChipGlyph(pr: PullRequestInfo): Glyph { const chipFont = (family: string) => `500 ${CHIP_FONT}px ${family}` -/** How the chip sits in its label: `inline` on a tinted branch pill (divider, - * branch-ink number), `inset` on the solid HEAD pill (its own surface pill). */ -export type PrChipStyle = { kind: 'inline'; ink: string; divider: string } | { kind: 'inset' } - /** The chip's width for `pr`, text measured in the chip font. */ export function measurePrChip(ctx: CanvasRenderingContext2D, family: string, pr: PullRequestInfo) { ctx.font = chipFont(family) @@ -73,75 +75,99 @@ export function drawPrChip( colors: PrChipColors, style: PrChipStyle ): void { - if (style.kind === 'inset') { - ctx.beginPath() - ctx.roundRect(rect.x, rect.y, rect.w, PR_CHIP_H, PR_CHIP_H / 2 - 1) - ctx.fillStyle = colors.surface - ctx.fill() - } else { - // The hairline sits in the gap before the chip, a touch shorter than it. - const x = Math.round(rect.x - PR_CHIP_GAP / 2) + 0.5 - ctx.beginPath() - ctx.moveTo(x, rect.y + 2) - ctx.lineTo(x, rect.y + PR_CHIP_H - 2) - ctx.strokeStyle = style.divider - ctx.lineWidth = 1 - ctx.stroke() - } + // The hairline sits in the gap before the chip, a touch shorter than it. + const divX = Math.round(rect.x - PR_CHIP_GAP / 2) + 0.5 + ctx.beginPath() + ctx.moveTo(divX, rect.y + 2) + ctx.lineTo(divX, rect.y + PR_CHIP_H - 2) + ctx.strokeStyle = style.divider + ctx.lineWidth = 1 + ctx.stroke() const midY = rect.y + PR_CHIP_H / 2 let x = rect.x + PAD_X const glyph = prChipGlyph(pr) if (glyph) { - drawGlyph(ctx, glyph, x, midY - GLYPH / 2, colors) + drawGlyph(ctx, glyph, x + GLYPH / 2, midY, stateColor(glyph, colors), style) x += GLYPH + GLYPH_GAP } ctx.font = chipFont(colors.font) ctx.textBaseline = 'middle' ctx.textAlign = 'left' - ctx.fillStyle = style.kind === 'inset' ? colors.text : style.ink + ctx.fillStyle = style.ink ctx.fillText(`#${pr.number}`, x, midY + 0.5) } -/** A GLYPH-sized state mark with its top-left at (x, y). */ +function stateColor(glyph: Exclude, colors: PrChipColors): string { + if (glyph === 'success') return colors.success + if (glyph === 'pending') return colors.pending + if (glyph === 'merged') return colors.merged + return colors.failure +} + +/** How far a badge's knocked-out mark shrinks inside its disc. */ +const BADGE_MARK_SCALE = 0.6 + +/** A GLYPH-sized state mark centered on (cx, cy): the bare mark in its state + * color, or — badged — a state-color disc with the mark in the label's ink. */ function drawGlyph( ctx: CanvasRenderingContext2D, glyph: Exclude, - x: number, - y: number, - colors: PrChipColors + cx: number, + cy: number, + color: string, + style: PrChipStyle ): void { ctx.save() + ctx.translate(cx, cy) + if (style.badged) { + ctx.beginPath() + ctx.arc(0, 0, GLYPH / 2 + 0.5, 0, Math.PI * 2) + ctx.fillStyle = color + ctx.fill() + // A running check is the disc alone — the amber IS the mark. + if (glyph !== 'pending') { + ctx.scale(BADGE_MARK_SCALE, BADGE_MARK_SCALE) + drawMark(ctx, glyph, style.ink, 1.5 / BADGE_MARK_SCALE) + } + } else { + drawMark(ctx, glyph, color, 1.5) + } + ctx.restore() +} + +/** The state mark on a GLYPH box centered on the origin. */ +function drawMark( + ctx: CanvasRenderingContext2D, + glyph: Exclude, + color: string, + lineWidth: number +): void { + const h = GLYPH / 2 ctx.lineCap = 'round' ctx.lineJoin = 'round' - ctx.lineWidth = 1.5 - const s = GLYPH + ctx.lineWidth = lineWidth + ctx.strokeStyle = color + ctx.fillStyle = color + ctx.beginPath() if (glyph === 'success') { - ctx.strokeStyle = colors.success - ctx.beginPath() - ctx.moveTo(x + s * 0.12, y + s * 0.55) - ctx.lineTo(x + s * 0.4, y + s * 0.82) - ctx.lineTo(x + s * 0.9, y + s * 0.22) + ctx.moveTo(-h * 0.76, h * 0.1) + ctx.lineTo(-h * 0.2, h * 0.64) + ctx.lineTo(h * 0.8, -h * 0.56) ctx.stroke() } else if (glyph === 'failure') { - ctx.strokeStyle = colors.failure - ctx.beginPath() - ctx.moveTo(x + s * 0.2, y + s * 0.2) - ctx.lineTo(x + s * 0.8, y + s * 0.8) - ctx.moveTo(x + s * 0.8, y + s * 0.2) - ctx.lineTo(x + s * 0.2, y + s * 0.8) + ctx.moveTo(-h * 0.6, -h * 0.6) + ctx.lineTo(h * 0.6, h * 0.6) + ctx.moveTo(h * 0.6, -h * 0.6) + ctx.lineTo(-h * 0.6, h * 0.6) ctx.stroke() } else if (glyph === 'pending') { - ctx.fillStyle = colors.pending - ctx.beginPath() - ctx.arc(x + s / 2, y + s / 2, s * 0.33, 0, Math.PI * 2) + ctx.arc(0, 0, GLYPH * 0.33, 0, Math.PI * 2) ctx.fill() } else { octicons ??= { merged: new Path2D(MERGED_D), closed: new Path2D(CLOSED_D) } - ctx.fillStyle = glyph === 'merged' ? colors.merged : colors.failure - ctx.translate(x, y) - ctx.scale(s / 16, s / 16) + ctx.translate(-h, -h) + ctx.scale(GLYPH / 16, GLYPH / 16) ctx.fill(glyph === 'merged' ? octicons.merged : octicons.closed) } - ctx.restore() } diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 223a13f..87e26a5 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -92,8 +92,6 @@ export function readPalette(el: HTMLElement, dark: boolean): GraphPalette { tag: token('--pr-merged'), prChip: { font: css.fontFamily, - surface: token('--bg-elevated'), - text: token('--fg-muted'), success: token('--st-added'), failure: token('--st-deleted'), pending: token('--st-modified'), @@ -1129,11 +1127,11 @@ function drawLabels( pr.info, palette.prChip, head - ? { kind: 'inset' } + ? { ink: palette.onAccent, divider: withAlpha(palette.onAccent, 0.4), badged: true } : { - kind: 'inline', ink: branchFill(palette, row.color, 0.9), - divider: branchFill(palette, row.color, 0.35) + divider: branchFill(palette, row.color, 0.35), + badged: false } ) } From 2553464d240c61b4db449e1e663b554f7986ebb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 21:25:48 +0200 Subject: [PATCH 06/10] Draw the Graph PR chip with the switcher badge's exact glyphs The canvas chip now mirrors .branch-pr precisely: the same check/cross paths and 1.7 stroke at 10px, 11px octicons, a 6px running dot and 10.5px text. On the accent HEAD pill it is the badge itself (neutral pill, ring, muted number) instead of a disc or halo; the running dot pulses like the badge's ci-pulse, animating only while one is on screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/graph/GraphCanvas.tsx | 21 +- src/renderer/src/components/graph/layout.ts | 2 +- .../src/components/graph/prChip.test.ts | 18 +- src/renderer/src/components/graph/prChip.ts | 212 ++++++++++-------- src/renderer/src/components/graph/render.ts | 41 +++- 5 files changed, 189 insertions(+), 105 deletions(-) diff --git a/src/renderer/src/components/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index e10381c..43be21d 100644 --- a/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/src/renderer/src/components/graph/GraphCanvas.tsx @@ -48,6 +48,7 @@ import { drawScene, type GraphPalette, labelContentWidthFor, + prChipsPulsing, prChipWidthFor, readPalette, SUBJECT_FONT @@ -110,9 +111,15 @@ interface Props { onWipClick: () => void } +const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches + /** Extra world pixels the user may pan past the diagram's edge. */ const OVERSCROLL = 80 +/** Frame interval while a running-check dot pulses: its opacity breathes over + * 1.3s, so ~20fps is smooth — and it only runs while such a dot is visible. */ +const PULSE_FRAME_MS = 50 + /** How long the view must rest before the labels on screen are reported * (onLabelsInView): a pan or zoom sweeping across the diagram asks nothing, * only where it stops. */ @@ -259,6 +266,9 @@ export function GraphCanvas({ const onLabelsInViewRef = useRef(onLabelsInView) onLabelsInViewRef.current = onLabelsInView const labelsSettleRef = useRef | undefined>(undefined) + const pulseFrameRef = useRef | undefined>(undefined) + // Set once the draw loop exists (below): the pulse timer re-arms through it. + const invalidateRef = useRef<() => void>(() => {}) const prCard = usePrCard() const closePrCard = prCard.close @@ -290,8 +300,15 @@ export function GraphCanvas({ wip: s.wip, dayMarks: s.dayMarks, links: s.links, - rowPrs: s.rowPrs + rowPrs: s.rowPrs, + time: reducedMotion() ? 0 : performance.now() }) + // A running check's dot is on screen: keep breathing. Finite by design — + // the timer re-arms only while the frame it follows drew one. + clearTimeout(pulseFrameRef.current) + if (prChipsPulsing() && !reducedMotion()) { + pulseFrameRef.current = setTimeout(() => invalidateRef.current(), PULSE_FRAME_MS) + } // Every view change and data landing funnels through here, so this one // debounce covers them all: report the on-screen labels once it rests. clearTimeout(labelsSettleRef.current) @@ -316,6 +333,7 @@ export function GraphCanvas({ draw() }) }, [draw]) + invalidateRef.current = invalidate const clampView = useCallback(() => { const view = viewRef.current @@ -621,6 +639,7 @@ export function GraphCanvas({ () => () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) clearTimeout(labelsSettleRef.current) + clearTimeout(pulseFrameRef.current) }, [] ) diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index 86ffc57..ec1d933 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -218,7 +218,7 @@ function labelColumns(name: string, prChip: boolean): number { /** Room a label reserves for its PR chip: the octicon, its gaps and a * four-digit `#1234` (render.ts measures the real chip). */ -const PR_CHIP_RESERVE = 48 +const PR_CHIP_RESERVE = 52 /** Chain kinds that can carry a PR chip: real branches, and deleted ones * reconstructed from history (their landing commit may record the PR). */ diff --git a/src/renderer/src/components/graph/prChip.test.ts b/src/renderer/src/components/graph/prChip.test.ts index 3796f95..80c6358 100644 --- a/src/renderer/src/components/graph/prChip.test.ts +++ b/src/renderer/src/components/graph/prChip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { PullRequestInfo } from '@shared/types' -import { prChipGlyph } from './prChip' +import { ciPulseAlpha, prChipGlyph } from './prChip' function pr(overrides: Partial): PullRequestInfo { return { @@ -30,3 +30,19 @@ describe('prChipGlyph', () => { expect(prChipGlyph(pr({ state: 'closed' }))).toBe('closed') }) }) + +describe('ciPulseAlpha', () => { + test('breathes 1 → 0.35 → 1 over the badge pulse period', () => { + expect(ciPulseAlpha(0)).toBeCloseTo(1) + expect(ciPulseAlpha(650)).toBeCloseTo(0.35) + expect(ciPulseAlpha(1300)).toBeCloseTo(1) + }) + + test('stays within the keyframe range at every phase', () => { + for (let ms = 0; ms < 2600; ms += 37) { + const alpha = ciPulseAlpha(ms) + expect(alpha).toBeGreaterThanOrEqual(0.35 - 1e-9) + expect(alpha).toBeLessThanOrEqual(1) + } + }) +}) diff --git a/src/renderer/src/components/graph/prChip.ts b/src/renderer/src/components/graph/prChip.ts index afee26c..89997c7 100644 --- a/src/renderer/src/components/graph/prChip.ts +++ b/src/renderer/src/components/graph/prChip.ts @@ -1,70 +1,99 @@ // The PR chip a branch label wears: the canvas twin of the branch switcher's -// `#123` badge (.branch-pr in toolbar.css), so a PR reads the same everywhere -// in the app — only the leading glyph carries meaning: the CI rollup for an -// open PR (✓ passing, ✗ failing, amber dot running; nothing when no checks -// ran), GitHub's merged / closed octicon otherwise. Drawn in WORLD space inside -// the label pill, so it zooms with it. +// `#123` badge (.branch-pr in toolbar.css) — same glyph geometry, same sizes, +// same colors — so a PR reads identically everywhere in the app. Only the +// leading glyph carries meaning: the CI rollup for an open PR (✓ passing, +// ✗ failing, a pulsing amber dot running; nothing when no checks ran), GitHub's +// merged / closed octicon otherwise. Drawn in WORLD space inside the label +// pill, so it zooms with it. // -// Unlike the badge it wears no pill of its own: a neutral patch on a tinted -// branch label reads as a foreign sticker, worst at small zoom where it blurs -// into a smudge. The chip is a hairline divider, the glyph and the number, all -// in the label's own ink. On a tinted label the label sits on an opaque, -// near-background base, so the mid-luminance state colors contrast as bare -// marks for every branch hue. The HEAD label is a solid accent fill that would -// swallow a green ✓, so there each glyph becomes a BADGE — a disc in the state -// color with the mark knocked out in the label's ink (GitHub's check-circle-fill -// idiom): the state color survives, and the chip stays as inline as the rest. +// Two ways to sit in a label: +// • inline, on a tinted branch pill: no pill of its own (a neutral patch on a +// tinted label reads as a foreign sticker, worst at small zoom), just a +// hairline divider and the glyph + number in the branch's ink. The label's +// opaque near-background base lets the state colors contrast for any hue; +// • badge, on the solid accent HEAD pill, where the state colors would sink: +// the switcher badge itself — its neutral pill, 1px ring and muted number. +// +// The running dot breathes like the badge's (ci-pulse, primitives.css): +// ciPulseAlpha mirrors the keyframes, and the canvas only animates while such +// a dot is on screen (render.ts prChipsPulsing). import type { PullRequestInfo } from '@shared/types' import { PR_CHIP_GAP, PR_CHIP_H } from './geometry' -/** The state colors (render.ts readPalette), plus the font family. */ +/** The chip's slice of the graph palette (render.ts readPalette): the badge + * tokens (--pr-pill-bg / --pr-pill-ring / --fg-muted) and the state colors. */ export interface PrChipColors { font: string + pill: string + ring: string + text: string success: string failure: string pending: string merged: string } -/** How the chip sits in its label: its ink (number, and a badge's knocked-out - * mark), the divider's color, and whether glyphs are badged (HEAD's solid - * accent pill) or bare marks (tinted branch pills). */ -export interface PrChipStyle { - ink: string - divider: string - badged: boolean -} +/** How the chip sits in its label — see the file header. */ +export type PrChipStyle = { kind: 'inline'; ink: string; divider: string } | { kind: 'badge' } -const CHIP_FONT = 10 +// .branch-pr metrics (toolbar.css): 10.5px/500 text, 4px side padding, 2px gap +// after the glyph, 6px corner radius; CiStatus draws its check/cross at 10px, +// the octicons at 11px, the running dot at 6px. +const CHIP_FONT = 10.5 const PAD_X = 4 -const GLYPH = 9 -const GLYPH_GAP = 2.5 +const GLYPH_GAP = 2 +const RADIUS = 6 +const CI_ICON = 10 +const OCTICON = 11 +const DOT = 6 + +// Icon.Check / Icon.Close (lib/icons.tsx) on their 24-unit grid, stroked 1.7 — +// the exact marks CiStatus renders. +const CHECK_D = 'm5 12 5 5L20 6' +const CROSS_D = 'M6 6 18 18M18 6 6 18' +const ICON_STROKE = 1.7 // GitHub's merged / closed pull-request octicons on their 16-unit grid — the -// same paths as Icon.PrMerged / Icon.PrClosed (lib/icons.tsx). Built lazily: -// Path2D doesn't exist outside a browser (tests import this module). +// same paths as Icon.PrMerged / Icon.PrClosed. Path2D is built lazily: it +// doesn't exist outside a browser (tests import this module). const MERGED_D = 'M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z' const CLOSED_D = 'M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm0 11a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0-9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM11.25 9.5a.75.75 0 0 1 .75.75v.378a2.251 2.251 0 1 1-1.5 0V10.25a.75.75 0 0 1 .75-.75Zm0 4a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM9.22 1.227a.75.75 0 0 1 1.06 0l.97.97.97-.97a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l.97-.97-.97-.97a.75.75 0 0 1 0-1.06Z' -let octicons: { merged: Path2D; closed: Path2D } | null = null +let glyphPaths: Record<'success' | 'failure' | 'merged' | 'closed', Path2D> | null = null type Glyph = 'success' | 'failure' | 'pending' | 'merged' | 'closed' | null -/** The leading glyph for a PR — the badge's rule (BranchSwitcher PrGlyph). */ +/** The leading glyph for a PR — the badge's rule (PrHoverCard PrGlyph). */ export function prChipGlyph(pr: PullRequestInfo): Glyph { if (pr.state === 'open') return pr.checks return pr.state } +/** The badge's ci-pulse period (primitives.css). */ +const PULSE_MS = 1300 + +/** A running dot's opacity at time `ms`: 1 → 0.35 → 1 over PULSE_MS, eased + * like CSS ease-in-out keyframes — the canvas twin of `ci-pulse`. */ +export function ciPulseAlpha(ms: number): number { + const phase = (ms % PULSE_MS) / PULSE_MS + return 1 - 0.65 * ((1 - Math.cos(phase * 2 * Math.PI)) / 2) +} + const chipFont = (family: string) => `500 ${CHIP_FONT}px ${family}` +function glyphWidth(glyph: Exclude): number { + if (glyph === 'pending') return DOT + return glyph === 'merged' || glyph === 'closed' ? OCTICON : CI_ICON +} + /** The chip's width for `pr`, text measured in the chip font. */ export function measurePrChip(ctx: CanvasRenderingContext2D, family: string, pr: PullRequestInfo) { ctx.font = chipFont(family) const text = ctx.measureText(`#${pr.number}`).width - return PAD_X * 2 + text + (prChipGlyph(pr) ? GLYPH + GLYPH_GAP : 0) + const glyph = prChipGlyph(pr) + return PAD_X * 2 + text + (glyph ? glyphWidth(glyph) + GLYPH_GAP : 0) } /** Paint the chip for `pr` into `rect` (world space, from geometry prChipRect). */ @@ -73,28 +102,41 @@ export function drawPrChip( rect: { x: number; y: number; w: number }, pr: PullRequestInfo, colors: PrChipColors, - style: PrChipStyle + style: PrChipStyle, + /** The running dot's current opacity (ciPulseAlpha). */ + pulse: number ): void { - // The hairline sits in the gap before the chip, a touch shorter than it. - const divX = Math.round(rect.x - PR_CHIP_GAP / 2) + 0.5 - ctx.beginPath() - ctx.moveTo(divX, rect.y + 2) - ctx.lineTo(divX, rect.y + PR_CHIP_H - 2) - ctx.strokeStyle = style.divider - ctx.lineWidth = 1 - ctx.stroke() + if (style.kind === 'badge') { + ctx.beginPath() + ctx.roundRect(rect.x, rect.y, rect.w, PR_CHIP_H, RADIUS) + ctx.fillStyle = colors.pill + ctx.fill() + ctx.strokeStyle = colors.ring + ctx.lineWidth = 1 + ctx.stroke() + } else { + // The hairline sits in the gap before the chip, a touch shorter than it. + const x = Math.round(rect.x - PR_CHIP_GAP / 2) + 0.5 + ctx.beginPath() + ctx.moveTo(x, rect.y + 2) + ctx.lineTo(x, rect.y + PR_CHIP_H - 2) + ctx.strokeStyle = style.divider + ctx.lineWidth = 1 + ctx.stroke() + } const midY = rect.y + PR_CHIP_H / 2 let x = rect.x + PAD_X const glyph = prChipGlyph(pr) if (glyph) { - drawGlyph(ctx, glyph, x + GLYPH / 2, midY, stateColor(glyph, colors), style) - x += GLYPH + GLYPH_GAP + const size = glyphWidth(glyph) + drawGlyph(ctx, glyph, x, midY - size / 2, size, colors, pulse) + x += size + GLYPH_GAP } ctx.font = chipFont(colors.font) ctx.textBaseline = 'middle' ctx.textAlign = 'left' - ctx.fillStyle = style.ink + ctx.fillStyle = style.kind === 'badge' ? colors.text : style.ink ctx.fillText(`#${pr.number}`, x, midY + 0.5) } @@ -105,69 +147,45 @@ function stateColor(glyph: Exclude, colors: PrChipColors): string { return colors.failure } -/** How far a badge's knocked-out mark shrinks inside its disc. */ -const BADGE_MARK_SCALE = 0.6 - -/** A GLYPH-sized state mark centered on (cx, cy): the bare mark in its state - * color, or — badged — a state-color disc with the mark in the label's ink. */ +/** A `size`-square glyph with its top-left at (x, y), in its state color. */ function drawGlyph( ctx: CanvasRenderingContext2D, glyph: Exclude, - cx: number, - cy: number, - color: string, - style: PrChipStyle + x: number, + y: number, + size: number, + colors: PrChipColors, + pulse: number ): void { ctx.save() - ctx.translate(cx, cy) - if (style.badged) { + const color = stateColor(glyph, colors) + if (glyph === 'pending') { + ctx.globalAlpha *= pulse ctx.beginPath() - ctx.arc(0, 0, GLYPH / 2 + 0.5, 0, Math.PI * 2) + ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2) ctx.fillStyle = color ctx.fill() - // A running check is the disc alone — the amber IS the mark. - if (glyph !== 'pending') { - ctx.scale(BADGE_MARK_SCALE, BADGE_MARK_SCALE) - drawMark(ctx, glyph, style.ink, 1.5 / BADGE_MARK_SCALE) - } - } else { - drawMark(ctx, glyph, color, 1.5) + ctx.restore() + return } - ctx.restore() -} - -/** The state mark on a GLYPH box centered on the origin. */ -function drawMark( - ctx: CanvasRenderingContext2D, - glyph: Exclude, - color: string, - lineWidth: number -): void { - const h = GLYPH / 2 - ctx.lineCap = 'round' - ctx.lineJoin = 'round' - ctx.lineWidth = lineWidth - ctx.strokeStyle = color - ctx.fillStyle = color - ctx.beginPath() - if (glyph === 'success') { - ctx.moveTo(-h * 0.76, h * 0.1) - ctx.lineTo(-h * 0.2, h * 0.64) - ctx.lineTo(h * 0.8, -h * 0.56) - ctx.stroke() - } else if (glyph === 'failure') { - ctx.moveTo(-h * 0.6, -h * 0.6) - ctx.lineTo(h * 0.6, h * 0.6) - ctx.moveTo(h * 0.6, -h * 0.6) - ctx.lineTo(-h * 0.6, h * 0.6) - ctx.stroke() - } else if (glyph === 'pending') { - ctx.arc(0, 0, GLYPH * 0.33, 0, Math.PI * 2) - ctx.fill() + glyphPaths ??= { + success: new Path2D(CHECK_D), + failure: new Path2D(CROSS_D), + merged: new Path2D(MERGED_D), + closed: new Path2D(CLOSED_D) + } + ctx.translate(x, y) + if (glyph === 'merged' || glyph === 'closed') { + ctx.scale(size / 16, size / 16) + ctx.fillStyle = color + ctx.fill(glyphPaths[glyph]) } else { - octicons ??= { merged: new Path2D(MERGED_D), closed: new Path2D(CLOSED_D) } - ctx.translate(-h, -h) - ctx.scale(GLYPH / 16, GLYPH / 16) - ctx.fill(glyph === 'merged' ? octicons.merged : octicons.closed) + ctx.scale(size / 24, size / 24) + ctx.strokeStyle = color + ctx.lineWidth = ICON_STROKE + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.stroke(glyphPaths[glyph]) } + ctx.restore() } diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 87e26a5..f6f89bb 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -34,6 +34,7 @@ import { nodeY, prChipRect, toWorldX, + toWorldY, type View } from './geometry' import { @@ -45,7 +46,7 @@ import { rowMatchesSelection } from './layout' import { type BackportLink, linkedHashes } from './links' -import { drawPrChip, measurePrChip, type PrChipColors } from './prChip' +import { ciPulseAlpha, drawPrChip, measurePrChip, type PrChipColors, prChipGlyph } from './prChip' import { ACTIVE_GLOW, HIT_GLOW, @@ -92,6 +93,9 @@ export function readPalette(el: HTMLElement, dark: boolean): GraphPalette { tag: token('--pr-merged'), prChip: { font: css.fontFamily, + pill: token('--pr-pill-bg'), + ring: token('--pr-pill-ring'), + text: token('--fg-muted'), success: token('--st-added'), failure: token('--st-deleted'), pending: token('--st-modified'), @@ -188,6 +192,9 @@ export interface SceneState { /** Chain id → the PRs its label chip shows (rowPrs.ts); the chip draws the * most important one. */ rowPrs: ReadonlyMap + /** Frame timestamp (ms) — phases the running-check pulse; any fixed value + * (reduced motion) freezes it. */ + time: number } const LABEL_FONT = 11 @@ -255,6 +262,15 @@ export function labelWidthFor(name: string): number { return labelWidths.get(name) ?? name.length * 6.2 } +// Whether the last frame drew a running-check dot — the canvas keeps animating +// only while one is on screen. +let pulsingChips = false + +/** True when the last frame drew a pulsing running-check dot. */ +export function prChipsPulsing(): boolean { + return pulsingChips +} + /** Width of a row's PR chip as last drawn; 0 when it has none. */ export function prChipWidthFor(row: GraphRow): number { return prChipWidths.get(row.chain) ?? 0 @@ -1061,6 +1077,8 @@ function drawLabels( const { palette } = scene ctx.textBaseline = 'middle' ctx.textAlign = 'left' + pulsingChips = false + const pulse = ciPulseAlpha(scene.time) for (const { row, rect, sticky, pr } of labelBoxes) { const head = row.isHead // While a filter/search dims commits, labels of hitless branches ghost @@ -1127,19 +1145,32 @@ function drawLabels( pr.info, palette.prChip, head - ? { ink: palette.onAccent, divider: withAlpha(palette.onAccent, 0.4), badged: true } + ? { kind: 'badge' } : { + kind: 'inline', ink: branchFill(palette, row.color, 0.9), - divider: branchFill(palette, row.color, 0.35), - badged: false - } + divider: branchFill(palette, row.color, 0.35) + }, + pulse ) + if (prChipGlyph(pr.info) === 'pending' && onScreen(scene, rect)) pulsingChips = true } if (isActiveHit) drawRectPing(ctx, scene, rect, 5) ctx.globalAlpha = 1 } } +/** True when a world rect intersects the visible stage (below the header). */ +function onScreen(scene: SceneState, r: { x: number; y: number; w: number; h: number }): boolean { + const { view } = scene + return ( + r.x < toWorldX(view, scene.width) && + r.x + r.w > toWorldX(view, 0) && + r.y < toWorldY(view, scene.height) && + r.y + r.h > toWorldY(view, HEADER_H) + ) +} + /** The soft gold bloom a search hit wears — the find grammar every glyph * shares: node disc, branch pill and tag chip all cast the SAME warm halo, so * a result reads the same wherever it lives. It's a Gaussian shadow thrown by From 4f262b84e274003369b4c20394c21a57c6e10c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 21:41:15 +0200 Subject: [PATCH 07/10] Keep the current branch's PR chip inline, glyph in the pill's ink Drop the neutral badge pill on the accent HEAD label: like every other label, the chip is a divider plus the glyph and number, all in the pill's ink. The glyph keeps the switcher badge's exact shape, so the state still reads (check / cross / pulsing dot / octicon). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/renderer/src/components/graph/prChip.ts | 70 +++++++++------------ src/renderer/src/components/graph/render.ts | 9 +-- 2 files changed, 34 insertions(+), 45 deletions(-) diff --git a/src/renderer/src/components/graph/prChip.ts b/src/renderer/src/components/graph/prChip.ts index 89997c7..3cdc4e3 100644 --- a/src/renderer/src/components/graph/prChip.ts +++ b/src/renderer/src/components/graph/prChip.ts @@ -6,13 +6,13 @@ // merged / closed octicon otherwise. Drawn in WORLD space inside the label // pill, so it zooms with it. // -// Two ways to sit in a label: -// • inline, on a tinted branch pill: no pill of its own (a neutral patch on a -// tinted label reads as a foreign sticker, worst at small zoom), just a -// hairline divider and the glyph + number in the branch's ink. The label's -// opaque near-background base lets the state colors contrast for any hue; -// • badge, on the solid accent HEAD pill, where the state colors would sink: -// the switcher badge itself — its neutral pill, 1px ring and muted number. +// Unlike the badge it wears no pill of its own — a neutral patch on a tinted +// label reads as a foreign sticker, worst at small zoom — just a hairline +// divider and the glyph + number in the label's ink. On a tinted branch pill +// the glyph keeps its state color: the label's opaque near-background base +// lets it contrast for any hue. On the solid accent HEAD pill the state colors +// would sink into the fill, so there the glyph takes the pill's ink too — the +// same thin mark, its shape (✓ / ✗ / pulsing dot / octicon) carrying the state. // // The running dot breathes like the badge's (ci-pulse, primitives.css): // ciPulseAlpha mirrors the keyframes, and the canvas only animates while such @@ -21,29 +21,31 @@ import type { PullRequestInfo } from '@shared/types' import { PR_CHIP_GAP, PR_CHIP_H } from './geometry' -/** The chip's slice of the graph palette (render.ts readPalette): the badge - * tokens (--pr-pill-bg / --pr-pill-ring / --fg-muted) and the state colors. */ +/** The chip's slice of the graph palette (render.ts readPalette): the font + * family and the state colors. */ export interface PrChipColors { font: string - pill: string - ring: string - text: string success: string failure: string pending: string merged: string } -/** How the chip sits in its label — see the file header. */ -export type PrChipStyle = { kind: 'inline'; ink: string; divider: string } | { kind: 'badge' } +/** How the chip sits in its label: its ink (the number), the divider's color, + * and whether the glyph wears that ink instead of its state color (the solid + * accent HEAD pill — see the file header). */ +export interface PrChipStyle { + ink: string + divider: string + inkGlyph: boolean +} // .branch-pr metrics (toolbar.css): 10.5px/500 text, 4px side padding, 2px gap -// after the glyph, 6px corner radius; CiStatus draws its check/cross at 10px, +// after the glyph; CiStatus draws its check/cross at 10px, // the octicons at 11px, the running dot at 6px. const CHIP_FONT = 10.5 const PAD_X = 4 const GLYPH_GAP = 2 -const RADIUS = 6 const CI_ICON = 10 const OCTICON = 11 const DOT = 6 @@ -106,37 +108,28 @@ export function drawPrChip( /** The running dot's current opacity (ciPulseAlpha). */ pulse: number ): void { - if (style.kind === 'badge') { - ctx.beginPath() - ctx.roundRect(rect.x, rect.y, rect.w, PR_CHIP_H, RADIUS) - ctx.fillStyle = colors.pill - ctx.fill() - ctx.strokeStyle = colors.ring - ctx.lineWidth = 1 - ctx.stroke() - } else { - // The hairline sits in the gap before the chip, a touch shorter than it. - const x = Math.round(rect.x - PR_CHIP_GAP / 2) + 0.5 - ctx.beginPath() - ctx.moveTo(x, rect.y + 2) - ctx.lineTo(x, rect.y + PR_CHIP_H - 2) - ctx.strokeStyle = style.divider - ctx.lineWidth = 1 - ctx.stroke() - } + // The hairline sits in the gap before the chip, a touch shorter than it. + const divX = Math.round(rect.x - PR_CHIP_GAP / 2) + 0.5 + ctx.beginPath() + ctx.moveTo(divX, rect.y + 2) + ctx.lineTo(divX, rect.y + PR_CHIP_H - 2) + ctx.strokeStyle = style.divider + ctx.lineWidth = 1 + ctx.stroke() const midY = rect.y + PR_CHIP_H / 2 let x = rect.x + PAD_X const glyph = prChipGlyph(pr) if (glyph) { const size = glyphWidth(glyph) - drawGlyph(ctx, glyph, x, midY - size / 2, size, colors, pulse) + const color = style.inkGlyph ? style.ink : stateColor(glyph, colors) + drawGlyph(ctx, glyph, x, midY - size / 2, size, color, pulse) x += size + GLYPH_GAP } ctx.font = chipFont(colors.font) ctx.textBaseline = 'middle' ctx.textAlign = 'left' - ctx.fillStyle = style.kind === 'badge' ? colors.text : style.ink + ctx.fillStyle = style.ink ctx.fillText(`#${pr.number}`, x, midY + 0.5) } @@ -147,18 +140,17 @@ function stateColor(glyph: Exclude, colors: PrChipColors): string { return colors.failure } -/** A `size`-square glyph with its top-left at (x, y), in its state color. */ +/** A `size`-square glyph with its top-left at (x, y), in `color`. */ function drawGlyph( ctx: CanvasRenderingContext2D, glyph: Exclude, x: number, y: number, size: number, - colors: PrChipColors, + color: string, pulse: number ): void { ctx.save() - const color = stateColor(glyph, colors) if (glyph === 'pending') { ctx.globalAlpha *= pulse ctx.beginPath() diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index f6f89bb..a91cea0 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -93,9 +93,6 @@ export function readPalette(el: HTMLElement, dark: boolean): GraphPalette { tag: token('--pr-merged'), prChip: { font: css.fontFamily, - pill: token('--pr-pill-bg'), - ring: token('--pr-pill-ring'), - text: token('--fg-muted'), success: token('--st-added'), failure: token('--st-deleted'), pending: token('--st-modified'), @@ -1145,11 +1142,11 @@ function drawLabels( pr.info, palette.prChip, head - ? { kind: 'badge' } + ? { ink: palette.onAccent, divider: withAlpha(palette.onAccent, 0.4), inkGlyph: true } : { - kind: 'inline', ink: branchFill(palette, row.color, 0.9), - divider: branchFill(palette, row.color, 0.35) + divider: branchFill(palette, row.color, 0.35), + inkGlyph: false }, pulse ) From 1820de3eb911d9009fe259d4b15c4aaaf66018c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 21:45:09 +0200 Subject: [PATCH 08/10] Keep CI colors on the current branch's PR chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A white check and cross on the accent HEAD pill lost what makes CI glanceable: red means stop. The plain state colors sink because they're mid-luminance like the fill, so the HEAD glyph keeps its hue but is mixed toward the pill's ink — pale mint/coral on the light theme's blue, deeper green/red on the dark theme's lighter blue — derived from the theme tokens. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/graph/prChip.test.ts | 26 ++++++- src/renderer/src/components/graph/prChip.ts | 69 +++++++++++++++---- src/renderer/src/components/graph/render.ts | 32 ++++++--- 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/graph/prChip.test.ts b/src/renderer/src/components/graph/prChip.test.ts index 80c6358..c0b592a 100644 --- a/src/renderer/src/components/graph/prChip.test.ts +++ b/src/renderer/src/components/graph/prChip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { PullRequestInfo } from '@shared/types' -import { ciPulseAlpha, prChipGlyph } from './prChip' +import { ciPulseAlpha, mixHex, onAccentStates, prChipGlyph } from './prChip' function pr(overrides: Partial): PullRequestInfo { return { @@ -46,3 +46,27 @@ describe('ciPulseAlpha', () => { } }) }) + +describe('mixHex', () => { + test('blends two colors channel by channel', () => { + expect(mixHex('#ff0000', '#0000ff', 1)).toBe('#ff0000') + expect(mixHex('#ff0000', '#0000ff', 0)).toBe('#0000ff') + expect(mixHex('#ff0000', '#ffffff', 0.5)).toBe('#ff8080') + }) + + test('reads the short form and falls back to the second color', () => { + expect(mixHex('#f00', '#fff', 0.5)).toBe('#ff8080') + expect(mixHex('red', '#ffffff', 0.5)).toBe('#ffffff') + }) +}) + +describe('onAccentStates', () => { + test('pulls every state hue toward the accent pill ink', () => { + const states = { success: '#1a7f37', failure: '#cf222e', pending: '#9a6700', merged: '#8250df' } + const tuned = onAccentStates(states, '#ffffff') + for (const key of ['success', 'failure', 'pending', 'merged'] as const) { + expect(tuned[key]).toBe(mixHex(states[key], '#ffffff', 0.45)) + expect(tuned[key]).not.toBe(states[key]) + } + }) +}) diff --git a/src/renderer/src/components/graph/prChip.ts b/src/renderer/src/components/graph/prChip.ts index 3cdc4e3..00712cd 100644 --- a/src/renderer/src/components/graph/prChip.ts +++ b/src/renderer/src/components/graph/prChip.ts @@ -10,9 +10,12 @@ // label reads as a foreign sticker, worst at small zoom — just a hairline // divider and the glyph + number in the label's ink. On a tinted branch pill // the glyph keeps its state color: the label's opaque near-background base -// lets it contrast for any hue. On the solid accent HEAD pill the state colors -// would sink into the fill, so there the glyph takes the pill's ink too — the -// same thin mark, its shape (✓ / ✗ / pulsing dot / octicon) carrying the state. +// lets it contrast for any hue. On the solid accent HEAD pill the plain state +// colors sink: they're mid-luminance, like the fill. What's missing there is +// lightness contrast, not hue — so the HEAD glyph keeps its hue but is mixed +// toward the pill's ink (mixHex): a pale mint ✓ / coral ✗ on the light theme's +// blue, deeper ones on the dark theme's lighter blue. Red still means stop, and +// the mark contrasts like the pill's own text does. // // The running dot breathes like the badge's (ci-pulse, primitives.css): // ciPulseAlpha mirrors the keyframes, and the canvas only animates while such @@ -21,23 +24,65 @@ import type { PullRequestInfo } from '@shared/types' import { PR_CHIP_GAP, PR_CHIP_H } from './geometry' -/** The chip's slice of the graph palette (render.ts readPalette): the font - * family and the state colors. */ -export interface PrChipColors { - font: string +/** One set of glyph state colors. */ +export interface PrStateColors { success: string failure: string pending: string merged: string } +/** The chip's slice of the graph palette (render.ts readPalette): the font + * family, the state colors, and their accent-pill variants (onAccentStates). */ +export interface PrChipColors extends PrStateColors { + font: string + onAccent: PrStateColors +} + +/** How much of the state hue an accent-pill glyph keeps; the rest is the + * pill's ink. Enough hue that green/red stay unmistakable, enough ink that + * the mark clears the fill like the label text does. */ +const ON_ACCENT_HUE = 0.45 + +/** `a` mixed with `b` in sRGB: 1 = all `a`, 0 = all `b`. Hex in (#rgb or + * #rrggbb), hex out; an unparseable input falls back to `b`. */ +export function mixHex(a: string, b: string, amount: number): string { + const ca = parseHex(a) + const cb = parseHex(b) + if (!ca || !cb) return b + const channel = (i: number) => + Math.round(ca[i] * amount + cb[i] * (1 - amount)) + .toString(16) + .padStart(2, '0') + return `#${channel(0)}${channel(1)}${channel(2)}` +} + +function parseHex(color: string): [number, number, number] | null { + const m = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i) + if (!m) return null + const hex = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1] + const n = Number.parseInt(hex, 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} + +/** The state colors tuned for the accent HEAD pill: each hue pulled toward + * the pill's ink (see the file header). */ +export function onAccentStates(states: PrStateColors, ink: string): PrStateColors { + return { + success: mixHex(states.success, ink, ON_ACCENT_HUE), + failure: mixHex(states.failure, ink, ON_ACCENT_HUE), + pending: mixHex(states.pending, ink, ON_ACCENT_HUE), + merged: mixHex(states.merged, ink, ON_ACCENT_HUE) + } +} + /** How the chip sits in its label: its ink (the number), the divider's color, - * and whether the glyph wears that ink instead of its state color (the solid - * accent HEAD pill — see the file header). */ + * and whether it sits on the solid accent HEAD pill (glyph colors from + * PrChipColors.onAccent — see the file header). */ export interface PrChipStyle { ink: string divider: string - inkGlyph: boolean + onAccent: boolean } // .branch-pr metrics (toolbar.css): 10.5px/500 text, 4px side padding, 2px gap @@ -122,7 +167,7 @@ export function drawPrChip( const glyph = prChipGlyph(pr) if (glyph) { const size = glyphWidth(glyph) - const color = style.inkGlyph ? style.ink : stateColor(glyph, colors) + const color = stateColor(glyph, style.onAccent ? colors.onAccent : colors) drawGlyph(ctx, glyph, x, midY - size / 2, size, color, pulse) x += size + GLYPH_GAP } @@ -133,7 +178,7 @@ export function drawPrChip( ctx.fillText(`#${pr.number}`, x, midY + 0.5) } -function stateColor(glyph: Exclude, colors: PrChipColors): string { +function stateColor(glyph: Exclude, colors: PrStateColors): string { if (glyph === 'success') return colors.success if (glyph === 'pending') return colors.pending if (glyph === 'merged') return colors.merged diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index a91cea0..ed32ca3 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -46,7 +46,14 @@ import { rowMatchesSelection } from './layout' import { type BackportLink, linkedHashes } from './links' -import { ciPulseAlpha, drawPrChip, measurePrChip, type PrChipColors, prChipGlyph } from './prChip' +import { + ciPulseAlpha, + drawPrChip, + measurePrChip, + onAccentStates, + type PrChipColors, + prChipGlyph +} from './prChip' import { ACTIVE_GLOW, HIT_GLOW, @@ -91,14 +98,19 @@ export function readPalette(el: HTMLElement, dark: boolean): GraphPalette { subject: token('--fg-muted'), labelBg: token('--bg-elevated'), tag: token('--pr-merged'), - prChip: { - font: css.fontFamily, - success: token('--st-added'), - failure: token('--st-deleted'), - pending: token('--st-modified'), - merged: token('--pr-merged') - } + prChip: prChipColors(css.fontFamily, token) + } +} + +/** The PR chip's colors: the state tokens, plus their accent-pill variants. */ +function prChipColors(font: string, token: (name: string) => string): PrChipColors { + const states = { + success: token('--st-added'), + failure: token('--st-deleted'), + pending: token('--st-modified'), + merged: token('--pr-merged') } + return { font, ...states, onAccent: onAccentStates(states, token('--on-accent')) } } // One hue per palette slot; slot 0 (the mainline) is the app's blue family. @@ -1142,11 +1154,11 @@ function drawLabels( pr.info, palette.prChip, head - ? { ink: palette.onAccent, divider: withAlpha(palette.onAccent, 0.4), inkGlyph: true } + ? { ink: palette.onAccent, divider: withAlpha(palette.onAccent, 0.4), onAccent: true } : { ink: branchFill(palette, row.color, 0.9), divider: branchFill(palette, row.color, 0.35), - inkGlyph: false + onAccent: false }, pulse ) From 44152930c2b2e04e308813f5a5aed41fa871c8b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 22:21:34 +0200 Subject: [PATCH 09/10] Mark draft PRs with GitHub's draft octicon The badge showed nothing for a draft with no checks, and the hovercard faked drafts with a gray open-PR icon. Add GitHub's git-pull-request-draft octicon: the hovercard uses it for drafts, and the badge leads with it when a draft has no CI yet (CI still wins once checks run). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/common/PrHoverCard.tsx | 35 ++++++++++++------- src/renderer/src/lib/icons.tsx | 7 ++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/common/PrHoverCard.tsx b/src/renderer/src/components/common/PrHoverCard.tsx index 75cdc8f..ffb9469 100644 --- a/src/renderer/src/components/common/PrHoverCard.tsx +++ b/src/renderer/src/components/common/PrHoverCard.tsx @@ -27,11 +27,16 @@ function CiStatus({ state }: { state: PullRequestChecks }) { ) } -/** The leading state glyph for a PR, shared by the badge and the hovercard: the - * green/red/amber CI rollup for open PRs (nothing when no checks ran), or - * GitHub's merged/closed octicon (no CI dot — that CI is long settled). */ +/** The leading state glyph for a PR's badge: the green/red/amber CI rollup + * for an open PR; with no checks yet, GitHub's draft octicon for a draft (a + * ready PR shows nothing); or the merged/closed octicon once it settles (no + * CI — that is long settled). The Graph's label chips follow the same rule + * (graph/prChip.ts prChipGlyph). */ export function PrGlyph({ pr }: { pr: PullRequestInfo }) { - if (pr.state === 'open') return pr.checks ? : null + if (pr.state === 'open') { + if (pr.checks) return + return pr.draft ? : null + } return ( {pr.state === 'merged' ? : } @@ -39,17 +44,23 @@ export function PrGlyph({ pr }: { pr: PullRequestInfo }) { ) } -/** The hovercard's leading state glyph: GitHub's open / merged / closed pull- - * request octicon, tinted by state (green / muted draft / purple / red). Unlike - * the badge's CI-rollup glyph, this always shows — it's the row's only state cue - * now that the text label is gone. */ -function PrStateIcon({ pr }: { pr: PullRequestInfo }) { +const STATE_OCTICON = { + open: Icon.PrOpen, + draft: Icon.PrDraft, + merged: Icon.PrMerged, + closed: Icon.PrClosed +} + +/** A PR's state octicon — open / draft / merged / closed, tinted by state + * (green / muted / purple / red), exactly as github.com draws them. The + * hovercard rows lead with it (always: it's the row's only state cue); the + * badge borrows the draft one. */ +function PrStateIcon({ pr, size = 13 }: { pr: PullRequestInfo; size?: number }) { const state = pr.state === 'open' && pr.draft ? 'draft' : pr.state - const Glyph = - pr.state === 'merged' ? Icon.PrMerged : pr.state === 'closed' ? Icon.PrClosed : Icon.PrOpen + const Glyph = STATE_OCTICON[state] return ( - + ) } diff --git a/src/renderer/src/lib/icons.tsx b/src/renderer/src/lib/icons.tsx index 4c5ee47..1896a68 100644 --- a/src/renderer/src/lib/icons.tsx +++ b/src/renderer/src/lib/icons.tsx @@ -354,6 +354,13 @@ export const Icon = { ), + // GitHub's draft pull-request octicon (git-pull-request-draft: the dashed + // path to the target) — how github.com marks a draft. + PrDraft: ({ size = 16, ...p }: IconProps) => ( + + + + ), // GitHub's pull-request (open) octicon, matching the merged/closed pair. PrOpen: ({ size = 16, ...p }: IconProps) => ( From 63bcfa19e673cd963509f846ad0badc3489069e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 22:21:34 +0200 Subject: [PATCH 10/10] Mark the current branch with a home cap, tinted like every label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No state color sits well on the solid accent pill, so the current branch no longer is one: its label is tinted in the accent like any other label and leads with a solid cap carrying the home glyph — the same house the HEAD commit wears. PR glyphs are now identical on every branch, red and green intact. The graph chip also shows the draft octicon for a draft with no checks, matching the badge. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/graph/geometry.test.ts | 12 +- src/renderer/src/components/graph/geometry.ts | 16 +- src/renderer/src/components/graph/layout.ts | 13 +- .../src/components/graph/prChip.test.ts | 33 +--- src/renderer/src/components/graph/prChip.ts | 157 +++++++----------- src/renderer/src/components/graph/render.ts | 115 ++++++++----- 6 files changed, 173 insertions(+), 173 deletions(-) diff --git a/src/renderer/src/components/graph/geometry.test.ts b/src/renderer/src/components/graph/geometry.test.ts index 7b92674..3e6d0de 100644 --- a/src/renderer/src/components/graph/geometry.test.ts +++ b/src/renderer/src/components/graph/geometry.test.ts @@ -12,6 +12,7 @@ import { contentSize, HEADER_H, hitTest, + LABEL_CAP_W, LABEL_GAP, LABEL_H, LABEL_MIN_SCALE, @@ -377,7 +378,7 @@ describe('PR chips', () => { test('the chip nests in the pill right end with an even inset', () => { const { row } = feature() - const label = labelRect(row, labelContentWidth(40, 30)) + const label = labelRect(row, labelContentWidth(40, 30, false)) const chip = prChipRect(label, 30) const inset = (LABEL_H - PR_CHIP_H) / 2 expect(chip.y - label.y).toBe(inset) @@ -387,12 +388,17 @@ describe('PR chips', () => { }) test('a label without a chip is just its name', () => { - expect(labelContentWidth(40, 0)).toBe(40) + expect(labelContentWidth(40, 0, false)).toBe(40) + }) + + test('the current branch label makes room for its home cap', () => { + expect(labelContentWidth(40, 0, true)).toBe(40 + LABEL_CAP_W) + expect(labelContentWidth(40, 30, true) - labelContentWidth(40, 30, false)).toBe(LABEL_CAP_W) }) test('the chip is its own hit target, the name stays the label', () => { const { layout, row } = feature() - const width = labelContentWidth(40, 30) + const width = labelContentWidth(40, 30, false) const label = labelRect(row, width) const chip = prChipRect(label, 30) const at = (x: number) => diff --git a/src/renderer/src/components/graph/geometry.ts b/src/renderer/src/components/graph/geometry.ts index dbfbfab..1bcfd99 100644 --- a/src/renderer/src/components/graph/geometry.ts +++ b/src/renderer/src/components/graph/geometry.ts @@ -25,6 +25,10 @@ export const LABEL_H = 18 export const LABEL_GAP = 4 /** Label pill: horizontal padding either side of its content. */ export const LABEL_PAD_X = 8 +/** The current branch's label leads with a square solid CAP carrying the home + * glyph (render.ts drawLabels) — "you are here", the same house the HEAD + * commit wears. */ +export const LABEL_CAP_W = LABEL_H /** Below this zoom branch labels aren't drawn (an overview reads by shape, * not by name) — and so they neither hit-test nor ask the host for PRs. */ export const LABEL_MIN_SCALE = 0.4 @@ -37,11 +41,13 @@ export const PR_CHIP_H = 14 const PR_CHIP_INSET = (LABEL_H - PR_CHIP_H) / 2 /** A label's content width: the branch name, plus its PR chip when it has - * one (0 = none). The chip replaces the pill's right padding with its inset, - * so labelRect's symmetric padding still frames it exactly. */ -export function labelContentWidth(nameWidth: number, chipWidth: number): number { - if (chipWidth <= 0) return nameWidth - return nameWidth + PR_CHIP_GAP + chipWidth - (LABEL_PAD_X - PR_CHIP_INSET) + * one (0 = none), plus the current branch's leading cap. The chip replaces + * the pill's right padding with its inset, so labelRect's symmetric padding + * still frames it exactly. */ +export function labelContentWidth(nameWidth: number, chipWidth: number, capped: boolean): number { + const cap = capped ? LABEL_CAP_W : 0 + if (chipWidth <= 0) return cap + nameWidth + return cap + nameWidth + PR_CHIP_GAP + chipWidth - (LABEL_PAD_X - PR_CHIP_INSET) } /** Branch container capsule: horizontal padding past the outer nodes, and * half its height. Shared by the renderer and hit-testing — the capsule is diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index ec1d933..0e6f990 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -30,7 +30,7 @@ import type { Commit } from '@shared/types' import { type CommitRef, parseRefs } from '@/lib/format' // Value import from geometry is safe: geometry's layout imports are type-only. -import { COL_W } from './geometry' +import { COL_W, LABEL_CAP_W } from './geometry' import { branchFromPrMergeSubject, type LandedPr, landedPrOf } from './landedPr' import { type PackChain, packRows, type VerticalStub } from './packing' import { compareReleaseVersions, releaseVersionWithOverride } from './releases' @@ -212,8 +212,9 @@ export interface GraphInput { * by platform font and only exist after first paint). Mirrors render.ts * labelWidthFor's 6.2 px/char fallback plus the pill's 16px padding and a * little air before the next pill. */ -function labelColumns(name: string, prChip: boolean): number { - return Math.ceil((name.length * 6.2 + 16 + 8 + (prChip ? PR_CHIP_RESERVE : 0)) / COL_W) +function labelColumns(name: string, prChip: boolean, capped: boolean): number { + const extras = (prChip ? PR_CHIP_RESERVE : 0) + (capped ? LABEL_CAP_W : 0) + return Math.ceil((name.length * 6.2 + 16 + 8 + extras) / COL_W) } /** Room a label reserves for its PR chip: the octicon, its gaps and a @@ -658,7 +659,11 @@ export function layoutGraph(input: GraphInput): GraphLayout { capEnd: span[id].end, labelEnd: span[id].start + - labelColumns(chain.name, input.reservePrChips === true && canCarryPr(chain.kind)) - + labelColumns( + chain.name, + input.reservePrChips === true && canCarryPr(chain.kind), + id === headChain + ) - 1, parent: parentChainOf(id) ?? null, releaseRank: releaseRank.get(id) ?? null, diff --git a/src/renderer/src/components/graph/prChip.test.ts b/src/renderer/src/components/graph/prChip.test.ts index c0b592a..6991d46 100644 --- a/src/renderer/src/components/graph/prChip.test.ts +++ b/src/renderer/src/components/graph/prChip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { PullRequestInfo } from '@shared/types' -import { ciPulseAlpha, mixHex, onAccentStates, prChipGlyph } from './prChip' +import { ciPulseAlpha, prChipGlyph } from './prChip' function pr(overrides: Partial): PullRequestInfo { return { @@ -22,7 +22,12 @@ describe('prChipGlyph', () => { expect(prChipGlyph(pr({ checks: 'success' }))).toBe('success') expect(prChipGlyph(pr({ checks: 'failure' }))).toBe('failure') expect(prChipGlyph(pr({ checks: 'pending' }))).toBe('pending') - expect(prChipGlyph(pr({ checks: null, draft: true }))).toBeNull() + expect(prChipGlyph(pr({ checks: null }))).toBeNull() + }) + + test('a draft shows its CI once checks run, the draft octicon before', () => { + expect(prChipGlyph(pr({ draft: true, checks: 'failure' }))).toBe('failure') + expect(prChipGlyph(pr({ draft: true, checks: null }))).toBe('draft') }) test('a settled PR leads with its state octicon, never stale CI', () => { @@ -46,27 +51,3 @@ describe('ciPulseAlpha', () => { } }) }) - -describe('mixHex', () => { - test('blends two colors channel by channel', () => { - expect(mixHex('#ff0000', '#0000ff', 1)).toBe('#ff0000') - expect(mixHex('#ff0000', '#0000ff', 0)).toBe('#0000ff') - expect(mixHex('#ff0000', '#ffffff', 0.5)).toBe('#ff8080') - }) - - test('reads the short form and falls back to the second color', () => { - expect(mixHex('#f00', '#fff', 0.5)).toBe('#ff8080') - expect(mixHex('red', '#ffffff', 0.5)).toBe('#ffffff') - }) -}) - -describe('onAccentStates', () => { - test('pulls every state hue toward the accent pill ink', () => { - const states = { success: '#1a7f37', failure: '#cf222e', pending: '#9a6700', merged: '#8250df' } - const tuned = onAccentStates(states, '#ffffff') - for (const key of ['success', 'failure', 'pending', 'merged'] as const) { - expect(tuned[key]).toBe(mixHex(states[key], '#ffffff', 0.45)) - expect(tuned[key]).not.toBe(states[key]) - } - }) -}) diff --git a/src/renderer/src/components/graph/prChip.ts b/src/renderer/src/components/graph/prChip.ts index 00712cd..64352b6 100644 --- a/src/renderer/src/components/graph/prChip.ts +++ b/src/renderer/src/components/graph/prChip.ts @@ -2,20 +2,19 @@ // `#123` badge (.branch-pr in toolbar.css) — same glyph geometry, same sizes, // same colors — so a PR reads identically everywhere in the app. Only the // leading glyph carries meaning: the CI rollup for an open PR (✓ passing, -// ✗ failing, a pulsing amber dot running; nothing when no checks ran), GitHub's -// merged / closed octicon otherwise. Drawn in WORLD space inside the label -// pill, so it zooms with it. +// ✗ failing, a pulsing amber dot running), GitHub's draft octicon for a draft +// with no checks yet, nothing for a ready PR with no checks, and GitHub's +// merged / closed octicon once it settles. Drawn in WORLD space inside the +// label pill, so it zooms with it. // -// Unlike the badge it wears no pill of its own — a neutral patch on a tinted -// label reads as a foreign sticker, worst at small zoom — just a hairline -// divider and the glyph + number in the label's ink. On a tinted branch pill -// the glyph keeps its state color: the label's opaque near-background base -// lets it contrast for any hue. On the solid accent HEAD pill the plain state -// colors sink: they're mid-luminance, like the fill. What's missing there is -// lightness contrast, not hue — so the HEAD glyph keeps its hue but is mixed -// toward the pill's ink (mixHex): a pale mint ✓ / coral ✗ on the light theme's -// blue, deeper ones on the dark theme's lighter blue. Red still means stop, and -// the mark contrasts like the pill's own text does. +// It wears no pill of its own — a neutral patch on a tinted label reads as a +// foreign sticker, worst at small zoom — just a hairline divider and the glyph +// + number in the label's ink. On a tinted branch pill the glyph sits bare: the +// label's opaque near-background base lets the state colors contrast for any +// hue — the current branch's included: it is tinted in the accent like any +// other label (its identity is the solid home cap it leads with, render.ts +// drawHomeCap), so no state color ever lands on a saturated fill. The glyph +// is exactly the same on every branch, red and green intact. // // The running dot breathes like the badge's (ci-pulse, primitives.css): // ciPulseAlpha mirrors the keyframes, and the canvas only animates while such @@ -24,70 +23,27 @@ import type { PullRequestInfo } from '@shared/types' import { PR_CHIP_GAP, PR_CHIP_H } from './geometry' -/** One set of glyph state colors. */ -export interface PrStateColors { +/** The chip's slice of the graph palette (render.ts readPalette): the font + * family and the state colors. */ +export interface PrChipColors { + font: string success: string failure: string pending: string merged: string + draft: string } -/** The chip's slice of the graph palette (render.ts readPalette): the font - * family, the state colors, and their accent-pill variants (onAccentStates). */ -export interface PrChipColors extends PrStateColors { - font: string - onAccent: PrStateColors -} - -/** How much of the state hue an accent-pill glyph keeps; the rest is the - * pill's ink. Enough hue that green/red stay unmistakable, enough ink that - * the mark clears the fill like the label text does. */ -const ON_ACCENT_HUE = 0.45 - -/** `a` mixed with `b` in sRGB: 1 = all `a`, 0 = all `b`. Hex in (#rgb or - * #rrggbb), hex out; an unparseable input falls back to `b`. */ -export function mixHex(a: string, b: string, amount: number): string { - const ca = parseHex(a) - const cb = parseHex(b) - if (!ca || !cb) return b - const channel = (i: number) => - Math.round(ca[i] * amount + cb[i] * (1 - amount)) - .toString(16) - .padStart(2, '0') - return `#${channel(0)}${channel(1)}${channel(2)}` -} - -function parseHex(color: string): [number, number, number] | null { - const m = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i) - if (!m) return null - const hex = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1] - const n = Number.parseInt(hex, 16) - return [(n >> 16) & 255, (n >> 8) & 255, n & 255] -} - -/** The state colors tuned for the accent HEAD pill: each hue pulled toward - * the pill's ink (see the file header). */ -export function onAccentStates(states: PrStateColors, ink: string): PrStateColors { - return { - success: mixHex(states.success, ink, ON_ACCENT_HUE), - failure: mixHex(states.failure, ink, ON_ACCENT_HUE), - pending: mixHex(states.pending, ink, ON_ACCENT_HUE), - merged: mixHex(states.merged, ink, ON_ACCENT_HUE) - } -} - -/** How the chip sits in its label: its ink (the number), the divider's color, - * and whether it sits on the solid accent HEAD pill (glyph colors from - * PrChipColors.onAccent — see the file header). */ +/** How the chip sits in its label: its ink (the number) and the divider's + * color — both in the label's own hue. */ export interface PrChipStyle { ink: string divider: string - onAccent: boolean } // .branch-pr metrics (toolbar.css): 10.5px/500 text, 4px side padding, 2px gap -// after the glyph; CiStatus draws its check/cross at 10px, -// the octicons at 11px, the running dot at 6px. +// after the glyph; CiStatus draws its check/cross at 10px, the octicons at +// 11px, the running dot at 6px. const CHIP_FONT = 10.5 const PAD_X = 4 const GLYPH_GAP = 2 @@ -101,21 +57,27 @@ const CHECK_D = 'm5 12 5 5L20 6' const CROSS_D = 'M6 6 18 18M18 6 6 18' const ICON_STROKE = 1.7 -// GitHub's merged / closed pull-request octicons on their 16-unit grid — the -// same paths as Icon.PrMerged / Icon.PrClosed. Path2D is built lazily: it -// doesn't exist outside a browser (tests import this module). +// GitHub's merged / closed / draft pull-request octicons on their 16-unit grid +// — the same paths as Icon.PrMerged / Icon.PrClosed / Icon.PrDraft. Path2D is +// built lazily: it doesn't exist outside a browser (tests import this module). const MERGED_D = 'M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z' const CLOSED_D = 'M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm0 11a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0-9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM11.25 9.5a.75.75 0 0 1 .75.75v.378a2.251 2.251 0 1 1-1.5 0V10.25a.75.75 0 0 1 .75-.75Zm0 4a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM9.22 1.227a.75.75 0 0 1 1.06 0l.97.97.97-.97a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l.97-.97-.97-.97a.75.75 0 0 1 0-1.06Z' -let glyphPaths: Record<'success' | 'failure' | 'merged' | 'closed', Path2D> | null = null - -type Glyph = 'success' | 'failure' | 'pending' | 'merged' | 'closed' | null - -/** The leading glyph for a PR — the badge's rule (PrHoverCard PrGlyph). */ -export function prChipGlyph(pr: PullRequestInfo): Glyph { - if (pr.state === 'open') return pr.checks - return pr.state +const DRAFT_D = + 'M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 14a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM14 7.5a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm0-4.25a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z' +type Octicon = 'merged' | 'closed' | 'draft' +let glyphPaths: Record<'success' | 'failure' | Octicon, Path2D> | null = null + +type Glyph = 'success' | 'failure' | 'pending' | Octicon +const isOcticon = (glyph: Glyph): glyph is Octicon => + glyph === 'merged' || glyph === 'closed' || glyph === 'draft' + +/** The leading glyph for a PR — the badge's rule (PrHoverCard PrGlyph): CI + * first, then a draft's octicon, then (settled PRs) the state octicon. */ +export function prChipGlyph(pr: PullRequestInfo): Glyph | null { + if (pr.state !== 'open') return pr.state + return pr.checks ?? (pr.draft ? 'draft' : null) } /** The badge's ci-pulse period (primitives.css). */ @@ -130,17 +92,21 @@ export function ciPulseAlpha(ms: number): number { const chipFont = (family: string) => `500 ${CHIP_FONT}px ${family}` -function glyphWidth(glyph: Exclude): number { +function glyphSize(glyph: Glyph): number { if (glyph === 'pending') return DOT - return glyph === 'merged' || glyph === 'closed' ? OCTICON : CI_ICON + return isOcticon(glyph) ? OCTICON : CI_ICON +} + +/** The glyph's slot width plus the gap after it; 0 without a glyph. */ +function glyphAdvance(glyph: Glyph | null): number { + return glyph ? glyphSize(glyph) + GLYPH_GAP : 0 } /** The chip's width for `pr`, text measured in the chip font. */ export function measurePrChip(ctx: CanvasRenderingContext2D, family: string, pr: PullRequestInfo) { ctx.font = chipFont(family) const text = ctx.measureText(`#${pr.number}`).width - const glyph = prChipGlyph(pr) - return PAD_X * 2 + text + (glyph ? glyphWidth(glyph) + GLYPH_GAP : 0) + return PAD_X * 2 + glyphAdvance(prChipGlyph(pr)) + text } /** Paint the chip for `pr` into `rect` (world space, from geometry prChipRect). */ @@ -163,43 +129,41 @@ export function drawPrChip( ctx.stroke() const midY = rect.y + PR_CHIP_H / 2 - let x = rect.x + PAD_X const glyph = prChipGlyph(pr) if (glyph) { - const size = glyphWidth(glyph) - const color = stateColor(glyph, style.onAccent ? colors.onAccent : colors) - drawGlyph(ctx, glyph, x, midY - size / 2, size, color, pulse) - x += size + GLYPH_GAP + const cx = rect.x + PAD_X + glyphSize(glyph) / 2 + drawGlyph(ctx, glyph, cx, midY, stateColor(glyph, colors), pulse) } ctx.font = chipFont(colors.font) ctx.textBaseline = 'middle' ctx.textAlign = 'left' ctx.fillStyle = style.ink - ctx.fillText(`#${pr.number}`, x, midY + 0.5) + ctx.fillText(`#${pr.number}`, rect.x + PAD_X + glyphAdvance(glyph), midY + 0.5) } -function stateColor(glyph: Exclude, colors: PrStateColors): string { +function stateColor(glyph: Glyph, colors: PrChipColors): string { if (glyph === 'success') return colors.success if (glyph === 'pending') return colors.pending if (glyph === 'merged') return colors.merged + if (glyph === 'draft') return colors.draft return colors.failure } -/** A `size`-square glyph with its top-left at (x, y), in `color`. */ +/** A glyph centered on (cx, cy), at its badge size, in `color`. */ function drawGlyph( ctx: CanvasRenderingContext2D, - glyph: Exclude, - x: number, - y: number, - size: number, + glyph: Glyph, + cx: number, + cy: number, color: string, pulse: number ): void { + const size = glyphSize(glyph) ctx.save() if (glyph === 'pending') { ctx.globalAlpha *= pulse ctx.beginPath() - ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2) + ctx.arc(cx, cy, size / 2, 0, Math.PI * 2) ctx.fillStyle = color ctx.fill() ctx.restore() @@ -209,10 +173,11 @@ function drawGlyph( success: new Path2D(CHECK_D), failure: new Path2D(CROSS_D), merged: new Path2D(MERGED_D), - closed: new Path2D(CLOSED_D) + closed: new Path2D(CLOSED_D), + draft: new Path2D(DRAFT_D) } - ctx.translate(x, y) - if (glyph === 'merged' || glyph === 'closed') { + ctx.translate(cx - size / 2, cy - size / 2) + if (isOcticon(glyph)) { ctx.scale(size / 16, size / 16) ctx.fillStyle = color ctx.fill(glyphPaths[glyph]) diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index ed32ca3..36bf3e4 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -24,6 +24,7 @@ import { captionCenterOffset, columnsToNext, HEADER_H, + LABEL_CAP_W, LABEL_MIN_SCALE, LABEL_PAD_X, labelContentWidth, @@ -46,14 +47,7 @@ import { rowMatchesSelection } from './layout' import { type BackportLink, linkedHashes } from './links' -import { - ciPulseAlpha, - drawPrChip, - measurePrChip, - onAccentStates, - type PrChipColors, - prChipGlyph -} from './prChip' +import { ciPulseAlpha, drawPrChip, measurePrChip, type PrChipColors, prChipGlyph } from './prChip' import { ACTIVE_GLOW, HIT_GLOW, @@ -102,15 +96,17 @@ export function readPalette(el: HTMLElement, dark: boolean): GraphPalette { } } -/** The PR chip's colors: the state tokens, plus their accent-pill variants. */ +/** The PR chip's colors: the badge's state tokens (primitives.css + * .ci-status--*), and the label surface its HEAD well is cut from. */ function prChipColors(font: string, token: (name: string) => string): PrChipColors { - const states = { + return { + font, success: token('--st-added'), failure: token('--st-deleted'), pending: token('--st-modified'), - merged: token('--pr-merged') + merged: token('--pr-merged'), + draft: token('--fg-muted') } - return { font, ...states, onAccent: onAccentStates(states, token('--on-accent')) } } // One hue per palette slot; slot 0 (the mainline) is the app's blue family. @@ -288,7 +284,7 @@ export function prChipWidthFor(row: GraphRow): number { /** A row's full label content — name plus PR chip — as last drawn; what * hit-testing and pan/fit bounds must agree with. */ export function labelContentWidthFor(row: GraphRow): number { - return labelContentWidth(labelWidthFor(row.name), prChipWidthFor(row)) + return labelContentWidth(labelWidthFor(row.name), prChipWidthFor(row), row.isHead) } // Caption widths (SCREEN px) as last drawn, keyed by commit hash; 0 = culled. @@ -341,7 +337,7 @@ function computeLabelBoxes(ctx: CanvasRenderingContext2D, scene: SceneState): La const pr = info ? { info, width: measurePrChip(ctx, palette.font, info) } : null if (pr) prChipWidths.set(row.chain, pr.width) else prChipWidths.delete(row.chain) - const rect = labelRect(row, labelContentWidth(width, pr?.width ?? 0), leftClamp) + const rect = labelRect(row, labelContentWidth(width, pr?.width ?? 0, row.isHead), leftClamp) return { row, rect, sticky: rect.x > nodeX(row.startColumn) - NODE_R + 0.5, pr } }) } @@ -890,23 +886,38 @@ function drawHomeBadge( // badge glyphs need more surrounding air than a bare toolbar icon. // Keep the two in sync: one symbol, two sizes. const s = r / 13 - ctx.strokeStyle = palette.accent - ctx.lineWidth = Math.max(1.1, 1.7 * s) + strokeHouse(ctx, sx, sy, s, palette.accent, Math.max(1.1, 1.7 * s)) + ctx.restore() +} + +/** The home glyph — Icon.Home's single-outline house (walls, roof, door + * notched into the bottom edge) on a 13-unit box centered on (cx, cy), scaled + * by `s`. Shared by the HEAD commit's badge and the current branch's label + * cap: one symbol for "you are here", wherever it shows. */ +function strokeHouse( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + s: number, + color: string, + lineWidth: number +): void { + ctx.strokeStyle = color + ctx.lineWidth = lineWidth ctx.lineJoin = 'round' ctx.lineCap = 'round' ctx.beginPath() - ctx.moveTo(sx - 6.5 * s, sy + 6.5 * s) - ctx.lineTo(sx - 6.5 * s, sy - 2 * s) - ctx.lineTo(sx, sy - 6.5 * s) - ctx.lineTo(sx + 6.5 * s, sy - 2 * s) - ctx.lineTo(sx + 6.5 * s, sy + 6.5 * s) - ctx.lineTo(sx + 1.7 * s, sy + 6.5 * s) - ctx.lineTo(sx + 1.7 * s, sy + 2.8 * s) - ctx.lineTo(sx - 1.7 * s, sy + 2.8 * s) - ctx.lineTo(sx - 1.7 * s, sy + 6.5 * s) + ctx.moveTo(cx - 6.5 * s, cy + 6.5 * s) + ctx.lineTo(cx - 6.5 * s, cy - 2 * s) + ctx.lineTo(cx, cy - 6.5 * s) + ctx.lineTo(cx + 6.5 * s, cy - 2 * s) + ctx.lineTo(cx + 6.5 * s, cy + 6.5 * s) + ctx.lineTo(cx + 1.7 * s, cy + 6.5 * s) + ctx.lineTo(cx + 1.7 * s, cy + 2.8 * s) + ctx.lineTo(cx - 1.7 * s, cy + 2.8 * s) + ctx.lineTo(cx - 1.7 * s, cy + 6.5 * s) ctx.closePath() ctx.stroke() - ctx.restore() } /** Caption text (commit subjects, the WIP "uncommitted") drawn in SCREEN space @@ -1134,19 +1145,25 @@ function drawLabels( ctx.fill() } ctx.globalAlpha = inkAlpha - ctx.fillStyle = head ? palette.accent : branchFill(palette, row.color, 0.15) + // Every label is tinted — the current branch too, in the accent, so the + // PR glyphs read the same on all of them; the current one leads with a + // solid home cap instead of going solid (drawHomeCap). + ctx.fillStyle = head ? withAlpha(palette.accent, 0.15) : branchFill(palette, row.color, 0.15) ctx.fill() - if (!head) { - ctx.strokeStyle = branchFill(palette, row.color, 0.55) - ctx.lineWidth = 1 - if (row.kind === 'unnamed') ctx.setLineDash([3, 2]) - ctx.stroke() - ctx.setLineDash([]) - } + ctx.strokeStyle = head ? palette.accent : branchFill(palette, row.color, 0.55) + ctx.lineWidth = 1 + if (row.kind === 'unnamed') ctx.setLineDash([3, 2]) + ctx.stroke() + ctx.setLineDash([]) + if (head) drawHomeCap(ctx, palette, rect) // Per label: the previous label's PR chip left its own font set. ctx.font = `600 ${LABEL_FONT}px ${palette.font}` - ctx.fillStyle = head ? palette.onAccent : branchText(palette, row.color) - ctx.fillText(row.name, rect.x + LABEL_PAD_X, rect.y + rect.h / 2 + 0.5) + ctx.fillStyle = head ? palette.accent : branchText(palette, row.color) + ctx.fillText( + row.name, + rect.x + (head ? LABEL_CAP_W : 0) + LABEL_PAD_X, + rect.y + rect.h / 2 + 0.5 + ) if (pr) { drawPrChip( ctx, @@ -1154,11 +1171,10 @@ function drawLabels( pr.info, palette.prChip, head - ? { ink: palette.onAccent, divider: withAlpha(palette.onAccent, 0.4), onAccent: true } + ? { ink: withAlpha(palette.accent, 0.9), divider: withAlpha(palette.accent, 0.35) } : { ink: branchFill(palette, row.color, 0.9), - divider: branchFill(palette, row.color, 0.35), - onAccent: false + divider: branchFill(palette, row.color, 0.35) }, pulse ) @@ -1169,6 +1185,27 @@ function drawLabels( } } +/** The current branch's leading cap: the label's first LABEL_CAP_W in the + * solid accent, the home glyph knocked out of it in the pill's on-accent ink — + * "you are here", the same house the HEAD commit wears. */ +function drawHomeCap( + ctx: CanvasRenderingContext2D, + palette: GraphPalette, + rect: { x: number; y: number; w: number; h: number } +): void { + ctx.save() + ctx.beginPath() + ctx.rect(rect.x - 1, rect.y - 1, LABEL_CAP_W + 1, rect.h + 2) + ctx.clip() + ctx.beginPath() + ctx.roundRect(rect.x, rect.y, rect.w, rect.h, 5) + ctx.fillStyle = palette.accent + ctx.fill() + ctx.restore() + // A house ~9px across: the 13-unit glyph at s = 0.7, a hair past optical center. + strokeHouse(ctx, rect.x + LABEL_CAP_W / 2, rect.y + rect.h / 2, 0.7, palette.onAccent, 1.3) +} + /** True when a world rect intersects the visible stage (below the header). */ function onScreen(scene: SceneState, r: { x: number; y: number; w: number; h: number }): boolean { const { view } = scene