Files
boocode/apps/web/src/components/GitDiffView.tsx
indifferentketchup 31d8efe66a feat(web): enhanced file panel — side-by-side diff, hide whitespace, inline review
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.
2026-06-07 22:16:20 +00:00

680 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}