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 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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue