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>
|
||||
);
|
||||
};
|
||||
@@ -43,6 +43,7 @@ interface GitHeaderProps {
|
||||
isApplyingIdentity: boolean;
|
||||
isWorktreeMode: boolean;
|
||||
onOpenHistory?: () => void;
|
||||
onOpenGraph?: () => void;
|
||||
onOpenStashes?: () => void;
|
||||
actionTabItems?: SortableTabsStripItem[];
|
||||
activeActionTab?: string;
|
||||
@@ -246,6 +247,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
isApplyingIdentity,
|
||||
isWorktreeMode,
|
||||
onOpenHistory,
|
||||
onOpenGraph,
|
||||
onOpenStashes,
|
||||
actionTabItems,
|
||||
activeActionTab,
|
||||
@@ -258,7 +260,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
|
||||
const managementButtons = (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onOpenHistory || onOpenStashes ? (
|
||||
{onOpenHistory || onOpenGraph || onOpenStashes ? (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -267,13 +269,13 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 px-0"
|
||||
aria-label={t('gitView.history.title')}
|
||||
aria-label={t('gitView.header.repositoryViews')}
|
||||
>
|
||||
<Icon name="git-repository" className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{t('gitView.history.title')}</TooltipContent>
|
||||
<TooltipContent sideOffset={8}>{t('gitView.header.repositoryViews')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
{onOpenHistory ? (
|
||||
@@ -282,6 +284,12 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
{t('gitView.history.title')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onOpenGraph ? (
|
||||
<DropdownMenuItem onSelect={onOpenGraph}>
|
||||
<Icon name="git-merge" className="size-4" />
|
||||
{t('gitView.graph.title')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onOpenStashes ? (
|
||||
<DropdownMenuItem onSelect={onOpenStashes}>
|
||||
<Icon name="archive-stack" className="size-4" />
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -8,6 +14,10 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { getCommitFileDiff, type CommitFileDiffResponse } from '@/lib/gitApi';
|
||||
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import type { LanedCommit } from './gitGraph';
|
||||
import { GitGraphSegment } from './GitGraphSegment';
|
||||
import * as git from '@/lib/gitApi';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
|
||||
const HISTORY_DIFF_REQUEST_TIMEOUT_MS = 15000;
|
||||
const HISTORY_DIFF_LARGE_CHANGED_LINES = 500;
|
||||
@@ -54,12 +64,17 @@ const trimHistoryDiffCache = (cache: Map<string, HistoryDiffCacheValue>): Map<st
|
||||
|
||||
interface HistoryCommitRowProps {
|
||||
entry: GitLogEntry;
|
||||
mode?: 'history' | 'graph';
|
||||
laned?: LanedCommit;
|
||||
totalLanes?: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
files: CommitFileEntry[];
|
||||
isLoadingFiles: boolean;
|
||||
onCopyHash: (hash: string) => void;
|
||||
directory: string | undefined;
|
||||
onConflict?: (result: { conflict: boolean; conflictFiles?: string[]; operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase' }) => void;
|
||||
onActionSuccess?: () => void;
|
||||
}
|
||||
|
||||
function formatCommitDate(date: string) {
|
||||
@@ -93,21 +108,186 @@ function getChangeTypeColor(changeType: string) {
|
||||
}
|
||||
}
|
||||
|
||||
interface RefBadge {
|
||||
label: string;
|
||||
isHead: boolean;
|
||||
isTag: boolean;
|
||||
}
|
||||
|
||||
function parseRefBadges(refs: string): RefBadge[] {
|
||||
if (!refs) return [];
|
||||
return refs
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean)
|
||||
.map((r) => {
|
||||
const isHead = r.startsWith('HEAD ->');
|
||||
const label = isHead ? r.replace('HEAD -> ', '') : r.replace('tag: ', '');
|
||||
return {
|
||||
label,
|
||||
isHead,
|
||||
isTag: r.startsWith('tag: '),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const HistoryCommitRow = React.memo(({
|
||||
entry,
|
||||
mode = 'history',
|
||||
laned,
|
||||
totalLanes,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
files,
|
||||
isLoadingFiles,
|
||||
onCopyHash,
|
||||
directory,
|
||||
onConflict,
|
||||
onActionSuccess,
|
||||
}: HistoryCommitRowProps) => {
|
||||
const { t } = useI18n();
|
||||
const isGraphMode = mode === 'graph';
|
||||
type PendingAction =
|
||||
| 'checkout' | 'cherryPick' | 'revert'
|
||||
| 'merge' | 'rebase'
|
||||
| 'resetSoft' | 'resetMixed' | 'resetHard';
|
||||
|
||||
const [actionLoading, setActionLoading] = React.useState<string | null>(null);
|
||||
const [showCreateBranch, setShowCreateBranch] = React.useState(false);
|
||||
const [newBranchName, setNewBranchName] = React.useState('');
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction | null>(null);
|
||||
|
||||
const [openDiffPaths, setOpenDiffPaths] = React.useState<Set<string>>(new Set());
|
||||
const [diffCache, setDiffCache] = React.useState<Map<string, HistoryDiffCacheValue>>(new Map());
|
||||
const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!directory) return;
|
||||
setActionLoading('checkout');
|
||||
try {
|
||||
await git.checkoutCommit(directory, entry.hash);
|
||||
toast.success(t('gitView.history.actions.detachedHead'));
|
||||
onActionSuccess?.();
|
||||
} catch (e: unknown) {
|
||||
toast.error(String((e as Error).message));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBranch = async () => {
|
||||
if (!directory || !newBranchName.trim()) return;
|
||||
setActionLoading('createBranch');
|
||||
try {
|
||||
await git.createBranch(directory, newBranchName.trim(), entry.hash);
|
||||
setShowCreateBranch(false);
|
||||
setNewBranchName('');
|
||||
onActionSuccess?.();
|
||||
} catch (e: unknown) {
|
||||
toast.error(String((e as Error).message));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCherryPick = async () => {
|
||||
if (!directory) return;
|
||||
setActionLoading('cherryPick');
|
||||
try {
|
||||
const result = await git.cherryPick(directory, entry.hash);
|
||||
if (result.conflict) {
|
||||
onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'cherry-pick' });
|
||||
} else {
|
||||
onActionSuccess?.();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error(String((e as Error).message));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevert = async () => {
|
||||
if (!directory) return;
|
||||
setActionLoading('revert');
|
||||
try {
|
||||
const result = await git.revertCommit(directory, entry.hash);
|
||||
if (result.conflict) {
|
||||
onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'revert' });
|
||||
} else {
|
||||
onActionSuccess?.();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error(String((e as Error).message));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async (mode: 'soft' | 'mixed' | 'hard', force = false) => {
|
||||
if (!directory || actionLoading !== null) return;
|
||||
setActionLoading('reset');
|
||||
try {
|
||||
await git.resetToCommit(directory, entry.hash, mode, force);
|
||||
onActionSuccess?.();
|
||||
} catch (e: unknown) {
|
||||
toast.error(String((e as Error).message));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Single confirm handler dispatches to the right action based on pendingAction
|
||||
const confirmPendingAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
switch (action) {
|
||||
case 'checkout': return handleCheckout();
|
||||
case 'cherryPick': return handleCherryPick();
|
||||
case 'revert': return handleRevert();
|
||||
case 'merge': return handleMerge();
|
||||
case 'rebase': return handleRebase();
|
||||
case 'resetSoft': return handleReset('soft');
|
||||
case 'resetMixed': return handleReset('mixed');
|
||||
case 'resetHard': return handleReset('hard', true); // force=true: user already confirmed
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!directory) return;
|
||||
setActionLoading('merge');
|
||||
try {
|
||||
const result = await git.merge(directory, { branch: entry.hash });
|
||||
if (result.conflict) {
|
||||
onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'merge' });
|
||||
} else {
|
||||
onActionSuccess?.();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error(String((e as Error).message));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRebase = async () => {
|
||||
if (!directory) return;
|
||||
setActionLoading('rebase');
|
||||
try {
|
||||
const result = await git.rebase(directory, { onto: entry.hash });
|
||||
if (result.conflict) {
|
||||
onConflict?.({ conflict: true, conflictFiles: result.conflictFiles, operation: 'rebase' });
|
||||
} else {
|
||||
onActionSuccess?.();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error(String((e as Error).message));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFileDiff = React.useCallback(async (file: CommitFileEntry) => {
|
||||
const key = file.path;
|
||||
if (!directory) {
|
||||
@@ -169,15 +349,45 @@ export const HistoryCommitRow = React.memo(({
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'w-full flex items-start gap-3 px-3 py-2 text-left transition-colors',
|
||||
isExpanded ? 'bg-sidebar/90' : 'hover:bg-sidebar/40'
|
||||
isGraphMode
|
||||
? 'hover:bg-[var(--interactive-hover)]/40'
|
||||
: isExpanded ? 'bg-sidebar/90' : 'hover:bg-sidebar/40'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="h-2 w-2 translate-y-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: 'var(--status-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
{isGraphMode && laned && totalLanes !== undefined ? (
|
||||
<div className="-my-2 shrink-0 self-stretch">
|
||||
<GitGraphSegment laned={laned} totalLanes={totalLanes} isExpanded={isExpanded} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 translate-y-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: 'var(--status-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Ref badges */}
|
||||
{isGraphMode ? (() => {
|
||||
const badges = parseRefBadges(entry.refs);
|
||||
return badges.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 mb-0.5">
|
||||
{badges.map((badge) => (
|
||||
<span key={badge.label}
|
||||
className={cn(
|
||||
'inline-flex items-center px-1.5 py-0 typography-micro rounded font-medium',
|
||||
badge.isHead
|
||||
? 'bg-[var(--chart-1)] text-[var(--primary-foreground)]'
|
||||
: badge.isTag
|
||||
? 'bg-[var(--chart-5)] text-[var(--primary-foreground)]'
|
||||
: 'bg-[var(--interactive-hover)] text-[var(--foreground)]'
|
||||
)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
})() : null}
|
||||
|
||||
<p className="typography-ui-label font-medium text-foreground line-clamp-1">
|
||||
{entry.message}
|
||||
</p>
|
||||
@@ -217,6 +427,132 @@ export const HistoryCommitRow = React.memo(({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-2 pl-8 border-t border-border/40">
|
||||
{/* Action buttons */}
|
||||
{isGraphMode && pendingAction ? (
|
||||
/* Confirmation banner — replaces the button row while an action is pending */
|
||||
<div className="flex items-center gap-2 py-2 border-b border-border/30 mb-2">
|
||||
<span className="typography-micro text-muted-foreground flex-1 min-w-0">
|
||||
{t(`gitView.history.actions.${pendingAction}Confirm` as never)}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive" size="xs" className="h-6 shrink-0"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={(e) => { e.stopPropagation(); void confirmPendingAction(); }}
|
||||
>
|
||||
{actionLoading !== null
|
||||
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
||||
: null}
|
||||
{t('gitView.history.actions.confirmButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="xs" className="h-6 shrink-0"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={(e) => { e.stopPropagation(); setPendingAction(null); }}
|
||||
>
|
||||
{t('gitView.history.actions.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
) : isGraphMode ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 py-2 border-b border-border/30 mb-2">
|
||||
<Button variant="outline" size="xs" className="h-6"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={(e) => { e.stopPropagation(); setPendingAction('checkout'); }}
|
||||
>
|
||||
{t('gitView.history.actions.checkout')}
|
||||
</Button>
|
||||
|
||||
{showCreateBranch ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
autoFocus value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleCreateBranch();
|
||||
if (e.key === 'Escape') { setShowCreateBranch(false); setNewBranchName(''); }
|
||||
}}
|
||||
placeholder={t('gitView.history.actions.createBranchPlaceholder')}
|
||||
className="h-6 text-xs px-2 rounded border border-border/60 bg-background min-w-0 w-32"
|
||||
/>
|
||||
<Button variant="outline" size="xs" className="h-6"
|
||||
disabled={!newBranchName.trim() || actionLoading !== null}
|
||||
onClick={(e) => { e.stopPropagation(); void handleCreateBranch(); }}
|
||||
>
|
||||
{actionLoading === 'createBranch'
|
||||
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
||||
: null}
|
||||
{t('gitView.history.actions.createBranchConfirm')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="xs" className="h-6"
|
||||
onClick={(e) => { e.stopPropagation(); setShowCreateBranch(true); }}
|
||||
>
|
||||
{t('gitView.history.actions.createBranch')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="xs" className="h-6"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={(e) => { e.stopPropagation(); setPendingAction('cherryPick'); }}
|
||||
>
|
||||
{t('gitView.history.actions.cherryPick')}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="xs" className="h-6"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={(e) => { e.stopPropagation(); setPendingAction('revert'); }}
|
||||
>
|
||||
{t('gitView.history.actions.revert')}
|
||||
</Button>
|
||||
|
||||
{/* Reset: dropdown first to pick mode, then confirmation banner */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actionLoading === 'reset'
|
||||
? <Icon name="loader-4" className="size-3 animate-spin mr-1" />
|
||||
: null}
|
||||
{t('gitView.history.actions.reset')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-max">
|
||||
{(['soft', 'mixed', 'hard'] as const).map((mode) => (
|
||||
<DropdownMenuItem
|
||||
key={mode}
|
||||
disabled={actionLoading !== null}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingAction(`reset${mode.charAt(0).toUpperCase() + mode.slice(1)}` as PendingAction);
|
||||
}}
|
||||
>
|
||||
{t(`gitView.history.actions.reset${mode.charAt(0).toUpperCase() + mode.slice(1)}` as never)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button variant="outline" size="xs" className="h-6"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={(e) => { e.stopPropagation(); setPendingAction('merge'); }}
|
||||
>
|
||||
{t('gitView.history.actions.merge')}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="xs" className="h-6"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={(e) => { e.stopPropagation(); setPendingAction('rebase'); }}
|
||||
>
|
||||
{t('gitView.history.actions.rebase')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoadingFiles ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { HistoryCommitRow } from './HistoryCommitRow';
|
||||
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { assignLanes } from './gitGraph';
|
||||
import type { LanedCommit } from './gitGraph';
|
||||
|
||||
const LOG_SIZE_OPTIONS = [
|
||||
{ labelKey: 'gitView.history.logSize25', value: 25 },
|
||||
@@ -24,6 +27,7 @@ const LOG_SIZE_OPTIONS = [
|
||||
];
|
||||
|
||||
interface HistorySectionProps {
|
||||
mode?: 'history' | 'graph';
|
||||
log: { all: GitLogEntry[] } | null;
|
||||
isLogLoading: boolean;
|
||||
logMaxCount: number;
|
||||
@@ -41,9 +45,12 @@ interface HistorySectionProps {
|
||||
branchName: string;
|
||||
direction: 'up' | 'down';
|
||||
} | null;
|
||||
onConflict?: (result: { conflict: boolean; conflictFiles?: string[]; operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase' }) => void;
|
||||
onActionSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const HistorySection: React.FC<HistorySectionProps> = ({
|
||||
mode = 'history',
|
||||
log,
|
||||
isLogLoading,
|
||||
logMaxCount,
|
||||
@@ -57,10 +64,29 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
||||
showHeader = true,
|
||||
contentMaxHeightClassName = 'max-h-[50vh]',
|
||||
branchDivider = null,
|
||||
onConflict,
|
||||
onActionSuccess,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const isGraphMode = mode === 'graph';
|
||||
|
||||
const laned: LanedCommit[] = React.useMemo(
|
||||
() => (isGraphMode && log ? assignLanes(log.all) : []),
|
||||
[isGraphMode, log]
|
||||
);
|
||||
|
||||
const maxLanes = React.useMemo(
|
||||
() => Math.max(1, ...laned.map((l) => l.lane + 1)),
|
||||
[laned]
|
||||
);
|
||||
|
||||
const lanedByHash = React.useMemo(
|
||||
() => new Map(laned.map((l) => [l.commit.hash, l])),
|
||||
[laned]
|
||||
);
|
||||
|
||||
// Early return AFTER all hooks
|
||||
if (!log) {
|
||||
return null;
|
||||
}
|
||||
@@ -89,17 +115,44 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
||||
<HistoryCommitRow
|
||||
key={entry.hash}
|
||||
entry={entry}
|
||||
mode={mode}
|
||||
laned={isGraphMode ? lanedByHash.get(entry.hash) : undefined}
|
||||
totalLanes={isGraphMode ? maxLanes : undefined}
|
||||
isExpanded={expandedCommitHashes.has(entry.hash)}
|
||||
onToggle={() => onToggleCommit(entry.hash)}
|
||||
files={commitFilesMap.get(entry.hash) ?? []}
|
||||
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
|
||||
onCopyHash={onCopyHash}
|
||||
directory={directory}
|
||||
onConflict={onConflict}
|
||||
onActionSuccess={onActionSuccess}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const loadMoreButton = log.all.length >= logMaxCount ? (
|
||||
<div className="flex justify-center py-2 border-t border-border/40">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onLogMaxCountChange(logMaxCount + 25)}
|
||||
disabled={isLogLoading}
|
||||
className="px-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isLogLoading ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
||||
{t('gitView.history.loadingMore')}
|
||||
</span>
|
||||
) : (
|
||||
t('gitView.history.loadMore')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const content = (
|
||||
<ScrollableOverlay outerClassName={`min-h-0 ${contentMaxHeightClassName}`} className="h-full w-full">
|
||||
{log.all.length === 0 ? (
|
||||
@@ -109,30 +162,36 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : hasSplitHistory && branchDivider ? (
|
||||
<div className="flex flex-col gap-0">
|
||||
{topEntries.length > 0 ? (
|
||||
<div className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
||||
{renderCommitList(topEntries)}
|
||||
</div>
|
||||
) : null}
|
||||
<>
|
||||
<div className="flex flex-col gap-0">
|
||||
{topEntries.length > 0 ? (
|
||||
<div className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
||||
{renderCommitList(topEntries)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-1.5" aria-hidden>
|
||||
<span className="h-px flex-1 bg-border/60" />
|
||||
<span className="inline-flex max-w-[80%] items-center gap-1 typography-micro text-muted-foreground">
|
||||
<span className="truncate" title={branchDivider.branchName}>{branchDivider.branchName}</span>
|
||||
{dividerIcon}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-border/60" />
|
||||
<div className="flex items-center gap-2 px-3 py-1.5" aria-hidden>
|
||||
<span className="h-px flex-1 bg-border/60" />
|
||||
<span className="inline-flex max-w-[80%] items-center gap-1 typography-micro text-muted-foreground">
|
||||
<span className="truncate" title={branchDivider.branchName}>{branchDivider.branchName}</span>
|
||||
{dividerIcon}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
|
||||
{bottomEntries.length > 0 ? (
|
||||
<div className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
||||
{renderCommitList(bottomEntries)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{bottomEntries.length > 0 ? (
|
||||
<div className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
||||
{renderCommitList(bottomEntries)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{loadMoreButton}
|
||||
</>
|
||||
) : (
|
||||
renderCommitList(log.all)
|
||||
<>
|
||||
{renderCommitList(log.all)}
|
||||
{loadMoreButton}
|
||||
</>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { assignLanes } from './gitGraph';
|
||||
import type { GitLogEntry } from '@/lib/api/types';
|
||||
|
||||
function makeCommit(hash: string, parents: string[], refs = ''): GitLogEntry {
|
||||
return {
|
||||
hash,
|
||||
parents,
|
||||
date: '2024-01-01T00:00:00Z',
|
||||
message: `commit ${hash}`,
|
||||
refs,
|
||||
body: '',
|
||||
author_name: 'Test',
|
||||
author_email: 'test@test.com',
|
||||
filesChanged: 0,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('assignLanes', () => {
|
||||
test('returns empty array for empty input', () => {
|
||||
expect(assignLanes([])).toEqual([]);
|
||||
});
|
||||
|
||||
test('assigns lane 0 to all commits in a linear history', () => {
|
||||
const commits = [
|
||||
makeCommit('c', ['b']),
|
||||
makeCommit('b', ['a']),
|
||||
makeCommit('a', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
expect(result.every((r) => r.lane === 0)).toBe(true);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('assigns a color to every commit', () => {
|
||||
const commits = [makeCommit('a', [])];
|
||||
const result = assignLanes(commits);
|
||||
expect(result[0].color).toBeTruthy();
|
||||
expect(result[0].color).toContain('var(--');
|
||||
});
|
||||
|
||||
test('assigns separate lanes to two diverging branches', () => {
|
||||
// main: c -> a; feat: b -> a; order newest first: c, b, a
|
||||
const commits = [
|
||||
makeCommit('c', ['a']),
|
||||
makeCommit('b', ['a']),
|
||||
makeCommit('a', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
const cLane = result.find((r) => r.commit.hash === 'c')!.lane;
|
||||
const bLane = result.find((r) => r.commit.hash === 'b')!.lane;
|
||||
expect(cLane).not.toEqual(bLane);
|
||||
// convergence commit 'a' should be on the lower lane
|
||||
const aLane = result.find((r) => r.commit.hash === 'a')!.lane;
|
||||
expect(aLane <= Math.min(cLane, bLane)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles a merge commit (2 parents)', () => {
|
||||
const commits = [
|
||||
makeCommit('m', ['b', 'a']),
|
||||
makeCommit('b', ['base']),
|
||||
makeCommit('a', ['base']),
|
||||
makeCommit('base', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
expect(result).toHaveLength(4);
|
||||
result.forEach((r) => expect(r.lane >= 0).toBe(true));
|
||||
const baseResult = result.find((r) => r.commit.hash === 'base')!;
|
||||
expect(baseResult.lane).toBe(0);
|
||||
});
|
||||
|
||||
test('handles an octopus merge (3 parents)', () => {
|
||||
const commits = [
|
||||
makeCommit('oct', ['p1', 'p2', 'p3']),
|
||||
makeCommit('p1', ['base']),
|
||||
makeCommit('p2', ['base']),
|
||||
makeCommit('p3', ['base']),
|
||||
makeCommit('base', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
expect(result).toHaveLength(5);
|
||||
result.forEach((r) => expect(r.lane >= 0).toBe(true));
|
||||
});
|
||||
|
||||
test('root commit gets a top-stub connector', () => {
|
||||
const commits = [
|
||||
makeCommit('b', ['a']),
|
||||
makeCommit('a', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
const aResult = result.find((r) => r.commit.hash === 'a')!;
|
||||
const topStub = aResult.connectors.find((c) => c.type === 'top-stub');
|
||||
expect(topStub).not.toBeNull();
|
||||
});
|
||||
|
||||
test('commit with both parent and child gets a commit-lane connector', () => {
|
||||
const commits = [
|
||||
makeCommit('c', ['b']),
|
||||
makeCommit('b', ['a']),
|
||||
makeCommit('a', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
const bResult = result.find((r) => r.commit.hash === 'b')!;
|
||||
const commitLane = bResult.connectors.find((c) => c.type === 'commit-lane');
|
||||
expect(commitLane).not.toBeNull();
|
||||
});
|
||||
|
||||
test('merge commit produces branch-out connectors for extra parents', () => {
|
||||
const commits = [
|
||||
makeCommit('m', ['main', 'feat']),
|
||||
makeCommit('main', ['base']),
|
||||
makeCommit('feat', ['base']),
|
||||
makeCommit('base', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
const mResult = result.find((r) => r.commit.hash === 'm')!;
|
||||
const branchOut = mResult.connectors.filter((c) => c.type === 'branch-out');
|
||||
expect(branchOut.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('converges two branches cleanly with merge-in connectors', () => {
|
||||
const commits = [
|
||||
makeCommit('c', ['a']),
|
||||
makeCommit('b', ['a']),
|
||||
makeCommit('a', ['base']),
|
||||
makeCommit('base', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
|
||||
// 'a' should be where the two lanes converge
|
||||
const aResult = result.find((r) => r.commit.hash === 'a')!;
|
||||
const mergeIns = aResult.connectors.filter((c) => c.type === 'merge-in');
|
||||
expect(mergeIns.length).toBeGreaterThan(0);
|
||||
|
||||
// 'base' should only have one lane (the merged one)
|
||||
const baseResult = result.find((r) => r.commit.hash === 'base')!;
|
||||
const passingThroughBase = baseResult.connectors.filter((c) => c.type === 'passing');
|
||||
expect(passingThroughBase.length).toBe(0);
|
||||
});
|
||||
|
||||
test('produces passing connectors for unrelated active lanes', () => {
|
||||
const commits = [
|
||||
makeCommit('c', ['a']),
|
||||
makeCommit('b', ['a']),
|
||||
makeCommit('a', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
// While processing 'b', lane 0 (from c) is still active — should be 'passing'
|
||||
const bResult = result.find((r) => r.commit.hash === 'b')!;
|
||||
const passing = bResult.connectors.filter((c) => c.type === 'passing');
|
||||
expect(passing.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('produces a bottom-stub connector when a new branch starts', () => {
|
||||
const commits = [
|
||||
makeCommit('c', ['a']),
|
||||
makeCommit('b', ['a']),
|
||||
makeCommit('a', []),
|
||||
];
|
||||
const result = assignLanes(commits);
|
||||
// 'c' is the first commit processed — no child above claims it.
|
||||
// Its lane has a parent ('a') but no incoming.
|
||||
const cResult = result.find((r) => r.commit.hash === 'c')!;
|
||||
const bottomStub = cResult.connectors.find((c) => c.type === 'bottom-stub');
|
||||
expect(bottomStub).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user