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:
493
apps/web/src/components/GitDiffView.tsx
Normal file
493
apps/web/src/components/GitDiffView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
|
||||
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
|
||||
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -437,6 +438,7 @@ function usePendingChanges(sessionId: string) {
|
||||
});
|
||||
if (res.ok) {
|
||||
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'approved' } : c));
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -446,6 +448,7 @@ function usePendingChanges(sessionId: string) {
|
||||
});
|
||||
if (res.ok) {
|
||||
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'rejected' } : c));
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user