mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 22:22:38 +08:00
feat(git): commit graph in history view and cross-spawn everywhere
- render a VSCode-style commit graph in the source control history: lane-assignment algorithm, SVG strip with colored rails, merge and branch curves, commit dots, and branch/tag badges per commit - /commits returns parents and ref decorations across all branches (--branches --remotes --tags --topo-order) using unit-separator fields so pipes in commit subjects can't break parsing - collect stats via a single --shortstat pass instead of one `git show --stat` call per commit; raise the history limit to 50 - replace child_process spawn with cross-spawn in all runtimes, routes, and services: it resolves .cmd shims and PATHEXT on Windows (fixing taskmaster's npx invocations) and delegates to native spawn elsewhere, removing the per-file win32 ternaries - unit tests for the log parser and lane assignment
This commit is contained in:
95
src/components/git-panel/view/history/CommitGraphStrip.tsx
Normal file
95
src/components/git-panel/view/history/CommitGraphStrip.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { CommitGraphRow } from '../../utils/commitGraph';
|
||||
import { laneColor } from '../../utils/commitGraph';
|
||||
|
||||
// Geometry: each lane is a fixed-width column; the commit dot sits at NODE_Y
|
||||
// inside a fixed-height top zone (matching the collapsed row header), and
|
||||
// plain rails continue below it so lines stretch through expanded content.
|
||||
const LANE_WIDTH = 12;
|
||||
const NODE_ZONE_HEIGHT = 56;
|
||||
const NODE_Y = 28;
|
||||
const NODE_RADIUS = 3.5;
|
||||
const STROKE_WIDTH = 2;
|
||||
|
||||
const laneX = (lane: number) => lane * LANE_WIDTH + LANE_WIDTH / 2;
|
||||
|
||||
type CommitGraphStripProps = {
|
||||
row: CommitGraphRow;
|
||||
};
|
||||
|
||||
export default function CommitGraphStrip({ row }: CommitGraphStripProps) {
|
||||
const width = row.laneCount * LANE_WIDTH;
|
||||
const nodeX = laneX(row.nodeLane);
|
||||
const nodeColor = laneColor(row.nodeLane);
|
||||
|
||||
return (
|
||||
<div aria-hidden className="relative shrink-0 self-stretch overflow-hidden" style={{ width }}>
|
||||
<svg
|
||||
className="absolute left-0 top-0"
|
||||
width={width}
|
||||
height={NODE_ZONE_HEIGHT}
|
||||
fill="none"
|
||||
>
|
||||
{/* Lines passing straight through the row */}
|
||||
{row.passThrough.map((lane) => (
|
||||
<path
|
||||
key={`pass-${lane}`}
|
||||
d={`M ${laneX(lane)} 0 V ${NODE_ZONE_HEIGHT}`}
|
||||
stroke={laneColor(lane)}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* The node's own lane arriving from above / continuing below */}
|
||||
{row.hasTopContinuation && (
|
||||
<path d={`M ${nodeX} 0 V ${NODE_Y}`} stroke={nodeColor} strokeWidth={STROKE_WIDTH} />
|
||||
)}
|
||||
{row.hasParentContinuation && (
|
||||
<path d={`M ${nodeX} ${NODE_Y} V ${NODE_ZONE_HEIGHT}`} stroke={nodeColor} strokeWidth={STROKE_WIDTH} />
|
||||
)}
|
||||
|
||||
{/* Extra children merging into the node from the row above */}
|
||||
{row.inbound.map((lane) => (
|
||||
<path
|
||||
key={`in-${lane}`}
|
||||
d={`M ${laneX(lane)} 0 Q ${laneX(lane)} ${NODE_Y} ${nodeX} ${NODE_Y}`}
|
||||
stroke={laneColor(lane)}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Extra parents branching out of the node toward the row below */}
|
||||
{row.outbound.map((lane) => (
|
||||
<path
|
||||
key={`out-${lane}`}
|
||||
d={`M ${nodeX} ${NODE_Y} Q ${laneX(lane)} ${NODE_Y} ${laneX(lane)} ${NODE_ZONE_HEIGHT}`}
|
||||
stroke={laneColor(lane)}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Commit dot — slightly larger for merge/fork points */}
|
||||
<circle
|
||||
cx={nodeX}
|
||||
cy={NODE_Y}
|
||||
r={row.inbound.length > 0 || row.outbound.length > 0 ? NODE_RADIUS + 0.5 : NODE_RADIUS}
|
||||
fill={nodeColor}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Rails continuing below the node zone (through expanded content) */}
|
||||
{row.bottomLanes.map((lane) => (
|
||||
<div
|
||||
key={`rail-${lane}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: laneX(lane) - STROKE_WIDTH / 2,
|
||||
top: NODE_ZONE_HEIGHT,
|
||||
bottom: 0,
|
||||
width: STROKE_WIDTH,
|
||||
backgroundColor: laneColor(lane),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, GitBranch, Tag } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import type { GitCommitSummary } from '../../types/types';
|
||||
import type { CommitGraphRow } from '../../utils/commitGraph';
|
||||
import { laneColor } from '../../utils/commitGraph';
|
||||
import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils';
|
||||
import GitDiffViewer from '../shared/GitDiffViewer';
|
||||
import CommitGraphStrip from './CommitGraphStrip';
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
@@ -12,12 +15,36 @@ function formatDate(dateString: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
// One "HEAD -> main" / "origin/x" / "tag: v1" decoration pill next to the
|
||||
// commit message, tinted with the commit's graph lane color.
|
||||
function RefBadge({ refName, color }: { refName: string; color: string }) {
|
||||
const isTag = refName.startsWith('tag: ');
|
||||
const isHead = refName.startsWith('HEAD -> ');
|
||||
const label = isTag ? refName.slice(5) : isHead ? refName.slice(8) : refName;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-40 items-center gap-1 rounded-full border px-1.5 py-px text-[10px] font-medium leading-4"
|
||||
style={{
|
||||
borderColor: color,
|
||||
color,
|
||||
backgroundColor: isHead ? `${color}22` : 'transparent',
|
||||
}}
|
||||
title={refName}
|
||||
>
|
||||
{isTag ? <Tag className="h-2.5 w-2.5 shrink-0" /> : <GitBranch className="h-2.5 w-2.5 shrink-0" />}
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type CommitHistoryItemProps = {
|
||||
commit: GitCommitSummary;
|
||||
isExpanded: boolean;
|
||||
diff?: string;
|
||||
isMobile: boolean;
|
||||
wrapText: boolean;
|
||||
graphRow?: CommitGraphRow;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
@@ -27,6 +54,7 @@ export default function CommitHistoryItem({
|
||||
diff,
|
||||
isMobile,
|
||||
wrapText,
|
||||
graphRow,
|
||||
onToggle,
|
||||
}: CommitHistoryItemProps) {
|
||||
const fileSummary = useMemo(() => {
|
||||
@@ -34,8 +62,12 @@ export default function CommitHistoryItem({
|
||||
return parseCommitFiles(diff);
|
||||
}, [diff]);
|
||||
|
||||
const badgeColor = graphRow ? laneColor(graphRow.nodeLane) : 'var(--color-primary, #0ea5e9)';
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-0">
|
||||
<div className="flex border-b border-border last:border-0">
|
||||
{graphRow && <CommitGraphStrip row={graphRow} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
@@ -48,6 +80,13 @@ export default function CommitHistoryItem({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{commit.refs && commit.refs.length > 0 && (
|
||||
<span className="mb-0.5 flex flex-wrap gap-1">
|
||||
{commit.refs.map((refName) => (
|
||||
<RefBadge key={refName} refName={refName} color={badgeColor} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate text-sm font-medium text-foreground">{commit.message}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{commit.author}
|
||||
@@ -145,6 +184,7 @@ export default function CommitHistoryItem({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { History, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { GitDiffMap, GitCommitSummary } from '../../types/types';
|
||||
import { computeCommitGraph } from '../../utils/commitGraph';
|
||||
import CommitHistoryItem from './CommitHistoryItem';
|
||||
|
||||
type HistoryViewProps = {
|
||||
@@ -22,6 +23,15 @@ export default function HistoryView({
|
||||
}: HistoryViewProps) {
|
||||
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
|
||||
|
||||
// Lane layout for the commit graph; rows align 1:1 with recentCommits.
|
||||
// Older API responses without `parents` degrade to plain rows (no strip).
|
||||
const graphRows = useMemo(() => {
|
||||
if (!recentCommits.some((commit) => commit.parents !== undefined)) {
|
||||
return null;
|
||||
}
|
||||
return computeCommitGraph(recentCommits);
|
||||
}, [recentCommits]);
|
||||
|
||||
const toggleCommitExpanded = useCallback(
|
||||
(commitHash: string) => {
|
||||
const isExpanding = !expandedCommits.has(commitHash);
|
||||
@@ -59,7 +69,7 @@ export default function HistoryView({
|
||||
</div>
|
||||
) : (
|
||||
<div className={isMobile ? 'pb-4' : ''}>
|
||||
{recentCommits.map((commit) => (
|
||||
{recentCommits.map((commit, index) => (
|
||||
<CommitHistoryItem
|
||||
key={commit.hash}
|
||||
commit={commit}
|
||||
@@ -67,6 +77,7 @@ export default function HistoryView({
|
||||
diff={commitDiffs[commit.hash]}
|
||||
isMobile={isMobile}
|
||||
wrapText={wrapText}
|
||||
graphRow={graphRows?.[index]}
|
||||
onToggle={() => toggleCommitExpanded(commit.hash)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user