Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion src/renderer/src/components/graph/GraphCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<number | null>(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(
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions src/renderer/src/components/graph/geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
captionAlpha,
captionCenterOffset,
contentSize,
HEADER_H,
hitTest,
LABEL_GAP,
LABEL_H,
Expand All @@ -17,6 +18,7 @@ import {
nodeX,
nodeY,
ROW_H,
revealRowDy,
rowEndpoint
} from './geometry'
import { type GraphInput, layoutGraph } from './layout'
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/src/components/graph/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading