mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 17:12:11 +00:00
fix: optimize agent git diff rendering (#1564)
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
5e0fb95f2e
commit
e4d737e18c
3 changed files with 349 additions and 182 deletions
|
|
@ -1,5 +1,9 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { MultiFileDiff } from "@pierre/diffs/react"
|
import {
|
||||||
|
MultiFileDiff,
|
||||||
|
Virtualizer,
|
||||||
|
WorkerPoolContextProvider,
|
||||||
|
} from "@pierre/diffs/react"
|
||||||
import {
|
import {
|
||||||
FileTree,
|
FileTree,
|
||||||
useFileTree,
|
useFileTree,
|
||||||
|
|
@ -13,6 +17,12 @@ import {
|
||||||
GitPullRequestIcon,
|
GitPullRequestIcon,
|
||||||
SidebarSimpleIcon,
|
SidebarSimpleIcon,
|
||||||
} from "@phosphor-icons/react"
|
} from "@phosphor-icons/react"
|
||||||
|
import type {
|
||||||
|
FileContents,
|
||||||
|
VirtualFileMetrics,
|
||||||
|
WorkerInitializationRenderOptions,
|
||||||
|
WorkerPoolOptions,
|
||||||
|
} from "@pierre/diffs/react"
|
||||||
import type { GitStatus, GitStatusEntry } from "@pierre/trees"
|
import type { GitStatus, GitStatusEntry } from "@pierre/trees"
|
||||||
|
|
||||||
import type { AgentThread, Message } from "@/lib/agents/types"
|
import type { AgentThread, Message } from "@/lib/agents/types"
|
||||||
|
|
@ -83,6 +93,38 @@ const PANEL_MIN_WIDTH = 320
|
||||||
// Exported so the chat column can enforce the same floor via min-width.
|
// Exported so the chat column can enforce the same floor via min-width.
|
||||||
export const PANEL_MIN_CHAT_WIDTH = 360
|
export const PANEL_MIN_CHAT_WIDTH = 360
|
||||||
|
|
||||||
|
const DIFF_VIRTUALIZER_CONFIG = {
|
||||||
|
overscrollSize: 1200,
|
||||||
|
intersectionObserverMargin: 4800,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFF_VIRTUAL_METRICS = {
|
||||||
|
hunkLineCount: 80,
|
||||||
|
lineHeight: 18,
|
||||||
|
diffHeaderHeight: 0,
|
||||||
|
spacing: 8,
|
||||||
|
} satisfies Partial<VirtualFileMetrics>
|
||||||
|
|
||||||
|
const DIFF_WORKER_POOL_OPTIONS = {
|
||||||
|
workerFactory: () =>
|
||||||
|
new Worker(
|
||||||
|
new URL("@pierre/diffs/worker/worker-portable.js", import.meta.url),
|
||||||
|
{
|
||||||
|
type: "module",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
poolSize: 2,
|
||||||
|
totalASTLRUCacheSize: 120,
|
||||||
|
} satisfies WorkerPoolOptions
|
||||||
|
|
||||||
|
const DIFF_WORKER_HIGHLIGHTER_OPTIONS = {
|
||||||
|
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||||
|
lineDiffType: "word-alt",
|
||||||
|
maxLineDiffLength: 800,
|
||||||
|
tokenizeMaxLineLength: 1200,
|
||||||
|
langs: ["text"],
|
||||||
|
} satisfies WorkerInitializationRenderOptions
|
||||||
|
|
||||||
function getPanelMaxWidth(availableWidth?: number): number {
|
function getPanelMaxWidth(availableWidth?: number): number {
|
||||||
if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH
|
if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH
|
||||||
const available = availableWidth ?? window.innerWidth
|
const available = availableWidth ?? window.innerWidth
|
||||||
|
|
@ -90,7 +132,10 @@ function getPanelMaxWidth(availableWidth?: number): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampPanelWidth(width: number, availableWidth?: number): number {
|
function clampPanelWidth(width: number, availableWidth?: number): number {
|
||||||
return Math.min(getPanelMaxWidth(availableWidth), Math.max(PANEL_MIN_WIDTH, width))
|
return Math.min(
|
||||||
|
getPanelMaxWidth(availableWidth),
|
||||||
|
Math.max(PANEL_MIN_WIDTH, width)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStoredPanelWidth(): number {
|
function readStoredPanelWidth(): number {
|
||||||
|
|
@ -105,46 +150,84 @@ function readStoredPanelCollapsed(): boolean {
|
||||||
if (typeof window === "undefined") return true
|
if (typeof window === "undefined") return true
|
||||||
// Default to collapsed until the user opens it once.
|
// Default to collapsed until the user opens it once.
|
||||||
return (
|
return (
|
||||||
window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !== COLLAPSED_STATE_FALSE
|
window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !==
|
||||||
|
COLLAPSED_STATE_FALSE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelResizeHandle({
|
function PanelResizeHandle({
|
||||||
width,
|
width,
|
||||||
onResize,
|
onResize,
|
||||||
|
onResizeEnd,
|
||||||
}: {
|
}: {
|
||||||
width: number
|
width: number
|
||||||
onResize: (next: number) => void
|
onResize: (next: number) => number
|
||||||
|
onResizeEnd: (next: number) => void
|
||||||
}) {
|
}) {
|
||||||
const startRef = useRef<{ x: number; width: number } | null>(null)
|
const startRef = useRef<{ x: number; width: number } | null>(null)
|
||||||
|
const pendingWidthRef = useRef<number | null>(null)
|
||||||
|
const latestWidthRef = useRef(width)
|
||||||
|
const frameRef = useRef<number | null>(null)
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestWidthRef.current = width
|
||||||
|
}, [width])
|
||||||
|
|
||||||
|
const flushResize = useCallback(() => {
|
||||||
|
frameRef.current = null
|
||||||
|
const next = pendingWidthRef.current
|
||||||
|
pendingWidthRef.current = null
|
||||||
|
if (next == null) return
|
||||||
|
latestWidthRef.current = onResize(next)
|
||||||
|
}, [onResize])
|
||||||
|
|
||||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
startRef.current = { x: e.clientX, width }
|
startRef.current = { x: e.clientX, width: latestWidthRef.current }
|
||||||
setDragging(true)
|
setDragging(true)
|
||||||
e.currentTarget.setPointerCapture(e.pointerId)
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!startRef.current) return
|
if (!startRef.current) return
|
||||||
onResize(startRef.current.width - (e.clientX - startRef.current.x))
|
pendingWidthRef.current =
|
||||||
|
startRef.current.width - (e.clientX - startRef.current.x)
|
||||||
|
if (frameRef.current == null) {
|
||||||
|
frameRef.current = window.requestAnimationFrame(flushResize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (frameRef.current != null) {
|
||||||
|
window.cancelAnimationFrame(frameRef.current)
|
||||||
|
flushResize()
|
||||||
|
}
|
||||||
startRef.current = null
|
startRef.current = null
|
||||||
setDragging(false)
|
setDragging(false)
|
||||||
|
onResizeEnd(latestWidthRef.current)
|
||||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dragging) return
|
|
||||||
const prev = document.body.style.cursor
|
|
||||||
document.body.style.cursor = "col-resize"
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.cursor = prev
|
if (frameRef.current != null) {
|
||||||
|
window.cancelAnimationFrame(frameRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragging) return
|
||||||
|
const prevCursor = document.body.style.cursor
|
||||||
|
const prevUserSelect = document.body.style.userSelect
|
||||||
|
document.body.style.cursor = "col-resize"
|
||||||
|
document.body.style.userSelect = "none"
|
||||||
|
return () => {
|
||||||
|
document.body.style.cursor = prevCursor
|
||||||
|
document.body.style.userSelect = prevUserSelect
|
||||||
}
|
}
|
||||||
}, [dragging])
|
}, [dragging])
|
||||||
|
|
||||||
|
|
@ -237,22 +320,38 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setWidth = useCallback((next: number) => {
|
const applyWidth = useCallback(
|
||||||
const available = panelRef.current?.parentElement?.clientWidth
|
(next: number) => {
|
||||||
const clamped = clampPanelWidth(next, available)
|
const available = panelRef.current?.parentElement?.clientWidth
|
||||||
setWidthState(clamped)
|
const clamped = clampPanelWidth(next, available)
|
||||||
window.localStorage.setItem(PANEL_STORAGE_WIDTH, String(clamped))
|
if (!overlay && panelRef.current) {
|
||||||
}, [])
|
panelRef.current.style.width = `${clamped}px`
|
||||||
|
}
|
||||||
|
return clamped
|
||||||
|
},
|
||||||
|
[overlay]
|
||||||
|
)
|
||||||
|
|
||||||
|
const commitWidth = useCallback(
|
||||||
|
(next: number) => {
|
||||||
|
const clamped = applyWidth(next)
|
||||||
|
setWidthState((current) => (current === clamped ? current : clamped))
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(PANEL_STORAGE_WIDTH, String(clamped))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyWidth]
|
||||||
|
)
|
||||||
|
|
||||||
// Re-clamp against the real container width on mount and whenever the window
|
// Re-clamp against the real container width on mount and whenever the window
|
||||||
// resizes, so the panel can never squeeze the chat below its minimum width.
|
// resizes, so the panel can never squeeze the chat below its minimum width.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
const reclamp = () => setWidth(width)
|
const reclamp = () => commitWidth(width)
|
||||||
reclamp()
|
reclamp()
|
||||||
window.addEventListener("resize", reclamp)
|
window.addEventListener("resize", reclamp)
|
||||||
return () => window.removeEventListener("resize", reclamp)
|
return () => window.removeEventListener("resize", reclamp)
|
||||||
}, [setWidth, width])
|
}, [commitWidth, width])
|
||||||
const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null)
|
const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null)
|
||||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||||
const pr = thread.pr
|
const pr = thread.pr
|
||||||
|
|
@ -406,171 +505,234 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
|
||||||
overlay ? "mx-3 mb-3" : "mr-4 mb-4 ml-1"
|
overlay ? "mx-3 mb-3" : "mr-4 mb-4 ml-1"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{topTab !== "git" ? (
|
{topTab !== "git" ? (
|
||||||
<div className="flex flex-1 items-center justify-center p-6 text-xs text-[var(--ui-text-dim)]">
|
<div className="flex flex-1 items-center justify-center p-6 text-xs text-[var(--ui-text-dim)]">
|
||||||
Coming Soon
|
Coming Soon
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{pr && (
|
|
||||||
<div className="border-b border-[var(--ui-border)] px-4 py-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-medium text-[var(--ui-text)]">
|
|
||||||
{pr.title} #{pr.number}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
|
|
||||||
<span className="inline-flex items-center gap-1 rounded border border-[var(--ui-border)] px-1.5 py-0.5 capitalize">
|
|
||||||
<GitPullRequestIcon className="size-3" />
|
|
||||||
{pr.state}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{pr.headRef} → {pr.baseRef}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{pr.url && (
|
|
||||||
<a
|
|
||||||
href={pr.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
|
||||||
>
|
|
||||||
<ArrowSquareOutIcon className="size-3" />
|
|
||||||
View PR
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<>
|
||||||
|
{pr && (
|
||||||
<div className="flex items-center gap-1 border-b border-[var(--ui-border)] px-3 py-2">
|
<div className="border-b border-[var(--ui-border)] px-4 py-3">
|
||||||
{(
|
<div className="flex items-start justify-between gap-3">
|
||||||
[
|
<div className="min-w-0">
|
||||||
["diff", "Diff"],
|
<div className="truncate text-sm font-medium text-[var(--ui-text)]">
|
||||||
["review", "Review"],
|
{pr.title} #{pr.number}
|
||||||
["commits", "Commits"],
|
</div>
|
||||||
] as const
|
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
|
||||||
).map(([id, label]) => (
|
<span className="inline-flex items-center gap-1 rounded border border-[var(--ui-border)] px-1.5 py-0.5 capitalize">
|
||||||
<button
|
<GitPullRequestIcon className="size-3" />
|
||||||
key={id}
|
{pr.state}
|
||||||
type="button"
|
</span>
|
||||||
onClick={() => setTab(id)}
|
<span>
|
||||||
className={cn(
|
{pr.headRef} → {pr.baseRef}
|
||||||
"rounded-md px-2.5 py-1 text-xs transition-colors",
|
</span>
|
||||||
tab === id
|
</div>
|
||||||
? "bg-[var(--ui-accent-bubble)] font-medium text-[var(--ui-text)]"
|
</div>
|
||||||
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]"
|
{pr.url && (
|
||||||
|
<a
|
||||||
|
href={pr.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "outline",
|
||||||
|
size: "sm",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ArrowSquareOutIcon className="size-3" />
|
||||||
|
View PR
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{files.length > 0 && (
|
|
||||||
<span className="ml-auto flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
|
|
||||||
<span>
|
|
||||||
{files.length} file{files.length === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--ui-success)]">
|
|
||||||
+{totals.additions}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--ui-danger)]">-{totals.deletions}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex items-center gap-1 border-b border-[var(--ui-border)] px-3 py-2">
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
{(
|
||||||
{tab === "diff" && files.length > 0 ? (
|
[
|
||||||
<div className="space-y-2 p-2">
|
["diff", "Diff"],
|
||||||
{files.map((file) => (
|
["review", "Review"],
|
||||||
<FileDiffSection
|
["commits", "Commits"],
|
||||||
key={file.filePath}
|
] as const
|
||||||
file={file}
|
).map(([id, label]) => (
|
||||||
sectionRef={(node) => {
|
<button
|
||||||
sectionRefs.current[file.filePath] = node
|
key={id}
|
||||||
}}
|
type="button"
|
||||||
/>
|
onClick={() => setTab(id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1 text-xs transition-colors",
|
||||||
|
tab === id
|
||||||
|
? "bg-[var(--ui-accent-bubble)] font-medium text-[var(--ui-text)]"
|
||||||
|
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<span className="ml-auto flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
|
||||||
|
<span>
|
||||||
|
{files.length} file{files.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--ui-success)]">
|
||||||
|
+{totals.additions}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--ui-danger)]">
|
||||||
|
-{totals.deletions}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="p-6 text-center text-xs text-[var(--ui-text-dim)]">
|
|
||||||
{tab !== "diff"
|
|
||||||
? "Coming Soon"
|
|
||||||
: prDiff.isLoading
|
|
||||||
? "Loading PR diff…"
|
|
||||||
: "No diff available."}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{fullScreen && !isMobile && files.length > 0 && (
|
<div className="flex min-h-0 flex-1">
|
||||||
<div className="w-72 shrink-0 border-l border-[var(--ui-border)] bg-[var(--ui-surface)]">
|
{tab === "diff" && files.length > 0 ? (
|
||||||
<FileTreeExplorer
|
<WorkerPoolContextProvider
|
||||||
files={files}
|
poolOptions={DIFF_WORKER_POOL_OPTIONS}
|
||||||
selectedTreePath={selectedTreePath}
|
highlighterOptions={DIFF_WORKER_HIGHLIGHTER_OPTIONS}
|
||||||
onSelect={setSelectedTreePath}
|
>
|
||||||
/>
|
<Virtualizer
|
||||||
</div>
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
|
contentClassName="space-y-2 p-2"
|
||||||
|
config={DIFF_VIRTUALIZER_CONFIG}
|
||||||
|
>
|
||||||
|
{files.map((file) => (
|
||||||
|
<FileDiffSection
|
||||||
|
key={file.filePath}
|
||||||
|
file={file}
|
||||||
|
sectionRef={(node) => {
|
||||||
|
sectionRefs.current[file.filePath] = node
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Virtualizer>
|
||||||
|
</WorkerPoolContextProvider>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-6 text-center text-xs text-[var(--ui-text-dim)]">
|
||||||
|
{tab !== "diff"
|
||||||
|
? "Coming Soon"
|
||||||
|
: prDiff.isLoading
|
||||||
|
? "Loading PR diff…"
|
||||||
|
: "No diff available."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fullScreen && !isMobile && files.length > 0 && (
|
||||||
|
<div className="w-72 shrink-0 border-l border-[var(--ui-border)] bg-[var(--ui-surface)]">
|
||||||
|
<FileTreeExplorer
|
||||||
|
files={files}
|
||||||
|
selectedTreePath={selectedTreePath}
|
||||||
|
onSelect={setSelectedTreePath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
{!overlay && (
|
||||||
|
<PanelResizeHandle
|
||||||
|
width={width}
|
||||||
|
onResize={applyWidth}
|
||||||
|
onResizeEnd={commitWidth}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{!overlay && <PanelResizeHandle width={width} onResize={setWidth} />}
|
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileDiffSection({
|
function hashFileContents(contents: string): string {
|
||||||
file,
|
let hash = 0x811c9dc5
|
||||||
sectionRef,
|
for (let i = 0; i < contents.length; i++) {
|
||||||
}: {
|
hash ^= contents.charCodeAt(i)
|
||||||
file: PanelFile
|
hash = Math.imul(hash, 0x01000193)
|
||||||
sectionRef: (node: HTMLDivElement | null) => void
|
}
|
||||||
}) {
|
return (hash >>> 0).toString(36)
|
||||||
const [open, setOpen] = useState(true)
|
|
||||||
const diffOptions = useDiffOptions()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={sectionRef}
|
|
||||||
className="mb-2 scroll-mt-2 overflow-hidden rounded-lg border border-[var(--ui-border)]"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-2 bg-[var(--ui-panel-2)] px-3 py-2 text-left text-xs"
|
|
||||||
>
|
|
||||||
<CaretDownIcon
|
|
||||||
className={cn("size-3 transition-transform", !open && "-rotate-90")}
|
|
||||||
/>
|
|
||||||
<span className="truncate font-medium text-[var(--ui-text)]">
|
|
||||||
{file.treePath}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
|
||||||
<span className="text-[var(--ui-success)]">+{file.additions}</span>
|
|
||||||
<span className="text-[var(--ui-danger)]">-{file.deletions}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{open &&
|
|
||||||
(file.unrenderable ? (
|
|
||||||
<div className="bg-[var(--ui-panel)] p-4 text-center text-xs text-[var(--ui-text-dim)]">
|
|
||||||
Binary or large file — diff not shown.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-[420px] overflow-auto bg-[var(--ui-panel)] p-2 font-mono text-[11px] leading-5">
|
|
||||||
<MultiFileDiff
|
|
||||||
oldFile={{ name: file.treePath, contents: file.originalContent }}
|
|
||||||
newFile={{ name: file.treePath, contents: file.modifiedContent }}
|
|
||||||
options={diffOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileContentsCacheKey(
|
||||||
|
path: string,
|
||||||
|
side: "old" | "new",
|
||||||
|
contents: string
|
||||||
|
): string {
|
||||||
|
return `${path}:${side}:${contents.length}:${hashFileContents(contents)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileDiffSection = memo(
|
||||||
|
function FileDiffSection({
|
||||||
|
file,
|
||||||
|
sectionRef,
|
||||||
|
}: {
|
||||||
|
file: PanelFile
|
||||||
|
sectionRef: (node: HTMLDivElement | null) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const diffOptions = useDiffOptions()
|
||||||
|
const oldFile = useMemo<FileContents>(
|
||||||
|
() => ({
|
||||||
|
name: file.treePath,
|
||||||
|
contents: file.originalContent,
|
||||||
|
cacheKey: fileContentsCacheKey(
|
||||||
|
file.filePath,
|
||||||
|
"old",
|
||||||
|
file.originalContent
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[file.filePath, file.originalContent, file.treePath]
|
||||||
|
)
|
||||||
|
const newFile = useMemo<FileContents>(
|
||||||
|
() => ({
|
||||||
|
name: file.treePath,
|
||||||
|
contents: file.modifiedContent,
|
||||||
|
cacheKey: fileContentsCacheKey(
|
||||||
|
file.filePath,
|
||||||
|
"new",
|
||||||
|
file.modifiedContent
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[file.filePath, file.modifiedContent, file.treePath]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sectionRef}
|
||||||
|
className="mb-2 scroll-mt-2 overflow-hidden rounded-lg border border-[var(--ui-border)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-2 bg-[var(--ui-panel-2)] px-3 py-2 text-left text-xs"
|
||||||
|
>
|
||||||
|
<CaretDownIcon
|
||||||
|
className={cn("size-3 transition-transform", !open && "-rotate-90")}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-medium text-[var(--ui-text)]">
|
||||||
|
{file.treePath}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
<span className="text-[var(--ui-success)]">+{file.additions}</span>
|
||||||
|
<span className="text-[var(--ui-danger)]">-{file.deletions}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
(file.unrenderable ? (
|
||||||
|
<div className="bg-[var(--ui-panel)] p-4 text-center text-xs text-[var(--ui-text-dim)]">
|
||||||
|
Binary or large file — diff not shown.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden bg-[var(--ui-panel)] p-2">
|
||||||
|
<MultiFileDiff
|
||||||
|
oldFile={oldFile}
|
||||||
|
newFile={newFile}
|
||||||
|
options={diffOptions}
|
||||||
|
metrics={DIFF_VIRTUAL_METRICS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(prev, next) => prev.file === next.file
|
||||||
|
)
|
||||||
|
|
||||||
function FileTreeExplorer({
|
function FileTreeExplorer({
|
||||||
files,
|
files,
|
||||||
selectedTreePath,
|
selectedTreePath,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react"
|
||||||
import { preloadHighlighter } from "@pierre/diffs";
|
import { preloadHighlighter } from "@pierre/diffs"
|
||||||
import { useResolvedTheme } from "@/lib/theme";
|
import { useResolvedTheme } from "@/lib/theme"
|
||||||
|
|
||||||
export const DIFF_UNSAFE_CSS = `
|
export const DIFF_UNSAFE_CSS = `
|
||||||
[data-diffs-header],
|
[data-diffs-header],
|
||||||
|
|
@ -55,7 +55,7 @@ export const DIFF_UNSAFE_CSS = `
|
||||||
background-color: var(--ui-accent-bubble) !important;
|
background-color: var(--ui-accent-bubble) !important;
|
||||||
color: var(--ui-text-dim) !important;
|
color: var(--ui-text-dim) !important;
|
||||||
}
|
}
|
||||||
`;
|
`
|
||||||
|
|
||||||
export const diffOptions = {
|
export const diffOptions = {
|
||||||
theme: { light: "pierre-light", dark: "pierre-dark" } as const,
|
theme: { light: "pierre-light", dark: "pierre-dark" } as const,
|
||||||
|
|
@ -65,17 +65,21 @@ export const diffOptions = {
|
||||||
disableFileHeader: true,
|
disableFileHeader: true,
|
||||||
unsafeCSS: DIFF_UNSAFE_CSS,
|
unsafeCSS: DIFF_UNSAFE_CSS,
|
||||||
collapsedContextThreshold: 4,
|
collapsedContextThreshold: 4,
|
||||||
};
|
lineDiffType: "word-alt" as const,
|
||||||
|
maxLineDiffLength: 800,
|
||||||
|
tokenizeMaxLineLength: 1200,
|
||||||
|
tokenizeMaxLength: 120_000,
|
||||||
|
}
|
||||||
|
|
||||||
export function useDiffOptions() {
|
export function useDiffOptions() {
|
||||||
const resolvedTheme = useResolvedTheme();
|
const resolvedTheme = useResolvedTheme()
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({ ...diffOptions, themeType: resolvedTheme }),
|
() => ({ ...diffOptions, themeType: resolvedTheme }),
|
||||||
[resolvedTheme]
|
[resolvedTheme]
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let highlighterWarmup: Promise<void> | null = null;
|
let highlighterWarmup: Promise<void> | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pierre's <MultiFileDiff> renders an empty <diffs-container> on its first mount
|
* Pierre's <MultiFileDiff> renders an empty <diffs-container> on its first mount
|
||||||
|
|
@ -88,15 +92,15 @@ let highlighterWarmup: Promise<void> | null = null;
|
||||||
* Idempotent and client-only (preloadHighlighter creates a Shiki instance).
|
* Idempotent and client-only (preloadHighlighter creates a Shiki instance).
|
||||||
*/
|
*/
|
||||||
export function warmDiffHighlighter(): Promise<void> {
|
export function warmDiffHighlighter(): Promise<void> {
|
||||||
if (typeof window === "undefined") return Promise.resolve();
|
if (typeof window === "undefined") return Promise.resolve()
|
||||||
if (highlighterWarmup == null) {
|
if (highlighterWarmup == null) {
|
||||||
highlighterWarmup = preloadHighlighter({
|
highlighterWarmup = preloadHighlighter({
|
||||||
themes: [diffOptions.theme.light, diffOptions.theme.dark],
|
themes: [diffOptions.theme.light, diffOptions.theme.dark],
|
||||||
langs: ["text"],
|
langs: ["text"],
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
highlighterWarmup = null;
|
highlighterWarmup = null
|
||||||
throw error;
|
throw error
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
return highlighterWarmup;
|
return highlighterWarmup
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { VitePWA } from "vite-plugin-pwa"
|
||||||
|
|
||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
optimizeDeps: { include: ["workbox-window"] },
|
optimizeDeps: { include: ["workbox-window"] },
|
||||||
|
worker: { format: "es" },
|
||||||
plugins: [
|
plugins: [
|
||||||
devtools(),
|
devtools(),
|
||||||
nitro(),
|
nitro(),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue