import React, { useState, useCallback, useMemo, useEffect } from "react"; import { createRoot } from "react-dom/client"; import { PatchDiff, MultiFileDiff } from "@pierre/diffs/react"; import type { FileDiffOptions } from "@pierre/diffs/react"; interface CommitInfo { hash: string; message: string; time: string; diff: string; } interface DiffData { staged: string; unstaged: string; untracked: { path: string; content: string }[]; repoName: string; branch: string; commits: CommitInfo[]; } interface FileEntry { id: string; name: string; path: string; section: "staged" | "unstaged" | "untracked" | "committed"; additions: number; deletions: number; patch?: string; content?: string; } declare global { interface Window { updateDiffs: (data: DiffData) => void; } } const SECTION_COLORS = { staged: "#00cab1", unstaged: "#F59E0B", untracked: "#3B82F6", committed: "#9CA3AF", } as const; const SECTION_LABELS = { staged: "Staged", unstaged: "Unstaged", untracked: "Untracked", committed: "Committed", } as const; const diffOptions: FileDiffOptions = { theme: "pierre-dark", diffStyle: "unified", overflow: "scroll", themeType: "dark", }; /** Split a combined git diff into individual per-file patches. */ function splitPatch(patch: string): string[] { const parts: string[] = []; const lines = patch.split("\n"); let current: string[] = []; for (const line of lines) { if (line.startsWith("diff --git ") && current.length > 0) { parts.push(current.join("\n")); current = []; } current.push(line); } if (current.length > 0 && current.some((l) => l.startsWith("diff --git "))) { parts.push(current.join("\n")); } return parts; } /** Extract file path from a git diff header like "diff --git a/foo/bar.ts b/foo/bar.ts" */ function extractPathFromPatch(patch: string): string { const match = patch.match(/^diff --git a\/(.*?) b\/(.*)/m); if (match) return match[2]; return "unknown"; } /** Count additions and deletions from a patch */ function countChanges(patch: string): { additions: number; deletions: number } { let additions = 0; let deletions = 0; for (const line of patch.split("\n")) { if (line.startsWith("@@")) continue; // skip hunk headers if (line.startsWith("+") && !line.startsWith("+++")) additions++; if (line.startsWith("-") && !line.startsWith("---")) deletions++; } return { additions, deletions }; } /** Build file entries for working changes */ function buildWorkingEntries(data: DiffData): FileEntry[] { const entries: FileEntry[] = []; if (data.staged.trim()) { for (const patch of splitPatch(data.staged)) { const path = extractPathFromPatch(patch); const { additions, deletions } = countChanges(patch); entries.push({ id: `staged:${path}`, name: path.split("/").pop() || path, path, section: "staged", additions, deletions, patch, }); } } if (data.unstaged.trim()) { for (const patch of splitPatch(data.unstaged)) { const path = extractPathFromPatch(patch); const { additions, deletions } = countChanges(patch); entries.push({ id: `unstaged:${path}`, name: path.split("/").pop() || path, path, section: "unstaged", additions, deletions, patch, }); } } for (const { path, content } of data.untracked) { const lineCount = content.split("\n").length; entries.push({ id: `untracked:${path}`, name: path.split("/").pop() || path, path, section: "untracked", additions: lineCount, deletions: 0, content, }); } return entries; } /** Build file entries for a specific commit */ function buildCommitEntries(commit: CommitInfo): FileEntry[] { const entries: FileEntry[] = []; for (const patch of splitPatch(commit.diff)) { const path = extractPathFromPatch(patch); const { additions, deletions } = countChanges(patch); entries.push({ id: `commit:${commit.hash}:${path}`, name: path.split("/").pop() || path, path, section: "committed", additions, deletions, patch, }); } return entries; } /** Check if there are any working changes */ function hasWorkingChanges(data: DiffData): boolean { return ( data.staged.trim().length > 0 || data.unstaged.trim().length > 0 || data.untracked.length > 0 ); } // ─── Sidebar ─── function SidebarFile({ file, active, tabbed, onClick, }: { file: FileEntry; active: boolean; tabbed: boolean; onClick: () => void; }) { const color = SECTION_COLORS[file.section]; return (
{file.name} {tabbed && }
{file.path !== file.name ? file.path : ""}
{file.additions > 0 && +{file.additions}} {file.deletions > 0 && −{file.deletions}}
); } function SidebarSection({ section, files, activeId, openTabIds, onFileClick, }: { section: "staged" | "unstaged" | "untracked"; files: FileEntry[]; activeId: string | null; openTabIds: Set; onFileClick: (file: FileEntry) => void; }) { if (files.length === 0) return null; const color = SECTION_COLORS[section]; const label = SECTION_LABELS[section]; return (
{label} {files.length}
{files.map((f) => ( onFileClick(f)} /> ))}
); } // ─── Commit List ─── function CommitList({ data, selectedCommitId, workingFileCount, onSelect, }: { data: DiffData; selectedCommitId: string; workingFileCount: number; onSelect: (id: string) => void; }) { const dirty = hasWorkingChanges(data); return (
Commits
{dirty && (
onSelect("working")} style={selectedCommitId === "working" ? { borderLeftColor: "#E5C07B" } : undefined} >
Working Changes {workingFileCount}
)} {data.commits.map((commit) => { const isActive = selectedCommitId === commit.hash; return (
onSelect(commit.hash)} style={isActive ? { borderLeftColor: "rgba(255,255,255,0.3)" } : undefined} >
{commit.hash.slice(0, 7)} {commit.message} {commit.time}
); })}
); } // ─── Tabs ─── function TabBar({ tabs, activeId, filesMap, onSelect, onClose, }: { tabs: string[]; activeId: string | null; filesMap: Map; onSelect: (id: string) => void; onClose: (id: string) => void; }) { if (tabs.length === 0) return null; return (
{tabs.map((id) => { const file = filesMap.get(id); if (!file) return null; const color = SECTION_COLORS[file.section]; const isActive = id === activeId; return (
onSelect(id)} > {file.name} { e.stopPropagation(); onClose(id); }} > ×
); })}
); } // ─── Diff Content ─── function DiffView({ file }: { file: FileEntry }) { if (file.section === "untracked") { return (
); } return (
); } // ─── Main App ─── function App({ data }: { data: DiffData }) { const dirty = hasWorkingChanges(data); const defaultCommitId = dirty ? "working" : (data.commits[0]?.hash ?? "working"); const [selectedCommitId, setSelectedCommitId] = useState(defaultCommitId); const [openTabs, setOpenTabs] = useState([]); const [activeId, setActiveId] = useState(null); // Validate selectedCommitId when data changes (e.g. after rebase, amend) useEffect(() => { if (selectedCommitId === "working") return; const stillExists = data.commits.some((c) => c.hash === selectedCommitId); if (!stillExists) { const newDefault = hasWorkingChanges(data) ? "working" : (data.commits[0]?.hash ?? "working"); setSelectedCommitId(newDefault); } }, [data]); // Files for current selection const files = useMemo(() => { if (selectedCommitId === "working") return buildWorkingEntries(data); const commit = data.commits.find((c) => c.hash === selectedCommitId); return commit ? buildCommitEntries(commit) : []; }, [selectedCommitId, data]); const filesMap = useMemo(() => new Map(files.map((f) => [f.id, f])), [files]); const workingFileCount = useMemo(() => buildWorkingEntries(data).length, [data]); // When commit selection changes, reset tabs and auto-select first file useEffect(() => { if (files.length > 0) { const first = files[0]; setOpenTabs([first.id]); setActiveId(first.id); } else { setOpenTabs([]); setActiveId(null); } }, [selectedCommitId, files]); // When data updates (same commit), clean up tabs that no longer exist useEffect(() => { const validIds = new Set(files.map((f) => f.id)); setOpenTabs((prev) => { const next = prev.filter((id) => validIds.has(id)); return next; }); setActiveId((prev) => { if (prev && validIds.has(prev)) return prev; return files[0]?.id || null; }); }, [files]); const handleFileClick = useCallback((file: FileEntry) => { setOpenTabs((prev) => (prev.includes(file.id) ? prev : [...prev, file.id])); setActiveId(file.id); }, []); const handleTabSelect = useCallback((id: string) => { setActiveId(id); }, []); const handleTabClose = useCallback( (id: string) => { setOpenTabs((prev) => { const next = prev.filter((t) => t !== id); if (activeId === id) { const idx = prev.indexOf(id); const newActive = next[Math.min(idx, next.length - 1)] || null; setTimeout(() => setActiveId(newActive), 0); } return next; }); }, [activeId] ); const grouped = useMemo(() => { const staged = files.filter((f) => f.section === "staged"); const unstaged = files.filter((f) => f.section === "unstaged"); const untracked = files.filter((f) => f.section === "untracked"); return { staged, unstaged, untracked }; }, [files]); const openTabSet = useMemo(() => new Set(openTabs), [openTabs]); const activeFile = activeId ? filesMap.get(activeId) : null; const totalFiles = files.length; const handleCommitSelect = useCallback((id: string) => { setSelectedCommitId(id); }, []); if (!dirty && data.commits.length === 0) { return
No changes
; } return (
{/* Sidebar */}
{data.branch && ( {data.branch} )} {totalFiles} file{totalFiles !== 1 ? "s" : ""}
{selectedCommitId === "working" ? ( (["staged", "unstaged", "untracked"] as const).map((s) => ( )) ) : ( files.map((f) => ( handleFileClick(f)} /> )) )}
{/* Main panel */}
{openTabs.map((id) => { const file = filesMap.get(id); if (!file) return null; return (
); })} {openTabs.length === 0 && (
Select a file from the sidebar
)}
); } // Error boundary class ErrorBoundary extends React.Component< { children: React.ReactNode }, { error: Error | null } > { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } render() { if (this.state.error) { return (

Render Error

            {this.state.error.message}
            {"\n\n"}
            {this.state.error.stack}
          
); } return this.props.children; } } const root = createRoot(document.getElementById("app")!); window.updateDiffs = (data: DiffData) => { const loading = document.getElementById("loading"); if (loading) loading.style.display = "none"; document.getElementById("app")!.style.display = "block"; root.render( ); }; // Signal to Glimpse that the viewer bundle is loaded and ready try { (window as any).webkit.messageHandlers.glimpse.postMessage( JSON.stringify({ type: "viewer-ready" }) ); } catch {}