feat: add file-level accept/reject and diff tree filters

Add Accept/Reject controls to each file header and add search + filters
(unresolved/rejected/new) to the diff file tree.

Made-with: Cursor
This commit is contained in:
iliya 2026-03-04 12:35:03 +02:00
parent b162cb1854
commit f654c5f356
4 changed files with 206 additions and 66 deletions

View file

@ -122,6 +122,7 @@ export const ChangeReviewDialog = ({
const lastHunkActionAtRef = useRef<Record<string, number>>({}); const lastHunkActionAtRef = useRef<Record<string, number>>({});
const hunkDecisionUndoStackRef = useRef<Record<string, number[]>>({}); const hunkDecisionUndoStackRef = useRef<Record<string, number[]>>({});
const newFileApplyInFlightRef = useRef(new Set<string>()); const newFileApplyInFlightRef = useRef(new Set<string>());
const lastFileActionAtRef = useRef<number>(0);
const removedNewFileUndoStackRef = useRef< const removedNewFileUndoStackRef = useRef<
{ file: FileChangeSummary; index: number; restoreContent: string; removedAt: number }[] { file: FileChangeSummary; index: number; restoreContent: string; removedAt: number }[]
>([]); >([]);
@ -237,9 +238,10 @@ export const ChangeReviewDialog = ({
memberName, memberName,
]); ]);
// Per-new-file accept/reject (Cursor-style) // File-level accept/reject (Cursor-style)
const handleAcceptNewFile = useCallback( const handleAcceptFile = useCallback(
(filePath: string) => { (filePath: string) => {
lastFileActionAtRef.current = Date.now();
acceptAllFile(filePath); acceptAllFile(filePath);
const view = editorViewMapRef.current.get(filePath); const view = editorViewMapRef.current.get(filePath);
if (view) { if (view) {
@ -249,46 +251,52 @@ export const ChangeReviewDialog = ({
[acceptAllFile] [acceptAllFile]
); );
const handleRejectNewFile = useCallback( const handleRejectFile = useCallback(
async (filePath: string) => { async (filePath: string) => {
if (newFileApplyInFlightRef.current.has(filePath)) return; if (newFileApplyInFlightRef.current.has(filePath)) return;
newFileApplyInFlightRef.current.add(filePath); newFileApplyInFlightRef.current.add(filePath);
try { try {
const file = activeChangeSet?.files.find((f) => f.filePath === filePath);
const isNew = file?.isNewFile ?? false;
// Mark rejected in store + update CM view immediately for feedback // Mark rejected in store + update CM view immediately for feedback
lastFileActionAtRef.current = Date.now();
rejectAllFile(filePath); rejectAllFile(filePath);
const view = editorViewMapRef.current.get(filePath); const view = editorViewMapRef.current.get(filePath);
if (view) { if (view) {
requestAnimationFrame(() => rejectAllChunks(view)); requestAnimationFrame(() => rejectAllChunks(view));
} }
// Always apply immediately: rejecting a NEW file means deleting it from disk. if (REVIEW_INSTANT_APPLY) {
const file = activeChangeSet?.files.find((f) => f.filePath === filePath); // Reject a whole file should apply immediately (restore original on disk),
const isNew = file?.isNewFile ?? false; // and NEW-file reject should delete it.
if (!isNew) return; const result = await applySingleFileDecision(teamName, filePath, taskId, memberName);
const result = await applySingleFileDecision(teamName, filePath, taskId, memberName); if (isNew) {
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath); const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
if (result && !hasErrorForFile && file) { if (result && !hasErrorForFile && file) {
// Keep undo payload so Ctrl/Cmd+Z can restore the file (and re-add it to the review list). // Keep undo payload so Ctrl/Cmd+Z can restore the file (and re-add it to the review list).
const cachedModified = fileContents[filePath]?.modifiedFullContent; const cachedModified = fileContents[filePath]?.modifiedFullContent;
const restoreContent = const restoreContent =
cachedModified ?? cachedModified ??
(() => { (() => {
const writeSnippets = file.snippets.filter( const writeSnippets = file.snippets.filter(
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update') (s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
); );
if (writeSnippets.length === 0) return ''; if (writeSnippets.length === 0) return '';
return writeSnippets[writeSnippets.length - 1].newString; return writeSnippets[writeSnippets.length - 1].newString;
})(); })();
const index = activeChangeSet?.files.findIndex((f) => f.filePath === filePath) ?? 0; const index = activeChangeSet?.files.findIndex((f) => f.filePath === filePath) ?? 0;
removedNewFileUndoStackRef.current.push({ removedNewFileUndoStackRef.current.push({
file, file,
index: Math.max(0, index), index: Math.max(0, index),
restoreContent, restoreContent,
removedAt: Date.now(), removedAt: Date.now(),
}); });
lastNewFileRemoveAtRef.current = Date.now(); lastNewFileRemoveAtRef.current = Date.now();
removeReviewFile(filePath); removeReviewFile(filePath);
}
}
} }
} finally { } finally {
newFileApplyInFlightRef.current.delete(filePath); newFileApplyInFlightRef.current.delete(filePath);
@ -622,7 +630,11 @@ export const ChangeReviewDialog = ({
(max, v) => Math.max(max, v), (max, v) => Math.max(max, v),
0 0
); );
const lastReviewActionAt = Math.max(lastBulkActionAtRef.current, lastHunkAt); const lastReviewActionAt = Math.max(
lastBulkActionAtRef.current,
lastHunkAt,
lastFileActionAtRef.current
);
const newFileWasLastAction = lastNewFileRemoveAtRef.current >= lastReviewActionAt; const newFileWasLastAction = lastNewFileRemoveAtRef.current >= lastReviewActionAt;
const isInEditor = !!document.activeElement?.closest('.cm-editor'); const isInEditor = !!document.activeElement?.closest('.cm-editor');
const lastViewConnected = !!lastFocusedEditorRef.current?.dom.isConnected; const lastViewConnected = !!lastFocusedEditorRef.current?.dom.isConnected;
@ -949,8 +961,8 @@ export const ChangeReviewDialog = ({
onContentChanged={handleContentChanged} onContentChanged={handleContentChanged}
onDiscard={handleDiscardFile} onDiscard={handleDiscardFile}
onSave={handleSaveFile} onSave={handleSaveFile}
onAcceptNewFile={handleAcceptNewFile} onAcceptFile={handleAcceptFile}
onRejectNewFile={handleRejectNewFile} onRejectFile={handleRejectFile}
onRestoreMissingFile={handleRestoreMissingFile} onRestoreMissingFile={handleRestoreMissingFile}
onVisibleFileChange={handleVisibleFileChange} onVisibleFileChange={handleVisibleFileChange}
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}

View file

@ -38,8 +38,8 @@ interface ContinuousScrollViewProps {
onContentChanged: (filePath: string, content: string) => void; onContentChanged: (filePath: string, content: string) => void;
onDiscard: (filePath: string) => void; onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void; onSave: (filePath: string) => void;
onAcceptNewFile: (filePath: string) => void; onAcceptFile: (filePath: string) => void;
onRejectNewFile: (filePath: string) => void; onRejectFile: (filePath: string) => void;
onRestoreMissingFile?: (filePath: string, content: string) => void; onRestoreMissingFile?: (filePath: string, content: string) => void;
onVisibleFileChange: (filePath: string) => void; onVisibleFileChange: (filePath: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>; scrollContainerRef: React.RefObject<HTMLDivElement>;
@ -74,8 +74,8 @@ export const ContinuousScrollView = ({
onContentChanged, onContentChanged,
onDiscard, onDiscard,
onSave, onSave,
onAcceptNewFile, onAcceptFile,
onRejectNewFile, onRejectFile,
onRestoreMissingFile, onRestoreMissingFile,
onVisibleFileChange, onVisibleFileChange,
scrollContainerRef, scrollContainerRef,
@ -228,8 +228,8 @@ export const ContinuousScrollView = ({
onToggleCollapse={handleToggleCollapse} onToggleCollapse={handleToggleCollapse}
onDiscard={onDiscard} onDiscard={onDiscard}
onSave={onSave} onSave={onSave}
onAcceptNewFile={onAcceptNewFile} onAcceptFile={onAcceptFile}
onRejectNewFile={onRejectNewFile} onRejectFile={onRejectFile}
onRestoreMissingFile={onRestoreMissingFile} onRestoreMissingFile={onRestoreMissingFile}
/> />

View file

@ -26,8 +26,8 @@ interface FileSectionHeaderProps {
onDiscard: (filePath: string) => void; onDiscard: (filePath: string) => void;
onSave: (filePath: string) => void; onSave: (filePath: string) => void;
onRestoreMissingFile?: (filePath: string, content: string) => void; onRestoreMissingFile?: (filePath: string, content: string) => void;
onAcceptNewFile?: (filePath: string) => void; onAcceptFile?: (filePath: string) => void;
onRejectNewFile?: (filePath: string) => void; onRejectFile?: (filePath: string) => void;
} }
export const FileSectionHeader = ({ export const FileSectionHeader = ({
@ -41,8 +41,8 @@ export const FileSectionHeader = ({
onDiscard, onDiscard,
onSave, onSave,
onRestoreMissingFile, onRestoreMissingFile,
onAcceptNewFile, onAcceptFile,
onRejectNewFile, onRejectFile,
}: FileSectionHeaderProps): React.ReactElement => { }: FileSectionHeaderProps): React.ReactElement => {
const isMissingOnDisk = fileContent?.contentSource === 'unavailable'; const isMissingOnDisk = fileContent?.contentSource === 'unavailable';
const restoreContent = const restoreContent =
@ -145,11 +145,11 @@ export const FileSectionHeader = ({
)} )}
<div className="ml-auto flex items-center gap-1.5" data-no-collapse> <div className="ml-auto flex items-center gap-1.5" data-no-collapse>
{file.isNewFile && (onAcceptNewFile || onRejectNewFile) && ( {(onAcceptFile || onRejectFile) && (
<div className="mr-1 flex items-center gap-1.5"> <div className="mr-1 flex items-center gap-1.5">
{onAcceptNewFile && ( {onAcceptFile && (
<button <button
onClick={() => onAcceptNewFile(file.filePath)} onClick={() => onAcceptFile(file.filePath)}
disabled={applying} disabled={applying}
className={[ className={[
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50', 'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',
@ -161,9 +161,9 @@ export const FileSectionHeader = ({
Accept Accept
</button> </button>
)} )}
{onRejectNewFile && ( {onRejectFile && (
<button <button
onClick={() => onRejectNewFile(file.filePath)} onClick={() => onRejectFile(file.filePath)}
disabled={applying} disabled={applying}
className={[ className={[
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50', 'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',

View file

@ -14,6 +14,7 @@ import {
Eye, Eye,
Folder, Folder,
FolderOpen, FolderOpen,
Search,
X as XIcon, X as XIcon,
} from 'lucide-react'; } from 'lucide-react';
@ -255,7 +256,63 @@ export const ReviewFileTree = ({
const hunkDecisions = useStore((state) => state.hunkDecisions); const hunkDecisions = useStore((state) => state.hunkDecisions);
const fileDecisions = useStore((state) => state.fileDecisions); const fileDecisions = useStore((state) => state.fileDecisions);
const fileChunkCounts = useStore((state) => state.fileChunkCounts); const fileChunkCounts = useStore((state) => state.fileChunkCounts);
const tree = useMemo(() => buildTree(files, (f) => f.relativePath), [files]); const [query, setQuery] = useState('');
const [filterUnresolved, setFilterUnresolved] = useState(false);
const [filterRejected, setFilterRejected] = useState(false);
const [filterNew, setFilterNew] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredFiles = useMemo(() => {
const hasAnyFilter =
filterUnresolved || filterRejected || filterNew || normalizedQuery.length > 0;
if (!hasAnyFilter) return files;
const matchesQuery = (f: FileChangeSummary): boolean => {
if (!normalizedQuery) return true;
const name = f.relativePath.split(/[\\/]/).pop() ?? f.relativePath;
return (
f.relativePath.toLowerCase().includes(normalizedQuery) ||
f.filePath.toLowerCase().includes(normalizedQuery) ||
name.toLowerCase().includes(normalizedQuery)
);
};
const hasAnyRejected = (f: FileChangeSummary): boolean => {
if (fileDecisions[f.filePath] === 'rejected') return true;
const count = getFileHunkCount(f.filePath, f.snippets.length, fileChunkCounts);
for (let i = 0; i < count; i++) {
if (hunkDecisions[`${f.filePath}:${i}`] === 'rejected') return true;
}
return false;
};
return files.filter((f) => {
if (!matchesQuery(f)) return false;
if (filterNew && !f.isNewFile) return false;
if (filterUnresolved) {
const status = getFileStatus(f, hunkDecisions, fileDecisions, fileChunkCounts);
if (!(status === 'pending' || status === 'mixed')) return false;
}
if (filterRejected && !hasAnyRejected(f)) return false;
return true;
});
}, [
files,
normalizedQuery,
filterUnresolved,
filterRejected,
filterNew,
hunkDecisions,
fileDecisions,
fileChunkCounts,
]);
const tree = useMemo(() => buildTree(filteredFiles, (f) => f.relativePath), [filteredFiles]);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => new Set()); const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => new Set());
const toggleFolder = useCallback((fullPath: string) => { const toggleFolder = useCallback((fullPath: string) => {
@ -300,23 +357,94 @@ export const ReviewFileTree = ({
} }
return ( return (
<div className="py-1"> <div className="flex h-full flex-col">
{sortTreeNodes(tree).map((node) => ( <div className="border-b border-border p-2">
<TreeItem <div className="relative">
key={node.fullPath} <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-text-muted" />
node={node} <input
selectedFilePath={selectedFilePath} value={query}
activeFilePath={activeFilePath} onChange={(e) => setQuery(e.target.value)}
onSelectFile={onSelectFile} placeholder="Search files…"
depth={0} className="h-8 w-full rounded border border-border bg-surface px-7 text-xs text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500/30"
hunkDecisions={hunkDecisions} />
fileDecisions={fileDecisions} </div>
fileChunkCounts={fileChunkCounts}
viewedSet={viewedSet} <div className="mt-2 flex flex-wrap gap-1">
collapsedFolders={collapsedFolders} <button
onToggleFolder={toggleFolder} type="button"
/> onClick={() => setFilterUnresolved((v) => !v)}
))} className={cn(
'rounded px-2 py-1 text-[11px] font-medium transition-colors',
filterUnresolved
? 'bg-blue-500/20 text-blue-300'
: 'bg-surface-raised text-text-muted hover:text-text'
)}
>
Unresolved
</button>
<button
type="button"
onClick={() => setFilterRejected((v) => !v)}
className={cn(
'rounded px-2 py-1 text-[11px] font-medium transition-colors',
filterRejected
? 'bg-red-500/20 text-red-300'
: 'bg-surface-raised text-text-muted hover:text-text'
)}
>
Rejected
</button>
<button
type="button"
onClick={() => setFilterNew((v) => !v)}
className={cn(
'rounded px-2 py-1 text-[11px] font-medium transition-colors',
filterNew
? 'bg-green-500/20 text-green-300'
: 'bg-surface-raised text-text-muted hover:text-text'
)}
>
New
</button>
{(filterUnresolved || filterRejected || filterNew || normalizedQuery.length > 0) && (
<button
type="button"
onClick={() => {
setQuery('');
setFilterUnresolved(false);
setFilterRejected(false);
setFilterNew(false);
}}
className="ml-auto rounded px-2 py-1 text-[11px] font-medium text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Clear
</button>
)}
</div>
</div>
{filteredFiles.length === 0 ? (
<div className="flex-1 p-4 text-center text-xs text-text-muted">No matching files</div>
) : (
<div className="flex-1 overflow-y-auto py-1">
{sortTreeNodes(tree).map((node) => (
<TreeItem
key={node.fullPath}
node={node}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={0}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
fileChunkCounts={fileChunkCounts}
viewedSet={viewedSet}
collapsedFolders={collapsedFolders}
onToggleFolder={toggleFolder}
/>
))}
</div>
)}
</div> </div>
); );
}; };