mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +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:
@@ -302,6 +302,8 @@ This lets the application treat tooling as reusable workspace capability while s
|
||||
|
||||
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
|
||||
|
||||
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the run timeline after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the activity panel and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
|
||||
|
||||
### Execution observability
|
||||
|
||||
The architecture treats execution as observable by design:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { CommitComposer } from '@renderer/components/chat/CommitComposer';
|
||||
import { AppShell } from '@renderer/components/AppShell';
|
||||
import { ActivityPanel } from '@renderer/components/ActivityPanel';
|
||||
import { ChatPane } from '@renderer/components/ChatPane';
|
||||
@@ -43,6 +44,7 @@ import { createDefaultToolApprovalPolicy } from '@shared/domain/approval';
|
||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import { applySessionModelConfig } from '@shared/domain/session';
|
||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
@@ -124,6 +126,9 @@ export default function App() {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
|
||||
// Commit composer state
|
||||
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
||||
|
||||
// Terminal state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalHeight, setTerminalHeight] = useState(
|
||||
@@ -487,6 +492,20 @@ export default function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDiscardRunChanges = useCallback(
|
||||
(sessionId: string, runId: string, files?: ProjectGitFileReference[]) =>
|
||||
api.discardSessionRunGitChanges({ sessionId, runId, files }),
|
||||
[api],
|
||||
);
|
||||
|
||||
const handleOpenCommitComposer = useCallback(() => {
|
||||
if (!selectedSession) return;
|
||||
setCommitComposerCtx({
|
||||
projectId: selectedSession.projectId,
|
||||
sessionId: selectedSession.id,
|
||||
});
|
||||
}, [selectedSession]);
|
||||
|
||||
const handleCreateScratchpad = useCallback(() => {
|
||||
if (!workspace) return;
|
||||
const singlePatterns = workspace.patterns
|
||||
@@ -632,7 +651,9 @@ export default function App() {
|
||||
detailPanel = (
|
||||
<ActivityPanel
|
||||
activity={activityForSession}
|
||||
onDiscard={handleDiscardRunChanges}
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onOpenCommitComposer={handleOpenCommitComposer}
|
||||
pattern={patternForSession}
|
||||
session={selectedSession}
|
||||
sessionRequestUsage={requestUsageForSession}
|
||||
@@ -887,6 +908,15 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commitComposerCtx && (
|
||||
<CommitComposer
|
||||
onClose={() => setCommitComposerCtx(undefined)}
|
||||
projectId={commitComposerCtx.projectId}
|
||||
runId={commitComposerCtx.runId}
|
||||
sessionId={commitComposerCtx.sessionId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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