From a247f1ce67751ddec3ffffd0437f60f6e17953e5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:12:41 +0800 Subject: [PATCH] feat(vcs): draw lane changes in the Git log graph as straight diagonals (#1406) Lane changes bent within half a row as S-curves. A lane change and the vertical half of the neighbouring row that continues it now meet halfway between the lanes, so edges between adjacent rows run straight from node to node; only drawn positions change. 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 }; }