Compare commits
8 Commits
v0.13.3-de
...
ready/ui-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f59e66065 | ||
|
|
51ac7f152d | ||
|
|
df74c06ba2 | ||
|
|
5f144ca24d | ||
|
|
de66b1349a | ||
|
|
3d888fee64 | ||
|
|
1abcc8ee3c | ||
|
|
d0d5c309e6 |
4
.github/workflows/comment-pr-artifacts.yml
vendored
4
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -20,6 +21,7 @@ jobs:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RETENTION_DAYS: 7
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Wait for PR build and comment
|
||||
if: ${{ steps.auth.outputs.allowed == 'true' }}
|
||||
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
3
.github/workflows/pr-build.yml
vendored
3
.github/workflows/pr-build.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -45,7 +46,7 @@ jobs:
|
||||
|
||||
build:
|
||||
needs: authorize
|
||||
if: ${{ needs.authorize.outputs.allowed == 'true' }}
|
||||
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
@@ -11,10 +11,8 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { SettingsScreen } from "./components/settings-screen"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
@@ -59,7 +57,6 @@ import { openSettings } from "./stores/settings-screen"
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
@@ -183,10 +180,6 @@ const App: Component = () => {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
initReleaseNotifications()
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
200
packages/ui/src/lib/git-diff-lowlight.ts
Normal file
200
packages/ui/src/lib/git-diff-lowlight.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { createLowlight, common } from "lowlight"
|
||||
|
||||
type AstNode = {
|
||||
type: string
|
||||
value?: string
|
||||
children?: AstNode[]
|
||||
startIndex?: number
|
||||
endIndex?: number
|
||||
lineNumber?: number
|
||||
}
|
||||
|
||||
type SyntaxNodeEntry = {
|
||||
node: AstNode
|
||||
wrapper?: AstNode
|
||||
}
|
||||
|
||||
type SyntaxFileLine = {
|
||||
value: string
|
||||
lineNumber: number
|
||||
valueLength: number
|
||||
nodeList: SyntaxNodeEntry[]
|
||||
}
|
||||
|
||||
type LowlightApi = ReturnType<typeof createLowlight>
|
||||
|
||||
export function processAST(ast: { children: AstNode[] }) {
|
||||
let lineNumber = 1
|
||||
const syntaxObj: Record<number, SyntaxFileLine> = {}
|
||||
|
||||
const loopAST = (nodes: AstNode[], wrapper?: AstNode) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.type === "text") {
|
||||
const textValue = node.value ?? ""
|
||||
if (!textValue.includes("\n")) {
|
||||
const valueLength = textValue.length
|
||||
if (!syntaxObj[lineNumber]) {
|
||||
node.startIndex = 0
|
||||
node.endIndex = valueLength - 1
|
||||
syntaxObj[lineNumber] = {
|
||||
value: textValue,
|
||||
lineNumber,
|
||||
valueLength,
|
||||
nodeList: [{ node, wrapper }],
|
||||
}
|
||||
} else {
|
||||
node.startIndex = syntaxObj[lineNumber].valueLength
|
||||
node.endIndex = node.startIndex + valueLength - 1
|
||||
syntaxObj[lineNumber].value += textValue
|
||||
syntaxObj[lineNumber].valueLength += valueLength
|
||||
syntaxObj[lineNumber].nodeList.push({ node, wrapper })
|
||||
}
|
||||
node.lineNumber = lineNumber
|
||||
return
|
||||
}
|
||||
|
||||
const lines = textValue.split("\n")
|
||||
node.children = node.children || []
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const value = index === lines.length - 1 ? lines[index] : `${lines[index]}\n`
|
||||
const currentLineNumber = index === 0 ? lineNumber : ++lineNumber
|
||||
const valueLength = value.length
|
||||
const childNode: AstNode = {
|
||||
type: "text",
|
||||
value,
|
||||
startIndex: Infinity,
|
||||
endIndex: Infinity,
|
||||
lineNumber: currentLineNumber,
|
||||
}
|
||||
|
||||
if (!syntaxObj[currentLineNumber]) {
|
||||
childNode.startIndex = 0
|
||||
childNode.endIndex = valueLength - 1
|
||||
syntaxObj[currentLineNumber] = {
|
||||
value,
|
||||
lineNumber: currentLineNumber,
|
||||
valueLength,
|
||||
nodeList: [{ node: childNode, wrapper }],
|
||||
}
|
||||
} else {
|
||||
childNode.startIndex = syntaxObj[currentLineNumber].valueLength
|
||||
childNode.endIndex = childNode.startIndex + valueLength - 1
|
||||
syntaxObj[currentLineNumber].value += value
|
||||
syntaxObj[currentLineNumber].valueLength += valueLength
|
||||
syntaxObj[currentLineNumber].nodeList.push({ node: childNode, wrapper })
|
||||
}
|
||||
|
||||
node.children.push(childNode)
|
||||
}
|
||||
|
||||
node.lineNumber = lineNumber
|
||||
return
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
loopAST(node.children, node)
|
||||
node.lineNumber = lineNumber
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
loopAST(ast.children)
|
||||
return { syntaxFileObject: syntaxObj, syntaxFileLineNumber: lineNumber }
|
||||
}
|
||||
|
||||
export function _getAST() {
|
||||
return {}
|
||||
}
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
|
||||
lowlight.register("vue", function hljsDefineVue(hljs: any) {
|
||||
return {
|
||||
subLanguage: "xml",
|
||||
contains: [
|
||||
hljs.COMMENT("<!--", "-->", { relevance: 10 }),
|
||||
{
|
||||
begin: /^(\s*)(<script>)/gm,
|
||||
end: /^(\s*)(<\/script>)/gm,
|
||||
subLanguage: "javascript",
|
||||
excludeBegin: true,
|
||||
excludeEnd: true,
|
||||
},
|
||||
{
|
||||
begin: /^(?:\s*)(?:<script\s+lang=(["'])ts\1>)/gm,
|
||||
end: /^(\s*)(<\/script>)/gm,
|
||||
subLanguage: "typescript",
|
||||
excludeBegin: true,
|
||||
excludeEnd: true,
|
||||
},
|
||||
{
|
||||
begin: /^(\s*)(<style(\s+scoped)?>)/gm,
|
||||
end: /^(\s*)(<\/style>)/gm,
|
||||
subLanguage: "css",
|
||||
excludeBegin: true,
|
||||
excludeEnd: true,
|
||||
},
|
||||
{
|
||||
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])(?:s[ca]ss)\1(?:\s+scoped)?>)/gm,
|
||||
end: /^(\s*)(<\/style>)/gm,
|
||||
subLanguage: "scss",
|
||||
excludeBegin: true,
|
||||
excludeEnd: true,
|
||||
},
|
||||
{
|
||||
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])stylus\1(?:\s+scoped)?>)/gm,
|
||||
end: /^(\s*)(<\/style>)/gm,
|
||||
subLanguage: "stylus",
|
||||
excludeBegin: true,
|
||||
excludeEnd: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
let maxLineToIgnoreSyntax = 2000
|
||||
const ignoreSyntaxHighlightList: (string | RegExp)[] = []
|
||||
|
||||
export const highlighter = {
|
||||
name: "lowlight",
|
||||
get maxLineToIgnoreSyntax() {
|
||||
return maxLineToIgnoreSyntax
|
||||
},
|
||||
setMaxLineToIgnoreSyntax(value: number) {
|
||||
maxLineToIgnoreSyntax = value
|
||||
},
|
||||
get ignoreSyntaxHighlightList() {
|
||||
return ignoreSyntaxHighlightList
|
||||
},
|
||||
setIgnoreSyntaxHighlightList(values: (string | RegExp)[]) {
|
||||
ignoreSyntaxHighlightList.length = 0
|
||||
ignoreSyntaxHighlightList.push(...values)
|
||||
},
|
||||
getAST(raw: string, fileName?: string, lang?: string) {
|
||||
const language = typeof lang === "string" ? lang.trim() : ""
|
||||
if (
|
||||
fileName &&
|
||||
ignoreSyntaxHighlightList.some((item) => (item instanceof RegExp ? item.test(fileName) : fileName === item))
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (language && lowlight.registered(language)) {
|
||||
return lowlight.highlight(language, raw)
|
||||
}
|
||||
|
||||
return lowlight.highlightAuto(raw)
|
||||
},
|
||||
processAST(ast: { children: AstNode[] }) {
|
||||
return processAST(ast)
|
||||
},
|
||||
hasRegisteredCurrentLang(lang: string) {
|
||||
return lowlight.registered(lang)
|
||||
},
|
||||
getHighlighterEngine(): LowlightApi {
|
||||
return lowlight
|
||||
},
|
||||
type: "class" as const,
|
||||
}
|
||||
|
||||
export const versions = "local-common"
|
||||
@@ -114,6 +114,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
||||
"instanceShell.sessionChanges.actions.show": "Show changes",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Loading git changes...",
|
||||
"instanceShell.gitChanges.empty": "No git changes yet.",
|
||||
"instanceShell.gitChanges.deleted": "Deleted",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "File list",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "Panel de estado",
|
||||
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
|
||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
||||
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
||||
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
||||
"instanceShell.gitChanges.deleted": "Eliminado",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "Panneau d'état",
|
||||
"instanceShell.rightPanel.tabs.changes": "Modifications",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
|
||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
|
||||
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
||||
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
||||
"instanceShell.gitChanges.deleted": "Supprimé",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "ステータスパネル",
|
||||
"instanceShell.rightPanel.tabs.changes": "変更",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git 変更",
|
||||
"instanceShell.rightPanel.tabs.files": "ファイル",
|
||||
"instanceShell.rightPanel.tabs.status": "ステータス",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
|
||||
"instanceShell.sessionChanges.actions.show": "変更を表示",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
|
||||
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
|
||||
"instanceShell.gitChanges.deleted": "削除済み",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "Панель состояния",
|
||||
"instanceShell.rightPanel.tabs.changes": "Изменения",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Изменения Git",
|
||||
"instanceShell.rightPanel.tabs.files": "Файлы",
|
||||
"instanceShell.rightPanel.tabs.status": "Статус",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
|
||||
"instanceShell.sessionChanges.actions.show": "Показать изменения",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
|
||||
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
|
||||
"instanceShell.gitChanges.deleted": "Удалено",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Список файлов",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "状态面板",
|
||||
"instanceShell.rightPanel.tabs.changes": "更改",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git 更改",
|
||||
"instanceShell.rightPanel.tabs.files": "文件",
|
||||
"instanceShell.rightPanel.tabs.status": "状态",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件",
|
||||
"instanceShell.sessionChanges.actions.show": "显示更改",
|
||||
|
||||
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
|
||||
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
|
||||
"instanceShell.gitChanges.deleted": "已删除",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "文件列表",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "请选择文件",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { marked } from "marked"
|
||||
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
||||
import { getLogger } from "./logger"
|
||||
import { tGlobal } from "./i18n"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { decodeHtmlEntities, escapeHtml } from "./text-render-utils"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -11,43 +12,8 @@ let currentTheme: "light" | "dark" = "light"
|
||||
let isInitialized = false
|
||||
let highlightSuppressed = false
|
||||
let rendererSetup = false
|
||||
|
||||
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
|
||||
}
|
||||
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
|
||||
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
|
||||
|
||||
// Track loaded languages and queue for on-demand loading
|
||||
const loadedLanguages = new Set<string>()
|
||||
@@ -89,10 +55,15 @@ async function getOrCreateHighlighter() {
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
// Create highlighter with no preloaded languages
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||
langs: [],
|
||||
highlighterPromise = (async () => {
|
||||
const shiki = await loadShikiModule()
|
||||
return shiki.createHighlighter({
|
||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||
langs: [],
|
||||
})
|
||||
})().catch((error) => {
|
||||
highlighterPromise = null
|
||||
throw error
|
||||
})
|
||||
|
||||
highlighter = await highlighterPromise
|
||||
@@ -100,12 +71,37 @@ async function getOrCreateHighlighter() {
|
||||
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 {
|
||||
return token.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
|
||||
const normalized = normalizeLanguageToken(token)
|
||||
const bundledLanguages = bundledLanguagesCache
|
||||
if (!bundledLanguages) {
|
||||
return { canonical: null, raw: normalized }
|
||||
}
|
||||
|
||||
// Check if it's a direct key match
|
||||
if (normalized in bundledLanguages) {
|
||||
@@ -148,32 +144,43 @@ async function ensureLanguages(content: string) {
|
||||
|
||||
// Queue language loading tasks
|
||||
for (const token of foundLanguages) {
|
||||
const { canonical, raw } = resolveLanguage(token)
|
||||
const langKey = canonical || raw
|
||||
const rawToken = normalizeLanguageToken(token)
|
||||
if (!rawToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip "text" and aliases since Shiki handles plain text already
|
||||
if (langKey === "text" || raw === "text") {
|
||||
if (rawToken === "text") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if already loaded or queued
|
||||
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
|
||||
if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) {
|
||||
continue
|
||||
}
|
||||
|
||||
queuedLanguages.add(langKey)
|
||||
queuedLanguages.add(rawToken)
|
||||
|
||||
// Queue the language loading task
|
||||
languageLoadQueue.push(async () => {
|
||||
try {
|
||||
await loadShikiModule()
|
||||
const { canonical, raw } = resolveLanguage(token)
|
||||
const langKey = canonical || raw
|
||||
|
||||
if (langKey === "text" || raw === "text") {
|
||||
return
|
||||
}
|
||||
|
||||
const h = await getOrCreateHighlighter()
|
||||
await h.loadLanguage(langKey as never)
|
||||
loadedLanguages.add(langKey)
|
||||
loadedLanguages.add(raw)
|
||||
triggerLanguageListeners()
|
||||
} catch {
|
||||
// Quietly ignore errors
|
||||
} finally {
|
||||
queuedLanguages.delete(langKey)
|
||||
queuedLanguages.delete(rawToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -184,52 +191,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() {
|
||||
if (isQueueRunning || languageLoadQueue.length === 0) {
|
||||
return
|
||||
@@ -249,7 +210,6 @@ async function runLanguageLoadQueue() {
|
||||
|
||||
function setupRenderer(isDark: boolean) {
|
||||
currentTheme = isDark ? "dark" : "light"
|
||||
if (!highlighter) return
|
||||
if (rendererSetup) return
|
||||
|
||||
marked.setOptions({
|
||||
@@ -330,8 +290,9 @@ function setupRenderer(isDark: boolean) {
|
||||
}
|
||||
|
||||
export async function initMarkdown(isDark: boolean) {
|
||||
await getOrCreateHighlighter()
|
||||
setupRenderer(isDark)
|
||||
queueHighlighterWarmup()
|
||||
await getOrCreateHighlighter()
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
@@ -350,15 +311,16 @@ export async function renderMarkdown(
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!isInitialized) {
|
||||
await initMarkdown(currentTheme === "dark")
|
||||
setupRenderer(currentTheme === "dark")
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
const suppressHighlight = options?.suppressHighlight ?? false
|
||||
const decoded = decodeHtmlEntities(content)
|
||||
|
||||
if (!suppressHighlight) {
|
||||
// Queue language loading but don't wait for it to complete
|
||||
await ensureLanguages(decoded)
|
||||
queueHighlighterWarmup()
|
||||
void ensureLanguages(decoded)
|
||||
}
|
||||
|
||||
const previousSuppressed = highlightSuppressed
|
||||
@@ -375,13 +337,3 @@ export async function renderMarkdown(
|
||||
export async function getSharedHighlighter(): Promise<Highlighter> {
|
||||
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 {
|
||||
if (typeof segment === "string") {
|
||||
|
||||
@@ -134,6 +134,34 @@ export default defineConfig({
|
||||
main: resolve(__dirname, "./src/renderer/index.html"),
|
||||
loading: resolve(__dirname, "./src/renderer/loading.html"),
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const normalizedId = id.replace(/\\/g, "/")
|
||||
|
||||
if (normalizedId.includes("/node_modules/@git-diff-view/")) {
|
||||
return "git-diff-vendor"
|
||||
}
|
||||
|
||||
if (normalizedId.includes("/node_modules/highlight.js/") || normalizedId.includes("/node_modules/lowlight/")) {
|
||||
return "highlight-vendor"
|
||||
}
|
||||
|
||||
if (normalizedId.includes("/node_modules/fast-diff/")) {
|
||||
return "fast-diff-vendor"
|
||||
}
|
||||
|
||||
if (normalizedId.includes("/node_modules/monaco-editor/")) {
|
||||
return "monaco-vendor"
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes("/src/components/file-viewer/") ||
|
||||
normalizedId.includes("/src/lib/monaco/")
|
||||
) {
|
||||
return "monaco-viewer"
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user