Files
openchamber/packages/ui/src/components/views/git/gitGraph.ts
T
2026-08-29 10:36:24 +03:00

182 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { GitLogEntry } from '@/lib/api/types';
type LaneColor = string;
/**
* Describes one visible line/curve in a commit row's SVG.
* Each segment covers the FULL row height (y=0 to y=100%).
*
* Types:
* - 'passing' : straight vertical line, lane active but this row is not its commit
* - 'commit-lane': straight vertical line for this commit's lane (has both incoming and outgoing)
* - 'top-stub' : line from y=0 to dot-y only (branch HEAD — no child above)
* - 'bottom-stub': line from dot-y to y=100% only (root commit — nothing above)
* - 'branch-out' : bezier from (dot-x, dot-y) to (toLane-x, 100%) — new parent lane opens
* - 'merge-in' : bezier from (fromLane-x, 0) to (dot-x, dot-y) — lane converges here
*/
interface ConnectorSegment {
fromLane: number;
toLane: number;
color: LaneColor;
type: 'passing' | 'commit-lane' | 'top-stub' | 'bottom-stub' | 'branch-out' | 'merge-in';
}
export interface LanedCommit {
commit: GitLogEntry;
lane: number;
color: LaneColor;
/** All visible line segments in this row's height. */
connectors: ConnectorSegment[];
}
const LANE_COLORS: LaneColor[] = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'var(--syntax-keyword)',
'var(--syntax-string)',
'var(--status-info)',
];
function laneColor(lane: number): LaneColor {
return LANE_COLORS[lane % LANE_COLORS.length];
}
/**
* Assigns visual lanes to a list of commits (newest-first order).
*
* Greedy lane assignment algorithm (O(n × lanes) where lanes = max concurrent active branches):
* - activeLanes[i] holds the hash expected next on lane i (or null if free)
* - Each commit takes the lane that was waiting for it, or the next free lane
* - Merge commits open new lanes for additional parents
* - Connectors describe ALL visible lines in each row (both above and below the dot)
*/
export function assignLanes(commits: GitLogEntry[]): LanedCommit[] {
if (commits.length === 0) return [];
// activeLanes[i] = hash of the next commit expected on lane i, or null if free
const activeLanes: Array<string | null> = [];
const result: LanedCommit[] = [];
for (let i = 0; i < commits.length; i++) {
const commit = commits[i];
// Find all lanes waiting for this commit
const waitingLanes: number[] = [];
for (let li = 0; li < activeLanes.length; li++) {
if (activeLanes[li] === commit.hash) {
waitingLanes.push(li);
}
}
// Use the first waiting lane as the commit's lane
let assignedLane = waitingLanes.length > 0 ? waitingLanes[0] : -1;
if (assignedLane === -1) {
// No existing lane claimed this commit; take the first free lane
const freeLane = activeLanes.indexOf(null);
if (freeLane !== -1) {
assignedLane = freeLane;
} else {
assignedLane = activeLanes.length;
activeLanes.push(null);
}
}
// Mark other waiting lanes as converging here (will emit merge-in connectors)
const convergingLanes = waitingLanes.slice(1);
const color = laneColor(assignedLane);
const hasIncoming = activeLanes[assignedLane] === commit.hash;
const hasParent = commit.parents.length > 0;
// Update this commit's lane to point at its first parent
if (hasParent) {
activeLanes[assignedLane] = commit.parents[0];
} else {
activeLanes[assignedLane] = null;
}
// Open new lanes for additional parents (merge commits)
const extraParentLanes: number[] = [];
const extraParentIsNew = new Set<number>();
for (let p = 1; p < commit.parents.length; p++) {
const parentHash = commit.parents[p];
// Check if another lane is already waiting for this parent
const existingLane = activeLanes.indexOf(parentHash);
if (existingLane !== -1) {
extraParentLanes.push(existingLane);
} else {
const freeLane = activeLanes.indexOf(null);
if (freeLane !== -1) {
activeLanes[freeLane] = parentHash;
extraParentLanes.push(freeLane);
extraParentIsNew.add(freeLane);
} else {
const newLane = activeLanes.length;
activeLanes.push(parentHash);
extraParentLanes.push(newLane);
extraParentIsNew.add(newLane);
}
}
}
// Build connectors: ALL visible line segments in this row
const connectors: ConnectorSegment[] = [];
// This commit's own lane segment
if (hasIncoming && hasParent) {
connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'commit-lane' });
} else if (hasIncoming && !hasParent) {
connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'top-stub' });
} else if (!hasIncoming && hasParent) {
connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'bottom-stub' });
}
// else: orphan with no parent and no child — just the dot, no lines
// Merge-in connectors for converging lanes
for (const convergingLane of convergingLanes) {
connectors.push({
fromLane: convergingLane,
toLane: assignedLane,
color: laneColor(convergingLane),
type: 'merge-in',
});
// Clear the converging lane
activeLanes[convergingLane] = null;
}
// Branch-out segments for merge commit's extra parents
for (const extraLane of extraParentLanes) {
connectors.push({
fromLane: assignedLane,
toLane: extraLane,
color: laneColor(extraLane),
type: 'branch-out',
});
}
// Passing-through lanes (active but not this commit's lane or newly-opened extra parent lanes)
// Reused extra parents already have an active lane above the merge, so they must keep their
// vertical passing segment for continuity (otherwise a gap appears between
// the commit above and the merge row, as in the double-merge-of-same-branch case).
for (let lane = 0; lane < activeLanes.length; lane++) {
if (activeLanes[lane] === null) continue;
if (lane === assignedLane) continue;
if (extraParentIsNew.has(lane)) continue;
connectors.push({
fromLane: lane,
toLane: lane,
color: laneColor(lane),
type: 'passing',
});
}
result.push({ commit, lane: assignedLane, color, connectors });
}
return result;
}