Adds DiffSplitView component for side-by-side diff mode, whitespace-only change filtering, inline review comments with thread/gutter cell UI, diff preferences persistence, and write-file API support for in-browser editing. Backend: hideWhitespace param on git diff endpoint, write_file route.
680 lines
24 KiB
TypeScript
680 lines
24 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { ChevronDown, ChevronRight, Columns2, GitBranch, ListChevronsDownUp, ListChevronsUpDown, AlignJustify, Pilcrow, RefreshCw, Trash2, WrapText } from 'lucide-react';
|
||
import { codeToHtml } from 'shiki';
|
||
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||
import { cn } from '@/lib/utils';
|
||
import { DiffSplitView } from './DiffSplitView';
|
||
import { InlineReviewGutterCell } from './InlineReviewGutterCell';
|
||
import { InlineReviewEditor } from './InlineReviewEditor';
|
||
import { InlineReviewThread } from './InlineReviewThread';
|
||
import { useDiffComments } from '@/stores/useDiffCommentStore';
|
||
|
||
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;
|
||
sessionId?: string;
|
||
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;
|
||
layout: 'unified' | 'split';
|
||
wrapLines: boolean;
|
||
hideWhitespace: boolean;
|
||
onLayoutChange: (layout: 'unified' | 'split') => void;
|
||
onWrapLinesChange: (wrap: boolean) => void;
|
||
onHideWhitespaceChange: (hide: boolean) => void;
|
||
}
|
||
|
||
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,
|
||
layout,
|
||
wrapLines,
|
||
expanded,
|
||
onToggleExpand,
|
||
sessionId,
|
||
diffMode,
|
||
}: {
|
||
file: GitDiffFile;
|
||
uncommitted: boolean;
|
||
disabled: boolean;
|
||
onStage: (path: string) => void;
|
||
onUnstage: (path: string) => void;
|
||
onDiscardRequest: (file: GitDiffFile) => void;
|
||
layout: 'unified' | 'split';
|
||
wrapLines: boolean;
|
||
expanded: boolean;
|
||
onToggleExpand: (path: string) => void;
|
||
sessionId?: string;
|
||
diffMode?: string;
|
||
}) {
|
||
const [html, setHtml] = useState<string | null>(null);
|
||
const [highlighting, setHighlighting] = useState(false);
|
||
const highlightRef = useRef<HTMLDivElement | null>(null);
|
||
const [showEditor, setShowEditor] = useState(false);
|
||
const commentKey = `${file.path}:${file.change_type}`;
|
||
const diffModeVal = diffMode ?? '';
|
||
const { comments, addComment, updateComment, deleteComment } = useDiffComments(sessionId ?? '', diffModeVal);
|
||
const fileComments = comments.get(commentKey) ?? [];
|
||
|
||
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;
|
||
|
||
const handleAddComment = (body: string) => {
|
||
const comment = { id: crypto.randomUUID(), body, createdAt: Date.now(), updatedAt: Date.now() };
|
||
addComment(commentKey, comment);
|
||
setShowEditor(false);
|
||
};
|
||
|
||
const handleEditComment = (id: string, body: string) => {
|
||
updateComment(commentKey, id, body);
|
||
};
|
||
|
||
const handleDeleteComment = (id: string) => {
|
||
deleteComment(commentKey, id);
|
||
};
|
||
|
||
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={() => onToggleExpand(file.path)}
|
||
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 && (
|
||
layout === 'split' ? (
|
||
<DiffSplitView file={file} wrapLines={wrapLines} />
|
||
) : (
|
||
<>
|
||
{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={cn(
|
||
'text-[11px] overflow-x-auto rounded bg-muted/30 p-2',
|
||
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||
)}>
|
||
{file.diff_body}
|
||
</pre>
|
||
)
|
||
)}
|
||
{/* Comment button */}
|
||
<div className="flex items-center gap-1 mt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowEditor(!showEditor)}
|
||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5 px-1 py-0.5 rounded hover:bg-muted/40"
|
||
>
|
||
<span>{showEditor ? 'Cancel' : 'Comment'}</span>
|
||
</button>
|
||
<span className="text-[10px] text-muted-foreground/50">
|
||
{fileComments.length > 0 && `${fileComments.length} comment${fileComments.length > 1 ? 's' : ''}`}
|
||
</span>
|
||
</div>
|
||
{showEditor && (
|
||
<InlineReviewEditor
|
||
onSave={handleAddComment}
|
||
onCancel={() => setShowEditor(false)}
|
||
/>
|
||
)}
|
||
<InlineReviewThread
|
||
comments={fileComments}
|
||
onEditComment={handleEditComment}
|
||
onDeleteComment={handleDeleteComment}
|
||
/>
|
||
</>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
export function GitDiffView({
|
||
result,
|
||
loading,
|
||
error,
|
||
mode,
|
||
onSelectMode,
|
||
onRefresh,
|
||
mutating,
|
||
mutateError,
|
||
onStage,
|
||
onUnstage,
|
||
onCommit,
|
||
onDiscard,
|
||
modeSuggestion,
|
||
pendingCount,
|
||
layout,
|
||
wrapLines,
|
||
hideWhitespace,
|
||
onLayoutChange,
|
||
onWrapLinesChange,
|
||
onHideWhitespaceChange,
|
||
sessionId,
|
||
}: 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);
|
||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||
|
||
const allExpandedComputed = useMemo(
|
||
() => result !== null && (result.files?.length ?? 0) > 0 && result.files.every((f) => expandedFiles.has(f.path)),
|
||
[result, expandedFiles],
|
||
);
|
||
|
||
const handleExpandAllChange = useCallback((expand: boolean) => {
|
||
if (expand && result?.files) {
|
||
setExpandedFiles(new Set(result.files.map((f) => f.path)));
|
||
} else {
|
||
setExpandedFiles(new Set());
|
||
}
|
||
}, [result?.files]);
|
||
|
||
const handleToggleExpand = useCallback((path: string) => {
|
||
setExpandedFiles((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(path)) next.delete(path);
|
||
else next.add(path);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
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>
|
||
|
||
{/* Diff toolbar */}
|
||
<div className="flex items-center gap-1 px-2 py-1 border-b shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => onLayoutChange('unified')}
|
||
className={cn(
|
||
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
|
||
layout === 'unified'
|
||
? 'bg-muted text-foreground font-medium'
|
||
: 'text-muted-foreground hover:text-foreground',
|
||
)}
|
||
title="Unified diff"
|
||
>
|
||
<AlignJustify size={12} />
|
||
Unified
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onLayoutChange('split')}
|
||
className={cn(
|
||
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
|
||
layout === 'split'
|
||
? 'bg-muted text-foreground font-medium'
|
||
: 'text-muted-foreground hover:text-foreground',
|
||
)}
|
||
title="Split diff"
|
||
>
|
||
<Columns2 size={12} />
|
||
Split
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onHideWhitespaceChange(!hideWhitespace)}
|
||
className={cn(
|
||
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
|
||
hideWhitespace
|
||
? 'bg-muted text-foreground font-medium'
|
||
: 'text-muted-foreground hover:text-foreground',
|
||
)}
|
||
title={hideWhitespace ? 'Show whitespace' : 'Hide whitespace'}
|
||
>
|
||
<Pilcrow size={12} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onWrapLinesChange(!wrapLines)}
|
||
className={cn(
|
||
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
|
||
wrapLines
|
||
? 'bg-muted text-foreground font-medium'
|
||
: 'text-muted-foreground hover:text-foreground',
|
||
)}
|
||
title={wrapLines ? 'Unwrap lines' : 'Wrap lines'}
|
||
>
|
||
<WrapText size={12} />
|
||
</button>
|
||
<div className="flex-1" />
|
||
<button
|
||
type="button"
|
||
onClick={() => handleExpandAllChange(!allExpandedComputed)}
|
||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||
title={allExpandedComputed ? 'Collapse all' : 'Expand all'}
|
||
>
|
||
{allExpandedComputed ? <ListChevronsDownUp size={12} /> : <ListChevronsUpDown size={12} />}
|
||
</button>
|
||
<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}
|
||
layout={layout}
|
||
wrapLines={wrapLines}
|
||
expanded={expandedFiles.has(file.path)}
|
||
onToggleExpand={handleToggleExpand}
|
||
sessionId={sessionId}
|
||
diffMode={mode}
|
||
/>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|