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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user