mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-30 08:28:48 +02:00
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:
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, GitBranch as GitBranchIcon, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
type TurnEventLog,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||
import { GitPanel } from '@renderer/components/GitPanel';
|
||||
import { inferProvider } from '@shared/domain/models';
|
||||
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject } from '@shared/domain/project';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { ProviderIcon } from './ProviderIcons';
|
||||
|
||||
@@ -208,6 +211,8 @@ function formatTurnEventTimestamp(iso: string): string {
|
||||
interface ActivityPanelProps {
|
||||
activity?: SessionActivityState;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
pattern: PatternDefinition;
|
||||
session: SessionRecord;
|
||||
sessionRequestUsage?: SessionRequestUsageState;
|
||||
@@ -217,6 +222,8 @@ interface ActivityPanelProps {
|
||||
export function ActivityPanel({
|
||||
activity,
|
||||
onJumpToMessage,
|
||||
onDiscard,
|
||||
onOpenCommitComposer,
|
||||
pattern,
|
||||
session,
|
||||
sessionRequestUsage,
|
||||
@@ -345,7 +352,13 @@ export function ActivityPanel({
|
||||
)}
|
||||
</SectionHeader>
|
||||
|
||||
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
|
||||
<RunTimeline
|
||||
onDiscard={onDiscard}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
runs={session.runs}
|
||||
sessionId={session.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Turn events section ─────────────────────────── */}
|
||||
@@ -382,6 +395,18 @@ export function ActivityPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Git panel section ────────────────────────────── */}
|
||||
{!isScratchpadProject(session.projectId) && (
|
||||
<div className="mb-4">
|
||||
<SectionHeader>
|
||||
<GitBranchIcon className="size-3" />
|
||||
<span>Git</span>
|
||||
</SectionHeader>
|
||||
|
||||
<GitPanel projectId={session.projectId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
FileCode2,
|
||||
FileMinus2,
|
||||
FilePlus2,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
History,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||
import type {
|
||||
ProjectGitBranchSummary,
|
||||
ProjectGitCommitLogEntry,
|
||||
ProjectGitDetails,
|
||||
ProjectGitDiffPreview,
|
||||
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 relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) return 'just now';
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/* ── Section header ────────────────────────────────────────── */
|
||||
|
||||
function SectionHeader({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count?: number;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors duration-100 hover:bg-[var(--color-surface-2)]/30"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="size-2.5 text-[var(--color-text-muted)]" />
|
||||
: <ChevronRight className="size-2.5 text-[var(--color-text-muted)]" />}
|
||||
{icon}
|
||||
<span className="font-display text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
{label}
|
||||
</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="rounded-full bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[8px] font-semibold tabular-nums text-[var(--color-text-muted)]">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── File status icon ──────────────────────────────────────── */
|
||||
|
||||
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)]" />;
|
||||
}
|
||||
|
||||
/* ── Changed file row ──────────────────────────────────────── */
|
||||
|
||||
function ChangedFileEntry({
|
||||
file,
|
||||
projectId,
|
||||
}: {
|
||||
file: ProjectGitWorkingTreeFile;
|
||||
projectId: string;
|
||||
}) {
|
||||
const api = getElectronApi();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [preview, setPreview] = useState<ProjectGitDiffPreview>();
|
||||
|
||||
const dir = fileDir(file.path);
|
||||
const base = fileBaseName(file.path);
|
||||
const status = file.stagedStatus ?? file.unstagedStatus ?? 'modified';
|
||||
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
return;
|
||||
}
|
||||
setExpanded(true);
|
||||
if (!preview) {
|
||||
try {
|
||||
const result = await api.getProjectGitFilePreview({
|
||||
projectId,
|
||||
file: { path: file.path, previousPath: file.previousPath },
|
||||
});
|
||||
if (result) setPreview(result);
|
||||
} catch {
|
||||
// Preview is best-effort
|
||||
}
|
||||
}
|
||||
}, [api, expanded, file.path, file.previousPath, preview, projectId]);
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-3 py-[5px] text-left text-[10px] transition-colors duration-150 hover:bg-[var(--color-surface-2)]/40"
|
||||
onClick={() => void handleToggle()}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-2 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
{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-[7px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
|
||||
{status}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && preview && (
|
||||
<div className="border-t border-[var(--color-border-subtle)]">
|
||||
<pre className="max-h-40 overflow-auto bg-[var(--color-surface-0)] px-3 py-1 font-mono text-[9px] leading-relaxed">
|
||||
{preview.diff
|
||||
? preview.diff.split('\n').map((line, i) => {
|
||||
let cls = 'text-[var(--color-text-secondary)]';
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) cls = 'text-[var(--color-status-success)]';
|
||||
else if (line.startsWith('-') && !line.startsWith('---')) cls = 'text-[var(--color-status-error)]';
|
||||
else if (line.startsWith('@@')) cls = 'text-[var(--color-accent-sky)]';
|
||||
return <div key={i} className={cls}>{line || '\u00A0'}</div>;
|
||||
})
|
||||
: preview.newFileContents
|
||||
? preview.newFileContents.split('\n').map((line, i) => (
|
||||
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
|
||||
))
|
||||
: <div className="text-[var(--color-text-muted)] italic">Binary file</div>}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Branch row ────────────────────────────────────────────── */
|
||||
|
||||
function BranchRow({
|
||||
branch,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
isSwitching,
|
||||
}: {
|
||||
branch: ProjectGitBranchSummary;
|
||||
onSwitch: () => void;
|
||||
onDelete: () => void;
|
||||
isSwitching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 py-[5px] text-[10px]">
|
||||
<GitBranch className={`size-3 shrink-0 ${branch.isCurrent ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-muted)]'}`} />
|
||||
<span className={`min-w-0 flex-1 truncate font-mono ${branch.isCurrent ? 'font-medium text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{branch.name}
|
||||
</span>
|
||||
{branch.isCurrent && (
|
||||
<span className="rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
{!branch.isCurrent && (
|
||||
<>
|
||||
<button
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onSwitch}
|
||||
type="button"
|
||||
title={`Switch to ${branch.name}`}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
{isSwitching ? <Loader2 className="size-2.5 animate-spin" /> : <Check className="size-2.5" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
title={`Delete ${branch.name}`}
|
||||
>
|
||||
<Trash2 className="size-2.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Commit log row ────────────────────────────────────────── */
|
||||
|
||||
function CommitRow({ commit }: { commit: ProjectGitCommitLogEntry }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 px-3 py-[5px] text-[10px]">
|
||||
<GitCommitHorizontal className="mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[var(--color-text-secondary)]">
|
||||
{commit.subject}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 text-[9px] text-[var(--color-text-muted)]">
|
||||
<span className="font-mono">{commit.shortHash}</span>
|
||||
<span>·</span>
|
||||
<span>{commit.authorName}</span>
|
||||
<span>·</span>
|
||||
<span>{relativeTime(commit.committedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Create branch dialog ──────────────────────────────────── */
|
||||
|
||||
function CreateBranchForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-[var(--color-border-subtle)] px-3 py-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 rounded border border-[var(--color-border)] bg-[var(--color-surface-0)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmit(name.trim());
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
placeholder="Branch name…"
|
||||
value={name}
|
||||
/>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-status-success)] transition-colors duration-100 hover:bg-[var(--color-status-success)]/10 disabled:opacity-40"
|
||||
disabled={!name.trim()}
|
||||
onClick={() => onSubmit(name.trim())}
|
||||
type="button"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main export ───────────────────────────────────────────── */
|
||||
|
||||
interface GitPanelProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function GitPanel({ projectId }: GitPanelProps) {
|
||||
const api = getElectronApi();
|
||||
|
||||
const [details, setDetails] = useState<ProjectGitDetails>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showChanges, setShowChanges] = useState(true);
|
||||
const [showBranches, setShowBranches] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showCreateBranch, setShowCreateBranch] = useState(false);
|
||||
const [switchingBranch, setSwitchingBranch] = useState<string>();
|
||||
const [operationError, setOperationError] = useState<string>();
|
||||
const [networkBusy, setNetworkBusy] = useState<'push' | 'pull' | 'fetch'>();
|
||||
|
||||
const loadDetails = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
|
||||
try {
|
||||
const result = await api.getProjectGitDetails({ projectId });
|
||||
setDetails(result);
|
||||
setOperationError(undefined);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [api, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDetails();
|
||||
}, [loadDetails]);
|
||||
|
||||
const ctx = details?.context;
|
||||
const workingTree = details?.workingTree;
|
||||
const branches = details?.branches ?? [];
|
||||
const commits = details?.recentCommits ?? [];
|
||||
const changedFiles = workingTree?.files ?? [];
|
||||
|
||||
const handlePush = useCallback(async () => {
|
||||
setNetworkBusy('push');
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.pushProjectGit({ projectId });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setNetworkBusy(undefined);
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handlePull = useCallback(async () => {
|
||||
setNetworkBusy('pull');
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.pullProjectGit({ projectId });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setNetworkBusy(undefined);
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handleFetch = useCallback(async () => {
|
||||
setNetworkBusy('fetch');
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.fetchProjectGit({ projectId });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setNetworkBusy(undefined);
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handleSwitchBranch = useCallback(async (name: string) => {
|
||||
setSwitchingBranch(name);
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.switchProjectGitBranch({ projectId, name });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSwitchingBranch(undefined);
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handleDeleteBranch = useCallback(async (name: string) => {
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.deleteProjectGitBranch({ projectId, name });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handleCreateBranch = useCallback(async (name: string) => {
|
||||
setShowCreateBranch(false);
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.createProjectGitBranch({ projectId, name, checkout: true });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-4 animate-spin text-[var(--color-text-muted)]" aria-label="Loading git status" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ctx || ctx.status !== 'ready') {
|
||||
return (
|
||||
<div className="px-3 py-4 text-center text-[11px] text-[var(--color-text-muted)]">
|
||||
{ctx?.status === 'not-repository'
|
||||
? 'Not a git repository'
|
||||
: ctx?.status === 'git-missing'
|
||||
? 'Git is not installed'
|
||||
: ctx?.errorMessage ?? 'Unable to read git status'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Branch header + network actions */}
|
||||
<div className="border-b border-[var(--color-border-subtle)] px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitBranch className="size-3 text-[var(--color-accent-sky)]" />
|
||||
<span className="font-mono text-[11px] font-medium text-[var(--color-text-primary)]">
|
||||
{ctx.branch ?? 'detached HEAD'}
|
||||
</span>
|
||||
|
||||
{ctx.upstream && (
|
||||
<span className="text-[9px] text-[var(--color-text-muted)]">
|
||||
{ctx.ahead !== undefined && ctx.ahead > 0 && <span className="text-[var(--color-status-success)]">↑{ctx.ahead}</span>}
|
||||
{ctx.behind !== undefined && ctx.behind > 0 && <span className="ml-0.5 text-[var(--color-status-warning)]">↓{ctx.behind}</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Network buttons */}
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
|
||||
disabled={!!networkBusy}
|
||||
onClick={() => void handleFetch()}
|
||||
title="Fetch"
|
||||
type="button"
|
||||
>
|
||||
{networkBusy === 'fetch' ? <Loader2 className="size-3 animate-spin" /> : <Cloud className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
|
||||
disabled={!!networkBusy}
|
||||
onClick={() => void handlePull()}
|
||||
title="Pull"
|
||||
type="button"
|
||||
>
|
||||
{networkBusy === 'pull' ? <Loader2 className="size-3 animate-spin" /> : <ArrowDownToLine className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
|
||||
disabled={!!networkBusy}
|
||||
onClick={() => void handlePush()}
|
||||
title="Push"
|
||||
type="button"
|
||||
>
|
||||
{networkBusy === 'push' ? <Loader2 className="size-3 animate-spin" /> : <ArrowUpFromLine className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => void loadDetails(true)}
|
||||
title="Refresh"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className={`size-3 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ctx.isDirty && (
|
||||
<div className="mt-1 text-[9px] text-[var(--color-status-warning)]">
|
||||
{ctx.changedFileCount} uncommitted {ctx.changedFileCount === 1 ? 'change' : 'changes'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{operationError && (
|
||||
<div className="flex items-start gap-1.5 border-b border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-3 py-1.5 text-[9px] text-[var(--color-status-error)]" role="alert">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
<span className="min-w-0 flex-1">{operationError}</span>
|
||||
<button
|
||||
className="shrink-0 rounded p-0.5 transition-colors duration-100 hover:bg-[var(--color-status-error)]/10"
|
||||
onClick={() => setOperationError(undefined)}
|
||||
type="button"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changed files */}
|
||||
<SectionHeader
|
||||
count={changedFiles.length}
|
||||
expanded={showChanges}
|
||||
icon={<FileCode2 className="size-3 text-[var(--color-accent-sky)]" />}
|
||||
label="Changes"
|
||||
onToggle={() => setShowChanges(!showChanges)}
|
||||
/>
|
||||
{showChanges && (
|
||||
<div>
|
||||
{changedFiles.length === 0 ? (
|
||||
<p className="px-3 py-2 text-[10px] text-[var(--color-text-muted)]">Working tree clean</p>
|
||||
) : (
|
||||
changedFiles.map((file) => (
|
||||
<ChangedFileEntry
|
||||
file={file}
|
||||
key={file.path}
|
||||
projectId={projectId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branches */}
|
||||
<SectionHeader
|
||||
count={branches.length}
|
||||
expanded={showBranches}
|
||||
icon={<GitBranch className="size-3 text-[var(--color-status-success)]" />}
|
||||
label="Branches"
|
||||
onToggle={() => setShowBranches(!showBranches)}
|
||||
/>
|
||||
{showBranches && (
|
||||
<div>
|
||||
{/* New branch button */}
|
||||
{!showCreateBranch && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-3 py-[5px] text-[10px] text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-2)]/40 hover:text-[var(--color-text-accent)]"
|
||||
onClick={() => setShowCreateBranch(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
New branch
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showCreateBranch && (
|
||||
<CreateBranchForm
|
||||
onCancel={() => setShowCreateBranch(false)}
|
||||
onSubmit={(name) => void handleCreateBranch(name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{branches.map((branch) => (
|
||||
<BranchRow
|
||||
branch={branch}
|
||||
isSwitching={switchingBranch === branch.name}
|
||||
key={branch.name}
|
||||
onDelete={() => void handleDeleteBranch(branch.name)}
|
||||
onSwitch={() => void handleSwitchBranch(branch.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit history */}
|
||||
<SectionHeader
|
||||
count={commits.length}
|
||||
expanded={showHistory}
|
||||
icon={<History className="size-3 text-[var(--color-text-muted)]" />}
|
||||
label="Recent Commits"
|
||||
onToggle={() => setShowHistory(!showHistory)}
|
||||
/>
|
||||
{showHistory && (
|
||||
<div>
|
||||
{commits.length === 0 ? (
|
||||
<p className="px-3 py-2 text-[10px] text-[var(--color-text-muted)]">No commit history</p>
|
||||
) : (
|
||||
commits.map((commit) => (
|
||||
<CommitRow commit={commit} key={commit.hash} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,9 +26,10 @@ import {
|
||||
type CollapsedTimelineEvent,
|
||||
} from '@renderer/lib/runTimelineFormatting';
|
||||
import type { OrchestrationMode } from '@shared/domain/pattern';
|
||||
import type { ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
|
||||
import type { ProjectGitFileReference, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
|
||||
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
||||
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
|
||||
|
||||
/* ── Mode accent colours (shared with ActivityPanel) ───────── */
|
||||
|
||||
@@ -271,11 +272,17 @@ function RunCard({
|
||||
expanded,
|
||||
onToggle,
|
||||
onJumpToMessage,
|
||||
sessionId,
|
||||
onDiscard,
|
||||
onOpenCommitComposer,
|
||||
}: {
|
||||
run: SessionRunRecord;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
sessionId: string;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
}) {
|
||||
const accent = modeAccent[run.patternMode] ?? modeAccent.single;
|
||||
const statusStyle = runStatusStyles[run.status];
|
||||
@@ -352,6 +359,19 @@ function RunCard({
|
||||
Duration: {duration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post-run git changes */}
|
||||
{run.postRunGitSummary && run.status !== 'running' && onDiscard && (
|
||||
<div className="mt-2">
|
||||
<RunChangeSummaryCard
|
||||
onDiscard={onDiscard}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
runId={run.requestId}
|
||||
sessionId={sessionId}
|
||||
summary={run.postRunGitSummary}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -372,10 +392,13 @@ function EmptyTimeline() {
|
||||
|
||||
interface RunTimelineProps {
|
||||
runs: readonly SessionRunRecord[];
|
||||
sessionId: string;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
}
|
||||
|
||||
export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) {
|
||||
export function RunTimeline({ runs, sessionId, onJumpToMessage, onDiscard, onOpenCommitComposer }: RunTimelineProps) {
|
||||
const latestRunId = runs.length > 0 ? runs[0].id : undefined;
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | undefined>(latestRunId);
|
||||
|
||||
@@ -394,9 +417,12 @@ export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) {
|
||||
<RunCard
|
||||
expanded={expandedRunId === run.id}
|
||||
key={run.id}
|
||||
onDiscard={onDiscard}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
onToggle={() => setExpandedRunId(expandedRunId === run.id ? undefined : run.id)}
|
||||
run={run}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user