import { memo, useCallback, useEffect, useMemo, useState } from "react" import { FileTree, useFileTree, useFileTreeSelection, } from "@pierre/trees/react" import { CaretRightIcon, ListBulletsIcon, TreeViewIcon, } from "@phosphor-icons/react" import type { ReactNode } from "react" import type { FileTreeDirectoryHandle, GitStatus, GitStatusEntry, } from "@pierre/trees" import type { ReviewDiffFile } from "@/lib/api" import { Markdown } from "@/components/agents/ported" import { Skeleton } from "@/components/ui/skeleton" import { TREE_UNSAFE_CSS, treeThemeStyle, } from "@/components/agents/AgentGitPanel" import { cn } from "@/lib/utils" function reviewFileGitStatus(status: ReviewDiffFile["status"]): GitStatus { if (status === "removed") return "deleted" if (status === "added") return "added" if (status === "renamed") return "renamed" return "modified" } export type ReviewSidebarView = "ai" | "files" export interface ReviewSidebarGroup { index: number title: string summary: string additions: number deletions: number fileCount: number files: Array } export interface ReviewSidebarData { title: string files: Array | null selected: string | null viewed: Set onSelect: (path: string) => void groups: Array | null view: ReviewSidebarView onViewChange: (view: ReviewSidebarView) => void onSelectGroup: (index: number) => void } export function ReviewSidebarPanel({ data }: { data: ReviewSidebarData }) { const hasGroups = data.groups !== null && data.groups.length > 0 const showAi = data.view === "ai" && hasGroups return (
{data.title} {hasGroups && ( )}
{showAi ? ( ) : !data.files ? (
) : ( )}
) } function ReviewViewToggle({ view, onChange, }: { view: ReviewSidebarView onChange: (view: ReviewSidebarView) => void }) { return (
onChange("ai")} > onChange("files")} >
) } function ReviewViewToggleButton({ active, label, onClick, children, }: { active: boolean label: string onClick: () => void children: React.ReactNode }) { return ( ) } function ReviewGroupList({ groups, onSelectGroup, onSelectFile, }: { groups: Array onSelectGroup: (index: number) => void onSelectFile: (path: string) => void }) { return (
{groups.map((group) => ( ))}
) } function splitPath(path: string): { dir: string; base: string } { const idx = path.lastIndexOf("/") if (idx === -1) return { dir: "", base: path } return { dir: path.slice(0, idx), base: path.slice(idx + 1) } } // Older stored summaries embed `[label](#loc=path:line)` diff links. Render the // label as inline code instead so no stale jump-links leak into the explanation. function stripLocationLinks(summary: string): string { return summary.replace(/\[([^\]]+)\]\(#loc=[^)]*\)/g, "`$1`") } // Render a title with `backtick`-delimited spans as inline code chips, matching // the Markdown component's inline-code styling, without pulling in the full // block renderer for a single line. function renderInlineCode(text: string): Array { return text.split(/(`[^`]+`)/g).map((part, i) => { if (part.length >= 2 && part.startsWith("`") && part.endsWith("`")) { return ( {part.slice(1, -1)} ) } return {part} }) } // The whole card is the scroll-to-group target so clicks anywhere (including // the expanded explanation body) focus the diff. Nested controls — the file // links and the "Read explanation" toggle — stop propagation so they keep // their own behavior. memo'd + memoized string processing so re-renders from // sibling state don't re-run Markdown/inline-code work. const ReviewGroupRow = memo(function ReviewGroupRow({ group, onSelectGroup, onSelectFile, }: { group: ReviewSidebarGroup onSelectGroup: (index: number) => void onSelectFile: (path: string) => void }) { const [expanded, setExpanded] = useState(false) const title = useMemo(() => renderInlineCode(group.title), [group.title]) const summary = useMemo( () => stripLocationLinks(group.summary), [group.summary] ) const selectGroup = useCallback( () => onSelectGroup(group.index), [onSelectGroup, group.index] ) const onKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault() onSelectGroup(group.index) } }, [onSelectGroup, group.index] ) return (
{group.index} {title} {group.fileCount} file{group.fileCount === 1 ? "" : "s"} {group.additions > 0 && ( +{group.additions} )} {group.deletions > 0 && ( -{group.deletions} )}
{group.files.length > 0 && (
{group.files.map((path) => { const { dir, base } = splitPath(path) return ( ) })}
)} {group.summary && (
{expanded && (
)}
)}
) }) function ReviewFileTreeExplorer({ files, selected, onSelect, }: { files: Array selected: string | null onSelect: (path: string) => void }) { const paths = useMemo(() => files.map((file) => file.path), [files]) const gitStatus = useMemo>( () => files.map((file) => ({ path: file.path, status: reviewFileGitStatus(file.status), })), [files] ) const { model } = useFileTree({ paths, gitStatus, flattenEmptyDirectories: true, density: "default", icons: "complete", unsafeCSS: TREE_UNSAFE_CSS, }) useEffect(() => { model.resetPaths(paths) }, [model, paths]) useEffect(() => { model.setGitStatus(gitStatus) }, [model, gitStatus]) const selection = useFileTreeSelection(model) useEffect(() => { const path = selection[0] if (path) onSelect(path) }, [selection, onSelect]) useEffect(() => { if (!selected) return const segments = selected.split("/") for (let depth = 1; depth < segments.length; depth += 1) { const item = model.getItem(segments.slice(0, depth).join("/")) if (item?.isDirectory()) (item as FileTreeDirectoryHandle).expand() } model.scrollToPath(selected, { focus: false }) }, [model, selected]) return (
) }