feat(app): make session timelines much faster AND without flicker or scroll jumps (#32331)

This commit is contained in:
Luke Parker
2026-06-16 18:53:57 +02:00
committed by GitHub
parent e772664389
commit 3b811bd019
49 changed files with 2822 additions and 843 deletions

View File

@@ -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"
}
}

View File

@@ -2,6 +2,10 @@
content-visibility: auto;
}
[data-timeline-row] [data-component="file"] {
content-visibility: visible;
}
[data-component="file"][data-mode="text"] {
overflow: hidden;
}

View File

@@ -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> = {

View 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)
})

View 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)
)
}

View 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))]
}

View File

@@ -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,
})
})
})

View File

@@ -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,
},
],
}
}

View 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")
})

View 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,
}
}

View 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"])
})

View 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
},
}
}

View 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)
})

View 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,
}
}

View 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
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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)
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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) {