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..5f3d03ba8 --- /dev/null +++ b/packages/webui/webapp/components/code-view.tsx @@ -0,0 +1,405 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ClipboardEvent as ReactClipboardEvent, +} 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; + /** Source ended with `\n` — copy path restores it for round-trip. */ + trailingNewline: boolean; + } + | 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, + trailingNewline: + content.length > 0 && + content.charCodeAt(content.length - 1) === 10, + }, + 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; +}) { + // The outer wrapper is a two-column grid whose HEADER ROW SPANS BOTH + // columns (see `.file-preview-codeblock` in the stylesheet): the copy + // button row is one shared grid row, so whatever height it takes — a + // longer localized label, a narrower window — pushes the gutter and + // the code down by the same amount. Only the code column scrolls + // horizontally; the gutter stays pinned. (The pre-grid design put a + // `padding-top: 40px` mirror on the gutter, which drifted 1:1 with + // every header-height change: +22 px of header → dy 22 on all rows.) + return ( +
+ {!split ? ( +
{t("app.connecting")}
+ ) : ( + + )} +
+ ); +} + +function CodeTable({ + split, + t, + locale, +}: { + split: NonNullable; + t: (key: MessageKey) => string; + locale: Locale; +}) { + // Copy button: the rendered DOM subtree contains gutter digits + // AND code — a naive `textContent` would include both, leaking + // numbers into the clipboard. Instead, the split records carry + // the raw source per line; joining with `\n` reconstructs the + // exact source text (interior blanks are preserved because each + // blank line is an empty `text`, not a missing line). We also + // preserve a trailing newline so the source round-trips + // byte-for-byte: a file ending in `\n` produces N lines whose + // joined form is `line 1\n...\nline N-1` (no trailing `\n`) — the + // split marks `trailingNewline: true` when the source had one, + // and the copy path appends it back. Without this the user could + // `cp file.js file.js.bak; view in panel; cp clipboard file.js` and + // lose the final newline — a real annoyance for scripts. + const onCopy = async () => { + let text = split.lines.map((line) => line.text).join("\n"); + if (split.trailingNewline) text += "\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(); + } + }; + + // Selection copy: Chromium's Selection serialisation skips a block + // whose only child is empty, so a blank line contributes NO newline + // and a plain Ctrl+C silently drops every blank line (measured: a + // 148-byte source with 4 blanks → 143 chars). Zero-width characters + // are not a fix — they would land in the clipboard as invisible + // junk, and U+00A0 is what this feature just got rid of. So the + // copy event is intercepted and the clipboard is written from the + // split's recorded line texts — the same source of truth the Copy + // button uses — so a selection round-trips the source bytes: + // interior blanks, tabs and (when the selection reaches the last + // rendered row of a file that ends with `\n`) the trailing newline. + // Line granularity is deliberate: a partial-line selection copies + // the whole line, never a truncation of it. + const onSelectionCopy = useCallback( + (event: ReactClipboardEvent) => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; + const rows = event.currentTarget.querySelectorAll("[data-line]"); + if (rows.length === 0) return; + const range = sel.getRangeAt(0); + let first = -1; + let last = -1; + rows.forEach((row, i) => { + if (range.intersectsNode(row)) { + if (first < 0) first = i; + last = i; + } + }); + if (first < 0) return; + let text = split.lines + .slice(first, last + 1) + .map((line) => line.text) + .join("\n"); + if (last === split.lines.length - 1 && split.trailingNewline) text += "\n"; + event.clipboardData.setData("text/plain", text); + event.preventDefault(); + }, + [split], + ); + + // The grid children of `file-preview-codeblock` (see the stylesheet): + // row 1 — `file-preview-codeblock-header`, spanning BOTH columns. + // The copy button lives here, outside the horizontal scroller, + // so its height is ONE shared grid row: whatever height it + // takes pushes the gutter and the code down by the same amount. + // row 2, column 1 —