Watch reviewed files for external edits so stale full diffs reload safely, while local drafts get explicit conflict actions instead of silent overwrites. Made-with: Cursor
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import React from 'react';
|
|
|
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
|
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
|
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
|
|
|
|
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
|
import type { FileChangeSummary } from '@shared/types/review';
|
|
|
|
const CONTENT_SOURCE_LABELS: Record<string, string> = {
|
|
'file-history': 'File History',
|
|
'snippet-reconstruction': 'Reconstructed',
|
|
'disk-current': 'Current Disk',
|
|
'git-fallback': 'Git Fallback',
|
|
unavailable: 'Missing on disk',
|
|
};
|
|
|
|
interface FileSectionHeaderProps {
|
|
file: FileChangeSummary;
|
|
fileContent: FileChangeWithContent | null;
|
|
fileDecision: HunkDecision | undefined;
|
|
externalChange?: { type: 'change' | 'add' | 'unlink' };
|
|
pathChangeLabel?:
|
|
| { kind: 'deleted' }
|
|
| { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string };
|
|
hasEdits: boolean;
|
|
applying: boolean;
|
|
isCollapsed: boolean;
|
|
onToggleCollapse: (filePath: string) => void;
|
|
onDiscard: (filePath: string) => void;
|
|
onSave: (filePath: string) => void;
|
|
onReloadFromDisk?: (filePath: string) => void;
|
|
onKeepDraft?: (filePath: string) => void;
|
|
onRestoreMissingFile?: (filePath: string, content: string) => void;
|
|
onAcceptFile?: (filePath: string) => void;
|
|
onRejectFile?: (filePath: string) => void;
|
|
}
|
|
|
|
export const FileSectionHeader = ({
|
|
file,
|
|
fileContent,
|
|
fileDecision,
|
|
externalChange,
|
|
pathChangeLabel,
|
|
hasEdits,
|
|
applying,
|
|
isCollapsed,
|
|
onToggleCollapse,
|
|
onDiscard,
|
|
onSave,
|
|
onReloadFromDisk,
|
|
onKeepDraft,
|
|
onRestoreMissingFile,
|
|
onAcceptFile,
|
|
onRejectFile,
|
|
}: FileSectionHeaderProps): React.ReactElement => {
|
|
const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
|
|
const isPreviewOnly = isMissingOnDisk || fileContent?.contentSource === 'unavailable';
|
|
const restoreContent =
|
|
fileContent?.modifiedFullContent ??
|
|
(() => {
|
|
const writeSnippets = file.snippets.filter(
|
|
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
|
|
);
|
|
if (writeSnippets.length === 0) return null;
|
|
return writeSnippets[writeSnippets.length - 1].newString;
|
|
})();
|
|
const canRestore = !!onRestoreMissingFile && isPreviewOnly && !hasEdits && restoreContent != null;
|
|
const externalChangeLabel =
|
|
externalChange?.type === 'unlink'
|
|
? 'Deleted on disk'
|
|
: externalChange?.type === 'add'
|
|
? 'Recreated on disk'
|
|
: externalChange?.type === 'change'
|
|
? 'Changed on disk'
|
|
: null;
|
|
|
|
const handleHeaderClick = (e: React.MouseEvent): void => {
|
|
// Don't collapse when clicking action buttons
|
|
if ((e.target as HTMLElement).closest('[data-no-collapse]')) return;
|
|
onToggleCollapse(file.filePath);
|
|
};
|
|
|
|
const handleHeaderKeyDown = (e: React.KeyboardEvent): void => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onToggleCollapse(file.filePath);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={handleHeaderClick}
|
|
onKeyDown={handleHeaderKeyDown}
|
|
className="hover:bg-surface-raised/50 sticky top-0 z-10 flex cursor-pointer select-none items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2"
|
|
>
|
|
<span className="flex shrink-0 items-center text-text-muted">
|
|
{isCollapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
|
|
</span>
|
|
<FileIcon fileName={file.relativePath} className="size-3.5" />
|
|
<span className="text-xs font-medium text-text">{file.relativePath}</span>
|
|
|
|
{file.isNewFile && (
|
|
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
|
|
NEW
|
|
</span>
|
|
)}
|
|
|
|
{pathChangeLabel?.kind === 'deleted' && (
|
|
<span className="rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-300">
|
|
DELETED
|
|
</span>
|
|
)}
|
|
|
|
{pathChangeLabel && pathChangeLabel.kind !== 'deleted' && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="rounded bg-purple-500/20 px-1.5 py-0.5 text-[10px] text-purple-300">
|
|
{pathChangeLabel.kind.toUpperCase()}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-xs">
|
|
{pathChangeLabel.direction === 'from' ? 'From' : 'To'} {pathChangeLabel.otherPath}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{fileContent?.contentSource && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span
|
|
className={[
|
|
'rounded px-1.5 py-0.5 text-[10px]',
|
|
isPreviewOnly ? 'bg-red-500/20 text-red-300' : 'bg-surface-raised text-text-muted',
|
|
].join(' ')}
|
|
>
|
|
{isPreviewOnly
|
|
? 'Missing on disk'
|
|
: (CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource)}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-xs">
|
|
{isPreviewOnly ? (
|
|
<div className="space-y-1">
|
|
<div className="font-medium text-text">File is missing on disk</div>
|
|
<div className="text-text-muted">
|
|
We can still show a preview from agent logs, but your filesystem is out of sync.
|
|
</div>
|
|
{restoreContent != null ? (
|
|
<div className="text-text-muted">
|
|
Use <span className="font-medium text-text">Restore</span> to write the preview
|
|
content back to disk.
|
|
</div>
|
|
) : (
|
|
<div className="text-text-muted">
|
|
Full file content is not available to restore automatically.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span>
|
|
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
|
</span>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{fileDecision && (
|
|
<span
|
|
className={`rounded px-1.5 py-0.5 text-[10px] ${
|
|
fileDecision === 'accepted'
|
|
? 'bg-green-500/20 text-green-400'
|
|
: fileDecision === 'rejected'
|
|
? 'bg-red-500/20 text-red-400'
|
|
: 'bg-zinc-500/20 text-zinc-400'
|
|
}`}
|
|
>
|
|
{fileDecision}
|
|
</span>
|
|
)}
|
|
|
|
{externalChangeLabel && (
|
|
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">
|
|
{externalChangeLabel}
|
|
</span>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
|
|
{externalChange && onReloadFromDisk && onKeepDraft && (
|
|
<div className="mr-1 flex items-center gap-1.5">
|
|
<button
|
|
onClick={() => onReloadFromDisk(file.filePath)}
|
|
disabled={applying}
|
|
className="rounded bg-blue-500/15 px-2 py-1 text-xs font-medium text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
|
|
>
|
|
Reload from disk
|
|
</button>
|
|
<button
|
|
onClick={() => onKeepDraft(file.filePath)}
|
|
disabled={applying}
|
|
className="rounded bg-amber-500/15 px-2 py-1 text-xs font-medium text-amber-300 transition-colors hover:bg-amber-500/25 disabled:opacity-50"
|
|
>
|
|
Keep my draft
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{(onAcceptFile || onRejectFile) && (
|
|
<div className="mr-1 flex items-center gap-1.5">
|
|
{onAcceptFile && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<button
|
|
onClick={() => onAcceptFile(file.filePath)}
|
|
disabled={applying || isPreviewOnly}
|
|
className={[
|
|
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',
|
|
fileDecision === 'accepted'
|
|
? 'bg-green-500/25 text-green-300'
|
|
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25',
|
|
].join(' ')}
|
|
>
|
|
Accept
|
|
</button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{isPreviewOnly && (
|
|
<TooltipContent side="bottom">
|
|
Accept/Reject is disabled while the file is missing on disk.
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
)}
|
|
{onRejectFile && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<button
|
|
onClick={() => onRejectFile(file.filePath)}
|
|
disabled={applying || isPreviewOnly}
|
|
className={[
|
|
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',
|
|
fileDecision === 'rejected'
|
|
? 'bg-red-500/25 text-red-300'
|
|
: 'bg-red-500/15 text-red-400 hover:bg-red-500/25',
|
|
].join(' ')}
|
|
>
|
|
Reject
|
|
</button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{isPreviewOnly && (
|
|
<TooltipContent side="bottom">
|
|
Accept/Reject is disabled while the file is missing on disk.
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
)}
|
|
{canRestore && restoreContent != null && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => onRestoreMissingFile?.(file.filePath, restoreContent)}
|
|
disabled={applying}
|
|
className="flex items-center gap-1 rounded bg-blue-500/15 px-2 py-1 text-xs text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
|
|
>
|
|
<FilePlus className="size-3" />
|
|
Restore
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
Create/restore this file on disk from the preview
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{hasEdits && (
|
|
<>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => onDiscard(file.filePath)}
|
|
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
|
|
>
|
|
<Undo2 className="size-3" />
|
|
Discard
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Discard all edits for this file</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => onSave(file.filePath)}
|
|
disabled={applying}
|
|
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
|
|
>
|
|
{applying ? (
|
|
<Loader2 className="size-3 animate-spin" />
|
|
) : (
|
|
<Save className="size-3" />
|
|
)}
|
|
Save File
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<span>Save file to disk</span>
|
|
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
|
{shortcutLabel('⌘ S', 'Ctrl+S')}
|
|
</kbd>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|