feat(app): make session timelines much faster AND without flicker or scroll jumps (#32331)
This commit is contained in:
@@ -59,6 +59,7 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
@@ -76,7 +77,6 @@
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"virtua": "catalog:"
|
||||
"strip-ansi": "7.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
[data-timeline-row] [data-component="file"] {
|
||||
content-visibility: visible;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="text"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ const VIRTUALIZE_BYTES = 500_000
|
||||
const codeMetrics = {
|
||||
...DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
lineHeight: 24,
|
||||
fileGap: 0,
|
||||
spacing: 0,
|
||||
} satisfies Partial<VirtualFileMetrics>
|
||||
|
||||
type SharedProps<T> = {
|
||||
|
||||
32
packages/ui/src/components/markdown-code-state.test.ts
Normal file
32
packages/ui/src/components/markdown-code-state.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { shouldResetCodeTokens } from "./markdown-code-state"
|
||||
|
||||
const previous = {
|
||||
language: "ts",
|
||||
generation: 1,
|
||||
stableCount: 3,
|
||||
unstable: [],
|
||||
raw: "```ts\nconst x = 1\n```",
|
||||
}
|
||||
|
||||
test("resets tokens for a non-prefix replacement with the same generation and token count", () => {
|
||||
expect(
|
||||
shouldResetCodeTokens(previous, {
|
||||
language: "ts",
|
||||
generation: 1,
|
||||
stableCount: 3,
|
||||
raw: "```ts\nlet y = 2\n```",
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("retains tokens for an append-only streaming update", () => {
|
||||
expect(
|
||||
shouldResetCodeTokens(previous, {
|
||||
language: "ts",
|
||||
generation: 1,
|
||||
stableCount: 4,
|
||||
raw: `${previous.raw}\nmore`,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
22
packages/ui/src/components/markdown-code-state.ts
Normal file
22
packages/ui/src/components/markdown-code-state.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { MarkdownToken } from "./markdown-worker-protocol"
|
||||
|
||||
export type RenderedCodeState = {
|
||||
language: string
|
||||
generation: number
|
||||
stableCount: number
|
||||
unstable: MarkdownToken[]
|
||||
raw: string
|
||||
}
|
||||
|
||||
export function shouldResetCodeTokens(
|
||||
previous: RenderedCodeState | undefined,
|
||||
next: { language: string; generation: number; stableCount: number; raw: string },
|
||||
) {
|
||||
return (
|
||||
!previous ||
|
||||
previous.language !== next.language ||
|
||||
previous.generation !== next.generation ||
|
||||
next.stableCount < previous.stableCount ||
|
||||
!next.raw.startsWith(previous.raw)
|
||||
)
|
||||
}
|
||||
104
packages/ui/src/components/markdown-shiki.worker.ts
Normal file
104
packages/ui/src/components/markdown-shiki.worker.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { ShikiStreamTokenizer } from "@shikijs/stream"
|
||||
import {
|
||||
bundledLanguages,
|
||||
createHighlighter,
|
||||
getTokenStyleObject,
|
||||
stringifyTokenStyle,
|
||||
type BundledLanguage,
|
||||
type ThemedToken,
|
||||
} from "shiki"
|
||||
import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol"
|
||||
import { createLatestWorkerQueue } from "./markdown-worker-queue"
|
||||
|
||||
type Stream = {
|
||||
language: string
|
||||
source: string
|
||||
tokenizer: ShikiStreamTokenizer
|
||||
}
|
||||
|
||||
const streams = new Map<string, Stream>()
|
||||
let highlighter: ReturnType<typeof createHighlighter> | undefined
|
||||
const queue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
|
||||
run: highlight,
|
||||
supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
|
||||
dispose: (key) => void streams.delete(key),
|
||||
})
|
||||
|
||||
self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
|
||||
if (event.data.type === "init") {
|
||||
highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] })
|
||||
return
|
||||
}
|
||||
if (event.data.type === "dispose") {
|
||||
queue.dispose(event.data.key)
|
||||
return
|
||||
}
|
||||
|
||||
queue.highlight(event.data)
|
||||
}
|
||||
|
||||
async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highlight" }>) {
|
||||
try {
|
||||
const instance = await highlighter
|
||||
if (!instance) throw new Error("Shiki worker is not initialized")
|
||||
const language = request.language in bundledLanguages ? request.language : "text"
|
||||
if (!instance.getLoadedLanguages().includes(language))
|
||||
await instance.loadLanguage(bundledLanguages[language as BundledLanguage])
|
||||
|
||||
if (request.complete) {
|
||||
const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme: "OpenCode" })
|
||||
streams.delete(request.key)
|
||||
post({
|
||||
type: "highlight",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
reset: true,
|
||||
stable: result.tokens
|
||||
.flatMap((line, index) =>
|
||||
index === result.tokens.length - 1 ? line : [...line, { content: "\n", offset: 0 }],
|
||||
)
|
||||
.map(token),
|
||||
unstable: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const previous = streams.get(request.key)
|
||||
const reset = !previous || previous.language !== language || !request.text.startsWith(previous.source)
|
||||
const stream = reset
|
||||
? {
|
||||
language,
|
||||
source: "",
|
||||
tokenizer: new ShikiStreamTokenizer({ highlighter: instance, lang: language, theme: "OpenCode" }),
|
||||
}
|
||||
: previous
|
||||
const result = await stream.tokenizer.enqueue(request.text.slice(stream.source.length))
|
||||
stream.source = request.text
|
||||
streams.set(request.key, stream)
|
||||
post({
|
||||
type: "highlight",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
reset,
|
||||
stable: result.stable.filter((token) => token.content.length > 0).map(token),
|
||||
unstable: result.unstable.filter((token) => token.content.length > 0).map(token),
|
||||
})
|
||||
} catch (error) {
|
||||
post({
|
||||
type: "error",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function post(response: MarkdownWorkerResponse) {
|
||||
self.postMessage(response)
|
||||
}
|
||||
|
||||
function token(value: ThemedToken): MarkdownToken {
|
||||
return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { stream } from "./markdown-stream"
|
||||
import { canReusePendingBlock, project, stream } from "./markdown-stream"
|
||||
|
||||
describe("markdown stream", () => {
|
||||
test("heals incomplete emphasis while streaming", () => {
|
||||
@@ -15,8 +15,58 @@ describe("markdown stream", () => {
|
||||
|
||||
test("splits an unfinished trailing code fence from stable content", () => {
|
||||
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
|
||||
{ raw: "before\n\n", src: "before\n\n", mode: "live" },
|
||||
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" },
|
||||
{ raw: "before\n\n", src: "before\n\n", mode: "full" },
|
||||
{ raw: "```ts\nconst x = 1", src: "const x = 1", mode: "code", language: "ts" },
|
||||
])
|
||||
})
|
||||
|
||||
test("fully parses a code fence once it closes", () => {
|
||||
const text = "before\n\n```ts\nconst x = 1\n```"
|
||||
expect(stream(text, true)).toEqual([
|
||||
{ raw: "before\n\n", src: "before\n\n", mode: "full" },
|
||||
{ raw: "```ts\nconst x = 1\n```", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a completed code fence in worker-rendered code mode when prose follows", () => {
|
||||
expect(stream("```ts\nconst x = 1\n```\n\nafter", true)).toEqual([
|
||||
{ raw: "```ts\nconst x = 1\n```\n\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
||||
{ raw: "after", src: "after", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("freezes completed top-level blocks and only keeps the tail live", () => {
|
||||
expect(stream("# Plan\n\nFinished paragraph.\n\n- live item", true)).toEqual([
|
||||
{ raw: "# Plan\n\n", src: "# Plan\n\n", mode: "full" },
|
||||
{ raw: "Finished paragraph.\n\n", src: "Finished paragraph.\n\n", mode: "full" },
|
||||
{ raw: "- live item", src: "- live item", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a growing table together until a later block freezes it", () => {
|
||||
expect(stream("| a | b |\n|---|---|\n| 1 | 2 |", true)).toEqual([
|
||||
{ raw: "| a | b |\n|---|---|\n| 1 | 2 |", src: "| a | b |\n|---|---|\n| 1 | 2 |", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("reprojects non-prefix replacements from current content", () => {
|
||||
expect(stream("# Replacement\n\nNew body", true)).toEqual([
|
||||
{ raw: "# Replacement\n\n", src: "# Replacement\n\n", mode: "full" },
|
||||
{ raw: "New body", src: "New body", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("reprojects truncation without retaining removed blocks", () => {
|
||||
expect(stream("Only the restored prefix", true)).toEqual([
|
||||
{ raw: "Only the restored prefix", src: "Only the restored prefix", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("shifts later blocks when an earlier block is inserted", () => {
|
||||
expect(stream("# Inserted\n\nFirst body\n\nSecond body", true)).toEqual([
|
||||
{ raw: "# Inserted\n\n", src: "# Inserted\n\n", mode: "full" },
|
||||
{ raw: "First body\n\n", src: "First body\n\n", mode: "full" },
|
||||
{ raw: "Second body", src: "Second body", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -29,4 +79,112 @@ describe("markdown stream", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps compact and indented reference definitions with their uses", () => {
|
||||
expect(stream("[docs]\n\n [docs]:/guide", true)).toEqual([
|
||||
{
|
||||
raw: "[docs]\n\n [docs]:/guide",
|
||||
src: "[docs]\n\n [docs]:/guide",
|
||||
mode: "live",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps multiline reference definitions with their uses", () => {
|
||||
expect(stream("[docs][id]\n\n[id]:\n /guide", true)).toEqual([
|
||||
{
|
||||
raw: "[docs][id]\n\n[id]:\n /guide",
|
||||
src: "[docs][id]\n\n[id]:\n /guide",
|
||||
mode: "live",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses only the language portion of fence metadata", () => {
|
||||
expect(stream("```ts title=example\nconst x = 1", true)).toEqual([
|
||||
{
|
||||
raw: "```ts title=example\nconst x = 1",
|
||||
src: "const x = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves trailing newlines in open code fences", () => {
|
||||
expect(stream("```ts\nconst x = 1\n", true)).toEqual([
|
||||
{
|
||||
raw: "```ts\nconst x = 1\n",
|
||||
src: "const x = 1\n",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("only reuses pending blocks with compatible identity and content", () => {
|
||||
expect(canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "", })).toBe(false)
|
||||
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" })).toBe(true)
|
||||
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false)
|
||||
})
|
||||
|
||||
test("appends plain code deltas without reprojecting frozen blocks", () => {
|
||||
const previous = project(undefined, "# Plan\n\n```ts\nconst one = 1\n", true)
|
||||
const next = project(previous, `${previous.text}const two = 2\n`, true)
|
||||
|
||||
expect(next.blocks[0]).toBe(previous.blocks[0])
|
||||
expect(next.blocks.at(-1)).toEqual({
|
||||
raw: "```ts\nconst one = 1\nconst two = 2\n",
|
||||
src: "const one = 1\nconst two = 2\n",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not add a blank line before the first streamed code", () => {
|
||||
const previous = project(undefined, "```ts\n", true)
|
||||
const next = project(previous, `${previous.text}const x = 1`, true)
|
||||
|
||||
expect(next.blocks.at(-1)).toEqual({
|
||||
raw: "```ts\nconst x = 1",
|
||||
src: "const x = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
})
|
||||
})
|
||||
|
||||
test("closes code fences split across provider deltas", () => {
|
||||
const open = project(undefined, "```ts\nconst x = 1\n", true)
|
||||
const one = project(open, `${open.text}\``, true)
|
||||
const two = project(one, `${one.text}\``, true)
|
||||
const closed = project(two, `${two.text}\``, true)
|
||||
const prose = project(closed, `${closed.text}\nafter`, true)
|
||||
|
||||
expect(closed.blocks.at(-1)).toEqual({
|
||||
raw: "```ts\nconst x = 1\n```",
|
||||
src: "const x = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
complete: true,
|
||||
})
|
||||
expect(prose.blocks).toEqual([
|
||||
{ raw: "```ts\nconst x = 1\n```\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
||||
{ raw: "after", src: "after", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("closes tilde fences split across provider deltas", () => {
|
||||
const open = project(undefined, "~~~ts\nconst x = 1\n", true)
|
||||
const one = project(open, `${open.text}~`, true)
|
||||
const two = project(one, `${one.text}~`, true)
|
||||
const closed = project(two, `${two.text}~`, true)
|
||||
|
||||
expect(closed.blocks.at(-1)).toEqual({
|
||||
raw: "~~~ts\nconst x = 1\n~~~",
|
||||
src: "const x = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
complete: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,28 @@ import remend from "remend"
|
||||
export type Block = {
|
||||
raw: string
|
||||
src: string
|
||||
mode: "full" | "live"
|
||||
mode: "full" | "live" | "code"
|
||||
language?: string
|
||||
complete?: boolean
|
||||
}
|
||||
|
||||
export type Projection = {
|
||||
text: string
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
function refs(text: string) {
|
||||
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text)
|
||||
if (!text.includes("]:")) return false
|
||||
return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(?:\S+|\r?\n[ \t]+\S+)/m.test(text)
|
||||
}
|
||||
|
||||
function language(value: string | undefined) {
|
||||
return value?.trim().split(/\s+/, 1)[0] || undefined
|
||||
}
|
||||
|
||||
function openCode(raw: string) {
|
||||
const newline = raw.indexOf("\n")
|
||||
return newline < 0 ? "" : raw.slice(newline + 1)
|
||||
}
|
||||
|
||||
function open(raw: string) {
|
||||
@@ -22,28 +39,72 @@ function open(raw: string) {
|
||||
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
|
||||
}
|
||||
|
||||
function closesFence(raw: string, suffix: string) {
|
||||
const mark = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)?.[1]
|
||||
if (!mark) return suffix.includes("```") || suffix.includes("~~~")
|
||||
return `${raw.slice(-(mark.length - 1))}${suffix}`.includes(mark)
|
||||
}
|
||||
|
||||
function heal(text: string) {
|
||||
return remend(text, { linkMode: "text-only" })
|
||||
}
|
||||
|
||||
export function stream(text: string, live: boolean) {
|
||||
export function stream(text: string, live: boolean): Block[] {
|
||||
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
|
||||
const src = heal(text)
|
||||
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
const tokens = marked.lexer(text)
|
||||
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
||||
if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
const last = tokens[tail]
|
||||
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
const code = last as Tokens.Code
|
||||
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
const head = tokens
|
||||
.slice(0, tail)
|
||||
if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
|
||||
const result: Block[] = []
|
||||
for (let index = 0; index < tail; index++) {
|
||||
const token = tokens[index]
|
||||
if (!token || token.type === "space") continue
|
||||
let raw = token.raw
|
||||
while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw
|
||||
if (token.type === "code") {
|
||||
const code = token as Tokens.Code
|
||||
result.push({ raw, src: code.text, mode: "code", language: language(code.lang), complete: true })
|
||||
continue
|
||||
}
|
||||
result.push({ raw, src: raw, mode: "full" })
|
||||
}
|
||||
|
||||
const raw = tokens
|
||||
.slice(tail)
|
||||
.map((token) => token.raw)
|
||||
.join("")
|
||||
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[]
|
||||
return [
|
||||
{ raw: head, src: heal(head), mode: "live" },
|
||||
{ raw: code.raw, src: code.raw, mode: "live" },
|
||||
] satisfies Block[]
|
||||
if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }]
|
||||
|
||||
const code = last as Tokens.Code
|
||||
if (!open(code.raw))
|
||||
return [...result, { raw, src: code.text, mode: "code", language: language(code.lang), complete: true }]
|
||||
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
|
||||
}
|
||||
|
||||
export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
|
||||
if (!current || current.mode !== next.mode) return false
|
||||
if (next.mode === "code") return next.raw.startsWith(current.raw)
|
||||
return current.raw === next.raw
|
||||
}
|
||||
|
||||
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
|
||||
if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
|
||||
const tail = previous.blocks.at(-1)
|
||||
const suffix = text.slice(previous.text.length)
|
||||
if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
|
||||
return { text, blocks: stream(text, live) }
|
||||
return {
|
||||
text,
|
||||
blocks: [
|
||||
...previous.blocks.slice(0, -1),
|
||||
{
|
||||
...tail,
|
||||
raw: tail.raw + suffix,
|
||||
src: tail.src + suffix,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
77
packages/ui/src/components/markdown-worker-protocol.test.ts
Normal file
77
packages/ui/src/components/markdown-worker-protocol.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { applyMarkdownWorkerResponse, markdownBlockKey, shouldReleaseMarkdownWorkerState } from "./markdown-worker-protocol"
|
||||
|
||||
const token = (content: string): [string, string] => [content, ""]
|
||||
const response = (id: number, reset: boolean, stable: [string, string][], unstable: [string, string][]) => ({
|
||||
type: "highlight" as const,
|
||||
id,
|
||||
key: "code",
|
||||
reset,
|
||||
stable,
|
||||
unstable,
|
||||
})
|
||||
|
||||
test("accumulates stable worker tokens and replaces the unstable tail", () => {
|
||||
const first = applyMarkdownWorkerResponse(undefined, {
|
||||
type: "highlight",
|
||||
id: 1,
|
||||
key: "code",
|
||||
reset: true,
|
||||
stable: [token("one\n")],
|
||||
unstable: [token("tw")],
|
||||
})
|
||||
const second = applyMarkdownWorkerResponse(first, {
|
||||
type: "highlight",
|
||||
id: 2,
|
||||
key: "code",
|
||||
reset: false,
|
||||
stable: [token("two\n")],
|
||||
unstable: [token("three")],
|
||||
})
|
||||
|
||||
expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"])
|
||||
expect(second.unstable.map((item) => item[0])).toEqual(["three"])
|
||||
})
|
||||
|
||||
test("increments generation only when the worker resets token identity", () => {
|
||||
const first = applyMarkdownWorkerResponse(undefined, response(1, true, [["const", ""]], []))
|
||||
const append = applyMarkdownWorkerResponse(first, response(2, false, [[" x", ""]], []))
|
||||
const replacement = applyMarkdownWorkerResponse(append, response(3, true, [["let y", ""]], []))
|
||||
expect([first.generation, append.generation, replacement.generation]).toEqual([1, 1, 2])
|
||||
})
|
||||
|
||||
test("ignores stale worker responses and resets replacement streams", () => {
|
||||
const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] }
|
||||
expect(
|
||||
applyMarkdownWorkerResponse(current, {
|
||||
type: "highlight",
|
||||
id: 1,
|
||||
key: "code",
|
||||
reset: false,
|
||||
stable: [token("stale")],
|
||||
unstable: [],
|
||||
}),
|
||||
).toBe(current)
|
||||
|
||||
expect(
|
||||
applyMarkdownWorkerResponse(current, {
|
||||
type: "highlight",
|
||||
id: 3,
|
||||
key: "code",
|
||||
reset: true,
|
||||
stable: [token("replacement")],
|
||||
unstable: [],
|
||||
}).stable.map((item) => item[0]),
|
||||
).toEqual(["replacement"])
|
||||
})
|
||||
|
||||
test("releases only the latest completed worker state", () => {
|
||||
expect(shouldReleaseMarkdownWorkerState(true, 4, 4)).toBe(true)
|
||||
expect(shouldReleaseMarkdownWorkerState(true, 5, 4)).toBe(false)
|
||||
expect(shouldReleaseMarkdownWorkerState(false, 4, 4)).toBe(false)
|
||||
})
|
||||
|
||||
test("prefixes pending and dispatched block keys with the component owner", () => {
|
||||
expect(markdownBlockKey("owner", "message", 2, "code")).toBe("owner:message:2:code")
|
||||
expect(markdownBlockKey("owner", undefined, 2, "code")).toBe("owner:block:2")
|
||||
})
|
||||
48
packages/ui/src/components/markdown-worker-protocol.ts
Normal file
48
packages/ui/src/components/markdown-worker-protocol.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ThemeRegistrationResolved } from "shiki"
|
||||
|
||||
export type MarkdownToken = [content: string, style: string]
|
||||
|
||||
export type MarkdownWorkerRequest =
|
||||
| { type: "init"; theme: ThemeRegistrationResolved }
|
||||
| { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean }
|
||||
| { type: "dispose"; key: string }
|
||||
|
||||
export type MarkdownWorkerResponse =
|
||||
| {
|
||||
type: "highlight"
|
||||
id: number
|
||||
key: string
|
||||
reset: boolean
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
| { type: "error"; id: number; key: string; message: string }
|
||||
| { type: "superseded"; id: number; key: string }
|
||||
|
||||
export type MarkdownWorkerState = {
|
||||
id: number
|
||||
generation: number
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
|
||||
export function shouldReleaseMarkdownWorkerState(complete: boolean, latestID: number | undefined, responseID: number) {
|
||||
return complete && latestID === responseID
|
||||
}
|
||||
|
||||
export function markdownBlockKey(owner: string, cacheKey: string | undefined, index: number, mode: string) {
|
||||
return `${owner}:${cacheKey ? `${cacheKey}:${index}:${mode}` : `block:${index}`}`
|
||||
}
|
||||
|
||||
export function applyMarkdownWorkerResponse(
|
||||
state: MarkdownWorkerState | undefined,
|
||||
response: Extract<MarkdownWorkerResponse, { type: "highlight" }>,
|
||||
) {
|
||||
if (state && response.id <= state.id) return state
|
||||
return {
|
||||
id: response.id,
|
||||
generation: (state?.generation ?? 0) + (response.reset ? 1 : 0),
|
||||
stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable],
|
||||
unstable: response.unstable,
|
||||
}
|
||||
}
|
||||
49
packages/ui/src/components/markdown-worker-queue.test.ts
Normal file
49
packages/ui/src/components/markdown-worker-queue.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createLatestWorkerQueue } from "./markdown-worker-queue"
|
||||
|
||||
test("keeps only the latest queued request for each key", async () => {
|
||||
const processed: number[] = []
|
||||
const superseded: number[] = []
|
||||
let release = () => {}
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const queue = createLatestWorkerQueue<{ id: number; key: string }>({
|
||||
run: async (request) => {
|
||||
processed.push(request.id)
|
||||
if (request.id === 1) await blocked
|
||||
},
|
||||
supersede: (request) => superseded.push(request.id),
|
||||
dispose: () => {},
|
||||
})
|
||||
|
||||
queue.highlight({ id: 1, key: "code" })
|
||||
await Promise.resolve()
|
||||
queue.highlight({ id: 2, key: "code" })
|
||||
queue.highlight({ id: 3, key: "code" })
|
||||
queue.highlight({ id: 4, key: "code" })
|
||||
|
||||
expect(queue.pending()).toBe(1)
|
||||
expect(superseded).toEqual([2, 3])
|
||||
release()
|
||||
await queue.idle()
|
||||
expect(processed).toEqual([1, 4])
|
||||
})
|
||||
|
||||
test("serializes disposal before a later request for the same key", async () => {
|
||||
const events: string[] = []
|
||||
const queue = createLatestWorkerQueue<{ id: number; key: string }>({
|
||||
run: async (request) => {
|
||||
events.push(`highlight:${request.id}`)
|
||||
},
|
||||
supersede: (request) => events.push(`supersede:${request.id}`),
|
||||
dispose: (key) => events.push(`dispose:${key}`),
|
||||
})
|
||||
|
||||
queue.highlight({ id: 1, key: "code" })
|
||||
queue.dispose("code")
|
||||
queue.highlight({ id: 2, key: "code" })
|
||||
await queue.idle()
|
||||
|
||||
expect(events).toEqual(["supersede:1", "dispose:code", "highlight:2"])
|
||||
})
|
||||
64
packages/ui/src/components/markdown-worker-queue.ts
Normal file
64
packages/ui/src/components/markdown-worker-queue.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export function createLatestWorkerQueue<T extends { key: string }>(input: {
|
||||
run: (request: T) => Promise<void>
|
||||
supersede: (request: T) => void
|
||||
dispose: (key: string) => void
|
||||
}) {
|
||||
type Slot = { type: "highlight"; key: string; request?: T }
|
||||
const jobs: Array<Slot | { type: "dispose"; key: string }> = []
|
||||
const slots = new Map<string, Slot>()
|
||||
let running: Promise<void> | undefined
|
||||
let cursor = 0
|
||||
|
||||
const schedule = () => {
|
||||
if (running) return
|
||||
running = Promise.resolve()
|
||||
.then(async () => {
|
||||
while (cursor < jobs.length) {
|
||||
const job = jobs[cursor++]!
|
||||
if (job.type === "dispose") {
|
||||
input.dispose(job.key)
|
||||
continue
|
||||
}
|
||||
if (slots.get(job.key) === job) slots.delete(job.key)
|
||||
const request = job.request
|
||||
job.request = undefined
|
||||
if (request) await input.run(request)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
jobs.splice(0, cursor)
|
||||
cursor = 0
|
||||
running = undefined
|
||||
if (jobs.length > 0) schedule()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
highlight(request: T) {
|
||||
const slot = slots.get(request.key)
|
||||
if (slot) {
|
||||
if (slot.request) input.supersede(slot.request)
|
||||
slot.request = request
|
||||
return
|
||||
}
|
||||
const next: Slot = { type: "highlight", key: request.key, request }
|
||||
slots.set(request.key, next)
|
||||
jobs.push(next)
|
||||
schedule()
|
||||
},
|
||||
dispose(key: string) {
|
||||
const slot = slots.get(key)
|
||||
if (slot?.request) input.supersede(slot.request)
|
||||
if (slot) {
|
||||
slot.request = undefined
|
||||
slots.delete(key)
|
||||
}
|
||||
jobs.push({ type: "dispose", key })
|
||||
schedule()
|
||||
},
|
||||
pending: () => slots.size,
|
||||
async idle() {
|
||||
while (running) await running
|
||||
},
|
||||
}
|
||||
}
|
||||
56
packages/ui/src/components/markdown-worker-transport.test.ts
Normal file
56
packages/ui/src/components/markdown-worker-transport.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createWorkerTransport } from "./markdown-worker-transport"
|
||||
|
||||
test("posts one request and retains only the latest queued snapshot per key", () => {
|
||||
const posted: number[] = []
|
||||
const superseded: number[] = []
|
||||
const transport = createWorkerTransport<{ id: number; key: string }>({
|
||||
post: (request) => posted.push(request.id),
|
||||
supersede: (request) => superseded.push(request.id),
|
||||
})
|
||||
|
||||
transport.send({ id: 1, key: "code" })
|
||||
transport.send({ id: 2, key: "code" })
|
||||
transport.send({ id: 3, key: "code" })
|
||||
|
||||
expect(posted).toEqual([1])
|
||||
expect(superseded).toEqual([2])
|
||||
expect(transport.queued()).toBe(1)
|
||||
transport.complete("code", 1)
|
||||
expect(posted).toEqual([1, 3])
|
||||
expect(transport.queued()).toBe(0)
|
||||
})
|
||||
|
||||
test("ignores a disposed request response after the key is reused", () => {
|
||||
const posted: number[] = []
|
||||
const transport = createWorkerTransport<{ id: number; key: string }>({
|
||||
post: (request) => posted.push(request.id),
|
||||
supersede: () => {},
|
||||
})
|
||||
|
||||
transport.send({ id: 1, key: "code" })
|
||||
transport.dispose("code")
|
||||
transport.send({ id: 2, key: "code" })
|
||||
transport.send({ id: 3, key: "code" })
|
||||
transport.complete("code", 1)
|
||||
|
||||
expect(posted).toEqual([1, 2])
|
||||
expect(transport.queued()).toBe(1)
|
||||
transport.complete("code", 2)
|
||||
expect(posted).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("drops queued snapshots when a key is disposed", () => {
|
||||
const superseded: number[] = []
|
||||
const transport = createWorkerTransport<{ id: number; key: string }>({
|
||||
post: () => {},
|
||||
supersede: (request) => superseded.push(request.id),
|
||||
})
|
||||
|
||||
transport.send({ id: 1, key: "code" })
|
||||
transport.send({ id: 2, key: "code" })
|
||||
transport.dispose("code")
|
||||
|
||||
expect(superseded).toEqual([2])
|
||||
expect(transport.queued()).toBe(0)
|
||||
})
|
||||
41
packages/ui/src/components/markdown-worker-transport.ts
Normal file
41
packages/ui/src/components/markdown-worker-transport.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export function createWorkerTransport<T extends { id: number; key: string }>(input: {
|
||||
post: (request: T) => void
|
||||
supersede: (request: T) => void
|
||||
}) {
|
||||
const active = new Map<string, T>()
|
||||
const queued = new Map<string, T>()
|
||||
|
||||
return {
|
||||
send(request: T) {
|
||||
if (!active.has(request.key)) {
|
||||
active.set(request.key, request)
|
||||
input.post(request)
|
||||
return
|
||||
}
|
||||
const previous = queued.get(request.key)
|
||||
if (previous) input.supersede(previous)
|
||||
queued.set(request.key, request)
|
||||
},
|
||||
complete(key: string, id: number) {
|
||||
if (active.get(key)?.id !== id) return
|
||||
active.delete(key)
|
||||
const next = queued.get(key)
|
||||
if (!next) return
|
||||
queued.delete(key)
|
||||
active.set(key, next)
|
||||
input.post(next)
|
||||
},
|
||||
dispose(key: string) {
|
||||
active.delete(key)
|
||||
const request = queued.get(key)
|
||||
if (request) input.supersede(request)
|
||||
queued.delete(key)
|
||||
},
|
||||
reset() {
|
||||
queued.forEach(input.supersede)
|
||||
queued.clear()
|
||||
active.clear()
|
||||
},
|
||||
queued: () => queued.size,
|
||||
}
|
||||
}
|
||||
122
packages/ui/src/components/markdown-worker.ts
Normal file
122
packages/ui/src/components/markdown-worker.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import MarkdownShikiWorkerUrl from "./markdown-shiki.worker.ts?worker&url"
|
||||
import { OpenCodeTheme } from "../context/marked"
|
||||
import {
|
||||
applyMarkdownWorkerResponse,
|
||||
shouldReleaseMarkdownWorkerState,
|
||||
type MarkdownWorkerRequest,
|
||||
type MarkdownWorkerResponse,
|
||||
type MarkdownWorkerState,
|
||||
} from "./markdown-worker-protocol"
|
||||
import { createWorkerTransport } from "./markdown-worker-transport"
|
||||
|
||||
type Pending = {
|
||||
key: string
|
||||
complete: boolean
|
||||
resolve: (state: MarkdownWorkerState) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let worker: Worker | undefined
|
||||
let disabled: Error | undefined
|
||||
let nextID = 0
|
||||
const pending = new Map<number, Pending>()
|
||||
const states = new Map<string, MarkdownWorkerState>()
|
||||
const keys = new Set<string>()
|
||||
const latest = new Map<string, number>()
|
||||
const transport = createWorkerTransport<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
|
||||
post: (request) => worker!.postMessage(request),
|
||||
supersede: (request) => {
|
||||
const result = pending.get(request.id)
|
||||
if (!result) return
|
||||
pending.delete(request.id)
|
||||
result.reject(new MarkdownWorkerSupersededError())
|
||||
},
|
||||
})
|
||||
|
||||
export function highlightStreamingCode(key: string, text: string, language: string, complete = false) {
|
||||
const instance = getWorker()
|
||||
const id = ++nextID
|
||||
latest.set(key, id)
|
||||
keys.delete(key)
|
||||
keys.add(key)
|
||||
if (keys.size > 200) disposeStreamingCode(keys.values().next().value!)
|
||||
return new Promise<MarkdownWorkerState>((resolve, reject) => {
|
||||
pending.set(id, { key, complete, resolve, reject })
|
||||
transport.send({ type: "highlight", id, key, text, language, complete })
|
||||
})
|
||||
}
|
||||
|
||||
export function disposeStreamingCode(key: string) {
|
||||
keys.delete(key)
|
||||
latest.delete(key)
|
||||
states.delete(key)
|
||||
transport.dispose(key)
|
||||
pending.forEach((request, id) => {
|
||||
if (request.key !== key) return
|
||||
pending.delete(id)
|
||||
request.reject(new MarkdownWorkerDisposedError())
|
||||
})
|
||||
worker?.postMessage({ type: "dispose", key } satisfies MarkdownWorkerRequest)
|
||||
}
|
||||
|
||||
export class MarkdownWorkerDisposedError extends Error {}
|
||||
export class MarkdownWorkerSupersededError extends Error {}
|
||||
export class MarkdownWorkerUnavailableError extends Error {}
|
||||
|
||||
function getWorker() {
|
||||
if (worker) return worker
|
||||
if (disabled) throw new MarkdownWorkerUnavailableError(disabled.message)
|
||||
try {
|
||||
worker = new Worker(MarkdownShikiWorkerUrl, { type: "module" })
|
||||
} catch (error) {
|
||||
disabled = error instanceof Error ? error : new Error(String(error))
|
||||
throw new MarkdownWorkerUnavailableError(disabled.message)
|
||||
}
|
||||
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
const result = pending.get(event.data.id)
|
||||
if (!result) {
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
pending.delete(event.data.id)
|
||||
if (!keys.has(event.data.key)) {
|
||||
result.reject(new MarkdownWorkerDisposedError())
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "superseded") {
|
||||
result.reject(new MarkdownWorkerSupersededError())
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "error") {
|
||||
result.reject(new Error(event.data.message))
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
const state = applyMarkdownWorkerResponse(states.get(event.data.key), event.data)
|
||||
if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(event.data.key), event.data.id)) {
|
||||
states.delete(event.data.key)
|
||||
keys.delete(event.data.key)
|
||||
latest.delete(event.data.key)
|
||||
} else states.set(event.data.key, state)
|
||||
result.resolve(state)
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
}
|
||||
const fail = (message: string) => {
|
||||
const error = new Error(message)
|
||||
disabled = error
|
||||
transport.reset()
|
||||
pending.forEach((request) => request.reject(error))
|
||||
pending.clear()
|
||||
states.clear()
|
||||
keys.clear()
|
||||
latest.clear()
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
}
|
||||
worker.onerror = (event) => fail(event.message || "Markdown highlighting worker failed")
|
||||
worker.onmessageerror = () => fail("Markdown worker response failed")
|
||||
worker.postMessage({ type: "init", theme: OpenCodeTheme } satisfies MarkdownWorkerRequest)
|
||||
return worker
|
||||
}
|
||||
@@ -15,6 +15,12 @@
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
> [data-markdown-block]:first-child > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
> [data-markdown-block]:last-child > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Headings: Same size, distinguished by color and spacing */
|
||||
h1,
|
||||
@@ -121,6 +127,8 @@
|
||||
}
|
||||
|
||||
.shiki {
|
||||
background: var(--color-background-stronger);
|
||||
color: var(--text-base);
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -3,17 +3,48 @@ import { useI18n } from "../context/i18n"
|
||||
import DOMPurify from "dompurify"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { ComponentProps, createEffect, createMemo, createResource, createSignal, createUniqueId, onCleanup, splitProps } from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
import { stream } from "./markdown-stream"
|
||||
import { bundledLanguages } from "shiki"
|
||||
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
|
||||
import {
|
||||
disposeStreamingCode,
|
||||
highlightStreamingCode,
|
||||
MarkdownWorkerDisposedError,
|
||||
MarkdownWorkerSupersededError,
|
||||
MarkdownWorkerUnavailableError,
|
||||
} from "./markdown-worker"
|
||||
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
||||
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
||||
|
||||
type Entry = {
|
||||
raw: string
|
||||
hash: string
|
||||
html: string
|
||||
}
|
||||
|
||||
type RenderedBlock =
|
||||
| (Entry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||
| {
|
||||
key: string
|
||||
mode: "code"
|
||||
raw: string
|
||||
hash: string
|
||||
language: string
|
||||
complete: boolean
|
||||
generation: number
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
|
||||
type RenderResult = {
|
||||
text: string
|
||||
blocks: RenderedBlock[]
|
||||
}
|
||||
|
||||
const max = 200
|
||||
const cache = new Map<string, Entry>()
|
||||
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
||||
|
||||
if (typeof window !== "undefined" && DOMPurify.isSupported) {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
@@ -60,6 +91,22 @@ function fallback(markdown: string) {
|
||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||
}
|
||||
|
||||
async function code(text: string, language: string | undefined, key: string, complete = false) {
|
||||
const name = language && language in bundledLanguages ? language : "text"
|
||||
try {
|
||||
const result = await highlightStreamingCode(key, text, name, complete)
|
||||
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof MarkdownWorkerDisposedError) &&
|
||||
!(error instanceof MarkdownWorkerSupersededError) &&
|
||||
!(error instanceof MarkdownWorkerUnavailableError)
|
||||
)
|
||||
console.error("Markdown highlighting worker failed", error)
|
||||
return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
|
||||
}
|
||||
}
|
||||
|
||||
type CopyLabels = {
|
||||
copy: string
|
||||
copied: string
|
||||
@@ -238,6 +285,33 @@ function touch(key: string, value: Entry) {
|
||||
cache.delete(first)
|
||||
}
|
||||
|
||||
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
|
||||
if (!text) return { text, blocks: [] }
|
||||
const base = key ?? checksum(text)
|
||||
if (base) {
|
||||
const blocks = projection.blocks.flatMap((block, index) => {
|
||||
if (block.mode === "code") return []
|
||||
const cacheKey = `${base}:${index}:${block.mode}`
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached?.raw !== block.raw) return []
|
||||
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
|
||||
})
|
||||
if (blocks.length === projection.blocks.length) return { text, blocks }
|
||||
}
|
||||
return {
|
||||
text,
|
||||
blocks: [
|
||||
{
|
||||
key: "initial",
|
||||
mode: "full",
|
||||
raw: text,
|
||||
hash: checksum(text) ?? "",
|
||||
html: fallback(text),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function Markdown(
|
||||
props: ComponentProps<"div"> & {
|
||||
text: string
|
||||
@@ -251,51 +325,104 @@ export function Markdown(
|
||||
const marked = useMarked()
|
||||
const i18n = useI18n()
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const owner = createUniqueId()
|
||||
const activeCodeKeys = new Set<string>()
|
||||
const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
|
||||
const projection = createMemo((previous: Projection | undefined) =>
|
||||
project(previous, local.text, local.streaming ?? false),
|
||||
)
|
||||
const [html] = createResource(
|
||||
() => ({
|
||||
text: local.text,
|
||||
key: local.cacheKey,
|
||||
streaming: local.streaming ?? false,
|
||||
}),
|
||||
() => {
|
||||
return {
|
||||
text: local.text,
|
||||
key: local.cacheKey,
|
||||
projection: projection(),
|
||||
}
|
||||
},
|
||||
async (src) => {
|
||||
if (isServer) return fallback(src.text)
|
||||
if (!src.text) return ""
|
||||
if (isServer)
|
||||
return {
|
||||
text: src.text,
|
||||
blocks: [
|
||||
{
|
||||
key: "server",
|
||||
mode: "full" as const,
|
||||
raw: src.text,
|
||||
hash: checksum(src.text) ?? "",
|
||||
html: fallback(src.text),
|
||||
},
|
||||
],
|
||||
} satisfies RenderResult
|
||||
if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult
|
||||
|
||||
const base = src.key ?? checksum(src.text)
|
||||
return Promise.all(
|
||||
stream(src.text, src.streaming).map(async (block, index) => {
|
||||
const hash = checksum(block.raw)
|
||||
const key = base ? `${base}:${index}:${block.mode}` : hash
|
||||
src.projection.blocks.map(async (block, index) => {
|
||||
const key = base ? `${base}:${index}:${block.mode}` : undefined
|
||||
const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
|
||||
|
||||
if (key && hash) {
|
||||
if (block.mode === "code") {
|
||||
const cached = completedCode.get(blockKey)
|
||||
if (block.complete && cached?.raw === block.raw) return cached
|
||||
const result = await code(block.src, block.language, blockKey, block.complete)
|
||||
const rendered = {
|
||||
key: blockKey,
|
||||
mode: block.mode,
|
||||
raw: block.raw,
|
||||
hash: String(block.raw.length),
|
||||
complete: !!block.complete,
|
||||
...result,
|
||||
}
|
||||
if (block.complete) completedCode.set(blockKey, rendered)
|
||||
return rendered
|
||||
}
|
||||
|
||||
if (key) {
|
||||
const cached = cache.get(key)
|
||||
if (cached && cached.hash === hash) {
|
||||
if (cached?.raw === block.raw) {
|
||||
touch(key, cached)
|
||||
return cached.html
|
||||
return { key: blockKey, mode: block.mode, ...cached }
|
||||
}
|
||||
}
|
||||
|
||||
const next = await Promise.resolve(marked.parse(block.src))
|
||||
const safe = sanitize(next)
|
||||
if (key && hash) touch(key, { hash, html: safe })
|
||||
return safe
|
||||
const hash = checksum(block.raw)
|
||||
const safe = sanitize(await Promise.resolve(marked.parse(block.src)))
|
||||
if (key && hash) touch(key, { raw: block.raw, hash, html: safe })
|
||||
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
|
||||
}),
|
||||
)
|
||||
.then((list) => list.join(""))
|
||||
.catch(() => fallback(src.text))
|
||||
.then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
|
||||
.catch(
|
||||
() =>
|
||||
({
|
||||
text: src.text,
|
||||
blocks: [
|
||||
{
|
||||
key: base ?? "fallback",
|
||||
mode: "full" as const,
|
||||
raw: src.text,
|
||||
hash: checksum(src.text) ?? "",
|
||||
html: fallback(src.text),
|
||||
},
|
||||
],
|
||||
}) satisfies RenderResult,
|
||||
)
|
||||
},
|
||||
{
|
||||
initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
|
||||
},
|
||||
{ initialValue: fallback(local.text) },
|
||||
)
|
||||
|
||||
let copyCleanup: (() => void) | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const container = root()
|
||||
const content = local.text ? (html.latest ?? html() ?? "") : ""
|
||||
const result = html.latest ?? html()
|
||||
const projected = projection()
|
||||
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
|
||||
if (!container) return
|
||||
if (isServer) return
|
||||
|
||||
if (!content) {
|
||||
if (content.length === 0) {
|
||||
container.innerHTML = ""
|
||||
return
|
||||
}
|
||||
@@ -304,27 +431,17 @@ export function Markdown(
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}
|
||||
const temp = document.createElement("div")
|
||||
temp.innerHTML = content
|
||||
decorate(temp, labels)
|
||||
|
||||
morphdom(container, temp, {
|
||||
childrenOnly: true,
|
||||
onBeforeElUpdated: (fromEl, toEl) => {
|
||||
if (
|
||||
fromEl instanceof HTMLButtonElement &&
|
||||
toEl instanceof HTMLButtonElement &&
|
||||
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
fromEl.getAttribute("data-copied") === "true"
|
||||
) {
|
||||
setCopyState(toEl, labels, true)
|
||||
}
|
||||
if (fromEl.isEqualNode(toEl)) return false
|
||||
return true
|
||||
},
|
||||
const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key))
|
||||
activeCodeKeys.forEach((key) => {
|
||||
if (!nextCodeKeys.has(key)) disposeCode(key)
|
||||
})
|
||||
|
||||
activeCodeKeys.clear()
|
||||
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
||||
content.forEach((block, index) => updateBlock(container, index, block, labels))
|
||||
while (container.children.length > content.length) container.lastElementChild?.remove()
|
||||
container.querySelectorAll<HTMLButtonElement>('[data-slot="markdown-copy-button"]').forEach((button) =>
|
||||
setCopyState(button, labels, button.dataset.copied === "true"),
|
||||
)
|
||||
if (!copyCleanup)
|
||||
copyCleanup = setupCodeCopy(container, () => ({
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
@@ -334,6 +451,8 @@ export function Markdown(
|
||||
|
||||
onCleanup(() => {
|
||||
if (copyCleanup) copyCleanup()
|
||||
activeCodeKeys.forEach(disposeCode)
|
||||
completedCode.clear()
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -348,3 +467,153 @@ export function Markdown(
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function pendingBlocks(
|
||||
result: RenderResult | undefined,
|
||||
projection: Projection | undefined,
|
||||
cacheKey: string | undefined,
|
||||
owner: string,
|
||||
) {
|
||||
if (!result) return []
|
||||
if (!projection || result.text === projection.text) return result.blocks
|
||||
const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
|
||||
return projection.blocks.map((block, index) => {
|
||||
const current = initial ? undefined : result.blocks[index]
|
||||
if (current && canReusePendingBlock(current, block)) return current
|
||||
const key = markdownBlockKey(owner, cacheKey, index, block.mode)
|
||||
if (block.mode !== "code")
|
||||
return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) }
|
||||
return {
|
||||
key,
|
||||
mode: block.mode,
|
||||
raw: block.raw,
|
||||
hash: String(block.raw.length),
|
||||
language: block.language ?? "text",
|
||||
complete: !!block.complete,
|
||||
stable: [],
|
||||
generation: 0,
|
||||
unstable: [[block.src, ""] as MarkdownToken],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function disposeCode(key: string) {
|
||||
disposeStreamingCode(key)
|
||||
}
|
||||
|
||||
function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) {
|
||||
const current = container.children[index]
|
||||
if (block.mode === "code") {
|
||||
updateCodeBlock(container, current, block, labels)
|
||||
return
|
||||
}
|
||||
if (
|
||||
current instanceof HTMLDivElement &&
|
||||
current.dataset.markdownKey === block.key &&
|
||||
current.dataset.markdownHash === block.hash
|
||||
)
|
||||
return
|
||||
|
||||
const next = document.createElement("div")
|
||||
next.dataset.markdownBlock = ""
|
||||
next.dataset.markdownKey = block.key
|
||||
next.dataset.markdownHash = block.hash
|
||||
next.style.display = "contents"
|
||||
next.innerHTML = block.html
|
||||
decorate(next, labels)
|
||||
|
||||
if (!(current instanceof HTMLDivElement)) {
|
||||
container.appendChild(next)
|
||||
return
|
||||
}
|
||||
|
||||
morphdom(current, next, {
|
||||
onBeforeElUpdated: (fromEl, toEl) => {
|
||||
if (
|
||||
fromEl instanceof HTMLButtonElement &&
|
||||
toEl instanceof HTMLButtonElement &&
|
||||
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
toEl.getAttribute("data-slot") === "markdown-copy-button"
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (fromEl.isEqualNode(toEl)) return false
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updateCodeBlock(
|
||||
container: HTMLDivElement,
|
||||
current: Element | undefined,
|
||||
block: Extract<RenderedBlock, { mode: "code" }>,
|
||||
labels: CopyLabels,
|
||||
) {
|
||||
const existing =
|
||||
current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
|
||||
const next = existing ?? document.createElement("div")
|
||||
next.dataset.markdownBlock = ""
|
||||
next.dataset.markdownKey = block.key
|
||||
next.dataset.markdownHash = block.hash
|
||||
next.dataset.markdownComplete = block.complete ? "true" : "false"
|
||||
next.style.display = "contents"
|
||||
|
||||
const code = existing?.querySelector("code")
|
||||
if (code instanceof HTMLElement) {
|
||||
code.className = `language-${block.language}`
|
||||
const previous = renderedCodeTokens.get(next)
|
||||
const reset = shouldResetCodeTokens(previous, {
|
||||
language: block.language,
|
||||
generation: block.generation,
|
||||
stableCount: block.stable.length,
|
||||
raw: block.raw,
|
||||
})
|
||||
const stableCount = reset ? 0 : previous!.stableCount
|
||||
const tail = [...block.stable.slice(stableCount), ...block.unstable]
|
||||
const prior = reset ? [] : previous!.unstable
|
||||
const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index]))
|
||||
const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix)
|
||||
while (code.children.length > keep) code.lastElementChild?.remove()
|
||||
tail.slice(keep - stableCount).map(createTokenSpan).forEach((span) => code.appendChild(span))
|
||||
renderedCodeTokens.set(next, {
|
||||
language: block.language,
|
||||
generation: block.generation,
|
||||
stableCount: block.stable.length,
|
||||
unstable: block.unstable,
|
||||
raw: block.raw,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div")
|
||||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
const pre = document.createElement("pre")
|
||||
pre.className = "shiki OpenCode"
|
||||
const codeElement = document.createElement("code")
|
||||
codeElement.className = `language-${block.language}`
|
||||
;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span))
|
||||
pre.appendChild(codeElement)
|
||||
wrapper.appendChild(pre)
|
||||
wrapper.appendChild(createCopyButton(labels))
|
||||
next.appendChild(wrapper)
|
||||
renderedCodeTokens.set(next, {
|
||||
language: block.language,
|
||||
generation: block.generation,
|
||||
stableCount: block.stable.length,
|
||||
unstable: block.unstable,
|
||||
raw: block.raw,
|
||||
})
|
||||
if (current) current.replaceWith(next)
|
||||
else container.appendChild(next)
|
||||
}
|
||||
|
||||
function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
|
||||
return !!right && left[0] === right[0] && left[1] === right[1]
|
||||
}
|
||||
|
||||
function createTokenSpan(token: MarkdownToken) {
|
||||
const span = document.createElement("span")
|
||||
span.setAttribute("style", token[1])
|
||||
span.textContent = token[0]
|
||||
return span
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ export interface MessagePartProps {
|
||||
onToolOpenChange?: (open: boolean) => void
|
||||
deferToolContent?: boolean
|
||||
virtualizeDiff?: boolean
|
||||
onContentRendered?: () => void
|
||||
showAssistantCopyPartID?: string | null
|
||||
turnDurationMs?: number
|
||||
}
|
||||
@@ -188,13 +189,14 @@ export type PartComponent = Component<MessagePartProps>
|
||||
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
|
||||
|
||||
const TEXT_RENDER_PACE_MS = 24
|
||||
const TEXT_RENDER_IMMEDIATE = 512
|
||||
const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/
|
||||
|
||||
function step(size: number) {
|
||||
if (size <= 12) return 2
|
||||
if (size <= 48) return 4
|
||||
if (size <= 96) return 8
|
||||
return Math.min(24, Math.ceil(size / 8))
|
||||
return Math.min(256, Math.ceil(size / 4))
|
||||
}
|
||||
|
||||
function next(text: string, start: number) {
|
||||
@@ -233,6 +235,10 @@ function createPacedValue(getValue: () => string, live?: () => boolean) {
|
||||
sync(text)
|
||||
return
|
||||
}
|
||||
if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
|
||||
sync(text)
|
||||
return
|
||||
}
|
||||
const end = next(text, shown.length)
|
||||
sync(text.slice(0, end))
|
||||
if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
|
||||
@@ -250,6 +256,11 @@ function createPacedValue(getValue: () => string, live?: () => boolean) {
|
||||
sync(text)
|
||||
return
|
||||
}
|
||||
if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
|
||||
clear()
|
||||
sync(text)
|
||||
return
|
||||
}
|
||||
if (text.length === shown.length || timeout) return
|
||||
timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
|
||||
})
|
||||
@@ -1272,6 +1283,7 @@ export function Part(props: MessagePartProps) {
|
||||
onToolOpenChange={props.onToolOpenChange}
|
||||
deferToolContent={props.deferToolContent}
|
||||
virtualizeDiff={props.virtualizeDiff}
|
||||
onContentRendered={props.onContentRendered}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
/>
|
||||
@@ -1292,6 +1304,7 @@ export interface ToolProps {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
deferContent?: boolean
|
||||
virtualizeDiff?: boolean
|
||||
onContentRendered?: () => void
|
||||
forceOpen?: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
@@ -1438,6 +1451,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
|
||||
deferContent={props.deferToolContent}
|
||||
virtualizeDiff={props.virtualizeDiff}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -2021,7 +2035,13 @@ ToolRegistry.register({
|
||||
}
|
||||
>
|
||||
<div data-component="edit-content">
|
||||
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} {...fileCompProps()} />
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
onRendered={props.onContentRendered}
|
||||
{...fileCompProps()}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
@@ -2080,6 +2100,7 @@ ToolRegistry.register({
|
||||
cacheKey: checksum(props.input.content),
|
||||
}}
|
||||
overflow="scroll"
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
@@ -2208,6 +2229,7 @@ ToolRegistry.register({
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={file.view.fileDiff}
|
||||
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -2283,6 +2305,7 @@ ToolRegistry.register({
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={single()!.view.fileDiff}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { scrollKey } from "./scroll-view"
|
||||
import { scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
|
||||
|
||||
describe("scrollKey", () => {
|
||||
test("maps plain navigation keys", () => {
|
||||
@@ -17,3 +17,38 @@ describe("scrollKey", () => {
|
||||
expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("scrollTopFromThumbPointer", () => {
|
||||
test("keeps downward thumb movement monotonic when content height changes", () => {
|
||||
const first = scrollTopFromThumbPointer({
|
||||
pointer: 300,
|
||||
viewportTop: 100,
|
||||
grabOffset: 12,
|
||||
clientHeight: 600,
|
||||
scrollHeight: 6_000,
|
||||
thumbHeight: 60,
|
||||
})
|
||||
const second = scrollTopFromThumbPointer({
|
||||
pointer: 320,
|
||||
viewportTop: 100,
|
||||
grabOffset: 12,
|
||||
clientHeight: 600,
|
||||
scrollHeight: 60_000,
|
||||
thumbHeight: 32,
|
||||
})
|
||||
|
||||
expect(second).toBeGreaterThan(first)
|
||||
})
|
||||
|
||||
test("clamps pointer positions to the scroll range", () => {
|
||||
const input = {
|
||||
viewportTop: 100,
|
||||
grabOffset: 12,
|
||||
clientHeight: 600,
|
||||
scrollHeight: 6_000,
|
||||
thumbHeight: 60,
|
||||
}
|
||||
expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0)
|
||||
expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,24 @@ export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollTopFromThumbPointer(input: {
|
||||
pointer: number
|
||||
viewportTop: number
|
||||
grabOffset: number
|
||||
clientHeight: number
|
||||
scrollHeight: number
|
||||
thumbHeight: number
|
||||
}) {
|
||||
const padding = 8
|
||||
const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
|
||||
if (maxThumbTop <= 0) return 0
|
||||
const thumbTop = Math.max(
|
||||
0,
|
||||
Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop),
|
||||
)
|
||||
return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight)
|
||||
}
|
||||
|
||||
export function ScrollView(props: ScrollViewProps) {
|
||||
const i18n = useI18n()
|
||||
const merged = mergeProps({ orientation: "vertical" }, props)
|
||||
@@ -103,39 +121,37 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
updateThumb()
|
||||
})
|
||||
|
||||
let startY = 0
|
||||
let startScrollTop = 0
|
||||
|
||||
const onThumbPointerDown = (e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState("isDragging", true)
|
||||
startY = e.clientY
|
||||
startScrollTop = viewportRef.scrollTop
|
||||
const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
|
||||
|
||||
thumbRef.setPointerCapture(e.pointerId)
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const deltaY = e.clientY - startY
|
||||
const { scrollHeight, clientHeight } = viewportRef
|
||||
const maxScrollTop = scrollHeight - clientHeight
|
||||
const maxThumbTop = clientHeight - thumbHeight()
|
||||
|
||||
if (maxThumbTop > 0) {
|
||||
const scrollDelta = deltaY * (maxScrollTop / maxThumbTop)
|
||||
viewportRef.scrollTop = startScrollTop + scrollDelta
|
||||
}
|
||||
viewportRef.scrollTop = scrollTopFromThumbPointer({
|
||||
pointer: e.clientY,
|
||||
viewportTop: viewportRef.getBoundingClientRect().top,
|
||||
grabOffset,
|
||||
clientHeight,
|
||||
scrollHeight,
|
||||
thumbHeight: thumbHeight(),
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
const done = (e: PointerEvent) => {
|
||||
setState("isDragging", false)
|
||||
thumbRef.releasePointerCapture(e.pointerId)
|
||||
thumbRef.removeEventListener("pointermove", onPointerMove)
|
||||
thumbRef.removeEventListener("pointerup", onPointerUp)
|
||||
thumbRef.removeEventListener("pointerup", done)
|
||||
thumbRef.removeEventListener("pointercancel", done)
|
||||
}
|
||||
|
||||
thumbRef.addEventListener("pointermove", onPointerMove)
|
||||
thumbRef.addEventListener("pointerup", onPointerUp)
|
||||
thumbRef.addEventListener("pointerup", done)
|
||||
thumbRef.addEventListener("pointercancel", done)
|
||||
}
|
||||
|
||||
// Keybinds implementation
|
||||
|
||||
@@ -6,14 +6,16 @@ import { bundledLanguages, type BundledLanguage } from "shiki"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs"
|
||||
|
||||
registerCustomTheme("OpenCode", () => {
|
||||
return Promise.resolve({
|
||||
export const OpenCodeTheme = {
|
||||
name: "OpenCode",
|
||||
bg: "var(--color-background-stronger)",
|
||||
fg: "var(--text-base)",
|
||||
colors: {
|
||||
"editor.background": "var(--color-background-stronger)",
|
||||
"editor.foreground": "var(--text-base)",
|
||||
"gitDecoration.addedResourceForeground": "var(--syntax-diff-add)",
|
||||
"gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)",
|
||||
"gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)",
|
||||
// "gitDecoration.conflictingResourceForeground": "#ffca00",
|
||||
// "gitDecoration.modifiedResourceForeground": "#1a76d4",
|
||||
// "gitDecoration.untrackedResourceForeground": "#00cab1",
|
||||
@@ -373,8 +375,9 @@ registerCustomTheme("OpenCode", () => {
|
||||
"variable.constant": "var(--syntax-constant)",
|
||||
"variable.defaultLibrary": "var(--syntax-unknown)",
|
||||
},
|
||||
} as unknown as ThemeRegistrationResolved)
|
||||
})
|
||||
} as unknown as ThemeRegistrationResolved
|
||||
|
||||
registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme))
|
||||
|
||||
function renderMathInText(text: string): string {
|
||||
let result = text
|
||||
|
||||
@@ -17,24 +17,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
const unsafeCSS = `
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
|
||||
--diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer))));
|
||||
--diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer))));
|
||||
--diffs-bg-context: var(--diffs-bg-context-override, light-dark( color-mix(in lab, var(--diffs-bg) 98.5%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92.5%, var(--diffs-mixer))));
|
||||
--diffs-bg-separator: var(--diffs-bg-separator-override, light-dark( color-mix(in lab, var(--diffs-bg) 96%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-mixer))));
|
||||
--diffs-fg: light-dark(var(--diffs-light), var(--diffs-dark));
|
||||
--diffs-fg-number: var(--diffs-fg-number-override, light-dark(color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)), color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg))));
|
||||
--diffs-deletion-base: var(--syntax-diff-delete);
|
||||
--diffs-addition-base: var(--syntax-diff-add);
|
||||
--diffs-modified-base: var(--syntax-diff-unknown);
|
||||
--diffs-bg-deletion: var(--diffs-bg-deletion-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base))));
|
||||
--diffs-bg-deletion-number: var(--diffs-bg-deletion-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-deletion-base))));
|
||||
--diffs-bg-deletion-hover: var(--diffs-bg-deletion-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base))));
|
||||
--diffs-bg-deletion-emphasis: var(--diffs-bg-deletion-emphasis-override, light-dark(rgb(from var(--diffs-deletion-base) r g b / 0.7), rgb(from var(--diffs-deletion-base) r g b / 0.1)));
|
||||
--diffs-bg-addition: var(--diffs-bg-addition-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-addition-base))));
|
||||
--diffs-bg-addition-number: var(--diffs-bg-addition-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-addition-base))));
|
||||
--diffs-bg-addition-hover: var(--diffs-bg-addition-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 70%, var(--diffs-addition-base))));
|
||||
--diffs-bg-addition-emphasis: var(--diffs-bg-addition-emphasis-override, light-dark(rgb(from var(--diffs-addition-base) r g b / 0.07), rgb(from var(--diffs-addition-base) r g b / 0.1)));
|
||||
/* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
|
||||
--diffs-bg-deletion-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base))
|
||||
);
|
||||
--diffs-bg-addition-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base))
|
||||
);
|
||||
--diffs-selection-base: var(--surface-warning-strong);
|
||||
--diffs-selection-border: var(--border-warning-base);
|
||||
--diffs-selection-number-fg: #1c1917;
|
||||
|
||||
@@ -16,7 +16,7 @@ const cache = new WeakMap<Document | HTMLElement, Entry>()
|
||||
export const virtualMetrics: Partial<VirtualFileMetrics> = {
|
||||
lineHeight: 24,
|
||||
hunkSeparatorHeight: 24,
|
||||
fileGap: 0,
|
||||
spacing: 0,
|
||||
}
|
||||
|
||||
function scrollable(value: string) {
|
||||
|
||||
Reference in New Issue
Block a user