import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui"; import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs"; import type { PatchDiffProps } from "@pierre/diffs/react"; import { useFileDiffInstance } from "@pierre/diffs/react"; import { createElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { diffSummary, fileName, initialExpandedFileSet, nextExpandedFileSet, statusLabel, toFileViewModels, type DiffFileViewModel, type DiffPatchViewModel, type DiffRenderMode, } from "../diff-model.js"; import type { WorkspaceDiffResponse } from "../contracts.js"; type WorkspaceDiffData = WorkspaceDiffResponse; type WorkspacePatchDiffOptions = PatchDiffProps["options"]; type DiffViewMode = "working-tree" | "head"; type LucideIconProps = { size?: number }; function makeLucideIcon(paths: ReactNode) { return function LucideIcon({ size = 16 }: LucideIconProps) { return ( ); }; } // Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw. const RefreshCwIcon = makeLucideIcon( <> , ); function readInitialView(): DiffViewMode { if (typeof window === "undefined") return "working-tree"; return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree"; } function readInitialBaseRef() { if (typeof window === "undefined") return ""; return new URLSearchParams(window.location.search).get("baseRef") ?? ""; } function buttonClass(active = false) { return [ "inline-flex h-8 items-center justify-center rounded-md border px-2.5 text-xs font-medium transition-colors", active ? "border-foreground/20 bg-foreground text-background" : "border-border bg-background text-muted-foreground hover:text-foreground", ].join(" "); } function iconButtonClass(active = false) { return [ "inline-flex h-7 w-7 items-center justify-center rounded-md border text-xs transition-colors", active ? "border-foreground/20 bg-foreground text-background" : "border-border bg-background text-muted-foreground hover:text-foreground", ].join(" "); } function warningText(file: DiffFileViewModel) { if (file.binary) return "Binary file"; if (file.oversized) return "Too large to render"; if (file.truncated) return "Patch truncated"; if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning"; if (file.patches.every((patch) => !patch.patch)) return "No text patch"; return null; } const PATCH_KIND_LABELS: Record = { staged: "Staged", unstaged: "Unstaged", head: "Head", untracked: "Untracked", }; function patchKindLabel(kind: DiffPatchViewModel["kind"]) { return PATCH_KIND_LABELS[kind] ?? "Patch"; } function patchWarningText(patch: DiffPatchViewModel) { if (patch.binary) return "Binary file"; if (patch.oversized) return "Too large to render"; if (patch.truncated) return "Patch truncated"; if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning"; if (!patch.patch) return "No text patch"; return null; } function FileRow({ file, active, expanded, onSelect, onToggle, onCopy, }: { file: DiffFileViewModel; active: boolean; expanded: boolean; onSelect: () => void; onToggle: () => void; onCopy: () => void; }) { const warning = warningText(file); const expandLabel = expanded ? "Collapse file" : "Expand file"; const fileAriaLabel = expanded ? `Collapse ${file.path}` : `Expand ${file.path}`; return (
{statusLabel(file.status)} {`+${file.additions}`} {`-${file.deletions}`} {warning ? {warning} : null}
); } // The upstream React wrapper emits React 19 key warnings for its internal slot array. // This mounts the same Diffs custom element through the exported imperative hook. function WorkspacePatchDiff({ patch, options, }: { patch: string; options: WorkspacePatchDiffOptions; }) { const fileDiff = useMemo(() => getSingularPatch(patch), [patch]); const { ref } = useFileDiffInstance({ fileDiff, options, metrics: undefined, lineAnnotations: undefined, selectedLines: undefined, prerenderedHTML: undefined, hasGutterRenderUtility: false, hasCustomHeader: false, disableWorkerPool: false, }); return createElement(DIFFS_TAG_NAME, { ref }); } function EmptyState() { return (
No workspace changes
The workspace matches its current comparison target.
); } function LoadingState() { return (
Loading workspace changes…
); } export function ErrorState({ message, onRetry, }: { message: string; onRetry: () => void; }) { return (
Unable to load workspace changes.
Retry the request or open the details below for the technical error.
Troubleshooting details
          {message || "No error message was provided."}
        
); } function FileDiffPanel({ file, mode, }: { file: DiffFileViewModel; mode: DiffRenderMode; }) { const warning = warningText(file); if (warning) { return (
{warning ?? "No renderable patch is available for this file."}
); } return (
{file.patches.map((patch, index) => { const patchWarning = patchWarningText(patch); return (
{file.patches.length > 1 ? (
{patchKindLabel(patch.kind)} {`+${patch.additions}`} {`-${patch.deletions}`}
) : null} {patchWarning || !patch.patch ? (
{patchWarning ?? "No renderable patch is available for this file."}
) : ( )}
); })}
); } function CollapsedFilePanel({ file, onExpand, }: { file: DiffFileViewModel; onExpand: () => void; }) { const title = file.longDiff ? "Large diff folded" : "Diff folded"; const details = file.lineCount > 0 ? `${file.lineCount.toLocaleString()} lines` : statusLabel(file.status); return (
{title}
{details}
); } export function ChangesTab({ context }: PluginDetailTabProps) { const toast = usePluginToast(); const [mode, setMode] = useState("split"); const [view, setView] = useState(() => readInitialView()); const [baseRef, setBaseRef] = useState(() => readInitialBaseRef()); const baseRefTouchedRef = useRef(Boolean(baseRef.trim())); const [includeUntracked, setIncludeUntracked] = useState(false); const [expandedFiles, setExpandedFiles] = useState>(() => new Set()); const [selectedPath, setSelectedPath] = useState(null); const fileSectionRefs = useRef(new Map()); const diffScrollRef = useRef(null); const scrollSyncFrameRef = useRef(null); const params = useMemo(() => ({ workspaceId: context.entityId, companyId: context.companyId ?? "", projectId: context.projectId ?? "", entityType: context.entityType, view, baseRef: baseRef.trim() || null, includeUntracked, }), [baseRef, context.companyId, context.entityId, context.entityType, context.projectId, includeUntracked, view]); const { data, loading, error, refresh } = usePluginData("workspace-diff", params); const files = useMemo(() => toFileViewModels(data), [data]); const summary = useMemo(() => diffSummary(data), [data]); const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null; const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`; const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => { if (node) fileSectionRefs.current.set(filePath, node); else fileSectionRefs.current.delete(filePath); }, []); const selectFile = useCallback((filePath: string) => { setSelectedPath(filePath); window.requestAnimationFrame(() => { fileSectionRefs.current.get(filePath)?.scrollIntoView({ block: "start", behavior: "smooth", }); }); }, []); const syncSelectedPathFromScroll = useCallback(() => { const container = diffScrollRef.current; if (!container || files.length === 0) return; const containerTop = container.getBoundingClientRect().top; let nextPath = files[0]?.path ?? null; for (const file of files) { const section = fileSectionRefs.current.get(file.path); if (!section) continue; const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop; if (offsetFromScrollTop <= 48) { nextPath = file.path; } else { break; } } if (nextPath) { setSelectedPath((current) => current === nextPath ? current : nextPath); } }, [files]); const handleDiffScroll = useCallback(() => { if (scrollSyncFrameRef.current !== null) return; scrollSyncFrameRef.current = window.requestAnimationFrame(() => { scrollSyncFrameRef.current = null; syncSelectedPathFromScroll(); }); }, [syncSelectedPathFromScroll]); useEffect(() => { const defaultBaseRef = data?.defaultBaseRef?.trim(); if (!defaultBaseRef || baseRef.trim() || baseRefTouchedRef.current) return; setBaseRef(defaultBaseRef); }, [baseRef, data?.defaultBaseRef]); useEffect(() => { if (files.length === 0) { setExpandedFiles(new Set()); setSelectedPath(null); return; } setExpandedFiles(initialExpandedFileSet(files)); setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null); }, [files]); useEffect(() => { return () => { if (scrollSyncFrameRef.current !== null) { window.cancelAnimationFrame(scrollSyncFrameRef.current); } }; }, []); const copyPath = async (filePath: string) => { try { await navigator.clipboard.writeText(filePath); toast({ title: "Path copied", body: filePath }); } catch { toast({ title: "Copy failed", body: filePath, tone: "error" }); } }; return (
{summary.changedLabel} {summary.lineLabel} {summary.truncated ? ( Truncated ) : null} {summary.warningCount > 0 ? ( {summary.warningCount} warnings ) : null}
{compareLabel}
{view === "head" ? ( { baseRefTouchedRef.current = true; setBaseRef(event.target.value); }} placeholder="origin/master" aria-label="Base ref" /> ) : null} {view === "working-tree" ? ( ) : null}
{loading ? ( ) : error ? ( ) : files.length === 0 ? ( ) : (
{files .map((file, index) => (
{expandedFiles.has(file.path) ? ( ) : ( setExpandedFiles((current) => nextExpandedFileSet(current, file.path))} /> )}
))}
)}
); }