perf(ui): lazy-load markdown and diff rendering
This commit is contained in:
committed by
Shantur Rathore
parent
d15340a4b8
commit
d0d5c309e6
@@ -11,10 +11,8 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
|||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
import { SettingsScreen } from "./components/settings-screen"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useTheme } from "./lib/theme"
|
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
import { getLogger } from "./lib/logger"
|
import { getLogger } from "./lib/logger"
|
||||||
@@ -59,7 +57,6 @@ import { openSettings } from "./stores/settings-screen"
|
|||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const { isDark } = useTheme()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
@@ -183,10 +180,6 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
initReleaseNotifications()
|
initReleaseNotifications()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||||
import type { Highlighter } from "shiki/bundle/full"
|
import type { Highlighter } from "shiki/bundle/full"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
import { getSharedHighlighter } from "../lib/markdown"
|
||||||
|
import { escapeHtml } from "../lib/text-render-utils"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
|
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||||
import { disableCache } from "@git-diff-view/core"
|
import { disableCache } from "@git-diff-view/core"
|
||||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||||
import { ErrorBoundary } from "solid-js"
|
import { ErrorBoundary } from "solid-js"
|
||||||
import { getLanguageFromPath } from "../lib/markdown"
|
import { getLanguageFromPath } from "../lib/text-render-utils"
|
||||||
import { normalizeDiffText } from "../lib/diff-utils"
|
import { normalizeDiffText } from "../lib/diff-utils"
|
||||||
import { setCacheEntry } from "../lib/global-cache"
|
import { setCacheEntry } from "../lib/global-cache"
|
||||||
import type { CacheEntryParams } from "../lib/global-cache"
|
import type { CacheEntryParams } from "../lib/global-cache"
|
||||||
@@ -134,4 +135,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
@@ -8,6 +7,17 @@ import { useI18n } from "../lib/i18n"
|
|||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
type MarkdownModule = typeof import("../lib/markdown")
|
||||||
|
|
||||||
|
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
||||||
|
|
||||||
|
function loadMarkdownModule(): Promise<MarkdownModule> {
|
||||||
|
if (!markdownModulePromise) {
|
||||||
|
markdownModulePromise = import("../lib/markdown")
|
||||||
|
}
|
||||||
|
return markdownModulePromise
|
||||||
|
}
|
||||||
|
|
||||||
function hashText(value: string): string {
|
function hashText(value: string): string {
|
||||||
let hash = 2166136261
|
let hash = 2166136261
|
||||||
for (let index = 0; index < value.length; index++) {
|
for (let index = 0; index < value.length; index++) {
|
||||||
@@ -24,6 +34,36 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
|||||||
return `text-${hashText(text)}`
|
return `text-${hashText(text)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntitiesLocally(content: string): string {
|
||||||
|
if (!content.includes("&") || typeof document === "undefined") {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.innerHTML = content
|
||||||
|
return textarea.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(content: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFallbackHtml(content: string): string {
|
||||||
|
if (!content) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return escapeHtml(content).replace(/\n/g, "<br />")
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
part: TextPart
|
part: TextPart
|
||||||
instanceId?: string
|
instanceId?: string
|
||||||
@@ -38,7 +78,8 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestedText = ""
|
let latestRequestKey = ""
|
||||||
|
let cleanupLanguageListener: (() => void) | undefined
|
||||||
|
|
||||||
const notifyRendered = () => {
|
const notifyRendered = () => {
|
||||||
Promise.resolve().then(() => props.onRendered?.())
|
Promise.resolve().then(() => props.onRendered?.())
|
||||||
@@ -47,7 +88,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const resolved = createMemo(() => {
|
const resolved = createMemo(() => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
const rawText = typeof part.text === "string" ? part.text : ""
|
const rawText = typeof part.text === "string" ? part.text : ""
|
||||||
const text = decodeHtmlEntities(rawText)
|
const text = decodeHtmlEntitiesLocally(rawText)
|
||||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||||
const highlightEnabled = !props.disableHighlight
|
const highlightEnabled = !props.disableHighlight
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||||
@@ -55,7 +96,8 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
throw new Error("Markdown rendering requires a part id")
|
throw new Error("Markdown rendering requires a part id")
|
||||||
}
|
}
|
||||||
const version = resolvePartVersion(part, text)
|
const version = resolvePartVersion(part, text)
|
||||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
const requestKey = `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||||
|
return { part, text, themeKey, highlightEnabled, partId, version, requestKey }
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheHandle = useGlobalCache({
|
const cacheHandle = useGlobalCache({
|
||||||
@@ -69,20 +111,40 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(async () => {
|
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
||||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
const cacheEntry: RenderCache = {
|
||||||
|
text: snapshot.text,
|
||||||
|
html: renderedHtml,
|
||||||
|
theme: snapshot.themeKey,
|
||||||
|
mode: snapshot.version,
|
||||||
|
}
|
||||||
|
setHtml(renderedHtml)
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
||||||
setMarkdownTheme(themeKey === "dark")
|
const markdown = await loadMarkdownModule()
|
||||||
|
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||||
|
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||||
|
suppressHighlight: !snapshot.highlightEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
latestRequestedText = text
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
|
commitCacheEntry(snapshot, rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const snapshot = resolved()
|
||||||
|
latestRequestKey = snapshot.requestKey
|
||||||
|
|
||||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||||
if (!cache) return false
|
if (!cache) return false
|
||||||
return cache.theme === themeKey && cache.mode === version
|
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
||||||
}
|
}
|
||||||
|
|
||||||
const localCache = part.renderCache
|
const localCache = snapshot.part.renderCache
|
||||||
if (localCache && cacheMatches(localCache)) {
|
if (localCache && cacheMatches(localCache)) {
|
||||||
setHtml(localCache.html)
|
setHtml(localCache.html)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
@@ -96,111 +158,82 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitCacheEntry = (renderedHtml: string) => {
|
setHtml(renderFallbackHtml(snapshot.text))
|
||||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
notifyRendered()
|
||||||
setHtml(renderedHtml)
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!highlightEnabled) {
|
void renderSnapshot(snapshot).catch((error) => {
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
|
||||||
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(rendered)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to render markdown:", error)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(rendered)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to render markdown:", error)
|
log.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
commitCacheEntry(text)
|
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handleClick = async (e: Event) => {
|
const handleClick = async (event: Event) => {
|
||||||
const target = e.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||||
|
|
||||||
if (copyButton) {
|
if (!copyButton) {
|
||||||
e.preventDefault()
|
return
|
||||||
const code = copyButton.getAttribute("data-code")
|
|
||||||
if (code) {
|
|
||||||
const decodedCode = decodeURIComponent(code)
|
|
||||||
const success = await copyToClipboard(decodedCode)
|
|
||||||
const copyText = copyButton.querySelector(".copy-text")
|
|
||||||
if (copyText) {
|
|
||||||
if (success) {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
} else {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const code = copyButton.getAttribute("data-code")
|
||||||
|
if (!code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedCode = decodeURIComponent(code)
|
||||||
|
const success = await copyToClipboard(decodedCode)
|
||||||
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
|
if (!copyText) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
containerRef?.addEventListener("click", handleClick)
|
containerRef?.addEventListener("click", handleClick)
|
||||||
|
|
||||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
let disposed = false
|
||||||
if (props.disableHighlight) {
|
void loadMarkdownModule()
|
||||||
return
|
.then((markdown) => {
|
||||||
}
|
if (disposed) {
|
||||||
|
return
|
||||||
const { part, text, themeKey, version } = resolved()
|
|
||||||
|
|
||||||
setMarkdownTheme(themeKey === "dark")
|
|
||||||
|
|
||||||
if (latestRequestedText !== text) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
|
||||||
setHtml(rendered)
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to re-render markdown after language load:", error)
|
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
||||||
}
|
const snapshot = resolved()
|
||||||
})
|
if (!snapshot.highlightEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latestRequestKey = snapshot.requestKey
|
||||||
|
void renderSnapshot(snapshot).catch((error) => {
|
||||||
|
log.error("Failed to re-render markdown after language load:", error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log.error("Failed to load markdown module:", error)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
disposed = true
|
||||||
containerRef?.removeEventListener("click", handleClick)
|
containerRef?.removeEventListener("click", handleClick)
|
||||||
cleanupLanguageListener()
|
cleanupLanguageListener?.()
|
||||||
|
cleanupLanguageListener = undefined
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const proseClass = () => "markdown-body"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
class={proseClass()}
|
class="markdown-body"
|
||||||
data-view="markdown"
|
data-view="markdown"
|
||||||
data-part-id={resolved().partId}
|
data-part-id={resolved().partId}
|
||||||
data-markdown-theme={resolved().themeKey}
|
data-markdown-theme={resolved().themeKey}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
import { escapeHtml } from "../../lib/markdown"
|
import { escapeHtml } from "../../lib/text-render-utils"
|
||||||
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
|
|
||||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import type { DiffViewMode } from "../../stores/preferences"
|
import type { DiffViewMode } from "../../stores/preferences"
|
||||||
import { ToolCallDiffViewer } from "../diff-viewer"
|
|
||||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
import { getRelativePath } from "./utils"
|
import { getRelativePath } from "./utils"
|
||||||
import { getCacheEntry } from "../../lib/global-cache"
|
import { getCacheEntry } from "../../lib/global-cache"
|
||||||
|
|
||||||
|
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 = {
|
type CacheHandle = {
|
||||||
get<T>(): T | undefined
|
get<T>(): T | undefined
|
||||||
params(): unknown
|
params(): unknown
|
||||||
@@ -101,15 +116,20 @@ export function createDiffContentRenderer(params: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ToolCallDiffViewer
|
{cachedHtml ? (
|
||||||
diffText={payload.diffText}
|
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
|
||||||
filePath={payload.filePath}
|
) : (
|
||||||
theme={themeKey}
|
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
||||||
mode={diffMode()}
|
<LazyToolCallDiffViewer
|
||||||
cachedHtml={cachedHtml}
|
diffText={payload.diffText}
|
||||||
cacheEntryParams={cacheEntryParams as any}
|
filePath={payload.filePath}
|
||||||
onRendered={handleDiffRendered}
|
theme={themeKey}
|
||||||
/>
|
mode={diffMode()}
|
||||||
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
|
onRendered={handleDiffRendered}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||||
import { getLanguageFromPath } from "../../lib/markdown"
|
import { getLanguageFromPath } from "../../lib/text-render-utils"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { DiffPayload } from "./types"
|
import type { DiffPayload } from "./types"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
import { tGlobal } from "./i18n"
|
import { tGlobal } from "./i18n"
|
||||||
|
import type { Highlighter } from "shiki/bundle/full"
|
||||||
|
import { decodeHtmlEntities, escapeHtml } from "./text-render-utils"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -11,43 +12,8 @@ let currentTheme: "light" | "dark" = "light"
|
|||||||
let isInitialized = false
|
let isInitialized = false
|
||||||
let highlightSuppressed = false
|
let highlightSuppressed = false
|
||||||
let rendererSetup = false
|
let rendererSetup = false
|
||||||
|
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
|
||||||
const extensionToLanguage: Record<string, string> = {
|
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
|
||||||
ts: "typescript",
|
|
||||||
tsx: "typescript",
|
|
||||||
js: "javascript",
|
|
||||||
jsx: "javascript",
|
|
||||||
py: "python",
|
|
||||||
sh: "bash",
|
|
||||||
bash: "bash",
|
|
||||||
json: "json",
|
|
||||||
html: "html",
|
|
||||||
css: "css",
|
|
||||||
md: "markdown",
|
|
||||||
yaml: "yaml",
|
|
||||||
yml: "yaml",
|
|
||||||
sql: "sql",
|
|
||||||
rs: "rust",
|
|
||||||
go: "go",
|
|
||||||
cpp: "cpp",
|
|
||||||
cc: "cpp",
|
|
||||||
cxx: "cpp",
|
|
||||||
hpp: "cpp",
|
|
||||||
h: "cpp",
|
|
||||||
c: "c",
|
|
||||||
java: "java",
|
|
||||||
cs: "csharp",
|
|
||||||
php: "php",
|
|
||||||
rb: "ruby",
|
|
||||||
swift: "swift",
|
|
||||||
kt: "kotlin",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLanguageFromPath(path?: string | null): string | undefined {
|
|
||||||
if (!path) return undefined
|
|
||||||
const ext = path.split(".").pop()?.toLowerCase()
|
|
||||||
return ext ? extensionToLanguage[ext] : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track loaded languages and queue for on-demand loading
|
// Track loaded languages and queue for on-demand loading
|
||||||
const loadedLanguages = new Set<string>()
|
const loadedLanguages = new Set<string>()
|
||||||
@@ -89,23 +55,50 @@ async function getOrCreateHighlighter() {
|
|||||||
return highlighterPromise
|
return highlighterPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create highlighter with no preloaded languages
|
highlighterPromise = (async () => {
|
||||||
highlighterPromise = createHighlighter({
|
const shiki = await loadShikiModule()
|
||||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
return shiki.createHighlighter({
|
||||||
langs: [],
|
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||||
})
|
langs: [],
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
highlighter = await highlighterPromise
|
highlighter = await highlighterPromise
|
||||||
highlighterPromise = null
|
highlighterPromise = null
|
||||||
return highlighter
|
return highlighter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadShikiModule() {
|
||||||
|
if (!shikiModulePromise) {
|
||||||
|
shikiModulePromise = import("shiki/bundle/full").then((module) => {
|
||||||
|
bundledLanguagesCache = module.bundledLanguages
|
||||||
|
return module
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return shikiModulePromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueHighlighterWarmup() {
|
||||||
|
if (highlighter || highlighterPromise) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void getOrCreateHighlighter().catch((error) => {
|
||||||
|
log.warn("Failed to initialize markdown highlighter", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLanguageToken(token: string): string {
|
function normalizeLanguageToken(token: string): string {
|
||||||
return token.trim().toLowerCase()
|
return token.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
|
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
|
||||||
const normalized = normalizeLanguageToken(token)
|
const normalized = normalizeLanguageToken(token)
|
||||||
|
const bundledLanguages = bundledLanguagesCache
|
||||||
|
if (!bundledLanguages) {
|
||||||
|
return { canonical: null, raw: normalized }
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a direct key match
|
// Check if it's a direct key match
|
||||||
if (normalized in bundledLanguages) {
|
if (normalized in bundledLanguages) {
|
||||||
@@ -148,32 +141,43 @@ async function ensureLanguages(content: string) {
|
|||||||
|
|
||||||
// Queue language loading tasks
|
// Queue language loading tasks
|
||||||
for (const token of foundLanguages) {
|
for (const token of foundLanguages) {
|
||||||
const { canonical, raw } = resolveLanguage(token)
|
const rawToken = normalizeLanguageToken(token)
|
||||||
const langKey = canonical || raw
|
if (!rawToken) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Skip "text" and aliases since Shiki handles plain text already
|
// Skip "text" and aliases since Shiki handles plain text already
|
||||||
if (langKey === "text" || raw === "text") {
|
if (rawToken === "text") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already loaded or queued
|
// Skip if already loaded or queued
|
||||||
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
|
if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
queuedLanguages.add(langKey)
|
queuedLanguages.add(rawToken)
|
||||||
|
|
||||||
// Queue the language loading task
|
// Queue the language loading task
|
||||||
languageLoadQueue.push(async () => {
|
languageLoadQueue.push(async () => {
|
||||||
try {
|
try {
|
||||||
|
await loadShikiModule()
|
||||||
|
const { canonical, raw } = resolveLanguage(token)
|
||||||
|
const langKey = canonical || raw
|
||||||
|
|
||||||
|
if (langKey === "text" || raw === "text") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const h = await getOrCreateHighlighter()
|
const h = await getOrCreateHighlighter()
|
||||||
await h.loadLanguage(langKey as never)
|
await h.loadLanguage(langKey as never)
|
||||||
loadedLanguages.add(langKey)
|
loadedLanguages.add(langKey)
|
||||||
|
loadedLanguages.add(raw)
|
||||||
triggerLanguageListeners()
|
triggerLanguageListeners()
|
||||||
} catch {
|
} catch {
|
||||||
// Quietly ignore errors
|
// Quietly ignore errors
|
||||||
} finally {
|
} finally {
|
||||||
queuedLanguages.delete(langKey)
|
queuedLanguages.delete(rawToken)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -184,52 +188,6 @@ async function ensureLanguages(content: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeHtmlEntities(content: string): string {
|
|
||||||
if (!content.includes("&")) {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
|
|
||||||
const namedEntities: Record<string, string> = {
|
|
||||||
amp: "&",
|
|
||||||
lt: "<",
|
|
||||||
gt: ">",
|
|
||||||
quot: '"',
|
|
||||||
apos: "'",
|
|
||||||
nbsp: " ",
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = content
|
|
||||||
let previous = ""
|
|
||||||
|
|
||||||
while (result.includes("&") && result !== previous) {
|
|
||||||
previous = result
|
|
||||||
result = result.replace(entityPattern, (match, entity) => {
|
|
||||||
if (!entity) {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity[0] === "#") {
|
|
||||||
const isHex = entity[1]?.toLowerCase() === "x"
|
|
||||||
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
|
|
||||||
if (!Number.isNaN(value)) {
|
|
||||||
try {
|
|
||||||
return String.fromCodePoint(value)
|
|
||||||
} catch {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = namedEntities[entity.toLowerCase()]
|
|
||||||
return decoded !== undefined ? decoded : match
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runLanguageLoadQueue() {
|
async function runLanguageLoadQueue() {
|
||||||
if (isQueueRunning || languageLoadQueue.length === 0) {
|
if (isQueueRunning || languageLoadQueue.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -249,7 +207,6 @@ async function runLanguageLoadQueue() {
|
|||||||
|
|
||||||
function setupRenderer(isDark: boolean) {
|
function setupRenderer(isDark: boolean) {
|
||||||
currentTheme = isDark ? "dark" : "light"
|
currentTheme = isDark ? "dark" : "light"
|
||||||
if (!highlighter) return
|
|
||||||
if (rendererSetup) return
|
if (rendererSetup) return
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
@@ -330,8 +287,9 @@ function setupRenderer(isDark: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function initMarkdown(isDark: boolean) {
|
export async function initMarkdown(isDark: boolean) {
|
||||||
await getOrCreateHighlighter()
|
|
||||||
setupRenderer(isDark)
|
setupRenderer(isDark)
|
||||||
|
queueHighlighterWarmup()
|
||||||
|
await getOrCreateHighlighter()
|
||||||
isInitialized = true
|
isInitialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,15 +308,16 @@ export async function renderMarkdown(
|
|||||||
},
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
await initMarkdown(currentTheme === "dark")
|
setupRenderer(currentTheme === "dark")
|
||||||
|
isInitialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const suppressHighlight = options?.suppressHighlight ?? false
|
const suppressHighlight = options?.suppressHighlight ?? false
|
||||||
const decoded = decodeHtmlEntities(content)
|
const decoded = decodeHtmlEntities(content)
|
||||||
|
|
||||||
if (!suppressHighlight) {
|
if (!suppressHighlight) {
|
||||||
// Queue language loading but don't wait for it to complete
|
queueHighlighterWarmup()
|
||||||
await ensureLanguages(decoded)
|
void ensureLanguages(decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousSuppressed = highlightSuppressed
|
const previousSuppressed = highlightSuppressed
|
||||||
@@ -375,13 +334,3 @@ export async function renderMarkdown(
|
|||||||
export async function getSharedHighlighter(): Promise<Highlighter> {
|
export async function getSharedHighlighter(): Promise<Highlighter> {
|
||||||
return getOrCreateHighlighter()
|
return getOrCreateHighlighter()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHtml(text: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
}
|
|
||||||
return text.replace(/[&<"']/g, (m) => map[m])
|
|
||||||
}
|
|
||||||
|
|||||||
92
packages/ui/src/lib/text-render-utils.ts
Normal file
92
packages/ui/src/lib/text-render-utils.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const extensionToLanguage: Record<string, string> = {
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "javascript",
|
||||||
|
py: "python",
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
json: "json",
|
||||||
|
html: "html",
|
||||||
|
css: "css",
|
||||||
|
md: "markdown",
|
||||||
|
yaml: "yaml",
|
||||||
|
yml: "yaml",
|
||||||
|
sql: "sql",
|
||||||
|
rs: "rust",
|
||||||
|
go: "go",
|
||||||
|
cpp: "cpp",
|
||||||
|
cc: "cpp",
|
||||||
|
cxx: "cpp",
|
||||||
|
hpp: "cpp",
|
||||||
|
h: "cpp",
|
||||||
|
c: "c",
|
||||||
|
java: "java",
|
||||||
|
cs: "csharp",
|
||||||
|
php: "php",
|
||||||
|
rb: "ruby",
|
||||||
|
swift: "swift",
|
||||||
|
kt: "kotlin",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLanguageFromPath(path?: string | null): string | undefined {
|
||||||
|
if (!path) return undefined
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase()
|
||||||
|
return ext ? extensionToLanguage[ext] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeHtmlEntities(content: string): string {
|
||||||
|
if (!content.includes("&")) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
|
||||||
|
const namedEntities: Record<string, string> = {
|
||||||
|
amp: "&",
|
||||||
|
lt: "<",
|
||||||
|
gt: ">",
|
||||||
|
quot: '"',
|
||||||
|
apos: "'",
|
||||||
|
nbsp: " ",
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = content
|
||||||
|
let previous = ""
|
||||||
|
|
||||||
|
while (result.includes("&") && result !== previous) {
|
||||||
|
previous = result
|
||||||
|
result = result.replace(entityPattern, (match, entity) => {
|
||||||
|
if (!entity) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity[0] === "#") {
|
||||||
|
const isHex = entity[1]?.toLowerCase() === "x"
|
||||||
|
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
try {
|
||||||
|
return String.fromCodePoint(value)
|
||||||
|
} catch {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = namedEntities[entity.toLowerCase()]
|
||||||
|
return decoded !== undefined ? decoded : match
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
return text.replace(/[&<"']/g, (match) => map[match])
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { decodeHtmlEntities } from "../../lib/markdown"
|
import { decodeHtmlEntities } from "../../lib/text-render-utils"
|
||||||
|
|
||||||
function decodeTextSegment(segment: any): any {
|
function decodeTextSegment(segment: any): any {
|
||||||
if (typeof segment === "string") {
|
if (typeof segment === "string") {
|
||||||
|
|||||||
Reference in New Issue
Block a user