import { createContext, useContext, 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 } const ReviewSidebarContext = createContext<{ data: ReviewSidebarData | null setData: (data: ReviewSidebarData | null) => void } | null>(null) export function ReviewSidebarProvider({ children, }: { children: React.ReactNode }) { const [data, setData] = useState(null) const value = useMemo(() => ({ data, setData }), [data]) return ( {children} ) } export function useReviewSidebarData(): ReviewSidebarData | null { return useContext(ReviewSidebarContext)?.data ?? null } export function useRegisterReviewSidebar(data: ReviewSidebarData) { const setData = useContext(ReviewSidebarContext)?.setData useEffect(() => { if (!setData) return setData(data) return () => setData(null) }, [setData, data]) } 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) => ( onSelectGroup(group.index)} onSelectFile={onSelectFile} /> ))}
) } 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} }) } function ReviewGroupRow({ group, onSelect, onSelectFile, }: { group: ReviewSidebarGroup onSelect: () => void onSelectFile: (path: string) => void }) { const [expanded, setExpanded] = useState(false) return (
{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 (
) }