From 47f200203d23350d736807d88a7a74f360b1640f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 23 Jul 2026 11:48:45 +0200 Subject: [PATCH] Keep the clicked row visible when the diff pane opens over it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a commit or branch near the bottom of the Graph opens the diff pane under the stage — and the shrunk canvas swallowed the very thing that was just clicked. Arm a one-shot reveal on selection: the pane-opening resize consumes it and pans vertically just enough to keep the row's label, nodes and caption above the fold — never a recenter or horizontal jump, and it expires two frames later so splitter drags and window resizes stay entirely user-driven. --- .../src/components/graph/GraphCanvas.tsx | 65 ++++++++++++++++++- .../src/components/graph/geometry.test.ts | 51 +++++++++++++++ src/renderer/src/components/graph/geometry.ts | 24 +++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index c265882..fd014b9 100644 --- a/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/src/renderer/src/components/graph/GraphCanvas.tsx @@ -20,12 +20,19 @@ import { neighborNode, nodeX, nodeY, + revealRowDy, rowEndpoint, toWorldX, toWorldY, type View } from './geometry' -import type { BranchSelection, GraphLayout, GraphNode, GraphRow } from './layout' +import { + type BranchSelection, + type GraphLayout, + type GraphNode, + type GraphRow, + rowMatchesSelection +} from './layout' import { type BackportLink, twinHashes } from './links' import { captionMetrics, @@ -397,6 +404,53 @@ export function GraphCanvas({ [revealAt] ) + /** Minimal vertical pan keeping `row`'s band clear of the viewport edges. */ + const holdRowVisible = useCallback( + (row: number) => { + if (sizeRef.current.height === 0) return + const dy = revealRowDy(viewRef.current, sizeRef.current.height, row) + if (dy === 0) return + viewRef.current.y += dy + clampView() + invalidate() + }, + [clampView, invalidate] + ) + + /** One-shot row reveal armed by a new selection, consumed by the resize the + * opening diff pane triggers — see the selection effect below. */ + const revealOnResizeRef = useRef(null) + + // Selecting a commit or branch opens the diff pane UNDER the stage, which + // shrinks this canvas — swallowing the very row that was just clicked when + // it sat near the bottom edge. Arm a one-shot reveal: the pane-opening + // resize consumes it (the ResizeObserver fires before the shrunk frame + // paints), and it expires two frames later so splitter drags and window + // 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) + useEffect(() => { + if (selectionKey === null) return + const s = sceneRef.current + const row = s.selectedHash + ? (s.layout.nodeByHash.get(s.selectedHash)?.row ?? null) + : (s.layout.rows.find((r) => rowMatchesSelection(r, s.selectedBranch))?.index ?? null) + if (row === null) return + revealOnResizeRef.current = row + holdRowVisible(row) + let raf = requestAnimationFrame(() => { + raf = requestAnimationFrame(() => { + revealOnResizeRef.current = null + }) + }) + return () => { + cancelAnimationFrame(raf) + revealOnResizeRef.current = null + } + }, [selectionKey, holdRowVisible]) + // Every animated zoom entry point takes over the view — stop a drag fling // first so the anchor point doesn't slide while the scale glides. const zoomStepAt = useCallback( @@ -437,6 +491,15 @@ export function GraphCanvas({ jumpToHead() } clampView() + // A selection just opened the diff pane under the stage — this resize + // is the shrink that would swallow the clicked row. Keep it in view + // (see the selection effect above). + const revealRow = revealOnResizeRef.current + if (revealRow !== null && height > 0) { + revealOnResizeRef.current = null + viewRef.current.y += revealRowDy(viewRef.current, height, revealRow) + clampView() + } // Setting canvas.width/height wipes the backing store to transparent. // ResizeObserver fires after layout but *before* paint (and after this // frame's rAF callbacks already ran), so a rAF-deferred draw would land diff --git a/src/renderer/src/components/graph/geometry.test.ts b/src/renderer/src/components/graph/geometry.test.ts index fe9ec46..905e669 100644 --- a/src/renderer/src/components/graph/geometry.test.ts +++ b/src/renderer/src/components/graph/geometry.test.ts @@ -9,6 +9,7 @@ import { captionAlpha, captionCenterOffset, contentSize, + HEADER_H, hitTest, LABEL_GAP, LABEL_H, @@ -17,6 +18,7 @@ import { nodeX, nodeY, ROW_H, + revealRowDy, rowEndpoint } from './geometry' import { type GraphInput, layoutGraph } from './layout' @@ -231,6 +233,55 @@ describe('graph geometry', () => { expect(rowEndpoint(layout, f1, 'last').commit.hash).toBe('f2') }) + describe('revealRowDy — keep the clicked row clear of the diff pane', () => { + // The band row 4 draws (label pill through caption) in world y. + const row = 4 + const bandTop = nodeY(row) - ROW_H / 2 + const bandBottom = nodeY(row) + ROW_H / 2 + + test('no pan while the row is fully visible', () => { + const view = { x: 0, y: 0, scale: 1 } + expect(revealRowDy(view, bandBottom + 100, row)).toBe(0) + }) + + test('the pane shrink pans up exactly until the band clears the bottom edge', () => { + // The viewport bottom cuts the row's band 50px above its bottom — the + // diff pane just opened over it. + const view = { x: 0, y: 0, scale: 1 } + const height = bandBottom - 50 + expect(revealRowDy(view, height, row)).toBe(-50) + // Idempotent: after the pan the row fits and asks for nothing more. + expect(revealRowDy({ ...view, y: -50 }, height, row)).toBe(0) + }) + + test('the pan respects the zoom level', () => { + const view = { x: 0, y: 0, scale: 2 } + const height = bandBottom * 2 - 30 + expect(revealRowDy(view, height, row)).toBe(-30) + }) + + test('never drags the label band under the date header', () => { + // A viewport shorter than the row's band: bring it up only until its + // top reaches the header, accepting a cut bottom over a hidden label. + const view = { x: 0, y: -bandTop + HEADER_H + 10, scale: 1 } + const height = HEADER_H + ROW_H / 2 + expect(revealRowDy(view, height, row)).toBe(-10) + }) + + test('a row hidden under the header pans down symmetrically', () => { + // The band starts 30px above the header strip. + const view = { x: 0, y: -bandTop + HEADER_H - 30, scale: 1 } + expect(revealRowDy(view, 10_000, row)).toBe(30) + }) + + test('a row spanning the whole strip stays put', () => { + // Sticking out both ends: it is as visible as it can get. + const view = { x: 0, y: -bandTop + HEADER_H - 10, scale: 1 } + const height = HEADER_H + ROW_H - 30 + expect(revealRowDy(view, height, row)).toBe(0) + }) + }) + test('contentSize reserves a column for the WIP node', () => { const layout = sampleLayout() expect(contentSize(layout, layout.columnCount).width - contentSize(layout, null).width).toBe(44) diff --git a/src/renderer/src/components/graph/geometry.ts b/src/renderer/src/components/graph/geometry.ts index 666756e..f1135e5 100644 --- a/src/renderer/src/components/graph/geometry.ts +++ b/src/renderer/src/components/graph/geometry.ts @@ -170,6 +170,30 @@ export function labelRect( } } +/** Vertical pan (screen px, added to view.y) that brings a row's full band — + * label pill, node spine and caption — inside the strip between the date + * header and the viewport bottom; 0 when it already fits, or when the strip + * is too short to ever fit it. This is the "keep the clicked row visible" + * move for the diff pane that opens under the graph (GraphCanvas.tsx): + * vertical-only and never more than needed, so the view neither recenters + * nor jumps horizontally under the user's pointer. */ +export function revealRowDy(view: View, viewportHeight: number, row: number): number { + // The row pitch is sized so everything a row draws — label band above, + // nodes, caption below — fits inside it (see ROW_H). + const top = (nodeY(row) - ROW_H / 2) * view.scale + view.y + const bottom = (nodeY(row) + ROW_H / 2) * view.scale + view.y + // Swallowed by the pane below: pan up until the band's bottom clears the + // edge — but never so far that its label band slides under the header. + if (bottom > viewportHeight && top > HEADER_H) { + return Math.max(viewportHeight - bottom, HEADER_H - top) + } + // Under the header: the symmetric move down. + if (top < HEADER_H && bottom < viewportHeight) { + return Math.min(HEADER_H - top, viewportHeight - bottom) + } + return 0 +} + export type GraphHit = | { type: 'node'; node: GraphNode } | { type: 'label'; row: GraphRow }