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,149 @@
import React from 'react';
import type { LanedCommit } from './gitGraph';
export const LANE_WIDTH = 8;
interface GitGraphSegmentProps {
laned: LanedCommit;
totalLanes: number;
isExpanded: boolean;
}
/**
* Renders the git graph lane column using an HTML Canvas element.
*
* Layout isolation pattern:
* A plain <div> (no replaced-element intrinsic sizing) owns all layout via
* `height: 100%` + self-stretch on the parent. The <canvas> is absolutely
* positioned inside it (`inset: 0`) so it fills the div without affecting
* the flex layout measurement. Canvas intrinsic height (default 150px) never
* leaks into the row height calculation.
*
* useLayoutEffect reads the div's offsetHeight (stable, no replaced-element
* quirks) and sets the canvas drawing-buffer size + draws.
*/
export const GitGraphSegment: React.FC<GitGraphSegmentProps> = ({
laned,
totalLanes,
isExpanded,
}) => {
const { lane, color, connectors } = laned;
const effectiveLanes = Math.max(totalLanes, lane + 1);
const w = effectiveLanes * LANE_WIDTH + LANE_WIDTH / 2;
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useLayoutEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const h = container.offsetHeight;
if (h === 0) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const dotCy = h / 2;
const dotCx = lane * LANE_WIDTH + LANE_WIDTH / 2;
const styles = getComputedStyle(canvas);
const fallbackColor = styles.getPropertyValue('--surface-muted-foreground').trim() || styles.color;
const resolveColor = (value: string): string => {
if (!value.startsWith('var(')) return value;
const varName = value.slice(4, -1).trim();
return styles.getPropertyValue(varName).trim() || fallbackColor;
};
// Straight lines first so bezier curves render on top
const sorted = [...connectors].sort((a, b) => {
const isBezier = (t: string) => t === 'branch-out' || t === 'merge-in';
return (isBezier(a.type) ? 1 : 0) - (isBezier(b.type) ? 1 : 0);
});
for (const seg of sorted) {
const x1 = seg.fromLane * LANE_WIDTH + LANE_WIDTH / 2;
const x2 = seg.toLane * LANE_WIDTH + LANE_WIDTH / 2;
const lineAlpha = seg.type === 'passing'
? 0.72
: seg.type === 'branch-out' || seg.type === 'merge-in'
? 0.95
: 1;
ctx.beginPath();
ctx.strokeStyle = resolveColor(seg.color);
ctx.globalAlpha = lineAlpha;
ctx.lineWidth = 1.25;
ctx.lineCap = 'round';
switch (seg.type) {
case 'passing':
case 'commit-lane':
ctx.moveTo(x1, 0);
ctx.lineTo(x1, h);
break;
case 'top-stub':
ctx.moveTo(x1, 0);
ctx.lineTo(x1, dotCy);
break;
case 'bottom-stub':
ctx.moveTo(x1, dotCy);
ctx.lineTo(x1, h);
break;
case 'branch-out': {
const mid = (dotCy + h) / 2;
ctx.moveTo(dotCx, dotCy);
ctx.bezierCurveTo(dotCx, mid, x2, mid, x2, h);
break;
}
case 'merge-in': {
const mid = dotCy / 2;
ctx.moveTo(x1, 0);
ctx.bezierCurveTo(x1, mid, dotCx, mid, dotCx, dotCy);
break;
}
default:
continue;
}
ctx.stroke();
ctx.globalAlpha = 1;
}
// Dot — drawn last, always on top
const bg = styles.getPropertyValue('--background').trim() || styles.getPropertyValue('--surface-background').trim();
ctx.beginPath();
ctx.arc(dotCx, dotCy, 4, 0, Math.PI * 2);
ctx.fillStyle = resolveColor(color);
ctx.fill();
ctx.beginPath();
ctx.arc(dotCx, dotCy, 5, 0, Math.PI * 2);
ctx.strokeStyle = bg || fallbackColor;
ctx.lineWidth = 2;
ctx.stroke();
}, [laned, lane, color, connectors, totalLanes, isExpanded, w]);
return (
// This div owns the layout: height: 100% fills the self-stretch parent,
// width is fixed to the lane count. No replaced-element intrinsic sizing.
<div
ref={containerRef}
style={{ width: w, height: '100%', position: 'relative', flexShrink: 0, overflow: 'hidden' }}
>
{/* Canvas is absolutely inset so it matches the div exactly and never
contributes its own intrinsic height (150px default) to flex layout. */}
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
/>
</div>
);
};