From 062041f2aeb9fb3759e1216ece5821d5b1a4a4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 16:36:27 +0200 Subject: [PATCH 01/11] Fixed a layout issue with child branches --- .../src/components/graph/layout.test.ts | 58 ++++++++++++++ src/renderer/src/components/graph/layout.ts | 76 +++++++++++++++---- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index 96f5933..d594133 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -346,6 +346,64 @@ describe('layoutGraph', () => { expect(rowNamed(layout, 'feature').baseHash).toBe('y2') }) + test('a merged branch keeps its spine from a newer branch forked off its middle', () => { + // The PR #74 / #75 shape: gen (g1─g2─g3─g4) merged into main, while the + // checked-out extra forked from g3 and kept going (e1─e2). Without the + // merged-tip pin, extra's newer tip walks down through g3─g2─g1 and + // steals them, leaving gen a single orphaned commit (g4). + const layout = layoutGraph( + input( + [ + commit('e2', ['e1'], 'HEAD -> extra'), + commit('m2', ['m1', 'g4'], 'main', 'Merge pull request #74 from danipen/gen'), + commit('g4', ['g3'], 'gen'), + commit('e1', ['g3']), + commit('g3', ['g2']), + commit('g2', ['g1']), + commit('g1', ['m1']), + commit('m1', []) + ], + { headBranch: 'extra' } + ) + ) + const gen = rowNamed(layout, 'gen') + for (const hash of ['g1', 'g2', 'g3', 'g4']) { + expect(layout.nodeByHash.get(hash)?.chain).toBe(gen.chain) + } + // extra owns only its unique commits and forks from gen's g3. + const extra = rowNamed(layout, 'extra') + expect(layout.nodeByHash.get('e1')?.chain).toBe(extra.chain) + expect(extra.baseHash).toBe('g3') + expect(gen.baseHash).toBe('m1') + // And the child branch hangs BELOW the branch it grew from, even though + // it's checked out and has the newer tip. + expect(gen.index).toBe(1) + expect(extra.index).toBe(2) + }) + + test('child branches pack below their parent, grandchildren below both', () => { + // parent (merged into main) ← child (HEAD, forked from p1) ← grandchild + // (forked from c1): each fork level hangs one row further from the + // mainline, whatever the tip order says. + const layout = layoutGraph( + input( + [ + commit('gg1', ['c1'], 'grandchild'), + commit('c2', ['c1'], 'HEAD -> child'), + commit('M', ['a', 'p2'], 'main', "Merge branch 'parent'"), + commit('p2', ['p1'], 'parent'), + commit('c1', ['p1']), + commit('p1', ['a']), + commit('a', []) + ], + { headBranch: 'child' } + ) + ) + expect(rowNamed(layout, 'parent').index).toBe(1) + expect(rowNamed(layout, 'child').index).toBe(2) + expect(rowNamed(layout, 'grandchild').index).toBe(3) + }) + test('hideMerged keeps release lines even when merged up into main', () => { // Merge-up workflow: 11.x merged into main makes 11.x's tip a merge // source, but the release line must survive the filter — it isn't done. diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index 08d80ac..ce29770 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -7,8 +7,9 @@ // don't belong to a branch, so branches are reconstructed by walking // first-parent chains down from each tip, in priority order (default branch // first, so it owns the mainline spine; then release lines, newest version -// first — see releases.ts; then the checked-out branch; then the rest, newest -// tip first). Local and remote refs with the same base name +// first — see releases.ts; then branches whose tip was merged away, so they +// keep the commits they carried into that merge; then the checked-out branch; +// then the rest, newest tip first). Local and remote refs with the same base name // ("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 @@ -17,9 +18,10 @@ // Rows are then PACKED: the mainline keeps row 0 to itself, release lines // stack directly beneath it (newest version first — a stable "spine stack" // that keeps maintenance branches in the same rows), and every other chain -// goes to the lowest row where its column span (plus room for its label) -// doesn't collide — so short-lived branches that never overlap in time share -// a row instead of staircasing down the canvas. +// goes to the lowest row BELOW the chain it forked from where its column span +// (plus room for its label) doesn't collide — child branches hang beneath +// their parent, and short-lived branches that never overlap in time share a +// row instead of staircasing down the canvas. import type { Commit } from '@shared/types' import { type CommitRef, parseRefs } from '@/lib/format' @@ -177,6 +179,15 @@ function branchNameFromMergeSubject(subject: string): string | null { return m ? m[1] : null } +/** Every non-first parent in the window: the tips merges pulled in. */ +function collectMergeSourceHashes(commits: readonly Commit[]): Set { + const sources = new Set() + for (const c of commits) { + for (const parent of c.parents.slice(1)) sources.add(parent) + } + return sources +} + /** A chain's release-line version — named branches only: an unnamed chain * reconstructed from "Merge branch 'release/1.2'" is merged history, not a * living release line. */ @@ -216,11 +227,11 @@ function groupTips(input: GraphInput): { base: string; tips: Tip[] }[] { } }) // Tips arrive newest-first already (commits are date-ordered); order groups: - // default branch, then release lines newest version first, then the - // checked-out branch, then by newest tip. Claim order doubles as importance: - // pinning a release line lets it claim its own spine before the feature - // branches forked from it, whose newer tips would otherwise walk down the - // first parents and take its commits. + // default branch, then release lines newest version first, then merged-away + // branches, then the checked-out branch, then by newest tip. Claim order + // doubles as importance: pinning a release line lets it claim its own spine + // before the feature branches forked from it, whose newer tips would + // otherwise walk down the first parents and take its commits. const ordered = [...groups.entries()].sort((a, b) => a[1][0].order - b[1][0].order) const named = ordered.map(([base, tips]) => ({ base, tips })) const pin = (base: string | null) => { @@ -229,6 +240,14 @@ function groupTips(input: GraphInput): { base: string; tips: Tip[] }[] { if (i > 0) named.unshift(...named.splice(i, 1)) } pin(input.headBranch || null) + // A branch whose tip was merged away owns the commits it carried into that + // merge: it claims before the checked-out branch and other unmerged tips, + // or a branch forked from its middle would walk down the first parents and + // steal its spine — leaving the merged branch a single orphaned commit. + const mergeSources = collectMergeSourceHashes(input.commits) + for (const { base } of named.filter((g) => mergeSources.has(g.tips[0].hash)).reverse()) { + pin(base) + } for (const base of releaseLinesNewestFirst(named, input).reverse()) pin(base) pin(input.defaultBranch) return named @@ -261,11 +280,10 @@ export function layoutGraph(input: GraphInput): GraphLayout { // (a tip that is a merge source has been merged), structureOnly (merge // sources are structure), unnamed-chain naming, and the merge lead-out each // chain's packing interval reserves. - const mergeSources = new Set() + const mergeSources = collectMergeSourceHashes(commits) const mergeChildOf = new Map() for (const c of commits) { for (const parent of c.parents.slice(1)) { - mergeSources.add(parent) if (!mergeChildOf.has(parent)) mergeChildOf.set(parent, c) } } @@ -380,7 +398,8 @@ export function layoutGraph(input: GraphInput): GraphLayout { // ── Row packing: mainline alone on row 0; everyone else first-fit below ─── // "As near to main as possible": each chain takes the lowest row whose // occupied column intervals don't collide with its own (padded for the - // label). The HEAD chain is placed first so it lands closest to the top. + // label) — but never above the chain it forked from: a child branch always + // hangs beneath its parent, however new its tip or where HEAD sits. const headChain = headHash !== null ? chainOf.get(headHash) : undefined const defaultChain = chains.findIndex( (c) => c.name === input.defaultBranch && c.kind !== 'unnamed' @@ -388,9 +407,30 @@ export function layoutGraph(input: GraphInput): GraphLayout { const mainChain = defaultChain !== -1 ? defaultChain : (headChain ?? (chains.length > 0 ? 0 : -1)) const rowOfChain = new Map() if (mainChain >= 0) rowOfChain.set(mainChain, 0) + // The chain owning the commit this chain grew from (its fork parent), and + // the fork depth below the mainline (mainline 0, branches off it 1, …). + // Depth orders the packing parent-before-child, so every child's floor row + // is already resolved when its turn comes. + const parentChainOf = (id: number): number | undefined => { + const base = span[id].oldest?.parents[0] + return base === undefined ? undefined : chainOf.get(base) + } + const depths = new Map() + const depthOf = (id: number): number => { + if (id === mainChain) return 0 + const cached = depths.get(id) + if (cached !== undefined) return cached + // Chains can't cycle (bases only point at older columns); keeps the walk total. + depths.set(id, 1) + const parent = parentChainOf(id) + const depth = parent === undefined ? 1 : depthOf(parent) + 1 + depths.set(id, depth) + return depth + } // Release lines pack before everything else, newest version first, so they // stack directly under the mainline — maintenance branches always live in - // the same rows, whatever else is going on. HEAD's chain follows, then the + // the same rows, whatever else is going on. Then by fork depth (parents + // before their children), HEAD's chain first within its depth, then the // rest by start column so packing stays dense. const releaseRank = new Map() chains @@ -407,6 +447,8 @@ export function layoutGraph(input: GraphInput): GraphLayout { .sort((a, b) => { const releases = (releaseRank.get(a) ?? NOT_RELEASE) - (releaseRank.get(b) ?? NOT_RELEASE) if (releases !== 0) return releases + const depth = depthOf(a) - depthOf(b) + if (depth !== 0) return depth if (a === headChain) return -1 if (b === headChain) return 1 return span[a].start - span[b].start @@ -425,7 +467,11 @@ export function layoutGraph(input: GraphInput): GraphLayout { start: Math.min(span[id].start - LABEL_PAD_COLUMNS, forkColumn ?? Number.MAX_SAFE_INTEGER), end: Math.max(span[id].end + 1, mergeColumn ?? -1) } - let row = 0 + // A child branch never packs above its parent: the scan starts at the + // parent's row, so the first candidate row already sits below it. Release + // lines are exempt — their spine stack under the mainline is fixed. + const parent = parentChainOf(id) + let row = releaseRank.has(id) || parent === undefined ? 0 : (rowOfChain.get(parent) ?? 0) while (true) { const taken = occupied[row] if (!taken?.some((t) => interval.start <= t.end && t.start <= interval.end)) { From b0d69f86726fc7842265c5ac3d1b2bcf2ef371c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 16:52:54 +0200 Subject: [PATCH 02/11] Better handle empty branches in the layout --- .../src/components/graph/GraphCanvas.tsx | 8 +- .../src/components/graph/geometry.test.ts | 23 ++++- src/renderer/src/components/graph/geometry.ts | 16 ++- .../src/components/graph/layout.test.ts | 93 +++++++++++++++++ src/renderer/src/components/graph/layout.ts | 99 ++++++++++++++++--- .../src/components/graph/related.test.ts | 9 ++ src/renderer/src/components/graph/related.ts | 10 ++ src/renderer/src/components/graph/render.ts | 17 ++++ 8 files changed, 256 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index 14d91a2..1b90a1b 100644 --- a/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/src/renderer/src/components/graph/GraphCanvas.tsx @@ -174,7 +174,9 @@ export function GraphCanvas({ () => changesCount > 0 && headRow ? { - column: layout.columnCount, + // On a zero-commit branch the WIP node docks right of the lane's + // reserved slot; otherwise right of the newest commit column. + column: headRow.empty ? headRow.endColumn + 1 : layout.columnCount, row: headRow.index, count: changesCount, color: headRow.color @@ -244,7 +246,7 @@ export function GraphCanvas({ const clampView = useCallback(() => { const view = viewRef.current const { width, height } = sizeRef.current - const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip !== null) + const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip?.column ?? null) const cw = cs.width * view.scale const ch = cs.height * view.scale const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi) @@ -329,7 +331,7 @@ export function GraphCanvas({ zoomAnim.stop() panInertia.cancel() const { width, height } = sizeRef.current - const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip !== null) + const cs = contentSize(sceneRef.current.layout, sceneRef.current.wip?.column ?? null) const view = viewRef.current view.scale = Math.min( MAX_SCALE, diff --git a/src/renderer/src/components/graph/geometry.test.ts b/src/renderer/src/components/graph/geometry.test.ts index a44fcf6..bd37177 100644 --- a/src/renderer/src/components/graph/geometry.test.ts +++ b/src/renderer/src/components/graph/geometry.test.ts @@ -225,6 +225,27 @@ describe('graph geometry', () => { test('contentSize reserves a column for the WIP node', () => { const layout = sampleLayout() - expect(contentSize(layout, true).width - contentSize(layout, false).width).toBe(44) + expect(contentSize(layout, layout.columnCount).width - contentSize(layout, null).width).toBe( + 44 + ) + }) + + test('contentSize covers an empty branch slot past the last commit column', () => { + // 'fresh' points at the HEAD tip: zero commits, slot right of the tip. + const layout = layoutGraph({ + commits: [commit('b', ['a'], 'HEAD -> main, fresh'), commit('a', [])], + remotes: [], + headBranch: 'main', + detached: false, + defaultBranch: 'main' + }) + const fresh = layout.rows.find((r) => r.name === 'fresh') + if (!fresh) throw new Error('missing empty row') + expect(fresh.endColumn).toBe(layout.columnCount) + // One column wider than the commits alone would need. + expect(contentSize(layout, null).width).toBe( + contentSize({ ...layout, rows: layout.rows.filter((r) => r.name === 'main') }, null).width + + 44 + ) }) }) diff --git a/src/renderer/src/components/graph/geometry.ts b/src/renderer/src/components/graph/geometry.ts index 595c3d9..8c5a8e6 100644 --- a/src/renderer/src/components/graph/geometry.ts +++ b/src/renderer/src/components/graph/geometry.ts @@ -118,11 +118,19 @@ export const nodeY = (row: number): number => MARGIN_Y + row * ROW_H + ROW_H / 2 export const toWorldX = (view: View, screenX: number): number => (screenX - view.x) / view.scale export const toWorldY = (view: View, screenY: number): number => (screenY - view.y) / view.scale -/** World-space size of the whole diagram (an extra column when a WIP node shows). */ -export function contentSize(layout: GraphLayout, wip: boolean): { width: number; height: number } { - const columns = layout.columnCount + (wip ? 1 : 0) +/** World-space size of the whole diagram. `wipColumn` is the WIP node's + * column when it shows, or null. Both it and an empty branch's reserved slot + * can sit past the last commit column, so width follows the rightmost of + * commits, row spans and the WIP node. */ +export function contentSize( + layout: GraphLayout, + wipColumn: number | null +): { width: number; height: number } { + let last = layout.columnCount - 1 + for (const row of layout.rows) last = Math.max(last, row.endColumn) + if (wipColumn !== null) last = Math.max(last, wipColumn) return { - width: MARGIN_X * 2 + Math.max(1, columns) * COL_W, + width: MARGIN_X * 2 + Math.max(1, last + 1) * COL_W, height: MARGIN_Y + Math.max(1, layout.rowCount) * ROW_H + ROW_H / 2 } } diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index d594133..a3793c0 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -486,3 +486,96 @@ describe('layoutGraph', () => { expect(columns).toEqual([...columns].sort((x, y) => x - y)) }) }) + +describe('empty branches (zero-commit refs)', () => { + test("a branch pointing at another chain's commit gets an empty lane", () => { + const layout = layoutGraph(input([commit('b', ['a'], 'HEAD -> main, fresh'), commit('a', [])])) + const fresh = rowNamed(layout, 'fresh') + expect(fresh.empty).toBe(true) + expect(fresh.kind).toBe('branch') + // tip and base both name the anchor: the branch-changes range is empty. + expect(fresh.tipHash).toBe('b') + expect(fresh.baseHash).toBe('b') + // One reserved slot right of the anchor, on its own row below main. + expect(fresh.startColumn).toBe(2) + expect(fresh.endColumn).toBe(2) + expect(fresh.index).toBeGreaterThan(rowNamed(layout, 'main').index) + // It claimed no commits — main keeps its whole spine… + expect(layout.nodes.every((n) => n.chain !== fresh.chain)).toBe(true) + expect(layout.nodeByHash.get('b')?.row).toBe(0) + // …and a fork connector reaches from the anchor into the reserved slot. + const fork = layout.edges.find((e) => e.kind === 'fork') + expect(fork?.toColumn).toBe(1) + expect(fork?.toRow).toBe(0) + expect(fork?.fromColumn).toBe(2) + expect(fork?.fromRow).toBe(fresh.index) + // HEAD is on main, so main keeps the marker. + expect(rowNamed(layout, 'main').isHead).toBe(true) + expect(fresh.isHead).toBe(false) + expect(layout.nodeByHash.get('b')?.isHead).toBe(true) + }) + + test('HEAD on an empty branch moves the home marker to the empty lane', () => { + const layout = layoutGraph( + input([commit('b', ['a'], 'HEAD -> fresh, main'), commit('a', [])], { headBranch: 'fresh' }) + ) + const fresh = rowNamed(layout, 'fresh') + expect(fresh.empty).toBe(true) + expect(fresh.isHead).toBe(true) + expect(rowNamed(layout, 'main').isHead).toBe(false) + // The anchor node stays plain: home lives on the empty lane now. + expect(layout.nodeByHash.get('b')?.isHead).toBe(false) + // headHash still names the anchor commit (jump-to-home target). + expect(layout.headHash).toBe('b') + }) + + test('an empty branch anchored mid-spine hangs beside its anchor', () => { + const layout = layoutGraph( + input([commit('c', ['b'], 'HEAD -> main'), commit('b', ['a'], 'fresh'), commit('a', [])]) + ) + const fresh = rowNamed(layout, 'fresh') + expect(fresh.empty).toBe(true) + // Anchor b sits at column 1; the slot is the next column, one row down. + expect(fresh.startColumn).toBe(2) + expect(fresh.index).toBeGreaterThan(0) + }) + + test('a remote-only zero-commit ref becomes an empty remote lane', () => { + const layout = layoutGraph( + input([commit('b', ['a'], 'HEAD -> main, origin/fresh'), commit('a', [])]) + ) + const fresh = rowNamed(layout, 'fresh') + expect(fresh.empty).toBe(true) + expect(fresh.kind).toBe('remote') + }) + + test('two empty branches at one commit stack on separate rows', () => { + const layout = layoutGraph( + input([commit('b', ['a'], 'HEAD -> main, one, two'), commit('a', [])]) + ) + const one = rowNamed(layout, 'one') + const two = rowNamed(layout, 'two') + expect(one.empty).toBe(true) + expect(two.empty).toBe(true) + // Same reserved slot column — the packer must give them separate rows. + expect(one.startColumn).toBe(two.startColumn) + expect(one.index).not.toBe(two.index) + }) + + test('empty branches appear in the branch filter list', () => { + const names = collectBranchNames( + input([commit('b', ['a'], 'HEAD -> main, fresh'), commit('a', [])]) + ) + expect(names).toContain('fresh') + }) + + test('local and remote refs at an already-claimed commit share one empty lane', () => { + const layout = layoutGraph( + input([commit('b', ['a'], 'HEAD -> main, fresh, origin/fresh'), commit('a', [])]) + ) + const rows = layout.rows.filter((r) => r.name === 'fresh') + expect(rows).toHaveLength(1) + expect(rows[0].kind).toBe('branch') + expect(rows[0].empty).toBe(true) + }) +}) diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index ce29770..2b0e38d 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -49,6 +49,11 @@ export interface GraphRow { * or null for a chain that starts at a root commit. Feeds the * branch-changes view: everything in `base..tip` is what the branch did. */ baseHash: string | null + /** True for a zero-commit branch: a ref pointing at another chain's commit + * (freshly created, nothing committed yet). Its lane is one reserved slot + * right of that anchor commit; tipHash and baseHash both name the anchor, + * so the branch-changes range `base..tip` is honestly empty. */ + empty: boolean /** Palette slot — stable per branch name, 0 reserved for the mainline. */ color: number /** Inclusive column span of the row's nodes. */ @@ -158,6 +163,8 @@ interface Chain { name: string kind: GraphRowKind tipHash: string + /** Zero-commit branch: tipHash is another chain's commit (see GraphRow.empty). */ + empty?: boolean } /** Strip a known remote prefix: `origin/foo` → base `foo`, marked remote. */ @@ -317,12 +324,30 @@ export function layoutGraph(input: GraphInput): GraphLayout { const hasLocalRef = group.tips.some((t) => !t.isRemote) // The newest tip's walk usually passes through the older ones (local behind // remote). A genuinely diverged older tip starts its own row, same name. + let claimed = false for (const tip of group.tips) { - claim(tip.hash, { + const chain: Chain = { name: group.base, kind: hasLocalRef ? 'branch' : 'remote', tipHash: tip.hash - }) + } + if (claim(tip.hash, chain)) claimed = true + } + // A branch created at another branch's commit with nothing committed yet: + // every tip walk found its commits already claimed by a higher-priority + // chain. It's still a real branch the user can be on — reserve it an + // EMPTY lane anchored at the commit it points to, instead of letting it + // vanish from the diagram. + if (!claimed) { + const anchor = group.tips.find((t) => chainOf.has(t.hash)) + if (anchor) { + chains.push({ + name: group.base, + kind: hasLocalRef ? 'branch' : 'remote', + tipHash: anchor.hash, + empty: true + }) + } } } @@ -344,6 +369,13 @@ export function layoutGraph(input: GraphInput): GraphLayout { } } + // HEAD on a zero-commit branch: the ref decoration sits on the anchor + // commit (another chain's node), but "you are here" is the empty lane — + // it wears isHead (home badge, WIP node) and the anchor node stays plain. + const headEmptyChain = input.detached + ? -1 + : chains.findIndex((c) => c.empty && c.name === input.headBranch && c.tipHash === headHash) + // ── Structure-only: collapse the linear runs between structural commits ─── // A commit survives when it shapes the graph: chain tip or start, merge, // merge source, fork point (a commit some branch grew from), decorated @@ -394,26 +426,46 @@ export function layoutGraph(input: GraphInput): GraphLayout { s.end = Math.max(s.end, column) s.oldest = c } + // Empty chains hold no commits: their lane is the single slot just right of + // the anchor commit — where their first commit will land. (The anchor is + // always kept: it's a claimed commit and a chain tip, so structure-only + // keeps it too.) + chains.forEach((chain, id) => { + if (!chain.empty) return + const slot = (columnOf.get(chain.tipHash) ?? -1) + 1 + span[id].start = slot + span[id].end = slot + }) // ── Row packing: mainline alone on row 0; everyone else first-fit below ─── // "As near to main as possible": each chain takes the lowest row whose // occupied column intervals don't collide with its own (padded for the // label) — but never above the chain it forked from: a child branch always // hangs beneath its parent, however new its tip or where HEAD sits. - const headChain = headHash !== null ? chainOf.get(headHash) : undefined + // Packing priority follows the branch the user is ON — the empty lane when + // HEAD sits on a zero-commit branch. The mainline fallback stays the anchor + // chain: an empty lane can't own the row-0 spine. + const anchorHeadChain = headHash !== null ? chainOf.get(headHash) : undefined + const headChain = headEmptyChain !== -1 ? headEmptyChain : anchorHeadChain const defaultChain = chains.findIndex( (c) => c.name === input.defaultBranch && c.kind !== 'unnamed' ) - const mainChain = defaultChain !== -1 ? defaultChain : (headChain ?? (chains.length > 0 ? 0 : -1)) + const mainChain = + defaultChain !== -1 ? defaultChain : (anchorHeadChain ?? (chains.length > 0 ? 0 : -1)) const rowOfChain = new Map() if (mainChain >= 0) rowOfChain.set(mainChain, 0) + // A chain's fork base: the commit it grew from — for an empty chain, the + // anchor commit itself (the lane grows from where its first commit will + // fork off). + const baseHashOf = (id: number): string | null => + chains[id].empty ? chains[id].tipHash : (span[id].oldest?.parents[0] ?? null) // The chain owning the commit this chain grew from (its fork parent), and // the fork depth below the mainline (mainline 0, branches off it 1, …). // Depth orders the packing parent-before-child, so every child's floor row // is already resolved when its turn comes. const parentChainOf = (id: number): number | undefined => { - const base = span[id].oldest?.parents[0] - return base === undefined ? undefined : chainOf.get(base) + const base = baseHashOf(id) + return base === null ? undefined : chainOf.get(base) } const depths = new Map() const depthOf = (id: number): number => { @@ -460,8 +512,10 @@ export function layoutGraph(input: GraphInput): GraphLayout { // lead-in to the left, the merge lead-out to the right (the orthogonal // connector runs along the row — see render.ts) — so no other chain on // the row ever sits underneath those runs. - const forkColumn = columnOf.get(span[id].oldest?.parents[0] ?? '') - const mergeChild = mergeChildOf.get(chains[id].tipHash) + const forkColumn = columnOf.get(baseHashOf(id) ?? '') + // An empty chain's tipHash names another chain's commit — a merge of that + // commit is the OWNER's lead-out to reserve, not the empty lane's. + const mergeChild = chains[id].empty ? undefined : mergeChildOf.get(chains[id].tipHash) const mergeColumn = mergeChild ? columnOf.get(mergeChild.hash) : undefined const interval = { start: Math.min(span[id].start - LABEL_PAD_COLUMNS, forkColumn ?? Number.MAX_SAFE_INTEGER), @@ -494,11 +548,13 @@ export function layoutGraph(input: GraphInput): GraphLayout { kind: chain.kind, isHead: false, tipHash: chain.tipHash, - baseHash: span[id].oldest?.parents[0] ?? null, + baseHash: baseHashOf(id), color: id === mainChain ? 0 : colorForName(chain.name), startColumn: span[id].start, - endColumn: span[id].end + endColumn: span[id].end, + empty: chain.empty === true })) + if (headEmptyChain !== -1) rows[headEmptyChain].isHead = true // ── Nodes ────────────────────────────────────────────────────────────────── const nodes: GraphNode[] = [] @@ -515,7 +571,9 @@ export function layoutGraph(input: GraphInput): GraphLayout { color: rows[chainId].color, refs: parseRefs(commit.refs), isMerge: commit.parents.length > 1, - isHead: commit.hash === headHash, + // When HEAD is on a zero-commit branch, the marker belongs to the empty + // lane (its GraphRow.isHead), never to the anchor commit's node. + isHead: commit.hash === headHash && headEmptyChain === -1, truncated: false } nodes.push(node) @@ -563,6 +621,25 @@ export function layoutGraph(input: GraphInput): GraphLayout { }) } + // Empty lanes still show WHERE the branch will grow from: a fork connector + // from the anchor commit into the reserved slot. Both hashes name the + // anchor, so filter dimming treats the connector like the commit it's on. + chains.forEach((chain, id) => { + if (!chain.empty) return + const anchor = nodeByHash.get(chain.tipHash) + if (!anchor) return + edges.push({ + kind: 'fork', + color: rows[id].color, + fromHash: chain.tipHash, + toHash: chain.tipHash, + fromColumn: span[id].start, + fromRow: rowOfChain.get(id) ?? 0, + toColumn: anchor.column, + toRow: anchor.row + }) + }) + rows.sort((a, b) => a.index - b.index || a.startColumn - b.startColumn) return { rows, rowCount, nodes, edges, columnCount: kept.length, nodeByHash, headHash } } diff --git a/src/renderer/src/components/graph/related.test.ts b/src/renderer/src/components/graph/related.test.ts index 13dd32e..301d0a7 100644 --- a/src/renderer/src/components/graph/related.test.ts +++ b/src/renderer/src/components/graph/related.test.ts @@ -83,4 +83,13 @@ describe('relatedBranches', () => { expect(relatedBranches(layout, 'main', 1)).toEqual(new Set(['main'])) expect(relatedBranches(layout, 'ghost', 1)).toEqual(new Set(['ghost'])) }) + + test('an empty branch is one hop from the branch owning its anchor commit', () => { + // 'fresh' points at main's tip with no commits of its own: focusing on + // either side must reach the other, or focus-on-fresh would hide main and + // let fresh claim the whole spine. + const layout = layoutGraph(input([commit('m', ['a'], 'HEAD -> main, fresh'), commit('a', [])])) + expect(relatedBranches(layout, 'fresh', 1)).toEqual(new Set(['fresh', 'main'])) + expect(relatedBranches(layout, 'main', 1)).toEqual(new Set(['main', 'fresh'])) + }) }) diff --git a/src/renderer/src/components/graph/related.ts b/src/renderer/src/components/graph/related.ts index 88fe046..7ce369f 100644 --- a/src/renderer/src/components/graph/related.ts +++ b/src/renderer/src/components/graph/related.ts @@ -33,6 +33,16 @@ export function relatedBranches(layout: GraphLayout, seed: string, hops: number) connect(from, to) connect(to, from) } + // Empty lanes (zero-commit branches) own no nodes, so their fork connector + // maps to the anchor's chain on both ends above — relate them to the chain + // owning the commit they point at explicitly. + for (const row of layout.rows) { + if (!row.empty) continue + const anchor = layout.nodeByHash.get(row.tipHash)?.chain + if (anchor === undefined || anchor === row.chain) continue + connect(row.chain, anchor) + connect(anchor, row.chain) + } const nameOf = new Map() const seeds: number[] = [] diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 675dc3f..16707d8 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -283,6 +283,7 @@ export function drawScene(ctx: CanvasRenderingContext2D, scene: SceneState): voi drawBackportLinks(ctx, scene, c0, c1) drawNodes(ctx, scene, c0, c1, labelBoxes, twinsOf(scene.links)) drawWip(ctx, scene) + drawEmptyHeadBadge(ctx, scene, c0, c1) drawLabels(ctx, scene, labelBoxes) drawHeader(ctx, scene) @@ -596,6 +597,22 @@ function drawNodes( } } +/** HEAD on a zero-commit branch: no node carries isHead (layout moves it to + * the empty lane's row), so the home badge anchors to the lane's reserved + * slot — where the branch's first commit will land. */ +function drawEmptyHeadBadge( + ctx: CanvasRenderingContext2D, + scene: SceneState, + c0: number, + c1: number +): void { + for (const row of scene.layout.rows) { + if (!row.empty || !row.isHead) continue + if (row.endColumn < c0 || row.startColumn > c1) continue + drawHomeBadge(ctx, scene, nodeX(row.startColumn), nodeY(row.index)) + } +} + /** "You are here": a small accent house pinned to the home changeset's * shoulder. A badge, not a ring — the outer accent ring means "selected", * and the two states must never look alike. It behaves like a map pin: From addc2a9cd60509449d5866552a73d9c613fe7684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 17:01:31 +0200 Subject: [PATCH 03/11] Don't select empty branches together --- src/renderer/src/App.tsx | 12 +++++-- .../src/components/graph/GraphCanvas.tsx | 18 ++++++----- .../src/components/graph/GraphView.tsx | 17 +++++++--- .../src/components/graph/layout.test.ts | 29 ++++++++++++++++- src/renderer/src/components/graph/layout.ts | 20 ++++++++++-- src/renderer/src/components/graph/render.ts | 32 +++++++++++++------ 6 files changed, 101 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8df7e7e..ef27b07 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,7 +17,7 @@ import type { SyncStatus, UndoSnapshot } from '@shared/types' -import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react' +import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AboutDialog } from './components/app/AboutDialog' import { AppModals, type Modal } from './components/app/AppModals' import { CloneDialog } from './components/app/CloneDialog' @@ -392,6 +392,14 @@ export function App() { resetRange } = useBranchRange({ getRepoPath, fail, loadRangeDiff, clearDiff }) + // The Graph's lit-up container, identified by (name, tip) — a tip hash + // alone is ambiguous: empty branches share it with their anchor's chain. + // Memoized so the canvas' redraw trigger only fires when it truly changes. + const selectedGraphBranch = useMemo( + () => (branchRange ? { name: branchRange.name, tipHash: branchRange.head } : null), + [branchRange] + ) + /** Select a commit, dismissing any open branch-changes view. */ const selectCommitOnly = useCallback( (commit: Commit) => { @@ -1605,7 +1613,7 @@ export function App() { clearDiff() } }} - selectedBranchTip={branchRange?.head ?? null} + selectedBranch={selectedGraphBranch} onSelectBranch={(row) => { resetDetail() clearDiff() diff --git a/src/renderer/src/components/graph/GraphCanvas.tsx b/src/renderer/src/components/graph/GraphCanvas.tsx index 1b90a1b..27962bc 100644 --- a/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/src/renderer/src/components/graph/GraphCanvas.tsx @@ -25,7 +25,7 @@ import { toWorldY, type View } from './geometry' -import type { GraphLayout, GraphNode, GraphRow } from './layout' +import type { BranchSelection, GraphLayout, GraphNode, GraphRow } from './layout' import { type BackportLink, twinHashes } from './links' import { captionMetrics, @@ -55,8 +55,10 @@ interface Props { layout: GraphLayout theme: 'dark' | 'light' selectedHash: string | null - /** Tip of the branch whose changes view is open — its container lights up. */ - selectedBranchTip: string | null + /** The branch whose changes view is open — its container lights up. Matched + * by (name, tip): empty branches share their tip hash with the anchor's + * chain, so a hash alone would light every one of them (layout.ts). */ + selectedBranch: BranchSelection | null /** Commits kept at full strength while the rest dim; null = no filter. */ matches: ReadonlySet | null /** The current search hit (louder ring). */ @@ -141,7 +143,7 @@ export function GraphCanvas({ layout, theme, selectedHash, - selectedBranchTip, + selectedBranch, matches, activeMatch, changesCount, @@ -189,7 +191,7 @@ export function GraphCanvas({ const sceneRef = useRef({ layout, selectedHash, - selectedBranchTip, + selectedBranch, matches, activeMatch, wip, @@ -199,7 +201,7 @@ export function GraphCanvas({ sceneRef.current = { layout, selectedHash, - selectedBranchTip, + selectedBranch, matches, activeMatch, wip, @@ -225,7 +227,7 @@ export function GraphCanvas({ dpr, palette: paletteRef.current, selectedHash: s.selectedHash, - selectedBranchTip: s.selectedBranchTip, + selectedBranch: s.selectedBranch, hoverHash: hoverRef.current, matches: s.matches, activeMatch: s.activeMatch, @@ -436,7 +438,7 @@ export function GraphCanvas({ // biome-ignore lint/correctness/useExhaustiveDependencies: these values aren't read by invalidate — they're the intentional redraw triggers useEffect(invalidate, [ selectedHash, - selectedBranchTip, + selectedBranch, matches, activeMatch, wip, diff --git a/src/renderer/src/components/graph/GraphView.tsx b/src/renderer/src/components/graph/GraphView.tsx index b71763d..fa07416 100644 --- a/src/renderer/src/components/graph/GraphView.tsx +++ b/src/renderer/src/components/graph/GraphView.tsx @@ -14,7 +14,13 @@ import { Icon } from '@/lib/icons' import { usePersistentState } from '@/lib/persist' import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas' import { type AuthorOption, DATE_PRESETS, type DatePresetId, GraphToolbar } from './GraphToolbar' -import { collectBranchNames, type GraphNode, type GraphRow, layoutGraph } from './layout' +import { + type BranchSelection, + collectBranchNames, + type GraphNode, + type GraphRow, + layoutGraph +} from './layout' import { linkableChains, twinHashes } from './links' import { relatedBranches } from './related' import { releaseLineVersion, releaseVersionWithOverride } from './releases' @@ -34,8 +40,9 @@ interface Props { changesCount: number selectedCommit: Commit | null onSelectCommit: (commit: Commit | null) => void - /** Tip of the branch whose changes view is open, or null. */ - selectedBranchTip: string | null + /** The branch whose changes view is open, or null — matched by (name, tip) + * because empty branches share their tip hash with the anchor's chain. */ + selectedBranch: BranchSelection | null /** A branch label was clicked — open its whole-branch changes view. */ onSelectBranch: (row: GraphRow) => void /** Right-click menu for a commit node — the same one History uses. */ @@ -57,7 +64,7 @@ export function GraphView({ changesCount, selectedCommit, onSelectCommit, - selectedBranchTip, + selectedBranch, onSelectBranch, commitMenuFor, onCheckoutBranch, @@ -369,7 +376,7 @@ export function GraphView({ layout={layout} theme={theme} selectedHash={selectedCommit?.hash ?? null} - selectedBranchTip={selectedBranchTip} + selectedBranch={selectedBranch} matches={matches} activeMatch={activeMatch} changesCount={changesCount} diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index a3793c0..f72767e 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { Commit } from '@shared/types' -import { collectBranchNames, type GraphInput, layoutGraph } from './layout' +import { collectBranchNames, type GraphInput, layoutGraph, rowMatchesSelection } from './layout' /** Minimal commit for layout tests; only hash/parents/refs/subject matter. */ function commit(hash: string, parents: string[], refs = '', subject = `subject ${hash}`): Commit { @@ -569,6 +569,33 @@ describe('empty branches (zero-commit refs)', () => { expect(names).toContain('fresh') }) + test('selection matches one row even when empty branches share a tip hash', () => { + // main, one and two all point at commit b: selecting 'one' must light + // only 'one' — a tip-hash-only match would light all three. + const layout = layoutGraph( + input([commit('b', ['a'], 'HEAD -> main, one, two'), commit('a', [])]) + ) + const selection = { name: 'one', tipHash: 'b' } + const lit = layout.rows.filter((r) => rowMatchesSelection(r, selection)) + expect(lit).toHaveLength(1) + expect(lit[0].name).toBe('one') + expect(layout.rows.some((r) => rowMatchesSelection(r, null))).toBe(false) + }) + + test('origin/HEAD never becomes an empty lane (still shows when it owns commits)', () => { + // origin/HEAD rides the default branch's tip: a pointer, not a branch. + const layout = layoutGraph( + input([commit('b', ['a'], 'HEAD -> main, origin/HEAD'), commit('a', [])]) + ) + expect(layout.rows.some((r) => r.name === 'HEAD')).toBe(false) + // But a HEAD ref that claims commits of its own keeps its row, as before. + const claimed = layoutGraph( + input([commit('h', ['a'], 'origin/HEAD'), commit('b', ['a'], 'HEAD -> main'), commit('a', [])]) + ) + const headRow = claimed.rows.find((r) => r.name === 'HEAD') + expect(headRow?.empty).toBe(false) + }) + test('local and remote refs at an already-claimed commit share one empty lane', () => { const layout = layoutGraph( input([commit('b', ['a'], 'HEAD -> main, fresh, origin/fresh'), commit('a', [])]) diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index 2b0e38d..ad26192 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -61,6 +61,19 @@ export interface GraphRow { endColumn: number } +/** Identifies the row whose branch-changes view is open. A tip hash alone is + * ambiguous: every empty branch shares its anchor commit's hash with the + * chain that owns it (and with its fellow empty branches), so selection + * matches on (name, tip) — the pair is unique across rows. */ +export interface BranchSelection { + name: string + tipHash: string +} + +/** 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 + export interface GraphNode { commit: Commit /** The chain (GraphRow.chain) that claimed this commit. */ @@ -337,8 +350,11 @@ export function layoutGraph(input: GraphInput): GraphLayout { // every tip walk found its commits already claimed by a higher-priority // chain. It's still a real branch the user can be on — reserve it an // EMPTY lane anchored at the commit it points to, instead of letting it - // vanish from the diagram. - if (!claimed) { + // vanish from the diagram. Except `origin/HEAD`: its base splits to + // "HEAD", which is a pointer at the remote's default branch, not a branch + // anyone can be on — an empty "HEAD" lane is pure noise. (When such a ref + // genuinely claims commits above, it still shows, as before.) + if (!claimed && group.base !== 'HEAD') { const anchor = group.tips.find((t) => chainOf.has(t.hash)) if (anchor) { chains.push({ diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 16707d8..4254417 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -30,7 +30,14 @@ import { toWorldX, type View } from './geometry' -import { BRANCH_COLOR_COUNT, type GraphLayout, type GraphNode, type GraphRow } from './layout' +import { + BRANCH_COLOR_COUNT, + type BranchSelection, + type GraphLayout, + type GraphNode, + type GraphRow, + rowMatchesSelection +} from './layout' import { type BackportLink, linkedHashes } from './links' export interface GraphPalette { @@ -135,8 +142,8 @@ export interface SceneState { dpr: number palette: GraphPalette selectedHash: string | null - /** Tip hash of the branch whose changes view is open — its container lights up. */ - selectedBranchTip: string | null + /** The branch whose changes view is open — its container lights up. */ + selectedBranch: BranchSelection | null hoverHash: string | null /** Commits kept at full strength while everything else dims (filters/search), * or null when nothing is filtering. */ @@ -316,7 +323,7 @@ function drawContainers( const { palette, wip } = scene for (const row of scene.layout.rows) { if (row.endColumn < c0 - 1 || row.startColumn > c1 + 1) continue - const selected = row.tipHash === scene.selectedBranchTip + const selected = rowMatchesSelection(row, scene.selectedBranch) const y = nodeY(row.index) const x0 = nodeX(row.startColumn) - NODE_R - CAPSULE_PAD // The HEAD branch's capsule stretches to embrace the WIP node. @@ -599,7 +606,9 @@ function drawNodes( /** HEAD on a zero-commit branch: no node carries isHead (layout moves it to * the empty lane's row), so the home badge anchors to the lane's reserved - * slot — where the branch's first commit will land. */ + * slot — where the branch's first commit will land. Centered in the slot, + * not on the shoulder: the shoulder spot sits right under the accent label + * pill, and blue-on-blue melts the badge into it. */ function drawEmptyHeadBadge( ctx: CanvasRenderingContext2D, scene: SceneState, @@ -609,7 +618,7 @@ function drawEmptyHeadBadge( for (const row of scene.layout.rows) { if (!row.empty || !row.isHead) continue if (row.endColumn < c0 || row.startColumn > c1) continue - drawHomeBadge(ctx, scene, nodeX(row.startColumn), nodeY(row.index)) + drawHomeBadge(ctx, scene, nodeX(row.startColumn), nodeY(row.index), 'center') } } @@ -623,7 +632,11 @@ function drawHomeBadge( ctx: CanvasRenderingContext2D, scene: SceneState, x: number, - y: number + y: number, + /** 'shoulder' pins to a node's top-right (the default, over an avatar); + * 'center' sits on the point itself — empty lanes have no avatar to yield + * to, and their shoulder spot collides with the label pill above. */ + anchor: 'shoulder' | 'center' = 'shoulder' ): void { const { view, dpr, palette } = scene // Tracks the zoom: grows with the nodes when zoomed in, clamped on both @@ -658,8 +671,9 @@ function drawHomeBadge( // skews mirrored pixel coverage by up to ~33% (a lopsided roof), while // 0 and 0.5 offsets rasterize exactly. const snap = (v: number) => Math.round(v * dpr * 2) / (dpr * 2) - const sx = snap(x * view.scale + view.x + NODE_R * view.scale * 0.8) - const sy = snap(y * view.scale + view.y - NODE_R * view.scale * 0.8) + const off = anchor === 'shoulder' ? NODE_R * view.scale * 0.8 : 0 + const sx = snap(x * view.scale + view.x + off) + const sy = snap(y * view.scale + view.y - off) ctx.save() ctx.setTransform(dpr, 0, 0, dpr, 0, 0) // Below this the house is antialiasing soup: a solid accent mini-pin on From 6f71ee595963fd41c4e9cb171f97a16bdcf90f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 17:26:52 +0200 Subject: [PATCH 04/11] Improve column reposition --- src/renderer/src/components/graph/render.ts | 29 +++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 4254417..ea09ef7 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -157,6 +157,9 @@ export interface SceneState { } const LABEL_FONT = 11 +/** Breathing room the day-header label keeps from its segment's right boundary + * so it never touches the next day's label (drawHeader). */ +const HEADER_LABEL_PAD = 6 /** Caption font in SCREEN px: captions render map-label style — a constant * on-screen size at any zoom. Size and weight must mirror .graph-tip__subject * in graph.css exactly: the expansion card's first line sits on the caption's @@ -940,10 +943,26 @@ function drawHeader(ctx: CanvasRenderingContext2D, scene: SceneState): void { ctx.lineTo(x + 0.5, HEADER_H - 4) ctx.stroke() } - // Pin the label to the left edge while its day segment is still on screen, - // so the current day is always readable mid-pan. - const labelX = Math.max(x, 0) + 6 - const label = truncate(ctx, marks[i].label, Math.max(0, next - labelX - 6)) - if (label) ctx.fillText(label, labelX, HEADER_H / 2 + 0.5) + // Center the label in its day segment's currently-visible span so it stays + // readable mid-pan (the sticky effect): as the segment scrolls past the left + // edge the label recenters in the space that's left. But it must never spill + // past the segment's right boundary onto the next day's label — so we clamp + // it left, repositioning as far as we can until it sits right-aligned against + // the boundary (with a small margin). If even that won't fit, the label clips + // on the left instead of ellipsizing — the date is cut but never mangled. + const label = marks[i].label + const labelW = ctx.measureText(label).width + const visibleLeft = Math.max(x, 0) + const visibleRight = Math.min(next, width) + let labelX = (visibleLeft + visibleRight) / 2 - labelW / 2 + labelX = Math.max(labelX, visibleLeft) + // Right boundary wins over the left clamp: keep the date off the next day. + labelX = Math.min(labelX, next - HEADER_LABEL_PAD - labelW) + ctx.save() + ctx.beginPath() + ctx.rect(visibleLeft, 0, Math.max(0, visibleRight - visibleLeft), HEADER_H) + ctx.clip() + ctx.fillText(label, labelX, HEADER_H / 2 + 0.5) + ctx.restore() } } From fe4dd38ce5f3fbc4f8099a3d7f1e6e78deeda9f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 17:38:31 +0200 Subject: [PATCH 05/11] Improve mege liks and merge commits --- .../src/components/graph/layout.test.ts | 31 +++++++++++++++++++ src/renderer/src/components/graph/layout.ts | 8 +++++ src/renderer/src/components/graph/render.ts | 25 ++++++++++++--- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index f72767e..af8bef6 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -74,6 +74,37 @@ describe('layoutGraph', () => { expect(merge?.toRow).toBe(feature.index) }) + test('merge nodes carry the incoming branch color; other nodes carry none', () => { + // main: a ── b ───── m (merge) feature: f1 ── f2 + const layout = layoutGraph( + input([ + commit('m', ['b', 'f2'], 'HEAD -> main'), + commit('f2', ['f1'], 'feature'), + commit('f1', ['a']), + commit('b', ['a']), + commit('a', []) + ]) + ) + const feature = rowNamed(layout, 'feature') + // The merge ring wears the merged-in branch's palette slot — the same + // color its merge edge draws in, so line and ring read as one thing. + expect(layout.nodeByHash.get('m')?.mergeColor).toBe(feature.color) + expect(layout.nodeByHash.get('b')?.mergeColor).toBeNull() + expect(layout.nodeByHash.get('f2')?.mergeColor).toBeNull() + }) + + test('a merge whose merged parent is outside the window gets no merge color', () => { + // m's second parent never loaded: still isMerge, but there is no incoming + // edge to color a ring after — the node draws as a plain commit. + const layout = layoutGraph( + input([commit('m', ['b', 'zzz'], 'HEAD -> main'), commit('b', ['a']), commit('a', [])]) + ) + const m = layout.nodeByHash.get('m') + expect(m?.isMerge).toBe(true) + expect(m?.mergeColor).toBeNull() + expect(m?.truncated).toBe(true) + }) + test('local and remote refs with one base name share a single row', () => { // origin/main two ahead of main; the walk from the remote tip claims both. const layout = layoutGraph( diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index ad26192..a146c5e 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -85,6 +85,10 @@ export interface GraphNode { color: number refs: CommitRef[] isMerge: boolean + /** Palette slot of the branch that merged in (the first merge edge's color) + * — the merge ring the renderer draws around the node. Null when the commit + * isn't a merge or every merged parent fell outside the loaded window. */ + mergeColor: number | null /** The checked-out commit (HEAD) — gets the marker ring. */ isHead: boolean /** Some parents fell outside the loaded window (draw a continuation stub). */ @@ -587,6 +591,7 @@ export function layoutGraph(input: GraphInput): GraphLayout { color: rows[chainId].color, refs: parseRefs(commit.refs), isMerge: commit.parents.length > 1, + mergeColor: null, // When HEAD is on a zero-commit branch, the marker belongs to the empty // lane (its GraphRow.isHead), never to the anchor commit's node. isHead: commit.hash === headHash && headEmptyChain === -1, @@ -623,6 +628,9 @@ export function layoutGraph(input: GraphInput): GraphLayout { // host several chains, so a same-row fork must still draw as a fork. const sameChain = chainOf.get(node.commit.hash) === chainOf.get(resolved) const kind: GraphEdgeKind = parentIdx > 0 ? 'merge' : sameChain ? 'line' : 'fork' + // The node's merge ring wears the first incoming merge edge's color — + // octopus merges keep one ring (parents[1]'s branch), not a color wheel. + if (kind === 'merge' && node.mergeColor === null) node.mergeColor = target.color edges.push({ kind, // Merges carry the source branch's color, forks the new branch's. diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index ea09ef7..6a08a89 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -524,11 +524,15 @@ function drawNodes( } // Opaque backing disc: edges and spines terminate BEHIND the commit, so - // a dimmed (translucent) node never shows lines through its face. + // a dimmed (translucent) node never shows lines through its face. Merge + // nodes get a wider disc: it clears the air gap under the merge ring and + // makes every line dock at the ring's outer edge instead of vanishing + // beneath the avatar. + const isMergeNode = node.mergeColor !== null ctx.globalAlpha = 1 ctx.fillStyle = palette.bg ctx.beginPath() - ctx.arc(x, y, NODE_R + 1.5, 0, Math.PI * 2) + ctx.arc(x, y, isMergeNode ? NODE_R + 5 : NODE_R + 1.5, 0, Math.PI * 2) ctx.fill() ctx.globalAlpha = dim ? DIM_ALPHA : 1 @@ -568,14 +572,27 @@ function drawNodes( ctx.beginPath() ctx.arc(x, y, NODE_R + 0.5, 0, Math.PI * 2) ctx.stroke() + // Merge ring: a second ring in the INCOMING branch's color, separated + // from the branch ring by a hair of background. Merge edges keep their + // source branch's color, so the ring completes the association — the + // green line docks into a green ring — and a merge link can never be + // misread as a fork link. Fork/parent ends stay bare. + if (node.mergeColor !== null) { + ctx.lineWidth = 2 + ctx.strokeStyle = branchStroke(palette, node.mergeColor) + ctx.beginPath() + ctx.arc(x, y, NODE_R + 3.5, 0, Math.PI * 2) + ctx.stroke() + } if (isSelected) { // Outer accent ring: "this is picked" — selection only. The home // changeset wears the house badge below instead, so being at home - // never *looks* like a selection. + // never *looks* like a selection. On merge nodes it steps outside the + // merge ring — the two rings must never overlap. ctx.lineWidth = 2 ctx.strokeStyle = palette.accent ctx.beginPath() - ctx.arc(x, y, NODE_R + 4, 0, Math.PI * 2) + ctx.arc(x, y, isMergeNode ? NODE_R + 7 : NODE_R + 4, 0, Math.PI * 2) ctx.stroke() } // Backport twin dot: this change also lives on another line — hover or From 4e4e6c6be63825a699503eb68c522326c72ae137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 17:40:04 +0200 Subject: [PATCH 06/11] Fix test formatting --- src/renderer/src/components/graph/geometry.test.ts | 4 +--- src/renderer/src/components/graph/layout.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/graph/geometry.test.ts b/src/renderer/src/components/graph/geometry.test.ts index bd37177..b219dfe 100644 --- a/src/renderer/src/components/graph/geometry.test.ts +++ b/src/renderer/src/components/graph/geometry.test.ts @@ -225,9 +225,7 @@ describe('graph geometry', () => { test('contentSize reserves a column for the WIP node', () => { const layout = sampleLayout() - expect(contentSize(layout, layout.columnCount).width - contentSize(layout, null).width).toBe( - 44 - ) + expect(contentSize(layout, layout.columnCount).width - contentSize(layout, null).width).toBe(44) }) test('contentSize covers an empty branch slot past the last commit column', () => { diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index af8bef6..5a61622 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -621,7 +621,11 @@ describe('empty branches (zero-commit refs)', () => { expect(layout.rows.some((r) => r.name === 'HEAD')).toBe(false) // But a HEAD ref that claims commits of its own keeps its row, as before. const claimed = layoutGraph( - input([commit('h', ['a'], 'origin/HEAD'), commit('b', ['a'], 'HEAD -> main'), commit('a', [])]) + input([ + commit('h', ['a'], 'origin/HEAD'), + commit('b', ['a'], 'HEAD -> main'), + commit('a', []) + ]) ) const headRow = claimed.rows.find((r) => r.name === 'HEAD') expect(headRow?.empty).toBe(false) From eeb1ea86df3188dc291d6318b4b4046939ccb5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 17:40:04 +0200 Subject: [PATCH 07/11] Fix test formatting --- src/renderer/src/components/graph/geometry.test.ts | 4 +--- src/renderer/src/components/graph/layout.test.ts | 6 +++++- src/renderer/src/components/graph/render.ts | 14 +++++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/graph/geometry.test.ts b/src/renderer/src/components/graph/geometry.test.ts index bd37177..b219dfe 100644 --- a/src/renderer/src/components/graph/geometry.test.ts +++ b/src/renderer/src/components/graph/geometry.test.ts @@ -225,9 +225,7 @@ describe('graph geometry', () => { test('contentSize reserves a column for the WIP node', () => { const layout = sampleLayout() - expect(contentSize(layout, layout.columnCount).width - contentSize(layout, null).width).toBe( - 44 - ) + expect(contentSize(layout, layout.columnCount).width - contentSize(layout, null).width).toBe(44) }) test('contentSize covers an empty branch slot past the last commit column', () => { diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index af8bef6..5a61622 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -621,7 +621,11 @@ describe('empty branches (zero-commit refs)', () => { expect(layout.rows.some((r) => r.name === 'HEAD')).toBe(false) // But a HEAD ref that claims commits of its own keeps its row, as before. const claimed = layoutGraph( - input([commit('h', ['a'], 'origin/HEAD'), commit('b', ['a'], 'HEAD -> main'), commit('a', [])]) + input([ + commit('h', ['a'], 'origin/HEAD'), + commit('b', ['a'], 'HEAD -> main'), + commit('a', []) + ]) ) const headRow = claimed.rows.find((r) => r.name === 'HEAD') expect(headRow?.empty).toBe(false) diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 6a08a89..e9a99bc 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -396,11 +396,23 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, const cy = nodeY(edge.fromRow) ctx.strokeStyle = branchStroke(palette, edge.color) ctx.globalAlpha = lit ? 0.8 : 0.12 + // Merge edges end with an arrowhead: back-direction of the path's final + // tangent + the path's end point, set per shape below (merges are the one + // edge that flows INTO a commit — the arrow states the direction right + // where a merge link could be misread as a fork link). + let arrowBackX = 0 + let arrowBackY = 0 + let endY = cy ctx.beginPath() if (py === cy) { // Same-row hop (criss-cross merge / packed-row fork): a shallow arc. + const controlY = py - CAPSULE_HALF_H - 14 ctx.moveTo(px, py - NODE_R) - ctx.quadraticCurveTo((px + cx) / 2, py - CAPSULE_HALF_H - 14, cx, cy - NODE_R) + ctx.quadraticCurveTo((px + cx) / 2, controlY, cx, cy - NODE_R) + // End tangent of a quadratic points from control point to end. + arrowBackX = (px + cx) / 2 - cx + arrowBackY = controlY - (cy - NODE_R) + endY = cy - NODE_R } else if (edge.kind === 'fork') { // Orthogonal, Plastic-style: drop straight down/up the fork column, // then run along the child's row into its first commit. Long runs From b5ce24fbde10264770acf28a7ee2d3f4b32dceb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 17:46:23 +0200 Subject: [PATCH 08/11] Draw merge arrowheads docking into merge commits Wire up the previously-unused edge end tangents (arrowBackX/Y, endY) to render a filled, round-joined arrowhead where each merge edge docks into its merge commit's backing disc. --- src/renderer/src/components/graph/render.ts | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index e9a99bc..b20e051 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -434,12 +434,71 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, ctx.lineTo(cx - r, py) ctx.quadraticCurveTo(cx, py, cx, py + dir * r) ctx.lineTo(cx, cy - dir * NODE_R) + arrowBackY = -dir + endY = cy - dir * NODE_R } ctx.stroke() + if (edge.kind === 'merge') { + // The arrowhead is OPAQUE while its line draws at 0.8: a translucent + // triangle over a translucent line stacks alpha where they overlap and + // renders blotchy. Solid, the arrow simply caps the line (Plastic-style). + ctx.globalAlpha = lit ? 1 : 0.12 + const len = Math.hypot(arrowBackX, arrowBackY) || 1 + drawMergeArrow(ctx, cx, endY, arrowBackX / len, arrowBackY / len, cx, cy) + } } ctx.globalAlpha = 1 } +/** Length of the merge arrowhead along the edge, and its half-width — the + * round stroke below fattens these by ~1.5px on every side. */ +const ARROW_L = 6.5 +const ARROW_HALF_W = 3.5 + +/** Filled arrowhead where a merge edge docks into its merge commit, corners + * rounded Plastic-style by stroking the triangle with the scene's round + * lineJoin in its own color. The tip sits outside the merge node's enlarged + * backing disc (NODE_R + 5, see drawNodes) with room for the rounding — + * drawNodes paints that disc over the edge, so any part of the arrow inside + * it would be flattened. The path's end tangent is effectively straight out + * there, so walking back from the path's end along (ux, uy) to the dock + * radius lands on the drawn line — arcs and pipes alike. */ +function drawMergeArrow( + ctx: CanvasRenderingContext2D, + /** The edge path's end point (on the merge node's rim). */ + ex: number, + ey: number, + /** Unit vector pointing from the path's end BACK along the path. */ + ux: number, + uy: number, + /** Merge commit center. */ + nx: number, + ny: number +): void { + const dockR = NODE_R + 6.5 + // Solve |end + s·u - center| = dockR for s — where the retreating path + // crosses the dock radius; the arrow's tip sits there. + const dx = ex - nx + const dy = ey - ny + const du = dx * ux + dy * uy + const s = -du + Math.sqrt(Math.max(0, du * du - (dx * dx + dy * dy) + dockR * dockR)) + const tx = ex + ux * s + const ty = ey + uy * s + const bx = tx + ux * ARROW_L + const by = ty + uy * ARROW_L + ctx.beginPath() + ctx.moveTo(tx, ty) + ctx.lineTo(bx - uy * ARROW_HALF_W, by + ux * ARROW_HALF_W) + ctx.lineTo(bx + uy * ARROW_HALF_W, by - ux * ARROW_HALF_W) + ctx.closePath() + ctx.fillStyle = ctx.strokeStyle + ctx.fill() + // Round-joined stroke in the same color plumps the corners into soft caps. + ctx.lineWidth = 3 + ctx.stroke() + ctx.lineWidth = 2 +} + // Twin markers: which commits participate in any link. Cached per links array // (stable between frames) so the 60fps pan path never re-derives the set. const twinCache = new WeakMap>() From 1ef91521992a24f0f109e766b17551b064d68f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 17:56:14 +0200 Subject: [PATCH 09/11] Remove arrow heads (doesnt' look good) --- src/renderer/src/components/graph/render.ts | 70 --------------------- 1 file changed, 70 deletions(-) diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index b20e051..3fd40a0 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -396,23 +396,12 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, const cy = nodeY(edge.fromRow) ctx.strokeStyle = branchStroke(palette, edge.color) ctx.globalAlpha = lit ? 0.8 : 0.12 - // Merge edges end with an arrowhead: back-direction of the path's final - // tangent + the path's end point, set per shape below (merges are the one - // edge that flows INTO a commit — the arrow states the direction right - // where a merge link could be misread as a fork link). - let arrowBackX = 0 - let arrowBackY = 0 - let endY = cy ctx.beginPath() if (py === cy) { // Same-row hop (criss-cross merge / packed-row fork): a shallow arc. const controlY = py - CAPSULE_HALF_H - 14 ctx.moveTo(px, py - NODE_R) ctx.quadraticCurveTo((px + cx) / 2, controlY, cx, cy - NODE_R) - // End tangent of a quadratic points from control point to end. - arrowBackX = (px + cx) / 2 - cx - arrowBackY = controlY - (cy - NODE_R) - endY = cy - NODE_R } else if (edge.kind === 'fork') { // Orthogonal, Plastic-style: drop straight down/up the fork column, // then run along the child's row into its first commit. Long runs @@ -434,71 +423,12 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, ctx.lineTo(cx - r, py) ctx.quadraticCurveTo(cx, py, cx, py + dir * r) ctx.lineTo(cx, cy - dir * NODE_R) - arrowBackY = -dir - endY = cy - dir * NODE_R } ctx.stroke() - if (edge.kind === 'merge') { - // The arrowhead is OPAQUE while its line draws at 0.8: a translucent - // triangle over a translucent line stacks alpha where they overlap and - // renders blotchy. Solid, the arrow simply caps the line (Plastic-style). - ctx.globalAlpha = lit ? 1 : 0.12 - const len = Math.hypot(arrowBackX, arrowBackY) || 1 - drawMergeArrow(ctx, cx, endY, arrowBackX / len, arrowBackY / len, cx, cy) - } } ctx.globalAlpha = 1 } -/** Length of the merge arrowhead along the edge, and its half-width — the - * round stroke below fattens these by ~1.5px on every side. */ -const ARROW_L = 6.5 -const ARROW_HALF_W = 3.5 - -/** Filled arrowhead where a merge edge docks into its merge commit, corners - * rounded Plastic-style by stroking the triangle with the scene's round - * lineJoin in its own color. The tip sits outside the merge node's enlarged - * backing disc (NODE_R + 5, see drawNodes) with room for the rounding — - * drawNodes paints that disc over the edge, so any part of the arrow inside - * it would be flattened. The path's end tangent is effectively straight out - * there, so walking back from the path's end along (ux, uy) to the dock - * radius lands on the drawn line — arcs and pipes alike. */ -function drawMergeArrow( - ctx: CanvasRenderingContext2D, - /** The edge path's end point (on the merge node's rim). */ - ex: number, - ey: number, - /** Unit vector pointing from the path's end BACK along the path. */ - ux: number, - uy: number, - /** Merge commit center. */ - nx: number, - ny: number -): void { - const dockR = NODE_R + 6.5 - // Solve |end + s·u - center| = dockR for s — where the retreating path - // crosses the dock radius; the arrow's tip sits there. - const dx = ex - nx - const dy = ey - ny - const du = dx * ux + dy * uy - const s = -du + Math.sqrt(Math.max(0, du * du - (dx * dx + dy * dy) + dockR * dockR)) - const tx = ex + ux * s - const ty = ey + uy * s - const bx = tx + ux * ARROW_L - const by = ty + uy * ARROW_L - ctx.beginPath() - ctx.moveTo(tx, ty) - ctx.lineTo(bx - uy * ARROW_HALF_W, by + ux * ARROW_HALF_W) - ctx.lineTo(bx + uy * ARROW_HALF_W, by - ux * ARROW_HALF_W) - ctx.closePath() - ctx.fillStyle = ctx.strokeStyle - ctx.fill() - // Round-joined stroke in the same color plumps the corners into soft caps. - ctx.lineWidth = 3 - ctx.stroke() - ctx.lineWidth = 2 -} - // Twin markers: which commits participate in any link. Cached per links array // (stable between frames) so the 60fps pan path never re-derives the set. const twinCache = new WeakMap>() From 809b4ae22f5481618e0f0c29045b59d225638964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 18:00:45 +0200 Subject: [PATCH 10/11] Make all commit nodes same size to uniform the layout --- src/renderer/src/components/graph/render.ts | 60 +++++++++++++-------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index 3fd40a0..001c709 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -526,14 +526,13 @@ function drawNodes( // Opaque backing disc: edges and spines terminate BEHIND the commit, so // a dimmed (translucent) node never shows lines through its face. Merge - // nodes get a wider disc: it clears the air gap under the merge ring and - // makes every line dock at the ring's outer edge instead of vanishing - // beneath the avatar. - const isMergeNode = node.mergeColor !== null + // nodes wear a second ring but keep the same outer size as every other + // node (see below), so they share the same backing disc — every line + // docks at the ring's outer edge. ctx.globalAlpha = 1 ctx.fillStyle = palette.bg ctx.beginPath() - ctx.arc(x, y, isMergeNode ? NODE_R + 5 : NODE_R + 1.5, 0, Math.PI * 2) + ctx.arc(x, y, NODE_R + 1.5, 0, Math.PI * 2) ctx.fill() ctx.globalAlpha = dim ? DIM_ALPHA : 1 @@ -547,13 +546,19 @@ function drawNodes( } // Face: colored initials disc, covered by the avatar image once loaded. + // Merge nodes carry a second (inner) ring, so their face shrinks to sit + // inside it — the ring frames the avatar instead of cutting across it. The + // face reaches the inner ring's inner edge (10 − 0.75) with the same 0.5px + // overlap a normal node's ring has, so the ring sits ON the avatar edge + // with no hairline of background between them. + const faceR = node.mergeColor !== null ? NODE_R - 2.25 : NODE_R const image = avatarImageFor(node.commit.authorName, node.commit.authorEmail) ctx.beginPath() - ctx.arc(x, y, NODE_R, 0, Math.PI * 2) + ctx.arc(x, y, faceR, 0, Math.PI * 2) if (image) { ctx.save() ctx.clip() - ctx.drawImage(image, x - NODE_R, y - NODE_R, NODE_R * 2, NODE_R * 2) + ctx.drawImage(image, x - faceR, y - faceR, faceR * 2, faceR * 2) ctx.restore() } else { ctx.fillStyle = avatarColor(node.commit.authorEmail || node.commit.authorName) @@ -568,32 +573,41 @@ function drawNodes( // Ring in the branch color; louder states stack on top. const isActiveMatch = node.commit.hash === scene.activeMatch const isHover = node.commit.hash === scene.hoverHash - ctx.lineWidth = isHover || isActiveMatch ? 2.5 : 2 - ctx.strokeStyle = isActiveMatch ? palette.match : branchStroke(palette, node.color) - ctx.beginPath() - ctx.arc(x, y, NODE_R + 0.5, 0, Math.PI * 2) - ctx.stroke() - // Merge ring: a second ring in the INCOMING branch's color, separated - // from the branch ring by a hair of background. Merge edges keep their - // source branch's color, so the ring completes the association — the - // green line docks into a green ring — and a merge link can never be - // misread as a fork link. Fork/parent ends stay bare. + const emphasized = isHover || isActiveMatch if (node.mergeColor !== null) { - ctx.lineWidth = 2 - ctx.strokeStyle = branchStroke(palette, node.mergeColor) + // Merge nodes carry TWO concentric rings, but both are squeezed into the + // footprint of a single ring so a merge commit is exactly the same size + // as every other node — no ballooning. The outer ring is the INCOMING + // branch's color, sitting at the normal ring radius where the merge edge + // docks: the green line docks into a green ring, so a merge link can + // never be misread as a fork link. This node's own branch color tucks + // just inside it, a hair of background between them. + ctx.lineWidth = 1.5 + ctx.strokeStyle = branchStroke(palette, node.color) + ctx.beginPath() + ctx.arc(x, y, NODE_R - 2, 0, Math.PI * 2) + ctx.stroke() + ctx.lineWidth = emphasized ? 2.5 : 2 + ctx.strokeStyle = isActiveMatch ? palette.match : branchStroke(palette, node.mergeColor) + ctx.beginPath() + ctx.arc(x, y, NODE_R + 0.5, 0, Math.PI * 2) + ctx.stroke() + } else { + ctx.lineWidth = emphasized ? 2.5 : 2 + ctx.strokeStyle = isActiveMatch ? palette.match : branchStroke(palette, node.color) ctx.beginPath() - ctx.arc(x, y, NODE_R + 3.5, 0, Math.PI * 2) + ctx.arc(x, y, NODE_R + 0.5, 0, Math.PI * 2) ctx.stroke() } if (isSelected) { // Outer accent ring: "this is picked" — selection only. The home // changeset wears the house badge below instead, so being at home - // never *looks* like a selection. On merge nodes it steps outside the - // merge ring — the two rings must never overlap. + // never *looks* like a selection. Merge nodes are the same size now, so + // it sits at the same radius for every node. ctx.lineWidth = 2 ctx.strokeStyle = palette.accent ctx.beginPath() - ctx.arc(x, y, isMergeNode ? NODE_R + 7 : NODE_R + 4, 0, Math.PI * 2) + ctx.arc(x, y, NODE_R + 4, 0, Math.PI * 2) ctx.stroke() } // Backport twin dot: this change also lives on another line — hover or From 659d6d96361cf84972d4ba7cb2bde5823abc6ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 18:10:37 +0200 Subject: [PATCH 11/11] Fix lint --- src/main/ai/branch-name.ts | 12 +++++++----- .../src/components/app/CreateBranchDialog.tsx | 2 +- src/renderer/src/components/app/ErrorDialog.tsx | 4 +--- .../src/components/common/AiExplainCommit.tsx | 4 +--- .../src/components/common/AiTeaserPopover.tsx | 4 ++-- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/main/ai/branch-name.ts b/src/main/ai/branch-name.ts index e18ea49..fbb4dcd 100644 --- a/src/main/ai/branch-name.ts +++ b/src/main/ai/branch-name.ts @@ -67,7 +67,12 @@ export function slugFromModelOutput(text: string, taken: Iterable = []): // segment ending in '.lock'. Empty segments (from stripped junk) drop out. slug = slug .split('/') - .map((s) => s.replace(/(\.lock)+$/, '').replace(/^[-.]+/, '').replace(/[-.]+$/, '')) + .map((s) => + s + .replace(/(\.lock)+$/, '') + .replace(/^[-.]+/, '') + .replace(/[-.]+$/, '') + ) .filter(Boolean) .join('/') @@ -123,10 +128,7 @@ export interface BranchNameContext { export async function gatherBranchNameContext(repoPath: string): Promise { // Both reads are lock-free and independent — overlap them. - const [snapshot, branches] = await Promise.all([ - getRepoSnapshot(repoPath), - getBranches(repoPath) - ]) + const [snapshot, branches] = await Promise.all([getRepoSnapshot(repoPath), getBranches(repoPath)]) const pieces: string[] = [] let budget = BRANCH_NAME_CAPS.totalBytes diff --git a/src/renderer/src/components/app/CreateBranchDialog.tsx b/src/renderer/src/components/app/CreateBranchDialog.tsx index ccb3b8e..3f70b50 100644 --- a/src/renderer/src/components/app/CreateBranchDialog.tsx +++ b/src/renderer/src/components/app/CreateBranchDialog.tsx @@ -101,11 +101,11 @@ export function CreateBranchDialog({ // field greets the user with a name already forming. Only when a backend is // connected, there are changes to name from, and no caller-provided name. const autoRan = useRef(false) + // biome-ignore lint/correctness/useExhaustiveDependencies: fire once when the status resolves useEffect(() => { if (autoRan.current || !aiStatus || !canSuggest || initialName) return autoRan.current = true suggest() - // biome-ignore lint/correctness/useExhaustiveDependencies: fire once when the status resolves }, [aiStatus]) // The ghost shows while the field is empty: the streaming raw text as it diff --git a/src/renderer/src/components/app/ErrorDialog.tsx b/src/renderer/src/components/app/ErrorDialog.tsx index b91b66c..9872589 100644 --- a/src/renderer/src/components/app/ErrorDialog.tsx +++ b/src/renderer/src/components/app/ErrorDialog.tsx @@ -47,9 +47,7 @@ export function ErrorDialog({ message, info, ai, onClose }: Props) { const [teaserOpen, setTeaserOpen] = useState(false) const [noteOpen, setNoteOpen] = useState(false) const explainRef = useRef(null) - const explanation = useAiGeneration((e) => - setFailure(e instanceof Error ? e.message : String(e)) - ) + const explanation = useAiGeneration((e) => setFailure(e instanceof Error ? e.message : String(e))) const copy = () => { window.gitgrove.clipboardWrite(message) diff --git a/src/renderer/src/components/common/AiExplainCommit.tsx b/src/renderer/src/components/common/AiExplainCommit.tsx index 62981d1..93bceac 100644 --- a/src/renderer/src/components/common/AiExplainCommit.tsx +++ b/src/renderer/src/components/common/AiExplainCommit.tsx @@ -34,9 +34,7 @@ export function useAiExplainCommit(args: AiExplainArgs | null): { const [failure, setFailure] = useState(null) const [teaserOpen, setTeaserOpen] = useState(false) const chipRef = useRef(null) - const explanation = useAiGeneration((e) => - setFailure(e instanceof Error ? e.message : String(e)) - ) + const explanation = useAiGeneration((e) => setFailure(e instanceof Error ? e.message : String(e))) const hash = args?.hash // Some hosts remount per commit (keyed by hash), some don't (the Graph's diff --git a/src/renderer/src/components/common/AiTeaserPopover.tsx b/src/renderer/src/components/common/AiTeaserPopover.tsx index 0abf6a5..1d86f35 100644 --- a/src/renderer/src/components/common/AiTeaserPopover.tsx +++ b/src/renderer/src/components/common/AiTeaserPopover.tsx @@ -26,8 +26,8 @@ export function AiTeaserPopover({ anchor, open, onClose, title, body, onSetup }: {title}

- {body} Connect OpenAI, Anthropic, Gemini, a local Ollama or any compatible endpoint — - your key, sent only to your provider. + {body} Connect OpenAI, Anthropic, Gemini, a local Ollama or any compatible endpoint — your + key, sent only to your provider.