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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import {
Eye,
Folder,
FolderOpen,
Search,
X as XIcon,
} from 'lucide-react';
@ -255,7 +256,63 @@ export const ReviewFileTree = ({
const hunkDecisions = useStore((state) => state.hunkDecisions);
const fileDecisions = useStore((state) => state.fileDecisions);
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 toggleFolder = useCallback((fullPath: string) => {
@ -300,23 +357,94 @@ export const ReviewFileTree = ({
}
return (
<div className="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 className="flex h-full flex-col">
<div className="border-b border-border p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-text-muted" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search files…"
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"
/>
</div>
<div className="mt-2 flex flex-wrap gap-1">
<button
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>
);
};