agent-ecosystem/src/renderer/components/team/review/ReviewFileTree.tsx
2026-04-21 17:21:29 +03:00

482 lines
16 KiB
TypeScript

import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { getFileHunkCount } from '@renderer/store/slices/changeReviewSlice';
import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey';
import {
Check,
ChevronRight,
Circle,
CircleDot,
Eye,
Folder,
FolderOpen,
Search,
X as XIcon,
} from 'lucide-react';
import type { TreeNode } from '@renderer/utils/fileTreeBuilder';
import type { HunkDecision } from '@shared/types';
import type { FileChangeWithContent } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
interface ReviewFileTreeProps {
files: FileChangeSummary[];
fileContents?: Record<string, FileChangeWithContent>;
pathChangeLabels?: Record<
string,
{ kind: 'deleted' } | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string }
>;
selectedFilePath: string | null;
onSelectFile: (filePath: string) => void;
viewedSet?: Set<string>;
onMarkViewed?: (filePath: string) => void;
onUnmarkViewed?: (filePath: string) => void;
activeFilePath?: string;
}
type FileStatus = 'pending' | 'accepted' | 'rejected' | 'mixed';
function getFileStatus(
file: FileChangeSummary,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>,
fileChunkCounts: Record<string, number>
): FileStatus {
// File-level decision takes priority (set by Accept All / Reject All)
const reviewKey = getFileReviewKey(file);
const fileDec = fileDecisions[reviewKey] ?? fileDecisions[file.filePath];
if (fileDec === 'accepted') return 'accepted';
if (fileDec === 'rejected') return 'rejected';
const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
if (count === 0) return 'pending';
const decisions: HunkDecision[] = [];
for (let i = 0; i < count; i++) {
const key = buildHunkDecisionKey(reviewKey, i);
decisions.push(hunkDecisions[key] ?? hunkDecisions[`${file.filePath}:${i}`] ?? 'pending');
}
const allAccepted = decisions.every((d) => d === 'accepted');
const allRejected = decisions.every((d) => d === 'rejected');
const allPending = decisions.every((d) => d === 'pending');
if (allPending) return 'pending';
if (allAccepted) return 'accepted';
if (allRejected) return 'rejected';
return 'mixed';
}
const statusLabels: Record<FileStatus, string> = {
accepted: 'All changes accepted',
rejected: 'All changes rejected',
mixed: 'Partially reviewed',
pending: 'Pending review',
};
const FileStatusIcon = ({ status }: { status: FileStatus }): JSX.Element => {
const icon = (() => {
switch (status) {
case 'accepted':
return <Check className="size-3 shrink-0 text-green-400" />;
case 'rejected':
return <XIcon className="size-3 shrink-0 text-red-400" />;
case 'mixed':
return <CircleDot className="size-3 shrink-0 text-yellow-400" />;
case 'pending':
default:
return <Circle className="size-3 shrink-0 text-zinc-500" />;
}
})();
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">{icon}</span>
</TooltipTrigger>
<TooltipContent side="top">{statusLabels[status]}</TooltipContent>
</Tooltip>
);
};
const TreeItem = ({
node,
selectedFilePath,
activeFilePath,
onSelectFile,
depth,
hunkDecisions,
fileDecisions,
fileChunkCounts,
viewedSet,
collapsedFolders,
onToggleFolder,
pathChangeLabels,
}: {
node: TreeNode<FileChangeSummary>;
selectedFilePath: string | null;
activeFilePath?: string;
onSelectFile: (filePath: string) => void;
depth: number;
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
fileChunkCounts: Record<string, number>;
viewedSet?: Set<string>;
collapsedFolders: Set<string>;
onToggleFolder: (fullPath: string) => void;
pathChangeLabels?: ReviewFileTreeProps['pathChangeLabels'];
}): JSX.Element => {
if (node.isFile && node.data) {
const isSelected = node.data.filePath === selectedFilePath;
const isActive = node.data.filePath === activeFilePath && !isSelected;
const status = getFileStatus(node.data, hunkDecisions, fileDecisions, fileChunkCounts);
const label = pathChangeLabels?.[node.data.filePath];
return (
<button
data-tree-file={node.data.filePath}
onClick={() => onSelectFile(node.data!.filePath)}
className={cn(
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
isSelected
? 'bg-blue-500/20 text-blue-300'
: isActive
? 'border-l-2 border-blue-400 text-text'
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<FileStatusIcon status={status} />
<FileIcon fileName={node.name} className="size-3.5" />
{viewedSet && viewedSet.has(node.data.filePath) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<Eye className="size-3 shrink-0 text-blue-400" />
</span>
</TooltipTrigger>
<TooltipContent side="top">Viewed</TooltipContent>
</Tooltip>
)}
<span
className={cn(
'min-w-0 flex-1 truncate',
status === 'rejected' && 'text-text-muted line-through'
)}
>
{node.name}
</span>
{node.data.isNewFile && (
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-medium text-green-400">
new
</span>
)}
{label?.kind === 'deleted' && (
<span className="shrink-0 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-300">
deleted
</span>
)}
{label && label.kind !== 'deleted' && (
<span className="shrink-0 rounded bg-purple-500/20 px-1.5 py-0.5 text-[10px] font-medium text-purple-300">
{label.kind}
</span>
)}
<span className="ml-1 flex shrink-0 items-center gap-1">
{node.data.linesAdded > 0 && (
<span className="text-green-400">+{node.data.linesAdded}</span>
)}
{node.data.linesRemoved > 0 && (
<span className="text-red-400">-{node.data.linesRemoved}</span>
)}
</span>
</button>
);
}
const isOpen = !collapsedFolders.has(node.fullPath);
const FolderIcon = isOpen ? FolderOpen : Folder;
return (
<div>
<button
type="button"
onClick={() => onToggleFolder(node.fullPath)}
className="flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
aria-label={isOpen ? `Collapse ${node.name}` : `Expand ${node.name}`}
>
<ChevronRight
size={12}
className={cn('shrink-0 transition-transform duration-150', isOpen && 'rotate-90')}
/>
<FolderIcon className="size-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</button>
{isOpen &&
sortTreeNodes(node.children).map((child) => (
<TreeItem
key={child.fullPath}
node={child}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={depth + 1}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
fileChunkCounts={fileChunkCounts}
viewedSet={viewedSet}
collapsedFolders={collapsedFolders}
onToggleFolder={onToggleFolder}
pathChangeLabels={pathChangeLabels}
/>
))}
</div>
);
};
function applyExpandAncestors(prev: Set<string>, ancestors: string[]): Set<string> {
const collapsedAncestors = ancestors.filter((a) => prev.has(a));
if (collapsedAncestors.length === 0) return prev;
const next = new Set(prev);
for (const a of collapsedAncestors) {
next.delete(a);
}
return next;
}
function getAncestorFolderPaths(tree: TreeNode<FileChangeSummary>[], filePath: string): string[] {
const paths: string[] = [];
function walk(nodes: TreeNode<FileChangeSummary>[], ancestors: string[]): boolean {
for (const node of nodes) {
if (node.isFile && node.data?.filePath === filePath) {
paths.push(...ancestors);
return true;
}
if (!node.isFile) {
if (walk(node.children, [...ancestors, node.fullPath])) return true;
}
}
return false;
}
walk(tree, []);
return paths;
}
export const ReviewFileTree = ({
files,
pathChangeLabels,
selectedFilePath,
onSelectFile,
viewedSet,
activeFilePath,
}: ReviewFileTreeProps): JSX.Element => {
const hunkDecisions = useStore((state) => state.hunkDecisions);
const fileDecisions = useStore((state) => state.fileDecisions);
const fileChunkCounts = useStore((state) => state.fileChunkCounts);
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 => {
const reviewKey = getFileReviewKey(f);
if (fileDecisions[reviewKey] === 'rejected' || fileDecisions[f.filePath] === 'rejected') {
return true;
}
const count = getFileHunkCount(f.filePath, f.snippets.length, fileChunkCounts);
for (let i = 0; i < count; i++) {
if (
hunkDecisions[buildHunkDecisionKey(reviewKey, i)] === 'rejected' ||
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) => {
setCollapsedFolders((prev) => {
const next = new Set(prev);
if (next.has(fullPath)) {
next.delete(fullPath);
} else {
next.add(fullPath);
}
return next;
});
}, []);
// Auto-expand parent folders when a file is selected or becomes active
useEffect(() => {
const targetPath = selectedFilePath ?? activeFilePath;
if (!targetPath) return;
const ancestors = getAncestorFolderPaths(tree, targetPath);
if (ancestors.length === 0) return;
queueMicrotask(() => {
setCollapsedFolders((prev) => applyExpandAncestors(prev, ancestors));
});
}, [selectedFilePath, activeFilePath, tree]);
// Auto-scroll tree to active file when scroll-spy updates
useEffect(() => {
if (!activeFilePath) return;
const btn = document.querySelector<HTMLElement>(
`[data-tree-file="${CSS.escape(activeFilePath)}"]`
);
if (btn) {
btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [activeFilePath]);
if (files.length === 0) {
return <div className="p-4 text-center text-xs text-text-muted">No changed files</div>;
}
return (
<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}
pathChangeLabels={pathChangeLabels}
/>
))}
</div>
)}
</div>
);
};