Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
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 { useI18n } from "../lib/i18n"
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
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 type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { ErrorBoundary } from "solid-js"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { getLanguageFromPath } from "../lib/text-render-utils"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
@@ -134,4 +135,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
)
|
||||
|
||||
interface ChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -113,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -220,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Changes"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
|
||||
const LazyMonacoFileViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
||||
)
|
||||
|
||||
interface FilesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -51,8 +53,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||
|
||||
const emptyViewerMessage = () => {
|
||||
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||
return "Select a file to preview"
|
||||
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
}
|
||||
|
||||
const renderViewer = () => (
|
||||
@@ -77,7 +79,15 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(payload) => (
|
||||
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
@@ -91,7 +101,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -113,7 +123,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<Show when={props.browserLoading() && entriesValue === null}>
|
||||
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
||||
</Show>
|
||||
|
||||
<For each={sorted}>
|
||||
@@ -154,7 +164,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</span>
|
||||
</span>
|
||||
<Show when={props.browserLoading()}>
|
||||
<span>Loading…</span>
|
||||
<span>{props.t("instanceInfo.loading")}</span>
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
@@ -180,7 +190,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Files"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
)
|
||||
|
||||
interface GitChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -80,11 +82,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
})
|
||||
|
||||
const emptyViewerMessage = createMemo(() => {
|
||||
if (!hasSession()) return "Select a session to view changes."
|
||||
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
||||
const currentEntries = entries()
|
||||
if (currentEntries === null) return "Loading git changes…"
|
||||
if (nonDeleted().length === 0) return "No git changes yet."
|
||||
return "No file selected."
|
||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
})
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
@@ -122,7 +124,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={String(file().path || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
@@ -131,7 +140,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -144,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -169,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">deleted</span>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
@@ -200,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">deleted</span>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
@@ -220,8 +230,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
|
||||
<span class="file-path-text">{selected?.path || "Git Changes"}</span>
|
||||
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
||||
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||
</span>
|
||||
|
||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||
@@ -264,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Git Changes"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
@@ -8,6 +7,20 @@ import { useI18n } from "../lib/i18n"
|
||||
|
||||
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").catch((error) => {
|
||||
markdownModulePromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
return markdownModulePromise
|
||||
}
|
||||
|
||||
function hashText(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
@@ -24,6 +37,45 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
||||
return `text-${hashText(text)}`
|
||||
}
|
||||
|
||||
function resolvePartCacheId(part: TextPart, text: string): string {
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (partId) {
|
||||
return partId
|
||||
}
|
||||
|
||||
return `anonymous:${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 {
|
||||
part: TextPart
|
||||
instanceId?: string
|
||||
@@ -38,7 +90,8 @@ export function Markdown(props: MarkdownProps) {
|
||||
const { t } = useI18n()
|
||||
const [html, setHtml] = createSignal("")
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let latestRequestedText = ""
|
||||
let latestRequestKey = ""
|
||||
let cleanupLanguageListener: (() => void) | undefined
|
||||
|
||||
const notifyRendered = () => {
|
||||
Promise.resolve().then(() => props.onRendered?.())
|
||||
@@ -47,15 +100,14 @@ export function Markdown(props: MarkdownProps) {
|
||||
const resolved = createMemo(() => {
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const text = decodeHtmlEntitiesLocally(rawText)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (!partId) {
|
||||
throw new Error("Markdown rendering requires a part id")
|
||||
}
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||
const cacheId = resolvePartCacheId(part, text)
|
||||
const version = resolvePartVersion(part, text)
|
||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
@@ -63,26 +115,46 @@ export function Markdown(props: MarkdownProps) {
|
||||
sessionId: () => props.sessionId,
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { partId, themeKey, highlightEnabled } = resolved()
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
||||
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.
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
||||
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) => {
|
||||
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)) {
|
||||
setHtml(localCache.html)
|
||||
notifyRendered()
|
||||
@@ -96,111 +168,82 @@ export function Markdown(props: MarkdownProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const commitCacheEntry = (renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||
setHtml(renderedHtml)
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
setHtml(renderFallbackHtml(snapshot.text))
|
||||
notifyRendered()
|
||||
|
||||
if (!highlightEnabled) {
|
||||
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) {
|
||||
void renderSnapshot(snapshot).catch((error) => {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(text)
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const handleClick = async (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const handleClick = async (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||
|
||||
if (copyButton) {
|
||||
e.preventDefault()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!copyButton) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||
if (props.disableHighlight) {
|
||||
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()
|
||||
let disposed = false
|
||||
void loadMarkdownModule()
|
||||
.then((markdown) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
} 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(() => {
|
||||
disposed = true
|
||||
containerRef?.removeEventListener("click", handleClick)
|
||||
cleanupLanguageListener()
|
||||
cleanupLanguageListener?.()
|
||||
cleanupLanguageListener = undefined
|
||||
})
|
||||
})
|
||||
|
||||
const proseClass = () => "markdown-body"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={proseClass()}
|
||||
class="markdown-body"
|
||||
data-view="markdown"
|
||||
data-part-id={resolved().partId}
|
||||
data-markdown-theme={resolved().themeKey}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
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"
|
||||
|
||||
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 { DiffViewMode } from "../../stores/preferences"
|
||||
import { ToolCallDiffViewer } from "../diff-viewer"
|
||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||
import { getRelativePath } from "./utils"
|
||||
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 = {
|
||||
get<T>(): T | undefined
|
||||
params(): unknown
|
||||
@@ -101,15 +116,20 @@ export function createDiffContentRenderer(params: {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheEntryParams={cacheEntryParams as any}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
{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={diffMode()}
|
||||
cacheEntryParams={cacheEntryParams as any}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { DiffPayload } from "./types"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
|
||||
Reference in New Issue
Block a user