From 42d97daffd7a44861f512f2bf5221073e1924754 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:12:05 +0800 Subject: [PATCH] feat(vcs): draw the Git log graph's lane changes as straight diagonals A line that changed lane bent within half a row as an S-curve and then ran on vertically, so branches and merges looked like rounded steps. A lane change is now a straight diagonal across the row boundary: the half-row segment that changes lane and the vertical half of the neighbouring row that continues it meet halfway between the two lanes, so an edge between adjacent rows runs straight from node to node, both where a branch leaves and where it converges. Only the drawn positions change (fromX / toX); lanes, navigation and fragment actions keep using from / to. A lane that another line also ends in, and long-edge arrows, keep their positions. Fixes #800 --- src/features/vcs/GitLog.tsx | 15 ++++---- src/features/vcs/__tests__/graph.test.ts | 46 +++++++++++++++++++++++- src/features/vcs/graph.ts | 40 +++++++++++++++++++++ src/features/vcs/logPresentation.ts | 5 +-- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 8f781519..89edb7bb 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -1956,10 +1956,9 @@ function x(lane: number) { return LANE / 2 + lane * LANE; } +/** Straight, also across lanes (IDEA's diagonal edges). */ function seg(fromX: number, fromY: number, toX: number, toY: number) { - if (fromX === toX) return `M${fromX} ${fromY}L${toX} ${toY}`; - const mid = (fromY + toY) / 2; - return `M${fromX} ${fromY}C${fromX} ${mid} ${toX} ${mid} ${toX} ${toY}`; + return `M${fromX} ${fromY}L${toX} ${toY}`; } export function GraphCell({ @@ -2008,9 +2007,11 @@ export function GraphCell({ if (canNavigate) onNavigate?.(s.targetRow!, target); else if (canFragment) onFragment?.(target!, !!s.dashed, s.source); }; + const fromX = x(s.fromX ?? s.from); + const toX = x(s.toX ?? s.to); const path = - seg(x(s.from), up ? 0 : cy, x(s.to), up ? cy : ROW) + - (s.arrow ? arrowHead(x(up ? s.from : s.to), up ? 0 : ROW, up ? -1 : 1) : ""); + seg(fromX, up ? 0 : cy, toX, up ? cy : ROW) + + (s.arrow ? arrowHead(up ? fromX : toX, up ? 0 : ROW, up ? -1 : 1) : ""); return ( {label} ({ id, parents }); @@ -62,3 +62,47 @@ describe("computeGraph", () => { expect(m2.down).toContainEqual({ from: m2.lane, to: 0, color: rows[0]!.color }); }); }); + +describe("straightenLaneChanges", () => { + it("draws a lane change as one diagonal across the row boundary, keeping the lanes", () => { + // m merges f into main; f branched from a; b sits in lane 0 while lane 1 passes it. + const rows = straightenLaneChanges(computeGraph([c("m", "b", "f"), c("f", "a"), c("b", "a"), c("a")]).rows); + const [m, f, b, a] = rows; + // m → f: leaves m's node towards lane 1 and reaches f's node in a straight line. + expect(m!.down[1]).toMatchObject({ from: 0, to: 1, toX: 0.5 }); + expect(f!.up.find((s) => s.from === 1)).toMatchObject({ from: 1, to: 1, fromX: 0.5 }); + // Lane 1 converging into a: the vertical line of b's row meets it halfway. + expect(a!.up[1]).toMatchObject({ from: 1, to: 0, fromX: 0.5 }); + expect(b!.down.find((s) => s.from === 1)).toMatchObject({ from: 1, to: 1, toX: 0.5 }); + // Straight lines stay where they are. + expect(m!.down[0]!.toX).toBeUndefined(); + expect(a!.up[0]!.fromX).toBeUndefined(); + }); + + it("leaves a lane that another line also ends in, and arrows, as they are", () => { + const rows = straightenLaneChanges([ + { + lane: 0, + color: 0, + up: [], + down: [ + { from: 0, to: 1, color: 0 }, + { from: 1, to: 1, color: 1 }, + ], + width: 2, + }, + { + lane: 1, + color: 1, + up: [{ from: 1, to: 1, color: 1 }], + down: [{ from: 1, to: 2, color: 1, arrow: "down" }], + width: 3, + }, + { lane: 2, color: 1, up: [{ from: 2, to: 2, color: 1 }], down: [], width: 3 }, + ]); + expect(rows[0]!.down[0]!.toX).toBeUndefined(); + expect(rows[1]!.up[0]!.fromX).toBeUndefined(); + expect(rows[1]!.down[0]!.toX).toBeUndefined(); + expect(rows[2]!.up[0]!.fromX).toBeUndefined(); + }); +}); diff --git a/src/features/vcs/graph.ts b/src/features/vcs/graph.ts index 075dbdae..1f160952 100644 --- a/src/features/vcs/graph.ts +++ b/src/features/vcs/graph.ts @@ -21,6 +21,9 @@ export interface GraphSegment { arrow?: "up" | "down"; /** Destination row of a long-edge arrow in the displayed graph. */ targetRow?: number; + /** Drawn lane positions when they differ from `from` / `to` (see {@link straightenLaneChanges}). */ + fromX?: number; + toX?: number; } export interface GraphRow { @@ -503,6 +506,43 @@ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE): return out; } +/** + * Draws a lane change as one straight diagonal across the row boundary, like IDEA: a half-row + * segment that changes lane and the vertical half of the neighbouring row that continues it meet + * halfway between the two lanes. Only the drawn positions change; `from` / `to` stay the lanes. + */ +export function straightenLaneChanges(rows: readonly GraphRow[]): GraphRow[] { + const out = rows.map((r) => ({ ...r, up: [...r.up], down: [...r.down] })); + const vertical = (s: GraphSegment, lane: number) => s.from === lane && s.to === lane && !s.arrow; + for (let r = 0; r < out.length; r++) { + const row = out[r]!; + // Leaving the node towards another lane: continues straight down in the next row. + const next = out[r + 1]; + // Another line ending at the same lane (a merge into a lane that continues) keeps its position. + const shared = (list: readonly GraphSegment[], lane: number, end: "from" | "to") => + list.filter((x) => x[end] === lane).length > 1; + row.down.forEach((s, i) => { + if (s.from === s.to || s.arrow || !next || shared(row.down, s.to, "to")) return; + const j = next.up.findIndex((u) => vertical(u, s.to) && u.edge === s.edge && u.fromX === undefined); + if (j < 0) return; + const mid = (s.from + s.to) / 2; + row.down[i] = { ...s, toX: mid }; + next.up[j] = { ...next.up[j]!, fromX: mid }; + }); + // Converging into the node from another lane: continues a vertical line of the previous row. + const prev = out[r - 1]; + row.up.forEach((s, i) => { + if (s.from === s.to || s.arrow || !prev || shared(prev.down, s.from, "to")) return; + const j = prev.down.findIndex((d) => vertical(d, s.from) && d.edge === s.edge && d.toX === undefined); + if (j < 0) return; + const mid = (s.from + s.to) / 2; + row.up[i] = { ...s, fromX: mid }; + prev.down[j] = { ...prev.down[j]!, toX: mid }; + }); + } + return out; +} + /** Lane colours (tokens), cycled by colour index. */ export const LANE_COLORS = [ "var(--accent)", diff --git a/src/features/vcs/logPresentation.ts b/src/features/vcs/logPresentation.ts index b9d82228..c4471c99 100644 --- a/src/features/vcs/logPresentation.ts +++ b/src/features/vcs/logPresentation.ts @@ -1,4 +1,4 @@ -import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges } from "./graph"; +import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges, straightenLaneChanges } from "./graph"; import { commitKey, graphCommit, type RepoCommit } from "./multiRoot"; import type { LogOptions } from "./logOptions"; @@ -26,5 +26,6 @@ export function presentLogGraph( const byId = new Map(all.map((c) => [commitKey(c), c])); const commits = graph.map((c) => byId.get(c.id)!); const laid = computeGraph(graph).rows; - return { commits, graph, rows: options.longEdges ? laid : hideLongEdges(laid), linear, collapsed }; + const rows = straightenLaneChanges(options.longEdges ? laid : hideLongEdges(laid)); + return { commits, graph, rows, linear, collapsed }; }