feat: git diff panel (Files/Git tab in the file browser)

Adds a Git tab to the right-side file panel that shows the project
repository's diff and lets the user stage, unstage, commit, and discard
whole files in-session. Two comparison modes (Uncommitted vs HEAD, and the
branch vs its base — upstream tracking branch else default branch), auto-
selected by repo state on first open and pinned after explicit choice;
per-file expand/collapse with lazy syntax-highlighted diffs, +/- stats, and
binary/large-file placeholders. All git read and write logic lives in
apps/server via a new git_diff service: argv-safe execFile only (never a
shell), per-file paths validated repo-relative through pathGuard with a
realpath symlink-escape check, server-derived commit identity (the request
carries no author fields), and the write endpoints are deliberately absent
from the assistant tool registry. Reads are bounded (30s deadline, 10MB);
an index lock or an in-progress merge/rebase/cherry-pick/bisect surfaces as
"repository busy" and disables writes. The panel stays current via a client
git_diff_refresh session event (no new wire contract) coalesced across tab
open, mutations, turn completion, and pending-change apply. Discard is an
irrecoverable hard-delete behind a plain confirm that distinguishes
reverting a tracked file from deleting an untracked one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 03:18:41 +00:00
parent f32fd928b3
commit d8bb2dabfe
14 changed files with 2290 additions and 49 deletions

View File

@@ -0,0 +1,493 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react';
import { codeToHtml } from 'shiki';
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
import { cn } from '@/lib/utils';
interface WriteProps {
mutating: boolean;
mutateError: string | null;
onStage: (files: string[]) => Promise<boolean>;
onUnstage: (files: string[]) => Promise<boolean>;
onCommit: (message: string, files?: string[]) => Promise<boolean>;
onDiscard: (files: GitDiscardFileInfo[]) => Promise<boolean>;
}
interface Props extends WriteProps {
result: GitDiffResult | null;
loading: boolean;
error: string | null;
mode: GitDiffMode;
onSelectMode: (m: GitDiffMode) => void;
onRefresh: () => void;
/** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */
modeSuggestion?: GitDiffMode | null;
/** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */
pendingCount?: number;
}
const CHANGE_TYPE_LABELS: Record<string, string> = {
added: 'A',
modified: 'M',
deleted: 'D',
renamed: 'R',
untracked: '?',
};
const CHANGE_TYPE_COLORS: Record<string, string> = {
added: 'text-green-500',
modified: 'text-yellow-500',
deleted: 'text-red-500',
renamed: 'text-blue-500',
untracked: 'text-muted-foreground',
};
interface DiscardConfirmState {
file: GitDiffFile;
}
function DiscardConfirmDialog({
state,
onConfirm,
onCancel,
}: {
state: DiscardConfirmState;
onConfirm: () => void;
onCancel: () => void;
}) {
const isUntracked = state.file.change_type === 'untracked';
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
>
<div className="bg-popover border rounded-lg shadow-lg max-w-sm w-full p-4 flex flex-col gap-3">
<p className="text-sm font-medium">
{isUntracked ? 'Permanently delete file?' : 'Discard changes?'}
</p>
<p className="text-xs text-muted-foreground">
{isUntracked
? `${state.file.path} will be permanently deleted. This cannot be undone.`
: `Changes to ${state.file.path} will be reverted to the last commit. This cannot be undone.`}
</p>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onCancel}
className="text-xs px-3 py-1.5 rounded border hover:bg-muted max-md:min-h-[44px] max-md:min-w-[44px]"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="text-xs px-3 py-1.5 rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 max-md:min-h-[44px] max-md:min-w-[44px]"
>
{isUntracked ? 'Delete' : 'Discard'}
</button>
</div>
</div>
</div>
);
}
function FileDiffRow({
file,
uncommitted,
disabled,
onStage,
onUnstage,
onDiscardRequest,
}: {
file: GitDiffFile;
uncommitted: boolean;
disabled: boolean;
onStage: (path: string) => void;
onUnstage: (path: string) => void;
onDiscardRequest: (file: GitDiffFile) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [html, setHtml] = useState<string | null>(null);
const [highlighting, setHighlighting] = useState(false);
const highlightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!expanded || !file.diff_body) return;
if (html !== null) return;
let cancelled = false;
setHighlighting(true);
void codeToHtml(file.diff_body, { lang: 'diff', theme: 'github-dark' })
.then((result) => { if (!cancelled) setHtml(result); })
.catch(() => { if (!cancelled) setHtml(null); })
.finally(() => { if (!cancelled) setHighlighting(false); });
return () => { cancelled = true; };
}, [expanded, file.diff_body, html]);
useEffect(() => {
if (highlightRef.current && html !== null) {
// Shiki generates sanitized HTML — not user-supplied content.
// eslint-disable-next-line no-unsanitized/property
highlightRef.current.innerHTML = html;
}
}, [html]);
const typeLabel = CHANGE_TYPE_LABELS[file.change_type] ?? '?';
const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground';
const displayPath = file.old_path ? `${file.old_path}${file.path}` : file.path;
return (
<li className="border-b border-border/30 last:border-0">
<div className="flex items-center group">
<button
type="button"
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/40 text-left max-md:min-h-[44px] min-w-0"
onClick={() => setExpanded((p) => !p)}
aria-expanded={expanded}
>
{expanded
? <ChevronDown size={10} className="shrink-0 text-muted-foreground" />
: <ChevronRight size={10} className="shrink-0 text-muted-foreground" />}
<span className={cn('font-mono font-bold w-3 shrink-0', typeColor)}>{typeLabel}</span>
<span className="truncate flex-1">{displayPath}</span>
{(file.added_lines > 0 || file.removed_lines > 0) && (
<span className="shrink-0 text-muted-foreground/70 font-mono text-[10px]">
{file.added_lines > 0 && <span className="text-green-500">+{file.added_lines}</span>}
{file.added_lines > 0 && file.removed_lines > 0 && <span className="mx-0.5">/</span>}
{file.removed_lines > 0 && <span className="text-red-500">-{file.removed_lines}</span>}
</span>
)}
{file.staged && (
<span className="shrink-0 text-[10px] bg-blue-500/15 text-blue-400 px-1 rounded">staged</span>
)}
</button>
{/* Write affordances — Uncommitted mode only */}
{uncommitted && (
<div className="flex items-center gap-0.5 px-1 shrink-0">
{/* Stage / Unstage toggle */}
{file.change_type !== 'deleted' && (
<button
type="button"
disabled={disabled}
onClick={() => file.staged ? onUnstage(file.path) : onStage(file.path)}
className="text-[10px] px-1.5 py-0.5 rounded border border-border/50 hover:bg-muted disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
title={file.staged ? 'Unstage' : 'Stage'}
>
{file.staged ? '' : '+'}
</button>
)}
{/* Discard — separated secondary affordance */}
<button
type="button"
disabled={disabled}
onClick={() => onDiscardRequest(file)}
className="p-1 rounded hover:bg-destructive/15 hover:text-destructive text-muted-foreground/50 disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
title={file.change_type === 'untracked' ? 'Delete file' : 'Discard changes'}
>
<Trash2 size={10} />
</button>
</div>
)}
</div>
{expanded && (
<div className="px-2 pb-2">
{file.is_binary && (
<p className="text-xs text-muted-foreground italic px-2 py-1">Binary file</p>
)}
{file.is_too_large && (
<p className="text-xs text-muted-foreground italic px-2 py-1">Diff too large to display</p>
)}
{file.change_type === 'untracked' && (
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked not yet staged</p>
)}
{!file.is_binary && !file.is_too_large && file.diff_body && (
<>
{highlighting && (
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting</p>
)}
{!highlighting && html !== null ? (
<div
ref={highlightRef}
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
/>
) : (
!highlighting && (
<pre className="text-[11px] overflow-x-auto rounded bg-muted/30 p-2 whitespace-pre">
{file.diff_body}
</pre>
)
)}
</>
)}
</div>
)}
</li>
);
}
export function GitDiffView({
result,
loading,
error,
mode,
onSelectMode,
onRefresh,
mutating,
mutateError,
onStage,
onUnstage,
onCommit,
onDiscard,
modeSuggestion,
pendingCount,
}: Props) {
const [commitMessage, setCommitMessage] = useState('');
const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null);
const [lastAction, setLastAction] = useState<string | null>(null);
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function flashAction(msg: string) {
setLastAction(msg);
if (lastActionTimer.current) clearTimeout(lastActionTimer.current);
lastActionTimer.current = setTimeout(() => setLastAction(null), 2000);
}
const uncommitted = mode === 'uncommitted';
const inProgress = result?.in_progress_op ?? null;
const writeDisabled = mutating || !!inProgress;
const stagedFiles = result?.files.filter((f) => f.staged) ?? [];
const canDoCommit = uncommitted && stagedFiles.length > 0 && commitMessage.trim().length > 0 && !writeDisabled;
async function handleStage(path: string) {
const ok = await onStage([path]);
if (ok) flashAction('Staged');
}
async function handleUnstage(path: string) {
const ok = await onUnstage([path]);
if (ok) flashAction('Unstaged');
}
function handleDiscardRequest(file: GitDiffFile) {
setDiscardTarget({ file });
}
async function handleDiscardConfirm() {
if (!discardTarget) return;
const { file } = discardTarget;
setDiscardTarget(null);
const info: GitDiscardFileInfo = {
path: file.path,
change_type: file.change_type,
staged: file.staged,
};
const ok = await onDiscard([info]);
if (ok) flashAction(file.change_type === 'untracked' ? 'Deleted' : 'Discarded');
}
async function handleCommit() {
const msg = commitMessage.trim();
if (!msg) return;
const ok = await onCommit(msg);
if (ok) {
setCommitMessage('');
flashAction('Committed');
}
}
if (loading && !result) {
return (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Loading diff
</div>
);
}
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center">
<p className="text-xs text-destructive">{error}</p>
<button
type="button"
onClick={onRefresh}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 max-md:min-h-[44px]"
>
<RefreshCw size={12} />
Refresh
</button>
</div>
);
}
if (!result || !result.git_repo) {
return (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground px-4 text-center">
Not a git repository
</div>
);
}
const { files, base_label } = result;
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Mode selector */}
<div className="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
<button
type="button"
onClick={() => onSelectMode('uncommitted')}
className={cn(
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
mode === 'uncommitted'
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
>
Uncommitted
</button>
<button
type="button"
onClick={() => onSelectMode('committed')}
className={cn(
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
mode === 'committed'
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
>
Committed
</button>
<div className="flex-1" />
{(loading || mutating) && (
<span className="text-[10px] text-muted-foreground">{mutating ? 'Working…' : 'Refreshing…'}</span>
)}
{lastAction && !mutating && (
<span className="text-[10px] text-green-500">{lastAction}</span>
)}
<button
type="button"
onClick={onRefresh}
disabled={loading || mutating}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Refresh diff"
title="Refresh"
>
<RefreshCw size={12} />
</button>
</div>
{/* Committed-mode base label */}
{result.mode === 'committed' && base_label && (
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b flex items-center gap-1 shrink-0">
<GitBranch size={10} />
<span className="truncate">vs {base_label}</span>
</div>
)}
{/* FIX 2: Fallback label — committed was requested but no base branch found */}
{result.mode === 'uncommitted' && result.base_label && (
<div className="px-2 py-1 text-[10px] text-amber-600 dark:text-amber-400 border-b flex items-center gap-1 shrink-0">
<GitBranch size={10} />
<span className="truncate">{result.base_label}</span>
</div>
)}
{/* FIX 4: Mode suggestion — shown when pinned mode diverges from auto-selected mode */}
{modeSuggestion && (
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b shrink-0 flex items-center gap-1">
<span>Repo is now {modeSuggestion === 'uncommitted' ? 'dirty' : 'clean'} </span>
<button
type="button"
onClick={() => onSelectMode(modeSuggestion)}
className="underline hover:text-foreground"
>
switch to {modeSuggestion === 'uncommitted' ? 'Uncommitted' : 'Committed'}
</button>
</div>
)}
{/* In-progress op banner */}
{inProgress && (
<div className="px-2 py-1 text-[10px] text-yellow-500 bg-yellow-500/10 border-b shrink-0">
{inProgress} in progress write actions disabled
</div>
)}
{/* Mutation error */}
{mutateError && (
<div className="px-2 py-1 text-[10px] text-destructive bg-destructive/10 border-b shrink-0 truncate">
{mutateError}
</div>
)}
{/* File list */}
<div className="flex-1 overflow-y-auto">
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-8 text-xs text-muted-foreground text-center gap-1.5">
<span>{mode === 'uncommitted' ? 'No uncommitted changes' : 'No changes vs. the base branch'}</span>
{/* FIX 5: hint when pending changes exist in the Coder pane */}
{!!pendingCount && (
<span className="text-[10px]">
{pendingCount} pending {pendingCount === 1 ? 'change' : 'changes'} visible in the Coder pane
</span>
)}
</div>
) : (
<ul className="list-none">
{files.map((file) => (
<FileDiffRow
key={file.path}
file={file}
uncommitted={uncommitted}
disabled={writeDisabled}
onStage={handleStage}
onUnstage={handleUnstage}
onDiscardRequest={handleDiscardRequest}
/>
))}
</ul>
)}
</div>
{/* Commit panel — Uncommitted mode only */}
{uncommitted && (
<div className="shrink-0 border-t px-2 py-2 flex flex-col gap-1.5">
<textarea
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
disabled={writeDisabled}
placeholder="Commit message…"
rows={2}
className="w-full text-xs rounded border bg-background px-2 py-1 resize-none focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-40 placeholder:text-muted-foreground"
/>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground flex-1">
{stagedFiles.length > 0
? `${stagedFiles.length} file${stagedFiles.length > 1 ? 's' : ''} staged`
: 'No files staged'}
</span>
<button
type="button"
disabled={!canDoCommit}
onClick={handleCommit}
className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 max-md:min-h-[44px]"
>
Commit
</button>
</div>
</div>
)}
{/* Discard confirmation dialog */}
{discardTarget && (
<DiscardConfirmDialog
state={discardTarget}
onConfirm={handleDiscardConfirm}
onCancel={() => setDiscardTarget(null)}
/>
)}
</div>
);
}