diff --git a/.env.example b/.env.example
index a9216eebb..4f2b30080 100644
--- a/.env.example
+++ b/.env.example
@@ -79,6 +79,23 @@ STORE_SESSION_RESPONSE_BODY=true # 是否在 Redis 中存储会话响应
# - false:不存储响应体(注意:不影响本次请求处理;仅影响后续查看 response body)
# 说明:该开关不影响内部统计读取响应体(tokens/费用统计、SSE 假 200 检测仍会进行)
+# Dashboard CodeDisplay(长内容美化性能)
+# 说明:
+# - 方案1:长内容 Pretty 视图使用纯文本(textarea)展示,避免语法高亮 DOM 爆炸导致卡顿(默认开启)。
+# - 方案3:仅对可视窗口做语法高亮(虚拟化高亮)。默认关闭,需要显式开启;开启后可在 UI 切换“纯文本/虚拟高亮”。
+CCH_CODEDISPLAY_LARGE_PLAIN=true
+CCH_CODEDISPLAY_VIRTUAL_HIGHLIGHT=false
+
+# 可选调参(一般无需修改)
+CCH_CODEDISPLAY_WORKER_ENABLE=true
+CCH_CODEDISPLAY_PERF_DEBUG=false
+CCH_CODEDISPLAY_HIGHLIGHT_MAX_CHARS=30000
+CCH_CODEDISPLAY_VIRTUAL_OVERSCAN_LINES=50
+CCH_CODEDISPLAY_VIRTUAL_CONTEXT_LINES=50
+CCH_CODEDISPLAY_VIRTUAL_LINE_HEIGHT_PX=18
+CCH_CODEDISPLAY_MAX_PRETTY_OUTPUT_BYTES=20000000
+CCH_CODEDISPLAY_MAX_LINE_INDEX_LINES=200000
+
# 熔断器配置
# 功能说明:控制网络错误是否计入熔断器失败计数
# - false (默认):网络错误(DNS 解析失败、连接超时、代理连接失败等)不计入熔断器,仅供应商错误(4xx/5xx HTTP 响应)计入
diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json
index 15195b0c5..414c93802 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -568,7 +568,27 @@
"codeDisplay": {
"raw": "Raw",
"pretty": "Pretty",
+ "prettyWorking": "Formatting... {percent}%",
+ "viewPlain": "Plain",
+ "viewVirtual": "Virtual highlight",
+ "virtualFallbackToPlain": "Virtual highlight unavailable, switched to plain",
"searchPlaceholder": "Search",
+ "searchWorking": "Searching... {percent}%",
+ "search": {
+ "indexFailed": "Failed to build search index",
+ "indexCanceled": "Search index build canceled",
+ "indexTooManyLines": "Too many lines ({lineCount}, limit {maxLines})",
+ "indexTooManyLinesUnknown": "Too many lines (limit {maxLines})",
+ "failed": "Search failed",
+ "canceled": "Search canceled"
+ },
+ "cancel": "Cancel",
+ "retry": "Retry",
+ "prettyCanceled": "Formatting canceled",
+ "prettyFailed": "Formatting failed",
+ "prettyInvalidJson": "Invalid JSON",
+ "prettyOutputTooLarge": "Formatted output too large",
+ "prettyWorkerUnavailable": "Worker unavailable, cannot format very large JSON",
"expand": "Expand",
"collapse": "Collapse",
"themeAuto": "Auto",
@@ -582,6 +602,13 @@
"pageInfo": "Page {page} / {total}",
"sseEvent": "Event",
"sseData": "Data",
+ "virtual": {
+ "indexWorking": "Indexing... {percent}%",
+ "indexFailed": "Failed to build index, switched to plain",
+ "indexCanceled": "Indexing canceled, switched to plain",
+ "indexTooManyLines": "Too many lines ({lineCount}, max {maxLines}), switched to plain",
+ "indexTooManyLinesUnknown": "Too many lines (max {maxLines}), switched to plain"
+ },
"hardLimit": {
"title": "Content too large",
"size": "Size: {sizeMB} MB ({sizeBytes} bytes)",
diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json
index 1555e52af..fd2d8a3ef 100644
--- a/messages/ja/dashboard.json
+++ b/messages/ja/dashboard.json
@@ -568,7 +568,27 @@
"codeDisplay": {
"raw": "Raw",
"pretty": "Pretty",
+ "prettyWorking": "整形中... {percent}%",
+ "viewPlain": "テキスト",
+ "viewVirtual": "仮想ハイライト",
+ "virtualFallbackToPlain": "仮想ハイライトを利用できないため、テキスト表示に切り替えました",
"searchPlaceholder": "検索",
+ "searchWorking": "検索中... {percent}%",
+ "search": {
+ "indexFailed": "検索インデックスの作成に失敗しました",
+ "indexCanceled": "検索インデックスの作成をキャンセルしました",
+ "indexTooManyLines": "行数が多すぎます({lineCount} 行、上限 {maxLines} 行)",
+ "indexTooManyLinesUnknown": "行数が多すぎます(上限 {maxLines} 行)",
+ "failed": "検索に失敗しました",
+ "canceled": "検索をキャンセルしました"
+ },
+ "cancel": "キャンセル",
+ "retry": "再試行",
+ "prettyCanceled": "整形をキャンセルしました",
+ "prettyFailed": "整形に失敗しました",
+ "prettyInvalidJson": "無効な JSON",
+ "prettyOutputTooLarge": "整形結果が大きすぎます",
+ "prettyWorkerUnavailable": "Worker を利用できないため、大きな JSON を整形できません",
"expand": "展開",
"collapse": "折りたたむ",
"themeAuto": "自動",
@@ -582,6 +602,13 @@
"pageInfo": "{page} / {total} ページ",
"sseEvent": "イベント",
"sseData": "データ",
+ "virtual": {
+ "indexWorking": "索引作成中... {percent}%",
+ "indexFailed": "索引の作成に失敗したため、テキスト表示に切り替えました",
+ "indexCanceled": "索引作成をキャンセルしたため、テキスト表示に切り替えました",
+ "indexTooManyLines": "行数が多すぎます ({lineCount} 行、上限 {maxLines} 行)。テキスト表示に切り替えました",
+ "indexTooManyLinesUnknown": "行数が多すぎます (上限 {maxLines} 行)。テキスト表示に切り替えました"
+ },
"hardLimit": {
"title": "コンテンツが大きすぎます",
"size": "サイズ: {sizeMB} MB ({sizeBytes} bytes)",
diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json
index 64a2c8211..25ca15dfb 100644
--- a/messages/ru/dashboard.json
+++ b/messages/ru/dashboard.json
@@ -568,7 +568,27 @@
"codeDisplay": {
"raw": "Сырой",
"pretty": "Форматированный",
+ "prettyWorking": "Форматирование... {percent}%",
+ "viewPlain": "Текст",
+ "viewVirtual": "Виртуальная подсветка",
+ "virtualFallbackToPlain": "Виртуальная подсветка недоступна — переключено на простой текст",
"searchPlaceholder": "Поиск",
+ "searchWorking": "Поиск... {percent}%",
+ "search": {
+ "indexFailed": "Не удалось построить индекс поиска",
+ "indexCanceled": "Построение индекса поиска отменено",
+ "indexTooManyLines": "Слишком много строк ({lineCount}, лимит {maxLines})",
+ "indexTooManyLinesUnknown": "Слишком много строк (лимит {maxLines})",
+ "failed": "Поиск не удался",
+ "canceled": "Поиск отменён"
+ },
+ "cancel": "Отмена",
+ "retry": "Повторить",
+ "prettyCanceled": "Форматирование отменено",
+ "prettyFailed": "Ошибка форматирования",
+ "prettyInvalidJson": "Некорректный JSON",
+ "prettyOutputTooLarge": "Результат форматирования слишком большой",
+ "prettyWorkerUnavailable": "Worker недоступен — невозможно отформатировать большой JSON",
"expand": "Развернуть",
"collapse": "Свернуть",
"themeAuto": "Авто",
@@ -582,6 +602,13 @@
"pageInfo": "Страница {page} / {total}",
"sseEvent": "Событие",
"sseData": "Данные",
+ "virtual": {
+ "indexWorking": "Индексация... {percent}%",
+ "indexFailed": "Не удалось построить индекс — переключено на простой текст",
+ "indexCanceled": "Индексация отменена — переключено на простой текст",
+ "indexTooManyLines": "Слишком много строк ({lineCount}, максимум {maxLines}) — переключено на простой текст",
+ "indexTooManyLinesUnknown": "Слишком много строк (максимум {maxLines}) — переключено на простой текст"
+ },
"hardLimit": {
"title": "Содержимое слишком большое",
"size": "Размер: {sizeMB} MB ({sizeBytes} bytes)",
diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json
index 6743b6857..482b29f21 100644
--- a/messages/zh-CN/dashboard.json
+++ b/messages/zh-CN/dashboard.json
@@ -568,7 +568,27 @@
"codeDisplay": {
"raw": "原始",
"pretty": "美化",
+ "prettyWorking": "美化中... {percent}%",
+ "viewPlain": "纯文本",
+ "viewVirtual": "虚拟高亮",
+ "virtualFallbackToPlain": "虚拟高亮不可用,已回退到纯文本",
"searchPlaceholder": "搜索",
+ "searchWorking": "搜索中... {percent}%",
+ "search": {
+ "indexFailed": "建立搜索索引失败",
+ "indexCanceled": "已取消建立搜索索引",
+ "indexTooManyLines": "内容行数过多({lineCount} 行,上限 {maxLines} 行)",
+ "indexTooManyLinesUnknown": "内容行数过多(上限 {maxLines} 行)",
+ "failed": "搜索失败",
+ "canceled": "已取消搜索"
+ },
+ "cancel": "取消",
+ "retry": "重试",
+ "prettyCanceled": "已取消美化",
+ "prettyFailed": "美化失败",
+ "prettyInvalidJson": "无效的 JSON",
+ "prettyOutputTooLarge": "美化结果过大",
+ "prettyWorkerUnavailable": "当前环境无法使用 Worker,无法美化超大 JSON",
"expand": "展开",
"collapse": "收起",
"themeAuto": "跟随系统",
@@ -582,6 +602,13 @@
"pageInfo": "第 {page} / {total} 页",
"sseEvent": "事件",
"sseData": "数据",
+ "virtual": {
+ "indexWorking": "建立索引中... {percent}%",
+ "indexFailed": "建立索引失败,已回退到纯文本",
+ "indexCanceled": "已取消建立索引,已回退到纯文本",
+ "indexTooManyLines": "内容行数过多({lineCount} 行,上限 {maxLines} 行),已回退到纯文本",
+ "indexTooManyLinesUnknown": "内容行数过多(上限 {maxLines} 行),已回退到纯文本"
+ },
"hardLimit": {
"title": "内容过大",
"size": "大小:{sizeMB} MB({sizeBytes} 字节)",
diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json
index e28e8eb07..5d4e7b7a3 100644
--- a/messages/zh-TW/dashboard.json
+++ b/messages/zh-TW/dashboard.json
@@ -568,7 +568,27 @@
"codeDisplay": {
"raw": "原始內容",
"pretty": "格式化",
+ "prettyWorking": "格式化中... {percent}%",
+ "viewPlain": "純文字",
+ "viewVirtual": "虛擬高亮",
+ "virtualFallbackToPlain": "虛擬高亮不可用,已回退到純文字",
"searchPlaceholder": "搜尋",
+ "searchWorking": "搜尋中... {percent}%",
+ "search": {
+ "indexFailed": "建立搜尋索引失敗",
+ "indexCanceled": "已取消建立搜尋索引",
+ "indexTooManyLines": "內容行數過多({lineCount} 行,上限 {maxLines} 行)",
+ "indexTooManyLinesUnknown": "內容行數過多(上限 {maxLines} 行)",
+ "failed": "搜尋失敗",
+ "canceled": "已取消搜尋"
+ },
+ "cancel": "取消",
+ "retry": "重試",
+ "prettyCanceled": "已取消美化",
+ "prettyFailed": "美化失敗",
+ "prettyInvalidJson": "無效的 JSON",
+ "prettyOutputTooLarge": "美化結果過大",
+ "prettyWorkerUnavailable": "目前環境無法使用 Worker,無法美化超大 JSON",
"expand": "展開",
"collapse": "摺疊",
"themeAuto": "跟隨系統",
@@ -582,6 +602,13 @@
"pageInfo": "第 {page} / {total} 頁",
"sseEvent": "事件類型",
"sseData": "資料",
+ "virtual": {
+ "indexWorking": "建立索引中... {percent}%",
+ "indexFailed": "建立索引失敗,已回退到純文字",
+ "indexCanceled": "已取消建立索引,已回退到純文字",
+ "indexTooManyLines": "內容行數過多({lineCount} 行,上限 {maxLines} 行),已回退到純文字",
+ "indexTooManyLinesUnknown": "內容行數過多(上限 {maxLines} 行),已回退到純文字"
+ },
"hardLimit": {
"title": "內容過大",
"size": "大小:{sizeMB} MB({sizeBytes} 位元組)",
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index a7c6c18d5..b521e0e97 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { Footer } from "@/components/customs/footer";
+import { parseCodeDisplayConfigFromEnv } from "@/components/ui/code-display-config";
import { Toaster } from "@/components/ui/sonner";
import { type Locale, locales } from "@/i18n/config";
import { logger } from "@/lib/logger";
@@ -72,6 +73,7 @@ export default async function RootLayout({
// Load translation messages
const messages = await getMessages();
const timeZone = await resolveSystemTimezone();
+ const codeDisplayConfig = parseCodeDisplayConfigFromEnv(process.env);
// Create a stable `now` timestamp to avoid SSR/CSR hydration mismatch for relative time
const now = new Date();
@@ -79,7 +81,7 @@ export default async function RootLayout({
-
+
{children}
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index 7efd3a118..a9576a9cd 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -4,9 +4,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Agentation } from "agentation";
import { ThemeProvider } from "next-themes";
import { type ReactNode, useState } from "react";
+import type { CodeDisplayConfig } from "@/components/ui/code-display-config";
+import { CodeDisplayConfigProvider } from "@/components/ui/code-display-config-context";
interface AppProvidersProps {
children: ReactNode;
+ codeDisplayConfig?: CodeDisplayConfig;
}
export const QUERY_CLIENT_DEFAULTS = {
@@ -18,7 +21,7 @@ export const QUERY_CLIENT_DEFAULTS = {
},
};
-export function AppProviders({ children }: AppProvidersProps) {
+export function AppProviders({ children, codeDisplayConfig }: AppProvidersProps) {
const [queryClient] = useState(
() =>
new QueryClient({
@@ -28,17 +31,19 @@ export function AppProviders({ children }: AppProvidersProps) {
return (
-
- {children}
- {process.env.NODE_ENV === "development" && }
-
+
+
+ {children}
+ {process.env.NODE_ENV === "development" && }
+
+
);
}
diff --git a/src/components/ui/__tests__/code-display.test.tsx b/src/components/ui/__tests__/code-display.test.tsx
index 836bf1793..091fc8c49 100644
--- a/src/components/ui/__tests__/code-display.test.tsx
+++ b/src/components/ui/__tests__/code-display.test.tsx
@@ -20,16 +20,21 @@ function renderWithIntl(node: ReactNode) {
document.body.appendChild(container);
const root = createRoot(container);
- act(() => {
- root.render(
-
- {node}
-
- );
- });
+ const render = (next: ReactNode) => {
+ act(() => {
+ root.render(
+
+ {next}
+
+ );
+ });
+ };
+
+ render(node);
return {
container,
+ rerender: render,
unmount: () => {
act(() => root.unmount());
container.remove();
@@ -64,6 +69,27 @@ describe("CodeDisplay", () => {
unmount();
});
+ test("updates default mode when language changes", async () => {
+ const raw = '{"a":1,"b":2}';
+ const { container, rerender, unmount } = renderWithIntl(
+
+ );
+
+ // text 默认 raw
+ expect(container.textContent).toContain(raw);
+ expect(container.textContent).not.toContain('"a": 1');
+
+ rerender(
);
+
+ // 等待 useEffect 同步默认模式
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+
+ expect(container.textContent).toContain('"a": 1');
+ unmount();
+ });
+
test("raw mode renders HTML-like content as text (no script/img elements)", () => {
const malicious = `

Hello`;
const { container, unmount } = renderWithIntl(
diff --git a/src/components/ui/code-display-config-context.tsx b/src/components/ui/code-display-config-context.tsx
new file mode 100644
index 000000000..a3386fc6e
--- /dev/null
+++ b/src/components/ui/code-display-config-context.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import {
+ type CodeDisplayConfig,
+ DEFAULT_CODE_DISPLAY_CONFIG,
+} from "@/components/ui/code-display-config";
+
+const CodeDisplayConfigContext = createContext
(DEFAULT_CODE_DISPLAY_CONFIG);
+
+export function CodeDisplayConfigProvider({
+ value,
+ children,
+}: {
+ value?: CodeDisplayConfig;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCodeDisplayConfig(): CodeDisplayConfig {
+ return useContext(CodeDisplayConfigContext);
+}
diff --git a/src/components/ui/code-display-config.ts b/src/components/ui/code-display-config.ts
new file mode 100644
index 000000000..6f4472bd3
--- /dev/null
+++ b/src/components/ui/code-display-config.ts
@@ -0,0 +1,160 @@
+export interface CodeDisplayConfig {
+ /**
+ * 方案1:长内容 Pretty 视图使用纯文本(不做全量语法高亮)。
+ *
+ * 默认开启(未设置 env 时也视为 true)。
+ */
+ largePlainEnabled: boolean;
+
+ /**
+ * 方案3:仅对可视窗口做语法高亮(虚拟化高亮)。
+ *
+ * 默认关闭,需要显式开启。
+ */
+ virtualHighlightEnabled: boolean;
+
+ /**
+ * 是否启用 Web Worker(用于格式化/行索引/搜索等重计算)。
+ *
+ * 默认开启;测试环境可在实现里回落到主线程。
+ */
+ workerEnabled: boolean;
+
+ /**
+ * 性能调试开关:记录格式化/索引/高亮窗口更新等耗时。
+ */
+ perfDebugEnabled: boolean;
+
+ /**
+ * 超过该字符数,禁止使用全量 SyntaxHighlighter 渲染(避免 DOM 爆炸)。
+ */
+ highlightMaxChars: number;
+
+ /**
+ * 虚拟化高亮上下预渲染缓冲行数。
+ */
+ virtualOverscanLines: number;
+
+ /**
+ * 虚拟化高亮固定行高(像素),需配合 CSS 强制 line-height。
+ */
+ virtualLineHeightPx: number;
+
+ /**
+ * 虚拟化高亮的上下文预热行数,用于降低切片高亮的状态丢失风险。
+ */
+ virtualContextLines: number;
+
+ /**
+ * Pretty 输出的最大字节数(估算:按 UTF-16 字符 * 2)。
+ * 超过则不生成 pretty(提示下载或回退 raw),避免内存峰值。
+ */
+ maxPrettyOutputBytes: number;
+
+ /**
+ * 允许构建行索引(lineStarts)的最大行数,超过则禁用虚拟化高亮。
+ */
+ maxLineIndexLines: number;
+}
+
+/**
+ * Worker 不可用或被禁用时,为避免主线程 JSON.parse/stringify 造成长任务卡顿,
+ * 对同步 JSON pretty 的输入大小做一个硬上限。
+ */
+export const MAX_SYNC_JSON_CHARS = 200_000;
+
+export const DEFAULT_CODE_DISPLAY_CONFIG: CodeDisplayConfig = {
+ largePlainEnabled: true,
+ virtualHighlightEnabled: false,
+ workerEnabled: true,
+ perfDebugEnabled: false,
+ highlightMaxChars: 30_000,
+ virtualOverscanLines: 50,
+ virtualLineHeightPx: 18,
+ virtualContextLines: 50,
+ maxPrettyOutputBytes: 20_000_000,
+ maxLineIndexLines: 200_000,
+};
+
+function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean {
+ const normalized = value?.trim().toLowerCase();
+ if (!normalized) return fallback;
+ if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
+ return true;
+ }
+ if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
+ return false;
+ }
+ return fallback;
+}
+
+function parseIntEnv(
+ value: string | undefined,
+ fallback: number,
+ opts?: { min?: number; max?: number }
+): number {
+ const parsed = Number.parseInt(value?.trim() || "", 10);
+ const min = opts?.min ?? Number.NEGATIVE_INFINITY;
+ const max = opts?.max ?? Number.POSITIVE_INFINITY;
+
+ if (!Number.isFinite(parsed)) return fallback;
+ return Math.min(max, Math.max(min, parsed));
+}
+
+/**
+ * 从 env(建议在服务端读取)解析出 CodeDisplayConfig。
+ *
+ * 注意:此函数不直接依赖 process.env,调用方可传入任意 env 字典,避免在客户端误用。
+ */
+export function parseCodeDisplayConfigFromEnv(
+ env: Record
+): CodeDisplayConfig {
+ return {
+ largePlainEnabled: parseBooleanEnv(
+ env.CCH_CODEDISPLAY_LARGE_PLAIN,
+ DEFAULT_CODE_DISPLAY_CONFIG.largePlainEnabled
+ ),
+ virtualHighlightEnabled: parseBooleanEnv(
+ env.CCH_CODEDISPLAY_VIRTUAL_HIGHLIGHT,
+ DEFAULT_CODE_DISPLAY_CONFIG.virtualHighlightEnabled
+ ),
+ workerEnabled: parseBooleanEnv(
+ env.CCH_CODEDISPLAY_WORKER_ENABLE,
+ DEFAULT_CODE_DISPLAY_CONFIG.workerEnabled
+ ),
+ perfDebugEnabled: parseBooleanEnv(
+ env.CCH_CODEDISPLAY_PERF_DEBUG,
+ DEFAULT_CODE_DISPLAY_CONFIG.perfDebugEnabled
+ ),
+ highlightMaxChars: parseIntEnv(
+ env.CCH_CODEDISPLAY_HIGHLIGHT_MAX_CHARS,
+ DEFAULT_CODE_DISPLAY_CONFIG.highlightMaxChars,
+ { min: 1000, max: 5_000_000 }
+ ),
+ virtualOverscanLines: parseIntEnv(
+ env.CCH_CODEDISPLAY_VIRTUAL_OVERSCAN_LINES,
+ DEFAULT_CODE_DISPLAY_CONFIG.virtualOverscanLines,
+ { min: 0, max: 5000 }
+ ),
+ virtualLineHeightPx: parseIntEnv(
+ env.CCH_CODEDISPLAY_VIRTUAL_LINE_HEIGHT_PX,
+ DEFAULT_CODE_DISPLAY_CONFIG.virtualLineHeightPx,
+ { min: 10, max: 64 }
+ ),
+ virtualContextLines: parseIntEnv(
+ env.CCH_CODEDISPLAY_VIRTUAL_CONTEXT_LINES,
+ DEFAULT_CODE_DISPLAY_CONFIG.virtualContextLines,
+ { min: 0, max: 5000 }
+ ),
+ maxPrettyOutputBytes: parseIntEnv(
+ env.CCH_CODEDISPLAY_MAX_PRETTY_OUTPUT_BYTES,
+ DEFAULT_CODE_DISPLAY_CONFIG.maxPrettyOutputBytes,
+ { min: 1_000_000, max: 200_000_000 }
+ ),
+ maxLineIndexLines: parseIntEnv(
+ env.CCH_CODEDISPLAY_MAX_LINE_INDEX_LINES,
+ DEFAULT_CODE_DISPLAY_CONFIG.maxLineIndexLines,
+ { min: 10_000, max: 2_000_000 }
+ ),
+ };
+}
diff --git a/src/components/ui/code-display-matches-list.tsx b/src/components/ui/code-display-matches-list.tsx
new file mode 100644
index 000000000..6b7071a11
--- /dev/null
+++ b/src/components/ui/code-display-matches-list.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { cn } from "@/lib/utils";
+
+function getLineRange(
+ text: string,
+ lineStarts: Int32Array,
+ lineNo: number
+): [start: number, end: number] {
+ const lineCount = lineStarts.length;
+ if (lineNo < 0) return [0, 0];
+ if (lineNo >= lineCount) return [text.length, text.length];
+
+ const start = lineStarts[lineNo] ?? 0;
+ const nextStart = lineNo + 1 < lineCount ? (lineStarts[lineNo + 1] ?? text.length) : text.length;
+
+ // slice(start, end) 的 end 是排他上界:
+ // - 对非最后一行:nextStart 指向下一行行首(通常是 '\n' 后的索引)
+ // - 对最后一行:nextStart === text.length
+ let end = nextStart;
+ if (nextStart > start) {
+ const last = text.charCodeAt(nextStart - 1);
+ if (last === 10) {
+ end = nextStart - 1; // 去掉 '\n'
+ if (end > start && text.charCodeAt(end - 1) === 13) {
+ end -= 1; // 兼容 CRLF:去掉 '\r'
+ }
+ } else if (last === 13) {
+ end = nextStart - 1; // 兼容 CR:去掉 '\r'
+ }
+ }
+
+ end = Math.max(start, end);
+ return [start, end];
+}
+
+export function CodeDisplayMatchesList({
+ text,
+ matches,
+ lineStarts,
+ maxHeight,
+ lineHeightPx,
+ overscan = 20,
+ className,
+}: {
+ text: string;
+ matches: Int32Array;
+ lineStarts: Int32Array;
+ maxHeight?: string;
+ lineHeightPx: number;
+ overscan?: number;
+ className?: string;
+}) {
+ const containerRef = useRef(null);
+ const rafRef = useRef(null);
+ const scrollTopRef = useRef(0);
+
+ const [viewportHeight, setViewportHeight] = useState(0);
+ const [scrollTop, setScrollTop] = useState(0);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const update = () => setViewportHeight(el.clientHeight);
+ update();
+
+ let ro: ResizeObserver | null = null;
+ if (typeof ResizeObserver !== "undefined") {
+ ro = new ResizeObserver(update);
+ ro.observe(el);
+ }
+
+ window.addEventListener("resize", update);
+ return () => {
+ ro?.disconnect();
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+
+ useEffect(
+ () => () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ },
+ []
+ );
+
+ const totalRows = matches.length;
+ const { startIndex, endIndex, topPad, bottomPad } = useMemo(() => {
+ if (viewportHeight <= 0) {
+ return { startIndex: 0, endIndex: Math.min(totalRows, 50), topPad: 0, bottomPad: 0 };
+ }
+
+ const start = Math.max(0, Math.floor(scrollTop / lineHeightPx) - overscan);
+ const end = Math.min(
+ totalRows,
+ Math.ceil((scrollTop + viewportHeight) / lineHeightPx) + overscan
+ );
+ const top = start * lineHeightPx;
+ const bottom = (totalRows - end) * lineHeightPx;
+ return { startIndex: start, endIndex: end, topPad: top, bottomPad: bottom };
+ }, [lineHeightPx, overscan, scrollTop, totalRows, viewportHeight]);
+
+ return (
+ {
+ const next = e.currentTarget.scrollTop;
+ scrollTopRef.current = next;
+ if (rafRef.current !== null) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ setScrollTop(scrollTopRef.current);
+ });
+ }}
+ >
+
+
+ {Array.from({ length: Math.max(0, endIndex - startIndex) }, (_, localIdx) => {
+ const idx = startIndex + localIdx;
+ const lineNo = matches[idx] ?? 0;
+ const [start, end] = getLineRange(text, lineStarts, lineNo);
+ const lineText = text.slice(start, end);
+
+ return (
+
+ {lineNo + 1}
+ {lineText}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/ui/code-display-plain-textarea.tsx b/src/components/ui/code-display-plain-textarea.tsx
new file mode 100644
index 000000000..e7b0f32ac
--- /dev/null
+++ b/src/components/ui/code-display-plain-textarea.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { cn } from "@/lib/utils";
+
+export function CodeDisplayPlainTextarea({
+ value,
+ className,
+ maxHeight,
+ lineHeightPx = 18,
+}: {
+ value: string;
+ className?: string;
+ maxHeight?: string;
+ lineHeightPx?: number;
+}) {
+ const ref = useRef(null);
+
+ // 对超长文本,避免频繁 React 受控更新导致卡顿:
+ // - defaultValue 用于首屏/首次渲染
+ // - 后续变更通过 effect 直接写入 DOM
+ useEffect(() => {
+ if (!ref.current) return;
+ if (ref.current.value === value) return;
+ ref.current.value = value;
+ }, [value]);
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/code-display-virtual-highlighter.tsx b/src/components/ui/code-display-virtual-highlighter.tsx
new file mode 100644
index 000000000..182e9a856
--- /dev/null
+++ b/src/components/ui/code-display-virtual-highlighter.tsx
@@ -0,0 +1,304 @@
+"use client";
+
+import { Loader2, X } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
+import { Button } from "@/components/ui/button";
+import {
+ type BuildLineIndexErrorCode,
+ buildLineIndex,
+} from "@/components/ui/code-display-worker-client";
+import { cn, getTextKey } from "@/lib/utils";
+
+type RangeState = {
+ startLine: number;
+ endLine: number;
+ renderStartLine: number;
+ renderEndLine: number;
+ contextOffsetPx: number;
+ topPadPx: number;
+ bottomPadPx: number;
+ windowText: string;
+};
+
+export function CodeDisplayVirtualHighlighter({
+ text,
+ language,
+ maxHeight,
+ resolvedTheme,
+ lineHeightPx,
+ overscanLines,
+ contextLines,
+ maxLines,
+ workerEnabled,
+ perfDebugEnabled,
+ className,
+ onRequestPlainView,
+}: {
+ text: string;
+ language: "json" | "text";
+ maxHeight?: string;
+ resolvedTheme: "light" | "dark";
+ lineHeightPx: number;
+ overscanLines: number;
+ contextLines: number;
+ maxLines: number;
+ workerEnabled: boolean;
+ perfDebugEnabled: boolean;
+ className?: string;
+ onRequestPlainView?: (reason?: BuildLineIndexErrorCode, lineCount?: number) => void;
+}) {
+ const t = useTranslations("dashboard.sessions");
+
+ const highlighterStyle = resolvedTheme === "dark" ? oneDark : oneLight;
+ const textRef = useRef(text);
+ textRef.current = text;
+
+ const textKey = useMemo(() => getTextKey(text), [text]);
+ const onRequestPlainViewRef = useRef(onRequestPlainView);
+ onRequestPlainViewRef.current = onRequestPlainView;
+
+ const [indexStatus, setIndexStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
+ const [indexProgress, setIndexProgress] = useState<{ processed: number; total: number } | null>(
+ null
+ );
+ const [lineStarts, setLineStarts] = useState(null);
+ const [lineCount, setLineCount] = useState(0);
+
+ const indexAbortRef = useRef(null);
+ const indexJobRef = useRef(0);
+
+ useEffect(() => {
+ const currentText = textRef.current;
+ const currentTextKey = textKey;
+ const jobId = (indexJobRef.current += 1);
+
+ indexAbortRef.current?.abort();
+ const controller = new AbortController();
+ indexAbortRef.current = controller;
+
+ setIndexStatus("loading");
+ setIndexProgress({ processed: 0, total: currentText.length });
+ setLineStarts(null);
+ setLineCount(0);
+
+ const start = performance.now();
+ void buildLineIndex({
+ text: currentText,
+ maxLines,
+ onProgress: (p) => {
+ if (controller.signal.aborted) return;
+ if (p.stage !== "index") return;
+ setIndexProgress({ processed: p.processed, total: p.total });
+ },
+ signal: controller.signal,
+ workerEnabled,
+ }).then((res) => {
+ if (controller.signal.aborted) return;
+ if (jobId !== indexJobRef.current) return;
+
+ const costMs = Math.round(performance.now() - start);
+ if (perfDebugEnabled) {
+ console.debug("CodeDisplay buildLineIndex", {
+ costMs,
+ textKey: currentTextKey,
+ inputChars: currentText.length,
+ ok: res.ok,
+ errorCode: res.ok ? undefined : res.errorCode,
+ lineCount: res.lineCount,
+ });
+ }
+
+ if (!res.ok) {
+ setIndexStatus("error");
+ setIndexProgress(null);
+ onRequestPlainViewRef.current?.(res.errorCode, res.lineCount);
+ return;
+ }
+
+ setLineStarts(res.lineStarts);
+ setLineCount(res.lineCount);
+ setIndexStatus("ready");
+ setIndexProgress(null);
+ });
+
+ return () => controller.abort();
+ }, [maxLines, perfDebugEnabled, textKey, workerEnabled]);
+
+ const containerRef = useRef(null);
+ const rafRef = useRef(null);
+ const scrollTopRef = useRef(0);
+ const rangeRef = useRef(null);
+
+ const [viewportHeight, setViewportHeight] = useState(0);
+ const [range, setRange] = useState(null);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const update = () => setViewportHeight(el.clientHeight);
+ update();
+
+ let ro: ResizeObserver | null = null;
+ if (typeof ResizeObserver !== "undefined") {
+ ro = new ResizeObserver(update);
+ ro.observe(el);
+ }
+
+ window.addEventListener("resize", update);
+ return () => {
+ ro?.disconnect();
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+
+ useEffect(
+ () => () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ },
+ []
+ );
+
+ const updateThresholdLines = Math.max(1, Math.floor(overscanLines / 2));
+
+ const updateWindow = useCallback(
+ (force: boolean) => {
+ const starts = lineStarts;
+ if (!starts) return;
+ const lc = lineCount;
+ if (lc <= 0) return;
+
+ const currentText = textRef.current;
+ const scrollTop = scrollTopRef.current;
+ const height = viewportHeight;
+
+ const visibleStart = Math.floor(scrollTop / lineHeightPx);
+ const visibleEnd = Math.ceil((scrollTop + height) / lineHeightPx);
+
+ const startLine = Math.max(0, visibleStart - overscanLines);
+ const endLine = Math.min(lc, visibleEnd + overscanLines);
+ const renderStartLine = Math.max(0, startLine - contextLines);
+ const renderEndLine = endLine;
+
+ const prev = rangeRef.current;
+ if (!force && prev) {
+ if (
+ Math.abs(startLine - prev.startLine) < updateThresholdLines &&
+ Math.abs(endLine - prev.endLine) < updateThresholdLines
+ ) {
+ return;
+ }
+ }
+
+ const startOffset = starts[renderStartLine] ?? 0;
+ const endOffset =
+ renderEndLine < lc ? (starts[renderEndLine] ?? currentText.length) : currentText.length;
+
+ const contextOffsetPx = (startLine - renderStartLine) * lineHeightPx;
+ const topPadPx = startLine * lineHeightPx;
+ const bottomPadPx = (lc - endLine) * lineHeightPx;
+
+ const next: RangeState = {
+ startLine,
+ endLine,
+ renderStartLine,
+ renderEndLine,
+ contextOffsetPx,
+ topPadPx,
+ bottomPadPx,
+ windowText: currentText.slice(startOffset, endOffset),
+ };
+
+ rangeRef.current = next;
+ setRange(next);
+ },
+ [
+ contextLines,
+ lineCount,
+ lineHeightPx,
+ lineStarts,
+ overscanLines,
+ updateThresholdLines,
+ viewportHeight,
+ ]
+ );
+
+ useEffect(() => {
+ if (indexStatus !== "ready") return;
+ updateWindow(true);
+ }, [indexStatus, updateWindow]);
+
+ if (indexStatus === "loading") {
+ const percent =
+ indexProgress && indexProgress.total > 0
+ ? Math.floor((indexProgress.processed / indexProgress.total) * 100)
+ : 0;
+
+ return (
+
+
+
+
+ {t("codeDisplay.virtual.indexWorking", { percent })}
+
+
+
+
+ );
+ }
+
+ if (indexStatus !== "ready" || !lineStarts || !range) {
+ return null;
+ }
+
+ return (
+ {
+ scrollTopRef.current = e.currentTarget.scrollTop;
+ if (rafRef.current !== null) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ updateWindow(false);
+ });
+ }}
+ >
+
+
+
+ {range.windowText}
+
+
+
+
+ );
+}
diff --git a/src/components/ui/code-display-worker-client.ts b/src/components/ui/code-display-worker-client.ts
new file mode 100644
index 000000000..afd1adf12
--- /dev/null
+++ b/src/components/ui/code-display-worker-client.ts
@@ -0,0 +1,613 @@
+"use client";
+
+import { MAX_SYNC_JSON_CHARS } from "@/components/ui/code-display-config";
+
+export type FormatJsonPrettyErrorCode =
+ | "INVALID_JSON"
+ | "CANCELED"
+ | "OUTPUT_TOO_LARGE"
+ | "WORKER_UNAVAILABLE"
+ | "UNKNOWN";
+
+export type FormatJsonPrettyResult =
+ | { ok: true; text: string; usedStreaming: boolean }
+ | { ok: false; errorCode: FormatJsonPrettyErrorCode };
+
+export type StringifyJsonPrettyErrorCode = "CANCELED" | "OUTPUT_TOO_LARGE" | "UNKNOWN";
+
+export type StringifyJsonPrettyResult =
+ | { ok: true; text: string }
+ | { ok: false; errorCode: StringifyJsonPrettyErrorCode };
+
+export type BuildLineIndexErrorCode = "CANCELED" | "TOO_MANY_LINES" | "UNKNOWN";
+
+export type BuildLineIndexResult =
+ | { ok: true; lineStarts: Int32Array; lineCount: number }
+ | { ok: false; errorCode: BuildLineIndexErrorCode; lineCount?: number };
+
+export type SearchLinesErrorCode = "CANCELED" | "UNKNOWN";
+
+export type SearchLinesResult =
+ | { ok: true; matches: Int32Array }
+ | { ok: false; errorCode: SearchLinesErrorCode };
+
+type WorkerProgress = {
+ stage: "format" | "index" | "search";
+ processed: number;
+ total: number;
+};
+
+type WorkerResponse =
+ | {
+ type: "progress";
+ jobId: number;
+ stage: "format" | "index" | "search";
+ processed: number;
+ total: number;
+ }
+ | {
+ type: "formatJsonPrettyResult";
+ jobId: number;
+ ok: true;
+ text: string;
+ usedStreaming: boolean;
+ }
+ | {
+ type: "formatJsonPrettyResult";
+ jobId: number;
+ ok: false;
+ errorCode: "INVALID_JSON" | "CANCELED" | "OUTPUT_TOO_LARGE" | "UNKNOWN";
+ }
+ | {
+ type: "stringifyJsonPrettyResult";
+ jobId: number;
+ ok: true;
+ text: string;
+ }
+ | {
+ type: "stringifyJsonPrettyResult";
+ jobId: number;
+ ok: false;
+ errorCode: "CANCELED" | "OUTPUT_TOO_LARGE" | "UNKNOWN";
+ }
+ | {
+ type: "buildLineIndexResult";
+ jobId: number;
+ ok: true;
+ lineStarts: Int32Array;
+ lineCount: number;
+ }
+ | {
+ type: "buildLineIndexResult";
+ jobId: number;
+ ok: false;
+ errorCode: "CANCELED" | "TOO_MANY_LINES" | "UNKNOWN";
+ lineCount?: number;
+ }
+ | {
+ type: "searchLinesResult";
+ jobId: number;
+ ok: true;
+ matches: Int32Array;
+ }
+ | {
+ type: "searchLinesResult";
+ jobId: number;
+ ok: false;
+ errorCode: "CANCELED" | "UNKNOWN";
+ };
+
+type PendingJob =
+ | {
+ kind: "formatJsonPretty";
+ resolve: (v: FormatJsonPrettyResult) => void;
+ onProgress?: (p: WorkerProgress) => void;
+ }
+ | {
+ kind: "stringifyJsonPretty";
+ resolve: (v: StringifyJsonPrettyResult) => void;
+ }
+ | {
+ kind: "buildLineIndex";
+ resolve: (v: BuildLineIndexResult) => void;
+ onProgress?: (p: WorkerProgress) => void;
+ }
+ | {
+ kind: "searchLines";
+ resolve: (v: SearchLinesResult) => void;
+ onProgress?: (p: WorkerProgress) => void;
+ };
+
+let workerSingleton: Worker | null = null;
+let initialized = false;
+let nextJobId = 1;
+const pending = new Map();
+
+type FormatJsonPrettyResolve = Extract["resolve"];
+type StringifyJsonPrettyResolve = Extract["resolve"];
+type BuildLineIndexResolve = Extract["resolve"];
+type SearchLinesResolve = Extract["resolve"];
+
+const YIELD_MIN_INTERVAL_MS = 16;
+const PROGRESS_MIN_INTERVAL_MS = 200;
+
+function nowMs(): number {
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
+}
+
+async function yieldToEventLoop() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+function supportsWorker(): boolean {
+ return typeof Worker !== "undefined";
+}
+
+function getWorker(): Worker | null {
+ if (!supportsWorker()) return null;
+ if (workerSingleton) return workerSingleton;
+
+ try {
+ // Next.js 支持用 URL + module worker 方式打包
+ workerSingleton = new Worker(new URL("./code-display.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ return workerSingleton;
+ } catch {
+ // Worker 构造可能因 CSP / 打包资源异常等原因同步抛错:回落主线程实现
+ workerSingleton = null;
+ return null;
+ }
+}
+
+function ensureInitialized() {
+ if (initialized) return;
+
+ const w = getWorker();
+ if (!w) return;
+
+ w.onmessage = (e: MessageEvent) => {
+ const msg = e.data;
+ if (msg.type === "progress") {
+ const job = pending.get(msg.jobId);
+ if (!job) return;
+ if ("onProgress" in job) {
+ job.onProgress?.({
+ stage: msg.stage,
+ processed: msg.processed,
+ total: msg.total,
+ });
+ }
+ return;
+ }
+
+ const job = pending.get(msg.jobId);
+ if (!job) return;
+ pending.delete(msg.jobId);
+
+ switch (msg.type) {
+ case "formatJsonPrettyResult":
+ if (job.kind !== "formatJsonPretty") return;
+ job.resolve(
+ msg.ok
+ ? { ok: true, text: msg.text, usedStreaming: msg.usedStreaming }
+ : { ok: false, errorCode: msg.errorCode }
+ );
+ break;
+ case "stringifyJsonPrettyResult":
+ if (job.kind !== "stringifyJsonPretty") return;
+ job.resolve(
+ msg.ok ? { ok: true, text: msg.text } : { ok: false, errorCode: msg.errorCode }
+ );
+ break;
+ case "buildLineIndexResult":
+ if (job.kind !== "buildLineIndex") return;
+ job.resolve(
+ msg.ok
+ ? { ok: true, lineStarts: msg.lineStarts, lineCount: msg.lineCount }
+ : { ok: false, errorCode: msg.errorCode, lineCount: msg.lineCount }
+ );
+ break;
+ case "searchLinesResult":
+ if (job.kind !== "searchLines") return;
+ job.resolve(
+ msg.ok ? { ok: true, matches: msg.matches } : { ok: false, errorCode: msg.errorCode }
+ );
+ break;
+ }
+ };
+
+ w.onerror = () => {
+ // Worker 崩溃/加载失败:清空 pending,后续自动回落到主线程
+ for (const [jobId, job] of pending.entries()) {
+ job.resolve({ ok: false, errorCode: "UNKNOWN" });
+ pending.delete(jobId);
+ }
+ workerSingleton?.terminate();
+ workerSingleton = null;
+ initialized = false;
+ };
+
+ initialized = true;
+}
+
+function genJobId(): number {
+ // 预留 0 作为无效值
+ const id = nextJobId;
+ nextJobId += 1;
+ return id;
+}
+
+export function cancelWorkerJob(jobId: number) {
+ const job = pending.get(jobId);
+ if (!job) return;
+ pending.delete(jobId);
+
+ switch (job.kind) {
+ case "formatJsonPretty":
+ job.resolve({ ok: false, errorCode: "CANCELED" });
+ break;
+ case "stringifyJsonPretty":
+ job.resolve({ ok: false, errorCode: "CANCELED" });
+ break;
+ case "buildLineIndex":
+ job.resolve({ ok: false, errorCode: "CANCELED" });
+ break;
+ case "searchLines":
+ job.resolve({ ok: false, errorCode: "CANCELED" });
+ break;
+ }
+
+ const w = getWorker();
+ if (!w) return;
+ try {
+ w.postMessage({ type: "cancel", jobId });
+ } catch {
+ // best-effort:Worker 可能已被终止
+ }
+}
+
+export async function formatJsonPretty({
+ text,
+ indentSize,
+ maxOutputBytes,
+ onProgress,
+ signal,
+}: {
+ text: string;
+ indentSize: number;
+ maxOutputBytes: number;
+ onProgress?: (p: WorkerProgress) => void;
+ signal?: AbortSignal;
+}): Promise {
+ ensureInitialized();
+ const w = getWorker();
+ if (!w) {
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+ // Worker 不可用时,对超大 JSON 做同步 parse/stringify 会导致主线程卡顿:
+ // 直接返回错误,让上层回退到“纯文本展示/下载”。
+ if (text.length > MAX_SYNC_JSON_CHARS) return { ok: false, errorCode: "WORKER_UNAVAILABLE" };
+ // fallback(测试环境/不支持 Worker):小内容可直接 parse/stringify
+ try {
+ const parsed = JSON.parse(text) as unknown;
+ const pretty = JSON.stringify(parsed, null, indentSize);
+ if (pretty.length * 2 > maxOutputBytes) return { ok: false, errorCode: "OUTPUT_TOO_LARGE" };
+ return { ok: true, text: pretty, usedStreaming: false };
+ } catch {
+ return { ok: false, errorCode: "INVALID_JSON" };
+ }
+ }
+
+ const jobId = genJobId();
+ return new Promise((resolve) => {
+ if (signal?.aborted) {
+ resolve({ ok: false, errorCode: "CANCELED" });
+ return;
+ }
+
+ const onAbort = () => cancelWorkerJob(jobId);
+ signal?.addEventListener("abort", onAbort);
+
+ const wrappedResolve: FormatJsonPrettyResolve = (v) => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve(v);
+ };
+
+ pending.set(jobId, { kind: "formatJsonPretty", resolve: wrappedResolve, onProgress });
+ try {
+ w.postMessage({ type: "formatJsonPretty", jobId, text, indentSize, maxOutputBytes });
+ } catch {
+ pending.delete(jobId);
+ wrappedResolve({ ok: false, errorCode: "UNKNOWN" });
+ }
+ });
+}
+
+export async function stringifyJsonPretty({
+ value,
+ indentSize,
+ maxOutputBytes,
+ signal,
+}: {
+ value: unknown;
+ indentSize: number;
+ maxOutputBytes: number;
+ signal?: AbortSignal;
+}): Promise {
+ ensureInitialized();
+ const w = getWorker();
+ if (!w) {
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+ try {
+ const pretty = JSON.stringify(value, null, indentSize);
+ if (pretty.length * 2 > maxOutputBytes) return { ok: false, errorCode: "OUTPUT_TOO_LARGE" };
+ return { ok: true, text: pretty };
+ } catch {
+ return { ok: false, errorCode: "UNKNOWN" };
+ }
+ }
+
+ const jobId = genJobId();
+ return new Promise((resolve) => {
+ if (signal?.aborted) {
+ resolve({ ok: false, errorCode: "CANCELED" });
+ return;
+ }
+
+ const onAbort = () => cancelWorkerJob(jobId);
+ signal?.addEventListener("abort", onAbort);
+
+ const wrappedResolve: StringifyJsonPrettyResolve = (v) => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve(v);
+ };
+
+ pending.set(jobId, { kind: "stringifyJsonPretty", resolve: wrappedResolve });
+ try {
+ w.postMessage({ type: "stringifyJsonPretty", jobId, value, indentSize, maxOutputBytes });
+ } catch {
+ pending.delete(jobId);
+ wrappedResolve({ ok: false, errorCode: "UNKNOWN" });
+ }
+ });
+}
+
+export async function buildLineIndex({
+ text,
+ maxLines,
+ onProgress,
+ signal,
+ workerEnabled = true,
+}: {
+ text: string;
+ maxLines: number;
+ onProgress?: (p: WorkerProgress) => void;
+ signal?: AbortSignal;
+ workerEnabled?: boolean;
+}): Promise {
+ const buildLineIndexNoWorker = async (): Promise => {
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+
+ const total = text.length;
+ const starts: number[] = [0];
+ let lastYieldAt = nowMs();
+ let lastProgressAt = lastYieldAt;
+
+ for (let i = 0; i < total; i += 1) {
+ if ((i & 8191) === 0) {
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+
+ const now = nowMs();
+ if (now - lastYieldAt > YIELD_MIN_INTERVAL_MS) {
+ lastYieldAt = now;
+ await yieldToEventLoop();
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+ }
+
+ if (now - lastProgressAt > PROGRESS_MIN_INTERVAL_MS) {
+ lastProgressAt = now;
+ onProgress?.({ stage: "index", processed: i, total });
+ }
+ }
+
+ const code = text.charCodeAt(i);
+ if (code === 10) {
+ const nextLineCount = starts.length + 1;
+ if (nextLineCount > maxLines) {
+ onProgress?.({ stage: "index", processed: i, total });
+ return { ok: false, errorCode: "TOO_MANY_LINES", lineCount: nextLineCount };
+ }
+ starts.push(i + 1);
+ continue;
+ }
+ if (code === 13) {
+ const nextLineCount = starts.length + 1;
+ if (nextLineCount > maxLines) {
+ onProgress?.({ stage: "index", processed: i, total });
+ return { ok: false, errorCode: "TOO_MANY_LINES", lineCount: nextLineCount };
+ }
+
+ // CRLF 视为一个换行
+ if (i + 1 < total && text.charCodeAt(i + 1) === 10) {
+ starts.push(i + 2);
+ i += 1;
+ } else {
+ starts.push(i + 1);
+ }
+ }
+ }
+
+ onProgress?.({ stage: "index", processed: total, total });
+
+ const lineCount = starts.length;
+ const lineStarts = new Int32Array(lineCount);
+ for (let i = 0; i < lineCount; i += 1) {
+ lineStarts[i] = starts[i] ?? 0;
+ }
+
+ return { ok: true, lineStarts, lineCount };
+ };
+
+ if (!workerEnabled) {
+ return await buildLineIndexNoWorker();
+ }
+
+ ensureInitialized();
+ const w = getWorker();
+ if (!w) {
+ return await buildLineIndexNoWorker();
+ }
+
+ const jobId = genJobId();
+ return new Promise((resolve) => {
+ if (signal?.aborted) {
+ resolve({ ok: false, errorCode: "CANCELED" });
+ return;
+ }
+
+ const onAbort = () => cancelWorkerJob(jobId);
+ signal?.addEventListener("abort", onAbort);
+
+ const wrappedResolve: BuildLineIndexResolve = (v) => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve(v);
+ };
+
+ pending.set(jobId, { kind: "buildLineIndex", resolve: wrappedResolve, onProgress });
+ try {
+ w.postMessage({ type: "buildLineIndex", jobId, text, maxLines });
+ } catch {
+ pending.delete(jobId);
+ wrappedResolve({ ok: false, errorCode: "UNKNOWN" });
+ }
+ });
+}
+
+export async function searchLines({
+ text,
+ query,
+ maxResults,
+ onProgress,
+ signal,
+ workerEnabled = true,
+}: {
+ text: string;
+ query: string;
+ maxResults: number;
+ onProgress?: (p: WorkerProgress) => void;
+ signal?: AbortSignal;
+ workerEnabled?: boolean;
+}): Promise {
+ const searchLinesNoWorker = async (): Promise => {
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+ if (!query) return { ok: true, matches: new Int32Array(0) };
+
+ const total = text.length;
+ const lines: number[] = [];
+ let lastLine = -1;
+ let scan = 0;
+ let lineNo = 0;
+ let lastYieldAt = nowMs();
+ let lastProgressAt = lastYieldAt;
+
+ let pos = text.indexOf(query, 0);
+ while (pos !== -1) {
+ while (scan < pos) {
+ if ((scan & 8191) === 0) {
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+
+ const now = nowMs();
+ if (now - lastYieldAt > YIELD_MIN_INTERVAL_MS) {
+ lastYieldAt = now;
+ await yieldToEventLoop();
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+ }
+
+ if (now - lastProgressAt > PROGRESS_MIN_INTERVAL_MS) {
+ lastProgressAt = now;
+ onProgress?.({ stage: "search", processed: scan, total });
+ }
+ }
+
+ const code = text.charCodeAt(scan);
+ if (code === 10) {
+ lineNo += 1;
+ scan += 1;
+ continue;
+ }
+ if (code === 13) {
+ lineNo += 1;
+ if (scan + 1 < total && text.charCodeAt(scan + 1) === 10) {
+ scan += 2;
+ } else {
+ scan += 1;
+ }
+ continue;
+ }
+ scan += 1;
+ }
+
+ if (lineNo !== lastLine) {
+ lines.push(lineNo);
+ lastLine = lineNo;
+ if (lines.length >= maxResults) break;
+ }
+
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+ pos = text.indexOf(query, pos + 1);
+
+ const now = nowMs();
+ if (now - lastYieldAt > YIELD_MIN_INTERVAL_MS) {
+ lastYieldAt = now;
+ await yieldToEventLoop();
+ if (signal?.aborted) return { ok: false, errorCode: "CANCELED" };
+ }
+
+ if (now - lastProgressAt > PROGRESS_MIN_INTERVAL_MS) {
+ lastProgressAt = now;
+ onProgress?.({
+ stage: "search",
+ processed: Math.min(pos === -1 ? total : pos, total),
+ total,
+ });
+ }
+ }
+
+ onProgress?.({ stage: "search", processed: total, total });
+ return { ok: true, matches: Int32Array.from(lines) };
+ };
+
+ if (!workerEnabled) {
+ return await searchLinesNoWorker();
+ }
+
+ ensureInitialized();
+ const w = getWorker();
+ if (!w) {
+ return await searchLinesNoWorker();
+ }
+
+ const jobId = genJobId();
+ return new Promise((resolve) => {
+ if (signal?.aborted) {
+ resolve({ ok: false, errorCode: "CANCELED" });
+ return;
+ }
+
+ const onAbort = () => cancelWorkerJob(jobId);
+ signal?.addEventListener("abort", onAbort);
+
+ const wrappedResolve: SearchLinesResolve = (v) => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve(v);
+ };
+
+ pending.set(jobId, { kind: "searchLines", resolve: wrappedResolve, onProgress });
+ try {
+ w.postMessage({ type: "searchLines", jobId, text, query, maxResults });
+ } catch {
+ pending.delete(jobId);
+ wrappedResolve({ ok: false, errorCode: "UNKNOWN" });
+ }
+ });
+}
diff --git a/src/components/ui/code-display.tsx b/src/components/ui/code-display.tsx
index c08002d20..78a823824 100644
--- a/src/components/ui/code-display.tsx
+++ b/src/components/ui/code-display.tsx
@@ -7,39 +7,46 @@ import {
Copy,
Download,
File as FileIcon,
+ Loader2,
Search,
+ X,
} from "lucide-react";
import { useTranslations } from "next-intl";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { MAX_SYNC_JSON_CHARS } from "@/components/ui/code-display-config";
+import { useCodeDisplayConfig } from "@/components/ui/code-display-config-context";
+import { CodeDisplayMatchesList } from "@/components/ui/code-display-matches-list";
+import { CodeDisplayPlainTextarea } from "@/components/ui/code-display-plain-textarea";
+import { CodeDisplayVirtualHighlighter } from "@/components/ui/code-display-virtual-highlighter";
+import {
+ type BuildLineIndexErrorCode,
+ buildLineIndex,
+ formatJsonPretty,
+ type SearchLinesErrorCode,
+ searchLines,
+} from "@/components/ui/code-display-worker-client";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import { cn } from "@/lib/utils";
-import { parseSSEDataForDisplay } from "@/lib/utils/sse";
+import { useDebounce } from "@/lib/hooks/use-debounce";
+import { cn, getTextKey } from "@/lib/utils";
export type CodeDisplayLanguage = "json" | "sse" | "text";
const DEFAULT_MAX_CONTENT_BYTES = 1_000_000; // 1MB
const DEFAULT_MAX_LINES = 10_000;
-const PRETTY_MODE_DEFAULT_MAX_CHARS = 100_000;
+const DEFAULT_JSON_INDENT = 2;
+const LARGE_CONTENT_MAX_CHARS = 4000;
+const LARGE_CONTENT_MAX_LINES = 200;
+const SSE_VIRTUAL_THRESHOLD = 200;
+const SSE_ESTIMATED_ROW_HEIGHT_PX = 44;
+const SSE_OVERSCAN = 12;
-export interface CodeDisplayProps {
- content: string;
- language: CodeDisplayLanguage;
- fileName?: string;
- maxHeight?: string;
- expandedMaxHeight?: string;
- defaultExpanded?: boolean;
- maxContentBytes?: number;
- maxLines?: number;
- enableDownload?: boolean;
- enableCopy?: boolean;
- className?: string;
-}
+type DisplaySseEvent = { event: string; data: string };
function safeJsonParse(text: string): { ok: true; value: unknown } | { ok: false } {
try {
@@ -53,18 +60,86 @@ function stringifyPretty(value: unknown): string {
return JSON.stringify(value, null, 2);
}
+function CodeDisplaySseDataSyntaxHighlighter({
+ data,
+ highlighterStyle,
+}: {
+ data: string;
+ highlighterStyle: typeof oneDark;
+}) {
+ const highlightedText = useMemo(() => {
+ const parsed = safeJsonParse(data);
+ return parsed.ok ? stringifyPretty(parsed.value) : data;
+ }, [data]);
+
+ return (
+
+ {highlightedText}
+
+ );
+}
+
+/**
+ * 统计 UTF-8 字节数,最多统计到超过 limitBytes 为止。
+ *
+ * 当返回值 > limitBytes 时,表示“已超过上限”(返回值不代表真实总字节数)。
+ */
+function countUtf8BytesUpToLimit(text: string, limitBytes: number): number {
+ let bytes = 0;
+
+ for (let i = 0; i < text.length; i += 1) {
+ const code = text.charCodeAt(i);
+ if (code <= 0x7f) {
+ bytes += 1;
+ } else if (code <= 0x7ff) {
+ bytes += 2;
+ } else if (code >= 0xd800 && code <= 0xdbff) {
+ const next = i + 1 < text.length ? text.charCodeAt(i + 1) : 0;
+ if (next >= 0xdc00 && next <= 0xdfff) {
+ bytes += 4;
+ i += 1;
+ } else {
+ bytes += 3;
+ }
+ } else {
+ bytes += 3;
+ }
+
+ if (bytes > limitBytes) return bytes;
+ }
+
+ return bytes;
+}
+
function splitLines(text: string): string[] {
- return text.length === 0 ? [""] : text.split("\n");
+ if (text.length === 0) return [""];
+ // 小内容路径:兼容 CRLF/CR,避免 only-matches 在 Windows 行尾下错判。
+ if (!text.includes("\r")) return text.split("\n");
+ return text.replace(/\r\n?/g, "\n").split("\n");
}
function countLinesUpTo(text: string, maxLines: number): number {
if (text.length === 0) return 1;
let count = 1;
- for (let i = 0; i < text.length; i += 1) {
- if (text.charCodeAt(i) === 10) {
+ const total = text.length;
+ for (let i = 0; i < total; i += 1) {
+ const code = text.charCodeAt(i);
+ if (code === 10) {
+ count += 1;
+ } else if (code === 13) {
count += 1;
- if (count >= maxLines) return count;
+ // CRLF 视为一个换行
+ if (i + 1 < total && text.charCodeAt(i + 1) === 10) i += 1;
}
+ if (count >= maxLines) return count;
}
return count;
}
@@ -74,6 +149,389 @@ function getDefaultMode(language: CodeDisplayLanguage): "raw" | "pretty" {
return "pretty";
}
+function escapeRegExp(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+// NOTE: 这里不复用 `parseSSEDataForDisplay`:它会 split 整段文本并尝试 JSON.parse(data),
+// 对超长 SSE 内容容易造成额外内存/CPU 开销。CodeDisplay 只需要用于展示的轻量解析(保留 string data)。
+function parseSseForCodeDisplay(sseText: string): DisplaySseEvent[] {
+ const events: DisplaySseEvent[] = [];
+
+ let eventName = "";
+ let dataLines: string[] = [];
+
+ const flushEvent = () => {
+ if (dataLines.length === 0) {
+ eventName = "";
+ dataLines = [];
+ return;
+ }
+
+ const dataStr = dataLines.join("\n");
+ if (dataStr.trim() === "[DONE]") {
+ eventName = "";
+ dataLines = [];
+ return;
+ }
+
+ events.push({ event: eventName || "message", data: dataStr });
+ eventName = "";
+ dataLines = [];
+ };
+
+ let start = 0;
+ for (let i = 0; i <= sseText.length; i += 1) {
+ if (i !== sseText.length && sseText.charCodeAt(i) !== 10) continue;
+
+ let line = sseText.slice(start, i);
+ start = i + 1;
+
+ if (line.endsWith("\r")) {
+ line = line.slice(0, -1);
+ }
+
+ if (!line) {
+ flushEvent();
+ continue;
+ }
+
+ if (line.startsWith(":")) continue;
+
+ if (line.startsWith("event:")) {
+ eventName = line.substring(6).trim();
+ continue;
+ }
+
+ if (line.startsWith("data:")) {
+ let value = line.substring(5);
+ if (value.startsWith(" ")) value = value.slice(1);
+ dataLines.push(value);
+ }
+ }
+
+ flushEvent();
+ return events;
+}
+
+function buildOnlyMatchesText(text: string, lineStarts: Int32Array, matches: Int32Array): string {
+ const out: string[] = [];
+ const lineCount = lineStarts.length;
+
+ for (let i = 0; i < matches.length; i += 1) {
+ const lineNo = matches[i] ?? 0;
+ if (lineNo < 0 || lineNo >= lineCount) continue;
+
+ const start = lineStarts[lineNo] ?? 0;
+ const nextStart =
+ lineNo + 1 < lineCount ? (lineStarts[lineNo + 1] ?? text.length) : text.length;
+ let end = nextStart;
+ if (nextStart > start) {
+ const last = text.charCodeAt(nextStart - 1);
+ if (last === 10) {
+ end = nextStart - 1;
+ if (end > start && text.charCodeAt(end - 1) === 13) {
+ end -= 1;
+ }
+ } else if (last === 13) {
+ end = nextStart - 1;
+ }
+ }
+ end = Math.max(start, end);
+ out.push(text.slice(start, end));
+ }
+
+ return out.join("\n");
+}
+
+function CodeDisplaySseEvents({
+ events,
+ maxHeight,
+ resolvedTheme,
+ highlightMaxChars,
+ largePlainEnabled,
+ lineHeightPx,
+ labels,
+}: {
+ events: DisplaySseEvent[];
+ maxHeight: string | undefined;
+ resolvedTheme: "light" | "dark";
+ highlightMaxChars: number;
+ largePlainEnabled: boolean;
+ lineHeightPx: number;
+ labels: {
+ noMatches: string;
+ sseEvent: string;
+ sseData: string;
+ };
+}) {
+ const highlighterStyle = resolvedTheme === "dark" ? oneDark : oneLight;
+
+ const scrollRef = useRef(null);
+ const rafRef = useRef(null);
+ const scrollTopRef = useRef(0);
+
+ const [measuredRowHeight, setMeasuredRowHeight] = useState(SSE_ESTIMATED_ROW_HEIGHT_PX);
+ const measuredRowHeightRef = useRef(measuredRowHeight);
+ measuredRowHeightRef.current = measuredRowHeight;
+
+ const measureFirstRow = useCallback((el: HTMLDivElement | null) => {
+ if (!el) return;
+
+ const prev = measuredRowHeightRef.current;
+ // 避免在用户已滚动时更新估算高度导致列表跳动:仅在初始阶段做一次测量。
+ if (prev !== SSE_ESTIMATED_ROW_HEIGHT_PX && scrollTopRef.current > 0) return;
+
+ const h = el.getBoundingClientRect().height;
+ if (!Number.isFinite(h) || h <= 0) return;
+
+ const next = Math.max(24, Math.min(200, h));
+ if (Math.abs(prev - next) < 1) return;
+
+ measuredRowHeightRef.current = next;
+ setMeasuredRowHeight(next);
+ }, []);
+
+ const [viewportHeight, setViewportHeight] = useState(0);
+ const [scrollTop, setScrollTop] = useState(0);
+ const [expandedRows, setExpandedRows] = useState>(() => new Set());
+ const [activeRow, setActiveRow] = useState(null);
+ const lastEventsRef = useRef(null);
+
+ useEffect(() => {
+ if (lastEventsRef.current === null) {
+ lastEventsRef.current = events;
+ return;
+ }
+ // events 可能由搜索过滤产生新列表:重置展开状态/选中项以避免索引错位
+ lastEventsRef.current = events;
+ setExpandedRows(new Set());
+ setActiveRow(null);
+ }, [events]);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+
+ const update = () => setViewportHeight(el.clientHeight);
+ update();
+
+ let ro: ResizeObserver | null = null;
+ if (typeof ResizeObserver !== "undefined") {
+ ro = new ResizeObserver(update);
+ ro.observe(el);
+ }
+
+ window.addEventListener("resize", update);
+ return () => {
+ ro?.disconnect();
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+
+ useEffect(
+ () => () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ },
+ []
+ );
+
+ if (events.length === 0) {
+ return {labels.noMatches}
;
+ }
+
+ const useVirtual = events.length > SSE_VIRTUAL_THRESHOLD;
+ // SSE 列表的单行高度与代码行高不同,这里使用一个固定估算值用于折叠态虚拟化。
+ const estimatedRowHeight = measuredRowHeight;
+ const overscan = SSE_OVERSCAN;
+ const total = events.length;
+
+ const startIndex = useVirtual
+ ? viewportHeight <= 0
+ ? 0
+ : Math.max(0, Math.floor(scrollTop / estimatedRowHeight) - overscan)
+ : 0;
+ const endIndex = useVirtual
+ ? viewportHeight <= 0
+ ? Math.min(total, 50)
+ : Math.min(total, Math.ceil((scrollTop + viewportHeight) / estimatedRowHeight) + overscan)
+ : total;
+
+ const topPad = useVirtual ? startIndex * estimatedRowHeight : 0;
+ const bottomPad = useVirtual ? (total - endIndex) * estimatedRowHeight : 0;
+ const rows = events.slice(startIndex, endIndex);
+
+ const containerMaxHeight = useVirtual ? (maxHeight ?? "600px") : maxHeight;
+ const activeEvent =
+ useVirtual && activeRow !== null && activeRow >= 0 && activeRow < total
+ ? (events[activeRow] ?? null)
+ : null;
+
+ const renderData = (data: string) => {
+ if (data.length <= highlightMaxChars) {
+ return (
+
+ );
+ }
+
+ if (largePlainEnabled) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+
+
{
+ const next = e.currentTarget.scrollTop;
+ scrollTopRef.current = next;
+ if (!useVirtual) return;
+ if (rafRef.current !== null) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ setScrollTop(scrollTopRef.current);
+ });
+ }}
+ >
+
+ {topPad > 0 &&
}
+ {rows.map((evt, localIdx) => {
+ const index = startIndex + localIdx;
+ const open = expandedRows.has(index);
+ const active = activeRow === index;
+ const preview = evt.data.length > 120 ? `${evt.data.slice(0, 120)}...` : evt.data;
+
+ return (
+
+ {useVirtual ? (
+
+ ) : (
+
+ {
+ e.preventDefault();
+ setExpandedRows((prev) => {
+ const next = new Set(prev);
+ if (next.has(index)) next.delete(index);
+ else next.add(index);
+ return next;
+ });
+ }}
+ >
+
+
+ {index + 1}
+
+ {evt.event}
+
+ {preview}
+
+
+
+
+ {open && (
+
+
+
{labels.sseEvent}
+
{evt.event}
+
+
+
+
{labels.sseData}
+ {renderData(evt.data)}
+
+
+ )}
+
+ )}
+
+ );
+ })}
+ {bottomPad > 0 &&
}
+
+
+
+ {useVirtual && activeEvent && (
+
+
+
{labels.sseEvent}
+
{activeEvent.event}
+
+
+
+
{labels.sseData}
+ {renderData(activeEvent.data)}
+
+
+ )}
+
+ );
+}
+
+export interface CodeDisplayProps {
+ content: string;
+ language: CodeDisplayLanguage;
+ fileName?: string;
+ maxHeight?: string;
+ expandedMaxHeight?: string;
+ defaultExpanded?: boolean;
+ maxContentBytes?: number;
+ maxLines?: number;
+ enableDownload?: boolean;
+ enableCopy?: boolean;
+ className?: string;
+}
+
export function CodeDisplay({
content,
language,
@@ -89,28 +547,56 @@ export function CodeDisplay({
}: CodeDisplayProps) {
const t = useTranslations("dashboard.sessions");
const tActions = useTranslations("dashboard.actions");
+ const codeDisplayConfig = useCodeDisplayConfig();
+
const resolvedMaxContentBytes = maxContentBytes ?? DEFAULT_MAX_CONTENT_BYTES;
const resolvedMaxLines = maxLines ?? DEFAULT_MAX_LINES;
- const contentBytes = useMemo(() => new Blob([content]).size, [content]);
+
+ const contentBytes = useMemo(
+ () => countUtf8BytesUpToLimit(content, resolvedMaxContentBytes + 1),
+ [content, resolvedMaxContentBytes]
+ );
const isOverMaxBytes = contentBytes > resolvedMaxContentBytes;
- const [mode, setMode] = useState<"raw" | "pretty">(() => {
- const defaultMode = getDefaultMode(language);
- if (defaultMode === "pretty" && content.length > PRETTY_MODE_DEFAULT_MAX_CHARS) {
- return "raw";
- }
- return defaultMode;
- });
+ const lineCount = useMemo(() => {
+ if (isOverMaxBytes) return 0;
+ return countLinesUpTo(content, resolvedMaxLines + 1);
+ }, [content, isOverMaxBytes, resolvedMaxLines]);
+
+ const isLargeContent =
+ content.length > LARGE_CONTENT_MAX_CHARS || lineCount > LARGE_CONTENT_MAX_LINES;
+ const [expanded, setExpanded] = useState(defaultExpanded);
+ const isExpanded = expanded || !isLargeContent;
+ const contentMaxHeight = isExpanded ? expandedMaxHeight : maxHeight;
+
+ const isHardLimited = isOverMaxBytes || lineCount > resolvedMaxLines;
+
+ const [mode, setMode] = useState<"raw" | "pretty">(() => getDefaultMode(language));
const [searchQuery, setSearchQuery] = useState("");
const [showOnlyMatches, setShowOnlyMatches] = useState(false);
- const [expanded, setExpanded] = useState(defaultExpanded);
+ const [largePrettyView, setLargePrettyView] = useState<"plain" | "virtual">("plain");
+ const [forceLargePrettyPlain, setForceLargePrettyPlain] = useState(false);
+ const [largePrettyVirtualFallback, setLargePrettyVirtualFallback] = useState<{
+ errorCode: BuildLineIndexErrorCode;
+ lineCount?: number;
+ } | null>(null);
+
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
- const [expandedSseRows, setExpandedSseRows] = useState>(() => new Set());
- const sseScrollRef = useRef(null);
- const [sseViewportHeight, setSseViewportHeight] = useState(0);
- const [sseScrollTop, setSseScrollTop] = useState(0);
const [copied, setCopied] = useState(false);
+ const lastLanguageRef = useRef(language);
+ useEffect(() => {
+ const prevLanguage = lastLanguageRef.current;
+ if (prevLanguage === language) return;
+ lastLanguageRef.current = language;
+
+ const prevDefault = getDefaultMode(prevLanguage);
+ const nextDefault = getDefaultMode(language);
+
+ // 仅在用户未显式切换(仍处于上一个语言默认值)时,跟随语言更新默认模式。
+ setMode((current) => (current === prevDefault ? nextDefault : current));
+ }, [language]);
+
useEffect(() => {
const getTheme = () => (document.documentElement.classList.contains("dark") ? "dark" : "light");
@@ -122,96 +608,551 @@ export function CodeDisplay({
return () => observer.disconnect();
}, []);
+ const shouldFormatJsonInWorker =
+ language === "json" &&
+ !isHardLimited &&
+ codeDisplayConfig.workerEnabled &&
+ content.length > codeDisplayConfig.highlightMaxChars;
+
+ const jsonSourceKey = useMemo(() => getTextKey(content), [content]);
+
+ const shouldRunJsonPrettyJob = shouldFormatJsonInWorker && mode === "pretty";
+
+ const jsonPrettyReqIdRef = useRef(0);
+ const jsonPrettyAbortRef = useRef(null);
+ const [jsonPrettyText, setJsonPrettyText] = useState(null);
+ const [jsonPrettyTextKey, setJsonPrettyTextKey] = useState(null);
+ const [jsonPrettyStatus, setJsonPrettyStatus] = useState<
+ "idle" | "loading" | "ready" | "invalid" | "tooLarge" | "canceled" | "error"
+ >("idle");
+ const [jsonPrettyErrorCode, setJsonPrettyErrorCode] = useState(null);
+ const [jsonPrettyProgress, setJsonPrettyProgress] = useState<{
+ processed: number;
+ total: number;
+ } | null>(null);
+
useEffect(() => {
- if (mode !== "pretty") return;
- if (language === "text") return;
- if (content.length <= PRETTY_MODE_DEFAULT_MAX_CHARS) return;
- setMode("raw");
- }, [content, language, mode]);
+ return () => {
+ jsonPrettyAbortRef.current?.abort();
+ jsonPrettyAbortRef.current = null;
+ };
+ }, []);
- const lineCount = useMemo(() => {
- if (isOverMaxBytes) return 0;
- return countLinesUpTo(content, resolvedMaxLines + 1);
- }, [content, isOverMaxBytes, resolvedMaxLines]);
- const isLargeContent = content.length > 4000 || lineCount > 200;
- const isExpanded = expanded || !isLargeContent;
- const isHardLimited = isOverMaxBytes || lineCount > resolvedMaxLines;
+ const cancelJsonPretty = () => {
+ jsonPrettyAbortRef.current?.abort();
+ jsonPrettyAbortRef.current = null;
+ setJsonPrettyText(null);
+ setJsonPrettyTextKey(jsonSourceKey);
+ setJsonPrettyStatus("canceled");
+ setJsonPrettyErrorCode("CANCELED");
+ setJsonPrettyProgress(null);
+ };
+
+ const retryJsonPretty = () => {
+ jsonPrettyAbortRef.current?.abort();
+ jsonPrettyAbortRef.current = null;
+ setJsonPrettyText(null);
+ setJsonPrettyTextKey(null);
+ setJsonPrettyStatus("idle");
+ setJsonPrettyErrorCode(null);
+ setJsonPrettyProgress(null);
+ };
- const formattedJson = useMemo(() => {
+ useEffect(() => {
+ if (!shouldFormatJsonInWorker) {
+ jsonPrettyAbortRef.current?.abort();
+ jsonPrettyAbortRef.current = null;
+ setJsonPrettyText(null);
+ setJsonPrettyTextKey(null);
+ setJsonPrettyStatus("idle");
+ setJsonPrettyErrorCode(null);
+ setJsonPrettyProgress(null);
+ return;
+ }
+
+ if (jsonPrettyTextKey && jsonPrettyTextKey !== jsonSourceKey) {
+ setJsonPrettyText(null);
+ setJsonPrettyTextKey(null);
+ setJsonPrettyStatus("idle");
+ setJsonPrettyProgress(null);
+ }
+
+ if (!shouldRunJsonPrettyJob) {
+ jsonPrettyAbortRef.current?.abort();
+ jsonPrettyAbortRef.current = null;
+ if (jsonPrettyStatus === "loading") {
+ setJsonPrettyText(null);
+ setJsonPrettyStatus("canceled");
+ setJsonPrettyProgress(null);
+ }
+ if (jsonPrettyStatus === "canceled" || jsonPrettyStatus === "error") {
+ setJsonPrettyStatus("idle");
+ setJsonPrettyProgress(null);
+ }
+ return;
+ }
+
+ if (
+ jsonPrettyTextKey === jsonSourceKey &&
+ (jsonPrettyStatus === "loading" ||
+ jsonPrettyStatus === "ready" ||
+ jsonPrettyStatus === "invalid" ||
+ jsonPrettyStatus === "tooLarge" ||
+ jsonPrettyStatus === "canceled" ||
+ jsonPrettyStatus === "error")
+ ) {
+ return;
+ }
+
+ const reqId = (jsonPrettyReqIdRef.current += 1);
+ const controller = new AbortController();
+ jsonPrettyAbortRef.current?.abort();
+ jsonPrettyAbortRef.current = controller;
+
+ setJsonPrettyTextKey(jsonSourceKey);
+ setJsonPrettyStatus("loading");
+ setJsonPrettyErrorCode(null);
+ setJsonPrettyProgress({ processed: 0, total: content.length });
+
+ const start = performance.now();
+ void formatJsonPretty({
+ text: content,
+ indentSize: DEFAULT_JSON_INDENT,
+ maxOutputBytes: codeDisplayConfig.maxPrettyOutputBytes,
+ onProgress: (p) => {
+ if (controller.signal.aborted) return;
+ if (p.stage !== "format") return;
+ setJsonPrettyProgress({ processed: p.processed, total: p.total });
+ },
+ signal: controller.signal,
+ }).then((res) => {
+ if (controller.signal.aborted) return;
+ if (reqId !== jsonPrettyReqIdRef.current) return;
+
+ const costMs = Math.round(performance.now() - start);
+ if (codeDisplayConfig.perfDebugEnabled) {
+ console.debug("CodeDisplay formatJsonPretty", {
+ costMs,
+ inputChars: content.length,
+ ok: res.ok,
+ usedStreaming: res.ok ? res.usedStreaming : undefined,
+ errorCode: res.ok ? undefined : res.errorCode,
+ });
+ }
+
+ if (res.ok) {
+ setJsonPrettyText(res.text);
+ setJsonPrettyTextKey(jsonSourceKey);
+ setJsonPrettyStatus("ready");
+ setJsonPrettyErrorCode(null);
+ setJsonPrettyProgress(null);
+ return;
+ }
+
+ setJsonPrettyErrorCode(res.errorCode);
+ switch (res.errorCode) {
+ case "INVALID_JSON":
+ setJsonPrettyText(null);
+ setJsonPrettyTextKey(jsonSourceKey);
+ setJsonPrettyStatus("invalid");
+ setJsonPrettyProgress(null);
+ return;
+ case "OUTPUT_TOO_LARGE":
+ setJsonPrettyText(null);
+ setJsonPrettyTextKey(jsonSourceKey);
+ setJsonPrettyStatus("tooLarge");
+ setJsonPrettyProgress(null);
+ return;
+ case "CANCELED":
+ setJsonPrettyText(null);
+ setJsonPrettyTextKey(jsonSourceKey);
+ setJsonPrettyStatus("canceled");
+ setJsonPrettyProgress(null);
+ return;
+ default:
+ setJsonPrettyText(null);
+ setJsonPrettyTextKey(jsonSourceKey);
+ setJsonPrettyStatus("error");
+ setJsonPrettyProgress(null);
+ return;
+ }
+ });
+ }, [
+ content,
+ codeDisplayConfig.maxPrettyOutputBytes,
+ codeDisplayConfig.perfDebugEnabled,
+ jsonPrettyStatus,
+ jsonPrettyTextKey,
+ jsonSourceKey,
+ shouldFormatJsonInWorker,
+ shouldRunJsonPrettyJob,
+ ]);
+
+ const jsonPrettySyncText = useMemo(() => {
+ if (language !== "json") return null;
+ if (mode !== "pretty") return null;
+ if (isHardLimited) return null;
+ if (shouldFormatJsonInWorker) return null;
+
+ // 当 Worker 被禁用时,避免对超大 JSON 在主线程做 parse/stringify 导致卡顿。
+ const maxSyncChars = Math.min(codeDisplayConfig.highlightMaxChars, MAX_SYNC_JSON_CHARS);
+ if (!codeDisplayConfig.workerEnabled && content.length > maxSyncChars) return content;
+
+ const parsed = safeJsonParse(content);
+ if (!parsed.ok) return content;
+ return JSON.stringify(parsed.value, null, DEFAULT_JSON_INDENT);
+ }, [
+ content,
+ codeDisplayConfig.highlightMaxChars,
+ codeDisplayConfig.workerEnabled,
+ isHardLimited,
+ language,
+ mode,
+ shouldFormatJsonInWorker,
+ ]);
+
+ const resolvedPrettyText = useMemo(() => {
if (language !== "json") return content;
if (mode !== "pretty") return content;
if (isHardLimited) return content;
- const parsed = safeJsonParse(content);
- if (!parsed.ok) return content;
- return stringifyPretty(parsed.value);
- }, [content, isHardLimited, language, mode]);
- const sseEvents = useMemo(() => {
- if (language !== "sse") return null;
- if (mode !== "pretty") return null;
- if (isHardLimited) return null;
- return parseSSEDataForDisplay(content);
- }, [content, isHardLimited, language, mode]);
+ if (shouldFormatJsonInWorker) {
+ if (jsonPrettyStatus === "ready" && jsonPrettyTextKey === jsonSourceKey) {
+ return jsonPrettyText ?? content;
+ }
+ return content;
+ }
- const filteredSseEvents = useMemo(() => {
- if (!sseEvents) return null;
- const q = searchQuery.trim().toLowerCase();
- if (!q) return sseEvents;
+ return jsonPrettySyncText ?? content;
+ }, [
+ content,
+ isHardLimited,
+ jsonPrettyStatus,
+ jsonPrettySyncText,
+ jsonPrettyText,
+ jsonPrettyTextKey,
+ jsonSourceKey,
+ language,
+ mode,
+ shouldFormatJsonInWorker,
+ ]);
- return sseEvents.filter((evt) => {
- const eventText = evt.event.toLowerCase();
- const dataText = typeof evt.data === "string" ? evt.data : JSON.stringify(evt.data, null, 2);
- return eventText.includes(q) || dataText.toLowerCase().includes(q);
- });
- }, [searchQuery, sseEvents]);
+ const isLargePrettyText =
+ language !== "sse" &&
+ mode === "pretty" &&
+ resolvedPrettyText.length > codeDisplayConfig.highlightMaxChars;
+
+ const largePrettySourceKey = mode === "pretty" && language !== "sse" ? jsonSourceKey : null;
+ const lastLargePrettySourceKeyRef = useRef(null);
useEffect(() => {
- if (language !== "sse" || mode !== "pretty") return;
- setExpandedSseRows(new Set());
- }, [language, mode]);
+ if (!codeDisplayConfig.virtualHighlightEnabled || !isLargePrettyText) {
+ lastLargePrettySourceKeyRef.current = null;
+ setForceLargePrettyPlain(false);
+ setLargePrettyVirtualFallback(null);
+ setLargePrettyView("plain");
+ return;
+ }
+
+ if (lastLargePrettySourceKeyRef.current !== largePrettySourceKey) {
+ setForceLargePrettyPlain(false);
+ setLargePrettyVirtualFallback(null);
+ }
+
+ if (forceLargePrettyPlain) {
+ lastLargePrettySourceKeyRef.current = largePrettySourceKey;
+ setLargePrettyView("plain");
+ return;
+ }
+
+ if (!codeDisplayConfig.largePlainEnabled) {
+ lastLargePrettySourceKeyRef.current = largePrettySourceKey;
+ setLargePrettyView("virtual");
+ return;
+ }
+
+ if (lastLargePrettySourceKeyRef.current !== largePrettySourceKey) {
+ lastLargePrettySourceKeyRef.current = largePrettySourceKey;
+ setLargePrettyView("plain");
+ }
+ }, [
+ codeDisplayConfig.largePlainEnabled,
+ codeDisplayConfig.virtualHighlightEnabled,
+ forceLargePrettyPlain,
+ isLargePrettyText,
+ largePrettySourceKey,
+ ]);
+
+ const nonSseTextForMode =
+ language === "sse" ? content : mode === "pretty" ? resolvedPrettyText : content;
+ const nonSseTextForModeRef = useRef(nonSseTextForMode);
+ nonSseTextForModeRef.current = nonSseTextForMode;
+
+ const onlyMatchesQuery = searchQuery.trim();
+ const debouncedOnlyMatchesQuery = useDebounce(onlyMatchesQuery, 200);
+
+ const shouldOptimizeOnlyMatches =
+ language !== "sse" &&
+ showOnlyMatches &&
+ debouncedOnlyMatchesQuery.length > 0 &&
+ !isHardLimited &&
+ nonSseTextForMode.length > codeDisplayConfig.highlightMaxChars;
+
+ const onlyMatchesIndexAbortRef = useRef(null);
+ const onlyMatchesSearchAbortRef = useRef(null);
+ const onlyMatchesIndexJobRef = useRef(0);
+ const onlyMatchesSearchJobRef = useRef(0);
+
+ const [onlyMatchesLineStarts, setOnlyMatchesLineStarts] = useState(null);
+ const [onlyMatchesMatches, setOnlyMatchesMatches] = useState(null);
+
+ const [onlyMatchesIndexStatus, setOnlyMatchesIndexStatus] = useState<
+ "idle" | "loading" | "ready" | "error"
+ >("idle");
+ const [onlyMatchesSearchStatus, setOnlyMatchesSearchStatus] = useState<
+ "idle" | "loading" | "ready" | "error"
+ >("idle");
+ const [onlyMatchesIndexProgress, setOnlyMatchesIndexProgress] = useState<{
+ processed: number;
+ total: number;
+ } | null>(null);
+ const [onlyMatchesSearchProgress, setOnlyMatchesSearchProgress] = useState<{
+ processed: number;
+ total: number;
+ } | null>(null);
+ const [onlyMatchesIndexErrorCode, setOnlyMatchesIndexErrorCode] =
+ useState(null);
+ const [onlyMatchesIndexErrorLineCount, setOnlyMatchesIndexErrorLineCount] = useState<
+ number | null
+ >(null);
+ const [onlyMatchesSearchErrorCode, setOnlyMatchesSearchErrorCode] =
+ useState(null);
+
+ const onlyMatchesLineIndexCacheRef = useRef<{
+ key: string;
+ lineStarts: Int32Array;
+ lineCount: number;
+ } | null>(null);
+
+ const nonSseTextKey = useMemo(() => getTextKey(nonSseTextForMode), [nonSseTextForMode]);
+ const nonSseTextKeyRef = useRef(nonSseTextKey);
+ nonSseTextKeyRef.current = nonSseTextKey;
useEffect(() => {
- if (language !== "sse" || mode !== "pretty") return;
- const el = sseScrollRef.current;
- if (!el) return;
+ if (!shouldOptimizeOnlyMatches) {
+ onlyMatchesIndexAbortRef.current?.abort();
+ onlyMatchesSearchAbortRef.current?.abort();
+ onlyMatchesIndexAbortRef.current = null;
+ onlyMatchesSearchAbortRef.current = null;
+ setOnlyMatchesLineStarts(null);
+ setOnlyMatchesMatches(null);
+ setOnlyMatchesIndexStatus("idle");
+ setOnlyMatchesSearchStatus("idle");
+ setOnlyMatchesIndexProgress(null);
+ setOnlyMatchesSearchProgress(null);
+ setOnlyMatchesIndexErrorCode(null);
+ setOnlyMatchesIndexErrorLineCount(null);
+ setOnlyMatchesSearchErrorCode(null);
+ return;
+ }
- const update = () => setSseViewportHeight(el.clientHeight);
- update();
+ const jobId = (onlyMatchesIndexJobRef.current += 1);
+ const text = nonSseTextForModeRef.current;
- let ro: ResizeObserver | null = null;
- if (typeof ResizeObserver !== "undefined") {
- ro = new ResizeObserver(update);
- ro.observe(el);
+ const cached = onlyMatchesLineIndexCacheRef.current;
+ if (cached && cached.key === nonSseTextKey) {
+ setOnlyMatchesLineStarts(cached.lineStarts);
+ setOnlyMatchesIndexStatus("ready");
+ setOnlyMatchesIndexProgress(null);
+ setOnlyMatchesIndexErrorCode(null);
+ setOnlyMatchesIndexErrorLineCount(null);
+ } else {
+ const controller = new AbortController();
+ onlyMatchesIndexAbortRef.current?.abort();
+ onlyMatchesIndexAbortRef.current = controller;
+
+ setOnlyMatchesLineStarts(null);
+ setOnlyMatchesIndexStatus("loading");
+ setOnlyMatchesIndexProgress({ processed: 0, total: text.length });
+ setOnlyMatchesIndexErrorCode(null);
+ setOnlyMatchesIndexErrorLineCount(null);
+
+ void buildLineIndex({
+ text,
+ maxLines: codeDisplayConfig.maxLineIndexLines,
+ onProgress: (p) => {
+ if (controller.signal.aborted) return;
+ if (p.stage !== "index") return;
+ setOnlyMatchesIndexProgress({ processed: p.processed, total: p.total });
+ },
+ signal: controller.signal,
+ workerEnabled: codeDisplayConfig.workerEnabled,
+ }).then((res) => {
+ if (controller.signal.aborted) return;
+ if (jobId !== onlyMatchesIndexJobRef.current) return;
+
+ if (!res.ok) {
+ setOnlyMatchesLineStarts(null);
+ setOnlyMatchesIndexStatus("error");
+ setOnlyMatchesIndexProgress(null);
+ setOnlyMatchesIndexErrorCode(res.errorCode);
+ setOnlyMatchesIndexErrorLineCount(res.lineCount ?? null);
+ return;
+ }
+
+ onlyMatchesLineIndexCacheRef.current = {
+ key: nonSseTextKey,
+ lineStarts: res.lineStarts,
+ lineCount: res.lineCount,
+ };
+ setOnlyMatchesLineStarts(res.lineStarts);
+ setOnlyMatchesIndexStatus("ready");
+ setOnlyMatchesIndexProgress(null);
+ setOnlyMatchesIndexErrorCode(null);
+ setOnlyMatchesIndexErrorLineCount(null);
+ });
}
- window.addEventListener("resize", update);
return () => {
- ro?.disconnect();
- window.removeEventListener("resize", update);
+ onlyMatchesIndexAbortRef.current?.abort();
};
- }, [language, mode]);
+ }, [
+ codeDisplayConfig.maxLineIndexLines,
+ codeDisplayConfig.workerEnabled,
+ nonSseTextKey,
+ shouldOptimizeOnlyMatches,
+ ]);
+
+ useEffect(() => {
+ if (!shouldOptimizeOnlyMatches) return;
+ if (onlyMatchesIndexStatus !== "ready" || !onlyMatchesLineStarts) {
+ onlyMatchesSearchAbortRef.current?.abort();
+ onlyMatchesSearchAbortRef.current = null;
+ setOnlyMatchesMatches(null);
+ setOnlyMatchesSearchStatus("idle");
+ setOnlyMatchesSearchProgress(null);
+ setOnlyMatchesSearchErrorCode(null);
+ return;
+ }
+
+ const jobId = (onlyMatchesSearchJobRef.current += 1);
+ const jobTextKey = nonSseTextKey;
+ const text = nonSseTextForModeRef.current;
+ const query = debouncedOnlyMatchesQuery;
+
+ const controller = new AbortController();
+ onlyMatchesSearchAbortRef.current?.abort();
+ onlyMatchesSearchAbortRef.current = controller;
+
+ setOnlyMatchesMatches(null);
+ setOnlyMatchesSearchStatus("loading");
+ setOnlyMatchesSearchProgress({ processed: 0, total: text.length });
+ setOnlyMatchesSearchErrorCode(null);
+
+ void searchLines({
+ text,
+ query,
+ maxResults: Math.min(resolvedMaxLines, 50_000),
+ onProgress: (p) => {
+ if (controller.signal.aborted) return;
+ if (p.stage !== "search") return;
+ setOnlyMatchesSearchProgress({ processed: p.processed, total: p.total });
+ },
+ signal: controller.signal,
+ workerEnabled: codeDisplayConfig.workerEnabled,
+ }).then((res) => {
+ if (controller.signal.aborted) return;
+ if (jobId !== onlyMatchesSearchJobRef.current) return;
+ if (jobTextKey !== nonSseTextKeyRef.current) return;
+
+ if (!res.ok) {
+ setOnlyMatchesMatches(null);
+ setOnlyMatchesSearchStatus("error");
+ setOnlyMatchesSearchProgress(null);
+ setOnlyMatchesSearchErrorCode(res.errorCode);
+ return;
+ }
+
+ setOnlyMatchesMatches(res.matches);
+ setOnlyMatchesSearchStatus("ready");
+ setOnlyMatchesSearchProgress(null);
+ setOnlyMatchesSearchErrorCode(null);
+ });
- const lineFilteredText = useMemo(() => {
+ return () => {
+ controller.abort();
+ };
+ }, [
+ codeDisplayConfig.workerEnabled,
+ debouncedOnlyMatchesQuery,
+ nonSseTextKey,
+ onlyMatchesIndexStatus,
+ onlyMatchesLineStarts,
+ resolvedMaxLines,
+ shouldOptimizeOnlyMatches,
+ ]);
+
+ const nonSseFilteredText = useMemo(() => {
if (language === "sse") return null;
- if (isOverMaxBytes) return content;
- const q = searchQuery.trim();
- if (!q || !showOnlyMatches) return content;
- const lines = splitLines(content);
- const matches = lines.filter((line) => line.includes(q));
+ if (!showOnlyMatches) return null;
+ if (!onlyMatchesQuery) return null;
+ if (shouldOptimizeOnlyMatches) return null;
+
+ const shouldDebounceFallbackSearch =
+ nonSseTextForMode.length > codeDisplayConfig.highlightMaxChars;
+ const query = shouldDebounceFallbackSearch ? debouncedOnlyMatchesQuery : onlyMatchesQuery;
+ if (!query) return null;
+
+ const lines = splitLines(nonSseTextForMode);
+ const matches = lines.filter((line) => line.includes(query));
return matches.length === 0 ? "" : matches.join("\n");
- }, [content, isOverMaxBytes, language, searchQuery, showOnlyMatches]);
+ }, [
+ debouncedOnlyMatchesQuery,
+ codeDisplayConfig.highlightMaxChars,
+ language,
+ nonSseTextForMode,
+ onlyMatchesQuery,
+ showOnlyMatches,
+ shouldOptimizeOnlyMatches,
+ ]);
const highlighterStyle = resolvedTheme === "dark" ? oneDark : oneLight;
- const displayText = lineFilteredText ?? content;
- const contentMaxHeight = isExpanded ? expandedMaxHeight : maxHeight;
const downloadFileName =
fileName ??
(language === "json" ? "content.json" : language === "sse" ? "content.sse" : "content.txt");
+ const resolveTextForAction = (): string => {
+ if (language === "sse") return content;
+
+ const baseText = mode === "pretty" ? resolvedPrettyText : content;
+ if (!showOnlyMatches || !onlyMatchesQuery) return baseText;
+
+ if (!shouldOptimizeOnlyMatches) {
+ return nonSseFilteredText ?? baseText;
+ }
+
+ if (onlyMatchesLineStarts && onlyMatchesMatches) {
+ return buildOnlyMatchesText(baseText, onlyMatchesLineStarts, onlyMatchesMatches);
+ }
+
+ return baseText;
+ };
+
const handleDownload = () => {
- const blob = new Blob([content], {
- type: language === "json" ? "application/json" : "text/plain",
+ const text = resolveTextForAction();
+
+ const isCandidateJson = language === "json" && !(showOnlyMatches && onlyMatchesQuery);
+ let downloadType: "application/json" | "text/plain" = "text/plain";
+
+ // 避免对超大内容在主线程 JSON.parse;小内容可用于更准确决定 MIME 类型。
+ const maxValidateJsonChars = MAX_SYNC_JSON_CHARS;
+ if (isCandidateJson && text.length <= maxValidateJsonChars) {
+ if (safeJsonParse(text).ok) downloadType = "application/json";
+ }
+
+ const blob = new Blob([text], {
+ type: downloadType,
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -230,7 +1171,7 @@ export function CodeDisplay({
const handleCopy = async () => {
try {
- await navigator.clipboard.writeText(content);
+ await navigator.clipboard.writeText(resolveTextForAction());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
@@ -238,6 +1179,30 @@ export function CodeDisplay({
}
};
+ const labels = useMemo(
+ () => ({
+ noMatches: t("codeDisplay.noMatches"),
+ sseEvent: t("codeDisplay.sseEvent"),
+ sseData: t("codeDisplay.sseData"),
+ }),
+ [t]
+ );
+
+ const sseEvents = useMemo(() => {
+ if (language !== "sse") return null;
+ if (mode !== "pretty") return null;
+ if (isHardLimited) return null;
+ return parseSseForCodeDisplay(content);
+ }, [content, isHardLimited, language, mode]);
+
+ const filteredSseEvents = useMemo(() => {
+ if (!sseEvents) return null;
+ const q = searchQuery.trim();
+ if (!q) return sseEvents;
+ const re = new RegExp(escapeRegExp(q), "i");
+ return sseEvents.filter((evt) => re.test(evt.event) || re.test(evt.data));
+ }, [searchQuery, sseEvents]);
+
if (isHardLimited) {
const sizeBytes = contentBytes;
const sizeMB = (sizeBytes / 1_000_000).toFixed(2);
@@ -300,10 +1265,7 @@ export function CodeDisplay({
{
- setExpandedSseRows(new Set());
- setSearchQuery(e.target.value);
- }}
+ onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("codeDisplay.searchPlaceholder")}
className="pl-8 h-9"
/>
@@ -376,6 +1338,106 @@ export function CodeDisplay({
);
+ const renderOnlyMatchesOptimized = () => {
+ const showProgress =
+ onlyMatchesIndexStatus === "loading" || onlyMatchesSearchStatus === "loading";
+ const progress = onlyMatchesSearchProgress ?? onlyMatchesIndexProgress;
+ const percent =
+ progress && progress.total > 0 ? Math.floor((progress.processed / progress.total) * 100) : 0;
+
+ if (showProgress) {
+ return (
+
+
+
+ {t("codeDisplay.searchWorking", { percent })}
+
+
+
+ );
+ }
+
+ if (
+ onlyMatchesIndexStatus === "ready" &&
+ onlyMatchesSearchStatus === "ready" &&
+ onlyMatchesLineStarts &&
+ onlyMatchesMatches
+ ) {
+ if (onlyMatchesMatches.length === 0) {
+ return {t("codeDisplay.noMatches")}
;
+ }
+ return (
+
+ );
+ }
+
+ if (onlyMatchesIndexStatus === "error" || onlyMatchesSearchStatus === "error") {
+ const message = (() => {
+ if (onlyMatchesIndexStatus === "error") {
+ if (onlyMatchesIndexErrorCode === "CANCELED")
+ return t("codeDisplay.search.indexCanceled");
+ if (onlyMatchesIndexErrorCode === "TOO_MANY_LINES") {
+ const lineCount = onlyMatchesIndexErrorLineCount;
+ if (typeof lineCount === "number") {
+ return t("codeDisplay.search.indexTooManyLines", {
+ lineCount,
+ maxLines: codeDisplayConfig.maxLineIndexLines,
+ });
+ }
+ return t("codeDisplay.search.indexTooManyLinesUnknown", {
+ maxLines: codeDisplayConfig.maxLineIndexLines,
+ });
+ }
+ return t("codeDisplay.search.indexFailed");
+ }
+
+ if (onlyMatchesSearchErrorCode === "CANCELED") return t("codeDisplay.search.canceled");
+ return t("codeDisplay.search.failed");
+ })();
+
+ return (
+
+
{message}
+
+
+ );
+ }
+
+ return {t("codeDisplay.noMatches")}
;
+ };
+
return (
-
+ {shouldOptimizeOnlyMatches ? (
+ renderOnlyMatchesOptimized()
+ ) : content.length > codeDisplayConfig.highlightMaxChars &&
+ codeDisplayConfig.largePlainEnabled ? (
+
+ ) : (
+
+
+ {showOnlyMatches && onlyMatchesQuery ? (nonSseFilteredText ?? content) : content}
+
+
+ )}
- {language === "json" ? (
-
-
- {formattedJson}
-
-
- ) : language === "sse" ? (
- {
- const target = e.currentTarget;
- setSseScrollTop(target.scrollTop);
- }}
- >
- {(() => {
- if (!filteredSseEvents) return null;
+ {codeDisplayConfig.virtualHighlightEnabled &&
+ isLargePrettyText &&
+ !shouldOptimizeOnlyMatches &&
+ (codeDisplayConfig.largePlainEnabled || forceLargePrettyPlain) && (
+
+
+
+ {forceLargePrettyPlain && (
+
+ {(() => {
+ const fallback = largePrettyVirtualFallback;
+ if (!fallback) return t("codeDisplay.virtualFallbackToPlain");
+ if (fallback.errorCode === "CANCELED") {
+ return t("codeDisplay.virtual.indexCanceled");
+ }
+ if (fallback.errorCode === "TOO_MANY_LINES") {
+ if (typeof fallback.lineCount === "number") {
+ return t("codeDisplay.virtual.indexTooManyLines", {
+ lineCount: fallback.lineCount,
+ maxLines: codeDisplayConfig.maxLineIndexLines,
+ });
+ }
+ return t("codeDisplay.virtual.indexTooManyLinesUnknown", {
+ maxLines: codeDisplayConfig.maxLineIndexLines,
+ });
+ }
+ return t("codeDisplay.virtual.indexFailed");
+ })()}
+
+ )}
+
+ )}
- if (filteredSseEvents.length === 0) {
- return (
-
- {t("codeDisplay.noMatches")}
-
- );
- }
-
- const useVirtual =
- filteredSseEvents.length > 200 &&
- expandedSseRows.size === 0 &&
- sseViewportHeight > 0;
-
- const estimatedRowHeight = 44;
- const overscan = 12;
- const total = filteredSseEvents.length;
-
- const startIndex = useVirtual
- ? Math.max(0, Math.floor(sseScrollTop / estimatedRowHeight) - overscan)
- : 0;
- const endIndex = useVirtual
- ? Math.min(
- total,
- Math.ceil((sseScrollTop + sseViewportHeight) / estimatedRowHeight) +
- overscan
- )
- : total;
-
- const topPad = useVirtual ? startIndex * estimatedRowHeight : 0;
- const bottomPad = useVirtual ? (total - endIndex) * estimatedRowHeight : 0;
-
- const rows = filteredSseEvents.slice(startIndex, endIndex);
-
- return (
-
- {topPad > 0 &&
}
- {rows.map((evt, localIdx) => {
- const index = startIndex + localIdx;
- const open = expandedSseRows.has(index);
- const dataText =
- typeof evt.data === "string" ? evt.data : stringifyPretty(evt.data);
- const preview =
- dataText.length > 120 ? `${dataText.slice(0, 120)}...` : dataText;
-
- return (
-
-
- {
- e.preventDefault();
- setExpandedSseRows((prev) => {
- const next = new Set(prev);
- if (next.has(index)) {
- next.delete(index);
- } else {
- next.add(index);
- }
- return next;
- });
- }}
- >
-
-
- {index + 1}
-
- {evt.event}
-
- {preview}
-
-
-
-
-
-
- {t("codeDisplay.sseEvent")}
-
-
{evt.event}
-
-
-
- {t("codeDisplay.sseData")}
-
-
- {dataText}
-
-
-
-
-
- );
+ {shouldFormatJsonInWorker &&
+ mode === "pretty" &&
+ (jsonPrettyStatus === "invalid" ||
+ jsonPrettyStatus === "tooLarge" ||
+ jsonPrettyStatus === "canceled" ||
+ jsonPrettyStatus === "error") && (
+
+
+ {jsonPrettyStatus === "canceled"
+ ? t("codeDisplay.prettyCanceled")
+ : jsonPrettyStatus === "invalid"
+ ? t("codeDisplay.prettyInvalidJson")
+ : jsonPrettyStatus === "tooLarge"
+ ? t("codeDisplay.prettyOutputTooLarge")
+ : jsonPrettyErrorCode === "WORKER_UNAVAILABLE"
+ ? t("codeDisplay.prettyWorkerUnavailable")
+ : t("codeDisplay.prettyFailed")}
+
+ {(jsonPrettyStatus === "canceled" || jsonPrettyStatus === "error") && (
+
+ )}
+
+ )}
+
+ {language === "sse" ? (
+
+ ) : shouldFormatJsonInWorker && jsonPrettyStatus === "loading" ? (
+
+
+
+
+
+ {t("codeDisplay.prettyWorking", {
+ percent:
+ jsonPrettyProgress && jsonPrettyProgress.total > 0
+ ? Math.floor(
+ (jsonPrettyProgress.processed / jsonPrettyProgress.total) * 100
+ )
+ : 0,
})}
- {bottomPad > 0 && }
-
- );
- })()}
+
+
+
+
+ {codeDisplayConfig.largePlainEnabled ? (
+
+ ) : (
+
+ )}
+
+ ) : shouldOptimizeOnlyMatches ? (
+ renderOnlyMatchesOptimized()
+ ) : isLargePrettyText &&
+ codeDisplayConfig.virtualHighlightEnabled &&
+ !forceLargePrettyPlain &&
+ largePrettyView === "virtual" ? (
+
{
+ setForceLargePrettyPlain(true);
+ setLargePrettyVirtualFallback(reason ? { errorCode: reason, lineCount } : null);
+ setLargePrettyView("plain");
+ }}
+ />
+ ) : isLargePrettyText &&
+ (codeDisplayConfig.largePlainEnabled || forceLargePrettyPlain) ? (
+
+ ) : isLargePrettyText ? (
+
+
+ {showOnlyMatches && onlyMatchesQuery
+ ? (nonSseFilteredText ?? resolvedPrettyText)
+ : resolvedPrettyText}
+
) : (
- {displayText}
+ {showOnlyMatches && onlyMatchesQuery
+ ? (nonSseFilteredText ?? resolvedPrettyText)
+ : resolvedPrettyText}
)}
- {searchQuery.trim() &&
+ {showOnlyMatches &&
+ onlyMatchesQuery &&
language !== "sse" &&
- showOnlyMatches &&
- (lineFilteredText ?? "") === "" && (
+ !shouldOptimizeOnlyMatches &&
+ (nonSseFilteredText ?? "") === "" && (
{t("codeDisplay.noMatches")}
)}
diff --git a/src/components/ui/code-display.worker.ts b/src/components/ui/code-display.worker.ts
new file mode 100644
index 000000000..3ba869af1
--- /dev/null
+++ b/src/components/ui/code-display.worker.ts
@@ -0,0 +1,963 @@
+///
+
+export {};
+
+type WorkerRequest =
+ | {
+ type: "formatJsonPretty";
+ jobId: number;
+ text: string;
+ indentSize: number;
+ maxOutputBytes: number;
+ }
+ | {
+ type: "stringifyJsonPretty";
+ jobId: number;
+ value: unknown;
+ indentSize: number;
+ maxOutputBytes: number;
+ }
+ | {
+ type: "buildLineIndex";
+ jobId: number;
+ text: string;
+ maxLines: number;
+ }
+ | {
+ type: "searchLines";
+ jobId: number;
+ text: string;
+ query: string;
+ maxResults: number;
+ }
+ | {
+ type: "cancel";
+ jobId: number;
+ };
+
+type WorkerResponse =
+ | {
+ type: "progress";
+ jobId: number;
+ stage: "format" | "index" | "search";
+ processed: number;
+ total: number;
+ }
+ | {
+ type: "formatJsonPrettyResult";
+ jobId: number;
+ ok: true;
+ text: string;
+ usedStreaming: boolean;
+ }
+ | {
+ type: "formatJsonPrettyResult";
+ jobId: number;
+ ok: false;
+ errorCode: "INVALID_JSON" | "CANCELED" | "OUTPUT_TOO_LARGE" | "UNKNOWN";
+ }
+ | {
+ type: "stringifyJsonPrettyResult";
+ jobId: number;
+ ok: true;
+ text: string;
+ }
+ | {
+ type: "stringifyJsonPrettyResult";
+ jobId: number;
+ ok: false;
+ errorCode: "CANCELED" | "OUTPUT_TOO_LARGE" | "UNKNOWN";
+ }
+ | {
+ type: "buildLineIndexResult";
+ jobId: number;
+ ok: true;
+ lineStarts: Int32Array;
+ lineCount: number;
+ }
+ | {
+ type: "buildLineIndexResult";
+ jobId: number;
+ ok: false;
+ errorCode: "CANCELED" | "TOO_MANY_LINES" | "UNKNOWN";
+ lineCount?: number;
+ }
+ | {
+ type: "searchLinesResult";
+ jobId: number;
+ ok: true;
+ matches: Int32Array;
+ }
+ | {
+ type: "searchLinesResult";
+ jobId: number;
+ ok: false;
+ errorCode: "CANCELED" | "UNKNOWN";
+ };
+
+const cancelledJobs = new Set();
+const CANCELLED_JOB_TTL_MS = 60_000;
+const YIELD_MIN_INTERVAL_MS = 50;
+
+function isCancelled(jobId: number): boolean {
+ return cancelledJobs.has(jobId);
+}
+
+async function yieldToEventLoop() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+function estimateUtf16Bytes(textLength: number): number {
+ return textLength * 2;
+}
+
+function post(msg: WorkerResponse, transfer?: Transferable[]) {
+ (self as DedicatedWorkerGlobalScope).postMessage(msg, transfer ?? []);
+}
+
+function safeJsonParse(text: string): { ok: true; value: unknown } | { ok: false } {
+ try {
+ return { ok: true, value: JSON.parse(text) };
+ } catch {
+ return { ok: false };
+ }
+}
+
+function stringifyPretty(value: unknown, indentSize: number): string {
+ return JSON.stringify(value, null, indentSize);
+}
+
+function formatJsonPrettyStreaming({
+ text,
+ jobId,
+ indentSize,
+ maxOutputBytes,
+}: {
+ text: string;
+ jobId: number;
+ indentSize: number;
+ maxOutputBytes: number;
+}):
+ | { ok: true; text: string }
+ | { ok: false; errorCode: "INVALID_JSON" | "CANCELED" | "OUTPUT_TOO_LARGE" } {
+ // 严格流式 pretty printer:不构建对象树,按字符扫描并校验 JSON 语法。
+ // 目标:只要输入是合法 JSON,就能输出完整 pretty;若非法,返回 INVALID_JSON(不做“容错修复”)。
+
+ const chunks: string[] = [];
+ let outLen = 0;
+
+ const indentCache = new Map();
+ const getIndent = (level: number) => {
+ const cached = indentCache.get(level);
+ if (cached) return cached;
+ const str = " ".repeat(level * indentSize);
+ indentCache.set(level, str);
+ return str;
+ };
+
+ const push = (s: string): { ok: true } | { ok: false; errorCode: "OUTPUT_TOO_LARGE" } => {
+ chunks.push(s);
+ outLen += s.length;
+ if (estimateUtf16Bytes(outLen) > maxOutputBytes) {
+ return { ok: false, errorCode: "OUTPUT_TOO_LARGE" };
+ }
+ return { ok: true };
+ };
+
+ const isWhitespaceCharCode = (code: number) =>
+ code === 0x20 || code === 0x0a || code === 0x0d || code === 0x09 || code === 0xfeff;
+
+ const isDigitCharCode = (code: number) => code >= 0x30 && code <= 0x39;
+
+ const isHexDigitCharCode = (code: number) =>
+ (code >= 0x30 && code <= 0x39) ||
+ (code >= 0x41 && code <= 0x46) ||
+ (code >= 0x61 && code <= 0x66);
+
+ type ArrayFrame = {
+ type: "array";
+ state: "valueOrEnd" | "value" | "commaOrEnd";
+ itemCount: number;
+ };
+ type ObjectFrame = {
+ type: "object";
+ state: "keyOrEnd" | "key" | "colon" | "value" | "commaOrEnd";
+ itemCount: number;
+ };
+ type Frame = ArrayFrame | ObjectFrame;
+
+ const stack: Frame[] = [];
+ let rootCompleted = false;
+
+ let i = 0;
+ const total = text.length;
+ let lastProgressAt = 0;
+
+ const maybeReportProgress = () => {
+ const now = performance.now();
+ if (now - lastProgressAt < 200) return;
+ lastProgressAt = now;
+ post({ type: "progress", jobId, stage: "format", processed: i, total });
+ };
+
+ const skipWhitespace = () => {
+ while (i < total) {
+ if (isCancelled(jobId)) return { ok: false as const, errorCode: "CANCELED" as const };
+ const code = text.charCodeAt(i);
+ if (!isWhitespaceCharCode(code)) break;
+ i += 1;
+ if ((i & 8191) === 0) maybeReportProgress();
+ }
+ return { ok: true as const };
+ };
+
+ const parseStringToken = ():
+ | { ok: true; token: string }
+ | { ok: false; errorCode: "INVALID_JSON" | "CANCELED" } => {
+ if (text.charCodeAt(i) !== 0x22) return { ok: false, errorCode: "INVALID_JSON" };
+ const start = i;
+ i += 1; // skip opening quote
+
+ while (i < total) {
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+
+ if ((i & 8191) === 0) maybeReportProgress();
+
+ const code = text.charCodeAt(i);
+ if (code === 0x22) {
+ i += 1;
+ return { ok: true, token: text.slice(start, i) };
+ }
+ if (code === 0x5c) {
+ i += 1;
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ const esc = text.charCodeAt(i);
+ switch (esc) {
+ case 0x22: // "
+ case 0x5c: // \
+ case 0x2f: // /
+ case 0x62: // b
+ case 0x66: // f
+ case 0x6e: // n
+ case 0x72: // r
+ case 0x74: // t
+ i += 1;
+ continue;
+ case 0x75: {
+ // uXXXX
+ if (i + 4 >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ const c1 = text.charCodeAt(i + 1);
+ const c2 = text.charCodeAt(i + 2);
+ const c3 = text.charCodeAt(i + 3);
+ const c4 = text.charCodeAt(i + 4);
+ if (
+ !isHexDigitCharCode(c1) ||
+ !isHexDigitCharCode(c2) ||
+ !isHexDigitCharCode(c3) ||
+ !isHexDigitCharCode(c4)
+ ) {
+ return { ok: false, errorCode: "INVALID_JSON" };
+ }
+ i += 5;
+ continue;
+ }
+ default:
+ return { ok: false, errorCode: "INVALID_JSON" };
+ }
+ }
+ // JSON 字符串不能包含未转义的控制字符
+ if (code < 0x20) return { ok: false, errorCode: "INVALID_JSON" };
+ i += 1;
+ }
+
+ return { ok: false, errorCode: "INVALID_JSON" };
+ };
+
+ const parseNumberToken = ():
+ | { ok: true; token: string }
+ | { ok: false; errorCode: "INVALID_JSON" | "CANCELED" } => {
+ const start = i;
+
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+
+ let code = text.charCodeAt(i);
+ if (code === 0x2d) {
+ i += 1;
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ code = text.charCodeAt(i);
+ }
+
+ if (code === 0x30) {
+ i += 1;
+ if (i < total && isDigitCharCode(text.charCodeAt(i))) {
+ return { ok: false, errorCode: "INVALID_JSON" };
+ }
+ } else if (code >= 0x31 && code <= 0x39) {
+ i += 1;
+ while (i < total && isDigitCharCode(text.charCodeAt(i))) {
+ i += 1;
+ if ((i & 8191) === 0) maybeReportProgress();
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+ }
+ } else {
+ return { ok: false, errorCode: "INVALID_JSON" };
+ }
+
+ if (i < total && text.charCodeAt(i) === 0x2e) {
+ i += 1;
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ if (!isDigitCharCode(text.charCodeAt(i))) return { ok: false, errorCode: "INVALID_JSON" };
+ while (i < total && isDigitCharCode(text.charCodeAt(i))) {
+ i += 1;
+ if ((i & 8191) === 0) maybeReportProgress();
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+ }
+ }
+
+ if (i < total) {
+ const e = text.charCodeAt(i);
+ if (e === 0x65 || e === 0x45) {
+ i += 1;
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ const sign = text.charCodeAt(i);
+ if (sign === 0x2b || sign === 0x2d) {
+ i += 1;
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ }
+ if (!isDigitCharCode(text.charCodeAt(i))) return { ok: false, errorCode: "INVALID_JSON" };
+ while (i < total && isDigitCharCode(text.charCodeAt(i))) {
+ i += 1;
+ if ((i & 8191) === 0) maybeReportProgress();
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+ }
+ }
+ }
+
+ return { ok: true, token: text.slice(start, i) };
+ };
+
+ const parseKeywordToken = ():
+ | { ok: true; token: string }
+ | { ok: false; errorCode: "INVALID_JSON" } => {
+ const code = text.charCodeAt(i);
+ if (code === 0x74) {
+ if (text.slice(i, i + 4) !== "true") return { ok: false, errorCode: "INVALID_JSON" };
+ i += 4;
+ return { ok: true, token: "true" };
+ }
+ if (code === 0x66) {
+ if (text.slice(i, i + 5) !== "false") return { ok: false, errorCode: "INVALID_JSON" };
+ i += 5;
+ return { ok: true, token: "false" };
+ }
+ if (code === 0x6e) {
+ if (text.slice(i, i + 4) !== "null") return { ok: false, errorCode: "INVALID_JSON" };
+ i += 4;
+ return { ok: true, token: "null" };
+ }
+ return { ok: false, errorCode: "INVALID_JSON" };
+ };
+
+ const onValueCompleted = () => {
+ if (stack.length === 0) {
+ rootCompleted = true;
+ return;
+ }
+ const top = stack[stack.length - 1];
+ top.itemCount += 1;
+ top.state = "commaOrEnd";
+ };
+
+ const parseValue = ():
+ | { ok: true }
+ | { ok: false; errorCode: "INVALID_JSON" | "CANCELED" | "OUTPUT_TOO_LARGE" } => {
+ const ws = skipWhitespace();
+ if (!ws.ok) return ws;
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+
+ if ((i & 8191) === 0) maybeReportProgress();
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+
+ const code = text.charCodeAt(i);
+ if (code === 0x7b) {
+ // {
+ const pushed = push("{");
+ if (!pushed.ok) return pushed;
+ i += 1;
+ stack.push({ type: "object", state: "keyOrEnd", itemCount: 0 });
+ return { ok: true };
+ }
+ if (code === 0x5b) {
+ // [
+ const pushed = push("[");
+ if (!pushed.ok) return pushed;
+ i += 1;
+ stack.push({ type: "array", state: "valueOrEnd", itemCount: 0 });
+ return { ok: true };
+ }
+ if (code === 0x22) {
+ const token = parseStringToken();
+ if (!token.ok) return token;
+ const pushed = push(token.token);
+ if (!pushed.ok) return pushed;
+ onValueCompleted();
+ return { ok: true };
+ }
+ if (code === 0x2d || (code >= 0x30 && code <= 0x39)) {
+ const token = parseNumberToken();
+ if (!token.ok) return token;
+ const pushed = push(token.token);
+ if (!pushed.ok) return pushed;
+ onValueCompleted();
+ return { ok: true };
+ }
+ if (code === 0x74 || code === 0x66 || code === 0x6e) {
+ const token = parseKeywordToken();
+ if (!token.ok) return token;
+ const pushed = push(token.token);
+ if (!pushed.ok) return pushed;
+ onValueCompleted();
+ return { ok: true };
+ }
+
+ return { ok: false, errorCode: "INVALID_JSON" };
+ };
+
+ while (true) {
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+ if ((i & 8191) === 0) maybeReportProgress();
+
+ const ws = skipWhitespace();
+ if (!ws.ok) return ws;
+
+ if (stack.length === 0) {
+ if (rootCompleted) {
+ if (i !== total) return { ok: false, errorCode: "INVALID_JSON" };
+ post({ type: "progress", jobId, stage: "format", processed: total, total });
+ return { ok: true, text: chunks.join("") };
+ }
+
+ const v = parseValue();
+ if (!v.ok) return v;
+ continue;
+ }
+
+ const frame = stack[stack.length - 1];
+ if (frame.type === "array") {
+ if (frame.state === "valueOrEnd") {
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ if (text.charCodeAt(i) === 0x5d) {
+ // ]
+ const pushed = push("]");
+ if (!pushed.ok) return pushed;
+ i += 1;
+ stack.pop();
+ onValueCompleted();
+ continue;
+ }
+
+ // first element
+ const pushedNl = push("\n");
+ if (!pushedNl.ok) return pushedNl;
+ const pushedIndent = push(getIndent(stack.length));
+ if (!pushedIndent.ok) return pushedIndent;
+ frame.state = "value";
+ continue;
+ }
+
+ if (frame.state === "value") {
+ const v = parseValue();
+ if (!v.ok) return v;
+ continue;
+ }
+
+ // commaOrEnd
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ const code = text.charCodeAt(i);
+ if (code === 0x2c) {
+ // ,
+ const pushedComma = push(",");
+ if (!pushedComma.ok) return pushedComma;
+ const pushedNl = push("\n");
+ if (!pushedNl.ok) return pushedNl;
+ const pushedIndent = push(getIndent(stack.length));
+ if (!pushedIndent.ok) return pushedIndent;
+ i += 1;
+ frame.state = "value";
+ continue;
+ }
+ if (code === 0x5d) {
+ // ]
+ if (frame.itemCount > 0) {
+ const pushedNl = push("\n");
+ if (!pushedNl.ok) return pushedNl;
+ const pushedIndent = push(getIndent(stack.length - 1));
+ if (!pushedIndent.ok) return pushedIndent;
+ }
+ const pushedClose = push("]");
+ if (!pushedClose.ok) return pushedClose;
+ i += 1;
+ stack.pop();
+ onValueCompleted();
+ continue;
+ }
+ return { ok: false, errorCode: "INVALID_JSON" };
+ }
+
+ // object
+ if (frame.state === "keyOrEnd") {
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ if (text.charCodeAt(i) === 0x7d) {
+ // }
+ const pushed = push("}");
+ if (!pushed.ok) return pushed;
+ i += 1;
+ stack.pop();
+ onValueCompleted();
+ continue;
+ }
+
+ // first key
+ const pushedNl = push("\n");
+ if (!pushedNl.ok) return pushedNl;
+ const pushedIndent = push(getIndent(stack.length));
+ if (!pushedIndent.ok) return pushedIndent;
+ frame.state = "key";
+ continue;
+ }
+
+ if (frame.state === "key") {
+ const ws2 = skipWhitespace();
+ if (!ws2.ok) return ws2;
+ const token = parseStringToken();
+ if (!token.ok) return token;
+ const pushed = push(token.token);
+ if (!pushed.ok) return pushed;
+ frame.state = "colon";
+ continue;
+ }
+
+ if (frame.state === "colon") {
+ const ws2 = skipWhitespace();
+ if (!ws2.ok) return ws2;
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ if (text.charCodeAt(i) !== 0x3a) return { ok: false, errorCode: "INVALID_JSON" };
+ const pushed = push(": ");
+ if (!pushed.ok) return pushed;
+ i += 1;
+ frame.state = "value";
+ continue;
+ }
+
+ if (frame.state === "value") {
+ const v = parseValue();
+ if (!v.ok) return v;
+ continue;
+ }
+
+ // commaOrEnd
+ const ws2 = skipWhitespace();
+ if (!ws2.ok) return ws2;
+ if (i >= total) return { ok: false, errorCode: "INVALID_JSON" };
+ const code = text.charCodeAt(i);
+ if (code === 0x2c) {
+ const pushedComma = push(",");
+ if (!pushedComma.ok) return pushedComma;
+ const pushedNl = push("\n");
+ if (!pushedNl.ok) return pushedNl;
+ const pushedIndent = push(getIndent(stack.length));
+ if (!pushedIndent.ok) return pushedIndent;
+ i += 1;
+ frame.state = "key";
+ continue;
+ }
+ if (code === 0x7d) {
+ if (frame.itemCount > 0) {
+ const pushedNl = push("\n");
+ if (!pushedNl.ok) return pushedNl;
+ const pushedIndent = push(getIndent(stack.length - 1));
+ if (!pushedIndent.ok) return pushedIndent;
+ }
+ const pushedClose = push("}");
+ if (!pushedClose.ok) return pushedClose;
+ i += 1;
+ stack.pop();
+ onValueCompleted();
+ continue;
+ }
+ return { ok: false, errorCode: "INVALID_JSON" };
+ }
+}
+
+async function buildLineIndex({
+ text,
+ jobId,
+ maxLines,
+}: {
+ text: string;
+ jobId: number;
+ maxLines: number;
+}): Promise<
+ | { ok: true; lineStarts: Int32Array; lineCount: number }
+ | { ok: false; errorCode: "CANCELED" | "TOO_MANY_LINES"; lineCount?: number }
+> {
+ const total = text.length;
+
+ const starts: number[] = [0];
+ let lastProgressAt = 0;
+ let lastYieldAt = 0;
+
+ for (let i = 0; i < total; i += 1) {
+ if ((i & 8191) === 0) {
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+
+ const now = performance.now();
+ if (now - lastYieldAt > YIELD_MIN_INTERVAL_MS) {
+ lastYieldAt = now;
+ await yieldToEventLoop();
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+ }
+
+ if (now - lastProgressAt > 200) {
+ lastProgressAt = now;
+ post({ type: "progress", jobId, stage: "index", processed: i, total });
+ }
+ }
+
+ const code = text.charCodeAt(i);
+ if (code === 10) {
+ const nextLineCount = starts.length + 1;
+ if (nextLineCount > maxLines) {
+ post({ type: "progress", jobId, stage: "index", processed: i, total });
+ return { ok: false, errorCode: "TOO_MANY_LINES", lineCount: nextLineCount };
+ }
+ starts.push(i + 1);
+ } else if (code === 13) {
+ const nextLineCount = starts.length + 1;
+ if (nextLineCount > maxLines) {
+ post({ type: "progress", jobId, stage: "index", processed: i, total });
+ return { ok: false, errorCode: "TOO_MANY_LINES", lineCount: nextLineCount };
+ }
+
+ // CRLF 视为一个换行
+ if (i + 1 < total && text.charCodeAt(i + 1) === 10) {
+ starts.push(i + 2);
+ i += 1;
+ } else {
+ starts.push(i + 1);
+ }
+ }
+ }
+
+ const lineCount = starts.length;
+ const lineStarts = new Int32Array(lineCount);
+ for (let i = 0; i < lineCount; i += 1) {
+ lineStarts[i] = starts[i] ?? 0;
+ }
+
+ post({ type: "progress", jobId, stage: "index", processed: total, total });
+ return { ok: true, lineStarts, lineCount };
+}
+
+async function searchLines({
+ text,
+ query,
+ jobId,
+ maxResults,
+}: {
+ text: string;
+ query: string;
+ jobId: number;
+ maxResults: number;
+}): Promise<{ ok: true; matches: Int32Array } | { ok: false; errorCode: "CANCELED" }> {
+ if (!query) return { ok: true, matches: new Int32Array(0) };
+
+ const total = text.length;
+ let lastProgressAt = 0;
+ let lastYieldAt = 0;
+
+ const lines: number[] = [];
+ let lastLine = -1;
+ let scan = 0;
+ let lineNo = 0;
+
+ let pos = text.indexOf(query, 0);
+ while (pos !== -1) {
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+
+ // 更新 lineNo 到 pos 所在行(scan 指针单调前进,整体 O(n))
+ while (scan < pos) {
+ if ((scan & 8191) === 0) {
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+
+ const now = performance.now();
+ if (now - lastYieldAt > YIELD_MIN_INTERVAL_MS) {
+ lastYieldAt = now;
+ await yieldToEventLoop();
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+ }
+ }
+ const code = text.charCodeAt(scan);
+ if (code === 10) {
+ lineNo += 1;
+ scan += 1;
+ continue;
+ }
+ if (code === 13) {
+ lineNo += 1;
+ // CRLF 视为一个换行
+ if (scan + 1 < total && text.charCodeAt(scan + 1) === 10) {
+ scan += 2;
+ } else {
+ scan += 1;
+ }
+ continue;
+ }
+ scan += 1;
+ }
+
+ if (lineNo !== lastLine) {
+ lines.push(lineNo);
+ lastLine = lineNo;
+ if (lines.length >= maxResults) break;
+ }
+
+ pos = text.indexOf(query, pos + 1);
+
+ const now = performance.now();
+ if (now - lastYieldAt > YIELD_MIN_INTERVAL_MS) {
+ lastYieldAt = now;
+ await yieldToEventLoop();
+ if (isCancelled(jobId)) return { ok: false, errorCode: "CANCELED" };
+ }
+ if (now - lastProgressAt > 200) {
+ lastProgressAt = now;
+ post({
+ type: "progress",
+ jobId,
+ stage: "search",
+ processed: Math.min(pos === -1 ? total : pos, total),
+ total,
+ });
+ }
+ }
+
+ post({ type: "progress", jobId, stage: "search", processed: total, total });
+ return { ok: true, matches: Int32Array.from(lines) };
+}
+
+type WorkerJobRequest = Exclude;
+
+const jobQueue: WorkerJobRequest[] = [];
+let isProcessingQueue = false;
+
+function handleCancel(jobId: number) {
+ cancelledJobs.add(jobId);
+ setTimeout(() => cancelledJobs.delete(jobId), CANCELLED_JOB_TTL_MS);
+
+ // best-effort:如果任务还在队列里,直接移除避免无意义计算
+ for (let i = jobQueue.length - 1; i >= 0; i -= 1) {
+ if (jobQueue[i]?.jobId === jobId) {
+ jobQueue.splice(i, 1);
+ }
+ }
+}
+
+async function handleJob(msg: WorkerJobRequest) {
+ const { jobId } = msg;
+
+ if (msg.type === "formatJsonPretty") {
+ // 优先使用流式格式化;若失败可回落为 JSON.parse/stringify(更严格但可能占内存)。
+ const streaming = formatJsonPrettyStreaming({
+ text: msg.text,
+ jobId,
+ indentSize: msg.indentSize,
+ maxOutputBytes: msg.maxOutputBytes,
+ });
+
+ if (streaming.ok) {
+ post({
+ type: "formatJsonPrettyResult",
+ jobId,
+ ok: true,
+ text: streaming.text,
+ usedStreaming: true,
+ });
+ return;
+ }
+
+ if (streaming.errorCode === "CANCELED") {
+ post({ type: "formatJsonPrettyResult", jobId, ok: false, errorCode: "CANCELED" });
+ return;
+ }
+ if (streaming.errorCode === "OUTPUT_TOO_LARGE") {
+ post({
+ type: "formatJsonPrettyResult",
+ jobId,
+ ok: false,
+ errorCode: "OUTPUT_TOO_LARGE",
+ });
+ return;
+ }
+
+ // 回落:严格 JSON.parse(可能较慢/占内存,但能处理部分边界情况)
+ if (isCancelled(jobId)) {
+ post({ type: "formatJsonPrettyResult", jobId, ok: false, errorCode: "CANCELED" });
+ return;
+ }
+ const parsed = safeJsonParse(msg.text);
+ if (isCancelled(jobId)) {
+ post({ type: "formatJsonPrettyResult", jobId, ok: false, errorCode: "CANCELED" });
+ return;
+ }
+ if (!parsed.ok) {
+ post({ type: "formatJsonPrettyResult", jobId, ok: false, errorCode: "INVALID_JSON" });
+ return;
+ }
+
+ if (isCancelled(jobId)) {
+ post({ type: "formatJsonPrettyResult", jobId, ok: false, errorCode: "CANCELED" });
+ return;
+ }
+ const text = stringifyPretty(parsed.value, msg.indentSize);
+ if (isCancelled(jobId)) {
+ post({ type: "formatJsonPrettyResult", jobId, ok: false, errorCode: "CANCELED" });
+ return;
+ }
+ if (estimateUtf16Bytes(text.length) > msg.maxOutputBytes) {
+ post({
+ type: "formatJsonPrettyResult",
+ jobId,
+ ok: false,
+ errorCode: "OUTPUT_TOO_LARGE",
+ });
+ return;
+ }
+
+ if (isCancelled(jobId)) {
+ post({ type: "formatJsonPrettyResult", jobId, ok: false, errorCode: "CANCELED" });
+ return;
+ }
+ post({ type: "formatJsonPrettyResult", jobId, ok: true, text, usedStreaming: false });
+ return;
+ }
+
+ if (msg.type === "stringifyJsonPretty") {
+ if (isCancelled(jobId)) {
+ post({ type: "stringifyJsonPrettyResult", jobId, ok: false, errorCode: "CANCELED" });
+ return;
+ }
+
+ const text = stringifyPretty(msg.value, msg.indentSize);
+ if (isCancelled(jobId)) {
+ post({ type: "stringifyJsonPrettyResult", jobId, ok: false, errorCode: "CANCELED" });
+ return;
+ }
+ if (estimateUtf16Bytes(text.length) > msg.maxOutputBytes) {
+ post({
+ type: "stringifyJsonPrettyResult",
+ jobId,
+ ok: false,
+ errorCode: "OUTPUT_TOO_LARGE",
+ });
+ return;
+ }
+
+ post({ type: "stringifyJsonPrettyResult", jobId, ok: true, text });
+ return;
+ }
+
+ if (msg.type === "buildLineIndex") {
+ const result = await buildLineIndex({ text: msg.text, jobId, maxLines: msg.maxLines });
+
+ if (!result.ok) {
+ post({
+ type: "buildLineIndexResult",
+ jobId,
+ ok: false,
+ errorCode: result.errorCode,
+ lineCount: result.lineCount,
+ });
+ return;
+ }
+
+ post(
+ {
+ type: "buildLineIndexResult",
+ jobId,
+ ok: true,
+ lineStarts: result.lineStarts,
+ lineCount: result.lineCount,
+ },
+ [result.lineStarts.buffer]
+ );
+ return;
+ }
+
+ if (msg.type === "searchLines") {
+ const result = await searchLines({
+ text: msg.text,
+ query: msg.query,
+ jobId,
+ maxResults: msg.maxResults,
+ });
+
+ if (!result.ok) {
+ post({ type: "searchLinesResult", jobId, ok: false, errorCode: result.errorCode });
+ return;
+ }
+
+ post({ type: "searchLinesResult", jobId, ok: true, matches: result.matches }, [
+ result.matches.buffer,
+ ]);
+ return;
+ }
+}
+
+async function processQueue() {
+ while (jobQueue.length > 0) {
+ const msg = jobQueue.shift();
+ if (!msg) continue;
+
+ const { jobId } = msg;
+ try {
+ await handleJob(msg);
+ } catch {
+ // 最后兜底
+ if (msg.type === "formatJsonPretty") {
+ post({ type: "formatJsonPrettyResult", jobId, ok: false, errorCode: "UNKNOWN" });
+ continue;
+ }
+ if (msg.type === "stringifyJsonPretty") {
+ post({ type: "stringifyJsonPrettyResult", jobId, ok: false, errorCode: "UNKNOWN" });
+ continue;
+ }
+ if (msg.type === "buildLineIndex") {
+ post({ type: "buildLineIndexResult", jobId, ok: false, errorCode: "UNKNOWN" });
+ continue;
+ }
+ if (msg.type === "searchLines") {
+ post({ type: "searchLinesResult", jobId, ok: false, errorCode: "UNKNOWN" });
+ }
+ } finally {
+ cancelledJobs.delete(jobId);
+ }
+ }
+
+ isProcessingQueue = false;
+}
+
+(self as DedicatedWorkerGlobalScope).onmessage = (e: MessageEvent) => {
+ const msg = e.data;
+
+ if (msg.type === "cancel") {
+ handleCancel(msg.jobId);
+ return;
+ }
+
+ jobQueue.push(msg);
+ if (isProcessingQueue) return;
+
+ isProcessingQueue = true;
+ void processQueue();
+};
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts
index bc25ec410..fa2806b5d 100644
--- a/src/lib/utils/index.ts
+++ b/src/lib/utils/index.ts
@@ -22,6 +22,8 @@ export {
// SSE 处理
export { parseSSEData } from "./sse";
+// 大文本 key(用于缓存/依赖触发)
+export { getTextKey } from "./text-key";
export { formatTokenAmount } from "./token";
// 验证和格式化
export {
diff --git a/src/lib/utils/text-key.ts b/src/lib/utils/text-key.ts
new file mode 100644
index 000000000..cf1883cb2
--- /dev/null
+++ b/src/lib/utils/text-key.ts
@@ -0,0 +1,71 @@
+/**
+ * 为大文本生成轻量级 key。
+ *
+ * 用途:
+ * - 避免在依赖数组中直接放入超大字符串
+ * - 用于缓存命中(例如 lineStarts / prettyText)
+ *
+ * 注意:
+ * - 对较小文本:使用 FNV-1a 全量哈希,尽量避免碰撞
+ * - 对超大文本:使用固定多窗口 + 全局步进采样,避免在主线程做 O(n) 扫描导致卡顿,同时尽量降低碰撞概率
+ *
+ * FULL_HASH_MAX_CHARS 是一个性能阈值:长度刚好跨过该值时,会从“全量哈希”切换为“采样哈希”。
+ * 这是有意为之,用于避免极端大文本触发主线程卡顿。
+ */
+export function getTextKey(text: string): string {
+ const len = text.length;
+ if (len === 0) return "0:0";
+
+ const FULL_HASH_MAX_CHARS = 200_000;
+ const WINDOW_CHARS = 4096;
+
+ let hashA = 2166136261;
+ let hashB = 2166136261 ^ len;
+
+ const update = (code: number) => {
+ hashA ^= code;
+ hashA = Math.imul(hashA, 16777619);
+
+ hashB ^= code;
+ hashB = Math.imul(hashB, 2246822507);
+ };
+
+ if (len <= FULL_HASH_MAX_CHARS) {
+ for (let i = 0; i < len; i += 1) {
+ update(text.charCodeAt(i));
+ }
+ return `${len}:${(hashA >>> 0).toString(36)}:${(hashB >>> 0).toString(36)}`;
+ }
+
+ const windowHalf = WINDOW_CHARS >> 1;
+ const pushWindow = (start: number) => {
+ const end = Math.min(len, start + WINDOW_CHARS);
+ for (let i = start; i < end; i += 1) {
+ update(text.charCodeAt(i));
+ }
+ return end;
+ };
+
+ let cursor = 0;
+ cursor = pushWindow(0);
+ cursor = pushWindow(Math.max(cursor, Math.floor(len * 0.25) - windowHalf));
+ cursor = pushWindow(Math.max(cursor, Math.floor(len * 0.5) - windowHalf));
+ cursor = pushWindow(Math.max(cursor, Math.floor(len * 0.75) - windowHalf));
+ pushWindow(Math.max(cursor, len - WINDOW_CHARS));
+
+ // 全局步进采样:让 hash 覆盖整个文本范围,降低“只修改未覆盖窗口区域”时的碰撞概率。
+ const STRIDE_SAMPLES = 16_384;
+ const step = Math.max(1, Math.floor(len / STRIDE_SAMPLES));
+
+ for (let i = 0; i < len; i += step) {
+ update(text.charCodeAt(i));
+ }
+ if (step > 1) {
+ const offset = step >> 1;
+ for (let i = offset; i < len; i += step) {
+ update(text.charCodeAt(i));
+ }
+ }
+
+ return `${len}:${(hashA >>> 0).toString(36)}:${(hashB >>> 0).toString(36)}`;
+}
diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts
index 5b7204e8a..d64b36ee1 100644
--- a/tests/integration/usage-ledger.test.ts
+++ b/tests/integration/usage-ledger.test.ts
@@ -278,47 +278,45 @@ run("usage ledger integration", () => {
});
describe("backfill", () => {
- test(
- "backfill copies non-warmup message_request rows when ledger rows are missing",
- { timeout: 60_000 },
- async () => {
- const userId = nextUserId();
- const providerId = nextProviderId();
- const keepA = await insertMessageRequestRow({
- key: nextKey("backfill-a"),
- userId,
- providerId,
- costUsd: "1.100000000000000",
- });
- const keepB = await insertMessageRequestRow({
- key: nextKey("backfill-b"),
- userId,
- providerId,
- costUsd: "2.200000000000000",
- });
- const warmup = await insertMessageRequestRow({
- key: nextKey("backfill-warmup"),
- userId,
- providerId,
- blockedBy: "warmup",
- });
-
- await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup]));
-
- const summary = await backfillUsageLedger();
- expect(summary.totalProcessed).toBeGreaterThanOrEqual(2);
-
- const rows = await db
- .select({ requestId: usageLedger.requestId })
- .from(usageLedger)
- .where(inArray(usageLedger.requestId, [keepA, keepB, warmup]));
- const requestIds = rows.map((row) => row.requestId);
-
- expect(requestIds).toContain(keepA);
- expect(requestIds).toContain(keepB);
- expect(requestIds).not.toContain(warmup);
- }
- );
+ test("backfill copies non-warmup message_request rows when ledger rows are missing", {
+ timeout: 60_000,
+ }, async () => {
+ const userId = nextUserId();
+ const providerId = nextProviderId();
+ const keepA = await insertMessageRequestRow({
+ key: nextKey("backfill-a"),
+ userId,
+ providerId,
+ costUsd: "1.100000000000000",
+ });
+ const keepB = await insertMessageRequestRow({
+ key: nextKey("backfill-b"),
+ userId,
+ providerId,
+ costUsd: "2.200000000000000",
+ });
+ const warmup = await insertMessageRequestRow({
+ key: nextKey("backfill-warmup"),
+ userId,
+ providerId,
+ blockedBy: "warmup",
+ });
+
+ await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup]));
+
+ const summary = await backfillUsageLedger();
+ expect(summary.totalProcessed).toBeGreaterThanOrEqual(2);
+
+ const rows = await db
+ .select({ requestId: usageLedger.requestId })
+ .from(usageLedger)
+ .where(inArray(usageLedger.requestId, [keepA, keepB, warmup]));
+ const requestIds = rows.map((row) => row.requestId);
+
+ expect(requestIds).toContain(keepA);
+ expect(requestIds).toContain(keepB);
+ expect(requestIds).not.toContain(warmup);
+ });
test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => {
const requestId = await insertMessageRequestRow({
diff --git a/tests/unit/code-display-config.test.ts b/tests/unit/code-display-config.test.ts
new file mode 100644
index 000000000..457ca4531
--- /dev/null
+++ b/tests/unit/code-display-config.test.ts
@@ -0,0 +1,60 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, test } from "vitest";
+import {
+ DEFAULT_CODE_DISPLAY_CONFIG,
+ parseCodeDisplayConfigFromEnv,
+} from "@/components/ui/code-display-config";
+
+describe("parseCodeDisplayConfigFromEnv", () => {
+ test("uses defaults when env is empty", () => {
+ const cfg = parseCodeDisplayConfigFromEnv({});
+ expect(cfg).toEqual(DEFAULT_CODE_DISPLAY_CONFIG);
+ });
+
+ test("parses boolean env values with common aliases", () => {
+ const cfg = parseCodeDisplayConfigFromEnv({
+ CCH_CODEDISPLAY_LARGE_PLAIN: "0",
+ CCH_CODEDISPLAY_VIRTUAL_HIGHLIGHT: "yes",
+ CCH_CODEDISPLAY_WORKER_ENABLE: "off",
+ CCH_CODEDISPLAY_PERF_DEBUG: "1",
+ });
+
+ expect(cfg.largePlainEnabled).toBe(false);
+ expect(cfg.virtualHighlightEnabled).toBe(true);
+ expect(cfg.workerEnabled).toBe(false);
+ expect(cfg.perfDebugEnabled).toBe(true);
+ });
+
+ test("clamps numeric env values to safe ranges", () => {
+ const cfg = parseCodeDisplayConfigFromEnv({
+ CCH_CODEDISPLAY_HIGHLIGHT_MAX_CHARS: "10", // min 1000
+ CCH_CODEDISPLAY_VIRTUAL_OVERSCAN_LINES: "-1", // min 0
+ CCH_CODEDISPLAY_VIRTUAL_CONTEXT_LINES: "999999", // max 5000
+ CCH_CODEDISPLAY_VIRTUAL_LINE_HEIGHT_PX: "200", // max 64
+ CCH_CODEDISPLAY_MAX_PRETTY_OUTPUT_BYTES: "123", // min 1_000_000
+ CCH_CODEDISPLAY_MAX_LINE_INDEX_LINES: "1", // min 10_000
+ });
+
+ expect(cfg.highlightMaxChars).toBe(1000);
+ expect(cfg.virtualOverscanLines).toBe(0);
+ expect(cfg.virtualContextLines).toBe(5000);
+ expect(cfg.virtualLineHeightPx).toBe(64);
+ expect(cfg.maxPrettyOutputBytes).toBe(1_000_000);
+ expect(cfg.maxLineIndexLines).toBe(10_000);
+ });
+
+ test("clamps numeric env values to upper bounds", () => {
+ const cfg = parseCodeDisplayConfigFromEnv({
+ CCH_CODEDISPLAY_HIGHLIGHT_MAX_CHARS: "99999999", // max 5_000_000
+ CCH_CODEDISPLAY_MAX_PRETTY_OUTPUT_BYTES: "9999999999", // max 200_000_000
+ CCH_CODEDISPLAY_MAX_LINE_INDEX_LINES: "99999999", // max 2_000_000
+ });
+
+ expect(cfg.highlightMaxChars).toBe(5_000_000);
+ expect(cfg.maxPrettyOutputBytes).toBe(200_000_000);
+ expect(cfg.maxLineIndexLines).toBe(2_000_000);
+ });
+});
diff --git a/tests/unit/code-display-perf-ui.test.tsx b/tests/unit/code-display-perf-ui.test.tsx
new file mode 100644
index 000000000..afb7b2582
--- /dev/null
+++ b/tests/unit/code-display-perf-ui.test.tsx
@@ -0,0 +1,685 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { CodeDisplay } from "@/components/ui/code-display";
+import type { CodeDisplayConfig } from "@/components/ui/code-display-config";
+import { CodeDisplayConfigProvider } from "@/components/ui/code-display-config-context";
+import { CodeDisplayMatchesList } from "@/components/ui/code-display-matches-list";
+
+const workerClientMocks = vi.hoisted(() => ({
+ buildLineIndex: vi.fn(),
+ searchLines: vi.fn(),
+ formatJsonPretty: vi.fn(),
+}));
+
+vi.mock("@/components/ui/code-display-worker-client", () => ({
+ buildLineIndex: workerClientMocks.buildLineIndex,
+ searchLines: workerClientMocks.searchLines,
+ formatJsonPretty: workerClientMocks.formatJsonPretty,
+}));
+
+vi.mock("@/lib/hooks/use-debounce", () => ({
+ useDebounce: (value: T) => value,
+}));
+
+const dashboardMessages = JSON.parse(
+ fs.readFileSync(
+ (() => {
+ try {
+ const dir = path.dirname(fileURLToPath(import.meta.url));
+ return path.resolve(dir, "../../messages/en/dashboard.json");
+ } catch {
+ try {
+ const u = new URL(import.meta.url);
+ const marker = "/@fs/";
+ const idx = u.pathname.indexOf(marker);
+ if (idx !== -1) {
+ const absPath = decodeURIComponent(u.pathname.slice(idx + marker.length));
+ return path.resolve(path.dirname(absPath), "../../messages/en/dashboard.json");
+ }
+ } catch {
+ // ignore
+ }
+
+ return path.join(process.cwd(), "messages/en/dashboard.json");
+ }
+ })(),
+ "utf8"
+ )
+);
+const codeDisplayMessages = dashboardMessages.sessions.codeDisplay as {
+ showAll: string;
+ search: { failed: string; indexTooManyLines: string };
+ virtual: { indexTooManyLines: string };
+};
+const tooManyLinesPrefix = codeDisplayMessages.search.indexTooManyLines.split("{")[0];
+const virtualTooManyLinesPrefix = codeDisplayMessages.virtual.indexTooManyLines.split("{")[0];
+
+function renderWithIntl(node: ReactNode, codeDisplayConfig: CodeDisplayConfig) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(
+
+ {node}
+
+ );
+ });
+
+ return {
+ container,
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+async function flushMicrotasks() {
+ await act(async () => {
+ await Promise.resolve();
+ });
+}
+
+async function waitFor(predicate: () => boolean, timeoutMs = 2000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ if (predicate()) return;
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ });
+ }
+ throw new Error("Timeout waiting for condition");
+}
+
+function click(el: Element) {
+ act(() => {
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
+ });
+}
+
+function inputText(el: HTMLInputElement, value: string) {
+ const prototype = Object.getPrototypeOf(el) as HTMLInputElement;
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
+ act(() => {
+ descriptor?.set?.call(el, value);
+ el.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
+ el.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
+ });
+}
+
+function buildLineStarts(text: string): Int32Array {
+ const starts: number[] = [0];
+ for (let i = 0; i < text.length; i += 1) {
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
+ }
+ return Int32Array.from(starts);
+}
+
+function makeConfig(partial: Partial): CodeDisplayConfig {
+ return {
+ largePlainEnabled: true,
+ virtualHighlightEnabled: false,
+ workerEnabled: true,
+ perfDebugEnabled: false,
+ highlightMaxChars: 30_000,
+ virtualOverscanLines: 50,
+ virtualLineHeightPx: 18,
+ virtualContextLines: 50,
+ maxPrettyOutputBytes: 20_000_000,
+ maxLineIndexLines: 200_000,
+ ...partial,
+ };
+}
+
+beforeEach(() => {
+ workerClientMocks.buildLineIndex.mockReset();
+ workerClientMocks.searchLines.mockReset();
+ workerClientMocks.formatJsonPretty.mockReset();
+
+ workerClientMocks.buildLineIndex.mockResolvedValue({
+ ok: true,
+ lineStarts: new Int32Array([0]),
+ lineCount: 1,
+ });
+ workerClientMocks.searchLines.mockResolvedValue({ ok: true, matches: new Int32Array(0) });
+ workerClientMocks.formatJsonPretty.mockResolvedValue({
+ ok: false,
+ errorCode: "UNKNOWN",
+ });
+});
+
+describe("CodeDisplay - large content performance strategy", () => {
+ test("large JSON pretty defaults to plain textarea when enabled (scheme1)", async () => {
+ const obj = { a: Array.from({ length: 30 }, (_, i) => i) };
+ const raw = JSON.stringify(obj);
+ const pretty = JSON.stringify(obj, null, 2);
+ const highlightMaxChars = Math.floor((raw.length + pretty.length) / 2);
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars,
+ largePlainEnabled: true,
+ virtualHighlightEnabled: false,
+ })
+ );
+
+ await flushMicrotasks();
+
+ const textarea = container.querySelector("textarea");
+ expect(textarea).not.toBeNull();
+ expect((textarea as HTMLTextAreaElement).value).toBe(pretty);
+ expect(
+ container.querySelector('[data-testid="code-display-large-pretty-view-virtual"]')
+ ).toBeNull();
+ expect(container.querySelector('[data-testid="code-display-virtual-highlighter"]')).toBeNull();
+
+ unmount();
+ });
+
+ test("worker pretty can cancel and retry without getting stuck", async () => {
+ const raw = JSON.stringify({ a: "x".repeat(200) });
+
+ let callNo = 0;
+ workerClientMocks.formatJsonPretty.mockImplementation(async ({ signal }) => {
+ callNo += 1;
+
+ if (callNo === 1) {
+ return await new Promise((resolve) => {
+ signal?.addEventListener("abort", () => resolve({ ok: false, errorCode: "CANCELED" }));
+ });
+ }
+
+ return await new Promise((resolve) => {
+ setTimeout(() => resolve({ ok: true, text: '{"a":1}', usedStreaming: false }), 50);
+ });
+ });
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars: 10,
+ largePlainEnabled: false,
+ virtualHighlightEnabled: false,
+ workerEnabled: true,
+ })
+ );
+
+ await waitFor(
+ () => container.querySelector('[data-testid="code-display-json-pretty-cancel"]') !== null
+ );
+ expect(workerClientMocks.formatJsonPretty).toHaveBeenCalledTimes(1);
+
+ click(container.querySelector('[data-testid="code-display-json-pretty-cancel"]') as Element);
+
+ await waitFor(
+ () => container.querySelector('[data-testid="code-display-json-pretty-retry"]') !== null
+ );
+
+ click(container.querySelector('[data-testid="code-display-json-pretty-retry"]') as Element);
+
+ await waitFor(() => workerClientMocks.formatJsonPretty.mock.calls.length === 2);
+ await waitFor(
+ () => container.querySelector('[data-testid="code-display-json-pretty-cancel"]') === null
+ );
+
+ unmount();
+ });
+
+ test("worker pretty does not loop when scheme1 is disabled and output is still large", async () => {
+ const obj = { a: Array.from({ length: 200 }, (_, i) => i) };
+ const raw = JSON.stringify(obj);
+ const pretty = JSON.stringify(obj, null, 2);
+
+ workerClientMocks.formatJsonPretty.mockResolvedValue({
+ ok: true,
+ text: pretty,
+ usedStreaming: false,
+ });
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars: 10,
+ largePlainEnabled: false,
+ virtualHighlightEnabled: false,
+ workerEnabled: true,
+ })
+ );
+
+ await waitFor(() => workerClientMocks.formatJsonPretty.mock.calls.length === 1);
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ });
+ expect(workerClientMocks.formatJsonPretty).toHaveBeenCalledTimes(1);
+
+ expect(container.querySelector("textarea")).toBeNull();
+ expect(container.querySelector("pre.whitespace-pre-wrap.break-words.font-mono")).not.toBeNull();
+
+ unmount();
+ });
+
+ test("large pretty never uses SyntaxHighlighter above highlightMaxChars (falls back to )", async () => {
+ const content = "x".repeat(200);
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars: 10,
+ largePlainEnabled: false,
+ virtualHighlightEnabled: false,
+ })
+ );
+
+ await flushMicrotasks();
+
+ const prettyTab = container.querySelector('[data-testid="code-display-mode-pretty"]');
+ expect(prettyTab).not.toBeNull();
+ click(prettyTab as Element);
+
+ await flushMicrotasks();
+
+ expect(container.querySelector("textarea")).toBeNull();
+ expect(container.querySelector('[data-testid="code-display-virtual-highlighter"]')).toBeNull();
+
+ const pre = container.querySelector("pre.whitespace-pre-wrap.break-words.font-mono");
+ expect(pre).not.toBeNull();
+ expect((pre as HTMLElement).textContent).toContain(content.slice(0, 50));
+
+ unmount();
+ });
+
+ test("when virtual highlight is enabled, can switch from plain to virtual view (scheme3)", async () => {
+ const obj = { a: Array.from({ length: 30 }, (_, i) => i) };
+ const raw = JSON.stringify(obj);
+ const pretty = JSON.stringify(obj, null, 2);
+ const highlightMaxChars = Math.floor((raw.length + pretty.length) / 2);
+
+ let resolveIndex:
+ | ((v: { ok: true; lineStarts: Int32Array; lineCount: number }) => void)
+ | undefined;
+ workerClientMocks.buildLineIndex.mockImplementation(
+ async () =>
+ await new Promise<{ ok: true; lineStarts: Int32Array; lineCount: number }>((resolve) => {
+ resolveIndex = resolve;
+ })
+ );
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars,
+ largePlainEnabled: true,
+ virtualHighlightEnabled: true,
+ workerEnabled: true,
+ })
+ );
+
+ await flushMicrotasks();
+
+ const toggle = container.querySelector(
+ '[data-testid="code-display-large-pretty-view-virtual"]'
+ );
+ expect(toggle).not.toBeNull();
+
+ click(toggle as Element);
+
+ expect(workerClientMocks.buildLineIndex).toHaveBeenCalledTimes(1);
+ const firstCallArgs = workerClientMocks.buildLineIndex.mock.calls[0]?.[0] as { text: string };
+ expect(firstCallArgs.text).toBe(pretty);
+
+ await waitFor(
+ () => container.querySelector('[data-testid="code-display-virtual-highlighter"]') !== null
+ );
+ expect(container.querySelector("textarea")).toBeNull();
+
+ resolveIndex?.({
+ ok: true,
+ lineStarts: buildLineStarts(pretty),
+ lineCount: pretty.split("\n").length,
+ });
+ await flushMicrotasks();
+
+ expect(
+ container.querySelector('[data-testid="code-display-virtual-highlighter"]')
+ ).not.toBeNull();
+ expect(container.textContent).toContain('"a"');
+
+ unmount();
+ });
+
+ test("large only-matches uses worker index+search and renders matches list", async () => {
+ const content = ["alpha", "beta", "alpha gamma", "delta"].join("\n");
+ const starts = buildLineStarts(content);
+
+ workerClientMocks.buildLineIndex.mockResolvedValue({
+ ok: true,
+ lineStarts: starts,
+ lineCount: starts.length,
+ });
+ workerClientMocks.searchLines.mockResolvedValue({
+ ok: true,
+ matches: Int32Array.from([0, 2]),
+ });
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars: 10,
+ workerEnabled: true,
+ largePlainEnabled: true,
+ })
+ );
+
+ await flushMicrotasks();
+
+ const onlyMatchesToggle = container.querySelector(
+ '[data-testid="code-display-only-matches-toggle"]'
+ );
+ expect(onlyMatchesToggle).not.toBeNull();
+ click(onlyMatchesToggle as Element);
+
+ await waitFor(() => {
+ const btn = container.querySelector('[data-testid="code-display-only-matches-toggle"]');
+ return (btn?.textContent || "").includes(codeDisplayMessages.showAll);
+ });
+
+ const searchInput = container.querySelector(
+ '[data-testid="code-display-search"]'
+ ) as HTMLInputElement | null;
+ expect(searchInput).not.toBeNull();
+ inputText(searchInput as HTMLInputElement, "alpha");
+
+ await flushMicrotasks();
+ expect((searchInput as HTMLInputElement).value).toBe("alpha");
+
+ await waitFor(() => workerClientMocks.buildLineIndex.mock.calls.length > 0);
+ await waitFor(() => workerClientMocks.searchLines.mock.calls.length > 0);
+ await waitFor(
+ () => container.querySelector('[data-testid="code-display-matches-list"]') !== null
+ );
+
+ expect(workerClientMocks.buildLineIndex).toHaveBeenCalled();
+ expect(workerClientMocks.searchLines).toHaveBeenCalled();
+
+ const list = container.querySelector('[data-testid="code-display-matches-list"]');
+ expect(list).not.toBeNull();
+ expect((list as HTMLElement).textContent).toContain("alpha gamma");
+ expect((list as HTMLElement).textContent).toContain("alpha");
+
+ unmount();
+ });
+
+ test("large only-matches still works when worker is disabled (uses no-worker path)", async () => {
+ const content = ["alpha", "beta", "alpha gamma", "delta"].join("\n");
+ const starts = buildLineStarts(content);
+
+ workerClientMocks.buildLineIndex.mockResolvedValue({
+ ok: true,
+ lineStarts: starts,
+ lineCount: starts.length,
+ });
+ workerClientMocks.searchLines.mockResolvedValue({
+ ok: true,
+ matches: Int32Array.from([0, 2]),
+ });
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars: 10,
+ workerEnabled: false,
+ largePlainEnabled: true,
+ })
+ );
+
+ await flushMicrotasks();
+
+ const onlyMatchesToggle = container.querySelector(
+ '[data-testid="code-display-only-matches-toggle"]'
+ );
+ expect(onlyMatchesToggle).not.toBeNull();
+ click(onlyMatchesToggle as Element);
+
+ await waitFor(() => {
+ const btn = container.querySelector('[data-testid="code-display-only-matches-toggle"]');
+ return (btn?.textContent || "").includes(codeDisplayMessages.showAll);
+ });
+
+ const searchInput = container.querySelector(
+ '[data-testid="code-display-search"]'
+ ) as HTMLInputElement | null;
+ expect(searchInput).not.toBeNull();
+ inputText(searchInput as HTMLInputElement, "alpha");
+
+ await flushMicrotasks();
+ await waitFor(() => workerClientMocks.buildLineIndex.mock.calls.length > 0);
+ await waitFor(() => workerClientMocks.searchLines.mock.calls.length > 0);
+ await waitFor(
+ () => container.querySelector('[data-testid="code-display-matches-list"]') !== null
+ );
+
+ const buildArgs = workerClientMocks.buildLineIndex.mock.calls[0]?.[0] as {
+ workerEnabled?: boolean;
+ };
+ expect(buildArgs.workerEnabled).toBe(false);
+
+ const searchArgs = workerClientMocks.searchLines.mock.calls[0]?.[0] as {
+ workerEnabled?: boolean;
+ };
+ expect(searchArgs.workerEnabled).toBe(false);
+
+ const list = container.querySelector('[data-testid="code-display-matches-list"]');
+ expect(list).not.toBeNull();
+ expect((list as HTMLElement).textContent).toContain("alpha gamma");
+
+ unmount();
+ });
+
+ test("only-matches shows index error message when line index build fails", async () => {
+ const content = ["alpha", "beta", "alpha gamma", "delta"].join("\n");
+
+ workerClientMocks.buildLineIndex.mockResolvedValue({
+ ok: false,
+ errorCode: "TOO_MANY_LINES",
+ lineCount: 300_000,
+ });
+ workerClientMocks.searchLines.mockResolvedValue({
+ ok: true,
+ matches: Int32Array.from([0, 2]),
+ });
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars: 10,
+ workerEnabled: true,
+ largePlainEnabled: true,
+ })
+ );
+
+ await flushMicrotasks();
+
+ const onlyMatchesToggle = container.querySelector(
+ '[data-testid="code-display-only-matches-toggle"]'
+ );
+ expect(onlyMatchesToggle).not.toBeNull();
+ click(onlyMatchesToggle as Element);
+
+ await waitFor(() => {
+ const btn = container.querySelector('[data-testid="code-display-only-matches-toggle"]');
+ return (btn?.textContent || "").includes(codeDisplayMessages.showAll);
+ });
+
+ const searchInput = container.querySelector(
+ '[data-testid="code-display-search"]'
+ ) as HTMLInputElement | null;
+ expect(searchInput).not.toBeNull();
+ inputText(searchInput as HTMLInputElement, "alpha");
+
+ await flushMicrotasks();
+ await waitFor(() => workerClientMocks.buildLineIndex.mock.calls.length > 0);
+ await waitFor(() => (container.textContent || "").includes(tooManyLinesPrefix));
+ expect(workerClientMocks.searchLines).not.toHaveBeenCalled();
+
+ expect(container.querySelector('[data-testid="code-display-matches-list"]')).toBeNull();
+
+ unmount();
+ });
+
+ test("only-matches shows search error message when worker search fails", async () => {
+ const content = ["alpha", "beta", "alpha gamma", "delta"].join("\n");
+ const starts = buildLineStarts(content);
+
+ workerClientMocks.buildLineIndex.mockResolvedValue({
+ ok: true,
+ lineStarts: starts,
+ lineCount: starts.length,
+ });
+ workerClientMocks.searchLines.mockResolvedValue({
+ ok: false,
+ errorCode: "UNKNOWN",
+ });
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars: 10,
+ workerEnabled: true,
+ largePlainEnabled: true,
+ })
+ );
+
+ await flushMicrotasks();
+
+ const onlyMatchesToggle = container.querySelector(
+ '[data-testid="code-display-only-matches-toggle"]'
+ );
+ expect(onlyMatchesToggle).not.toBeNull();
+ click(onlyMatchesToggle as Element);
+
+ await waitFor(() => {
+ const btn = container.querySelector('[data-testid="code-display-only-matches-toggle"]');
+ return (btn?.textContent || "").includes(codeDisplayMessages.showAll);
+ });
+
+ const searchInput = container.querySelector(
+ '[data-testid="code-display-search"]'
+ ) as HTMLInputElement | null;
+ expect(searchInput).not.toBeNull();
+ inputText(searchInput as HTMLInputElement, "alpha");
+
+ await flushMicrotasks();
+ await waitFor(() => workerClientMocks.searchLines.mock.calls.length > 0);
+ await waitFor(() => (container.textContent || "").includes(codeDisplayMessages.search.failed));
+
+ expect(container.querySelector('[data-testid="code-display-matches-list"]')).toBeNull();
+
+ unmount();
+ });
+
+ test("when line index fails, fallback forces plain textarea even if scheme1 is disabled", async () => {
+ workerClientMocks.buildLineIndex.mockResolvedValue({
+ ok: false,
+ errorCode: "TOO_MANY_LINES",
+ lineCount: 300_000,
+ });
+
+ const obj = { a: Array.from({ length: 30 }, (_, i) => i) };
+ const raw = JSON.stringify(obj);
+ const pretty = JSON.stringify(obj, null, 2);
+ const highlightMaxChars = Math.floor((raw.length + pretty.length) / 2);
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({
+ highlightMaxChars,
+ largePlainEnabled: false,
+ virtualHighlightEnabled: true,
+ workerEnabled: true,
+ })
+ );
+
+ await waitFor(() => container.querySelector("textarea") !== null);
+ await waitFor(() => (container.textContent || "").includes(virtualTooManyLinesPrefix));
+
+ const textarea = container.querySelector("textarea");
+ expect(textarea).not.toBeNull();
+ expect((textarea as HTMLTextAreaElement).value).toBe(pretty);
+
+ expect(
+ container.querySelector('[data-testid="code-display-large-pretty-view-plain"]')
+ ).not.toBeNull();
+ expect(
+ container.querySelector('[data-testid="code-display-large-pretty-view-virtual"]')
+ ).not.toBeNull();
+
+ unmount();
+ });
+
+ test("matches list does not truncate the last character on final line", async () => {
+ const text = "abc";
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({})
+ );
+
+ await flushMicrotasks();
+
+ const list = container.querySelector('[data-testid="code-display-matches-list"]');
+ expect(list).not.toBeNull();
+ expect((list as HTMLElement).textContent).toContain("abc");
+
+ unmount();
+ });
+
+ test("matches list strips CR-only line endings", async () => {
+ const text = "a\rb\r";
+
+ const { container, unmount } = renderWithIntl(
+ ,
+ makeConfig({})
+ );
+
+ await flushMicrotasks();
+
+ const spans = Array.from(container.querySelectorAll("span.whitespace-pre"));
+ expect(spans.length).toBe(2);
+ expect(spans[0]?.textContent).toBe("a");
+ expect(spans[1]?.textContent).toBe("b");
+
+ unmount();
+ });
+});
diff --git a/tests/unit/code-display-worker-client.test.ts b/tests/unit/code-display-worker-client.test.ts
new file mode 100644
index 000000000..f27cdabc0
--- /dev/null
+++ b/tests/unit/code-display-worker-client.test.ts
@@ -0,0 +1,293 @@
+/**
+ * @vitest-environment node
+ */
+
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+type AnyWorkerMessage = { type: string; jobId: number; [k: string]: unknown };
+
+class FakeWorker {
+ static instances: FakeWorker[] = [];
+ static throwOnPostMessage = false;
+
+ onmessage: ((e: { data: unknown }) => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ messages: AnyWorkerMessage[] = [];
+ terminated = false;
+
+ constructor(..._args: unknown[]) {
+ FakeWorker.instances.push(this);
+ }
+
+ postMessage(message: AnyWorkerMessage) {
+ if (FakeWorker.throwOnPostMessage) {
+ throw new Error("postMessage failed");
+ }
+ this.messages.push(message);
+ }
+
+ terminate() {
+ this.terminated = true;
+ }
+}
+
+const originalWorker = (globalThis as unknown as { Worker?: unknown }).Worker;
+
+async function importFreshWorkerClient() {
+ vi.resetModules();
+ return await import("@/components/ui/code-display-worker-client");
+}
+
+beforeEach(() => {
+ FakeWorker.instances = [];
+ FakeWorker.throwOnPostMessage = false;
+});
+
+afterEach(() => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = originalWorker;
+});
+
+describe("code-display-worker-client (no Worker fallback)", () => {
+ test("formatJsonPretty pretty-prints small JSON synchronously", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { formatJsonPretty } = await importFreshWorkerClient();
+
+ const res = await formatJsonPretty({
+ text: '{"a":1}',
+ indentSize: 2,
+ maxOutputBytes: 1_000_000,
+ });
+
+ expect(res.ok).toBe(true);
+ if (res.ok) {
+ expect(res.text).toContain('"a": 1');
+ expect(res.usedStreaming).toBe(false);
+ }
+ });
+
+ test("formatJsonPretty returns INVALID_JSON for invalid input", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { formatJsonPretty } = await importFreshWorkerClient();
+
+ const res = await formatJsonPretty({
+ text: "not-json",
+ indentSize: 2,
+ maxOutputBytes: 1_000_000,
+ });
+
+ expect(res).toEqual({ ok: false, errorCode: "INVALID_JSON" });
+ });
+
+ test("formatJsonPretty returns OUTPUT_TOO_LARGE when output exceeds budget", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { formatJsonPretty } = await importFreshWorkerClient();
+
+ const res = await formatJsonPretty({
+ text: '{"a":1}',
+ indentSize: 2,
+ maxOutputBytes: 10,
+ });
+
+ expect(res).toEqual({ ok: false, errorCode: "OUTPUT_TOO_LARGE" });
+ });
+
+ test("formatJsonPretty returns WORKER_UNAVAILABLE for very large JSON (avoid main thread freeze)", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { formatJsonPretty } = await importFreshWorkerClient();
+
+ const big = `{"a":"${"x".repeat(200_001)}"}`;
+ const res = await formatJsonPretty({
+ text: big,
+ indentSize: 2,
+ maxOutputBytes: 1_000_000_000,
+ });
+
+ expect(res).toEqual({ ok: false, errorCode: "WORKER_UNAVAILABLE" });
+ });
+
+ test("buildLineIndex returns correct starts and lineCount", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { buildLineIndex } = await importFreshWorkerClient();
+
+ const res = await buildLineIndex({ text: "a\nb\n", maxLines: 100 });
+ expect(res.ok).toBe(true);
+ if (res.ok) {
+ expect(Array.from(res.lineStarts)).toEqual([0, 2, 4]);
+ expect(res.lineCount).toBe(3);
+ }
+ });
+
+ test("buildLineIndex supports CRLF line endings", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { buildLineIndex } = await importFreshWorkerClient();
+
+ const res = await buildLineIndex({ text: "a\r\nb\r\n", maxLines: 100 });
+ expect(res.ok).toBe(true);
+ if (res.ok) {
+ expect(Array.from(res.lineStarts)).toEqual([0, 3, 6]);
+ expect(res.lineCount).toBe(3);
+ }
+ });
+
+ test("buildLineIndex returns TOO_MANY_LINES when exceeding maxLines", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { buildLineIndex } = await importFreshWorkerClient();
+
+ const res = await buildLineIndex({ text: "a\nb\n", maxLines: 2 });
+ expect(res).toEqual({ ok: false, errorCode: "TOO_MANY_LINES", lineCount: 3 });
+ });
+
+ test("searchLines returns unique line numbers that contain the query", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { searchLines } = await importFreshWorkerClient();
+
+ const text = ["alpha", "beta", "alpha gamma", "delta"].join("\n");
+ const res = await searchLines({ text, query: "alpha", maxResults: 100 });
+ expect(res.ok).toBe(true);
+ if (res.ok) {
+ expect(Array.from(res.matches)).toEqual([0, 2]);
+ }
+ });
+
+ test("searchLines supports CRLF line endings", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = undefined;
+ const { searchLines } = await importFreshWorkerClient();
+
+ const text = ["alpha", "beta", "alpha gamma", "delta"].join("\r\n");
+ const res = await searchLines({ text, query: "alpha", maxResults: 100 });
+ expect(res.ok).toBe(true);
+ if (res.ok) {
+ expect(Array.from(res.matches)).toEqual([0, 2]);
+ }
+ });
+});
+
+describe("code-display-worker-client (Worker mode)", () => {
+ test("workerEnabled=false forces no-worker fallback even when Worker exists", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = FakeWorker as unknown;
+ const { buildLineIndex } = await importFreshWorkerClient();
+
+ const res = await buildLineIndex({
+ text: "a\nb\n",
+ maxLines: 100,
+ workerEnabled: false,
+ });
+
+ expect(FakeWorker.instances.length).toBe(0);
+ expect(res.ok).toBe(true);
+ if (res.ok) {
+ expect(Array.from(res.lineStarts)).toEqual([0, 2, 4]);
+ expect(res.lineCount).toBe(3);
+ }
+ });
+
+ test("routes progress messages and resolves buildLineIndex result", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = FakeWorker as unknown;
+ const { buildLineIndex } = await importFreshWorkerClient();
+
+ const onProgress = vi.fn();
+ const p = buildLineIndex({
+ text: "a\nb",
+ maxLines: 100,
+ onProgress,
+ });
+
+ expect(FakeWorker.instances.length).toBe(1);
+ const w = FakeWorker.instances[0]!;
+ const posted = w.messages.find((m) => m.type === "buildLineIndex");
+ expect(posted).toBeTruthy();
+
+ const jobId = (posted as AnyWorkerMessage).jobId;
+ w.onmessage?.({ data: { type: "progress", jobId, stage: "index", processed: 1, total: 3 } });
+ expect(onProgress).toHaveBeenCalledWith({ stage: "index", processed: 1, total: 3 });
+
+ w.onmessage?.({
+ data: {
+ type: "buildLineIndexResult",
+ jobId,
+ ok: true,
+ lineStarts: new Int32Array([0, 2]),
+ lineCount: 2,
+ },
+ });
+
+ const resolved = await p;
+ expect(resolved.ok).toBe(true);
+ if (resolved.ok) {
+ expect(Array.from(resolved.lineStarts)).toEqual([0, 2]);
+ expect(resolved.lineCount).toBe(2);
+ }
+ });
+
+ test("aborting a pending job resolves CANCELED and posts cancel message", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = FakeWorker as unknown;
+ const { searchLines } = await importFreshWorkerClient();
+
+ const controller = new AbortController();
+ const p = searchLines({
+ text: "hello\nworld",
+ query: "o",
+ maxResults: 100,
+ signal: controller.signal,
+ });
+
+ expect(FakeWorker.instances.length).toBe(1);
+ const w = FakeWorker.instances[0]!;
+ const posted = w.messages.find((m) => m.type === "searchLines");
+ expect(posted).toBeTruthy();
+ const jobId = (posted as AnyWorkerMessage).jobId;
+
+ controller.abort();
+
+ await expect(p).resolves.toEqual({ ok: false, errorCode: "CANCELED" });
+ expect(w.messages.some((m) => m.type === "cancel" && m.jobId === jobId)).toBe(true);
+ });
+
+ test("postMessage failure resolves UNKNOWN (best-effort)", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = FakeWorker as unknown;
+ FakeWorker.throwOnPostMessage = true;
+ const { formatJsonPretty } = await importFreshWorkerClient();
+
+ const res = await formatJsonPretty({
+ text: '{"a":1}',
+ indentSize: 2,
+ maxOutputBytes: 1_000_000,
+ });
+
+ expect(res).toEqual({ ok: false, errorCode: "UNKNOWN" });
+ });
+
+ test("worker onerror resolves pending jobs and resets singleton", async () => {
+ (globalThis as unknown as { Worker?: unknown }).Worker = FakeWorker as unknown;
+ const { stringifyJsonPretty } = await importFreshWorkerClient();
+
+ const p = stringifyJsonPretty({
+ value: { a: 1 },
+ indentSize: 2,
+ maxOutputBytes: 1_000_000,
+ });
+
+ const w = FakeWorker.instances[0]!;
+ w.onerror?.();
+
+ await expect(p).resolves.toEqual({ ok: false, errorCode: "UNKNOWN" });
+
+ // second call should create a new worker instance because singleton was reset
+ const p2 = stringifyJsonPretty({
+ value: { a: 2 },
+ indentSize: 2,
+ maxOutputBytes: 1_000_000,
+ });
+ expect(FakeWorker.instances.length).toBe(2);
+
+ // resolve it to avoid pending leak
+ const w2 = FakeWorker.instances[1]!;
+ const posted = w2.messages.find((m) => m.type === "stringifyJsonPretty");
+ const jobId = (posted as AnyWorkerMessage).jobId;
+ w2.onmessage?.({
+ data: { type: "stringifyJsonPrettyResult", jobId, ok: true, text: '{\n "a": 2\n}' },
+ });
+ await expect(p2).resolves.toEqual({ ok: true, text: '{\n "a": 2\n}' });
+ });
+});