perf(web-read-card): lazy-load read grammars and guard empty-window copy
Only TypeScript, shell, and JSON grammars load at Web boot; the read card's wider langFromPath extension set loads through dynamic imports on first use, so a session that never opens a read card in one of those languages avoids ~1.6 MB of grammar modules and their synchronous init. ReadBlock/CodeBlock re-render on grammar-load via useSyncExternalStore, picking up highlighting once the grammar registers. ReadBlock hides the copy control on an empty window (a successful read of an empty file settles to lines: [] with card:'read'), matching TerminalBlock so it cannot wipe the clipboard.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
|
||||
2026-07-30-web-read-card-frontend.md: 3d1893b72dc9ef8fed3ee3d92e52980b3eb06743
|
||||
2026-07-30-web-read-card-frontend.zh.md: acfb31a8e6306a90f634075754074e163d2431e1
|
||||
2026-07-30-web-read-card-frontend.md: f504cab7705d03f6d3e911da05c509da50bb9abe
|
||||
2026-07-30-web-read-card-frontend.zh.md: b6314f21ba3eb2283788374b10c77ed22e26d16c
|
||||
@@ -20,12 +20,18 @@ The chat row renders the card **resident** under the summary line, capped at `CH
|
||||
|
||||
Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside.
|
||||
|
||||
**Read-card grammars load lazily; only the boot three stay eager.** `highlight.ts` is a platform seed `ui-primitives` loads on every Web boot, and its warm-up unconditionally builds the shiki singleton. The read card's `langFromPath` hints span the full source/config/markup extension set (python, rust, yaml, html, …); registering all of them eagerly would add ~1.6 MB of grammar modules to the boot chunk and their synchronous init to every session, including sessions that never open a read card. So only the three grammars every session already renders — TypeScript, shell, JSON (the markdown-fence and `run_code` languages) — load at boot. Each read-card extension grammar sits behind a dynamic `import()` in `LAZY_GRAMMARS`, keyed by the grammar id its aliases resolve to. On the first `highlightLines`/`highlightToHtml` call for a lazy language, `ensureGrammar` starts the import (once) and returns not-ready, so the card renders plain that frame; when the import resolves it registers the grammar with `loadLanguageSync`, bumps a load counter, and notifies subscribers. `ReadBlock` and `CodeBlock` subscribe through `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)`, so the card re-renders with highlighting the moment the grammar is ready. An unknown/absent language still returns undefined synchronously (plain, never an error).
|
||||
|
||||
**The empty-window copy control is hidden, matching `TerminalBlock`.** A successful read of an empty file returns `lines: []`, `totalLines: 0`, and `presentResult` still projects `card: 'read'`, so the empty-window branch is reachable — the read card is not, as an earlier draft assumed, unreachable for an empty result. `ReadBlock` therefore hides the copy control when `lines` is empty, exactly as `TerminalBlock` hides copy on empty output, so the button can never wipe the clipboard with an empty string.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Extend `CodeBlock` with an optional line-number gutter and `startLine`.** Rejected: it imposes a read-specific gutter, a windowed-count note, and a height cap on every markdown fence and `run_code` body that shares `CodeBlock`, for no benefit to those callers. The genuinely shared surface is the shiki grammar singleton, which both blocks reuse through `highlight.ts`; the chrome around it differs (a read has a gutter and a window note, a fence has neither), so a second small primitive is the correct split, exactly as `TerminalBlock` is a second primitive over the same tokens rather than a `CodeBlock` mode.
|
||||
|
||||
**Reuse `highlightToHtml` and inject gutter numbers with CSS counters.** Rejected: the single-`<pre>` HTML shiki emits has no per-line boundary a gutter can hang a file line number off (a windowed read's numbers start above 1 and are not a simple CSS counter increment), and parsing the numbers back out of the HTML would be fragile. `codeToTokens` gives the per-line token structure directly.
|
||||
|
||||
**Register all read-card grammars eagerly in the boot warm-up.** Rejected: it puts ~1.6 MB of grammar modules and their synchronous init on every Web boot for a card most sessions never open. The lazy path costs a single plain-first frame the first time a given language is read, then highlights on the grammar-load re-render; the boot cost is paid only for the three grammars every session already renders.
|
||||
|
||||
## Consequences
|
||||
|
||||
`ui-primitives` gains `ReadBlock` and `highlightLines`; no new runtime dependency (shiki was already present for `CodeBlock`). `ReadBlock` reads only the read view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the read capability still gets the backend's `content` fallback (the envelope-stripped text) through the generic card, unchanged.
|
||||
@@ -34,11 +40,11 @@ A read row in the Web chat now carries the file content resident, a deliberate d
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, and its `undefined` for an unknown/absent language; and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, and the copy control writing the window's raw text on both the accepted and refused clipboard paths. Both `ReadBlock.tsx` and `highlight.ts` hold per-file 100% coverage (the latter over this spec plus `code-block.spec.tsx`, which covers `highlightToHtml`).
|
||||
`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs.
|
||||
|
||||
`packages/client/ui-conversation/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so it is written against no gate pressure.
|
||||
|
||||
The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`; the render-site fallback row is already covered by the read sub-dispatches in the turn 64 `run_code` sample. It is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`.
|
||||
The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -20,12 +20,18 @@ Status: implemented
|
||||
|
||||
整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。
|
||||
|
||||
**读取卡片的语法按需 lazy 加载,只有 boot 三种保持 eager。** `highlight.ts` 是 `ui-primitives` 在每次 Web 启动都加载的平台 seed,其预热会无条件构建 shiki 单例。读取卡片的 `langFromPath` 提示覆盖完整的源码/配置/标记扩展集(python、rust、yaml、html……);把它们全部 eager 注册会给启动 chunk 增加约 1.6 MB 的语法模块、并把它们的同步初始化摊给每个会话,包括从不打开读取卡片的会话。因此只有每个会话本就渲染的三种语法 —— TypeScript、shell、JSON(markdown 围栏与 `run_code` 语言)—— 在 boot 时加载。每种读取卡片扩展语法置于 `LAZY_GRAMMARS` 中一个动态 `import()` 之后,以其别名解析到的语法 id 为键。对某个 lazy 语言首次调用 `highlightLines`/`highlightToHtml` 时,`ensureGrammar` 启动 import(仅一次)并返回未就绪,于是卡片该帧渲染纯文本;import 解析后用 `loadLanguageSync` 注册该语法、递增一个加载计数、并通知订阅者。`ReadBlock` 与 `CodeBlock` 通过 `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)` 订阅,因此语法就绪的那一刻卡片就重渲染带上高亮。未知/缺省语言仍同步返回 undefined(纯文本,绝不报错)。
|
||||
|
||||
**空窗口的复制控件被隐藏,与 `TerminalBlock` 对齐。** 成功读取一个空文件会返回 `lines: []`、`totalLines: 0`,且 `presentResult` 仍投出 `card: 'read'`,因此空窗口分支是可达的 —— 读取卡片并非如早前草稿所假设的对空结果不可达。故 `ReadBlock` 在 `lines` 为空时隐藏复制控件,正如 `TerminalBlock` 对空输出隐藏复制,使按钮绝不会用空字符串清空剪贴板。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**给 `CodeBlock` 加一个可选行号栏和 `startLine`。** 拒绝:这会把读取专属的行号栏、窗口计数提示和高度上限强加给共享 `CodeBlock` 的每个 markdown 围栏和 `run_code` 程序体,对那些调用者毫无好处。真正共享的界面是 shiki 语法单例,两个 block 都通过 `highlight.ts` 复用它;围绕它的外壳各不相同(读取有行号栏和窗口提示,围栏两者都没有),因此第二个小 primitive 是正确的切分 —— 正如 `TerminalBlock` 是基于同一套 token 的第二个 primitive,而不是 `CodeBlock` 的一种模式。
|
||||
|
||||
**复用 `highlightToHtml`,用 CSS counter 注入行号。** 拒绝:shiki 产出的单 `<pre>` HTML 没有可供行号栏挂上文件行号的逐行边界(窗口读取的行号从大于 1 处开始,不是简单的 CSS counter 自增),而从 HTML 里把行号解析回来又很脆弱。`codeToTokens` 直接给出逐行 token 结构。
|
||||
|
||||
**在 boot 预热里 eager 注册所有读取卡片语法。** 拒绝:这会给每次 Web 启动摊上约 1.6 MB 语法模块及其同步初始化,只为一张多数会话从不打开的卡片。lazy 路径的代价是某个语言首次被读取时的一帧纯文本,随后在语法加载的重渲染里高亮;boot 代价只为每个会话本就渲染的三种语法付出。
|
||||
|
||||
## Consequences
|
||||
|
||||
`ui-primitives` 增加 `ReadBlock` 和 `highlightLines`;没有新的运行时依赖(shiki 已因 `CodeBlock` 存在)。`ReadBlock` 只读取读取视图的字段,因此保持为渲染意图所承载内容的纯函数 —— 无会话查询,与产出该视图的 presenter 一样可安全回放。没有读取能力的 UI 仍通过通用卡片拿到后端的 `content` 回退(剥掉外壳的文本),保持不变。
|
||||
@@ -34,11 +40,11 @@ Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、以及它对未知/缺省语言返回 `undefined`;还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本。`ReadBlock.tsx` 与 `highlight.ts` 均保持每文件 100% 覆盖(后者由本 spec 加上覆盖 `highlightToHtml` 的 `code-block.spec.tsx` 共同达成)。
|
||||
`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。
|
||||
|
||||
`packages/client/ui-conversation/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-conversation/src/*`),因此不承受门槛压力。
|
||||
|
||||
fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`;渲染点回退行已由 turn 64 的 `run_code` 样例中的读取子派发覆盖。它排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。
|
||||
fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx` 的 `web_fetch` 用例钉住。turn 66 排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -9,10 +9,15 @@
|
||||
// two cards collapse a long body at the same place. Colors resolve through
|
||||
// --shiki-*/--dsw-* tokens.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import { highlightLines, type HighlightSpan } from './markdown/highlight.ts'
|
||||
import {
|
||||
grammarLoadCount,
|
||||
highlightLines,
|
||||
subscribeGrammarLoaded,
|
||||
type HighlightSpan,
|
||||
} from './markdown/highlight.ts'
|
||||
import css from './ReadBlock.module.css'
|
||||
|
||||
/**
|
||||
@@ -75,9 +80,14 @@ export function ReadBlock({
|
||||
// Highlighting the whole window in one call (not line by line) keeps grammar
|
||||
// context across lines — a multi-line string or comment stays one construct.
|
||||
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||||
// Re-render when a lazy grammar finishes loading, so a read card that showed
|
||||
// plain text while its language's grammar imported picks up highlighting. The
|
||||
// snapshot value is opaque; only its change across renders drives the memo.
|
||||
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
|
||||
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
|
||||
// unknown/absent language, when every line renders as bare text.
|
||||
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang])
|
||||
// unknown/absent (or not-yet-loaded) language, when every line renders as
|
||||
// bare text.
|
||||
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -131,16 +141,15 @@ export function ReadBlock({
|
||||
<span className={css.count}>{`显示 ${lines.length} / ${totalLines} 行`}</span>
|
||||
)}
|
||||
<span className={css.lang}>{lang ?? ''}</span>
|
||||
{/* No empty-window guard around the copy control, unlike TerminalBlock
|
||||
(which hides copy on empty output): a read card is reached only for
|
||||
a settled read whose result view declares `card:'read'`, and the
|
||||
read tool projects that view solely for a parsed envelope with a
|
||||
line window. An empty or non-envelope result falls back to the
|
||||
generic card upstream (readCardModel returns null), so `lines` is
|
||||
never empty here — the branch TerminalBlock needs cannot arise. */}
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
{/* Hide copy on an empty window, matching TerminalBlock's empty-output
|
||||
guard: a successful read of an empty file returns lines: [] with
|
||||
card:'read', so this branch is reachable, and copying then would
|
||||
wipe the clipboard with an empty string. */}
|
||||
{lines.length > 0 && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
// plain fallback for everything else. Chrome (language banner + copy) matches
|
||||
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from '../clipboard.ts'
|
||||
import { highlightToHtml } from './highlight.ts'
|
||||
import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
|
||||
import css from './CodeBlock.module.css'
|
||||
|
||||
export interface CodeBlockProps {
|
||||
@@ -21,7 +21,11 @@ export interface CodeBlockProps {
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
|
||||
// text while its language's grammar imported picks up highlighting. The
|
||||
// snapshot value is opaque; only its change across renders drives the memo.
|
||||
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
* theme package's token sheets as `--shiki-*` custom properties (light and
|
||||
* dark blocks), never here — the repo's tokens-only styling rule.
|
||||
*
|
||||
* Grammars are the set the harness actually renders: the markdown-fence and
|
||||
* `run_code` languages (TypeScript, shell, JSON) plus the file-extension
|
||||
* language hints the read tool's `langFromPath` emits (`packages/fs/tool-fs`),
|
||||
* so a read card highlights the same source, config, and markup extensions the
|
||||
* backend recognizes. An unknown or absent language falls back to plain text
|
||||
* (no highlighting, still monospace) — never an error.
|
||||
* Only the three markdown-fence and `run_code` grammars (TypeScript, shell,
|
||||
* JSON) load into the singleton at boot — the set every session renders. The
|
||||
* read card's wider extension set (the file-extension language hints the read
|
||||
* tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
|
||||
* markup, …) is imported lazily and registered the first time such a language
|
||||
* is requested, so a session that never opens a read card in one of those
|
||||
* languages pays neither the ~1.6 MB of grammar modules nor their synchronous
|
||||
* init. The first render of a lazy language falls back to plain text while its
|
||||
* grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
|
||||
* with highlighting. An unknown or absent language falls back to plain text (no
|
||||
* highlighting, still monospace) — never an error.
|
||||
*/
|
||||
|
||||
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
|
||||
@@ -18,55 +23,66 @@ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
|
||||
import langTs from '@shikijs/langs/typescript'
|
||||
import langBash from '@shikijs/langs/shellscript'
|
||||
import langJson from '@shikijs/langs/json'
|
||||
import langPython from '@shikijs/langs/python'
|
||||
import langRuby from '@shikijs/langs/ruby'
|
||||
import langGo from '@shikijs/langs/go'
|
||||
import langRust from '@shikijs/langs/rust'
|
||||
import langJava from '@shikijs/langs/java'
|
||||
import langC from '@shikijs/langs/c'
|
||||
import langCpp from '@shikijs/langs/cpp'
|
||||
import langCsharp from '@shikijs/langs/csharp'
|
||||
import langKotlin from '@shikijs/langs/kotlin'
|
||||
import langSwift from '@shikijs/langs/swift'
|
||||
import langPhp from '@shikijs/langs/php'
|
||||
import langYaml from '@shikijs/langs/yaml'
|
||||
import langToml from '@shikijs/langs/toml'
|
||||
import langIni from '@shikijs/langs/ini'
|
||||
import langMarkdown from '@shikijs/langs/markdown'
|
||||
import langMdx from '@shikijs/langs/mdx'
|
||||
import langHtml from '@shikijs/langs/html'
|
||||
import langCss from '@shikijs/langs/css'
|
||||
import langScss from '@shikijs/langs/scss'
|
||||
import langLess from '@shikijs/langs/less'
|
||||
import langSql from '@shikijs/langs/sql'
|
||||
import langXml from '@shikijs/langs/xml'
|
||||
import langLua from '@shikijs/langs/lua'
|
||||
import type { HighlighterCore } from 'shiki/core'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
/**
|
||||
* Grammars the singleton registers; each entry's own `name` is the id
|
||||
* `codeToTokens`/`codeToHtml` resolve. The TypeScript grammar embeds JS/JSX/TSX,
|
||||
* so the JS-family fence aliases resolve to it rather than a separate grammar.
|
||||
*/
|
||||
const LANGS = [
|
||||
langTs, langBash, langJson,
|
||||
langPython, langRuby, langGo, langRust, langJava,
|
||||
langC, langCpp, langCsharp, langKotlin, langSwift, langPhp,
|
||||
langYaml, langToml, langIni,
|
||||
langMarkdown, langMdx, langHtml, langCss, langScss, langLess,
|
||||
langSql, langXml, langLua,
|
||||
]
|
||||
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
|
||||
* from a boot grammar so no direct `@shikijs/types` dependency is needed. */
|
||||
type LangModule = { default: typeof langTs }
|
||||
|
||||
/**
|
||||
* Language ids (and aliases) the singleton registers; everything else renders
|
||||
* Grammars the singleton loads at boot; each entry's own `name` is the id
|
||||
* `codeToTokens`/`codeToHtml` resolve. The TypeScript grammar embeds JS/JSX/TSX,
|
||||
* so the JS-family fence aliases resolve to it rather than a separate grammar.
|
||||
* The read card's wider set loads lazily through {@link LAZY_GRAMMARS}.
|
||||
*/
|
||||
const LANGS = [langTs, langBash, langJson]
|
||||
|
||||
/**
|
||||
* The read card's extension grammars, each behind a dynamic import so its
|
||||
* module stays out of the boot chunk until a read of that language renders.
|
||||
* Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
|
||||
* `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
|
||||
* hands the whole array to `loadLanguageSync`, which registers each entry
|
||||
* (including embedded sub-grammars). The three boot grammars are absent —
|
||||
* already loaded, so no alias value ever points at a missing entry here.
|
||||
*/
|
||||
const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
|
||||
['python', () => import('@shikijs/langs/python')],
|
||||
['ruby', () => import('@shikijs/langs/ruby')],
|
||||
['go', () => import('@shikijs/langs/go')],
|
||||
['rust', () => import('@shikijs/langs/rust')],
|
||||
['java', () => import('@shikijs/langs/java')],
|
||||
['c', () => import('@shikijs/langs/c')],
|
||||
['cpp', () => import('@shikijs/langs/cpp')],
|
||||
['csharp', () => import('@shikijs/langs/csharp')],
|
||||
['kotlin', () => import('@shikijs/langs/kotlin')],
|
||||
['swift', () => import('@shikijs/langs/swift')],
|
||||
['php', () => import('@shikijs/langs/php')],
|
||||
['yaml', () => import('@shikijs/langs/yaml')],
|
||||
['toml', () => import('@shikijs/langs/toml')],
|
||||
['ini', () => import('@shikijs/langs/ini')],
|
||||
['markdown', () => import('@shikijs/langs/markdown')],
|
||||
['mdx', () => import('@shikijs/langs/mdx')],
|
||||
['html', () => import('@shikijs/langs/html')],
|
||||
['css', () => import('@shikijs/langs/css')],
|
||||
['scss', () => import('@shikijs/langs/scss')],
|
||||
['less', () => import('@shikijs/langs/less')],
|
||||
['sql', () => import('@shikijs/langs/sql')],
|
||||
['xml', () => import('@shikijs/langs/xml')],
|
||||
['lua', () => import('@shikijs/langs/lua')],
|
||||
])
|
||||
|
||||
/**
|
||||
* Language ids (and aliases) the highlighter accepts; everything else renders
|
||||
* plain. A Map, not an object: fence info strings are assistant-authored, so
|
||||
* a label like `constructor` or `__proto__` must miss instead of resolving an
|
||||
* inherited property and crashing the renderer inside shiki. Keys cover both
|
||||
* the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids
|
||||
* the read tool's `langFromPath` emits, so both callers resolve the same
|
||||
* grammars. The JS family maps to the TypeScript grammar (which embeds it),
|
||||
* unchanged from when this was the only non-shell/JSON grammar.
|
||||
* unchanged from when this was the only non-shell/JSON grammar. A value not in
|
||||
* {@link LANGS} names a {@link LAZY_GRAMMARS} entry loaded on first use.
|
||||
*/
|
||||
const LANG_ALIASES = new Map<string, string>([
|
||||
['typescript', 'typescript'],
|
||||
@@ -132,6 +148,62 @@ function highlighter(): HighlighterCore {
|
||||
return singleton
|
||||
}
|
||||
|
||||
/** Grammar ids whose lazy import is in flight or done, so it is requested once. */
|
||||
const requested = new Set<string>()
|
||||
/** Subscribers re-rendered after a lazy grammar registers (React callers). */
|
||||
const listeners = new Set<() => void>()
|
||||
/** Bumped on each lazy-grammar load; the `useSyncExternalStore` snapshot. */
|
||||
let loadCount = 0
|
||||
|
||||
/**
|
||||
* Subscribe to lazy-grammar load completions; `listener` fires after a
|
||||
* {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a
|
||||
* caller that rendered its plain fallback while the grammar loaded can
|
||||
* re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with
|
||||
* {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function.
|
||||
* @param listener - invoked (no args) on each grammar-load completion.
|
||||
* @returns a disposer that removes the listener.
|
||||
*/
|
||||
export function subscribeGrammarLoaded(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The lazy-grammar load counter — a value that changes on every load, so a
|
||||
* `useSyncExternalStore` snapshot re-renders the subscriber when a grammar
|
||||
* registers. Opaque: only its identity across renders matters.
|
||||
* @returns the current load count.
|
||||
*/
|
||||
export function grammarLoadCount(): number {
|
||||
return loadCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the grammar `resolved` names is registered. A boot grammar (not in
|
||||
* {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready
|
||||
* synchronously; a lazy grammar not yet loaded starts its import (once) and
|
||||
* reports not-ready, so the caller renders plain until a
|
||||
* {@link subscribeGrammarLoaded} listener fires.
|
||||
* @param resolved - the grammar id an alias resolved to.
|
||||
* @returns whether the grammar is registered and ready to tokenize now.
|
||||
*/
|
||||
function ensureGrammar(resolved: string): boolean {
|
||||
const load = LAZY_GRAMMARS.get(resolved)
|
||||
// A boot grammar (already registered) has no lazy loader; it is always ready.
|
||||
if (load === undefined) return true
|
||||
if (highlighter().getLoadedLanguages().includes(resolved)) return true
|
||||
if (!requested.has(resolved)) {
|
||||
requested.add(resolved)
|
||||
void load().then((mod) => {
|
||||
highlighter().loadLanguageSync(mod.default)
|
||||
loadCount += 1
|
||||
for (const listener of listeners) listener()
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Engine + grammar construction costs a long task (~120-175ms); building it
|
||||
// during the first finalized fence's render would jank exactly when a stream
|
||||
// completes. Warm the singleton in a deferred task at module load (= plugin
|
||||
@@ -144,14 +216,17 @@ const warmupTimer = setTimeout(() => { highlighter() }, 0)
|
||||
/**
|
||||
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
|
||||
* when `lang` maps to a registered grammar; `undefined` means the caller
|
||||
* renders its plain fallback.
|
||||
* renders its plain fallback. A lazy grammar not yet loaded returns `undefined`
|
||||
* for this call and loads in the background; subscribe with
|
||||
* {@link onGrammarLoaded} to re-highlight once it registers.
|
||||
* @param code - the source text.
|
||||
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
|
||||
* @returns the highlighted HTML, or `undefined` for unknown languages.
|
||||
* @returns the highlighted HTML, or `undefined` for unknown or not-yet-loaded languages.
|
||||
*/
|
||||
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
|
||||
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
|
||||
if (resolved === undefined) return undefined
|
||||
if (!ensureGrammar(resolved)) return undefined
|
||||
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
|
||||
}
|
||||
|
||||
@@ -179,11 +254,12 @@ export interface HighlightSpan {
|
||||
* is dropped so the run count matches the caller's own line array.
|
||||
* @param code - the source text.
|
||||
* @param lang - the language hint (a file-extension-derived language id).
|
||||
* @returns one entry per source line (each an array of runs), or `undefined` for unknown languages.
|
||||
* @returns one entry per source line (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
|
||||
*/
|
||||
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
|
||||
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
|
||||
if (resolved === undefined) return undefined
|
||||
if (!ensureGrammar(resolved)) return undefined
|
||||
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
|
||||
// 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
|
||||
|
||||
@@ -31,6 +31,24 @@ describe('highlightToHtml', () => {
|
||||
expect(highlightToHtml('x', 'cobol')).toBeUndefined()
|
||||
expect(highlightToHtml('x', undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
// Every read-tool language hint whose grammar loads lazily (the boot set —
|
||||
// ts/js/bash/sh/json — is covered above). Touching each one drives its own
|
||||
// dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
|
||||
const LAZY_ALIASES = [
|
||||
'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
|
||||
'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
|
||||
'xml', 'lua',
|
||||
]
|
||||
|
||||
it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
|
||||
// First touch returns the plain fallback (undefined) and starts the import.
|
||||
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toBeUndefined()
|
||||
// Once every grammar has registered, the same call highlights.
|
||||
await vi.waitFor(() => {
|
||||
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodeBlock', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
|
||||
import { highlightLines } from '../src/markdown/highlight.ts'
|
||||
import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -71,6 +71,25 @@ describe('highlightLines', () => {
|
||||
expect(highlightLines('x', 'cobol')).toBeUndefined()
|
||||
expect(highlightLines('x', undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('loads a lazy grammar on first use: plain first, highlighted after it registers', async () => {
|
||||
// A boot grammar (ts) is ready synchronously; a lazy grammar (python) is
|
||||
// not, so the first call renders plain and imports the grammar, and a
|
||||
// subscriber fires once it registers, after which the same call highlights.
|
||||
let notified = 0
|
||||
const stop = subscribeGrammarLoaded(() => { notified += 1 })
|
||||
// First touch: grammar not loaded yet, so plain fallback while it imports.
|
||||
expect(highlightLines('def f(): pass', 'py')).toBeUndefined()
|
||||
// The import + loadLanguageSync resolve on a microtask; wait for the notify.
|
||||
await vi.waitFor(() => { expect(notified).toBeGreaterThan(0) })
|
||||
expect(grammarLoadCount()).toBeGreaterThan(0)
|
||||
const result = highlightLines('def f(): pass', 'py')
|
||||
expect(result).not.toBeUndefined()
|
||||
// `def` is a python keyword and carries a --shiki-* color once highlighted.
|
||||
const keyword = result!.flat().find(span => span.text === 'def')
|
||||
expect(keyword?.style?.color).toContain('var(--shiki-')
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock rows', () => {
|
||||
@@ -212,4 +231,11 @@ describe('ReadBlock copy', () => {
|
||||
const view = render(<ReadBlock className="x" label="a" lines={lines(1)} totalLines={1} />)
|
||||
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the copy control for an empty window so it cannot wipe the clipboard', () => {
|
||||
// A successful read of an empty file settles to lines: [] with card:'read',
|
||||
// so this branch is reachable; copying then would clear the clipboard.
|
||||
const view = render(<ReadBlock label="empty.ts" lines={[]} totalLines={0} />)
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user