import type { GitFileDiff } from "@opengeni/sdk"; import { ArrowUpRightIcon, FileWarningIcon, HistoryIcon } from "lucide-react"; import { Component, type ReactNode, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { VList, type VListHandle } from "virtua"; import { cn } from "../lib/cn"; import { useUnicodeFallbackFonts } from "../lib/use-unicode-fonts"; import { useThemeType } from "../lib/use-theme-type"; import { formatAsOf } from "../hooks/use-machine-chip"; import type { SandboxGitFileDiff } from "../hooks/use-sandbox-git"; import { useWindowedSections } from "../hooks/use-windowed-sections"; import { PierreDiff } from "./pierre-diff"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; /* ---------------------------------------------------------------------------- Changes tab — a PR-review surface. A file rail (status glyph, ±counts, grouped by repository when metadata is available, otherwise by top-level directory for a large legacy input) on the left; a stacked, WINDOWED diff pane on the right. Only the file sections inside the visible window ± overscan mount a Pierre/Shiki highlighter (the one renderer), so a 40- or 400-file change set never mounts N highlighters at once (D2). A per-file size guard (binary / diff too large) degrades to an "open live" affordance instead of a diff body. Workspace-wide Git results carry an explicit `repoRoot`, so grouping never guesses repository ownership from a directory name. Multi-repo results group at every size; legacy unscoped inputs group by top-level directory only past the threshold. -------------------------------------------------------------------------- */ /** Group the rail (and reorder the pane) once the change set is larger than this. */ const GROUP_THRESHOLD = 20; /** Mount this many sections beyond the visible window on each side. */ const OVERSCAN = 2; const HEADER_PX = 30; const LINE_PX = 18; const GUARD_BODY_PX = 52; const COMPACT_SURFACE_PX = 560; const useChangesLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; const STATUS_TINT: Record = { added: "text-og-status-idle", modified: "text-og-status-running", deleted: "text-og-status-failed", renamed: "text-og-accent", copied: "text-og-accent", untracked: "text-og-fg-subtle", ignored: "text-og-fg-subtle", conflicted: "text-og-status-failed", typechange: "text-og-status-running", }; const STATUS_LETTER: Record = { added: "A", modified: "M", deleted: "D", renamed: "R", copied: "C", untracked: "U", ignored: "I", conflicted: "!", typechange: "T", }; function compactFileLabel(file: SandboxGitFileDiff): string { const slash = file.path.lastIndexOf("/"); const basename = slash >= 0 ? file.path.slice(slash + 1) : file.path; const parent = slash >= 0 ? file.path.slice(0, slash + 1) : ""; return `${STATUS_LETTER[file.status]} · ${basename}${parent ? ` — ${parent}` : ""}`; } export type WorkbenchChangesProps = { /** The changed files (`useSandboxGit().diff`). Assumed non-empty by the caller. */ diff: SandboxGitFileDiff[]; /** Which source the diff came from — drives the "live" vs "as of turn" badge. */ source: "live" | "capture" | null; /** When the served capture was taken (ISO), when `source === "capture"`. */ capturedAt: string | null; /** The capture's turn revision, for the "as of turn N" badge. */ captureRevision?: number | null | undefined; themeType?: "dark" | "light" | undefined; /** Route a guarded binary/oversize file into the live Files surface. */ onOpenFile?: ((path: string) => void) | undefined; className?: string | undefined; }; type RailRow = | { kind: "group"; label: string; count: number } | { kind: "file"; file: SandboxGitFileDiff; index: number }; type ChangesRailVListProps = { children: ReactNode; className: string; coarsePointer: boolean; files: readonly SandboxGitFileDiff[]; rowCount: number; }; type ChangesRailVListState = { renderedRowCount: number; resetVersion: number; }; type ChangesRailSnapshot = { focusedPath: string | null; scrollOffset: number; }; /** Virtua must reset its cached range when the rail shrinks. Keep that remount * scoped to shrink transitions and preserve the reader's focus/scroll context. */ class ChangesRailVList extends Component< ChangesRailVListProps, ChangesRailVListState, ChangesRailSnapshot | null > { state: ChangesRailVListState = { renderedRowCount: this.props.rowCount, resetVersion: 0, }; private host: HTMLDivElement | null = null; private listHandle: VListHandle | null = null; private pendingFocusedPath: string | null = null; private restoreFrame: number | null = null; static getDerivedStateFromProps( props: ChangesRailVListProps, state: ChangesRailVListState, ): ChangesRailVListState | null { if (props.rowCount === state.renderedRowCount) return null; return { renderedRowCount: props.rowCount, resetVersion: props.rowCount < state.renderedRowCount ? state.resetVersion + 1 : state.resetVersion, }; } getSnapshotBeforeUpdate( _previousProps: ChangesRailVListProps, previousState: ChangesRailVListState, ): ChangesRailSnapshot | null { if (previousState.resetVersion === this.state.resetVersion) return null; const scroller = this.host?.firstElementChild; if (!(scroller instanceof HTMLElement)) return null; const active = document.activeElement; const focusedRow = active instanceof HTMLElement && scroller.contains(active) ? active.closest("[data-rail-file-path]") : null; return { focusedPath: focusedRow?.dataset.railFilePath ?? this.pendingFocusedPath, scrollOffset: scroller.scrollTop, }; } componentDidUpdate( _previousProps: ChangesRailVListProps, _previousState: ChangesRailVListState, snapshot: ChangesRailSnapshot | null, ): void { if (!snapshot || !this.listHandle) return; this.cancelRestoreFrame(); const maxOffset = Math.max(0, this.listHandle.scrollSize - this.listHandle.viewportSize); this.listHandle.scrollTo(Math.min(Math.max(snapshot.scrollOffset, 0), maxOffset)); const focusedPath = snapshot.focusedPath; if (!focusedPath) return; this.pendingFocusedPath = focusedPath; this.continueFocusRestore(); } componentWillUnmount(): void { this.cancelRestoreFrame(); this.pendingFocusedPath = null; } private cancelRestoreFrame(): void { if (this.restoreFrame === null) return; if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.restoreFrame); this.restoreFrame = null; } private continueFocusRestore = (): void => { const path = this.pendingFocusedPath; if (!path) return; if (this.restoreFocusedPath(path)) { this.pendingFocusedPath = null; return; } if (this.restoreFrame === null && typeof requestAnimationFrame === "function") { this.restoreFrame = requestAnimationFrame(() => { this.restoreFrame = null; this.continueFocusRestore(); }); } }; private restoreFocusedPath(path: string): boolean { const host = this.host; if (!host) return true; const active = document.activeElement; if (active instanceof HTMLElement && active.isConnected && active !== document.body) { return true; } if (!this.props.files.some((file) => file.path === path)) return true; const row = Array.from(host.querySelectorAll("[data-rail-file-path]")).find( (candidate) => candidate.dataset.railFilePath === path, ); if (!row) return false; row.focus({ preventScroll: true }); return true; } private setHost = (host: HTMLDivElement | null): void => { this.host = host; }; private setListHandle = (handle: VListHandle | null): void => { this.listHandle = handle; }; render() { return (
{this.props.children}
); } } /** Order the files and build the rail rows (grouped past the threshold). The * returned `orderedFiles` drives BOTH the rail and the diff pane so a rail row's * `index` addresses the matching pane section. Exported for tests. */ export function buildRail( t: OpenGeniText, files: SandboxGitFileDiff[], ): { orderedFiles: SandboxGitFileDiff[]; rows: RailRow[]; grouped: boolean; } { const roots = new Set( files.flatMap((file) => (file.repoRoot === undefined ? [] : [file.repoRoot])), ); const groupByRepo = roots.size > 1; if (!groupByRepo && files.length <= GROUP_THRESHOLD) { return { orderedFiles: files, rows: files.map((file, index) => ({ kind: "file", file, index })), grouped: false, }; } const groups = new Map(); for (const file of files) { const slash = file.path.indexOf("/"); const key = groupByRepo ? file.repoRoot || t("workbenchChanges.workspace") : slash > 0 ? file.path.slice(0, slash) : t("workbenchChanges.root"); const bucket = groups.get(key); if (bucket) bucket.push(file); else groups.set(key, [file]); } const labels = [...groups.keys()].sort((a, b) => a.localeCompare(b)); const orderedFiles: SandboxGitFileDiff[] = []; const rows: RailRow[] = []; for (const label of labels) { const bucket = (groups.get(label) ?? []).slice().sort((a, b) => a.path.localeCompare(b.path)); rows.push({ kind: "group", label, count: bucket.length }); for (const file of bucket) { rows.push({ kind: "file", file, index: orderedFiles.length }); orderedFiles.push(file); } } return { orderedFiles, rows, grouped: true }; } /** A file's diff body is "too large to inline" when it's binary or the diff guard * tripped (the backend omits hunks past its size cap) — open it live instead. */ function isGuarded(file: GitFileDiff): boolean { return file.isBinary || file.truncated; } /** Estimate a section's rendered height for the windowing math. Line-count based, * so it is close enough that the first paint barely shifts as real heights land. */ function estimateSectionHeight(file: GitFileDiff): number { if (isGuarded(file)) return HEADER_PX + GUARD_BODY_PX; let lines = 0; for (const hunk of file.hunks) lines += hunk.lines.length + 1; return HEADER_PX + Math.max(lines, 1) * LINE_PX + 8; } export function WorkbenchChanges({ diff, source, capturedAt, captureRevision, themeType, onOpenFile, className, }: WorkbenchChangesProps) { const t = useOpenGeniText(); const rootRef = useRef(null); const [compact, setCompact] = useState(false); const [coarsePointer, setCoarsePointer] = useState(false); const resolvedTheme = useThemeType(themeType); const [layout, setLayout] = useState<"unified" | "split">("unified"); const [activePath, setActivePath] = useState(null); const pickerId = useId(); const unicodeFontPaths = useMemo( () => diff.flatMap((file) => (file.oldPath ? [file.path, file.oldPath] : [file.path])), [diff], ); useUnicodeFallbackFonts(unicodeFontPaths); const { orderedFiles, rows, grouped } = useMemo(() => buildRail(t, diff), [diff, t]); const selectedIndex = orderedFiles.findIndex((file) => file.path === activePath); const activeIndex = selectedIndex >= 0 ? selectedIndex : 0; useChangesLayoutEffect(() => { const root = rootRef.current; if (!root || typeof ResizeObserver === "undefined") return; const update = (width: number) => { // DOM test environments report a zero geometry. Keep the deterministic // desktop/SSR branch until the observer has a real measurement. if (width > 0) setCompact(width < COMPACT_SURFACE_PX); }; update(root.getBoundingClientRect().width); const observer = new ResizeObserver((entries) => { update(entries[0]?.contentRect.width ?? root.getBoundingClientRect().width); }); observer.observe(root); return () => observer.disconnect(); }, []); useChangesLayoutEffect(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; const query = window.matchMedia("(pointer: coarse)"); const update = () => setCoarsePointer(query.matches); update(); if (typeof query.addEventListener === "function") { query.addEventListener("change", update); return () => query.removeEventListener("change", update); } if (typeof query.addListener === "function") { query.addListener(update); return () => query.removeListener(update); } }, []); const additions = useMemo(() => diff.reduce((sum, f) => sum + f.additions, 0), [diff]); const deletions = useMemo(() => diff.reduce((sum, f) => sum + f.deletions, 0), [diff]); const estimateHeight = useCallback( (index: number) => { const file = orderedFiles[index]; return file ? estimateSectionHeight(file) : HEADER_PX + LINE_PX; }, [orderedFiles], ); const windowed = useWindowedSections({ count: orderedFiles.length, estimateHeight, overscan: OVERSCAN, }); const jumpTo = useCallback( (index: number) => { setActivePath(orderedFiles[index]?.path ?? null); windowed.scrollToIndex(index); }, [orderedFiles, windowed], ); // Track which section is at the top of the pane so the rail highlights it. const onPaneScroll = useCallback(() => { const el = windowed.scrollRef.current; if (!el) { return; } const top = el.scrollTop; const offsets = windowed.offsets; let idx = 0; for (let i = 0; i < orderedFiles.length; i++) { if ((offsets[i] ?? 0) <= top + 4) idx = i; else break; } const nextPath = orderedFiles[idx]?.path ?? null; setActivePath((previous) => (previous === nextPath ? previous : nextPath)); }, [windowed, orderedFiles]); useEffect(() => { const el = windowed.scrollRef.current; if (!el) return; el.addEventListener("scroll", onPaneScroll, { passive: true }); return () => el.removeEventListener("scroll", onPaneScroll); }, [windowed.scrollRef, onPaneScroll]); const sourceBadge = describeSource(t, source, capturedAt, captureRevision ?? null); const activeFile = orderedFiles[activeIndex] ?? orderedFiles[0]; return (
{/* Summary + source badge + layout toggle. */}
{t("workbenchChanges.filesChanged", { count: diff.length })} +{additions} −{deletions}
{compact ? null : }
{compact ? (
{activeFile ? ( ) : null}
) : (
{/* File rail — virtualized (virtua): a dense change set is fine. */} {rows.map((row) => row.kind === "group" ? (
{row.label} {row.count}
) : ( jumpTo(row.index)} /> ), )}
{/* Diff pane — windowed file sections. Only the sections inside the visible window ± overscan are in the DOM; the container reserves the full scroll height so scrolling + the rail-jump stay accurate. */}
{orderedFiles.map((file, index) => { if (index < windowed.range.start || index >= windowed.range.end) return null; return ( ); })}
)}
); } /** * The data-source badge. "live" stays understated (the header chip already * carries machine liveness) — but a capture-served diff is honestly labelled * historical: a muted clock + the "as of turn N ·