feat(git-graph): VS Code-style git graph with commit actions in History modal (#1431)

* feat(types): add parents to GitLogEntry and new commit action types

* feat(git): add parent hashes and --all flag to getLog

* fix(git): move record separator to start of log format string

* feat(git): add checkoutCommit server function and route

* feat(git): add cherryPick server function and route

* feat(git): add revertCommit server function and route

* feat(git): add resetToCommit server function and route

* fix(tests): make git service tests branch-name portable, add error path tests

* feat(client): add checkoutCommit, cherryPick, revertCommit, resetToCommit API wrappers

* feat(git-graph): add lane assignment algorithm with tests

* feat(git-graph): add GitGraphSegment per-row SVG renderer

* feat(i18n): add locale strings for git graph action buttons

* fix(git-graph): handle lane convergence, fix SVG path coords, add connector tests

* feat(git-graph): add ref badges and action buttons to HistoryCommitRow

* fix(git-graph): add loading guards to reset actions, use theme tokens for ref badges

* fix(git-graph): conditional hooks, stale graph log, conflict handling, i18n

* fix(types): replace toBeDefined with toBeTruthy, fix toast API usage

* fix(lint): remove unused variables

* fix(git-graph): fix SVG height causing 150px row spacing

* fix(git-graph): smooth bezier curves, fill row height, round line caps

* fix(git-graph): non-scaling-stroke fixes bezier white spaces, sort curves on top

* fix(git-graph): remove viewBox scaling, match SVG height to actual row height

* fix(git-graph): ResizeObserver tracks actual row height, eliminates SVG height mismatch

* feat(git-graph): replace SVG with Canvas for graph rendering

* fix(git-graph): isolate canvas from flex layout to prevent replaced-element height leak

* feat(git-graph): align action buttons, add confirmation popups for all actions

* fix(git-graph): address code review findings CR-001 through CR-005

- CR-001: VS Code getGitLog now forwards 'all' option and parses %P parents
- CR-002: VS Code bridge/gitService implement checkoutCommit, cherryPick,
  revertCommit, resetToCommit with conflict detection and hard-reset guard
- CR-003: server-side commit hash validated with /^[0-9a-fA-F]{7,40}$/
  in both routes.js and service.js; 12 new rejection tests added
- CR-004: cherry-pick/revert conflict path now refreshes fetchStatus/
  fetchBranches/fetchLog; conflict toast uses i18n keys in all 7 locales
- CR-005: corrected O(n) comment to O(n x lanes)

* fix(i18n): add zh-TW locale and common.language.traditionalChinese key to all locales

upstream/main added zh-TW.ts after branch diverged; CI type-check fails
when PR is merged because zh-TW.ts was missing all gitView.history.actions.*
keys and loadMore/loadingMore. Also adds common.language.traditionalChinese
to en.ts and all 6 non-English files to match upstream en.ts.

* fix: harden git history actions

* feat: split git history graph view

* chore: remove git graph planning docs

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Erman HAVUÇ
2026-05-27 00:13:25 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent cc3d1bd63c
commit 52ffe9daef
26 changed files with 2373 additions and 111 deletions
@@ -0,0 +1,171 @@
import type { GitLogEntry } from '@/lib/api/types';
export 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
*/
export 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)',
];
export 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[] = [];
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);
const newLane = freeLane !== -1 ? freeLane : activeLanes.length;
activeLanes[newLane] = parentHash;
if (newLane === activeLanes.length) activeLanes.push(parentHash);
extraParentLanes.push(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 extra parent lanes)
for (let lane = 0; lane < activeLanes.length; lane++) {
if (activeLanes[lane] === null) continue;
if (lane === assignedLane) continue;
if (extraParentLanes.includes(lane)) continue;
connectors.push({
fromLane: lane,
toLane: lane,
color: laneColor(lane),
type: 'passing',
});
}
result.push({ commit, lane: assignedLane, color, connectors });
}
return result;
}