feat: add git workflow frontend components

- RunChangeSummaryCard: post-run change review card with expandable
  file diffs, origin badges, selective discard with confirmation,
  and commit composer launch button
- CommitComposer: slide-over panel for staging files, editing AI-
  suggested commit messages, conventional commit type selection,
  and commit with optional push
- GitPanel: embedded activity panel section with branch management,
  push/pull/fetch operations, working tree inspection with inline
  diffs, and recent commit history
- Wire RunChangeSummaryCard into RunTimeline after completed runs
- Wire CommitComposer as overlay in App.tsx with state management
- Wire GitPanel into ActivityPanel for non-scratchpad sessions
- Update ARCHITECTURE.md with frontend git integration description

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-31 16:43:45 +01:00
co-authored by Copilot
parent 906433f408
commit 5a71539705
7 changed files with 1672 additions and 4 deletions
@@ -0,0 +1,551 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ArrowUpFromLine,
Check,
ChevronRight,
FileCode2,
FileMinus2,
FilePlus2,
GitBranch,
Loader2,
Sparkles,
X,
} from 'lucide-react';
import { getElectronApi } from '@renderer/lib/electronApi';
import type {
ProjectGitCommitMessageSuggestion,
ProjectGitConventionalCommitType,
ProjectGitDetails,
ProjectGitDiffPreview,
ProjectGitFileReference,
ProjectGitWorkingTreeFile,
} from '@shared/domain/project';
/* ── Helpers ───────────────────────────────────────────────── */
function fileBaseName(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
}
function fileDir(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : '';
}
function fileIcon(file: ProjectGitWorkingTreeFile) {
if (file.stagedStatus === 'added' || file.unstagedStatus === 'untracked') {
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
}
if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') {
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
}
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
}
function statusLabel(file: ProjectGitWorkingTreeFile): string {
return file.stagedStatus ?? file.unstagedStatus ?? 'modified';
}
const CONVENTIONAL_TYPES: { value: ProjectGitConventionalCommitType; label: string }[] = [
{ value: 'feat', label: 'feat' },
{ value: 'fix', label: 'fix' },
{ value: 'refactor', label: 'refactor' },
{ value: 'docs', label: 'docs' },
{ value: 'test', label: 'test' },
{ value: 'chore', label: 'chore' },
];
/* ── Diff line renderer ────────────────────────────────────── */
function DiffLine({ line }: { line: string }) {
let textClass = 'text-[var(--color-text-secondary)]';
let bgClass = '';
if (line.startsWith('+') && !line.startsWith('+++')) {
textClass = 'text-[var(--color-status-success)]';
bgClass = 'bg-[var(--color-status-success)]/[0.06]';
} else if (line.startsWith('-') && !line.startsWith('---')) {
textClass = 'text-[var(--color-status-error)]';
bgClass = 'bg-[var(--color-status-error)]/[0.06]';
} else if (line.startsWith('@@')) {
textClass = 'text-[var(--color-accent-sky)]';
} else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
textClass = 'text-[var(--color-text-muted)]';
}
return <div className={`${textClass} ${bgClass} -mx-3 px-3`}>{line || '\u00A0'}</div>;
}
/* ── File row with staging checkbox ────────────────────────── */
function CommitFileRow({
file,
isStaged,
onToggle,
onPreview,
preview,
previewExpanded,
}: {
file: ProjectGitWorkingTreeFile;
isStaged: boolean;
onToggle: () => void;
onPreview: () => void;
preview?: ProjectGitDiffPreview;
previewExpanded: boolean;
}) {
const dir = fileDir(file.path);
const base = fileBaseName(file.path);
const hasPreview = !!preview?.diff || !!preview?.newFileContents;
return (
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
<div className="flex items-center gap-1 px-2.5 py-[5px] text-[10px]">
{/* Staging checkbox */}
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
isStaged
? 'border-[var(--color-status-success)] bg-[var(--color-status-success)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={onToggle}
type="button"
aria-label={`${isStaged ? 'Unstage' : 'Stage'} ${file.path}`}
aria-pressed={isStaged}
>
{isStaged && <Check className="size-2" />}
</button>
{/* File path + expand */}
<button
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors duration-150 hover:text-[var(--color-text-primary)]"
onClick={onPreview}
type="button"
aria-expanded={previewExpanded}
>
{hasPreview || previewExpanded ? (
<ChevronRight
className={`size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${previewExpanded ? 'rotate-90' : ''}`}
/>
) : (
<span className="w-2.5 shrink-0" />
)}
{fileIcon(file)}
<span className="min-w-0 flex-1 truncate font-mono">
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
<span className="text-[var(--color-text-primary)]">{base}</span>
</span>
<span className="shrink-0 rounded px-1 py-px text-[8px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
{statusLabel(file)}
</span>
</button>
</div>
{/* Diff preview */}
{previewExpanded && preview && (
<div className="border-t border-[var(--color-border-subtle)]">
<pre className="max-h-52 overflow-auto bg-[var(--color-surface-0)] px-3 py-1.5 font-mono text-[10px] leading-relaxed">
{preview.diff
? preview.diff.split('\n').map((line, i) => <DiffLine key={i} line={line} />)
: preview.newFileContents
? preview.newFileContents.split('\n').map((line, i) => (
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
))
: preview.isBinary
? <div className="text-[var(--color-text-muted)] italic">Binary file</div>
: <div className="text-[var(--color-text-muted)] italic">Loading</div>}
</pre>
</div>
)}
</div>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface CommitComposerProps {
projectId: string;
sessionId: string;
runId?: string;
onClose: () => void;
}
export function CommitComposer({
projectId,
sessionId,
runId,
onClose,
}: CommitComposerProps) {
const api = getElectronApi();
const [details, setDetails] = useState<ProjectGitDetails>();
const [loading, setLoading] = useState(true);
const [commitMessage, setCommitMessage] = useState('');
const [commitType, setCommitType] = useState<ProjectGitConventionalCommitType>('feat');
const [stagedPaths, setStagedPaths] = useState<Set<string>>(new Set());
const [previews, setPreviews] = useState<Record<string, ProjectGitDiffPreview>>({});
const [expandedPath, setExpandedPath] = useState<string>();
const [committing, setCommitting] = useState(false);
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const [commitError, setCommitError] = useState<string>();
const [commitSuccess, setCommitSuccess] = useState(false);
// Load git details on mount
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const result = await api.getProjectGitDetails({ projectId });
if (!cancelled) {
setDetails(result);
// Pre-stage files that are already in the index
if (result.workingTree) {
const preStaged = new Set<string>();
for (const file of result.workingTree.files) {
if (file.stagedStatus && file.stagedStatus !== 'unmerged') {
preStaged.add(file.path);
}
}
setStagedPaths(preStaged);
}
}
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => { cancelled = true; };
}, [api, projectId]);
// Load commit message suggestion
useEffect(() => {
let cancelled = false;
async function suggest() {
try {
const suggestion = await api.suggestProjectGitCommitMessage({
sessionId,
runId,
conventionalType: commitType,
});
if (!cancelled && suggestion) {
setCommitMessage(suggestion.message);
setCommitType(suggestion.type);
}
} catch {
// Suggestion is best-effort
}
}
if (!commitMessage) {
void suggest();
}
return () => { cancelled = true; };
}, [api, sessionId, runId]); // Deliberately omitting commitType/commitMessage to avoid re-triggering
const files = useMemo(
() => details?.workingTree?.files ?? [],
[details?.workingTree?.files],
);
const stagedFiles = useMemo(
() => files.filter((f) => stagedPaths.has(f.path)),
[files, stagedPaths],
);
const toggleStaged = useCallback((path: string) => {
setStagedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const toggleAll = useCallback(() => {
if (stagedPaths.size === files.length) {
setStagedPaths(new Set());
} else {
setStagedPaths(new Set(files.map((f) => f.path)));
}
}, [files, stagedPaths.size]);
const handlePreview = useCallback(async (file: ProjectGitWorkingTreeFile) => {
if (expandedPath === file.path) {
setExpandedPath(undefined);
return;
}
setExpandedPath(file.path);
if (!previews[file.path]) {
try {
const preview = await api.getProjectGitFilePreview({
projectId,
file: { path: file.path, previousPath: file.previousPath },
});
if (preview) {
setPreviews((prev) => ({ ...prev, [file.path]: preview }));
}
} catch {
// Preview is best-effort
}
}
}, [api, expandedPath, previews, projectId]);
const handleSuggestMessage = useCallback(async () => {
try {
const suggestion = await api.suggestProjectGitCommitMessage({
sessionId,
runId,
conventionalType: commitType,
});
if (suggestion) {
setCommitMessage(suggestion.message);
setCommitType(suggestion.type);
}
} catch {
// Best-effort
}
}, [api, sessionId, runId, commitType]);
const handleCommit = useCallback(async () => {
if (!commitMessage.trim() || stagedFiles.length === 0) return;
setCommitting(true);
setCommitError(undefined);
try {
const filesToCommit: ProjectGitFileReference[] = stagedFiles.map((f) => ({
path: f.path,
previousPath: f.previousPath,
}));
await api.commitProjectGitChanges({
projectId,
message: commitMessage.trim(),
files: filesToCommit,
push: pushAfterCommit,
});
setCommitSuccess(true);
setTimeout(() => onClose(), 1200);
} catch (error) {
setCommitError(error instanceof Error ? error.message : String(error));
} finally {
setCommitting(false);
}
}, [api, commitMessage, onClose, projectId, pushAfterCommit, stagedFiles]);
// Keyboard: Escape to close
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-stretch justify-end"
role="dialog"
aria-modal="true"
aria-labelledby="commit-composer-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
onClick={onClose}
/>
{/* Panel */}
<div className="relative z-10 flex w-[420px] max-w-full flex-col border-l border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
{/* Header */}
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] px-4 py-3">
<GitBranch className="size-4 text-[var(--color-accent-sky)]" />
<h2 id="commit-composer-title" className="font-display text-sm font-semibold text-[var(--color-text-primary)]">
Commit Changes
</h2>
{details?.context.branch && (
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
on {details.context.branch}
</span>
)}
<div className="flex-1" />
<button
className="rounded-md p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onClose}
type="button"
aria-label="Close commit composer"
>
<X className="size-4" />
</button>
</div>
{/* Loading */}
{loading && (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-5 animate-spin text-[var(--color-text-muted)]" aria-label="Loading git details" />
</div>
)}
{/* Success state */}
{commitSuccess && (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6">
<div className="flex size-12 items-center justify-center rounded-full bg-[var(--color-status-success)]/10">
<Check className="size-6 text-[var(--color-status-success)]" />
</div>
<p className="text-sm font-medium text-[var(--color-status-success)]">
Changes committed{pushAfterCommit ? ' and pushed' : ''}
</p>
</div>
)}
{/* Content */}
{!loading && !commitSuccess && (
<>
{/* Commit message */}
<div className="border-b border-[var(--color-border-subtle)] px-4 py-3">
{/* Type selector */}
<div className="mb-2 flex items-center gap-1">
{CONVENTIONAL_TYPES.map((ct) => (
<button
key={ct.value}
className={`rounded-md px-2 py-0.5 text-[10px] font-medium transition-colors duration-100 ${
commitType === ct.value
? 'bg-[var(--color-accent)]/15 text-[var(--color-text-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => setCommitType(ct.value)}
type="button"
>
{ct.label}
</button>
))}
<div className="flex-1" />
<button
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-accent)]"
onClick={() => void handleSuggestMessage()}
type="button"
title="Suggest commit message"
>
<Sparkles className="size-3" />
Suggest
</button>
</div>
<textarea
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface-0)] px-3 py-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
placeholder="Commit message…"
rows={3}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
/>
</div>
{/* File list */}
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Select all header */}
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] px-2.5 py-1.5">
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
stagedPaths.size === files.length && files.length > 0
? 'border-[var(--color-status-success)] bg-[var(--color-status-success)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={toggleAll}
type="button"
aria-label={stagedPaths.size === files.length ? 'Unstage all' : 'Stage all'}
>
{stagedPaths.size === files.length && files.length > 0 && <Check className="size-2" />}
</button>
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">
{stagedPaths.size} of {files.length} staged
</span>
</div>
{files.length === 0 ? (
<p className="px-4 py-6 text-center text-[11px] text-[var(--color-text-muted)]">
Working tree is clean nothing to commit.
</p>
) : (
files.map((file) => (
<CommitFileRow
file={file}
isStaged={stagedPaths.has(file.path)}
key={file.path}
onPreview={() => void handlePreview(file)}
onToggle={() => toggleStaged(file.path)}
preview={previews[file.path]}
previewExpanded={expandedPath === file.path}
/>
))
)}
</div>
{/* Error */}
{commitError && (
<div className="border-t border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-4 py-2 text-[10px] text-[var(--color-status-error)]" role="alert">
{commitError}
</div>
)}
{/* Footer actions */}
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-4 py-3">
{/* Push toggle */}
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
<input
type="checkbox"
checked={pushAfterCommit}
onChange={(e) => setPushAfterCommit(e.target.checked)}
className="accent-[var(--color-accent)]"
/>
Push after commit
</label>
<div className="flex-1" />
<button
className="rounded-md px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-[11px] font-medium text-white transition-colors duration-150 hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
disabled={committing || !commitMessage.trim() || stagedFiles.length === 0}
onClick={() => void handleCommit()}
type="button"
>
{committing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ArrowUpFromLine className="size-3" />
)}
{committing ? 'Committing…' : pushAfterCommit ? 'Commit & Push' : 'Commit'}
</button>
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,420 @@
import { useCallback, useMemo, useState } from 'react';
import {
AlertTriangle,
ArrowDownToLine,
Check,
ChevronRight,
FileCode2,
FileMinus2,
FilePlus2,
GitBranch,
Loader2,
RotateCcw,
Trash2,
} from 'lucide-react';
import type {
ProjectGitFileReference,
ProjectGitRunChangedFile,
ProjectGitRunChangeSummary,
} from '@shared/domain/project';
/* ── Helpers ───────────────────────────────────────────────── */
function fileBaseName(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
}
function fileDir(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : '';
}
function kindLabel(kind: ProjectGitRunChangedFile['kind']): string {
switch (kind) {
case 'added': return 'added';
case 'modified': return 'modified';
case 'deleted': return 'deleted';
case 'renamed': return 'renamed';
case 'copied': return 'copied';
case 'type-changed': return 'type changed';
case 'unmerged': return 'conflict';
case 'untracked': return 'new';
case 'cleaned': return 'cleaned';
}
}
function kindIcon(kind: ProjectGitRunChangedFile['kind']) {
switch (kind) {
case 'added':
case 'untracked':
case 'copied':
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
case 'deleted':
case 'cleaned':
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
default:
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
}
}
function originBadge(origin: ProjectGitRunChangedFile['origin']) {
if (origin === 'pre-existing') {
return (
<span className="rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]">
pre-existing
</span>
);
}
return null;
}
/* ── Mini diff-stats bar ───────────────────────────────────── */
function DiffStatsBar({ additions, deletions }: { additions: number; deletions: number }) {
const total = additions + deletions;
if (total === 0) return null;
const blocks = 5;
const addBlocks = Math.max(additions > 0 ? 1 : 0, Math.round((additions / total) * blocks));
const delBlocks = blocks - addBlocks;
return (
<span className="inline-flex gap-px" aria-label={`${additions} additions, ${deletions} deletions`}>
{Array.from({ length: addBlocks }, (_, i) => (
<span key={`a${i}`} className="size-1.5 rounded-[1px] bg-[var(--color-status-success)]" />
))}
{Array.from({ length: delBlocks }, (_, i) => (
<span key={`d${i}`} className="size-1.5 rounded-[1px] bg-[var(--color-status-error)]" />
))}
</span>
);
}
/* ── Diff line renderer ────────────────────────────────────── */
function DiffLine({ line }: { line: string }) {
let textClass = 'text-[var(--color-text-secondary)]';
let bgClass = '';
if (line.startsWith('+') && !line.startsWith('+++')) {
textClass = 'text-[var(--color-status-success)]';
bgClass = 'bg-[var(--color-status-success)]/[0.06]';
} else if (line.startsWith('-') && !line.startsWith('---')) {
textClass = 'text-[var(--color-status-error)]';
bgClass = 'bg-[var(--color-status-error)]/[0.06]';
} else if (line.startsWith('@@')) {
textClass = 'text-[var(--color-accent-sky)]';
} else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
textClass = 'text-[var(--color-text-muted)]';
}
return <div className={`${textClass} ${bgClass} -mx-3 px-3`}>{line || '\u00A0'}</div>;
}
/* ── Single file row ───────────────────────────────────────── */
function ChangedFileRow({
file,
isSelected,
onToggleSelect,
canSelect,
}: {
file: ProjectGitRunChangedFile;
isSelected: boolean;
onToggleSelect: () => void;
canSelect: boolean;
}) {
const [diffExpanded, setDiffExpanded] = useState(false);
const hasPreview = !!file.preview?.diff || !!file.preview?.newFileContents;
const dir = fileDir(file.path);
const base = fileBaseName(file.path);
return (
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
<div className="flex items-center gap-1 px-2 py-[5px] text-[10px]">
{/* Select checkbox */}
{canSelect && (
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
isSelected
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={onToggleSelect}
type="button"
aria-label={`${isSelected ? 'Deselect' : 'Select'} ${file.path}`}
aria-pressed={isSelected}
>
{isSelected && <Check className="size-2" />}
</button>
)}
{/* Expand diff button */}
<button
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors duration-150 hover:bg-[var(--color-surface-3)]/40 disabled:cursor-default"
disabled={!hasPreview}
onClick={hasPreview ? () => setDiffExpanded(!diffExpanded) : undefined}
type="button"
aria-expanded={hasPreview ? diffExpanded : undefined}
>
{hasPreview ? (
<ChevronRight
className={`size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${diffExpanded ? 'rotate-90' : ''}`}
/>
) : (
<span className="w-2.5 shrink-0" />
)}
{kindIcon(file.kind)}
<span className="min-w-0 flex-1 truncate font-mono">
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
<span className="text-[var(--color-text-primary)]">{base}</span>
</span>
<span className="shrink-0 rounded px-1 py-px text-[8px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
{kindLabel(file.kind)}
</span>
{originBadge(file.origin)}
{(file.additions > 0 || file.deletions > 0) && (
<span className="flex items-center gap-1.5 shrink-0 font-mono">
{file.additions > 0 && <span className="text-[var(--color-status-success)]">+{file.additions}</span>}
{file.deletions > 0 && <span className="text-[var(--color-status-error)]">{file.deletions}</span>}
<DiffStatsBar additions={file.additions} deletions={file.deletions} />
</span>
)}
</button>
</div>
{/* Diff preview */}
{diffExpanded && file.preview && (
<div className="border-t border-[var(--color-border-subtle)]">
<pre className="max-h-64 overflow-auto bg-[var(--color-surface-0)] px-3 py-1.5 font-mono text-[10px] leading-relaxed">
{file.preview.diff
? file.preview.diff.split('\n').map((line, i) => <DiffLine key={i} line={line} />)
: file.preview.newFileContents
? file.preview.newFileContents.split('\n').map((line, i) => (
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
))
: file.preview.isBinary
? <div className="text-[var(--color-text-muted)] italic">Binary file</div>
: null}
</pre>
</div>
)}
</div>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface RunChangeSummaryCardProps {
summary: ProjectGitRunChangeSummary;
sessionId: string;
runId: string;
onDiscard: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
}
export function RunChangeSummaryCard({
summary,
sessionId,
runId,
onDiscard,
onOpenCommitComposer,
}: RunChangeSummaryCardProps) {
const [expanded, setExpanded] = useState(false);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [discarding, setDiscarding] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<'bulk' | 'selected' | undefined>();
const revertableFiles = useMemo(
() => summary.files.filter((file) => file.canRevert),
[summary.files],
);
const hasRevertable = revertableFiles.length > 0;
const toggleSelect = useCallback((path: string) => {
setSelectedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleDiscard = useCallback(async (mode: 'bulk' | 'selected') => {
setDiscarding(true);
try {
if (mode === 'selected') {
const files: ProjectGitFileReference[] = summary.files
.filter((f) => selectedPaths.has(f.path))
.map((f) => ({ path: f.path, previousPath: f.previousPath }));
await onDiscard(sessionId, runId, files);
setSelectedPaths(new Set());
} else {
await onDiscard(sessionId, runId);
}
} finally {
setDiscarding(false);
setConfirmDiscard(undefined);
}
}, [onDiscard, sessionId, runId, selectedPaths, summary.files]);
const selectedRevertableCount = useMemo(
() => revertableFiles.filter((f) => selectedPaths.has(f.path)).length,
[revertableFiles, selectedPaths],
);
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)]/80">
{/* Header */}
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors duration-150 hover:bg-[var(--color-surface-2)]/40"
onClick={() => setExpanded(!expanded)}
type="button"
aria-expanded={expanded}
aria-label={`${summary.fileCount} files changed by this run`}
>
<ChevronRight
className={`size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
/>
<GitBranch className="size-3 shrink-0 text-[var(--color-accent-sky)]" />
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
{summary.fileCount} {summary.fileCount === 1 ? 'file' : 'files'} changed
</span>
{(summary.additions > 0 || summary.deletions > 0) && (
<span className="flex items-center gap-1.5 font-mono text-[10px]">
{summary.additions > 0 && (
<span className="text-[var(--color-status-success)]">+{summary.additions}</span>
)}
{summary.deletions > 0 && (
<span className="text-[var(--color-status-error)]">{summary.deletions}</span>
)}
<DiffStatsBar additions={summary.additions} deletions={summary.deletions} />
</span>
)}
{summary.branchChanged && (
<span className="ml-auto flex items-center gap-1 text-[9px] text-[var(--color-status-warning)]">
<AlertTriangle className="size-2.5" />
branch changed
</span>
)}
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-[var(--color-border-subtle)]">
{/* Branch info */}
{summary.branchChanged && summary.branchAtStart && summary.branchAtEnd && (
<div className="flex items-center gap-1.5 border-b border-[var(--color-border-subtle)] px-3 py-1.5 text-[9px] text-[var(--color-text-muted)]">
<GitBranch className="size-2.5" />
<span className="font-mono text-[var(--color-text-secondary)]">{summary.branchAtStart}</span>
<span></span>
<span className="font-mono text-[var(--color-text-secondary)]">{summary.branchAtEnd}</span>
</div>
)}
{/* File list */}
<div>
{summary.files.map((file) => (
<ChangedFileRow
canSelect={hasRevertable && file.canRevert}
file={file}
isSelected={selectedPaths.has(file.path)}
key={file.path}
onToggleSelect={() => toggleSelect(file.path)}
/>
))}
</div>
{/* Actions */}
{(hasRevertable || onOpenCommitComposer) && (
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-3 py-2">
{/* Commit button */}
{onOpenCommitComposer && (
<button
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1 text-[10px] font-medium text-white transition-colors duration-150 hover:bg-[var(--color-accent-hover)]"
onClick={onOpenCommitComposer}
type="button"
>
<ArrowDownToLine className="size-3" />
Commit changes
</button>
)}
<div className="flex-1" />
{/* Discard actions */}
{hasRevertable && !confirmDiscard && (
<>
{selectedRevertableCount > 0 && (
<button
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-[var(--color-status-error)] transition-colors duration-150 hover:bg-[var(--color-status-error)]/10"
disabled={discarding}
onClick={() => setConfirmDiscard('selected')}
type="button"
>
<Trash2 className="size-3" />
Discard {selectedRevertableCount} selected
</button>
)}
<button
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-status-error)]"
disabled={discarding}
onClick={() => setConfirmDiscard('bulk')}
type="button"
>
<RotateCcw className="size-3" />
Discard all
</button>
</>
)}
{/* Confirmation */}
{confirmDiscard && (
<div className="flex items-center gap-1.5 rounded-md border border-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/5 px-2.5 py-1" role="alert">
<AlertTriangle className="size-3 text-[var(--color-status-error)]" />
<span className="text-[10px] text-[var(--color-status-error)]">
{confirmDiscard === 'bulk'
? `Revert all ${revertableFiles.length} files to pre-run state?`
: `Revert ${selectedRevertableCount} selected files?`}
</span>
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold text-[var(--color-status-error)] transition-colors duration-100 hover:bg-[var(--color-status-error)]/15"
disabled={discarding}
onClick={() => void handleDiscard(confirmDiscard)}
type="button"
>
{discarding ? <Loader2 className="size-2.5 animate-spin" /> : <Check className="size-2.5" />}
Yes
</button>
<button
className="rounded px-1.5 py-0.5 text-[9px] font-semibold text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
onClick={() => setConfirmDiscard(undefined)}
type="button"
>
Cancel
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}