import { memo, useCallback, useEffect, useMemo } from "react" import { FileTree, useFileTree, useFileTreeSelection, } from "@pierre/trees/react" import { 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 { 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 } 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 // The block currently pinned at the top of the diff (scroll-spy), highlighted // in the agenda. null when no block is active or the AI view isn't shown. activeGroup: number | null } 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, activeGroup, onSelectGroup, }: { groups: Array activeGroup: number | null onSelectGroup: (index: number) => void }) { return (
{groups.map((group) => ( ))}
) } // 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. export 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} }) } // A single agenda entry: just the block number + title, like a Google-Docs // outline. Clicking (or Enter/Space) scrolls the diff to that block. The active // block (scroll-spy) gets an accent rule + emphasis. memo'd so scroll-spy // re-renders only repaint the rows whose active state actually changed. const ReviewGroupRow = memo(function ReviewGroupRow({ group, active, onSelectGroup, }: { group: ReviewSidebarGroup active: boolean onSelectGroup: (index: number) => void }) { const title = useMemo(() => renderInlineCode(group.title), [group.title]) 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}
) }) 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 (
) }