diff --git a/.changeset/live-pr-demo-shared-core.md b/.changeset/live-pr-demo-shared-core.md new file mode 100644 index 0000000..a5e1020 --- /dev/null +++ b/.changeset/live-pr-demo-shared-core.md @@ -0,0 +1,5 @@ +--- +"diffhub": patch +--- + +Fix the diff viewer occasionally rendering blank (line backgrounds only, no code) on first load until you scroll — the shared CodeView now reliably paints its first window. Internally, the diff viewer engine and chrome (status bar, file list, per-file header, sidebar) were extracted into a shared `@diffhub/diff-core` package that also powers the new diffhub.blode.co live PR viewer. diff --git a/README.md b/README.md index a458ae5..014c1a6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Local diff viewer for cmux. DiffHub opens your branch in a browser split so you can review it locally. By default it compares against the detected base branch, usually `origin/main`. +**Live demo:** browse any GitHub PR in the viewer — e.g. [diffhub.blode.co/oven-sh/bun/pull/16000](https://diffhub.blode.co/oven-sh/bun/pull/16000). + ![DiffHub screenshot](apps/cli/public/screenshot.png) ## Quick start diff --git a/apps/cli/app/globals.css b/apps/cli/app/globals.css index 8edf373..2e7bdc0 100644 --- a/apps/cli/app/globals.css +++ b/apps/cli/app/globals.css @@ -2,6 +2,10 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; +/* Scan the shared diff-viewer package so its utility classes (sidebar, chrome, + * button variants) are generated — Tailwind skips node_modules by default. */ +@source "../../../packages/diff-core/src"; + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/apps/cli/app/layout.tsx b/apps/cli/app/layout.tsx index 9741d97..3725b0b 100644 --- a/apps/cli/app/layout.tsx +++ b/apps/cli/app/layout.tsx @@ -2,7 +2,7 @@ import { Agentation } from "agentation"; import type { Metadata } from "next"; import { JetBrains_Mono } from "next/font/google"; import localFont from "next/font/local"; -import { DiffsWorkerProvider } from "@/components/DiffsWorkerProvider"; +import { DiffsWorkerProvider } from "@diffhub/diff-core/react"; import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; diff --git a/apps/cli/components/DiffApp.tsx b/apps/cli/components/DiffApp.tsx index b003da9..c78e01b 100644 --- a/apps/cli/components/DiffApp.tsx +++ b/apps/cli/components/DiffApp.tsx @@ -2,25 +2,27 @@ import type { AnnotationSide } from "@pierre/diffs"; import { useCallback, useEffect, useMemo, useRef, useState, startTransition } from "react"; -import { StatusBar } from "./StatusBar"; -import type { DiffMode, WatchStatus } from "./StatusBar"; -import { FileList } from "./FileList"; +import { useTheme } from "next-themes"; +import { FileList, SidebarInset, SidebarProvider, StatusBar } from "@diffhub/diff-core/react"; +import type { DiffMode } from "@diffhub/diff-core/react"; import { DiffViewer } from "./DiffViewer"; import type { DiffViewerHandle } from "./DiffViewer"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { Button } from "@/components/ui/button"; import { toCommentSide } from "@/lib/comment-sides"; import type { Comment, CommentTag } from "@/lib/comment-types"; +import { exportCommentsAsPrompt } from "@/lib/export-comments"; import { useLocalStorage } from "@/lib/use-local-storage"; +import { getWatchStatusMeta } from "@/lib/watch-status"; +import type { WatchStatus } from "@/lib/watch-status"; import { WATCH_STREAM_EVENTS } from "@/lib/watch-stream"; -import type { DisplaySettings } from "@/lib/display-settings"; +import type { DisplaySettings, DiffThemeSelection } from "@diffhub/diff-core"; import { + DEFAULT_DIFF_THEMES, DEFAULT_DISPLAY_SETTINGS, DISPLAY_SETTINGS_KEY, + normalizeDiffThemes, normalizeDisplaySettings, -} from "@/lib/display-settings"; -import type { DiffThemeSelection } from "@/lib/diff-themes"; -import { DEFAULT_DIFF_THEMES, normalizeDiffThemes } from "@/lib/diff-themes"; +} from "@diffhub/diff-core"; const DIFF_THEME_KEY = "diffhub-diff-theme"; @@ -823,6 +825,26 @@ export const DiffApp = ({ return true; }, []); + // The shared StatusBar is decoupled from next-themes / comment storage, so the + // CLI feeds it the color mode and a comment-export callback here. + const { theme, setTheme } = useTheme(); + const themeMode: "system" | "light" | "dark" = + theme === "light" || theme === "dark" ? theme : "system"; + + const commentsByFile = useMemo(() => { + const map = new Map(); + for (const comment of comments) { + map.set(comment.file, (map.get(comment.file) ?? 0) + 1); + } + return map; + }, [comments]); + + const handleCopyComments = useCallback(async () => { + const text = exportCommentsAsPrompt(comments); + await navigator.clipboard.writeText(text); + await handleClearComments(); + }, [comments, handleClearComments]); + const syncNotice = getSyncNotice(loadError, filesData); if (loadError && filesData === null) { @@ -851,7 +873,7 @@ export const DiffApp = ({ files={filesData?.files ?? []} selectedFile={selectedFile} onSelectFile={scrollToFile} - comments={comments} + commentsByFile={commentsByFile} filterQuery={filterQuery} onFilterChange={setFilterQuery} isLoading={filesData === null} @@ -870,9 +892,9 @@ export const DiffApp = ({ baseBranch={filesData?.baseBranch ?? "main"} refreshing={refreshing} onRefresh={handleManualRefresh} - watchStatus={watchStatus} - comments={comments} - onClearComments={handleClearComments} + watch={getWatchStatusMeta(watchStatus, refreshing)} + commentCount={comments.length} + onCopyComments={handleCopyComments} diffMode={diffMode} onDiffModeChange={handleDiffModeChange} layout={layout} @@ -885,6 +907,8 @@ export const DiffApp = ({ onDisplaySettingsChange={handleDisplaySettingsChange} diffThemes={diffThemes} onDiffThemesChange={handleDiffThemesChange} + themeMode={themeMode} + onThemeModeChange={setTheme} /> diff --git a/apps/cli/components/DiffViewer.tsx b/apps/cli/components/DiffViewer.tsx index fd1ee6d..e1d85af 100644 --- a/apps/cli/components/DiffViewer.tsx +++ b/apps/cli/components/DiffViewer.tsx @@ -27,11 +27,13 @@ import { useWorkerPool } from "@pierre/diffs/react"; import { toAnnotationSide } from "@/lib/comment-sides"; import type { Comment, CommentTag } from "@/lib/comment-types"; import type { DiffFileStat } from "@/lib/diff-file-stat"; -import { DEFAULT_DIFF_THEMES } from "@/lib/diff-themes"; -import { CODE_VIEW_LAYOUT } from "@/lib/diff-stream/constants"; -import { usePatchLoader } from "./use-patch-loader"; -import { useIsWorkerPoolReady } from "./use-worker-pool-ready"; -import { FileDiffHeader } from "./FileDiffHeader"; +import { CODE_VIEW_LAYOUT, DEFAULT_DIFF_THEMES } from "@diffhub/diff-core"; +import { + FileDiffHeader, + useCodeViewPaintNudge, + useIsWorkerPoolReady, + usePatchLoader, +} from "@diffhub/diff-core/react"; import { cn } from "@/lib/utils"; import { BranchIcon, CopySimpleIcon, TrashIcon, CheckIcon } from "blode-icons-react"; import { Button } from "@/components/ui/button"; @@ -520,6 +522,7 @@ const DiffViewerInner = ( const codeViewRef = useRef | null>(null); const containerRef = useRef(null); + const diffRootRef = useRef(null); // Single active inline-comment input target (gutter "+"). const [commentTarget, setCommentTarget] = useState(null); @@ -606,20 +609,28 @@ const DiffViewerInner = ( setCommentTarget(null); }, []); - const diffQuery = useMemo( - () => (diffMode === "uncommitted" ? "?mode=uncommitted" : ""), + const endpoint = useMemo( + () => (diffMode === "uncommitted" ? "/api/diff?mode=uncommitted" : "/api/diff"), [diffMode], ); const { initialItems, loadState, errorMessage, viewerKey, retry } = usePatchLoader({ - diffQuery, + endpoint, onReset: handleReset, prepareItems, reloadKey, viewerRef: codeViewRef, }); + // Force CodeView's first window to paint (Chrome can skip compositing the + // freshly-mounted shadow-DOM grid until a repaint is forced). + useCodeViewPaintNudge( + diffRootRef, + isWorkerReady && (loadState === "ready" || initialItems.length > 0), + viewerKey, + ); + // ── Push theme changes into the worker pool ──────────────────────────────── // Background tokenizers keep the pair they were initialized with unless we // tell them otherwise, so re-resolve on every theme/themeType change. @@ -924,7 +935,7 @@ const DiffViewerInner = ( } return ( -
+
+ + + + +
-
+ DiffHub showing a branch diff with file sidebar and split view -
+
diff --git a/apps/web/app/(viewer)/[owner]/[repo]/pull/[number]/PrDiffViewer.tsx b/apps/web/app/(viewer)/[owner]/[repo]/pull/[number]/PrDiffViewer.tsx new file mode 100644 index 0000000..a93db69 --- /dev/null +++ b/apps/web/app/(viewer)/[owner]/[repo]/pull/[number]/PrDiffViewer.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { + DEFAULT_DIFF_THEMES, + DEFAULT_DISPLAY_SETTINGS, + normalizeDiffThemes, + normalizeDisplaySettings, +} from "@diffhub/diff-core"; +import type { DiffThemeSelection, DisplaySettings } from "@diffhub/diff-core"; +import { + FileDiffHeader, + FileList, + ReadOnlyDiffView, + SidebarInset, + SidebarProvider, + StatusBar, +} from "@diffhub/diff-core/react"; +import type { + DiffFileStat, + DiffHeaderInfo, + ReadOnlyDiffViewHandle, + ViewerFile, +} from "@diffhub/diff-core/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const LAYOUT_KEY = "diffhub-demo-layout"; +const THEME_KEY = "diffhub-diff-theme"; +const DISPLAY_KEY = "diffhub-display-settings"; +const COLOR_MODE_KEY = "diffhub-color-mode"; + +type Layout = "split" | "unified"; +type ColorMode = "system" | "light" | "dark"; + +const normalizeColorMode = (value: unknown): ColorMode => + value === "light" || value === "system" ? value : "dark"; + +const readStored = (key: string, normalize: (value: unknown) => T, fallback: T): T => { + if (typeof window === "undefined") { + return fallback; + } + try { + const raw = window.localStorage.getItem(key); + return raw === null ? fallback : normalize(JSON.parse(raw)); + } catch { + return fallback; + } +}; + +const usePersisted = ( + key: string, + normalize: (value: unknown) => T, + fallback: T, +): [T, (next: T) => void] => { + const [value, setValue] = useState(() => readStored(key, normalize, fallback)); + const set = useCallback( + (next: T) => { + setValue(next); + try { + window.localStorage.setItem(key, JSON.stringify(next)); + } catch { + // ignore quota / privacy-mode errors + } + }, + [key], + ); + return [value, set]; +}; + +const normalizeLayout = (value: unknown): Layout => (value === "split" ? "split" : "unified"); + +interface PrDiffViewerProps { + owner: string; + repo: string; + number: string; + prUrl: string; + baseRef: string; + headRef: string; +} + +export const PrDiffViewer = ({ + owner, + repo, + number, + prUrl, + baseRef, + headRef, +}: PrDiffViewerProps): React.JSX.Element => { + const [layout, setLayout] = usePersisted(LAYOUT_KEY, normalizeLayout, "unified"); + const [themes, setThemes] = usePersisted( + THEME_KEY, + normalizeDiffThemes, + DEFAULT_DIFF_THEMES, + ); + const [display, setDisplay] = usePersisted( + DISPLAY_KEY, + normalizeDisplaySettings, + DEFAULT_DISPLAY_SETTINGS, + ); + + const [colorMode, setColorMode] = usePersisted( + COLOR_MODE_KEY, + normalizeColorMode, + "dark", + ); + const [files, setFiles] = useState([]); + const [activeFile, setActiveFile] = useState(null); + const [statsOpen, setStatsOpen] = useState(true); + const [allCollapsed, setAllCollapsed] = useState(false); + const [filterQuery, setFilterQuery] = useState(""); + const [sidebarWidth, setSidebarWidth] = useState(288); + const [systemDark, setSystemDark] = useState(true); + const viewerRef = useRef(null); + + // Resolve Auto/Light/Dark to an effective mode, mirroring the CLI's behavior. + useEffect(() => { + const query = window.matchMedia("(prefers-color-scheme: dark)"); + const update = () => setSystemDark(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + const isDark = colorMode === "dark" || (colorMode === "system" && systemDark); + + // Scope the palette at the document level too, so base-ui's portaled popups + // (theme/settings dropdowns, tooltips) inherit it instead of the light + // marketing tokens. + useEffect(() => { + document.documentElement.classList.add("diffhub-app"); + return () => document.documentElement.classList.remove("diffhub-app", "diffhub-light"); + }, []); + useEffect(() => { + document.documentElement.classList.toggle("diffhub-light", !isDark); + }, [isDark]); + + const endpoint = useMemo( + () => + `/api/github-diff?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent( + repo, + )}&number=${encodeURIComponent(number)}`, + [owner, repo, number], + ); + const reloadKey = `${owner}/${repo}/${number}`; + + const scrollToFile = useCallback((id: string) => { + setActiveFile(id); + viewerRef.current?.scrollToFile(id); + }, []); + const collapseAll = useCallback(() => viewerRef.current?.collapseAll(), []); + const expandAll = useCallback(() => viewerRef.current?.expandAll(), []); + + // The StatusBar speaks "split" | "stacked" (CLI vocabulary); the viewer speaks + // "split" | "unified". Translate between the two. + const statusBarLayout = layout === "unified" ? "stacked" : "split"; + const handleLayoutChange = useCallback( + (next: "split" | "stacked") => setLayout(next === "split" ? "split" : "unified"), + [setLayout], + ); + + const renderHeader = useCallback( + (info: DiffHeaderInfo) => ( + + ), + [], + ); + + // j / k move through files and scroll the diff to the target. + const filesRef = useRef(files); + filesRef.current = files; + const activeRef = useRef(activeFile); + activeRef.current = activeFile; + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "j" && event.key !== "k") { + return; + } + const target = event.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target?.isContentEditable) { + return; + } + const list = filesRef.current; + if (list.length === 0) { + return; + } + event.preventDefault(); + const currentIndex = list.findIndex((file) => file.id === activeRef.current); + const delta = event.key === "j" ? 1 : -1; + const nextIndex = Math.min( + list.length - 1, + Math.max(0, (currentIndex === -1 ? 0 : currentIndex) + delta), + ); + scrollToFile(list[nextIndex].id); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [scrollToFile]); + + const totals = useMemo(() => { + let insertionsSum = 0; + let deletionsSum = 0; + for (const file of files) { + insertionsSum += file.insertions; + deletionsSum += file.deletions; + } + return { deletions: deletionsSum, insertions: insertionsSum }; + }, [files]); + + // The shared FileList renders a `@pierre/trees` tree from DiffFileStat records. + const fileStats = useMemo( + () => + files.map((file) => ({ + binary: false, + changes: file.insertions + file.deletions, + deletions: file.deletions, + file: file.path, + insertions: file.insertions, + })), + [files], + ); + + return ( + + + + +
+ +
+ + +
+
+ ); +}; diff --git a/apps/web/app/(viewer)/[owner]/[repo]/pull/[number]/page.tsx b/apps/web/app/(viewer)/[owner]/[repo]/pull/[number]/page.tsx new file mode 100644 index 0000000..11c05bc --- /dev/null +++ b/apps/web/app/(viewer)/[owner]/[repo]/pull/[number]/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { fetchPrMeta, githubPrUrl, isGithubError, parseRepoParams } from "@/lib/github"; +import { PrDiffViewer } from "./PrDiffViewer"; + +interface PageParams { + owner: string; + repo: string; + number: string; +} + +export const generateMetadata = async ({ + params, +}: { + params: Promise; +}): Promise => { + const { owner, repo, number } = await params; + const title = `${owner}/${repo} #${number} · DiffHub`; + return { + description: `Browse the diff for ${owner}/${repo} pull request #${number} in DiffHub's live PR viewer.`, + robots: { follow: true, index: false }, + title, + }; +}; + +export default async function PullRequestPage({ + params, +}: { + params: Promise; +}): Promise { + const raw = await params; + const repoParams = parseRepoParams(raw); + if (repoParams === null) { + notFound(); + } + + const meta = await fetchPrMeta(repoParams); + if (isGithubError(meta) && meta.status === 404) { + notFound(); + } + + const { owner, repo, number } = repoParams; + const hasMeta = !isGithubError(meta); + + return ( + + ); +} diff --git a/apps/web/app/(viewer)/layout.tsx b/apps/web/app/(viewer)/layout.tsx new file mode 100644 index 0000000..0403935 --- /dev/null +++ b/apps/web/app/(viewer)/layout.tsx @@ -0,0 +1,24 @@ +import { DiffsWorkerProvider } from "@diffhub/diff-core/react"; +import { JetBrains_Mono } from "next/font/google"; + +// Diff body font, matching the CLI viewer's monospace. +const jetbrainsMono = JetBrains_Mono({ + display: "swap", + subsets: ["latin"], + variable: "--font-jetbrains-mono", +}); + +// The live-demo viewer is full-bleed, mirroring the DiffHub CLI: it skips the +// marketing Navbar/Footer and mounts the diff worker pool (Shiki highlighting). +// The `.diffhub-app` palette scope + light/dark toggle is applied by PrDiffViewer +// (on both the viewer root and , so portaled popups inherit it); this +// wrapper only carries the JetBrains Mono font variable. +export default function ViewerLayout({ + children, +}: Readonly<{ children: React.ReactNode }>): React.JSX.Element { + return ( + +
{children}
+
+ ); +} diff --git a/apps/web/app/api/github-diff/route.ts b/apps/web/app/api/github-diff/route.ts new file mode 100644 index 0000000..60f5f69 --- /dev/null +++ b/apps/web/app/api/github-diff/route.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from "next/server"; +import { fetchPrDiff, parseRepoParams } from "@/lib/github"; + +export const runtime = "nodejs"; + +const textResponse = (body: string, status: number): Response => + new Response(body, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + status, + }); + +// Streams a GitHub PR's raw unified diff (text/plain) so the client viewer can +// parse and render files as the patch arrives. The browser only ever calls this +// same-origin route; GitHub is reached server-side with Next's data cache. +export const GET = async (request: NextRequest): Promise => { + const { searchParams } = request.nextUrl; + const params = parseRepoParams({ + number: searchParams.get("number") ?? "", + owner: searchParams.get("owner") ?? "", + repo: searchParams.get("repo") ?? "", + }); + + if (params === null) { + return textResponse("Invalid repository or pull request reference.", 400); + } + + try { + const result = await fetchPrDiff(params); + if (!result.ok) { + return textResponse(result.error.message, result.error.status === 404 ? 404 : 502); + } + return textResponse(result.diff, 200); + } catch (error) { + console.error("[diffhub] /api/github-diff failed", { error }); + return textResponse("Failed to load this diff.", 502); + } +}; diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 077b31a..aaf3d00 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -3,6 +3,10 @@ @import "shadcn/tailwind.css"; @plugin "@tailwindcss/typography"; +/* Scan the shared diff-viewer package so its utility classes (sidebar, chrome, + * button variants) are generated — Tailwind skips node_modules by default. */ +@source "../../../packages/diff-core/src"; + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -40,6 +44,11 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); + /* Diff-specific semantic tokens (used by the live PR demo viewer) */ + --color-diff-green: var(--diff-green); + --color-diff-purple: var(--diff-purple); + --color-diff-selected: var(--diff-selected); + --color-diff-success: var(--diff-success); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -120,6 +129,90 @@ } } +/* + * Live PR demo viewer — scoped dark theme that mirrors the DiffHub CLI viewer + * (apps/cli). Applied via the `.diffhub-app` wrapper on the (viewer) route only, + * so the marketing site stays light. Token values are copied from the CLI's + * `.dark` palette (achromatic + GitHub Primer diff colors). + */ +.diffhub-app { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --link: oklch(0.708 0.12 250); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.922 0 0); + --sidebar-primary-foreground: oklch(0.205 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + /* Diff-specific tokens (GitHub Primer dark) */ + --diff-green: #3fb950; + --diff-purple: oklch(0.78 0.12 300); + --diff-selected: oklch(0.269 0 0); + --diff-success: #3fb950; + /* The diff body + monospace UI use JetBrains Mono, like the CLI. */ + --font-mono: var(--font-jetbrains-mono), ui-monospace, "SFMono-Regular", Menlo, monospace; + --diffs-font-family: var(--font-jetbrains-mono), ui-monospace, monospace; + background-color: var(--background); + color: var(--foreground); + color-scheme: dark; +} + +/* Light color mode for the viewer (mirrors the CLI's light palette), toggled by + * the Auto/Light/Dark control. The `.diffhub-light` class is added alongside + * `.diffhub-app` on both the viewer root and (so portaled popups match). */ +.diffhub-app.diffhub-light { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --link: oklch(0.55 0.2 250); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --diff-green: #1a7f37; + --diff-selected: oklch(0.93 0 0); + --diff-success: #1a7f37; + color-scheme: light; +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/apps/web/components/shared/demo-launcher.tsx b/apps/web/components/shared/demo-launcher.tsx new file mode 100644 index 0000000..6240052 --- /dev/null +++ b/apps/web/components/shared/demo-launcher.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { Button } from "@/components/ui/button"; + +// Accepts a full GitHub PR URL or an owner/repo#123 / owner/repo/pull/123 shorthand. +const parsePrPath = (raw: string): string | null => { + const value = raw.trim(); + if (value === "") { + return null; + } + const patterns = [ + /github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/i, + /^([\w.-]+)\/([\w.-]+)\/pull\/(\d+)$/i, + /^([\w.-]+)\/([\w.-]+)#(\d+)$/i, + ]; + for (const pattern of patterns) { + const match = value.match(pattern); + if (match) { + return `/${match[1]}/${match[2]}/pull/${match[3]}`; + } + } + return null; +}; + +export const DemoLauncher = (): React.JSX.Element => { + const router = useRouter(); + const [value, setValue] = useState(""); + const [error, setError] = useState(false); + + const handleSubmit = useCallback( + (event: React.FormEvent): void => { + event.preventDefault(); + const path = parsePrPath(value); + if (path === null) { + setError(true); + return; + } + setError(false); + router.push(path); + }, + [value, router], + ); + + const handleChange = useCallback((event: React.ChangeEvent): void => { + setValue(event.target.value); + setError(false); + }, []); + + return ( +
+
+ + +
+

+ {error ? ( + Enter a GitHub pull request URL. + ) : ( + <> + Try{" "} + + oven-sh/bun#16000 + + + )} +

+
+ ); +}; diff --git a/apps/web/lib/config.ts b/apps/web/lib/config.ts index be49dd9..22d9f07 100644 --- a/apps/web/lib/config.ts +++ b/apps/web/lib/config.ts @@ -2,6 +2,7 @@ export const siteConfig = { description: "Local diff viewer for cmux. Review your branch against the detected base branch.", links: { author: "https://matthewblode.com", + demo: "/oven-sh/bun/pull/16000", docs: "https://diffhub.blode.co/docs", github: "https://github.com/mblode/diffhub", loom: "https://www.loom.com/share/e0203dd97b354508a791ecd339094a02", diff --git a/apps/web/lib/github.ts b/apps/web/lib/github.ts new file mode 100644 index 0000000..4f87935 --- /dev/null +++ b/apps/web/lib/github.ts @@ -0,0 +1,148 @@ +// Server-side helpers for the live PR demo. The browser never talks to GitHub +// directly (the site CSP only allows same-origin connect-src); these run on the +// server and rely on Next's data cache (revalidate) to stay under GitHub's +// unauthenticated rate limit. + +const GITHUB_API = "https://api.github.com"; +const OWNER_REPO_PATTERN = /^[A-Za-z0-9._-]+$/; + +// Cache each PR response for an hour. A burst of distinct PRs can still exhaust +// the ~60 req/hr/IP unauthenticated limit; that surfaces as a 429 to the client. +const REVALIDATE_SECONDS = 3600; + +export interface RepoParams { + owner: string; + repo: string; + number: string; +} + +export interface PrMeta { + title: string; + number: number; + state: "open" | "closed"; + merged: boolean; + htmlUrl: string; + authorLogin: string | null; + authorUrl: string | null; + baseRef: string; + headRef: string; + additions: number; + deletions: number; + changedFiles: number; +} + +export interface GithubError { + status: number; + message: string; +} + +/** Validate untrusted route params before they reach a GitHub URL. */ +export const parseRepoParams = (params: { + owner: string; + repo: string; + number: string; +}): RepoParams | null => { + const { owner, repo, number } = params; + if (!OWNER_REPO_PATTERN.test(owner) || !OWNER_REPO_PATTERN.test(repo)) { + return null; + } + if (!/^\d+$/.test(number) || number.length > 12) { + return null; + } + return { number, owner, repo }; +}; + +const baseHeaders = { + "User-Agent": "diffhub-live-demo", +}; + +const messageForStatus = (status: number, fallback: string): string => { + if (status === 404) { + return "Pull request not found."; + } + if (status === 403 || status === 429) { + return "GitHub rate limit reached. Try again in a little while."; + } + if (status === 406 || status === 422) { + return "This diff is too large for GitHub to generate."; + } + return fallback; +}; + +/** Fetch the PR metadata used for the header. Returns a GithubError on failure. */ +export const fetchPrMeta = async ({ + owner, + repo, + number, +}: RepoParams): Promise => { + const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/pulls/${number}`, { + headers: { ...baseHeaders, Accept: "application/vnd.github+json" }, + next: { revalidate: REVALIDATE_SECONDS }, + }); + + if (!response.ok) { + return { + message: messageForStatus(response.status, "Failed to load this pull request."), + status: response.status, + }; + } + + const data = (await response.json()) as { + title: string; + number: number; + state: "open" | "closed"; + merged: boolean; + html_url: string; + user: { login: string; html_url: string } | null; + base: { ref: string }; + head: { ref: string }; + additions: number; + deletions: number; + changed_files: number; + }; + + return { + additions: data.additions, + authorLogin: data.user?.login ?? null, + authorUrl: data.user?.html_url ?? null, + baseRef: data.base.ref, + changedFiles: data.changed_files, + deletions: data.deletions, + headRef: data.head.ref, + htmlUrl: data.html_url, + merged: data.merged, + number: data.number, + state: data.state, + title: data.title, + }; +}; + +export const isGithubError = (value: PrMeta | GithubError): value is GithubError => + "status" in value && typeof (value as GithubError).status === "number"; + +/** Fetch the raw unified diff for a PR (text/plain). */ +export const fetchPrDiff = async ({ + owner, + repo, + number, +}: RepoParams): Promise<{ ok: true; diff: string } | { ok: false; error: GithubError }> => { + const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/pulls/${number}`, { + headers: { ...baseHeaders, Accept: "application/vnd.github.diff" }, + next: { revalidate: REVALIDATE_SECONDS }, + }); + + if (!response.ok) { + return { + error: { + message: messageForStatus(response.status, "Failed to load this diff."), + status: response.status, + }, + ok: false, + }; + } + + return { diff: await response.text(), ok: true }; +}; + +export const githubPrUrl = ({ owner, repo, number }: RepoParams): string => + `https://github.com/${owner}/${repo}/pull/${number}`; diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index 31f0864..34dab4a 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -3,3 +3,13 @@ import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); + +export const truncateFilePath = (path: string, maxSegments = 4) => { + const segments = path.split("/"); + if (segments.length <= maxSegments) { + return path; + } + const filename = segments.at(-1) ?? ""; + const parent = segments.at(-2) ?? ""; + return `${segments[0]}/…/${parent}/${filename}`; +}; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 7ee82c4..c58bb89 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -11,7 +11,11 @@ try { const contentSecurityPolicy = [ "default-src 'self'", - `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`, + // 'wasm-unsafe-eval' lets the diff viewer's shiki-wasm highlighter instantiate + // its WebAssembly module (needed by the live PR demo's syntax highlighting). + `script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`, + // The highlighter runs in a module worker spawned from a blob URL. + "worker-src 'self' blob:", "connect-src 'self'", "img-src 'self' data:", "style-src 'self' 'unsafe-inline'", @@ -90,6 +94,7 @@ const nextConfig = { ], }; }, + transpilePackages: ["@diffhub/diff-core"], }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 9eed043..21a6363 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,9 @@ }, "dependencies": { "@base-ui/react": "^1.3.0", + "@diffhub/diff-core": "*", + "@pierre/diffs": "^1.2.4", + "@pierre/trees": "^1.0.0-beta.4", "@tailwindcss/typography": "^0.5.19", "blode-icons-react": "^0.3.10", "class-variance-authority": "^0.7.1", diff --git a/package-lock.json b/package-lock.json index 9d18ea2..e4abe8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "diffhub", "workspaces": [ - "apps/*" + "apps/*", + "packages/*" ], "devDependencies": { "lefthook": "^2.1.5", @@ -18,7 +19,7 @@ }, "apps/cli": { "name": "diffhub", - "version": "0.1.23", + "version": "0.2.0", "license": "MIT", "dependencies": { "@base-ui/react": "^1.3.0", @@ -44,6 +45,7 @@ }, "devDependencies": { "@changesets/cli": "^2.29.0", + "@diffhub/diff-core": "*", "@tailwindcss/postcss": "^4", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -293,6 +295,9 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.3.0", + "@diffhub/diff-core": "*", + "@pierre/diffs": "^1.2.4", + "@pierre/trees": "^1.0.0-beta.4", "@tailwindcss/typography": "^0.5.19", "blode-icons-react": "^0.3.10", "class-variance-authority": "^0.7.1", @@ -1305,6 +1310,10 @@ "node": ">=20.19.0" } }, + "node_modules/@diffhub/diff-core": { + "resolved": "packages/diff-core", + "link": true + }, "node_modules/@diffhub/web": { "resolved": "apps/web", "link": true @@ -10313,6 +10322,35 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/diff-core": { + "name": "@diffhub/diff-core", + "version": "0.0.0", + "devDependencies": { + "@base-ui/react": "^1.3.0", + "@pierre/diffs": "^1.2.4", + "@pierre/trees": "^1.0.0-beta.4", + "@types/react": "^19", + "@types/react-dom": "^19", + "blode-icons-react": "^0.3.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "next": "16.2.3", + "tailwind-merge": "^3.5.0", + "typescript": "^5" + }, + "peerDependencies": { + "@base-ui/react": "^1.3.0", + "@pierre/diffs": "^1.2.4", + "@pierre/trees": "^1.0.0-beta.4", + "blode-icons-react": "^0.3.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "next": "^16", + "react": "^19", + "react-dom": "^19", + "tailwind-merge": "^3.5.0" + } } } } diff --git a/package.json b/package.json index 2ac2c0f..717a231 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "diffhub", "private": true, "workspaces": [ - "apps/*" + "apps/*", + "packages/*" ], "type": "module", "scripts": { diff --git a/packages/diff-core/oxfmt.config.ts b/packages/diff-core/oxfmt.config.ts new file mode 100644 index 0000000..4538ed3 --- /dev/null +++ b/packages/diff-core/oxfmt.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "oxfmt"; +import ultracite from "ultracite/oxfmt"; + +export default defineConfig({ + extends: [ultracite], +}); diff --git a/packages/diff-core/oxlint.config.ts b/packages/diff-core/oxlint.config.ts new file mode 100644 index 0000000..c760e32 --- /dev/null +++ b/packages/diff-core/oxlint.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "oxlint"; +import core from "ultracite/oxlint/core"; +import next from "ultracite/oxlint/next"; +import react from "ultracite/oxlint/react"; + +export default defineConfig({ + extends: [core, react, next], + overrides: [ + { + // These modules are ports of the diffshub reference and keep its camelCase + // filenames so they stay easy to diff against upstream. + files: ["src/stream/*.ts"], + rules: { + "unicorn/filename-case": "off", + }, + }, + ], + rules: { + // Intentional public entry points for the package. + "no-barrel-file": "off", + // React components use PascalCase filenames by convention. + "unicorn/filename-case": ["error", { cases: { kebabCase: true, pascalCase: true } }], + }, +}); diff --git a/packages/diff-core/package.json b/packages/diff-core/package.json new file mode 100644 index 0000000..e05d3fb --- /dev/null +++ b/packages/diff-core/package.json @@ -0,0 +1,42 @@ +{ + "name": "@diffhub/diff-core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./react": "./src/react.ts" + }, + "scripts": { + "lint": "oxlint .", + "lint:fix": "oxlint --fix .", + "format": "oxfmt --write .", + "format:check": "oxfmt .", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@base-ui/react": "^1.3.0", + "@pierre/diffs": "^1.2.4", + "@pierre/trees": "^1.0.0-beta.4", + "@types/react": "^19", + "@types/react-dom": "^19", + "blode-icons-react": "^0.3.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "next": "16.2.3", + "tailwind-merge": "^3.5.0", + "typescript": "^5" + }, + "peerDependencies": { + "@base-ui/react": "^1.3.0", + "@pierre/diffs": "^1.2.4", + "@pierre/trees": "^1.0.0-beta.4", + "blode-icons-react": "^0.3.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "next": "^16", + "react": "^19", + "react-dom": "^19", + "tailwind-merge": "^3.5.0" + } +} diff --git a/apps/cli/components/FileDiffHeader.tsx b/packages/diff-core/src/chrome/file-diff-header.tsx similarity index 92% rename from apps/cli/components/FileDiffHeader.tsx rename to packages/diff-core/src/chrome/file-diff-header.tsx index 409691c..6c0d310 100644 --- a/apps/cli/components/FileDiffHeader.tsx +++ b/packages/diff-core/src/chrome/file-diff-header.tsx @@ -7,10 +7,10 @@ import { CircleMinusIcon, CirclePlusIcon, } from "blode-icons-react"; -import { Button } from "@/components/ui/button"; -import { CopyButton } from "@/components/ui/copy-button"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn, truncateFilePath } from "@/lib/utils"; +import { cn, truncateFilePath } from "../lib/utils"; +import { Button } from "../ui/button"; +import { CopyButton } from "../ui/copy-button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; // `DiffFileStat` carries no git change-type, so derive a coarse one from the // line counts to colour the header icon (new = green, removed = red, else muted). @@ -34,8 +34,9 @@ interface FileDiffHeaderProps { file: string; insertions: number; deletions: number; - commentCount: number; - repoPath: string; + commentCount?: number; + // When set, the copy button copies `${repoPath}/${file}`; otherwise just the path. + repoPath?: string; collapsed?: boolean; active?: boolean; onToggleCollapse?: () => void; @@ -47,7 +48,7 @@ export const FileDiffHeader = ({ file, insertions, deletions, - commentCount, + commentCount = 0, repoPath, collapsed = false, active = false, @@ -123,7 +124,7 @@ export const FileDiffHeader = ({ {dir && {dir}/} {filename} - + {file !== truncated && {file}} diff --git a/apps/cli/components/FileList.tsx b/packages/diff-core/src/chrome/file-list.tsx similarity index 94% rename from apps/cli/components/FileList.tsx rename to packages/diff-core/src/chrome/file-list.tsx index 485c1ee..8490bbd 100644 --- a/apps/cli/components/FileList.tsx +++ b/packages/diff-core/src/chrome/file-list.tsx @@ -1,16 +1,15 @@ "use client"; import dynamic from "next/dynamic"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { ChevronDownIcon, MagnifyingGlassIcon } from "blode-icons-react"; -import type { DiffFileStat } from "@/lib/diff-file-stat"; -import type { Comment } from "@/lib/comment-types"; -import { cn } from "@/lib/utils"; -import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader } from "@/components/ui/sidebar"; +import type { DiffFileStat } from "../lib/diff-file-stat"; +import { cn } from "../lib/utils"; +import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader } from "../ui/sidebar"; // Loaded client-only: the tree mounts a Preact subtree into a custom element // and owns its own DOM, so it can't be server-rendered (mirrors CodeView). -const FileTreeBody = dynamic(() => import("./FileTreeBody"), { ssr: false }); +const FileTreeBody = dynamic(() => import("./file-tree-body"), { ssr: false }); const FILE_NAVIGATE_EVENT = "diffhub:file:navigate"; @@ -20,7 +19,8 @@ interface FileListProps { files: DiffFileStat[]; selectedFile: string | null; onSelectFile: (file: string, behavior?: ScrollBehavior) => void; - comments: Comment[]; + /** path → comment count, rendered as a trailing tree-row decoration. */ + commentsByFile?: Map; filterQuery: string; onFilterChange: (q: string) => void; isLoading?: boolean; @@ -116,7 +116,7 @@ export const FileList = ({ files, selectedFile, onSelectFile, - comments, + commentsByFile, filterQuery, onFilterChange, isLoading = false, @@ -174,14 +174,6 @@ export const FileList = ({ [sidebarWidth], ); - const commentsByFile = useMemo(() => { - const map = new Map(); - for (const c of comments) { - map.set(c.file, (map.get(c.file) ?? 0) + 1); - } - return map; - }, [comments]); - const visibleFiles = files; const handleNavigate = useCallback( diff --git a/apps/cli/components/FileTreeBody.tsx b/packages/diff-core/src/chrome/file-tree-body.tsx similarity index 92% rename from apps/cli/components/FileTreeBody.tsx rename to packages/diff-core/src/chrome/file-tree-body.tsx index 7b8475f..7381735 100644 --- a/apps/cli/components/FileTreeBody.tsx +++ b/packages/diff-core/src/chrome/file-tree-body.tsx @@ -3,8 +3,10 @@ import { useEffect, useMemo, useRef } from "react"; import { FileTree as PierreFileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; import type { FileTreeRowDecoration, FileTreeRowDecorationContext } from "@pierre/trees"; -import type { DiffFileStat } from "@/lib/diff-file-stat"; -import { toGitStatusEntries } from "@/lib/file-tree-git-status"; +import type { DiffFileStat } from "../lib/diff-file-stat"; +import { toGitStatusEntries } from "../lib/file-tree-git-status"; + +const EMPTY_COMMENTS = new Map(); interface FileTreeBodyProps { files: DiffFileStat[]; @@ -12,7 +14,7 @@ interface FileTreeBodyProps { /** Called when the user activates a file row (single click / keyboard). */ onNavigate: (file: string) => void; /** path → comment count, rendered as a trailing row decoration. */ - commentsByFile: Map; + commentsByFile?: Map; /** Current filter text; drives the tree's hide-non-matches search. */ filterQuery: string; } @@ -20,10 +22,6 @@ interface FileTreeBodyProps { // Swap the tree's built-in chevron (a bold 16px filled glyph) for the lighter // blode-icons-react ChevronDownIcon. The tree references icons by sprite symbol // id, so we inject a and remap the `file-tree-icon-chevron` slot to it. -// Remapping preserves `data-icon-name="file-tree-icon-chevron"` (via the icon's -// `remappedFrom`), so the library's expand/collapse rotation CSS still applies — -// we only need the down-pointing glyph. The path/viewBox are copied verbatim -// from `blode-icons-react` `ChevronDownIcon` (24×24, 2px round stroke). const CHEVRON_SYMBOL_ID = "diffhub-tree-chevron"; const CHEVRON_VIEW_BOX = "0 0 24 24"; const CHEVRON_SPRITE = ``; @@ -54,7 +52,7 @@ const FileTreeBody = ({ files, selectedFile, onNavigate, - commentsByFile, + commentsByFile = EMPTY_COMMENTS, filterQuery, }: FileTreeBodyProps) => { const paths = useMemo(() => files.map((f) => f.file), [files]); diff --git a/apps/cli/components/StatusBar.tsx b/packages/diff-core/src/chrome/status-bar.tsx similarity index 61% rename from apps/cli/components/StatusBar.tsx rename to packages/diff-core/src/chrome/status-bar.tsx index 4cd0cb7..2fb4744 100644 --- a/apps/cli/components/StatusBar.tsx +++ b/packages/diff-core/src/chrome/status-bar.tsx @@ -19,21 +19,17 @@ import { SettingsGear1Icon, ContrastIcon, } from "blode-icons-react"; -import { useTheme } from "next-themes"; import type { ReactNode } from "react"; -import { useRef, useState, useSyncExternalStore } from "react"; -import type { Comment } from "@/lib/comment-types"; -import type { WatchStatus } from "@/lib/watch-status"; -import { getWatchStatusMeta } from "@/lib/watch-status"; -import type { DisplaySettings, DiffIndicatorStyle } from "@/lib/display-settings"; -import type { DiffThemeSelection } from "@/lib/diff-themes"; -import { DIFF_THEMES } from "@/lib/diff-themes"; -import { exportCommentsAsPrompt } from "@/lib/export-comments"; -import { Button } from "@/components/ui/button"; -import { Kbd } from "@/components/ui/kbd"; -import { SidebarTrigger } from "@/components/ui/sidebar"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useRef, useState } from "react"; +import type { DiffIndicatorStyle, DisplaySettings } from "../display/display-settings"; +import type { DiffThemeSelection } from "../themes/diff-themes"; +import { DIFF_THEMES } from "../themes/diff-themes"; +import { cn } from "../lib/utils"; +import { Button } from "../ui/button"; +import { Kbd } from "../ui/kbd"; +import { SidebarTrigger } from "../ui/sidebar"; +import { Spinner } from "../ui/spinner"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { DropdownMenu, DropdownMenuContent, @@ -44,11 +40,16 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; +} from "../ui/dropdown-menu"; export type DiffMode = "all" | "uncommitted"; -export type { WatchStatus }; +export type ThemeModeOption = "system" | "light" | "dark"; + +export interface StatusBarSyncNotice { + detail?: string; + label: string; + tone: "neutral" | "warning" | "destructive"; +} const DIFF_MODES: { value: DiffMode; label: string }[] = [ { label: "All", value: "all" }, @@ -64,20 +65,21 @@ const truncateMiddle = (str: string, maxLen = 24) => { }; interface StatusBarProps { - branch: string; - baseBranch: string; - refreshing: boolean; - onRefresh: () => void; - watchStatus: WatchStatus; - syncNotice: { - detail?: string; - label: string; - tone: "neutral" | "warning" | "destructive"; - } | null; - comments: Comment[]; - onClearComments: () => Promise; - diffMode: DiffMode; - onDiffModeChange: (mode: DiffMode) => void; + // Branch comparison badges (e.g. baseBranch ← branch). Hidden when omitted. + branch?: string; + baseBranch?: string; + // Live status + force refresh. Hidden when onRefresh is omitted. + refreshing?: boolean; + onRefresh?: () => void; + watch?: { label: string; dotClassName: string }; + syncNotice?: StatusBarSyncNotice | null; + // Comment export. Hidden unless commentCount > 0 and onCopyComments is set. + commentCount?: number; + onCopyComments?: () => unknown; + // Diff scope. Hidden when onDiffModeChange is omitted. + diffMode?: DiffMode; + onDiffModeChange?: (mode: DiffMode) => void; + // View controls (always shown). layout: "split" | "stacked"; onLayoutChange: (l: "split" | "stacked") => void; allCollapsed: boolean; @@ -87,6 +89,19 @@ interface StatusBarProps { onDisplaySettingsChange: (settings: DisplaySettings) => void; diffThemes: DiffThemeSelection; onDiffThemesChange: (themes: DiffThemeSelection) => void; + // Color mode segmented control. Hidden when onThemeModeChange is omitted. + themeMode?: ThemeModeOption; + onThemeModeChange?: (mode: ThemeModeOption) => void; + // Sidebar collapse toggle. Requires a SidebarProvider ancestor. + showSidebarTrigger?: boolean; + // Optional trailing link (e.g. "View on GitHub" for the live demo). + githubUrl?: string; +} + +interface SegmentedOption { + value: T; + label: string; + icon?: ReactNode; } const INDICATOR_OPTIONS: SegmentedOption[] = [ @@ -98,8 +113,6 @@ const INDICATOR_OPTIONS: SegmentedOption[] = [ const LIGHT_THEMES = DIFF_THEMES.filter((theme) => theme.type === "light"); const DARK_THEMES = DIFF_THEMES.filter((theme) => theme.type === "dark"); -// ── Inline primitives (no Switch/ToggleGroup in components/ui) ─────────────── - const Switch = ({ checked, onChange, @@ -133,12 +146,6 @@ const Switch = ({ ); -interface SegmentedOption { - value: T; - label: string; - icon?: ReactNode; -} - const SegmentedControl = ({ value, options, @@ -190,7 +197,6 @@ const SegmentedControl = ({
); -type ThemeModeOption = "system" | "light" | "dark"; const THEME_MODE_OPTIONS: SegmentedOption[] = [ { icon: , label: "Auto", value: "system" }, { icon: , label: "Light", value: "light" }, @@ -200,8 +206,6 @@ const THEME_MODE_OPTIONS: SegmentedOption[] = [ const themeNameById = (id: string): string => DIFF_THEMES.find((theme) => theme.id === id)?.name ?? id; -// A per-mode syntax-theme picker rendered as a drill-in submenu: the trigger -// row shows the current selection; the submenu lists every theme of that type. const ThemeSubmenu = ({ icon, selectedId, @@ -239,8 +243,6 @@ const ThemeSubmenu = ({ ); -// Theme picker: color mode (Auto/Light/Dark) plus per-mode syntax-theme -// submenus, all inside a single DropdownMenu (matches the reference layout). const ThemePicker = ({ diffThemes, onDiffThemesChange, @@ -249,8 +251,8 @@ const ThemePicker = ({ }: { diffThemes: DiffThemeSelection; onDiffThemesChange: (themes: DiffThemeSelection) => void; - themeMode: ThemeModeOption; - onModeChange: (mode: ThemeModeOption) => void; + themeMode?: ThemeModeOption; + onModeChange?: (mode: ThemeModeOption) => void; }) => ( -
- -
+ {themeMode && onModeChange && ( +
+ +
+ )} } selectedId={diffThemes.light} @@ -293,25 +297,20 @@ const ThemePicker = ({
); -const getSyncNoticeToneClass = ( - tone: NonNullable["tone"] | undefined, -) => { +const getSyncNoticeToneClass = (tone: StatusBarSyncNotice["tone"] | undefined) => { if (tone === "destructive") { return "border-destructive/30 bg-destructive/10 text-destructive"; } - if (tone === "warning") { return "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400"; } - return "border-border bg-muted/40 text-muted-foreground"; }; -const SyncNoticeChip = ({ syncNotice }: { syncNotice: StatusBarProps["syncNotice"] }) => { +const SyncNoticeChip = ({ syncNotice }: { syncNotice: StatusBarSyncNotice | null | undefined }) => { if (!syncNotice) { return null; } - return (
null; - -const useHasMounted = () => - useSyncExternalStore( - () => noop, - () => true, - () => false, +const BranchBadge = ({ value }: { value: string }) => { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value); + if (timerRef.current) { + clearTimeout(timerRef.current); + } + setCopied(true); + timerRef.current = setTimeout(() => setCopied(false), 1500); + } catch { + // clipboard unavailable + } + }; + return ( + + + } + > + + {truncateMiddle(value)} + + + + + + {copied ? "Copied!" : "Click to copy"} + ); +}; // oxlint-disable-next-line complexity export const StatusBar = ({ branch, baseBranch, - refreshing, + refreshing = false, onRefresh, - watchStatus, + watch, syncNotice, - comments, - onClearComments, + commentCount = 0, + onCopyComments, diffMode, onDiffModeChange, layout, @@ -354,46 +390,25 @@ export const StatusBar = ({ onDisplaySettingsChange, diffThemes, onDiffThemesChange, + themeMode, + onThemeModeChange, + showSidebarTrigger = true, + githubUrl, }: StatusBarProps) => { const [copied, setCopied] = useState(false); - const [copiedBranch, setCopiedBranch] = useState<"branch" | "base" | null>(null); - const mounted = useHasMounted(); - const { setTheme, theme } = useTheme(); - const copiedTimerRef = useRef | null>(null); - const branchTimerRef = useRef | null>(null); // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop - const copyBranch = async (value: string, which: "branch" | "base") => { - try { - await navigator.clipboard.writeText(value); - if (branchTimerRef.current) { - clearTimeout(branchTimerRef.current); - } - setCopiedBranch(which); - branchTimerRef.current = setTimeout(() => setCopiedBranch(null), 1500); - } catch { - // clipboard unavailable + const handleCopyComments = async () => { + if (!onCopyComments) { + return; } - }; - - // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop - const copyCommentsAsPrompt = async () => { - try { - const text = exportCommentsAsPrompt(comments); - await navigator.clipboard.writeText(text); - const cleared = await onClearComments(); - if (!cleared) { - return; - } - if (copiedTimerRef.current) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => setCopied(false), 2000); - } catch { - // clipboard unavailable — don't flip copied state + await onCopyComments(); + if (copiedTimerRef.current) { + clearTimeout(copiedTimerRef.current); } + setCopied(true); + copiedTimerRef.current = setTimeout(() => setCopied(false), 2000); }; // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop @@ -404,167 +419,110 @@ export const StatusBar = ({ onDisplaySettingsChange({ ...displaySettings, [key]: value }); }; - const themeMode: ThemeModeOption = theme === "light" || theme === "dark" ? theme : "system"; - const watchMeta = getWatchStatusMeta(watchStatus, refreshing); + const watchMeta = watch ?? { dotClassName: "bg-diff-green", label: "Live" }; return (
- {/* Sidebar toggle */} - - } /> - - Toggle sidebar - ⌘B - - - - {/* Branch comparison badges */} -
+ {showSidebarTrigger && ( - copyBranch(branch, "branch")} - className="relative whitespace-nowrap rounded-md border border-border bg-background px-2.5 py-1 font-mono text-xs text-muted-foreground cursor-pointer hover:bg-secondary" - /> - } - > - - {truncateMiddle(branch)} - - - - - - - {copiedBranch === "branch" ? "Copied!" : "Click to copy"} - - - - - copyBranch(baseBranch, "base")} - className="relative whitespace-nowrap rounded-md border border-border bg-background px-2.5 py-1 font-mono text-xs text-muted-foreground cursor-pointer hover:bg-secondary" - /> - } - > - - {truncateMiddle(baseBranch)} - - - - - - - {copiedBranch === "base" ? "Copied!" : "Click to copy"} + } /> + + Toggle sidebar + ⌘B -
+ )} + + {branch !== undefined && baseBranch !== undefined && ( +
+ + + +
+ )}
- {/* Live status dot — doubles as force refresh (spins while updating) */} - - - } - > - {refreshing ? ( - - ) : ( - <> - + - - - )} - - - {watchMeta.label} · Force refresh - R - - + } + > + {refreshing ? ( + + ) : ( + <> + + + + )} + + + {watchMeta.label} · Force refresh + R + + + )} - {/* Diff mode dropdown */} - - - } - > - {diffMode === "uncommitted" ? "Uncommitted" : "All"} - - - - onDiffModeChange(value as DiffMode)} + {onDiffModeChange && ( + + + } > - {DIFF_MODES.map(({ value, label }) => ( - - {label} - - ))} - - - + {diffMode === "uncommitted" ? "Uncommitted" : "All"} + + + + onDiffModeChange(value as DiffMode)} + > + {DIFF_MODES.map(({ value, label }) => ( + + {label} + + ))} + + + + )} - {/* Comments export */} - {comments.length > 0 && ( + {commentCount > 0 && onCopyComments && ( } @@ -576,7 +534,7 @@ export const StatusBar = ({ )} {copied ? "Copied!" - : `Copy & clear ${comments.length} comment${comments.length === 1 ? "" : "s"}`} + : `Copy & clear ${commentCount} comment${commentCount === 1 ? "" : "s"}`} Copy all comments as AI prompt, then clear them @@ -584,10 +542,8 @@ export const StatusBar = ({ )} - {/* Divider — separates diff status/scope from view controls */}
diff --git a/apps/cli/lib/display-settings.ts b/packages/diff-core/src/display/display-settings.ts similarity index 100% rename from apps/cli/lib/display-settings.ts rename to packages/diff-core/src/display/display-settings.ts diff --git a/packages/diff-core/src/index.ts b/packages/diff-core/src/index.ts new file mode 100644 index 0000000..42de2c2 --- /dev/null +++ b/packages/diff-core/src/index.ts @@ -0,0 +1,9 @@ +// Framework-agnostic diff utilities, types, and client-safe catalogs shared by +// the DiffHub CLI viewer and the diffhub.blode.co live demo. + +export * from "./stream/constants"; +export * from "./stream/gitPatchMetadata"; +export * from "./stream/streamGitPatchFiles"; +export * from "./stream/diffItemAccumulator"; +export * from "./themes/diff-themes"; +export * from "./display/display-settings"; diff --git a/packages/diff-core/src/lib/diff-file-stat.ts b/packages/diff-core/src/lib/diff-file-stat.ts new file mode 100644 index 0000000..71bb706 --- /dev/null +++ b/packages/diff-core/src/lib/diff-file-stat.ts @@ -0,0 +1,20 @@ +export interface DiffFileStat { + file: string; + changes: number; + insertions: number; + deletions: number; + binary: boolean; +} + +export const LARGE_FILE_CHANGES_THRESHOLD = 500; +export const LARGE_FILE_PATCH_BYTES_THRESHOLD = 500_000; + +export const isLargeDiffFile = (stat: DiffFileStat, patchBytes?: number): boolean => { + if (stat.binary) { + return false; + } + if (stat.changes >= LARGE_FILE_CHANGES_THRESHOLD) { + return true; + } + return patchBytes !== undefined && patchBytes >= LARGE_FILE_PATCH_BYTES_THRESHOLD; +}; diff --git a/packages/diff-core/src/lib/file-tree-git-status.ts b/packages/diff-core/src/lib/file-tree-git-status.ts new file mode 100644 index 0000000..917acfc --- /dev/null +++ b/packages/diff-core/src/lib/file-tree-git-status.ts @@ -0,0 +1,21 @@ +import type { GitStatus, GitStatusEntry } from "@pierre/trees"; +import type { DiffFileStat } from "./diff-file-stat"; + +/** + * Map a diff file's insertion/deletion counts to a `@pierre/trees` git status, + * which the tree uses to colour the filename (added → green, deleted → red, + * everything else → modified/neutral): a pure add is "added", a pure delete is + * "deleted", and any mixed or binary change is "modified". + */ +const toGitStatus = (stat: DiffFileStat): GitStatus => { + if (stat.insertions > 0 && stat.deletions === 0) { + return "added"; + } + if (stat.deletions > 0 && stat.insertions === 0) { + return "deleted"; + } + return "modified"; +}; + +export const toGitStatusEntries = (files: DiffFileStat[]): GitStatusEntry[] => + files.map((stat) => ({ path: stat.file, status: toGitStatus(stat) })); diff --git a/packages/diff-core/src/lib/use-mobile.ts b/packages/diff-core/src/lib/use-mobile.ts new file mode 100644 index 0000000..88a69bc --- /dev/null +++ b/packages/diff-core/src/lib/use-mobile.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +const MOBILE_BREAKPOINT = 768; + +export const useIsMobile = () => { + const [isMobile, setIsMobile] = useState(); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +}; diff --git a/packages/diff-core/src/lib/utils.ts b/packages/diff-core/src/lib/utils.ts new file mode 100644 index 0000000..b16763a --- /dev/null +++ b/packages/diff-core/src/lib/utils.ts @@ -0,0 +1,15 @@ +import { clsx } from "clsx"; +import type { ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); + +export const truncateFilePath = (path: string, maxSegments = 4) => { + const segments = path.split("/"); + if (segments.length <= maxSegments) { + return path; + } + const filename = segments.at(-1) ?? ""; + const parent = segments.at(-2) ?? ""; + return `${segments[0]}/…/${parent}/${filename}`; +}; diff --git a/packages/diff-core/src/react.ts b/packages/diff-core/src/react.ts new file mode 100644 index 0000000..97a17ff --- /dev/null +++ b/packages/diff-core/src/react.ts @@ -0,0 +1,32 @@ +// React entry point: the worker pool provider, streaming hook, the read-only +// CodeView wrapper, and the shared diff-viewer chrome (status bar, file list, +// per-file header) used by both the CLI viewer and the diffhub.blode.co demo. + +export { DiffsWorkerProvider } from "./worker/DiffsWorkerProvider"; +export { useIsWorkerPoolReady } from "./worker/use-worker-pool-ready"; +export { usePatchLoader } from "./stream/use-patch-loader"; +export type { PatchLoadState } from "./stream/use-patch-loader"; +export { ReadOnlyDiffView } from "./viewer/ReadOnlyDiffView"; +export { useCodeViewPaintNudge } from "./viewer/use-paint-nudge"; +export type { DiffHeaderInfo, ReadOnlyDiffViewHandle, ViewerFile } from "./viewer/ReadOnlyDiffView"; + +// Chrome shared verbatim between apps/cli and apps/web. +export { FileDiffHeader } from "./chrome/file-diff-header"; +export { FileList } from "./chrome/file-list"; +export { StatusBar } from "./chrome/status-bar"; +export type { DiffMode, StatusBarSyncNotice, ThemeModeOption } from "./chrome/status-bar"; + +// Sidebar shell + context (FileList renders inside a SidebarProvider). +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarInset, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from "./ui/sidebar"; + +export type { DiffFileStat } from "./lib/diff-file-stat"; +export { isLargeDiffFile } from "./lib/diff-file-stat"; diff --git a/apps/cli/lib/diff-stream/constants.ts b/packages/diff-core/src/stream/constants.ts similarity index 92% rename from apps/cli/lib/diff-stream/constants.ts rename to packages/diff-core/src/stream/constants.ts index 509aa69..162663b 100644 --- a/apps/cli/lib/diff-stream/constants.ts +++ b/packages/diff-core/src/stream/constants.ts @@ -10,7 +10,7 @@ export const CODE_VIEW_FILE_TREE_ITEM_HEIGHT = 24; export const CODE_VIEW_BATCH_COUNT = 25; export const CODE_VIEW_BATCH_COUNT_MAX = 96; -// Streaming publish cadence used by the patch loader (built in a later phase). +// Streaming publish cadence used by the patch loader. export const STREAM_PUBLISH_INTERVAL_MS = 100; export const STREAM_INITIAL_PUBLISH_INTERVAL_MS = 500; export const STREAM_WORK_BUDGET_MS = 8; diff --git a/apps/cli/lib/diff-stream/diffItemAccumulator.ts b/packages/diff-core/src/stream/diffItemAccumulator.ts similarity index 100% rename from apps/cli/lib/diff-stream/diffItemAccumulator.ts rename to packages/diff-core/src/stream/diffItemAccumulator.ts diff --git a/apps/cli/lib/diff-stream/gitPatchMetadata.ts b/packages/diff-core/src/stream/gitPatchMetadata.ts similarity index 79% rename from apps/cli/lib/diff-stream/gitPatchMetadata.ts rename to packages/diff-core/src/stream/gitPatchMetadata.ts index cead17a..0672b22 100644 --- a/apps/cli/lib/diff-stream/gitPatchMetadata.ts +++ b/packages/diff-core/src/stream/gitPatchMetadata.ts @@ -7,8 +7,9 @@ const detachCommitPrefix = (value: string): string => commitPrefixDecoder.decode(commitPrefixEncoder.encode(value)); // Local `git diff` output has no `From ` commit-format-patch headers, so -// this never produces a real prefix in DiffHub. Ported faithfully so the stream -// parser stays identical to the reference. +// this never produces a real prefix for a plain diff. GitHub PR `.diff` output +// is the same shape. Ported faithfully so the stream parser stays identical to +// the reference. export const getPatchTreePathPrefix = ( patchMetadata: string | undefined, patchIndex: number, diff --git a/apps/cli/lib/diff-stream/streamGitPatchFiles.ts b/packages/diff-core/src/stream/streamGitPatchFiles.ts similarity index 100% rename from apps/cli/lib/diff-stream/streamGitPatchFiles.ts rename to packages/diff-core/src/stream/streamGitPatchFiles.ts diff --git a/apps/cli/components/use-patch-loader.ts b/packages/diff-core/src/stream/use-patch-loader.ts similarity index 92% rename from apps/cli/components/use-patch-loader.ts rename to packages/diff-core/src/stream/use-patch-loader.ts index e783a79..3b2aeba 100644 --- a/apps/cli/components/use-patch-loader.ts +++ b/packages/diff-core/src/stream/use-patch-loader.ts @@ -11,15 +11,15 @@ import { STREAM_INITIAL_PUBLISH_INTERVAL_MS, STREAM_PUBLISH_INTERVAL_MS, STREAM_WORK_BUDGET_MS, -} from "@/lib/diff-stream/constants"; -import type { DiffStats } from "@/lib/diff-stream/diffItemAccumulator"; +} from "./constants"; +import type { DiffStats } from "./diffItemAccumulator"; import { appendFileDiffToAccumulator, buildDiffItems, createDiffItemAccumulator, takePendingItems, -} from "@/lib/diff-stream/diffItemAccumulator"; -import { streamGitPatchFiles } from "@/lib/diff-stream/streamGitPatchFiles"; +} from "./diffItemAccumulator"; +import { streamGitPatchFiles } from "./streamGitPatchFiles"; export type PatchLoadState = "idle" | "streaming" | "parsing" | "ready" | "error"; @@ -28,8 +28,9 @@ const GENERIC_PATCH_LOAD_ERROR = "Failed to load the diff. Try refreshing."; interface UsePatchLoaderOptions { // Bump to force a fresh load (mode change, manual refresh, watcher change). reloadKey: string; - // Query string appended to /api/diff (e.g. "?mode=uncommitted"). - diffQuery: string; + // Full endpoint (path + query) to fetch the raw unified patch from, e.g. + // "/api/diff?mode=uncommitted" or "/api/github-diff?owner=…&repo=…&number=…". + endpoint: string; viewerRef: RefObject | null>; // Stamp collapse state + annotations onto freshly built items, and record // their ids, before they are handed to the viewer. Mutates in place. @@ -60,7 +61,7 @@ const yieldToBrowser = (): Promise => export const usePatchLoader = ({ reloadKey, - diffQuery, + endpoint, viewerRef, prepareItems, onReset, @@ -73,14 +74,14 @@ export const usePatchLoader = ({ const [loadAttempt, setLoadAttempt] = useState(0); const requestIdRef = useRef(0); - // Keep the latest callbacks/query in refs so the streaming closure can read + // Keep the latest callbacks/endpoint in refs so the streaming closure can read // live values without re-binding the load effect on every render. const prepareItemsRef = useRef(prepareItems); prepareItemsRef.current = prepareItems; const onResetRef = useRef(onReset); onResetRef.current = onReset; - const diffQueryRef = useRef(diffQuery); - diffQueryRef.current = diffQuery; + const endpointRef = useRef(endpoint); + endpointRef.current = endpoint; const retry = useCallback(() => { setLoadAttempt((attempt) => attempt + 1); @@ -167,7 +168,7 @@ export const usePatchLoader = ({ }; try { - const response = await fetch(`/api/diff${diffQueryRef.current}`, { + const response = await fetch(endpointRef.current, { cache: "no-store", signal: controller.signal, }); @@ -230,7 +231,7 @@ export const usePatchLoader = ({ return; } console.error("[diffhub] patch load failed", { error }); - setErrorMessage(GENERIC_PATCH_LOAD_ERROR); + setErrorMessage(error instanceof Error ? error.message : GENERIC_PATCH_LOAD_ERROR); setLoadState("error"); } }; diff --git a/apps/cli/lib/diff-themes.ts b/packages/diff-core/src/themes/diff-themes.ts similarity index 100% rename from apps/cli/lib/diff-themes.ts rename to packages/diff-core/src/themes/diff-themes.ts diff --git a/packages/diff-core/src/ui/button.tsx b/packages/diff-core/src/ui/button.tsx new file mode 100644 index 0000000..5c1d3ba --- /dev/null +++ b/packages/diff-core/src/ui/button.tsx @@ -0,0 +1,90 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva } from "class-variance-authority"; +import type { VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "../lib/utils"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg border border-transparent bg-clip-padding font-sans font-medium text-sm outline-none transition-[color,background-color,border-color,box-shadow,opacity,transform] duration-150 ease-out focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45 aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-45 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + defaultVariants: { + size: "default", + variant: "default", + }, + variants: { + size: { + default: + "h-10 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", + icon: "size-10", + "icon-lg": "size-11", + "icon-sm": + "size-9 in-data-[slot=button-group]:rounded-lg rounded-[min(var(--radius-md),12px)]", + "icon-xs": + "size-8 in-data-[slot=button-group]:rounded-lg rounded-[min(var(--radius-md),10px)] [&_svg:not([class*='size-'])]:size-3", + input: + "h-[var(--field-height)] gap-2 rounded-[var(--field-radius)] px-[var(--field-padding-x)] py-[var(--field-padding-y)]", + "input-sm": + "h-[var(--field-height-sm)] gap-2 rounded-[var(--field-radius)] px-[var(--field-padding-x)] py-[var(--field-padding-y)]", + lg: "h-11 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3.5 has-data-[icon=inline-start]:pl-3.5", + sm: "h-9 gap-1 in-data-[slot=button-group]:rounded-lg rounded-[min(var(--radius-md),12px)] px-3 text-[0.8rem] has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5", + xs: "h-8 gap-1 in-data-[slot=button-group]:rounded-lg rounded-[min(var(--radius-md),10px)] px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", + }, + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/95 aria-pressed:bg-primary/95", + destructive: + "bg-red-600 text-white hover:bg-red-700 focus-visible:border-red-600 focus-visible:ring-red-500/30 active:bg-red-800 aria-pressed:bg-red-800 dark:bg-red-500 dark:aria-pressed:bg-red-300 dark:active:bg-red-300 dark:hover:bg-red-400", + destructiveSecondary: + "border-red-200 text-red-700 hover:bg-red-50 active:bg-red-100 aria-pressed:bg-red-100 dark:border-red-800 dark:text-red-300 dark:aria-pressed:bg-red-950 dark:active:bg-red-950 dark:hover:bg-red-950/60", + ghost: + "hover:bg-muted hover:text-foreground active:bg-muted/80 aria-pressed:bg-muted/80 dark:aria-pressed:bg-muted/60 dark:active:bg-muted/60 dark:hover:bg-muted/50", + input: + "border-input bg-card font-normal font-sans text-base text-foreground leading-snug shadow-input hover:border-input-hover focus-visible:ring-2 focus-visible:ring-ring/15 focus-visible:ring-offset-1 focus-visible:ring-offset-background active:border-input-hover/80 aria-pressed:border-input-hover aria-invalid:border-destructive-foreground data-[placeholder]:text-placeholder-foreground", + link: "text-primary underline-offset-4 hover:underline active:opacity-80 aria-pressed:underline aria-pressed:opacity-80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground active:bg-muted/80 aria-expanded:bg-muted aria-pressed:bg-muted/80 dark:border-input dark:bg-input/30 dark:aria-pressed:bg-input/60 dark:active:bg-input/60 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/85 active:bg-secondary/75 aria-expanded:bg-secondary aria-pressed:bg-secondary/75", + success: + "bg-green-600 text-white hover:bg-green-700 focus-visible:border-green-600 focus-visible:ring-green-500/30 active:bg-green-800 aria-pressed:bg-green-800 dark:bg-green-500 dark:aria-pressed:bg-green-300 dark:active:bg-green-300 dark:hover:bg-green-400", + successSecondary: + "border-green-200 text-green-700 hover:bg-green-50 active:bg-green-100 aria-pressed:bg-green-100 dark:border-green-800 dark:text-green-300 dark:aria-pressed:bg-green-950 dark:active:bg-green-950 dark:hover:bg-green-950/60", + warning: + "bg-yellow-600 text-white hover:bg-yellow-700 focus-visible:border-yellow-600 focus-visible:ring-yellow-500/30 active:bg-yellow-800 aria-pressed:bg-yellow-800 dark:bg-yellow-500 dark:text-yellow-950 dark:aria-pressed:bg-yellow-300 dark:active:bg-yellow-300 dark:hover:bg-yellow-400", + warningSecondary: + "border-yellow-200 text-yellow-700 hover:bg-yellow-50 active:bg-yellow-100 aria-pressed:bg-yellow-100 dark:border-yellow-800 dark:text-yellow-300 dark:aria-pressed:bg-yellow-950 dark:active:bg-yellow-950 dark:hover:bg-yellow-950/60", + }, + }, + }, +); + +const Button = ({ + className, + variant = "default", + size = "default", + asChild = false, + children, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) => + useRender({ + defaultTagName: "button", + props: mergeProps<"button">( + { + className: cn(buttonVariants({ className, size, variant })), + }, + asChild ? props : { ...props, children }, + ), + render: asChild ? (children as React.ReactElement) : undefined, + state: { + size, + slot: "button", + variant, + }, + }); + +export { Button, buttonVariants }; diff --git a/packages/diff-core/src/ui/copy-button.tsx b/packages/diff-core/src/ui/copy-button.tsx new file mode 100644 index 0000000..8d6f198 --- /dev/null +++ b/packages/diff-core/src/ui/copy-button.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { CheckIcon, CopySimpleIcon } from "blode-icons-react"; + +import { cn } from "../lib/utils"; +import { Button } from "./button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"; + +interface CopyButtonProps { + value: string; + label?: string; + copiedLabel?: string; + delay?: number; + className?: string; +} + +export const CopyButton = ({ + value, + label = "Copy file path", + copiedLabel = "Copied!", + delay = 400, + className, +}: CopyButtonProps) => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard API unavailable — fail silently + } + }, [value]); + + return ( + + + + } + > + {copied ? : } + + {copied ? copiedLabel : label} + + + ); +}; diff --git a/packages/diff-core/src/ui/dropdown-menu.tsx b/packages/diff-core/src/ui/dropdown-menu.tsx new file mode 100644 index 0000000..fa0e501 --- /dev/null +++ b/packages/diff-core/src/ui/dropdown-menu.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; +import { CheckIcon, ChevronRightIcon } from "blode-icons-react"; + +import { cn } from "../lib/utils"; + +const DropdownMenu = ({ ...props }: MenuPrimitive.Root.Props) => ( + +); + +const DropdownMenuTrigger = ({ ...props }: MenuPrimitive.Trigger.Props) => ( + +); + +const DropdownMenuGroup = ({ ...props }: MenuPrimitive.Group.Props) => ( + +); + +const DropdownMenuContent = ({ + className, + side = "bottom", + sideOffset = 4, + align = "end", + alignOffset = 0, + children, + ...props +}: MenuPrimitive.Popup.Props & + Pick) => ( + + + + {children} + + + +); + +const DropdownMenuItem = ({ + className, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { variant?: "default" | "destructive" }) => ( + +); + +const DropdownMenuCheckboxItem = ({ + className, + children, + ...props +}: MenuPrimitive.CheckboxItem.Props) => ( + + + + + + + {children} + +); + +const DropdownMenuRadioGroup = ({ ...props }: MenuPrimitive.RadioGroup.Props) => ( + +); + +const DropdownMenuRadioItem = ({ + className, + children, + ...props +}: MenuPrimitive.RadioItem.Props) => ( + + + + + + + {children} + +); + +const DropdownMenuLabel = ({ className, ...props }: MenuPrimitive.GroupLabel.Props) => ( + +); + +const DropdownMenuSeparator = ({ className, ...props }: MenuPrimitive.Separator.Props) => ( + +); + +const DropdownMenuSub = ({ ...props }: MenuPrimitive.SubmenuRoot.Props) => ( + +); + +const DropdownMenuSubTrigger = ({ + className, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props) => ( + + {children} + + +); + +const DropdownMenuSubContent = ({ + className, + sideOffset = 4, + alignOffset = -4, + children, + ...props +}: MenuPrimitive.Popup.Props & + Pick) => ( + + + + {children} + + + +); + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/packages/diff-core/src/ui/kbd.tsx b/packages/diff-core/src/ui/kbd.tsx new file mode 100644 index 0000000..1f11092 --- /dev/null +++ b/packages/diff-core/src/ui/kbd.tsx @@ -0,0 +1,14 @@ +import type * as React from "react"; +import { cn } from "../lib/utils"; + +export const Kbd = ({ className, children, ...props }: React.ComponentProps<"kbd">) => ( + + {children} + +); diff --git a/packages/diff-core/src/ui/sheet.tsx b/packages/diff-core/src/ui/sheet.tsx new file mode 100644 index 0000000..19ba3d4 --- /dev/null +++ b/packages/diff-core/src/ui/sheet.tsx @@ -0,0 +1,95 @@ +"use client"; + +import * as React from "react"; +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"; + +import { cn } from "../lib/utils"; +import { Button } from "./button"; + +const XIcon = (props: React.SVGProps) => ( + +); + +const Sheet = ({ ...props }: SheetPrimitive.Root.Props) => ( + +); + +const SheetPortal = ({ ...props }: SheetPrimitive.Portal.Props) => ( + +); + +const SheetOverlay = ({ className, ...props }: SheetPrimitive.Backdrop.Props) => ( + +); + +const SheetContent = ({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left"; + showCloseButton?: boolean; +}) => ( + + + + {children} + {showCloseButton && ( + } + > + + Close + + )} + + +); + +const SheetHeader = ({ className, ...props }: React.ComponentProps<"div">) => ( +
+); + +const SheetTitle = ({ className, ...props }: SheetPrimitive.Title.Props) => ( + +); + +const SheetDescription = ({ className, ...props }: SheetPrimitive.Description.Props) => ( + +); + +export { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle }; diff --git a/packages/diff-core/src/ui/sidebar.tsx b/packages/diff-core/src/ui/sidebar.tsx new file mode 100644 index 0000000..acb52ab --- /dev/null +++ b/packages/diff-core/src/ui/sidebar.tsx @@ -0,0 +1,321 @@ +"use client"; + +import type * as React from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; + +import { LayoutLeftIcon } from "blode-icons-react"; +import { useIsMobile } from "../lib/use-mobile"; +import { cn } from "../lib/utils"; +import { Button } from "./button"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet"; +import { TooltipProvider } from "./tooltip"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +interface SidebarContextProps { + isMobile: boolean; + open: boolean; + openMobile: boolean; + setOpen: (open: boolean) => void; + setOpenMobile: (open: boolean) => void; + state: "expanded" | "collapsed"; + toggleSidebar: () => void; +} + +const SidebarContext = createContext(null); + +const useSidebar = () => { + const context = useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +}; + +const SidebarProvider = ({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const open = openProp ?? internalOpen; + const setOpen = useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + setInternalOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + void globalThis.cookieStore?.set({ + expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, + name: SIDEBAR_COOKIE_NAME, + path: "/", + value: String(openState), + }); + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = useCallback( + () => (isMobile ? setOpenMobile((prev) => !prev) : setOpen((prev) => !prev)), + [isMobile, setOpen], + ); + + // Adds a keyboard shortcut to toggle the sidebar. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = useMemo( + () => ({ + isMobile, + open, + openMobile, + setOpen, + setOpenMobile, + state, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +}; + +const Sidebar = ({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +}; + +const SidebarTrigger = ({ className, onClick, ...props }: React.ComponentProps) => { + const { state, toggleSidebar } = useSidebar(); + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + toggleSidebar(); + }, + [onClick, toggleSidebar], + ); + return ( + + ); +}; + +const SidebarInset = ({ className, ...props }: React.ComponentProps<"main">) => ( +
+); + +const SidebarHeader = ({ className, ...props }: React.ComponentProps<"div">) => ( +
+); + +const SidebarFooter = ({ className, ...props }: React.ComponentProps<"div">) => ( +
+); + +const SidebarContent = ({ className, ...props }: React.ComponentProps<"div">) => ( +
+); + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarInset, + SidebarProvider, + SidebarTrigger, + useSidebar, +}; diff --git a/packages/diff-core/src/ui/spinner.tsx b/packages/diff-core/src/ui/spinner.tsx new file mode 100644 index 0000000..0ad4a1b --- /dev/null +++ b/packages/diff-core/src/ui/spinner.tsx @@ -0,0 +1,14 @@ +import type * as React from "react"; +import { cn } from "../lib/utils"; + +export const Spinner = ({ className, ...props }: React.ComponentProps<"span">) => ( + +); diff --git a/packages/diff-core/src/ui/tooltip.tsx b/packages/diff-core/src/ui/tooltip.tsx new file mode 100644 index 0000000..c3ddf97 --- /dev/null +++ b/packages/diff-core/src/ui/tooltip.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; + +import { cn } from "../lib/utils"; + +const TooltipProvider = ({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) => ( + +); + +const Tooltip = ({ ...props }: TooltipPrimitive.Root.Props) => ( + +); + +const TooltipTrigger = ({ ...props }: TooltipPrimitive.Trigger.Props) => ( + +); + +const TooltipContent = ({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick) => ( + + + + {children} + + + + +); + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/packages/diff-core/src/viewer/ReadOnlyDiffView.tsx b/packages/diff-core/src/viewer/ReadOnlyDiffView.tsx new file mode 100644 index 0000000..f023c93 --- /dev/null +++ b/packages/diff-core/src/viewer/ReadOnlyDiffView.tsx @@ -0,0 +1,492 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { + Component, + forwardRef, + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { ErrorInfo, ReactNode } from "react"; +import type { + CodeViewDiffItem, + CodeViewItem, + CodeViewOptions, + FileDiffMetadata, +} from "@pierre/diffs"; +import type { CodeViewHandle } from "@pierre/diffs/react"; +import { useWorkerPool } from "@pierre/diffs/react"; +import { CODE_VIEW_LAYOUT } from "../stream/constants"; +import type { DiffStats } from "../stream/diffItemAccumulator"; +import { usePatchLoader } from "../stream/use-patch-loader"; +import type { DiffThemeSelection } from "../themes/diff-themes"; +import { DEFAULT_DIFF_THEMES } from "../themes/diff-themes"; +import { useIsWorkerPoolReady } from "../worker/use-worker-pool-ready"; +import { useCodeViewPaintNudge } from "./use-paint-nudge"; + +// Lines longer than this skip syntax tokenization (rendered as plain text) so a +// single minified/generated line can't block the highlighter. Kept in sync with +// the worker-pool init in DiffsWorkerProvider. +const LONG_LINE_TOKENIZE_LIMIT = 5000; + +export interface ViewerFile { + id: string; + path: string; + oldPath?: string; + insertions: number; + deletions: number; + status: FileDiffMetadata["type"]; +} + +export interface DiffHeaderInfo { + file: string; + path: string; + insertions: number; + deletions: number; + status: FileDiffMetadata["type"]; + collapsed: boolean; + active: boolean; + onToggle: () => void; +} + +export interface ReadOnlyDiffViewHandle { + scrollToFile: (file: string) => void; + collapseAll: () => void; + expandAll: () => void; +} + +interface ReadOnlyDiffViewProps { + // Full endpoint (path + query) that returns the raw unified patch (text/plain). + endpoint: string; + // Bump to force a fresh stream. + reloadKey: string; + layout: "split" | "unified"; + showLineNumbers?: boolean; + wordWrap?: boolean; + showBackgrounds?: boolean; + diffIndicators?: "classic" | "bars" | "none"; + diffThemes?: DiffThemeSelection; + themeType?: "light" | "dark"; + activeFileId?: string | null; + onActiveFileChange?: (file: string) => void; + onFilesChange?: (files: ViewerFile[]) => void; + onDiffStats?: (stats: DiffStats) => void; + onAllCollapsedChange?: (allCollapsed: boolean) => void; + // Custom per-file header; falls back to a minimal built-in header. + renderHeader?: (info: DiffHeaderInfo) => ReactNode; + // Rendered when the patch is empty (no files changed). + emptyState?: ReactNode; + // Rendered while the worker pool warms up / the first batch streams in. + loadingState?: ReactNode; +} + +const fileStats = (fileDiff: FileDiffMetadata): { insertions: number; deletions: number } => { + let insertions = 0; + let deletions = 0; + for (const hunk of fileDiff.hunks) { + insertions += hunk.additionLines; + deletions += hunk.deletionLines; + } + return { deletions, insertions }; +}; + +const toViewerFile = (item: CodeViewDiffItem): ViewerFile => { + const { fileDiff } = item; + const { insertions, deletions } = fileStats(fileDiff); + return { + deletions, + id: item.id, + insertions, + oldPath: fileDiff.prevName, + path: fileDiff.name, + status: fileDiff.type, + }; +}; + +class DiffErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error): { error: Error } { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error("[diffhub] CodeView threw", { + componentStack: info.componentStack, + error: error.message, + previousError: this.state.error?.message, + }); + } + + render(): ReactNode { + if (this.state.error) { + return ( +
+ Failed to render diff: {this.state.error.message} +
+ ); + } + return this.props.children; + } +} + +/* oxlint-disable promise/prefer-await-to-then */ +const CodeView = dynamic( + () => import("@pierre/diffs/react").then((m) => ({ default: m.CodeView })), + { ssr: false }, +) as unknown as (props: { + key?: React.Key; + ref?: React.Ref>; + initialItems?: readonly CodeViewItem[]; + options?: CodeViewOptions; + className?: string; + containerRef?: React.Ref; + onScroll?: (scrollTop: number, viewer: unknown) => void; + renderCustomHeader?: (item: CodeViewItem) => ReactNode; +}) => React.JSX.Element; +/* oxlint-enable promise/prefer-await-to-then */ + +const DefaultHeader = ({ path, insertions, deletions, collapsed, onToggle }: DiffHeaderInfo) => { + const slash = path.lastIndexOf("/"); + const dir = slash === -1 ? "" : path.slice(0, slash + 1); + const name = slash === -1 ? path : path.slice(slash + 1); + return ( + + ); +}; + +const DefaultEmpty = () => ( +
+ No file changes in this diff. +
+); + +const DefaultLoading = () => ( +
+ Loading diff… +
+); + +// oxlint-disable-next-line complexity -- streaming + collapse + nudge + theme wiring in one view +const ReadOnlyDiffViewInner = ( + { + endpoint, + reloadKey, + layout, + showLineNumbers = true, + wordWrap = true, + showBackgrounds = true, + diffIndicators = "bars", + diffThemes = DEFAULT_DIFF_THEMES, + themeType = "light", + activeFileId = null, + onActiveFileChange, + onFilesChange, + onDiffStats, + onAllCollapsedChange, + renderHeader, + emptyState, + loadingState, + }: ReadOnlyDiffViewProps, + ref: React.Ref, +) => { + const workerPool = useWorkerPool(); + const isWorkerReady = useIsWorkerPoolReady(); + const workerPoolRef = useRef(workerPool); + workerPoolRef.current = workerPool; + + const codeViewRef = useRef | null>(null); + const containerRef = useRef(null); + const rootRef = useRef(null); + + // Files collected as they stream in, reported to the parent for the sidebar. + const filesRef = useRef([]); + const [collapsedFiles, setCollapsedFiles] = useState>(() => new Set()); + const collapsedFilesRef = useRef(collapsedFiles); + collapsedFilesRef.current = collapsedFiles; + const loadedItemIdsRef = useRef>(new Set()); + + const onFilesChangeRef = useRef(onFilesChange); + onFilesChangeRef.current = onFilesChange; + const onActiveFileChangeRef = useRef(onActiveFileChange); + onActiveFileChangeRef.current = onActiveFileChange; + const onAllCollapsedChangeRef = useRef(onAllCollapsedChange); + onAllCollapsedChangeRef.current = onAllCollapsedChange; + + const prepareItems = useCallback((items: CodeViewDiffItem[]): void => { + const collapsed = collapsedFilesRef.current; + for (const item of items) { + loadedItemIdsRef.current.add(item.id); + item.collapsed = collapsed.has(item.id); + filesRef.current.push(toViewerFile(item)); + } + onFilesChangeRef.current?.([...filesRef.current]); + }, []); + + const handleReset = useCallback(() => { + filesRef.current = []; + loadedItemIdsRef.current = new Set(); + onFilesChangeRef.current?.([]); + }, []); + + const { initialItems, loadState, errorMessage, diffStats, viewerKey, retry } = + usePatchLoader({ + endpoint, + onReset: handleReset, + prepareItems, + reloadKey, + viewerRef: codeViewRef, + }); + + const onDiffStatsRef = useRef(onDiffStats); + onDiffStatsRef.current = onDiffStats; + useLayoutEffect(() => { + if (diffStats) { + onDiffStatsRef.current?.(diffStats); + } + }, [diffStats]); + + // Push theme changes into the worker pool so background tokenizers reload the + // active theme pair (they keep their init theme otherwise). + useLayoutEffect(() => { + if (workerPool === undefined) { + return; + } + void workerPool.setRenderOptions({ theme: { dark: diffThemes.dark, light: diffThemes.light } }); + }, [workerPool, diffThemes.dark, diffThemes.light]); + + // Reconcile collapse state imperatively (header chevron + collapse-all driven). + useLayoutEffect(() => { + const viewer = codeViewRef.current; + if (!viewer) { + return; + } + for (const id of loadedItemIdsRef.current) { + const item = viewer.getItem(id); + if (!item || item.type !== "diff") { + continue; + } + const shouldCollapse = collapsedFiles.has(id); + if ((item.collapsed ?? false) === shouldCollapse) { + continue; + } + item.collapsed = shouldCollapse; + item.version = (item.version ?? 0) + 1; + viewer.updateItem(item); + } + const count = loadedItemIdsRef.current.size; + onAllCollapsedChangeRef.current?.(count > 0 && collapsedFiles.size >= count); + }, [collapsedFiles]); + + const toggleCollapse = useCallback((file: string) => { + setCollapsedFiles((previous) => { + const next = new Set(previous); + if (next.has(file)) { + next.delete(file); + } else { + next.add(file); + } + return next; + }); + }, []); + + useImperativeHandle( + ref, + () => ({ + collapseAll: () => setCollapsedFiles(new Set(loadedItemIdsRef.current)), + expandAll: () => setCollapsedFiles(new Set()), + scrollToFile: (file: string) => { + const item = codeViewRef.current?.getItem(file); + const pool = workerPoolRef.current; + if (pool && item?.type === "diff") { + pool.primeDiffHighlightCache(item.fileDiff); + } + codeViewRef.current?.scrollTo({ align: "start", id: file, type: "item" }); + }, + }), + [], + ); + + // Force CodeView's first window to paint (Chrome can skip compositing the + // freshly-mounted shadow-DOM grid until something forces a repaint). + const hasInitialContent = isWorkerReady && (loadState === "ready" || initialItems.length > 0); + useCodeViewPaintNudge(rootRef, hasInitialContent, viewerKey); + + // Active-file tracking: report the topmost rendered item on scroll (rAF-debounced). + const activeFileRafRef = useRef(0); + const pendingActiveFileRef = useRef(null); + const handleScroll = useCallback((_scrollTop: number, viewer: unknown) => { + const instance = viewer as + | { getRenderedItems?: () => { id: string; top?: number }[] } + | undefined; + const rendered = instance?.getRenderedItems?.(); + if (!rendered || rendered.length === 0) { + return; + } + const [firstItem] = rendered; + let topItem = firstItem; + for (const item of rendered) { + if ((item.top ?? 0) < (topItem.top ?? 0)) { + topItem = item; + } + } + pendingActiveFileRef.current = topItem.id; + if (activeFileRafRef.current !== 0) { + return; + } + activeFileRafRef.current = requestAnimationFrame(() => { + activeFileRafRef.current = 0; + const file = pendingActiveFileRef.current; + pendingActiveFileRef.current = null; + if (file) { + onActiveFileChangeRef.current?.(file); + } + }); + }, []); + + const options = useMemo>( + () => ({ + diffIndicators, + diffStyle: layout === "split" ? "split" : "unified", + disableBackground: !showBackgrounds, + // Must stay false: providing `renderCustomHeader` switches the header into + // "custom" mode, but the header host is only created when the file header + // is not disabled. Setting this true renders no headers at all. + disableFileHeader: false, + disableLineNumbers: !showLineNumbers, + expandUnchanged: true, + expansionLineCount: 100, + hunkSeparators: "line-info", + layout: CODE_VIEW_LAYOUT, + lineDiffType: "word-alt", + lineHoverHighlight: "number", + maxLineDiffLength: 500, + overflow: wordWrap ? "wrap" : "scroll", + stickyHeaders: true, + theme: { dark: diffThemes.dark, light: diffThemes.light }, + themeType, + tokenizeMaxLineLength: LONG_LINE_TOKENIZE_LIMIT, + }), + [ + diffIndicators, + layout, + showBackgrounds, + showLineNumbers, + wordWrap, + themeType, + diffThemes.dark, + diffThemes.light, + ], + ); + + const activeFileRef = useRef(activeFileId); + activeFileRef.current = activeFileId; + const renderHeaderRef = useRef(renderHeader); + renderHeaderRef.current = renderHeader; + const renderCustomHeader = useCallback( + (item: CodeViewItem) => { + if (item.type !== "diff") { + return null; + } + const file = item.id; + const { insertions, deletions } = fileStats(item.fileDiff); + const info: DiffHeaderInfo = { + active: activeFileRef.current === file, + collapsed: item.collapsed ?? false, + deletions, + file, + insertions, + onToggle: () => toggleCollapse(file), + path: item.fileDiff.name, + status: item.fileDiff.type, + }; + return (renderHeaderRef.current ?? DefaultHeader)(info); + }, + [toggleCollapse], + ); + + if (loadState === "error") { + return ( +
+

{errorMessage ?? "Failed to load the diff."}

+ +
+ ); + } + + const hasContent = loadState === "ready" || initialItems.length > 0; + if (!isWorkerReady || !hasContent) { + if (loadState === "ready" && initialItems.length === 0) { + return emptyState ?? ; + } + return loadingState ?? ; + } + + if (loadState === "ready" && initialItems.length === 0) { + return emptyState ?? ; + } + + return ( +
+ + + +
+ ); +}; + +export const ReadOnlyDiffView = forwardRef( + ReadOnlyDiffViewInner, +); diff --git a/packages/diff-core/src/viewer/use-paint-nudge.ts b/packages/diff-core/src/viewer/use-paint-nudge.ts new file mode 100644 index 0000000..36f5a00 --- /dev/null +++ b/packages/diff-core/src/viewer/use-paint-nudge.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect } from "react"; +import type { RefObject } from "react"; + +/** + * Force CodeView's first window to composite/paint. + * + * CodeView renders its virtualized grid into a pooled, sticky-positioned Shadow + * DOM. On first mount Chrome can skip *compositing* that grid until something + * forces a repaint, so a freshly-rendered diff can look blank (only line + * backgrounds, no text) until the user scrolls — even though the rows are already + * in the DOM and tokenized. Flicking the scroll container's opacity to 0.999 and + * back forces a repaint of the subtree (including the Shadow DOM) with no layout + * shift and no visible change (0.999 is indistinguishable from 1). The skip clears + * at different moments depending on when the highlighter drains, so we nudge on a + * slow cadence across the settling window; once painted, it stays painted. + * + * `root` should contain the CodeView scroll element (matched by `.overflow-y-auto`). + * `active` gates the nudge on the viewer being mounted with content (worker ready + * + at least one item). `resetKey` re-arms the nudge after a fresh stream. + */ +export const useCodeViewPaintNudge = ( + root: RefObject, + active: boolean, + resetKey: unknown = 0, +): void => { + useEffect(() => { + if (!active) { + return; + } + const container = root.current; + if (!container) { + return; + } + const restoreTimers = new Set>(); + const nudge = () => { + const scroller = container.querySelector(".overflow-y-auto"); + if (!scroller || scroller.style.opacity) { + return; + } + scroller.style.opacity = "0.999"; + const timer = globalThis.setTimeout(() => { + scroller.style.opacity = ""; + restoreTimers.delete(timer); + }, 100); + restoreTimers.add(timer); + }; + let ticks = 0; + const interval = globalThis.setInterval(() => { + ticks += 1; + nudge(); + if (ticks >= 50) { + globalThis.clearInterval(interval); + } + }, 300); + return () => { + globalThis.clearInterval(interval); + for (const timer of restoreTimers) { + globalThis.clearTimeout(timer); + } + const scroller = container.querySelector(".overflow-y-auto"); + if (scroller) { + scroller.style.opacity = ""; + } + }; + // oxlint-disable-next-line exhaustive-deps -- root is a stable ref object + }, [active, resetKey]); +}; diff --git a/apps/cli/components/DiffsWorkerProvider.tsx b/packages/diff-core/src/worker/DiffsWorkerProvider.tsx similarity index 86% rename from apps/cli/components/DiffsWorkerProvider.tsx rename to packages/diff-core/src/worker/DiffsWorkerProvider.tsx index 45c1eac..8cec77d 100644 --- a/apps/cli/components/DiffsWorkerProvider.tsx +++ b/packages/diff-core/src/worker/DiffsWorkerProvider.tsx @@ -3,7 +3,7 @@ import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import type { WorkerInitializationRenderOptions, WorkerPoolOptions } from "@pierre/diffs/react"; import { useMemo } from "react"; -import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from "@/lib/diff-themes"; +import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from "../themes/diff-themes"; interface DiffsWorkerProviderProps { children: React.ReactNode; @@ -39,8 +39,8 @@ export const DiffsWorkerProvider = ({ children }: DiffsWorkerProviderProps): Rea langs: ["cpp", "css", "go", "python", "rust", "sh", "swift", "tsx", "typescript", "zig"], preferredHighlighter: "shiki-wasm", theme: { dark: DEFAULT_DARK_THEME, light: DEFAULT_LIGHT_THEME }, - // Long-line safeguard kept in sync with DiffViewer's CodeView options: - // skip tokenizing pathological (minified) lines so they can't stall a worker. + // Long-line safeguard kept in sync with the CodeView options: skip + // tokenizing pathological (minified) lines so they can't stall a worker. tokenizeMaxLineLength: 5000, }), [], diff --git a/apps/cli/components/use-worker-pool-ready.ts b/packages/diff-core/src/worker/use-worker-pool-ready.ts similarity index 100% rename from apps/cli/components/use-worker-pool-ready.ts rename to packages/diff-core/src/worker/use-worker-pool-ready.ts diff --git a/packages/diff-core/tsconfig.json b/packages/diff-core/tsconfig.json new file mode 100644 index 0000000..9c1607b --- /dev/null +++ b/packages/diff-core/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "skipLibCheck": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules"] +}