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; pathChangeLabels?: Record< string, { kind: 'deleted' } | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string } >; selectedFilePath: string | null; onSelectFile: (filePath: string) => void; viewedSet?: Set; onMarkViewed?: (filePath: string) => void; onUnmarkViewed?: (filePath: string) => void; activeFilePath?: string; } type FileStatus = 'pending' | 'accepted' | 'rejected' | 'mixed'; function getFileStatus( file: FileChangeSummary, hunkDecisions: Record, fileDecisions: Record, fileChunkCounts: Record ): 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 = { 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 ; case 'rejected': return ; case 'mixed': return ; case 'pending': default: return ; } })(); return ( {icon} {statusLabels[status]} ); }; const TreeItem = ({ node, selectedFilePath, activeFilePath, onSelectFile, depth, hunkDecisions, fileDecisions, fileChunkCounts, viewedSet, collapsedFolders, onToggleFolder, pathChangeLabels, }: { node: TreeNode; selectedFilePath: string | null; activeFilePath?: string; onSelectFile: (filePath: string) => void; depth: number; hunkDecisions: Record; fileDecisions: Record; fileChunkCounts: Record; viewedSet?: Set; collapsedFolders: Set; 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 ( ); } const isOpen = !collapsedFolders.has(node.fullPath); const FolderIcon = isOpen ? FolderOpen : Folder; return (
{isOpen && sortTreeNodes(node.children).map((child) => ( ))}
); }; function applyExpandAncestors(prev: Set, ancestors: string[]): Set { 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[], filePath: string): string[] { const paths: string[] = []; function walk(nodes: TreeNode[], 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>(() => 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( `[data-tree-file="${CSS.escape(activeFilePath)}"]` ); if (btn) { btn.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } }, [activeFilePath]); if (files.length === 0) { return
No changed files
; } return (
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" />
{(filterUnresolved || filterRejected || filterNew || normalizedQuery.length > 0) && ( )}
{filteredFiles.length === 0 ? (
No matching files
) : (
{sortTreeNodes(tree).map((node) => ( ))}
)}
); };