fix(web): leave overlong source lines uncolored

TextMate scanning cost grows superlinearly with line length, so a
generated line of tens of thousands of characters blocked the page for
seconds when the sidebar previewed the file. Lines of 1,000 or more
UTF-16 units now render as one plain run in every highlighted surface.
This commit is contained in:
Turtle committed 2026-10-06 10:53:31 +08:00
1 parent c042d32d21
commit a150f22c91
3 files changed
+30 -3

No files matched your search

+1
View File
@@ -199,6 +199,7 @@ These limits define how the atoms behave at the edges; they are current package
- **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network.
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
- **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path.
- **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more.
- **The fish logo is a redrawn approximation** — its font glyph has no exportable vector geometry in the local design data, so a hand-authored recreation stands in until an exact export path exists.
- **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, error, and idle.
@@ -275,6 +275,14 @@ const regexEngine = createJavaScriptRegexEngine({
}),
})
/**
* Lines at least this many UTF-16 units long render as one uncolored token.
* TextMate scanning cost grows superlinearly with line length (a 20,000-unit
* string literal blocks the main thread for seconds), and minified or
* generated lines are where that length occurs.
*/
const TOKENIZE_MAX_LINE_LENGTH = 1000
let singleton: HighlighterCore | undefined
/** Representative paths through every boot grammar, compiled before user content is timed. */
@@ -386,7 +394,9 @@ export function highlightToHtml(code: string, lang: string | undefined): string
const resolved = grammarForHint(lang)
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
return highlighter().codeToHtml(code, {
lang: resolved, theme: 'css-variables', tokenizeMaxLineLength: TOKENIZE_MAX_LINE_LENGTH,
})
}
/**
@@ -492,6 +502,7 @@ export class StreamingHighlightSession {
return highlighter().codeToTokensBase(text, {
lang: resolved,
theme: 'css-variables',
tokenizeMaxLineLength: TOKENIZE_MAX_LINE_LENGTH,
...(this.state === undefined ? {} : { grammarState: this.state }),
})
}
@@ -586,7 +597,9 @@ export function highlightLines(code: string, lang: string | undefined): Highligh
const resolved = grammarForHint(lang)
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
const { tokens } = highlighter().codeToTokens(code, {
lang: resolved, theme: 'css-variables', tokenizeMaxLineLength: TOKENIZE_MAX_LINE_LENGTH,
})
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
// third, empty line the caller's own line array does not carry. Drop that
// one terminator line so the two structures stay in step. The explicit
@@ -4,7 +4,9 @@ import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, renderHook, waitFor } from '@testing-library/react'
import { languageForPath as sharedLanguageForPath, readLangHintForPath } from '@deepseek-ai/dsh-util-code-language'
import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from '../src/code-highlighting.ts'
import { grammarForHint, supportsHighlighting } from '../src/markdown/highlight.ts'
import {
StreamingHighlightSession, grammarForHint, highlightLines, highlightToHtml, supportsHighlighting,
} from '../src/markdown/highlight.ts'
afterEach(cleanup)
@@ -102,4 +104,15 @@ describe('code highlighting', () => {
}
expect(hook.result.current('sample')).not.toBeUndefined()
})
it('leaves an overlong line as one uncolored run while its neighbors still highlight', () => {
const literal = `const table = "${'ab'.repeat(500)}"`
const code = ['const before = 1', literal, 'const after = 2'].join('\n')
const lines = highlightLines(code, 'javascript')
expect(lines?.map(line => line.length > 1)).toEqual([true, false, true])
expect(lines?.[1]).toEqual([{ text: literal, style: { color: '' } }])
expect(new StreamingHighlightSession().update(code, 'javascript')?.[1]).toEqual([{ text: literal, style: { color: '' } }])
const html = highlightToHtml(code, 'javascript')
expect(html).toContain(`<span class="line"><span>${literal}</span></span>`)
})
})