197 lines
7.3 KiB
TypeScript
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 }
|
|
}
|