import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { CodeView } from "@pierre/diffs/react"; import type { CodeViewItem, CodeViewOptions } from "@pierre/diffs"; import type { CodeViewHandle } from "@pierre/diffs/react"; import { cn } from "../../lib/utils"; import { diffOptions, parseDiff } from "../../lib/highlighter"; import { Button } from "../../ui/button"; import { TooltipProvider } from "../../ui/tooltip"; import { FileTree } from "../file-tree/file-tree"; import { PrAction, ToolButton } from "./diff-actions"; import { FileTreeShell } from "./diff-file-tree"; import { same, selection } from "./selection"; import type { DiffViewFile, DiffViewMode, DiffViewPr, DiffViewProps } from "./types"; import type { Selection } from "./selection"; import { IconChevronRight, IconCollapseVertical, IconExpandVertical, IconFileDiff, IconListTree, IconLoader, IconRows, IconSplitView, } from "../../icons"; type Totals = { adds: number; dels: number; }; type Cache = { item: CodeViewItem; key: string; }; type HeaderProps = { active: boolean; file: DiffViewFile; open: boolean; subtitle?: string; onToggle: (file: DiffViewFile, open: boolean) => void; }; const HEADER = 32; const BUFFER = 1600; function total(files: DiffViewFile[]): Totals { let adds = 0; let dels = 0; for (let file of files) { adds += file.adds; dels += file.dels; } return { adds, dels }; } function basename(path: string) { let index = path.lastIndexOf("/"); return index === -1 ? path : path.slice(index + 1); } function dirname(path: string) { let index = path.lastIndexOf("/"); return index === -1 ? "" : path.slice(0, index); } function filesKey(files: DiffViewFile[]) { return files.map(file => `${file.from ?? ""}->${file.file}`).join("\0"); } function stamp(file: DiffViewFile) { return [ file.signature ?? (file.patch === undefined ? "missing" : `inline:${file.patch.length}`), file.adds, file.dels, file.binary ? "binary" : "text", file.pending ? "pending" : "ready", file.error ?? "", ].join("\0"); } function duplicateNames(files: DiffViewFile[]) { let counts = new Map(); for (let file of files) { let name = basename(file.file); counts.set(name, (counts.get(name) ?? 0) + 1); } return new Set([...counts].filter(([, count]) => count > 1).map(([name]) => name)); } function context(file: DiffViewFile, duplicates: Set) { if (file.from) return `${file.from} -> ${file.file}`; if (duplicates.has(basename(file.file))) return dirname(file.file); } function hash(value: string) { let hash = 0x811c9dc5; for (let index = 0; index < value.length; index++) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 0x01000193); } return hash >>> 0; } function text(file: DiffViewFile) { if (file.pending) return `Loading diff for ${file.file}...`; if (file.error) return file.error; if (file.patch === undefined) return `Diff not loaded for ${file.file}.`; return "Invalid or empty patch."; } function option(view: DiffViewMode): CodeViewOptions { let base = diffOptions(view); return { diffIndicators: base.diffIndicators, diffStyle: view, disableFileHeader: false, expansionLineCount: base.expansionLineCount, hunkSeparators: "line-info", itemMetrics: { diffHeaderHeight: HEADER, hunkLineCount: 8, lineHeight: 20, paddingBottom: 0, paddingTop: 0, spacing: 0, }, layout: { gap: 0, paddingBottom: 0, paddingTop: 0, }, overflow: "scroll", stickyHeaders: true, theme: base.theme, unsafeCSS: ` ${base.unsafeCSS ?? ""} :host { --diffs-light-bg: transparent; --diffs-dark-bg: transparent; --diffs-header-font-family: var(--font-sans); --diffs-font-family: var(--font-mono); --diffs-font-size: 12px; --diffs-line-height: 20px; --diffs-gap-fallback: 0px; --diffs-bg-buffer-override: transparent; --diffs-bg-separator-override: var(--color-background); --diffs-bg-context-override: color-mix(in oklch, var(--color-code), transparent 18%); --diffs-bg-context-gutter-override: color-mix(in oklch, var(--color-code), transparent 8%); --diffs-bg-addition-override: color-mix(in oklch, var(--color-accent), transparent 82%); --diffs-bg-deletion-override: color-mix(in oklch, var(--color-destructive), transparent 84%); --diffs-addition-color-override: var(--color-accent); --diffs-deletion-color-override: var(--color-destructive); } [data-code] { padding-block-start: 0; padding-block-end: 0; } [data-diffs-header] { block-size: ${HEADER}px; min-block-size: ${HEADER}px; max-block-size: ${HEADER}px; padding-inline: 0; overflow: hidden; border-block-end: 0; box-shadow: inset 0 -1px var(--color-border); } slot[name='header-custom']::slotted(*) { block-size: 100%; inline-size: 100%; } [data-diffs-header][data-sticky] { background-color: color-mix(in oklch, var(--color-popover), transparent 4%); } `, }; } function item(file: DiffViewFile, open: boolean, cache: Map): CodeViewItem { let state = `${stamp(file)}\0${open ? "open" : "closed"}`; let version = hash(state); let key = `${file.file}\0${version}`; let cached = cache.get(file.file); if (cached?.key === key) return cached.item; let { patch } = file; let parsed = file.binary || patch === undefined ? null : parseDiff( patch, file.oldFile && file.newFile ? { oldFile: file.oldFile, newFile: file.newFile } : undefined, ); // Stamp a content-stable cacheKey so pierre's worker cache survives a // scroll-release/re-create instead of re-highlighting (a visible flash). if (parsed && !parsed.cacheKey) { parsed.cacheKey = file.signature || `${file.file}\0${patch?.length}`; } let next: CodeViewItem = parsed ? { id: file.file, type: "diff", fileDiff: parsed, version, collapsed: !open, } : { id: file.file, type: "file", file: { name: file.file, contents: text(file), lang: "text", cacheKey: key, }, version, collapsed: !open, }; cache.set(file.file, { key, item: next }); return next; } function Header({ active, file, open, subtitle, onToggle, }: HeaderProps) { let name = basename(file.file); let from = file.from ? basename(file.from) : undefined; let dir = !from && subtitle ? dirname(file.file) : undefined; return (
{file.binary ? null : ( )}
); } function DiffView(props: DiffViewProps) { let key = useMemo(() => filesKey(props.files), [props.files]); return ; } function useDiffView({ files, base = "origin/main", head = "Changes", pr, creatingPr, preparingPr, mode, defaultMode = "unified", defaultFileTreeOpen = false, selected, selectedKey, onSelectedChange, onModeChange, onOpenPr, onCreatePr, onCreateDraftPr, onManualCreatePr, onFileOpen, className, }: DiffViewProps) { let [navigation, setNavigation] = useState< { current: string; applied?: Selection; pending?: string; request?: Selection } >(() => ({ current: files[0]?.file ?? "" })); let { current } = navigation; let [internalMode, setInternalMode] = useState(defaultMode); let [treeOpen, setTreeOpen] = useState(defaultFileTreeOpen); let [collapsed, setCollapsed] = useState>(() => new Set()); let code = useRef>(null); let cache = useRef(new Map()); let names = useMemo(() => new Set(files.map(file => file.file)), [files]); let external = useMemo(() => selection(names, selected, selectedKey), [ names, selected, selectedKey, ]); // A selectedKey is a navigation intent, including re-opening an already selected file. // Preserve user collapse actions when the parent acknowledges their selection. if (!same(navigation.applied, external)) { let request = external && !(navigation.pending === external.file && external.key === undefined) ? external : undefined; setNavigation({ current: external?.file ?? current, applied: external, request }); if (request) { setCollapsed(previous => { if (!previous.has(request.file)) return previous; let next = new Set(previous); next.delete(request.file); return next; }); } } let active = external ? external.file : names.has(current) ? current : files[0]?.file ?? ""; let summary = useMemo(() => total(files), [files]); let duplicates = useMemo(() => duplicateNames(files), [files]); let index = useMemo(() => new Map(files.map(file => [file.file, file])), [files]); let paths = useMemo(() => files.map(file => file.file), [files]); let decorations = useMemo( () => new Map( files.map(file => [file.file, { text: file.binary ? "Binary" : `+${file.adds} -${file.dels}`, title: file.binary ? "Binary file" : `${file.adds} additions, ${file.dels} deletions`, }]), ), [files], ); let view = mode ?? internalMode; let options = useMemo(() => option(view), [view]); let expandable = useMemo(() => files.filter(file => !file.binary), [files]); let allExpanded = expandable.length > 0 && expandable.every(file => !collapsed.has(file.file)); let snapshot = useMemo(() => { let previous = new Map(cache.current); let next = new Map(); let items: CodeViewItem[] = []; for (let file of files) { let open = !file.binary && !collapsed.has(file.file); let value = item(file, open, previous); let hit = previous.get(file.file); if (hit) next.set(file.file, hit); items.push(value); } return { cache: next, items }; }, [collapsed, files]); let { items } = snapshot; useLayoutEffect(() => { cache.current = snapshot.cache; }, [snapshot]); let scrollTo = useCallback((file: string) => { requestAnimationFrame(() => { code.current?.scrollTo({ type: "item", id: file, align: "start", behavior: "smooth-auto", }); }); }, []); let select = useCallback((file: string) => { setNavigation(previous => ({ ...previous, current: file, pending: file })); onSelectedChange?.(file); }, [onSelectedChange]); let activate = useCallback((file: string, scroll = false) => { select(file); if (scroll) scrollTo(file); }, [scrollTo, select]); let load = useEffectEvent((file: string) => onFileOpen?.(file)); useEffect(() => { if (!navigation.request) return; // This requests lazy patch I/O for a navigation intent. Selection and expansion // are already synchronized above; the callback does not return derived view data. load(navigation.request.file); scrollTo(navigation.request.file); }, [navigation.request, scrollTo]); function fileOpen(file: DiffViewFile) { return !file.binary && !collapsed.has(file.file); } function toggleFile(file: DiffViewFile, open: boolean) { select(file.file); if (file.binary) return; if (!open) onFileOpen?.(file.file); setCollapsed(previous => { let next = new Set(previous); if (open) next.add(file.file); else next.delete(file.file); return next; }); } function toggleMode() { let next: DiffViewMode = view === "unified" ? "split" : "unified"; setInternalMode(next); onModeChange?.(next); } function toggleAll() { if (!allExpanded) { for (let file of expandable) onFileOpen?.(file.file); } setCollapsed(allExpanded ? new Set(expandable.map(file => file.file)) : new Set()); } let header = (item: CodeViewItem) => { let file = index.get(item.id); if (!file) return null; let open = fileOpen(file); return (
); }; useLayoutEffect(() => { let view = code.current?.getInstance(); if (!view || view.config.overscrollSize === BUFFER) return; // Keep fast momentum scrolls inside CodeView's rendered window. view.config.overscrollSize = BUFFER; view.render(true); }, []); return { files, base, head, pr, creatingPr, preparingPr, onOpenPr, onCreatePr, onCreateDraftPr, onManualCreatePr, className, summary, view, toggleMode, allExpanded, toggleAll, expandable, treeOpen, setTreeOpen, paths, active, activate, decorations, code, items, options, header, }; } function DiffViewContent(props: DiffViewProps) { let { files, base, head, pr, creatingPr, preparingPr, onOpenPr, onCreatePr, onCreateDraftPr, onManualCreatePr, className, summary, view, toggleMode, allExpanded, toggleAll, expandable, treeOpen, setTreeOpen, paths, active, activate, decorations, code, items, options, header, } = useDiffView(props); if (files.length === 0) { return (
No file changes
); } return (
{head} -> {base}
+{summary.adds} -{summary.dels}
{view === "unified" ? : } {expandable.length ? ( {allExpanded ? : } ) : null} setTreeOpen(open => !open)} >
{treeOpen ? ( activate(file, true)} /> ) : null}
); } export { DiffView }; export type { DiffViewFile, DiffViewMode, DiffViewPr, DiffViewProps };