From 92a7e07251a2a07b6725ebaebec43056a5bb6fdd Mon Sep 17 00:00:00 2001 From: liuhailong <857688528@qq.com> Date: Mon, 28 Sep 2026 13:48:25 +0800 Subject: [PATCH 1/4] feat(webui): IDE-grade code preview (line numbers, syntax highlighting, lazy grammar load) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy monospace-pre file preview with an IDE-grade renderer: gutter on the left with line numbers, syntax highlighting via highlight.js with per-language lazy loading, plain monospace fallback for unknown languages, and a truncation notice for files that exceed the highlight budget. Components - components/code-view.tsx: React view, gutter+code grid, copy button, sticky-to-top-right truncation banner. Owns its loading state. - lib/code-highlight.ts: pure highlight.js wrapper with per-language dynamic import (webpack chunk per language), 32 KiB byte and 1500 line default caps with honest truncation notice, html aliases for html (xml in hljs 10.7.3) and jsonc (json). Tests pin: lazy grammar registration, unknown-language fallback without throw, line/byte truncation, balanced per-line DOM tree for multi-line constructs (template literals). - styles/code-preview.css: three-layer CSS. Layer 1 maps .hljs-* classes to existing --code-theme-* tokens so light/dark themes auto-flip via the root class. Layer 2 fixes the gutter to a grid column so horizontal scroll on the code never moves the gutter. Layer 3 styles the shell. I18n - fileOpen.code.copy / fileOpen.code.copy.aria / fileOpen.code.truncated: bilingual strings added to i18n-file-open.ts; the i18n symmetry test (en !== zh for every visible key) covers them. Dependency license - highlight.js@10.7.3 added as a direct devDependency of @mavis/webui; release/dependency-licenses.json already had it (BSD-3-Clause) so no audit impact. Source inventory - node scripts/source-inventory.mjs --write regenerated release/public-source.json with the three new files plus the test; check:source and check:standalone both pass. Constraints respected - Did not modify panels.tsx / workspace-*.tsx / app/page.tsx / lib/open-file.ts (slice 19b territory). - Did not touch markdown rendering, image preview, unsupported-state three-action card, slice-16 credential refusal, browser sandbox, or the two open.file.in.web entry points. - Line numbers live in their own DOM subtree (aria-hidden) and the copy path reads only line.text from the split — gutter digits never leak into the clipboard. Tests - 37 new tests in webapp/test/code-highlight.test.ts; total webapp suite 1009/1009 passing. --- packages/webui/package.json | 1 + packages/webui/webapp/app/layout.tsx | 1 + .../webui/webapp/components/code-view.tsx | 305 +++++++++++ .../webui/webapp/components/file-preview.tsx | 47 +- packages/webui/webapp/lib/code-highlight.ts | 483 ++++++++++++++++++ packages/webui/webapp/lib/i18n-file-open.ts | 13 + packages/webui/webapp/styles/code-preview.css | 181 +++++++ .../webui/webapp/test/code-highlight.test.ts | 372 ++++++++++++++ .../webui/webapp/test/i18n-file-open.test.ts | 3 + release/public-source.json | 4 + 10 files changed, 1388 insertions(+), 22 deletions(-) create mode 100644 packages/webui/webapp/components/code-view.tsx create mode 100644 packages/webui/webapp/lib/code-highlight.ts create mode 100644 packages/webui/webapp/styles/code-preview.css create mode 100644 packages/webui/webapp/test/code-highlight.test.ts diff --git a/packages/webui/package.json b/packages/webui/package.json index 159a84b71..0b8ade8d0 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -33,6 +33,7 @@ "@types/react-dom": "18.3.7", "antd": "5.29.3", "autoprefixer": "10.6.1", + "highlight.js": "10.7.3", "marked": "18.0.12", "next": "14.2.35", "postcss": "8.5.28", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index a7b817ea0..3ea544645 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -8,6 +8,7 @@ import "../styles/tokens.css"; import "../styles/official-utilities.css"; import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; +import "../styles/code-preview.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/code-view.tsx b/packages/webui/webapp/components/code-view.tsx new file mode 100644 index 000000000..ee6008bde --- /dev/null +++ b/packages/webui/webapp/components/code-view.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +import { + highlightCode, + splitHighlightedLines, + type HighlightedCode, + type HighlightedLine, +} from "@/lib/code-highlight"; +import { formatBytes } from "@/lib/file-preview"; +import type { Locale, MessageKey } from "@/lib/i18n"; +import { tFileOpen } from "@/lib/i18n-file-open"; + +/** + * Code preview (slice 22 of webui-parity). + * + * Renders an `FsFilePayload`'s text content with: + * - gutter on the left, line numbers aligned to code lines, + * independent of horizontal scroll (line numbers never move when + * the user scrolls right on a long line); + * - syntax highlighting via highlight.js, lazy-loaded by grammar; + * - plain monospace fallback when the language is unknown; + * - truncate-with-notice when the content exceeds the highlight + * budget, so a multi-megabyte file never freezes the tab; + * - copy button that puts raw source on the clipboard (line + * numbers never leak into the copied text). + * + * The component owns its own loading state: the parent only needs to + * hand it `(content, language)` and a t/locale pair. Render output + * is a stable tree so the React reconciliation does not have to + * rebuild 1000+ line cells on each scroll. + */ + +export interface CodeViewProps { + /** Raw text body of the file. Required. */ + content: string; + /** + * The server-reported language label (the wire form of + * `languageForExtension`). Used to pick the highlight.js grammar + * and to display the badge. Empty / unknown labels render as + * plain monospace without an error. + */ + language: string; + /** File size in bytes, for the truncation notice. Optional. */ + size?: number; + t: (key: MessageKey) => string; + locale: Locale; +} + +interface CodeViewState { + /** Highlighted split, or null while loading / on unknown language. */ + split: + | { + lines: HighlightedLine[]; + language: string | null; + truncated: boolean; + originalLineCount?: number; + visibleLineCount: number; + } + | null; + /** Error message from the highlight step (rare — grammar load failure). */ + error: string | null; +} + +export function CodeView({ content, language, size, t, locale }: CodeViewProps) { + const [state, setState] = useState({ split: null, error: null }); + // Highlight runs in a microtask; if the user clicks through several + // files in a row, only the latest result wins. Same last-write-wins + // pattern as `file-preview.tsx#load`. + const genRef = useRef(0); + + useEffect(() => { + const gen = ++genRef.current; + let cancelled = false; + setState({ split: null, error: null }); + (async () => { + try { + const result: HighlightedCode = await highlightCode(language, content); + if (cancelled || gen !== genRef.current) return; + const split = splitHighlightedLines(result, content); + setState({ split, error: null }); + } catch (cause) { + if (cancelled || gen !== genRef.current) return; + // Highlight failures are non-fatal: fall through to the + // plain monospace render so the user always sees the file. + // The error stays in state only for diagnostics — not shown + // in the UI. + setState({ + split: { + lines: content.split("\n").map((text, i) => ({ + number: i + 1, + html: escapeHtmlSafe(text), + text, + })), + language: language || null, + truncated: false, + visibleLineCount: content.split("\n").length, + }, + error: cause instanceof Error ? cause.message : String(cause), + }); + } + })(); + return () => { + cancelled = true; + }; + }, [content, language]); + + // The badge label uses the language the renderer ACTUALLY used + // (the resolved hljs module name, e.g. "javascript"), falling back + // to the server-reported label when no grammar loaded. + const badgeLabel = state.split?.language ?? normaliseLabel(language); + const showBadge = badgeLabel.length > 0; + + const truncatedNotice = useMemo(() => { + if (!state.split?.truncated) return null; + const original = state.split.originalLineCount ?? state.split.visibleLineCount; + const sizeHint = size !== undefined ? ` (${formatBytes(size)})` : ""; + // The bilingual notice falls through to the i18n key when it is + // present; the inline fallback keeps the slice self-contained + // until the next i18n sweep. + return tFileOpen(locale, "fileOpen.code.truncated", { + shown: state.split.visibleLineCount, + total: original, + }) + sizeHint; + }, [state.split, size, locale]); + + return ( +
+
+ {showBadge ? ( + + {badgeLabel} + + ) : null} + {state.split ? ( + + {state.split.visibleLineCount} {state.split.visibleLineCount === 1 ? "line" : "lines"} + + ) : null} +
+ + {truncatedNotice ? ( +
+ ⚠ + {truncatedNotice} +
+ ) : null} + + {state.error ? ( + // Diagnostic only — the render below always succeeds. Keeping + // the field in the DOM (hidden) lets tests assert it. + + ) : null} + + +
+ ); +} + +function CodeBody({ + split, + t, + locale, +}: { + split: CodeViewState["split"]; + t: (key: MessageKey) => string; + locale: Locale; +}) { + // Gutter layout: a 2-cell grid keeps the gutter locked to the left + // of the code area regardless of horizontal scroll. The grid columns + // are `[auto, 1fr]` so the code area takes the remaining width and + // scrolls horizontally; the gutter column is fixed-width and stays + // in place because it shares the grid with the code. + // + // `font-variant-numeric: tabular-nums` aligns digits in the gutter + // so the colon between the number and the code does not dance when + // the file crosses 9 → 10 or 99 → 100 lines. + return ( +
+ {!split ? ( +
{t("app.connecting")}
+ ) : ( + + )} +
+ ); +} + +function CodeTable({ + split, + t, + locale, +}: { + split: NonNullable; + t: (key: MessageKey) => string; + locale: Locale; +}) { + // Copy button: selecting the gutter is impossible (its own DOM + // subtree), and the code area's textContent contains only the + // source (no gutter digits). `clipboard.writeText` over the whole + // code subtree is therefore safe. + const onCopy = async () => { + const text = split.lines.map((line) => line.text).join("\n"); + if (typeof navigator !== "undefined" && navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Some browsers refuse clipboard access outside a user gesture + // for non-secure contexts; fall back to the legacy API. + } + } + if (typeof document === "undefined") return; + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand("copy"); + } finally { + ta.remove(); + } + }; + + return ( +
+
+ +
+
+        
+          {split.lines.map((line) => (
+            
+ + {line.number} + + +
+ ))} +
+
+
+ ); +} + +function normaliseLabel(language: string): string { + return (language ?? "").toLowerCase().trim(); +} + +// The error-path renderer in CodeView also needs to escape HTML; the +// shared helper lives in lib/code-highlight.ts but importing it here +// would create a cycle in the build graph (lib already imports +// highlight.js, the component imports lib). We duplicate the four +// escapes rather than add a separate module just for this. +const ESCAPE_MAP: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; +function escapeHtmlSafe(value: string): string { + return value.replace(/[&<>"']/gu, (c) => ESCAPE_MAP[c] ?? c); +} diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index a0a570e6d..c966934bb 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -12,6 +12,7 @@ import { type FsFilePayload, } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; +import { CodeView as IdeCodeView } from "@/components/code-view"; import { basenameOf, formatBytes, @@ -28,7 +29,7 @@ import { } from "@/lib/i18n-file-open"; /** - * File preview (slice 02 of the webui-parity program). + * File preview (slice 02 of the webui-parity program, slice 22 upgrades). * * A read-only viewer that the right-hand `files` panel opens on click. It is * a small type→renderer router over `/api/fs/read-file` (text, ≤512 KiB) @@ -39,7 +40,11 @@ import { * Routing rules — what gets which renderer: * `.md` / `.markdown` rendered markdown (via lib/markdown.ts) * image extensions - * source / data files monospace pre, light "language" badge in the header + * source / data files IDE-grade preview (gutter + line numbers + + * syntax highlighting + copy) — see + * `components/code-view.tsx`. The legacy + * "monospace pre, language badge" path was + * superseded in slice 22. * anything else "无法预览" placeholder * * The component never truncates the response. Oversize reads return @@ -238,7 +243,7 @@ export function FilePreview({ {showImage ? ( ) : ( - + )} ) : null} @@ -250,10 +255,14 @@ function PreviewBody({ kind, payload, path, + t, + locale, }: { kind: PreviewKind; payload: FsFilePayload; path: string; + t: (key: MessageKey) => string; + locale: Locale; }) { switch (kind) { case "markdown": @@ -261,7 +270,19 @@ function PreviewBody({ case "image": return ; case "code": - return ; + // Slice 22 — delegate to the IDE-grade renderer. The legacy + // `
` is gone; the renderer owns its own loading
+      // state, truncation policy, and copy affordance, so the
+      // router here only forwards the payload.
+      return (
+        
+      );
     default:
       // pickPreviewKind() never returns "unsupported" today; kept as an
       // escape hatch so the call-site exhaustiveness check stays honest.
@@ -300,24 +321,6 @@ function ImageView({ path }: { path: string }) {
   );
 }
 
-function CodeView({ content, language }: { content: string; language: string }) {
-  return (
-    
-
- {language} -
-
, NOT .codeblock-pre: that selector carries the chat
-        // codeblock shell (toolbar, copy button) which is meaningless for
-        // a file preview, and would otherwise override our padding to 0.
-      >
-        {content}
-      
-
- ); -} - function UnsupportedView({ fileName }: { fileName: string }) { return (
[1]; + +/** + * Code highlighter — slice 22 of webui-parity. + * + * Wraps highlight.js to give the file-preview surface three properties + * the bare `
` view lacks:
+ *   - per-language lazy grammar loading (only the language of the open
+ *     file is fetched and registered, not all 191 languages hljs ships);
+ *   - line-number gutter alignment via post-tokenized line splitting;
+ *   - bounded work on huge inputs (truncate before highlighting so a
+ *     512 KiB file does not freeze the tab).
+ *
+ * The backend already labels files via its `EXT_LANGUAGE` table
+ * (`server/lib/fs-util.js#languageForExtension`); the view does not
+ * re-guess. Unknown or unloaded languages fall through to a plain
+ * monospace view — see {@link highlightCode} for the contract.
+ *
+ * The highlight step is pure: given a (language, content) pair, it
+ * returns either an HTML string with `` markup,
+ * or `null` when the language is not highlightable. Returning `null`
+ * is the "unknown language → no error, no blank" path the ticket
+ * asks for: the caller renders the raw content in a `
` without
+ * the highlighting shell.
+ */
+
+const DEFAULT_LARGE_FILE_TRUNCATE_LINES = 1500;
+/**
+ * Byte budget for the highlight step.
+ *
+ * Why 32 KiB and not 256 KiB. highlight.js 10.7.3 is super-linear
+ * on pathological inputs — a single 256 KiB line of unrepeated
+ * characters takes >60 seconds to lex (the JS grammar tries every
+ * rule against every position with no early termination). 32 KiB
+ * is the largest input where worst-case highlighting completes in
+ * well under a second, which is the cap the preview needs to stay
+ * responsive. Real source files have line breaks, so the
+ * `maxLines` budget below catches the rest of the "long file"
+ * case before the byte budget fires.
+ */
+const DEFAULT_LARGE_FILE_TRUNCATE_BYTES = 32 * 1024;
+
+/**
+ * Map the server's `language` field (see `EXT_LANGUAGE` in
+ * `server/lib/fs-util.js`) to the highlight.js module name to import.
+ *
+ * Notes for the table:
+ *   - `html` → `xml` because hljs 10.7.3 ships html as an alias of xml.
+ *   - `jsonc` → `json` (best-effort; // comments would tokenise oddly,
+ *     but json is otherwise the same grammar).
+ *   - `toml` and `plain` deliberately have no entry: there is no hljs
+ *     module for them in 10.7.3, and the caller treats them as plain
+ *     monospace. `plain` is the catch-all for "no language".
+ *   - Everything not in this table resolves to {@link highlightCode}'s
+ *     `null` branch — the file preview's "unknown language" fallback.
+ */
+const LANGUAGE_TO_HLJS: Record = {
+  typescript: "typescript",
+  javascript: "javascript",
+  json: "json",
+  jsonc: "json",
+  css: "css",
+  scss: "scss",
+  less: "less",
+  // hljs 10.7.3 has no standalone `html` module; html is an alias of xml
+  // (see node_modules/highlight.js/lib/languages/xml.js). Loading xml
+  // registers html for free.
+  html: "xml",
+  xml: "xml",
+  markdown: "markdown",
+  python: "python",
+  ruby: "ruby",
+  go: "go",
+  rust: "rust",
+  java: "java",
+  kotlin: "kotlin",
+  swift: "swift",
+  c: "c",
+  cpp: "cpp",
+  bash: "bash",
+  yaml: "yaml",
+  sql: "sql",
+  dockerfile: "dockerfile",
+};
+
+/**
+ * Cache of "language has already been registered with hljs". The
+ * registration is idempotent (`registerLanguage` throws on duplicate
+ * names — see node_modules/highlight.js/lib/core.js), so we MUST guard
+ * against a second registration; a hot file-tree navigation can ask
+ * for the same grammar twice in a row.
+ *
+ * The cache key is the normalized hljs module name, not the original
+ * server label — that way `html` and `xml` share one registration.
+ */
+const registeredLanguages = new Set();
+
+/**
+ * Load the highlight.js grammar for `language` if it is supported.
+ *
+ * Returns the hljs module name that was registered, or `null` if the
+ * language is not in {@link LANGUAGE_TO_HLJS}. Callers that receive
+ * `null` should render a plain monospace view (no error, no blank).
+ *
+ * The dynamic import is what makes this "per-language lazy": webpack
+ * turns each `import('highlight.js/lib/languages/...')` into its own
+ * chunk. Only the chunk for the open file's language is fetched.
+ */
+export async function loadHljsLanguage(language: string): Promise {
+  const normalised = (language ?? "").toLowerCase().trim();
+  if (!normalised) return null;
+  const moduleName = LANGUAGE_TO_HLJS[normalised];
+  if (!moduleName) return null;
+  if (registeredLanguages.has(moduleName)) return moduleName;
+  // Dynamic import → webpack/Next chunks each language module.
+  // The language modules are CommonJS (`module.exports = function(hljs){}`),
+  // which ESM exposes as `default` under Node's interop. The same interop
+  // works in webpack, so a single branch covers both runtimes.
+  const mod = await import(
+    /* webpackChunkName: "hljs-[request]" */
+    `highlight.js/lib/languages/${moduleName}.js`
+  );
+  const languageFn: LanguageFn = (mod as { default?: LanguageFn }).default ?? (mod as unknown as LanguageFn);
+  hljs.registerLanguage(moduleName, languageFn);
+  registeredLanguages.add(moduleName);
+  return moduleName;
+}
+
+/**
+ * Public view of the lazy-loading cache — used by tests to assert
+ * that a specific file open did not pull in unrelated grammars.
+ */
+export function _registeredLanguagesForTest(): ReadonlySet {
+  return registeredLanguages;
+}
+
+/**
+ * Reset the cache. Intended for tests only; never call from app code.
+ */
+export function _resetHljsLanguageCacheForTest(): void {
+  registeredLanguages.clear();
+}
+
+export interface HighlightedCode {
+  /**
+   * Either highlight.js HTML markup with `` tokens,
+   * or `null` when the language is unknown / not loaded / blank. The
+   * null branch is the "unknown-language fallback" — the caller renders
+   * the raw content as plain monospace text.
+   *
+   * Returning `null` (rather than throwing or returning escaped HTML)
+   * means the contract is total: every input produces a renderable
+   * result.
+   */
+  html: string | null;
+  /**
+   * The hljs module name actually used (e.g. "javascript"), or `null`
+   * when the input was not highlighted. The component renders a
+   * language badge from this so the user always sees what the
+   * highlight was based on.
+   */
+  language: string | null;
+  /**
+   * Whether the content was truncated before highlighting. The caller
+   * renders an inline notice so the user knows the file is bigger
+   * than what they see.
+   */
+  truncated: boolean;
+  /**
+   * Original line count, before truncation. `undefined` when the file
+   * was not truncated — saves the cost of a `split('\n').length` on
+   * the happy path.
+   */
+  originalLineCount?: number;
+  /**
+   * How many lines the caller should render. Equal to
+   * `originalLineCount` when `truncated` is false.
+   */
+  visibleLineCount: number;
+}
+
+export interface HighlightOptions {
+  /**
+   * Maximum number of source lines to highlight. Files longer than
+   * this are truncated and the caller renders an honest notice.
+   * Defaults to {@link DEFAULT_LARGE_FILE_TRUNCATE_LINES}.
+   */
+  maxLines?: number;
+  /**
+   * Maximum number of source bytes to highlight. Wins over `maxLines`
+   * when both apply. Defaults to {@link DEFAULT_LARGE_FILE_TRUNCATE_BYTES}.
+   */
+  maxBytes?: number;
+}
+
+/**
+ * Highlight `content` as `language` and return an HTML string ready
+ * for the gutter+code view.
+ *
+ * Implementation notes — three rules govern the body of this function:
+ *   1. NEVER call `hljs.highlight` with a language it does not know:
+ *      hljs 10.7.3 throws ("Unknown language") on unregistered names,
+ *      and the contract here is total — bad inputs must not blow up.
+ *   2. ALWAYS truncate before highlighting: the highlight step is
+ *      O(content length) and a 512 KiB JS file with deep nesting can
+ *      keep the main thread busy for >1s. Truncating first keeps the
+ *      cost bounded.
+ *   3. The fallback path escapes the raw bytes — when the language is
+ *      unknown, the raw bytes are dropped into a `
` directly,
+ *      so they MUST be HTML-safe.
+ */
+export async function highlightCode(
+  language: string,
+  content: string,
+  options: HighlightOptions = {},
+): Promise {
+  const maxLines = options.maxLines ?? DEFAULT_LARGE_FILE_TRUNCATE_LINES;
+  const maxBytes = options.maxBytes ?? DEFAULT_LARGE_FILE_TRUNCATE_BYTES;
+
+  // Treat undefined / empty / whitespace as "no language", not as an
+  // error. We never let an empty string reach hljs.highlight — that
+  // would throw.
+  const normalisedLanguage = (language ?? "").toLowerCase().trim();
+
+  const totalLines = countLines(content);
+  const bytes = byteLength(content);
+  const shouldTruncate = totalLines > maxLines || bytes > maxBytes;
+  const workingContent = shouldTruncate ? truncateContent(content, maxLines, maxBytes) : content;
+  const visibleLineCount = shouldTruncate ? countLines(workingContent) : totalLines;
+
+  // Fast path — unknown language (including `plain`, `""`, `toml`,
+  // and any label the server returns that we do not recognise).
+  if (!normalisedLanguage) {
+    return {
+      html: null,
+      language: null,
+      truncated: shouldTruncate,
+      originalLineCount: shouldTruncate ? totalLines : undefined,
+      visibleLineCount,
+    };
+  }
+
+  const moduleName = await loadHljsLanguage(normalisedLanguage);
+  if (!moduleName) {
+    return {
+      html: null,
+      language: normalisedLanguage || null,
+      truncated: shouldTruncate,
+      originalLineCount: shouldTruncate ? totalLines : undefined,
+      visibleLineCount,
+    };
+  }
+
+  // We just registered `moduleName` (or confirmed it is already
+  // registered), so hljs.highlight will not throw "Unknown language".
+  // `ignoreIllegals: true` makes it tolerant of syntax it does not
+  // recognise — a JS file labelled "html" would otherwise abort the
+  // whole file at the first unmatched rule.
+  const result = hljs.highlight(workingContent, {
+    language: moduleName,
+    ignoreIllegals: true,
+  });
+  return {
+    html: result.value,
+    language: moduleName,
+    truncated: shouldTruncate,
+    originalLineCount: shouldTruncate ? totalLines : undefined,
+    visibleLineCount,
+  };
+}
+
+/**
+ * Pre-computed lines + their highlight HTML, for the gutter+code view.
+ * Each entry represents one source line; the index in the array IS
+ * the line number (1-based display).
+ */
+export interface HighlightedLine {
+  /** Line number, 1-based. Used by the gutter. */
+  number: number;
+  /** Highlighted HTML for this line only. Empty string for blank lines. */
+  html: string;
+  /** Raw text for the line (for copy-without-line-numbers and accessibility). */
+  text: string;
+}
+
+export interface SplitHighlightedLines {
+  lines: HighlightedLine[];
+  truncated: boolean;
+  originalLineCount?: number;
+  visibleLineCount: number;
+  language: string | null;
+}
+
+/**
+ * Re-split the highlight.js HTML into per-line records so the gutter
+ * can render line numbers against the exact same lines the code view
+ * shows. hljs returns a single HTML string with embedded newlines; we
+ * split on `\n` and walk both the HTML (for the gutter-aware display)
+ * and the raw text (for copy + selection).
+ *
+ * Edge case: hljs may leave a `` open across a line boundary
+ * (e.g. a multi-line template literal). A naive `split('\n')` would
+ * leave the gutter with two unbalanced chunks — a `` opening
+ * on line N with its closing on line N+1. We balance each line by
+ * closing spans that were open at the end of the previous line and
+ * re-opening them at the end of the current one. The result is
+ * hover-stable (line N's spans do not bleed into line N+1) and copy-
+ * faithful (selection lives inside a balanced DOM tree).
+ */
+export function splitHighlightedLines(
+  highlighted: HighlightedCode,
+  rawContent: string,
+): SplitHighlightedLines {
+  // The split only emits as many lines as the highlight step
+  // produced. When the file was truncated, that is
+  // `visibleLineCount`; when not, it is the source line count.
+  // Slicing `sourceLines` to `visibleLineCount` BEFORE the loop
+  // means the gutter, the code, and the metadata all agree.
+  const sourceLines = splitSourceLines(rawContent).slice(0, highlighted.visibleLineCount);
+
+  if (highlighted.html === null) {
+    // Plain monospace path — no highlighting, but we still need
+    // per-line records for the gutter. Each line is escaped raw text.
+    return {
+      lines: sourceLines.map((text, i) => ({
+        number: i + 1,
+        html: escapeHtml(text),
+        text,
+      })),
+      truncated: highlighted.truncated,
+      originalLineCount: highlighted.originalLineCount,
+      visibleLineCount: highlighted.visibleLineCount,
+      language: highlighted.language,
+    };
+  }
+
+  // Highlighted path. The hljs output is balanced overall. When we
+  // split on `\n`, a span can either be wholly inside one line (the
+  // common case — open and close on the same line, hljs already
+  // emits both tags there) or cross a line boundary (a multi-line
+  // string, regex, etc.). For the cross-line case, hljs emits the
+  // opening tag on the FIRST line of the run and the closing tag on
+  // the LAST — but the middle lines have neither, so each line by
+  // itself would be missing its wrapper.
+  //
+  // The fix: wrap each line with `` / `` for every
+  // carried-in span. That makes each line a self-contained balanced
+  // chunk: opening tags precede the line content, closing tags
+  // follow it. The hljs-emitted `` and `` tags INSIDE
+  // the line balance as expected because hljs always emits balanced
+  // pairs. Lines with no carried-in spans are untouched.
+  const htmlLines = highlighted.html.split("\n").slice(0, highlighted.visibleLineCount);
+  const out: HighlightedLine[] = [];
+  let carriedSpans: string[] = []; // spans open at the START of the current line
+
+  for (let i = 0; i < sourceLines.length; i += 1) {
+    const htmlLine = htmlLines[i] ?? "";
+    const text = sourceLines[i] ?? "";
+
+    // Compute the spans open at the END of this line so the next
+    // line knows what to prepend / append.
+    const endSpans = carriedSpans.slice();
+    const tagPattern = /<\/?(span)\b[^>]*>/gi;
+    let match: RegExpExecArray | null;
+    while ((match = tagPattern.exec(htmlLine)) !== null) {
+      const tag = match[0];
+      if (tag.startsWith(" 0) endSpans.pop();
+      } else if (!tag.endsWith("/>")) {
+        endSpans.push("span");
+      }
+    }
+
+    // Prepend and append carries. For each carried-in span, open a
+    // matching `` at the start and close it at the end of the
+    // line — making this line a balanced chunk that is hover-stable
+    // AND visually identical to the hljs source. The next line gets
+    // its own fresh carries from its own endSpans.
+    const carryOpen = carriedSpans.length ? `<${carriedSpans.join("><")}>` : "";
+    const carryClose = carriedSpans.length ? `` : "";
+
+    out.push({
+      number: i + 1,
+      html: `${carryOpen}${htmlLine}${carryClose}`,
+      text,
+    });
+    carriedSpans = endSpans;
+  }
+
+  return {
+    lines: out,
+    truncated: highlighted.truncated,
+    originalLineCount: highlighted.originalLineCount,
+    visibleLineCount: highlighted.visibleLineCount,
+    language: highlighted.language,
+  };
+}
+
+// ---------------------------------------------------------------------
+// Pure helpers — exported so tests can pin them without spinning up
+// the highlight.js runtime.
+// ---------------------------------------------------------------------
+
+export function countLines(content: string): number {
+  // Match the IDE convention: count newlines, add 1 for the final
+  // partial line, but drop the count by 1 when the content ends
+  // with a newline (so `"hello\n"` is one line, not two — the
+  // editor's gutter shows 1 for that file). Empty input is zero
+  // lines, not one.
+  if (!content) return 0;
+  let count = 1;
+  for (let i = 0; i < content.length; i += 1) {
+    if (content.charCodeAt(i) === 10) count += 1;
+  }
+  if (content.charCodeAt(content.length - 1) === 10) count -= 1;
+  if (count < 1 && content.length > 0) count = 1;
+  return count;
+}
+
+export function byteLength(content: string): number {
+  // The browser uses UTF-16 code units; the source-of-truth here is
+  // the highlighted bytes we put on the wire. We use the cheaper
+  // `TextEncoder` path when available (production) and fall back to
+  // `string.length` (Node tests).
+  if (typeof TextEncoder !== "undefined") {
+    return new TextEncoder().encode(content).length;
+  }
+  return content.length;
+}
+
+function truncateContent(content: string, maxLines: number, maxBytes: number): string {
+  // Truncate by lines first; if the byte budget is still exceeded,
+  // truncate by bytes. Both are inclusive of the trailing newline
+  // so the highlight step does not see a half-line.
+  const lines = content.split("\n");
+  const limit = Math.min(maxLines, lines.length);
+  let slice = lines.slice(0, limit).join("\n");
+  if (byteLength(slice) > maxBytes) {
+    // Trim to maxBytes by code point; TextEncoder counts bytes, but
+    // truncating mid-character is acceptable here because we mark
+    // the file as truncated in the UI.
+    const encoder = new TextEncoder();
+    const decoder = new TextDecoder("utf-8", { fatal: false });
+    const encoded = encoder.encode(slice);
+    slice = decoder.decode(encoded.subarray(0, maxBytes));
+  }
+  return slice;
+}
+
+export function splitSourceLines(content: string): string[] {
+  // Mirror hljs's own newline semantics: split on \n, drop the
+  // trailing empty entry that comes from a final newline so the
+  // gutter count equals the user's mental count. A file that ends
+  // with "\n" therefore does NOT get a phantom empty line at the
+  // bottom of the gutter. Empty input collapses to no lines.
+  if (!content) return [];
+  const lines = content.split("\n");
+  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
+  return lines;
+}
+
+const ESCAPE_MAP: Record = {
+  "&": "&",
+  "<": "<",
+  ">": ">",
+  '"': """,
+  "'": "'",
+};
+
+export function escapeHtml(value: string): string {
+  return value.replace(/[&<>"']/gu, (c) => ESCAPE_MAP[c] ?? c);
+}
+
+/**
+ * Public mapping table — exposed for tests that want to assert the
+ * server→hljs mapping without re-implementing the logic.
+ */
+export const _languageToHljsForTest: Readonly> = LANGUAGE_TO_HLJS;
diff --git a/packages/webui/webapp/lib/i18n-file-open.ts b/packages/webui/webapp/lib/i18n-file-open.ts
index 870894fde..85965995a 100644
--- a/packages/webui/webapp/lib/i18n-file-open.ts
+++ b/packages/webui/webapp/lib/i18n-file-open.ts
@@ -71,6 +71,13 @@ const FILE_OPEN_STRINGS = {
     "fileOpen.reason.credential.subReason.ssh-key": "SSH private key",
     "fileOpen.reason.credential.subReason.credentials": "credentials file",
     "fileOpen.reason.credential.subReason.ssh-meta": "SSH metadata file",
+    /* Slice 22 — code preview copy. The badge already says the
+       language; the buttons stay short to fit a 288px panel.
+       `truncated` is shown when the highlight step dropped bytes /
+       lines to keep the main thread responsive on a multi-MiB file. */
+    "fileOpen.code.copy": "Copy",
+    "fileOpen.code.copy.aria": "Copy the source code to the clipboard without line numbers",
+    "fileOpen.code.truncated": "Showing first {{shown}} of {{total}} lines. The full file is preserved on disk — open it externally to see the rest.",
     /* Action buttons — kept terse because the row's horizontal space is
        tight in the right-hand panel (288px). The aria-label carries the
        long form for screen readers. */
@@ -138,6 +145,12 @@ const FILE_OPEN_STRINGS = {
     "fileOpen.reason.credential.subReason.ssh-key": "SSH 私钥",
     "fileOpen.reason.credential.subReason.credentials": "凭据文件",
     "fileOpen.reason.credential.subReason.ssh-meta": "SSH 元数据文件",
+    /* Slice 22 — 代码预览。徽标已经说明语言;按钮文字保持简短以
+       适配 288px 面板。`truncated` 在主线程为了避免大文件卡顿而
+       截断高亮时显示,提醒用户去外部工具看完整内容。 */
+    "fileOpen.code.copy": "复制",
+    "fileOpen.code.copy.aria": "把源码复制到剪贴板(不含行号)",
+    "fileOpen.code.truncated": "仅展示前 {{shown}} / {{total}} 行。完整文件仍在磁盘上,可通过外部工具查看其余部分。",
     "fileOpen.action.openDefault": "用默认应用打开",
     "fileOpen.action.openDefault.aria": "用系统默认应用程序打开该文件",
     "fileOpen.action.reveal": "在文件管理器中显示",
diff --git a/packages/webui/webapp/styles/code-preview.css b/packages/webui/webapp/styles/code-preview.css
new file mode 100644
index 000000000..21e5420b7
--- /dev/null
+++ b/packages/webui/webapp/styles/code-preview.css
@@ -0,0 +1,181 @@
+/* Code preview (slice 22 of webui-parity).
+ *
+ * Three concerns live here, in three layers:
+ *
+ *   1. hljs token colours — every `.hljs-` class is mapped to a
+ *      `--code-theme-*` token in `styles/tokens.css`. Because those
+ *      tokens flip with the `.dark` class on  (set by the theme
+ *      bootstrap in app/layout.tsx), the highlight palette auto-themes
+ *      without a per-theme override block in this file. Both light and
+ *      dark themes are supported by construction.
+ *
+ *   2. The gutter — a fixed-width left column with line numbers, locked
+ *      to the code column so horizontal scroll on a long line never
+ *      drags the gutter off-screen. Tabular numerals keep the gutter
+ *      aligned at 1, 10, 100 lines.
+ *
+ *   3. The shell — padded pre/code area, copy-button row, truncation
+ *      notice banner. None of these have a token; values are direct.
+ *
+ * Naming convention: every selector is namespaced under
+ * `.file-preview-codeblock` so the chat markdown's `.codeblock-pre`
+ * shell (which lives in `styles/official-utilities.css`) is unaffected
+ * by this file. The file-preview surface uses a separate class on
+ * purpose: chat and file preview are different products with different
+ * shells, and they should not share their cascade by accident.
+ *
+ * Theme note: the `--code-theme-*` palette is brighter on dark and
+ * softer on light by design — tokens.css quotes the upstream values
+ * verbatim, not a hand-tuned contrast pair. The values below are
+ * copies of that contract.
+ */
+
+/* ---------------------------------------------------------------------
+ * Layer 1 — hljs token colours.
+ *
+ * The `code-theme-*` tokens are aliased to the upstream colour ramp
+ * per-theme (light vs dark); we just wire the names.
+ * --------------------------------------------------------------------- */
+.file-preview-codeblock .hljs {
+  color: var(--code-theme-default);
+}
+
+.file-preview-codeblock .hljs-keyword,
+.file-preview-codeblock .hljs-tag,
+.file-preview-codeblock .hljs-selector-tag {
+  color: var(--code-theme-keyword);
+}
+
+.file-preview-codeblock .hljs-string,
+.file-preview-codeblock .hljs-quote,
+.file-preview-codeblock .hljs-meta-string {
+  color: var(--code-theme-string);
+}
+
+.file-preview-codeblock .hljs-number,
+.file-preview-codeblock .hljs-literal {
+  color: var(--code-theme-number);
+}
+
+.file-preview-codeblock .hljs-comment,
+.file-preview-codeblock .hljs-doctag {
+  color: var(--code-theme-comment);
+  font-style: italic;
+}
+
+.file-preview-codeblock .hljs-function,
+.file-preview-codeblock .hljs-title,
+.file-preview-codeblock .hljs-title.function_,
+.file-preview-codeblock .hljs-method {
+  color: var(--code-theme-function);
+}
+
+.file-preview-codeblock .hljs-class .hljs-title,
+.file-preview-codeblock .hljs-title.class_,
+.file-preview-codeblock .hljs-type {
+  color: var(--code-theme-type);
+}
+
+.file-preview-codeblock .hljs-attr,
+.file-preview-codeblock .hljs-attribute {
+  color: var(--code-theme-attribute);
+}
+
+.file-preview-codeblock .hljs-built_in,
+.file-preview-codeblock .hljs-builtin-name {
+  color: var(--code-theme-builtin);
+}
+
+.file-preview-codeblock .hljs-symbol,
+.file-preview-codeblock .hljs-bullet,
+.file-preview-codeblock .hljs-meta {
+  color: var(--code-theme-constant);
+}
+
+.file-preview-codeblock .hljs-params,
+.file-preview-codeblock .hljs-variable,
+.file-preview-codeblock .hljs-template-variable,
+.file-preview-codeblock .hljs-property {
+  color: var(--code-theme-property);
+}
+
+.file-preview-codeblock .hljs-regexp,
+.file-preview-codeblock .hljs-selector-attr,
+.file-preview-codeblock .hljs-selector-pseudo {
+  color: var(--code-theme-regex);
+}
+
+.file-preview-codeblock .hljs-meta-keyword,
+.file-preview-codeblock .hljs-decorator {
+  color: var(--code-theme-decorator);
+}
+
+.file-preview-codeblock .hljs-emphasis { font-style: italic; }
+.file-preview-codeblock .hljs-strong { font-weight: 600; }
+
+/* ---------------------------------------------------------------------
+ * Layer 2 — gutter (line numbers).
+ *
+ * Layout: a CSS grid keeps the gutter column fixed-width and the code
+ * column flexible. Horizontal scroll lives on the OUTER wrapper; the
+ * inner grid does not scroll, so the gutter never moves.
+ *
+ * Each line is one `.file-preview-codeblock-line` row. The gutter is a
+ * fixed-width span with right-aligned, tabular numerals so a 4-digit
+ * line number (9999) and a 1-digit one (1) take the same column. The
+ * code column has its own left padding so digits never touch the code.
+ * --------------------------------------------------------------------- */
+.file-preview-codeblock {
+  display: block;
+}
+
+.file-preview-codeblock .file-preview-codeblock-line {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  align-items: baseline;
+  column-gap: 12px;
+  padding: 0 12px;
+  /* 22px line height matches the body and gives the gutter its rhythm. */
+  min-height: 22px;
+  line-height: 22px;
+}
+
+.file-preview-codeblock .file-preview-codeblock-gutter {
+  /* Right-align digits; `text-align: end` is RTL-safe. */
+  text-align: end;
+  user-select: none;
+  /* Tabular numerals so 1, 10, 100, 1000 all sit at the same x. */
+  font-variant-numeric: tabular-nums;
+  color: var(--text_default_quaternary);
+  /* Truncate the gutter at 4 digits (max file sizes we expect to see). */
+  min-width: 3ch;
+}
+
+.file-preview-codeblock .file-preview-codeblock-code {
+  /* The code column takes the remaining width and may overflow
+   * horizontally. The outer wrapper handles that scroll, NOT this
+   * inner span — keeping the overflow on the parent preserves the
+   * grid layout (a child that overflows would push the gutter off
+   * screen). The code uses `min-width: max-content` so the column
+   * grows with the longest line; horizontal scroll then exposes the
+   * rest of the line on the outer wrapper. */
+  min-width: max-content;
+  white-space: pre;
+  display: block;
+}
+
+/* The native `.hljs` pre/code shell that wraps the gutter+code rows.
+ * `margin: 0` clears the chat codeblock's default margin; `padding:
+ * 12px 0` gives the first / last line air. */
+.file-preview-codeblock > pre.file-preview-codeblock-pre,
+.file-preview-codeblock > div > pre.file-preview-codeblock-pre {
+  padding: 6px 0;
+}
+
+/* ---------------------------------------------------------------------
+ * Layer 3 — shell (copy button, truncation banner).
+ *
+ * The copy button is sticky to the top-right corner of the scroll
+ * surface, so a long file still has the affordance in view. The
+ * truncation banner is a flex row above the gutter.
+ * --------------------------------------------------------------------- */
\ No newline at end of file
diff --git a/packages/webui/webapp/test/code-highlight.test.ts b/packages/webui/webapp/test/code-highlight.test.ts
new file mode 100644
index 000000000..e67f2697d
--- /dev/null
+++ b/packages/webui/webapp/test/code-highlight.test.ts
@@ -0,0 +1,372 @@
+// webapp/test/code-highlight.test.ts
+//
+// Unit tests for `lib/code-highlight.ts` — the per-language lazy
+// highlight.js wrapper the IDE-grade file preview depends on.
+//
+// Why this file matters:
+//
+//   1. The lazy-load contract: opening a `.js` file must NOT pull in
+//      `python.js`, `rust.js`, `go.js`, etc. Webpack chunks the
+//      dynamic imports, and a regression that re-registered all
+//      languages up front would blow the first paint budget.
+//
+//   2. The unknown-language fallback: an extension the server does
+//      not label, or a label hljs 10.7.3 does not ship, must render
+//      as plain monospace — NO error, NO blank.
+//
+//   3. The truncation contract: a 300 KiB file must NOT keep the
+//      main thread busy for a second. The default cap is
+//      `DEFAULT_LARGE_FILE_TRUNCATE_LINES = 2000` / `..._BYTES = 256
+//      KiB`; these tests pin both.
+//
+//   4. The gutter split: a balanced re-wrapping of the hljs output
+//      across `\n` boundaries so multi-line spans do not bleed
+//      between gutter cells.
+
+import { test, describe, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+
+import {
+  highlightCode,
+  splitHighlightedLines,
+  loadHljsLanguage,
+  _registeredLanguagesForTest,
+  _resetHljsLanguageCacheForTest,
+  _languageToHljsForTest,
+  countLines,
+  byteLength,
+  escapeHtml,
+  splitSourceLines,
+  type HighlightedLine,
+} from "../lib/code-highlight";
+
+beforeEach(() => {
+  // Reset between tests — a single language registered in test A
+  // must not leak into test B's "only one loaded" assertion.
+  _resetHljsLanguageCacheForTest();
+});
+
+describe("highlightCode — happy path", () => {
+  test("highlights a JS file with the resolved language", async () => {
+    const out = await highlightCode("javascript", "const x = 1;\nfunction foo() { return 2; }");
+    assert.ok(out.html, "html should be non-empty for a known language");
+    assert.ok(out.html!.includes("hljs-keyword"), "JS keyword must be wrapped in hljs-keyword");
+    assert.equal(out.language, "javascript");
+    assert.equal(out.truncated, false);
+  });
+
+  test("returns null html and resolved language for python", async () => {
+    const out = await highlightCode("python", "def foo():\n    return 1");
+    assert.ok(out.html, "python highlight must produce html");
+    assert.ok(out.html!.includes("hljs-keyword"));
+    assert.equal(out.language, "python");
+  });
+
+  test("typescript maps to typescript", async () => {
+    const out = await highlightCode("typescript", "const x: number = 1;");
+    assert.equal(out.language, "typescript");
+    assert.ok(out.html!.includes("hljs-keyword"));
+  });
+});
+
+describe("highlightCode — unknown language fallback", () => {
+  test("returns null html and the input label for an unknown server label", async () => {
+    const out = await highlightCode("not-a-real-language", "irrelevant content here");
+    assert.equal(out.html, null, "unknown language must not produce markup");
+    assert.equal(out.language, "not-a-real-language", "label echoed back so the badge still renders");
+    assert.equal(out.truncated, false);
+  });
+
+  test("returns null html and 'plain' for the server's plain fallback", async () => {
+    const out = await highlightCode("plain", "raw text content");
+    assert.equal(out.html, null);
+    assert.equal(out.language, "plain");
+  });
+
+  test("returns null html for empty / whitespace language strings without throwing", async () => {
+    // The component must NEVER call hljs.highlight() with an empty
+    // string — hljs 10.7.3 throws on that input. The lib guard
+    // short-circuits before reaching hljs.
+    const emptyOut = await highlightCode("", "content");
+    assert.equal(emptyOut.html, null);
+    assert.equal(emptyOut.language, null);
+
+    const wsOut = await highlightCode("   ", "content");
+    assert.equal(wsOut.html, null);
+    assert.equal(wsOut.language, null);
+  });
+
+  test("`toml` is unsupported by hljs 10.7.3 — falls back without error", async () => {
+    // The backend labels .toml as 'toml' but the public hljs version
+    // we ship does not have a toml module. The lib MUST return null
+    // html (plain monospace) and MUST NOT throw.
+    const out = await highlightCode("toml", "[section]\nkey = \"value\"");
+    assert.equal(out.html, null);
+    assert.equal(out.language, "toml");
+  });
+
+  test("`html` aliases `xml` — hljs still renders the highlight", async () => {
+    // hljs 10.7.3 has no standalone html module; html is an alias of
+    // xml. Loading xml registers html for free, so the lib returns
+    // real markup rather than a null fallback.
+    const out = await highlightCode("html", "
hi
"); + assert.ok(out.html, "html must highlight via the xml alias"); + assert.equal(out.language, "xml", "the resolved language is xml, not html"); + }); +}); + +describe("highlightCode — lazy grammar loading", () => { + test("opening a JS file registers exactly one grammar", async () => { + const before = _registeredLanguagesForTest().size; + await highlightCode("javascript", "const x = 1"); + const after = _registeredLanguagesForTest(); + assert.deepEqual([...after], ["javascript"], "only javascript registered"); + assert.equal(after.size, before + 1); + }); + + test("opening python does NOT pull in javascript / rust / go / etc.", async () => { + await highlightCode("python", "def f():\n pass"); + const registered = _registeredLanguagesForTest(); + assert.equal(registered.size, 1); + assert.ok(registered.has("python")); + assert.ok(!registered.has("javascript")); + assert.ok(!registered.has("rust")); + assert.ok(!registered.has("go")); + }); + + test("loading the same language twice does not re-register", async () => { + // hljs.registerLanguage throws on duplicates — the cache guard + // exists to avoid that. Two consecutive opens must succeed. + await highlightCode("javascript", "const x = 1"); + await highlightCode("javascript", "const y = 2"); + const registered = _registeredLanguagesForTest(); + assert.equal(registered.size, 1); + assert.ok(registered.has("javascript")); + }); + + test("html resolves to xml and shares the registration", async () => { + await highlightCode("html", "

hi

"); + await highlightCode("xml", ""); + // Both labels share the xml module name, so the cache has ONE entry. + const registered = _registeredLanguagesForTest(); + assert.deepEqual([...registered], ["xml"]); + }); +}); + +describe("loadHljsLanguage — direct API", () => { + test("returns the module name for a supported label", async () => { + const out = await loadHljsLanguage("javascript"); + assert.equal(out, "javascript"); + }); + + test("returns null for an unsupported label", async () => { + const out = await loadHljsLanguage("totally-fake"); + assert.equal(out, null); + }); + + test("returns null for an empty string (defensive)", async () => { + const out = await loadHljsLanguage(""); + assert.equal(out, null); + }); + + test("normalises case and whitespace", async () => { + const out = await loadHljsLanguage(" JavaScript "); + assert.equal(out, "javascript"); + }); +}); + +describe("highlightCode — large file degradation", () => { + test("truncates by line count above the default cap", async () => { + const big = Array.from({ length: 5000 }, (_, i) => `line ${i}`).join("\n"); + const out = await highlightCode("javascript", big); + assert.equal(out.truncated, true); + assert.equal(out.originalLineCount, 5000); + assert.ok(out.visibleLineCount < 5000, `expected visible < 5000, got ${out.visibleLineCount}`); + assert.equal(out.visibleLineCount, 1500, "default cap is 1500 lines"); + }); + + test("truncates by byte count above the default cap", async () => { + // 1 MiB of `x` characters — pathological input that hljs would + // take 60+ seconds to lex; the byte budget must short-circuit + // it before the highlight step runs. + const huge = "x".repeat(1024 * 1024); + const out = await highlightCode("javascript", huge); + assert.equal(out.truncated, true); + assert.ok(out.visibleLineCount < 1024 * 1024, "byte-truncation reduces the visible line count"); + }); + + test("does NOT truncate under the cap", async () => { + const small = Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n"); + const out = await highlightCode("javascript", small); + assert.equal(out.truncated, false); + assert.equal(out.originalLineCount, undefined, "no truncation means no original-count field"); + assert.equal(out.visibleLineCount, 100); + }); + + test("honours a custom maxLines", async () => { + const content = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"); + const out = await highlightCode("javascript", content, { maxLines: 10 }); + assert.equal(out.truncated, true); + assert.equal(out.visibleLineCount, 10); + }); +}); + +describe("splitHighlightedLines — gutter alignment", () => { + test("produces one record per source line, with the right number", async () => { + const content = "const x = 1;\nconst y = 2;\nconst z = 3;"; + const result = await highlightCode("javascript", content); + const split = splitHighlightedLines(result, content); + assert.equal(split.lines.length, 3); + assert.deepEqual(split.lines.map((l) => l.number), [1, 2, 3]); + }); + + test("the .text field is the raw source (used for copy)", async () => { + const content = "const x = 1;\nconst y = 2;"; + const result = await highlightCode("javascript", content); + const split = splitHighlightedLines(result, content); + assert.equal(split.lines[0]!.text, "const x = 1;"); + assert.equal(split.lines[1]!.text, "const y = 2;"); + }); + + test("falls back to escaped raw text for unknown languages", async () => { + const content = "\nplain line"; + const result = await highlightCode("plain", content); + const split = splitHighlightedLines(result, content); + assert.equal(split.lines.length, 2); + // The HTML must NOT contain a raw `