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; onUnstage: (files: string[]) => Promise; onCommit: (message: string, files?: string[]) => Promise; onDiscard: (files: GitDiscardFileInfo[]) => Promise; } 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 = { added: 'A', modified: 'M', deleted: 'D', renamed: 'R', untracked: '?', }; const CHANGE_TYPE_COLORS: Record = { 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 (

{isUntracked ? 'Permanently delete file?' : 'Discard changes?'}

{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.`}

); } 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(null); const [highlighting, setHighlighting] = useState(false); const highlightRef = useRef(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 (
  • {/* Write affordances — Uncommitted mode only */} {uncommitted && (
    {/* Stage / Unstage toggle */} {file.change_type !== 'deleted' && ( )} {/* Discard — separated secondary affordance */}
    )}
    {expanded && (
    {file.is_binary && (

    Binary file

    )} {file.is_too_large && (

    Diff too large to display

    )} {file.change_type === 'untracked' && (

    Untracked — not yet staged

    )} {!file.is_binary && !file.is_too_large && file.diff_body && ( layout === 'split' ? ( ) : ( <> {highlighting && (

    Highlighting…

    )} {!highlighting && html !== null ? (
    ) : ( !highlighting && (
                          {file.diff_body}
                        
    ) )} {/* Comment button */}
    {fileComments.length > 0 && `${fileComments.length} comment${fileComments.length > 1 ? 's' : ''}`}
    {showEditor && ( setShowEditor(false)} /> )} ) )}
    )}
  • ); } 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(null); const [lastAction, setLastAction] = useState(null); const lastActionTimer = useRef | null>(null); const [expandedFiles, setExpandedFiles] = useState>(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 (
    Loading diff…
    ); } if (error) { return (

    {error}

    ); } if (!result || !result.git_repo) { return (
    Not a git repository
    ); } const { files, base_label } = result; return (
    {/* Mode selector */}
    {(loading || mutating) && ( {mutating ? 'Working…' : 'Refreshing…'} )} {lastAction && !mutating && ( {lastAction} )}
    {/* Diff toolbar */}
    {/* Committed-mode base label */} {result.mode === 'committed' && base_label && (
    vs {base_label}
    )} {/* FIX 2: Fallback label — committed was requested but no base branch found */} {result.mode === 'uncommitted' && result.base_label && (
    {result.base_label}
    )} {/* FIX 4: Mode suggestion — shown when pinned mode diverges from auto-selected mode */} {modeSuggestion && (
    Repo is now {modeSuggestion === 'uncommitted' ? 'dirty' : 'clean'} —
    )} {/* In-progress op banner */} {inProgress && (
    {inProgress} in progress — write actions disabled
    )} {/* Mutation error */} {mutateError && (
    {mutateError}
    )} {/* File list */}
    {files.length === 0 ? (
    {mode === 'uncommitted' ? 'No uncommitted changes' : 'No changes vs. the base branch'} {/* FIX 5: hint when pending changes exist in the Coder pane */} {!!pendingCount && ( {pendingCount} pending {pendingCount === 1 ? 'change' : 'changes'} visible in the Coder pane )}
    ) : (
      {files.map((file) => ( ))}
    )}
    {/* Commit panel — Uncommitted mode only */} {uncommitted && (