diff --git a/media/gitGraph.css b/media/gitGraph.css index 95a9701..1e94e40 100644 --- a/media/gitGraph.css +++ b/media/gitGraph.css @@ -1,3 +1,19 @@ +:root { + --color-1: #f0883e; + --color-2: #6a9955; + --color-3: #569cd6; + --color-4: #c586c0; + --color-5: #ce9178; + --color-6: #9cdcfe; + --color-7: #4ec9b0; + --color-8: #dcdcaa; + + --col-graph-width: 50px; + --col-hash-width: 90px; + --col-author-width: 160px; + --col-date-width: 160px; +} + * { box-sizing: border-box; margin: 0; @@ -11,6 +27,8 @@ body { font-size: 13px; height: 100vh; overflow: hidden; + display: flex; + flex-direction: column; } #header { @@ -20,6 +38,7 @@ body { align-items: center; gap: 8px; background: var(--vscode-sideBarSectionHeader-background); + flex-shrink: 0; } #header h2 { @@ -44,31 +63,44 @@ body { margin-left: auto; } -#table-container { - overflow-y: auto; - height: calc(100vh - 49px); +#column-headers { + display: flex; + align-items: center; + padding: 8px 0; + border-bottom: 2px solid var(--vscode-panel-border); + background: var(--vscode-sideBarSectionHeader-background); + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 1; } -table { - width: 100%; - border-collapse: collapse; +#column-headers .col-graph { + width: var(--col-graph-width); + flex-shrink: 0; } -thead { - position: sticky; - top: 0; - z-index: 1; - background: var(--vscode-editor-background); +#column-headers .col-hash { + width: var(--col-hash-width); + flex-shrink: 0; } -thead tr { - border-bottom: 2px solid var(--vscode-panel-border); - background: var(--vscode-sideBarSectionHeader-background); +#column-headers .col-message { + flex: 1; } -th { - padding: 8px 12px; - text-align: left; +#column-headers .col-author { + width: var(--col-author-width); + flex-shrink: 0; +} + +#column-headers .col-date { + width: var(--col-date-width); + flex-shrink: 0; +} + +#column-headers div { + padding: 0 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; @@ -83,35 +115,31 @@ th { sans-serif; } -th:last-child { +#column-headers div:last-child { border-right: none; } -tbody tr { - border-bottom: 1px solid var(--vscode-panel-border, rgba(255, 255, 255, 0.05)); - cursor: pointer; - transition: background 0.1s; -} - -tbody tr:nth-child(even) { - background: var( - --vscode-list-inactiveSelectionBackground, - rgba(255, 255, 255, 0.02) - ); +#commits-container { + overflow-y: auto; + flex: 1; } -tbody tr:hover { - background: var(--vscode-list-hoverBackground); +.col-hash { + width: var(--col-hash-width); + flex-shrink: 0; + padding: 8px 12px; } -td { +.col-author { + width: var(--col-author-width); + flex-shrink: 0; padding: 8px 12px; - vertical-align: middle; } -.graph-cell { - width: 40px; - padding: 0 8px; +.col-date { + width: var(--col-date-width); + flex-shrink: 0; + padding: 8px 12px; } .hash { @@ -121,14 +149,6 @@ td { white-space: nowrap; } -.message { - color: var(--vscode-editor-foreground); - max-width: 400px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .author { color: var(--vscode-terminal-ansiGreen); white-space: nowrap; @@ -141,11 +161,58 @@ td { font-size: 12px; } +.commit-row { + display: flex; + align-items: stretch; + min-height: 40px; +} + +.commit-row-alt { + background: var( + --vscode-list-inactiveSelectionBackground, + rgba(255, 255, 255, 0.02) + ); +} + +.commit-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.col-graph { + width: var(--col-graph-width); + flex: 0 0 var(--col-graph-width); /* fixed width */ + padding: 0 8px; + display: flex; + align-items: stretch; +} + svg.graph { + height: 100%; + width: 100%; display: block; overflow: visible; } +.col-message { + flex: 1; + padding: 8px 12px; + overflow: hidden; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.message { + color: var(--vscode-editor-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + min-width: 0; + flex-shrink: 1; +} + .refs { color: var(--vscode-terminal-ansiGreen); font-size: 10px; @@ -153,5 +220,6 @@ svg.graph { padding: 2px 6px; border-radius: 3px; display: inline-block; - margin-left: 4px; + white-space: nowrap; + flex-shrink: 0; } diff --git a/src/gopswebpanel/GitGraphLayout.ts b/src/gopswebpanel/GitGraphLayout.ts new file mode 100644 index 0000000..ba21aae --- /dev/null +++ b/src/gopswebpanel/GitGraphLayout.ts @@ -0,0 +1,218 @@ +import { Edge } from "../models/Edge"; +import { PassThrough } from "../models/Passthrough"; +import { CommitLayout } from "../models/CommitLayout"; +import { GitCommitModel } from "../models/GitCommitModel"; +import { LaneManager } from "./LaneManager"; + +const LANE_COLORS = [ + "#569cd6", // blue + "#6a9955", // green + "#f0883e", // orange + "#4ec9b0", // teal + "#ce9178", // brown + "#dcdcaa", // yellow + "#9cdcfe", // light blue +]; + +const getColor = (lane: number) => LANE_COLORS[lane % LANE_COLORS.length]; + +export class GitGraphLayout { + private static computePassThroughs( + snapshot: (string | null)[], + lane: number, + currentHash: string, + ): PassThrough[] { + const passThroughs: PassThrough[] = []; + snapshot.forEach((hash, idx) => { + if (idx !== lane && hash !== null && hash !== currentHash) { + passThroughs.push({ lane: idx, color: getColor(idx) }); + } + }); + return passThroughs; + } + + private static hasTopConnector( + snapshot: (string | null)[], + lane: number, + currentHash: string, + ): boolean { + console.log( + `hasTopConnector: hash=${currentHash} lane=${lane} snapshot[lane]=${snapshot[lane]}`, + ); + return snapshot[lane] === currentHash; + } + + private static hasBottomConnector(parent: string | null): boolean { + console.log(`hasBottomConnector: parent=${parent}`); + return parent !== null; + } + + // Second pass: for each commit, find children in different lanes + // and add an outgoing edge. Children appear earlier in the log + // (newer first), so they're already in the layout when this runs. + private static computeOutgoingEdges( + layout: Map, + childMap: Map, + ): void { + layout.forEach((cl) => { + const children = childMap.get(cl.hash) || []; + children.forEach((childHash) => { + const child = layout.get(childHash); + if (!child) { + return; + } + if (child.lane === cl.lane) { + return; + } // same lane — handled by connector + if (child.lane < cl.lane) { + return; + } // child converges back — incoming edge, not outgoing + cl.outgoingEdges.push({ + fromLane: cl.lane, + toLane: child.lane, + fromHash: cl.hash, + toHash: childHash, + color: getColor(child.lane), + }); + }); + }); + } + + private static computeIncomingEdges( + layout: Map, + commits: GitCommitModel[], + ): void { + commits.forEach((commit) => { + const cl = layout.get(commit.hash); + if (!cl) { + return; + } + const isMergeCommit = commit.parents.length > 1; + if (!isMergeCommit) { + return; + } // only merge commits have incoming edges + commit.parents.forEach((p, index) => { + if (index === 0) { + return; + } // primary parent — handled by bottom connector + const parent = layout.get(p); + if (!parent) { + return; + } + if (parent.lane === cl.lane) { + return; + } // same lane — handled by connector + cl.incomingEdges.push({ + fromLane: cl.lane, + toLane: parent.lane, + fromHash: cl.hash, + toHash: p, + color: getColor(parent.lane), + }); + }); + }); + } + + private static resolveTopConnectors( + layout: Map, + commits: GitCommitModel[], + ): void { + commits.forEach((commit) => { + if (commit.parents.length > 1) { + commit.parents.slice(1).forEach((p) => { + const target = layout.get(p); + if (target) { + target.hasTopConnector = true; + } + }); + } + }); + } + + private static calculateChildMap( + commits: GitCommitModel[], + ): Map { + const childMap = new Map(); + commits.forEach((commit) => { + commit.parents.forEach((p) => { + if (!childMap.has(p)) { + childMap.set(p, []); + } + childMap.get(p)!.push(commit.hash); + }); + }); + return childMap; + } + + public static computeLayout( + commits: GitCommitModel[], + ): Map { + const layout = new Map(); + const laneManager = new LaneManager(); + + console.log("ALL COMMITS:"); + commits.forEach((c) => console.log(c.toString())); + + const hashToIndex = new Map(); + commits.forEach((c, i) => hashToIndex.set(c.hash, i)); + console.log("HASH TO INDEX:"); + hashToIndex.forEach((i, h) => console.log(`hash=${h} -> ${i}`)); + + // Build child map: parent hash → list of child hashes + // Needed for computing outgoing edges in the second pass. + const childMap = this.calculateChildMap(commits); + + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + //snashot lane state before the commit is assigned + const snapshot = [...laneManager.getLanes()]; + const lane = laneManager.findLaneForCommit(commit.hash); + const color = getColor(lane); + const parent = commit.parents[0] || null; + + console.log( + `Commit ${commit.hash} parents=${commit.parents.length} ${commit.parents.join(", ")}`, + ); + + laneManager.next(lane, parent); + + const passThroughs = this.computePassThroughs( + snapshot, + lane, + commit.hash, + ); + const hasTopConnector = this.hasTopConnector(snapshot, lane, commit.hash); + const hasBottomConnector = this.hasBottomConnector(parent); + + const commitLayout: CommitLayout = { + hash: commit.hash, + lane: lane, + color: color, + outgoingEdges: [], + incomingEdges: [], + passThroughs: passThroughs, + hasTopConnector: hasTopConnector, + hasBottomConnector: hasBottomConnector, + }; + + layout.set(commit.hash, commitLayout); + } + + //SECOND PASS ACTIVITIES: + //second pass needed because branch tip parents appear later in the log than the merge commit that references them. + this.resolveTopConnectors(layout, commits); + //Outgoing edges computed after all commits are placed so all child lanes are known. + this.computeOutgoingEdges(layout, childMap); + this.computeIncomingEdges(layout, commits); + console.log("LAYOUT:"); + layout.forEach((cl, hash) => { + console.log( + `Commit ${hash}: lane=${cl.lane}, outgoingEdges=[${cl.outgoingEdges.map((e) => `from ${e.fromLane} to ${e.toLane}`).join(", ")}], incomingEdges=[${cl.incomingEdges.map((e) => `from ${e.fromLane} to ${e.toLane}`).join(", ")}]`, + ); + }); + + return layout; + } +} + +export const computeLayout = GitGraphLayout.computeLayout.bind(GitGraphLayout); diff --git a/src/gopswebpanel/GitGraphPanel.ts b/src/gopswebpanel/GitGraphPanel.ts index 85e2d6a..3c6dbd0 100644 --- a/src/gopswebpanel/GitGraphPanel.ts +++ b/src/gopswebpanel/GitGraphPanel.ts @@ -17,11 +17,23 @@ export class GitGraphPanel { { enableScripts: true }, ); + panel.onDidDispose(() => { + GitGraphPanel.currentPanel = undefined; + }); + GitGraphPanel.currentPanel = new GitGraphPanel(panel); - await GitGraphPanel.currentPanel.render(extensionUri, branchName, gitService); + await GitGraphPanel.currentPanel.render( + extensionUri, + branchName, + gitService, + ); } - private async render(extensionUri: vscode.Uri, branchName: string, gitService: GitService) { + private async render( + extensionUri: vscode.Uri, + branchName: string, + gitService: GitService, + ) { const commits = await gitService.getBranchCommits(branchName); const cssUri = this.panel.webview.asWebviewUri( vscode.Uri.joinPath(extensionUri, "media", "gitGraph.css"), diff --git a/src/gopswebpanel/GitGraphRenderer.ts b/src/gopswebpanel/GitGraphRenderer.ts new file mode 100644 index 0000000..8f4abb4 --- /dev/null +++ b/src/gopswebpanel/GitGraphRenderer.ts @@ -0,0 +1,185 @@ +import { PassThrough } from "../models/Passthrough"; +import { CommitLayout } from "../models/CommitLayout"; +import { GitCommitModel } from "../models/GitCommitModel"; +import { Edge } from "../models/Edge"; + +export const ROW_HEIGHT = 40; +export const LANE_WIDTH = 20; + +export const HALF = ROW_HEIGHT / 2; +export const EDGE_STROKE_WIDTH = 2; + +const COMMIT_MARKER_RADIUS_NORMAL = 4; +const COMMIT_MARKER_RADIUS_MERGE_INNER = 3; +const COMMIT_MARKER_RADIUS_MERGE_OUTER = 4.5; +const COMMIT_MARKER_RADIUS_HEAD = 7; +const COMMIT_MARKER_STROKE_WIDTH_MERGE = 1; +const COMMIT_MARKER_STROKE_WIDTH_HEAD = 3; +const COMMIT_MARKER_HEAD_COLOR = "#f0a500"; +const MERGE_MESSAGE_COLOR = "#888888"; +type CommitMarkerKind = "commit" | "merge" | "head"; + +export const GitGraphRenderer = { + laneX(lane: number): number { + return lane * LANE_WIDTH + LANE_WIDTH; + }, + + makePath( + fromX: number, + fromY: number, + toX: number, + toY: number, + color: string, + ): string { + const isVertical = fromX === toX; + + if (isVertical) { + return ``; + } + + const midY = (fromY + toY) / 2; + + return ``; + }, + + makeCommitMarker( + cx: number, + cy: number, + color: string, + kind: CommitMarkerKind, + ): string { + if (kind === "merge") { + // Double ring: outer circle (stroke only) + inner filled circle + return ( + `` + + `` + ); + } + + if (kind === "head") { + // Double diamond: outer diamond (stroke only) + inner filled diamond + const outer = COMMIT_MARKER_RADIUS_HEAD; + const inner = COMMIT_MARKER_RADIUS_HEAD - 3; + return ( + `` + + `` + ); + } + +// Normal commit: filled circle with black border +return ``; + }, + + makeSvg(width: number, content: string): string { + return ` + ${content} + `; + }, + + drawGraphCell(cl: CommitLayout, svgWidth: number, isFirst: boolean): string { + const isMergeCommit = cl.incomingEdges.length > 0; + const cx = this.laneX(cl.lane); + const cy = HALF; + let svgContent = ""; + + // HANDLE PASSTHROUGHS: + cl.passThroughs.forEach((pt: PassThrough) => { + const x = this.laneX(pt.lane); + svgContent += this.makePath(x, 0, x, ROW_HEIGHT, pt.color); + }); + + // HANDLE CONNECTORS: + if (cl.hasTopConnector) { + svgContent += this.makePath(cx, 0, cx, cy, cl.color); + } + + if (cl.hasBottomConnector) { + svgContent += this.makePath(cx, cy, cx, ROW_HEIGHT, cl.color); + } + + // HANDLE OUTGOING EDGES: + cl.outgoingEdges.forEach((edge: Edge) => { + const fromX = this.laneX(edge.fromLane); + const toX = this.laneX(edge.toLane); + svgContent += this.makePath(fromX, cy, toX, 0, edge.color); + }); + + // HANDLE INCOMING EDGES: + if (!isFirst) { + cl.incomingEdges.forEach((edge: Edge) => { + const fromX = this.laneX(edge.fromLane); + const toX = this.laneX(edge.toLane); + svgContent += this.makePath(fromX, cy, toX, ROW_HEIGHT, edge.color); + }); + } + + // HANDLE COMMIT CIRCLE: + let kind: CommitMarkerKind = "commit"; + if (isFirst) { + kind = "head"; + } else if (isMergeCommit) { + kind = "merge"; + } + svgContent += this.makeCommitMarker( + cx, + cy, + isFirst ? COMMIT_MARKER_HEAD_COLOR : cl.color, + kind, + ); + + return `
+ ${this.makeSvg(svgWidth, svgContent)} +
`; + }, + + formatDate(dateStr: string): string { + const d = new Date(dateStr); + return ( + d.toLocaleDateString() + + " " + + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ); + }, + + drawCommitRow( + commit: GitCommitModel, + cl: CommitLayout, + svgWidth: number, + isFirst: boolean, + isAlt: boolean, + ): string { + const graphCell = this.drawGraphCell(cl, svgWidth, isFirst); + + let msgText = commit.isMergeCommit + ? "[MERGE] " + commit.message + : commit.message; + + const truncated = + msgText.length > 60 ? msgText.substring(0, 60) + "..." : msgText; + const refs = commit.refs ? ` ${commit.refs}` : ""; + + // Merge commit messages are styled grey + const messageStyle = commit.isMergeCommit + ? ` style="color:${MERGE_MESSAGE_COLOR}"` + : ""; + + return ` +
+ ${graphCell} +
${commit.hash}
+
+ ${truncated}${refs} +
+
${commit.author}
+
${this.formatDate(commit.date)}
+
+ `; + }, +}; diff --git a/src/gopswebpanel/GitGraphWebview.ts b/src/gopswebpanel/GitGraphWebview.ts index 2c80342..0fec4ed 100644 --- a/src/gopswebpanel/GitGraphWebview.ts +++ b/src/gopswebpanel/GitGraphWebview.ts @@ -1,16 +1,51 @@ import * as vscode from "vscode"; import { GitCommitModel } from "../models/GitCommitModel"; +import { GitGraphLayout } from "./GitGraphLayout"; +import { GitGraphRenderer } from "./GitGraphRenderer"; +import { CommitLayout } from "../models/CommitLayout"; export function renderGitGraph( branchName: string, commits: GitCommitModel[], cssUri: vscode.Uri, ): string { + const layout = GitGraphLayout.computeLayout(commits); + + // Calculate svg width + let maxLane = 0; + layout.forEach((entry: CommitLayout) => { + if (entry.lane > maxLane) { + maxLane = entry.lane; + } + }); + + const svgWidth = GitGraphRenderer.laneX(maxLane + 2); + + const rows = commits + .map((commit, i) => { + const cl = layout.get(commit.hash); + if (!cl) { + return ""; + } + return GitGraphRenderer.drawCommitRow( + commit, + cl, + svgWidth, + i === 0, + i % 2 !== 0, + ); + }) + .join(""); + return ` + -
- - - - - - - - - - - - -
HashMessageAuthorDate
-
- +
+ ${rows} +
`; diff --git a/src/gopswebpanel/LaneManager.ts b/src/gopswebpanel/LaneManager.ts new file mode 100644 index 0000000..53eae9a --- /dev/null +++ b/src/gopswebpanel/LaneManager.ts @@ -0,0 +1,41 @@ +export class LaneManager { + private readonly lanes: (string | null)[] = []; + + // Occupies a lane for the given commit hash and returns the lane index. + public findLaneForCommit(hash: string): number { + const lane = this.lanes.indexOf(hash); + + if (lane !== -1) { + // This lane (the lowest-numbered match) wins. Any other, + // higher-numbered lanes still pointing at this same hash were + // racing toward the same convergence point and are now dead — + // free them so future branch tips can reuse them. + for (let i = lane + 1; i < this.lanes.length; i++) { + if (this.lanes[i] === hash) { + this.lanes[i] = null; + } + } + return lane; + } + + // Reuse a freed (null) lane if one is available, otherwise + // append a new one. + const freeLane = this.lanes.indexOf(null); + if (freeLane !== -1) { + this.lanes[freeLane] = hash; + return freeLane; + } + + const newLane = this.lanes.length; + this.lanes.push(hash); + return newLane; + } + + public next(lane: number, next: string | null): void { + this.lanes[lane] = next; + } + + public getLanes(): (string | null)[] { + return this.lanes; + } +} diff --git a/src/models/CommitLayout.ts b/src/models/CommitLayout.ts new file mode 100644 index 0000000..7899a8b --- /dev/null +++ b/src/models/CommitLayout.ts @@ -0,0 +1,13 @@ +import { Edge } from "./Edge"; +import { PassThrough } from "./Passthrough"; + +export interface CommitLayout { + hash: string; + lane: number; + color: string; + outgoingEdges: Edge[]; + incomingEdges: Edge[]; + passThroughs: PassThrough[]; + hasTopConnector: boolean; + hasBottomConnector: boolean; +} diff --git a/src/models/Edge.ts b/src/models/Edge.ts new file mode 100644 index 0000000..571bc7b --- /dev/null +++ b/src/models/Edge.ts @@ -0,0 +1,7 @@ +export interface Edge { + fromLane: number; + toLane: number; + fromHash: string; + toHash: string; + color: string; +} diff --git a/src/models/GitCommitModel.ts b/src/models/GitCommitModel.ts index 836ded2..5bbc0d4 100644 --- a/src/models/GitCommitModel.ts +++ b/src/models/GitCommitModel.ts @@ -1,8 +1,32 @@ -export interface GitCommitModel { - hash: string; - message: string; - author: string; - date: string; - isMergeCommit: boolean; - refs: string; +export class GitCommitModel { + constructor( + public hash: string, + public message: string, + public author: string, + public date: string, + public isMergeCommit: boolean, + public refs: string, + public parents: string[], + ) {} + + private static readonly HASH_WIDTH = 8; + private static readonly DATE_WIDTH = 25; + private static readonly REFS_WIDTH = 40; + + public toString(): string { + return [ + `hash=${this.fixedWidth(this.hash, GitCommitModel.HASH_WIDTH)}`, + `merge=${this.fixedWidth(this.isMergeCommit, 5)}`, + `date=${this.fixedWidth(this.date, GitCommitModel.DATE_WIDTH)}`, + `refs=${this.fixedWidth(this.refs, GitCommitModel.REFS_WIDTH)}`, + `parentCount=${this.parents.length}`, + `parents=[${this.parents + .map((p) => p.substring(0, GitCommitModel.HASH_WIDTH)) + .join(", ")}]`, + ].join(" | "); + } + + private fixedWidth(value: unknown, width: number): string { + return String(value).substring(0, width).padEnd(width); + } } diff --git a/src/models/Passthrough.ts b/src/models/Passthrough.ts new file mode 100644 index 0000000..dae7c55 --- /dev/null +++ b/src/models/Passthrough.ts @@ -0,0 +1,4 @@ +export interface PassThrough { + lane: number; + color: string; +} diff --git a/src/services/GitService.ts b/src/services/GitService.ts index 4a64ddd..5a5669f 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -234,24 +234,37 @@ export class GitService { return this.executeGitAction( async () => { const log = await this.git.log({ - [branchName]: null, + "--all": null, + "--topo-order": null, format: { hash: "%h", message: "%s", author: "%an", date: "%ai", - parentCount: "%P", + parents: "%P", refs: "%D", }, }); - return log.all.map((c: any) => ({ - hash: c.hash, - message: c.message, - author: c.author, - date: c.date, - isMergeCommit: c.parentCount.split(" ").length > 1, - refs: c.refs || "", - })); + return log.all.map( + (c: any) => + new GitCommitModel( + c.hash, + c.message, + c.author, + c.date, + c.parents + ? c.parents.trim().split(" ").filter(Boolean).length > 1 + : false, + c.refs || "", + c.parents + ? c.parents + .trim() + .split(" ") + .filter(Boolean) + .map((p: string) => p.substring(0, 7)) + : [], + ), + ); }, `Loaded commits for branch ${branchName}`, `Failed to load commits for branch ${branchName}`, diff --git a/test/unit/gopswebpanel/GitGraphLayout.test.ts b/test/unit/gopswebpanel/GitGraphLayout.test.ts new file mode 100644 index 0000000..aa111b0 --- /dev/null +++ b/test/unit/gopswebpanel/GitGraphLayout.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect } from "vitest"; +import { computeLayout } from "../../../src/gopswebpanel/GitGraphLayout"; +import { GitGraphRenderer } from "../../../src/gopswebpanel/GitGraphRenderer"; +import { GitCommitModel } from "../../../src/models/GitCommitModel"; + +const commit = (hash: string, parents: string[] = []): GitCommitModel => + new GitCommitModel(hash, "", "", "", false, "", parents); + +describe("computeLayout", () => { + it("returns a layout map with correct structure", () => { + const commits = [commit("a")]; + const layout = computeLayout(commits); + + expect(layout.size).toBe(1); + const aLayout = layout.get("a"); + expect(aLayout).toBeDefined(); + expect(aLayout?.lane).toBe(0); + expect(aLayout?.outgoingEdges).toEqual([]); + expect(aLayout?.passThroughs).toBeDefined(); + }); + + it("handles root commit with no parents", () => { + const commits = [commit("root")]; + const layout = computeLayout(commits); + + const rootLayout = layout.get("root"); + expect(rootLayout?.lane).toBe(0); + expect(rootLayout?.outgoingEdges).toEqual([]); + expect(rootLayout?.hasBottomConnector).toBe(false); + expect(rootLayout?.hasTopConnector).toBe(false); + }); + + it("assigns colors based on lane index", () => { + const commits = [commit("a"), commit("b", ["a"]), commit("c", ["a"])]; + const layout = computeLayout(commits); + + const bLayout = layout.get("b"); + const cLayout = layout.get("c"); + expect(bLayout?.color).toBeDefined(); + expect(cLayout?.color).toBeDefined(); + }); + + it("marks pass-throughs for lanes that continue in linear history", () => { + const commits = [commit("a"), commit("b", ["a"]), commit("c", ["b"])]; + const layout = computeLayout(commits); + + const bLayout = layout.get("b"); + expect(bLayout?.passThroughs).toBeDefined(); + }); + + it("lanes are non-negative integers", () => { + const commits = [ + commit("a"), + commit("b", ["a"]), + commit("c", ["a"]), + commit("d", ["a"]), + commit("e", ["b", "c", "d"]), + ]; + const layout = computeLayout(commits); + + for (const [, cl] of layout) { + expect(cl.lane).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(cl.lane)).toBe(true); + } + }); + + it("first commit is always on lane 0", () => { + const commits = [ + commit("a"), + commit("b", ["a"]), + commit("c", ["b"]), + commit("d", ["c"]), + ]; + const layout = computeLayout(commits); + + expect(layout.get("a")?.lane).toBe(0); + }); + + it("every edge points to a valid child hash", () => { + const commits = [ + commit("a"), + commit("b", ["a"]), + commit("c", ["b"]), + commit("d", ["c"]), + commit("e", ["d"]), + commit("f", ["e"]), + commit("g", ["f"]), + commit("h", ["g"]), + ]; + const layout = computeLayout(commits); + + for (const [, cl] of layout) { + for (const edge of cl.outgoingEdges) { + expect(commits.some((c) => c.hash === edge.toHash)).toBe(true); + } + } + }); + + it("pass-through lanes have valid structure", () => { + const commits = [commit("a"), commit("b", ["a"]), commit("c", ["b"])]; + const layout = computeLayout(commits); + + const cLayout = layout.get("c"); + if (cLayout && cLayout.passThroughs.length > 0) { + for (const pt of cLayout.passThroughs) { + expect(pt.lane).toBeGreaterThanOrEqual(0); + expect(pt.color).toBeDefined(); + } + } + }); + + it("renderer generates valid SVG for layout", () => { + const commits = [commit("a"), commit("b", ["a"])]; + const layout = computeLayout(commits); + + const svg = GitGraphRenderer.drawGraphCell(layout.get("b")!, 60, false); + + expect(svg).toContain(" { + const commits = [ + commit("a"), + commit("b", ["a"]), + commit("c", ["a"]), + commit("m", ["b", "c"]), + ]; + const layout = computeLayout(commits); + const svg = GitGraphRenderer.drawGraphCell(layout.get("m")!, 80, false); + + expect(svg).toContain(" { + const commits = [commit("a"), commit("b")]; + const layout = computeLayout(commits); + + expect(layout.size).toBe(2); + expect(layout.get("a")?.lane).toBe(0); + expect(layout.get("b")?.lane).toBeGreaterThanOrEqual(0); + }); + + it("linear history has no outgoing edges, only connectors", () => { + const commits = [commit("a", ["b"]), commit("b")]; + const layout = computeLayout(commits); + + expect(layout.get("a")?.outgoingEdges).toHaveLength(0); + expect(layout.get("a")?.hasBottomConnector).toBe(true); + expect(layout.get("b")?.hasTopConnector).toBe(true); + }); + + it("branch tip has no outgoing edges", () => { + const commits = [commit("tip", ["base"]), commit("base")]; + const layout = computeLayout(commits); + + expect(layout.get("tip")?.outgoingEdges).toHaveLength(0); + }); + + it("branch tip gets hasTopConnector set by resolveTopConnectors", () => { + const commits = [ + commit("merge", ["main", "branch"]), + commit("main"), + commit("branch"), + ]; + const layout = computeLayout(commits); + + expect(layout.get("branch")?.hasTopConnector).toBe(true); + }); + + it("merge commit has incoming edge for secondary parent in different lane", () => { + const commits = [ + commit("tip1", ["base"]), + commit("tip2", ["base"]), + commit("base"), + commit("merge", ["tip1", "tip2"]), + ]; + const layout = computeLayout(commits); + + const mergeLayout = layout.get("merge"); + expect(mergeLayout?.incomingEdges.length).toBeGreaterThan(0); + const incomingToHashes = mergeLayout?.incomingEdges.map((e) => e.toHash); + expect(incomingToHashes).toContain("tip2"); + }); + + it("non-merge commit has no incoming edges", () => { + const commits = [commit("a", ["b"]), commit("b")]; + const layout = computeLayout(commits); + + expect(layout.get("a")?.incomingEdges).toHaveLength(0); + }); + + it("outgoing edge toLane is always higher than fromLane", () => { + const commits = [ + commit("tip1", ["base"]), + commit("tip2", ["base"]), + commit("base"), + ]; + const layout = computeLayout(commits); + + const baseLayout = layout.get("base"); + baseLayout?.outgoingEdges.forEach((e) => { + expect(e.toLane).toBeGreaterThan(e.fromLane); + }); + }); + + it("commit with parent has bottom connector", () => { + const commits = [commit("a", ["b"]), commit("b")]; + const layout = computeLayout(commits); + + expect(layout.get("a")?.hasBottomConnector).toBe(true); + }); + + it("commit that is continuation of lane has top connector", () => { + const commits = [commit("a", ["b"]), commit("b", ["c"]), commit("c")]; + const layout = computeLayout(commits); + + expect(layout.get("b")?.hasTopConnector).toBe(true); + }); +});