| author | |
| committer | |
| log | d7d7f8f3eb7bf593925281eb62348e15a91252b8 |
| tree | 6763870c830490611b874695139c466b18a1b42f |
| parent | a9dabbf100d1f6c0b2ed7b5e879d469ac14fd186 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 169 insertions(+), 2 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+9-2| ... | @@ -123,6 +123,7 @@ import { fnv1a32 } from "../lib/diffRendering"; | ... | @@ -123,6 +123,7 @@ import { fnv1a32 } from "../lib/diffRendering"; |
| 123 | import { LRUCache } from "../lib/lruCache"; | 123 | import { LRUCache } from "../lib/lruCache"; |
| 124 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; | 124 | import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; |
| 125 | import { GitHubIcon } from "./Icons"; | 125 | import { GitHubIcon } from "./Icons"; |
| 126 | import { createIncrementalHighlighter } from "../lib/incrementalHighlighting"; | ||
| 126 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; | 127 | import { RenderErrorBoundary } from "./RenderErrorBoundary"; |
| 127 | import { useTheme } from "../hooks/useTheme"; | 128 | import { useTheme } from "../hooks/useTheme"; |
| 128 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; | 129 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; |
| ... | @@ -1050,9 +1051,15 @@ function UncachedShikiCodeBlock({ | ... | @@ -1050,9 +1051,15 @@ function UncachedShikiCodeBlock({ |
| 1050 | isStreaming, | 1051 | isStreaming, |
| 1051 | }: UncachedShikiCodeBlockProps) { | 1052 | }: UncachedShikiCodeBlockProps) { |
| 1052 | const highlighter = use(getSyntaxHighlighterPromise(language)); | 1053 | const highlighter = use(getSyntaxHighlighterPromise(language)); |
| 1054 | const incrementalHighlight = useMemo( | ||
| 1055 | () => (isStreaming ? createIncrementalHighlighter(highlighter, language, themeName) : null), | ||
| 1056 | [highlighter, isStreaming, language, themeName], | ||
| 1057 | ); | ||
| 1053 | const highlightedHtml = useMemo(() => { | 1058 | const highlightedHtml = useMemo(() => { |
| 1054 | try { | 1059 | try { |
| 1055 | return highlighter.codeToHtml(code, { lang: language, theme: themeName }); | 1060 | return incrementalHighlight |
| 1061 | ? incrementalHighlight(code) | ||
| 1062 | : highlighter.codeToHtml(code, { lang: language, theme: themeName }); | ||
| 1056 | } catch (error) { | 1063 | } catch (error) { |
| 1057 | // Log highlighting failures for debugging while falling back to plain text | 1064 | // Log highlighting failures for debugging while falling back to plain text |
| 1058 | console.warn( | 1065 | console.warn( |
| ... | @@ -1062,7 +1069,7 @@ function UncachedShikiCodeBlock({ | ... | @@ -1062,7 +1069,7 @@ function UncachedShikiCodeBlock({ |
| 1062 | // If highlighting fails for this language, render as plain text | 1069 | // If highlighting fails for this language, render as plain text |
| 1063 | return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); | 1070 | return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); |
| 1064 | } | 1071 | } |
| 1065 | }, [code, highlighter, language, themeName]); | 1072 | }, [code, highlighter, incrementalHighlight, language, themeName]); |
| 1066 | 1073 | ||
| 1067 | useEffect(() => { | 1074 | useEffect(() => { |
| 1068 | if (!isStreaming) { | 1075 | if (!isStreaming) { |
apps/web/src/lib/incrementalHighlighting.test.ts created+86| ... | @@ -0,0 +1,86 @@ | ||
| 1 | import { getSharedHighlighter } from "@pierre/diffs"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { createIncrementalHighlighter } from "./incrementalHighlighting"; | ||
| 5 | |||
| 6 | const samples = { | ||
| 7 | typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n", | ||
| 8 | python: '#!/usr/bin/python\nx = """multi\nline"""\nprint(x)\n', | ||
| 9 | bash: "#!/bin/bash\ncat <<EOF\nhello\nEOF\necho hi\n", | ||
| 10 | html: "<script>\nconst x = 1;\n</script>\n<style>\np {color:red;}\n</style>\n", | ||
| 11 | markdown: "# heading\n\n```ts\nconst a = 1;\n```\n\ntext\n", | ||
| 12 | rust: 'fn main() {\n let x = r#"multi\nline"#;\n}\n', | ||
| 13 | tsx: 'const element = <div\n className="x">\n{value}\n</div>;\n', | ||
| 14 | json: '{\n "value": [1,\n 2, 3]\n}\n', | ||
| 15 | yaml: "key: |\n multiline\n value\nnext: true\n", | ||
| 16 | css: '/* comment\n continued */\np::before {\n content: "text";\n}\n', | ||
| 17 | sql: "SELECT 'multi\nline'\nFROM table_name;\n", | ||
| 18 | } as const; | ||
| 19 | |||
| 20 | const highlighterPromise = getSharedHighlighter({ | ||
| 21 | langs: Object.keys(samples) as Array<keyof typeof samples>, | ||
| 22 | themes: ["pierre-dark", "pierre-light"], | ||
| 23 | preferredHighlighter: "shiki-wasm", | ||
| 24 | }); | ||
| 25 | |||
| 26 | describe("incremental code highlighting", () => { | ||
| 27 | it.each(Object.entries(samples))( | ||
| 28 | "matches full HTML at every streaming prefix in %s", | ||
| 29 | async (language, code) => { | ||
| 30 | const highlighter = await highlighterPromise; | ||
| 31 | for (const theme of ["pierre-dark", "pierre-light"] as const) { | ||
| 32 | const highlight = createIncrementalHighlighter(highlighter, language, theme); | ||
| 33 | for (let end = 0; end <= code.length; end++) { | ||
| 34 | const text = code.slice(0, end); | ||
| 35 | expect(highlight(text), `${theme}, prefix ${end}`).toBe( | ||
| 36 | highlighter.codeToHtml(text, { lang: language, theme }), | ||
| 37 | ); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | }, | ||
| 41 | ); | ||
| 42 | |||
| 43 | it("resets after edits and truncation, including edits to a completed line", async () => { | ||
| 44 | const highlighter = await highlighterPromise; | ||
| 45 | const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); | ||
| 46 | const inputs = [ | ||
| 47 | "/* open\ncomment\n", | ||
| 48 | "/* open\ncomment\n*/\nconst x = 1;", | ||
| 49 | "const edited = 2;\nconst x = 1;", | ||
| 50 | "const edited = 2;\nconst x = 10;", | ||
| 51 | "const edited = 2;\n", | ||
| 52 | "", | ||
| 53 | "\n\n\nconst fresh = true;\n", | ||
| 54 | ]; | ||
| 55 | for (const text of inputs) { | ||
| 56 | expect(highlight(text)).toBe( | ||
| 57 | highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), | ||
| 58 | ); | ||
| 59 | } | ||
| 60 | }); | ||
| 61 | |||
| 62 | it.each(["text", "plaintext", "plain", "txt", "ansi"])( | ||
| 63 | "preserves %s without requesting grammar state", | ||
| 64 | async (language) => { | ||
| 65 | const highlighter = await highlighterPromise; | ||
| 66 | const highlight = createIncrementalHighlighter(highlighter, language, "pierre-dark"); | ||
| 67 | for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { | ||
| 68 | expect(highlight(text)).toBe( | ||
| 69 | highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }), | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | }, | ||
| 73 | ); | ||
| 74 | |||
| 75 | it("preserves partial CRLF and CR line endings", async () => { | ||
| 76 | const highlighter = await highlighterPromise; | ||
| 77 | const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); | ||
| 78 | const code = "/* multi\r\nline */\r\nconst x = 1;\r\n"; | ||
| 79 | for (let end = 0; end <= code.length; end++) { | ||
| 80 | const text = code.slice(0, end); | ||
| 81 | expect(highlight(text)).toBe( | ||
| 82 | highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), | ||
| 83 | ); | ||
| 84 | } | ||
| 85 | }); | ||
| 86 | }); | ||
apps/web/src/lib/incrementalHighlighting.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import type { DiffsHighlighter } from "@pierre/diffs"; | ||
| 2 | |||
| 3 | import type { DiffThemeName } from "./diffRendering"; | ||
| 4 | |||
| 5 | function codeChildren(root: ReturnType<DiffsHighlighter["codeToHast"]>) { | ||
| 6 | const pre = root.children.find((node) => node.type === "element" && node.tagName === "pre"); | ||
| 7 | if (pre?.type !== "element") throw new Error("Missing highlighted pre element"); | ||
| 8 | const code = pre.children.find((node) => node.type === "element" && node.tagName === "code"); | ||
| 9 | if (code?.type !== "element") throw new Error("Missing highlighted code element"); | ||
| 10 | return code.children; | ||
| 11 | } | ||
| 12 | |||
| 13 | /** Resume tokenization after the last completed line. Keep its grammar state so | ||
| 14 | * multiline strings, comments, and embedded languages continue to highlight as | ||
| 15 | * they do in a full pass. The current line is always highlighted again. | ||
| 16 | */ | ||
| 17 | export function createIncrementalHighlighter( | ||
| 18 | highlighter: DiffsHighlighter, | ||
| 19 | language: string, | ||
| 20 | theme: DiffThemeName, | ||
| 21 | ) { | ||
| 22 | const options = { lang: language, theme }; | ||
| 23 | const newline = { type: "text" as const, value: "\n" }; | ||
| 24 | let cached: | ||
| 25 | | { | ||
| 26 | prefix: string; | ||
| 27 | state: ReturnType<DiffsHighlighter["getLastGrammarState"]>; | ||
| 28 | children: ReturnType<typeof codeChildren>; | ||
| 29 | } | ||
| 30 | | undefined; | ||
| 31 | |||
| 32 | return (code: string): string => { | ||
| 33 | // Plain text and ANSI do not have a TextMate grammar state. A CR at the end | ||
| 34 | // of a chunk can still become a CRLF, so keep that input on the full path. | ||
| 35 | if ( | ||
| 36 | !language || | ||
| 37 | ["text", "plaintext", "plain", "txt", "ansi"].includes(language) || | ||
| 38 | code.includes("\r") | ||
| 39 | ) { | ||
| 40 | return highlighter.codeToHtml(code, options); | ||
| 41 | } | ||
| 42 | if (cached && !code.startsWith(cached.prefix)) cached = undefined; | ||
| 43 | const end = code.lastIndexOf("\n") + 1; | ||
| 44 | if (end > (cached?.prefix.length ?? 0)) { | ||
| 45 | // Omit the final newline: Shiki would tokenize an extra empty line and | ||
| 46 | // advance the grammar state twice before we process the following line. | ||
| 47 | const root = highlighter.codeToHast(code.slice(cached?.prefix.length ?? 0, end - 1), { | ||
| 48 | ...options, | ||
| 49 | ...(cached ? { grammarState: cached.state } : {}), | ||
| 50 | }); | ||
| 51 | const state = highlighter.getLastGrammarState(root); | ||
| 52 | if (!state) { | ||
| 53 | cached = undefined; | ||
| 54 | return highlighter.codeToHtml(code, options); | ||
| 55 | } | ||
| 56 | cached = { | ||
| 57 | prefix: code.slice(0, end), | ||
| 58 | state, | ||
| 59 | children: [...(cached ? [...cached.children, newline] : []), ...codeChildren(root)], | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | const prefix = cached; | ||
| 63 | if (!prefix) return highlighter.codeToHtml(code, options); | ||
| 64 | return highlighter.codeToHtml(code.slice(prefix.prefix.length), { | ||
| 65 | ...options, | ||
| 66 | grammarState: prefix.state, | ||
| 67 | transformers: [ | ||
| 68 | { | ||
| 69 | code: (node) => ({ ...node, children: [...prefix.children, newline, ...node.children] }), | ||
| 70 | }, | ||
| 71 | ], | ||
| 72 | }); | ||
| 73 | }; | ||
| 74 | } | ||