import { useEffect, useMemo } from 'react'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { Check, Circle, CircleDot, File, FolderOpen, X as XIcon } from 'lucide-react'; import type { HunkDecision } from '@shared/types'; import type { FileChangeSummary } from '@shared/types/review'; interface ReviewFileTreeProps { files: FileChangeSummary[]; selectedFilePath: string | null; onSelectFile: (filePath: string) => void; viewedSet?: Set; onMarkViewed?: (filePath: string) => void; onUnmarkViewed?: (filePath: string) => void; activeFilePath?: string; } interface TreeNode { name: string; fullPath: string; isFile: boolean; file?: FileChangeSummary; children: TreeNode[]; } type FileStatus = 'pending' | 'accepted' | 'rejected' | 'mixed'; function buildTree(files: FileChangeSummary[]): TreeNode[] { const root: TreeNode = { name: '', fullPath: '', isFile: false, children: [] }; for (const file of files) { const parts = file.relativePath.split('/'); let current = root; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLast = i === parts.length - 1; const fullPath = parts.slice(0, i + 1).join('/'); let child = current.children.find((c) => c.name === part); if (!child) { child = { name: part, fullPath, isFile: isLast, file: isLast ? file : undefined, children: [], }; current.children.push(child); } current = child; } } function collapse(node: TreeNode): TreeNode { const collapsed: TreeNode = { ...node, children: node.children.map(collapse) }; if (!collapsed.isFile && collapsed.children.length === 1 && !collapsed.children[0].isFile) { const child = collapsed.children[0]; return { ...child, name: `${collapsed.name}/${child.name}`, children: child.children, }; } return collapsed; } return collapse(root).children; } function getFileStatus( file: FileChangeSummary, hunkDecisions: Record ): FileStatus { if (file.snippets.length === 0) return 'pending'; const decisions: HunkDecision[] = []; for (let i = 0; i < file.snippets.length; i++) { const key = `${file.filePath}:${i}`; decisions.push(hunkDecisions[key] ?? '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 FileStatusIcon = ({ status }: { status: FileStatus }) => { switch (status) { case 'accepted': return ; case 'rejected': return ; case 'mixed': return ; case 'pending': default: return ; } }; const TreeItem = ({ node, selectedFilePath, activeFilePath, onSelectFile, depth, hunkDecisions, viewedSet, onMarkViewed, onUnmarkViewed, }: { node: TreeNode; selectedFilePath: string | null; activeFilePath?: string; onSelectFile: (filePath: string) => void; depth: number; hunkDecisions: Record; viewedSet?: Set; onMarkViewed?: (filePath: string) => void; onUnmarkViewed?: (filePath: string) => void; }) => { if (node.isFile && node.file) { const isSelected = node.file.filePath === selectedFilePath; const isActive = node.file.filePath === activeFilePath && !isSelected; const status = getFileStatus(node.file, hunkDecisions); return ( ); } return (
{node.name}
{[...node.children] .sort((a, b) => { if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; return a.name.localeCompare(b.name); }) .map((child) => ( ))}
); }; export const ReviewFileTree = ({ files, selectedFilePath, onSelectFile, viewedSet, onMarkViewed, onUnmarkViewed, activeFilePath, }: ReviewFileTreeProps) => { const hunkDecisions = useStore((state) => state.hunkDecisions); const tree = useMemo(() => buildTree(files), [files]); // Auto-scroll tree to active file when scroll-spy updates useEffect(() => { if (!activeFilePath) return; const btn = document.querySelector( `[data-tree-file="${CSS.escape(activeFilePath)}"]` ); if (btn) { btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } }, [activeFilePath]); if (files.length === 0) { return
No changed files
; } return (
{[...tree] .sort((a, b) => { if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; return a.name.localeCompare(b.name); }) .map((node) => ( ))}
); };