Files
CodeNomad/packages/ui/src/components/tool-call/diff-render.tsx

197 lines
7.3 KiB
TypeScript

import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import useMediaQuery from "@suid/material/useMediaQuery"
import { AlignJustify, Copy, Split, WrapText } from "lucide-solid"
import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache"
import { copyToClipboard } from "../../lib/clipboard"
const LazyToolCallDiffViewer = lazy(() =>
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
)
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
onMount(() => {
props.onRendered?.()
})
return (
<div class="tool-call-diff-viewer">
<div innerHTML={props.html} />
</div>
)
}
type CacheHandle = {
get<T>(): T | undefined
params(): unknown
}
type DiffPrefs = {
diffViewMode?: DiffViewMode
}
export function createDiffContentRenderer(params: {
toolState: Accessor<ToolState | undefined>
preferences: Accessor<DiffPrefs>
setDiffViewMode: (mode: DiffViewMode) => void
isDark: Accessor<boolean>
t: (key: string, params?: Record<string, unknown>) => string
diffCache: CacheHandle
permissionDiffCache: CacheHandle
scrollHelpers: ToolScrollHelpers
handleScrollRendered: () => void
onContentRendered?: () => void
}) {
const compactDiffQuery = useMediaQuery("(max-width: 640px)")
const [mobileModeOverride, setMobileModeOverride] = createSignal<DiffViewMode | undefined>(undefined)
const [wordWrapEnabled, setWordWrapEnabled] = createSignal(true)
createEffect(() => {
if (!compactDiffQuery()) {
setMobileModeOverride(undefined)
}
})
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
const registerUntracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element, { disableTracking: true })
}
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = options?.label || (relativePath
? params.t("toolCall.diff.label.withPath", { path: relativePath })
: params.t("toolCall.diff.label"))
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const preferredMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const effectiveMode = () => {
if (!compactDiffQuery()) return preferredMode()
return mobileModeOverride() || "unified"
}
const shouldWrap = () => wordWrapEnabled()
const themeKey = params.isDark() ? "dark" : "light"
const state = params.toolState()
const disableScrollTracking = Boolean(
options?.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending"),
)
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const baseEntryParams = cacheHandle.params() as any
const cacheEntryParams = (() => {
const suffix = typeof options?.cacheKey === "string" ? options.cacheKey.trim() : ""
if (!suffix) return baseEntryParams
return {
...baseEntryParams,
cacheId: `${baseEntryParams.cacheId}:${suffix}`,
}
})()
const currentMode = createMemo(() => effectiveMode())
const currentWrap = createMemo(() => shouldWrap())
const cachedHtml = createMemo(() => {
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
if (
cached
&& cached.text === payload.diffText
&& cached.theme === themeKey
&& cached.mode === currentMode()
&& cached.wrap === currentWrap()
) {
return cached.html
}
return undefined
})
const handleModeChange = (mode: DiffViewMode) => {
if (compactDiffQuery()) {
setMobileModeOverride(mode)
}
params.setDiffViewMode(mode)
}
const nextViewMode = (): DiffViewMode => (currentMode() === "split" ? "unified" : "split")
const viewModeTitle = () =>
nextViewMode() === "split"
? params.t("toolCall.diff.switchToSplit")
: params.t("toolCall.diff.switchToUnified")
const wordWrapTitle = () =>
wordWrapEnabled()
? params.t("toolCall.diff.disableWordWrap")
: params.t("toolCall.diff.enableWordWrap")
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
const handleDiffRendered = () => {
params.handleScrollRendered()
params.onContentRendered?.()
}
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
data-diff-mode={currentMode()}
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="file-viewer-toolbar">
<button
type="button"
class="file-viewer-toolbar-icon-button"
onClick={() => void copyToClipboard(payload.diffText)}
aria-label={copyPatchTitle()}
title={copyPatchTitle()}
>
<Copy class="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
class="file-viewer-toolbar-icon-button"
onClick={() => handleModeChange(nextViewMode())}
aria-label={viewModeTitle()}
title={viewModeTitle()}
>
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
</button>
<button
type="button"
class={`file-viewer-toolbar-icon-button${wordWrapEnabled() ? " active" : ""}`}
onClick={() => setWordWrapEnabled((enabled) => !enabled)}
aria-label={wordWrapTitle()}
title={wordWrapTitle()}
>
<WrapText class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
{cachedHtml() ? (
<CachedDiffMarkup html={cachedHtml()!} onRendered={handleDiffRendered} />
) : (
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
<LazyToolCallDiffViewer
diffText={payload.diffText}
filePath={payload.filePath}
theme={themeKey}
mode={currentMode()}
wrap={currentWrap()}
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
</Suspense>
)}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)
}
return { renderDiffContent }
}