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

@@ -6,7 +6,10 @@ import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { useProjectGit } from '@/hooks/useProjectGit';
import { useGitDiff } from '@/hooks/useGitDiff';
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { GitDiffView } from '@/components/GitDiffView';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
@@ -21,6 +24,8 @@ import {
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
type RailTab = 'files' | 'git';
interface Props {
projectId: string;
sessionId: string;
@@ -45,12 +50,38 @@ export function RightRail({ projectId, sessionId }: Props) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
});
const [tab, setTab] = useState<RailTab>('files');
const [filter, setFilter] = useState('');
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
// Git metadata: dirty dot on the Git tab (no new fetch — reuses the 30s poll).
const git = useProjectGit(projectId);
const isDirty = git?.is_dirty ?? false;
// Git diff view state (Phase 2: includes write callbacks).
const { result: gitDiff, loading: gitLoading, error: gitError, mode: gitMode, selectMode, refresh: refreshDiff, mutating: gitMutating, mutateError: gitMutateError, stage: gitStage, unstage: gitUnstage, commit: gitCommit, discard: gitDiscard, modeSuggestion: gitModeSuggestion } = useGitDiff(projectId);
const showGitTab = gitDiff === null || gitDiff.git_repo;
// FIX 5: pending-changes count — fetched when git tab is active so the empty state
// can hint that unapplied pending changes exist in the Coder pane.
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
if (tab !== 'git') return;
const check = () => {
fetch(`/api/coder/sessions/${sessionId}/pending`)
.then((r) => r.ok ? r.json() as Promise<Array<{ status: string }>> : [])
.then((data) => setPendingCount(data.filter((c) => c.status === 'pending').length))
.catch(() => {});
};
check();
return sessionEvents.subscribe((e) => {
if (e.type === 'git_diff_refresh') check();
});
}, [tab, sessionId]);
// New-file-from-pasted-text modal. Queues a pending_changes create via
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
const [newFileOpen, setNewFileOpen] = useState(false);
@@ -167,6 +198,11 @@ export function RightRail({ projectId, sessionId }: Props) {
return [];
}, [filterActive, trimmed, fullFileList]);
// Trigger a git diff refresh whenever the Git tab becomes active.
useEffect(() => {
if (tab === 'git') sessionEvents.emit({ type: 'git_diff_refresh' });
}, [tab]);
// Listen for open_file_in_browser events
useEffect(() => {
return sessionEvents.subscribe((event) => {
@@ -206,17 +242,45 @@ export function RightRail({ projectId, sessionId }: Props) {
return (
<>
<aside className={asideCls}>
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<span className="text-xs font-medium flex-1">Files</span>
{/* Header: Files / Git tab strip, FilePlus (Files only), close */}
<div className="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
<button
type="button"
onClick={openNewFile}
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New file from pasted text"
title="New file"
onClick={() => setTab('files')}
className={cn(
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
tab === 'files' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground',
)}
>
<FilePlus size={14} />
Files
</button>
{showGitTab && (
<button
type="button"
onClick={() => setTab('git')}
className={cn(
'relative text-xs px-2 py-0.5 rounded max-md:min-h-[44px] flex items-center gap-1',
tab === 'git' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground',
)}
>
Git
{isDirty && (
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400 shrink-0" aria-label="dirty" />
)}
</button>
)}
<div className="flex-1" />
{tab === 'files' && (
<button
type="button"
onClick={openNewFile}
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New file from pasted text"
title="New file"
>
<FilePlus size={14} />
</button>
)}
<button
type="button"
onClick={closeRail}
@@ -226,47 +290,73 @@ export function RightRail({ projectId, sessionId }: Props) {
<PanelRightClose size={14} />
</button>
</div>
<div className="px-2 py-1.5 shrink-0">
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter files..."
className="h-7 text-xs"
{/* Files tab content */}
{tab === 'files' && (
<>
<div className="px-2 py-1.5 shrink-0">
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter files..."
className="h-7 text-xs"
/>
</div>
<div className="flex-1 overflow-y-auto px-1 py-1">
{filterActive ? (
filterResults.length > 0 ? (
<ul className="list-none space-y-0.5">
{filterResults.map((r) => (
<li key={r.path}>
<button
type="button"
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
onClick={() => void openFile(r.path)}
>
<FileText size={12} className="text-muted-foreground shrink-0" />
<span className="font-bold truncate">{r.name}</span>
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
</button>
</li>
))}
</ul>
) : (
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
)
) : (
<TreeLevel
parentPath=""
entries={rootEntries}
cache={cache}
expanded={expandedDirs}
depth={0}
onToggleDir={toggleDir}
onSelectFile={(path) => void openFile(path)}
/>
)}
</div>
</>
)}
{/* Git tab content */}
{tab === 'git' && (
<GitDiffView
result={gitDiff}
loading={gitLoading}
error={gitError}
mode={gitMode}
onSelectMode={selectMode}
onRefresh={refreshDiff}
mutating={gitMutating}
mutateError={gitMutateError}
onStage={gitStage}
onUnstage={gitUnstage}
onCommit={gitCommit}
onDiscard={gitDiscard}
modeSuggestion={gitModeSuggestion}
pendingCount={pendingCount}
/>
</div>
<div className="flex-1 overflow-y-auto px-1 py-1">
{filterActive ? (
filterResults.length > 0 ? (
<ul className="list-none space-y-0.5">
{filterResults.map((r) => (
<li key={r.path}>
<button
type="button"
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
onClick={() => void openFile(r.path)}
>
<FileText size={12} className="text-muted-foreground shrink-0" />
<span className="font-bold truncate">{r.name}</span>
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
</button>
</li>
))}
</ul>
) : (
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
)
) : (
<TreeLevel
parentPath=""
entries={rootEntries}
cache={cache}
expanded={expandedDirs}
depth={0}
onToggleDir={toggleDir}
onSelectFile={(path) => void openFile(path)}
/>
)}
</div>
)}
</aside>
{viewerFile && (