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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ handlers), `menu.ts`, `watcher.ts` (pushes `repo:changed`), `updater.ts`, `store
orchestration spine that wires the tabs together); `components/` (one component — or
the hook that drives just that feature — per file) grouped by feature: `changes/`,
`history/`, `graph/` (the Graph tab's 2D branch explorer: pure layout in `layout.ts`,
canvas drawing in `render.ts`, interactions in `GraphCanvas.tsx` — keep layout logic
pure and tested), `toolbar/`, `app/` (shell screens + app-level dialogs) and `common/` (shared
crossing-aware row packing in `packing.ts`, canvas drawing in `render.ts`, interactions
in `GraphCanvas.tsx` — keep layout logic pure and tested), `toolbar/`, `app/` (shell screens + app-level dialogs) and `common/` (shared
widgets and primitives — same-folder imports stay relative, cross-folder go through `@/`);
`lib/` (the **shared tier**: pure logic + hooks reused by 2+ features — a hook used by
exactly one feature lives in that feature's folder, not here. `lib/staging.ts` is the
Expand Down
48 changes: 28 additions & 20 deletions src/renderer/src/components/graph/geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ import type { Commit } from '@shared/types'
import {
CAPSULE_HALF_H,
CAPTION_FULL_ZOOM,
CAPTION_GAP_WORLD,
CAPTION_INK_ABOVE,
CAPTION_INK_BELOW,
captionAlpha,
captionCenterOffset,
contentSize,
hitTest,
LABEL_GAP,
LABEL_H,
NODE_R,
neighborNode,
nodeX,
nodeY,
ROW_H,
rowEndpoint
} from './geometry'
import { type GraphInput, layoutGraph } from './layout'
Expand Down Expand Up @@ -119,7 +124,7 @@ describe('graph geometry', () => {
expect(neighborNode(layout, m, 'ArrowRight')).toBeNull()
})

test('the caption hit band rides a screen-fixed gap below the capsule', () => {
test('the caption hit band sits a world-fixed gap below the capsule', () => {
const layout = sampleLayout()
const m = layout.nodeByHash.get('m')
if (!m) throw new Error('missing node')
Expand All @@ -138,27 +143,30 @@ describe('graph geometry', () => {
() => 30,
scale
)
// At zoom 1 the classic band: nodeY + 26 is the caption line's center.
expect(at(nodeY(m.row) + 26, 1)?.type).toBe('node')
// Zoomed in, the gap shrinks in WORLD units (constant on screen): the
// world point that hit at zoom 1 now lies below the band…
expect(at(nodeY(m.row) + 36, 3)).toBeNull()
// …while a point hugging the capsule edge hits.
expect(at(nodeY(m.row) + 22, 3)?.type).toBe('node')
// The caption rides a fixed WORLD gap below the capsule (its screen distance
// scales with zoom), so the SAME world point lands in its band at any zoom.
const center = captionCenterOffset(1)
expect(captionCenterOffset(3)).toBeCloseTo(center)
expect(at(nodeY(m.row) + center, 1)?.type).toBe('node')
expect(at(nodeY(m.row) + center, 3)?.type).toBe('node')
// A point up near the capsule edge sits in the gap above the band — no hit.
expect(at(nodeY(m.row) + CAPSULE_HALF_H + 1, 3)).toBeNull()
})

test('a squeezed caption splits its air between capsule and next label band', () => {
// Roomy corridor (zoom 2): the design gap below the capsule (11 screen px
// to the text center — see CAPTION_GAP_SCREEN).
expect(captionCenterOffset(2)).toBeCloseTo(CAPSULE_HALF_H + 11 / 2)
// Squeezed corridor (zoom 0.9): equal screen air above the ink and below
// it, and the ink stays clear of the next row's label band, 38 world px
// below the row center.
const offset = captionCenterOffset(0.9)
const airAbove = (offset - CAPSULE_HALF_H) * 0.9 - CAPTION_INK_ABOVE
const airBelow = (38 - offset) * 0.9 - CAPTION_INK_BELOW
expect(airAbove).toBeCloseTo(airBelow)
expect(airBelow).toBeGreaterThan(0)
test('the caption keeps a proportional gap, clear of both rails at every zoom', () => {
// The next row's label band starts this many world px below the row center.
const railBottom = ROW_H - NODE_R - LABEL_GAP - LABEL_H
// The caption center sits the fixed WORLD gap below the capsule — the SAME
// at every zoom, so on screen it scales in proportion with the diagram.
expect(captionCenterOffset(2)).toBeCloseTo(CAPSULE_HALF_H + CAPTION_GAP_WORLD)
expect(captionCenterOffset(3)).toBeCloseTo(CAPSULE_HALF_H + CAPTION_GAP_WORLD)
// At every visible zoom the screen-fixed ink stays clear of both rails: the
// capsule above and the next row's label band below.
for (const scale of [0.9, 1, 2, 3]) {
const offset = captionCenterOffset(scale)
expect(offset - CAPTION_INK_ABOVE / scale).toBeGreaterThan(CAPSULE_HALF_H)
expect(offset + CAPTION_INK_BELOW / scale).toBeLessThan(railBottom)
}
})

test('the caption layer is all-or-nothing: full at zoom 1, gone below the fade', () => {
Expand Down
53 changes: 33 additions & 20 deletions src/renderer/src/components/graph/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import type { GraphLayout, GraphNode, GraphRow } from './layout'
// World-space metrics (scaled by the view's zoom when drawn).
export const COL_W = 44
// Row pitch leaves clear air between a node's subject text (below the node)
// and the next row's label band — they must never touch (see render.ts).
export const ROW_H = 72
// and the next row's label band — they must never touch (see render.ts). It's
// sized so a SELECTED branch's halo+ring (screen-fixed, ±8px around the
// capsule — see drawContainers) clears the caption below it at every zoom, the
// same even embrace a selected commit node wears.
export const ROW_H = 78
export const NODE_R = 12
/** Left/right padding before the first and after the last column. */
export const MARGIN_X = 28
Expand All @@ -24,15 +27,18 @@ export const LABEL_GAP = 4
* half its height. Shared by the renderer and hit-testing — the capsule is
* itself a click target (it IS the branch). */
export const CAPSULE_PAD = 10
export const CAPSULE_HALF_H = 19
/** Caption line under a node (subject text). The capsule edge is world-space
* but caption glyphs are screen-fixed (render.ts SUBJECT_FONT), so the gap
* between them must be screen-fixed too — a world-space anchor sinks the
* text into the capsule when zooming out and floats it far below when
* zooming in. SCREEN px from the capsule's bottom edge down to the text
* line's CENTER when the corridor is roomy: 13px type spans ~5px of ink
* above its center, so 11 leaves ~6px of clear air under the capsule. */
export const CAPTION_GAP_SCREEN = 11
export const CAPSULE_HALF_H = 17
/** Caption line under a node (subject text). WORLD gap from the capsule's
* bottom edge down to the caption line's CENTER when the corridor is roomy.
* A WORLD distance (not screen) so the caption sits the same fraction below
* the capsule at EVERY zoom — it scales WITH the diagram, staying in exact
* proportion with the world-scaled selection halo/ring (drawContainers), just
* like a selected node's caption does. Sized to clear that halo (which reaches
* RING_HALO=6 world px below the capsule — see drawContainers) with room for
* the caption's own ink above its center (~5 screen px). Only the TEXT is
* screen-fixed size (render.ts SUBJECT_FONT); at low zoom its now-large world
* footprint is kept off the rails by the squeeze in captionCenterOffset. */
export const CAPTION_GAP_WORLD = 11
/** SCREEN height of the caption's hit band (13px type plus slop). */
export const CAPTION_BAND_H = 21
/** Approximate SCREEN ink extents of a caption line around its middle
Expand Down Expand Up @@ -89,17 +95,24 @@ export function captionWidth(layout: GraphLayout, node: GraphNode): number {
/** WORLD offset from a row's center down to its caption line's center at a
* zoom level. Shared by the renderer, the hover-card anchor and hit-testing.
*
* The caption lives in a corridor between two world-space rails: its own
* capsule's bottom edge above, and the next row's label band below. When the
* corridor is roomy the caption hugs the capsule with the design gap; when
* zooming out squeezes the corridor, the remaining slack is split evenly so
* the text keeps equal air on both sides instead of invading the labels. */
* The caption sits a fixed WORLD gap below the capsule (CAPTION_GAP_WORLD), so
* its distance scales with zoom and stays in proportion with the world-scaled
* selection halo/ring — the whole selection reads the same at any zoom. The
* one screen-fixed thing is the TEXT size, so in WORLD units the ink grows as
* you zoom out; the caption lives in a corridor between two world rails (the
* capsule's bottom edge above, the next row's label band below), so at low
* zoom that fat world-ink is clamped to keep it off both rails. */
export function captionCenterOffset(scale: number): number {
const railBottom = ROW_H - NODE_R - LABEL_GAP - LABEL_H
const corridor = (railBottom - CAPSULE_HALF_H) * scale
const slack = corridor - CAPTION_INK_ABOVE - CAPTION_INK_BELOW
const air = Math.max(1, Math.min(CAPTION_GAP_SCREEN - CAPTION_INK_ABOVE, slack / 2))
return CAPSULE_HALF_H + (air + CAPTION_INK_ABOVE) / scale
const inkAbove = CAPTION_INK_ABOVE / scale
const inkBelow = CAPTION_INK_BELOW / scale
// Window the caption CENTER can occupy without its ink crossing either rail.
const lo = CAPSULE_HALF_H + inkAbove
const hi = railBottom - inkBelow
// Aim for the proportional gap; at low zoom the window collapses (large world
// ink) so center within it instead — the text never overlaps a rail.
const target = CAPSULE_HALF_H + CAPTION_GAP_WORLD
return lo <= hi ? Math.min(Math.max(target, lo), hi) : (lo + hi) / 2
}

/** Pan/zoom: screen = world * scale + offset. */
Expand Down
33 changes: 28 additions & 5 deletions src/renderer/src/components/graph/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,28 @@ describe('layoutGraph', () => {
expect(kinds.filter((k) => k === 'fork')).toHaveLength(2)
})

test('a branch nested inside a longer-lived one packs above it', () => {
// feat forked first but merges LAST; fix forks and merges back entirely
// within feat's lifetime. Fork-order packing would give feat row 1 and
// slice fix's fork/merge connectors through its capsule — merge-column
// order nests the family crossing-free: fix rides row 1, feat hangs
// below with its verticals falling outside fix's span.
const layout = layoutGraph(
input([
commit('m2', ['m1', 'f2'], 'HEAD -> main', "Merge branch 'feat'"),
commit('f2', ['f1'], 'feat'),
commit('m1', ['b', 'x1'], '', "Merge branch 'fix'"),
commit('x1', ['b'], 'fix'),
commit('b', ['a']),
commit('f1', ['a']),
commit('a', [])
])
)
expect(rowNamed(layout, 'fix').index).toBe(1)
expect(rowNamed(layout, 'feat').index).toBe(2)
expect(layout.rowCount).toBe(3)
})

test('branch base hash feeds the branch-changes view', () => {
const layout = layoutGraph(
input([
Expand Down Expand Up @@ -313,11 +335,12 @@ describe('layoutGraph', () => {
expect(kinds).toEqual(['fork', 'line', 'line', 'merge'])
})

test('packing reserves the merge lead-out so connector runs stay clear', () => {
test('a label pad over a merge lead-out still shares the row', () => {
// early sits at column 2 but merges two columns later at m1 (column 4):
// its connector runs along the row to column 4. late's reservation starts
// at column 4 — without the lead-out they'd share row 1 and the connector
// would run under late's footprint; with it, late moves down a row.
// its connector runs along the row to column 4, under late's LABEL PAD
// (columns 4–6). A pad masks connector lines (the pill base is opaque),
// so the two share row 1 — only a capsule or another pad would push
// late down a row (see packing.test.ts for those cases).
const layout = layoutGraph(
input([
commit('m2', ['c5', 'y'], 'HEAD -> main', "Merge branch 'late'"),
Expand All @@ -332,7 +355,7 @@ describe('layoutGraph', () => {
])
)
expect(rowNamed(layout, 'early').index).toBe(1)
expect(rowNamed(layout, 'late').index).toBe(2)
expect(rowNamed(layout, 'late').index).toBe(1)
})

test('release lines stack directly under the mainline, newest version first', () => {
Expand Down
Loading
Loading