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:
parent
b162cb1854
commit
f654c5f356
4 changed files with 206 additions and 66 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue