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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
cc3d1bd63c
commit
52ffe9daef
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user