diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 47a1ac0..6ab3ba1 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' @@ -72,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. @@ -423,6 +429,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 +1484,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 +1603,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} @@ -1632,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) => { @@ -1647,6 +1662,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/common/PrHoverCard.tsx b/src/renderer/src/components/common/PrHoverCard.tsx new file mode 100644 index 0000000..ffb9469 --- /dev/null +++ b/src/renderer/src/components/common/PrHoverCard.tsx @@ -0,0 +1,248 @@ +// 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'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') { + if (pr.checks) return + return pr.draft ? : null + } + return ( + + {pr.state === 'merged' ? : } + + ) +} + +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 = STATE_OCTICON[state] + 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/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index 845f31f..43be21d 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,15 @@ import { computeDayMarks, drawScene, type GraphPalette, - labelWidthFor, + labelContentWidthFor, + prChipsPulsing, + 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 +91,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 @@ -95,9 +111,20 @@ 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. */ +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 +192,9 @@ export function GraphCanvas({ hitTags, changesCount, links, + rowPrs, + githubWebUrl, + onLabelsInView, controls, onSelectNode, onNodeMenu, @@ -217,7 +247,8 @@ export function GraphCanvas({ hitTags, wip, dayMarks, - links + links, + rowPrs }) sceneRef.current = { layout, @@ -229,8 +260,17 @@ export function GraphCanvas({ hitTags, wip, dayMarks, - links + links, + rowPrs } + 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 const draw = useCallback(() => { const canvas = canvasRef.current @@ -259,8 +299,31 @@ export function GraphCanvas({ matchPulse: matchPulseRef.current, wip: s.wip, dayMarks: s.dayMarks, - links: s.links + links: s.links, + 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) + 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(() => { @@ -270,12 +333,15 @@ export function GraphCanvas({ draw() }) }, [draw]) + invalidateRef.current = invalidate 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 +369,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 +428,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 +503,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 +606,7 @@ export function GraphCanvas({ hitTags, wip, links, + rowPrs, theme, invalidate ]) @@ -569,6 +638,8 @@ export function GraphCanvas({ useEffect( () => () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) + clearTimeout(labelsSettleRef.current) + clearTimeout(pulseFrameRef.current) }, [] ) @@ -582,7 +653,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 +666,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 +725,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 +756,7 @@ export function GraphCanvas({ wrapRef.current?.setPointerCapture(e.pointerId) setCursor('grabbing') setHover(null, null) + prCard.close() } if (pan.panned) { panBy(dx, dy) @@ -683,6 +773,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 +810,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 +842,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 +854,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 +885,7 @@ export function GraphCanvas({ zoomAnim.stop() panBy(-e.deltaX, -e.deltaY) setTooltip(null) + prCard.close() } } @@ -878,7 +982,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 +1036,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..3e6d0de 100644 --- a/src/renderer/src/components/graph/geometry.test.ts +++ b/src/renderer/src/components/graph/geometry.test.ts @@ -12,15 +12,22 @@ import { contentSize, HEADER_H, hitTest, + LABEL_CAP_W, 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 +367,83 @@ 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, false)) + 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, 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, false) + 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..1bcfd99 100644 --- a/src/renderer/src/components/graph/geometry.ts +++ b/src/renderer/src/components/graph/geometry.ts @@ -23,6 +23,32 @@ 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 +/** 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 +/** 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), 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 * itself a click target (it IS the branch). */ @@ -170,7 +196,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 +231,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 +302,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 +324,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/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..0e6f990 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 @@ -29,7 +30,8 @@ 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' @@ -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 @@ -84,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 @@ -186,6 +195,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 +212,20 @@ 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, 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 + * four-digit `#1234` (render.ts measures the real chip). */ +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). */ +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 +256,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 +657,14 @@ 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), + id === headChain + ) - + 1, parent: parentChainOf(id) ?? null, releaseRank: releaseRank.get(id) ?? null, isHead: id === headChain, @@ -639,7 +688,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 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..6991d46 --- /dev/null +++ b/src/renderer/src/components/graph/prChip.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import type { PullRequestInfo } from '@shared/types' +import { ciPulseAlpha, 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 }))).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', () => { + expect(prChipGlyph(pr({ state: 'merged', checks: 'failure' }))).toBe('merged') + 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 new file mode 100644 index 0000000..64352b6 --- /dev/null +++ b/src/renderer/src/components/graph/prChip.ts @@ -0,0 +1,193 @@ +// The PR chip a branch label wears: the canvas twin of the branch switcher's +// `#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), 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. +// +// 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 +// a dot is on screen (render.ts prChipsPulsing). + +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 + success: string + failure: string + pending: string + merged: string + draft: string +} + +/** 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 +} + +// .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. +const CHIP_FONT = 10.5 +const PAD_X = 4 +const GLYPH_GAP = 2 +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 / 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' +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). */ +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 glyphSize(glyph: Glyph): number { + if (glyph === 'pending') return DOT + 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 + return PAD_X * 2 + glyphAdvance(prChipGlyph(pr)) + text +} + +/** 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, + /** 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() + + const midY = rect.y + PR_CHIP_H / 2 + const glyph = prChipGlyph(pr) + if (glyph) { + 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}`, rect.x + PAD_X + glyphAdvance(glyph), midY + 0.5) +} + +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 glyph centered on (cx, cy), at its badge size, in `color`. */ +function drawGlyph( + ctx: CanvasRenderingContext2D, + 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(cx, cy, size / 2, 0, Math.PI * 2) + ctx.fillStyle = color + ctx.fill() + ctx.restore() + return + } + glyphPaths ??= { + success: new Path2D(CHECK_D), + failure: new Path2D(CROSS_D), + merged: new Path2D(MERGED_D), + closed: new Path2D(CLOSED_D), + draft: new Path2D(DRAFT_D) + } + ctx.translate(cx - size / 2, cy - size / 2) + if (isOcticon(glyph)) { + ctx.scale(size / 16, size / 16) + ctx.fillStyle = color + ctx.fill(glyphPaths[glyph]) + } else { + 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 da0cac6..36bf3e4 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,12 +24,18 @@ import { captionCenterOffset, columnsToNext, HEADER_H, + LABEL_CAP_W, + LABEL_MIN_SCALE, + LABEL_PAD_X, + labelContentWidth, labelRect, MARGIN_X, NODE_R, nodeX, nodeY, + prChipRect, toWorldX, + toWorldY, type View } from './geometry' import { @@ -39,6 +47,7 @@ import { rowMatchesSelection } from './layout' import { type BackportLink, linkedHashes } from './links' +import { ciPulseAlpha, drawPrChip, measurePrChip, type PrChipColors, prChipGlyph } from './prChip' import { ACTIVE_GLOW, HIT_GLOW, @@ -62,6 +71,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 +91,21 @@ 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: prChipColors(css.fontFamily, token) + } +} + +/** 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 { + return { + font, + success: token('--st-added'), + failure: token('--st-deleted'), + pending: token('--st-modified'), + merged: token('--pr-merged'), + draft: token('--fg-muted') } } @@ -170,6 +194,12 @@ 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 + /** Frame timestamp (ms) — phases the running-check pulse; any fixed value + * (reduced motion) freezes it. */ + time: number } const LABEL_FONT = 11 @@ -229,11 +259,34 @@ 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 } +// 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 +} + +/** 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), row.isHead) +} + // Caption widths (SCREEN px) as last drawn, keyed by commit hash; 0 = culled. const captionWidths = new Map() @@ -265,20 +318,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, row.isHead), leftClamp) + return { row, rect, sticky: rect.x > nodeX(row.startColumn) - NODE_R + 0.5, pr } }) } @@ -826,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 @@ -1020,10 +1095,11 @@ 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) { + 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 // with them — a full-strength label over dimmed commits would claim a @@ -1069,22 +1145,78 @@ 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.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, + prChipRect(rect, pr.width), + pr.info, + palette.prChip, + head + ? { 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) + }, + pulse + ) + if (prChipGlyph(pr.info) === 'pending' && onScreen(scene, rect)) pulsingChips = true } - ctx.fillStyle = head ? palette.onAccent : branchText(palette, row.color) - ctx.fillText(row.name, rect.x + 8, rect.y + rect.h / 2 + 0.5) if (isActiveHit) drawRectPing(ctx, scene, rect, 5) ctx.globalAlpha = 1 } } +/** 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 + 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 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/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/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) => ( 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/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); +} 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