Skip to content

feat(webui): IDE-grade code preview — line numbers, lazy per-language highlighting (webui-parity slice 22) - #64

Merged
fengzhi09 merged 4 commits into
mainfrom
feat/ide-code-preview
Sep 28, 2026
Merged

fengzhi09 merged 4 commits into
mainfrom
feat/ide-code-preview

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

What

Slice 22, from the reporter: 「代码文件要支持行号和语言关键字,标准的 ide 预览效果。」 Before this, source files rendered as a plain monospace <pre> with a language badge — no highlighting, no line numbers.

  • Gutter with line numbers, aligned row-for-row with the code, pinned during horizontal scroll, living in its own DOM subtree with user-select: none so line numbers never enter a copy.
  • Syntax highlighting via highlight.js 10.7.3 with real per-language lazy loading — each grammar is its own webpack chunk, and the initial payload carries none of them.
  • Language comes from the backend language field (no re-guessing); unknown, empty or whitespace languages fall back to plain monospace with a badge, never an error or a blank.
  • Bounded work on large or pathological input: caps are applied before highlighting, so a file that would take the highlighter tens of seconds degrades to a bounded, honestly-labelled truncation.
  • Readable in both themes by mapping tokens onto the existing theme token layer.

Two implementation details worth calling out

  1. Laziness required two fixes, not one. The first attempt used an expression-form import() of the grammar path, which webpack resolves as a context module — that pulled all 191 grammars into the initial route chunk. The real cause went deeper: the highlight.js root entry auto-registers every grammar on load. The fix was highlight.js/lib/core plus a switch of literal import() branches. Verified at the artefact level: unopened grammars are absent from the entire shipped output, and opening a .js fetches exactly its own chunk.
  2. Copy fidelity is enforced at the clipboard, not the DOM. A blank line is an empty block, and native DOM serialisation emits no newline for it — so getSelection().toString() stays lossy by design. The copy event is intercepted and the clipboard is rewritten from the split's recorded line texts, which is what Ctrl+C actually delivers.
  3. The gutter/code alignment is structural, not a magic number. The codeblock is a two-column grid whose first row is the copy-button header spanning both columns, with a shared row-gap; no offset constant exists to drift.

Verification

This slice needed four acceptance rounds, and the last one was re-run by a fresh independent agent after the previous verifier was found to have authored the fix it was reporting on. That independent pass reproduced the claims and additionally proved the alignment cannot drift by patching the shipped export CSS to make the header 32 px taller — dy stayed 0 on every row — then restoring the file byte-exact.

  • Ctrl+C byte-fidelity: 105/105, 327/327 (63 rows, 48 interior blank lines, a tab line, trailing newline) and 4050/4050 bytes; blank-line-bounded selections restore the blank, a single-blank selection delivers it, a tail selection restores the trailing newline; no U+00A0 and no line-number digits.
  • Alignment: dy = 0 for every row at 1600/1280/1000/720 px in both locales; horizontal pin dx = 0 at scrollLeft 2500–3000.
  • Laziness: unopened grammars absent from the shipped output; 22 grammars in individual chunks, none in the initial payload.
  • Pathological input: a 32 KiB cap fires before highlighting, turning a multi-second highlight into ~1–2 s of bounded work with a visible truncation notice.
  • Regressions: markdown, image, the oversize card, the credential refusal (a .env is refused, not rendered as code, with no leakage), sandbox="allow-scripts" intact, and the tree → preview open path.

Disclosed behaviour

Selecting a partial line copies the whole line (deliberate — never a truncation), and a document-wide Ctrl+A leaves the code block and serialises the surrounding app chrome, which is lossy for blank lines but cannot leak line numbers because the gutter is user-select: none.

Gates

test:webapp 1013/1013 · webapp:typecheck 0 · repo typecheck 0 · check:source ✓ · check:standalone ✓ after root build · full server suite 1946 pass / 0 fail / 2 skipped on a freshly built webapp/out · pnpm install --frozen-lockfile clean on a fresh clone

…g, lazy grammar load)

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.
…n horizontal scroll

Three blockers the previous round caught, plus two small fixes
acceptance flagged:

Lockfile. pnpm-lock.yaml now lists highlight.js@10.7.3 under the
webui importer; pnpm install --frozen-lockfile passes on a clean
tree (committed; verified twice).

Real per-language laziness. Replaced the expression-form dynamic
import with a switch of literal import() branches (one per
supported language), AND switched the hljs entry point from
highlight.js (the root, which auto-registers all 191 grammars at
module load) to highlight.js/lib/core (zero grammars, populated
only by the explicit switch). Added webapp/types/highlight-langs.d.ts
with ambient declarations for each language module so the
literal imports type-check. Verified by pnpm run build then
grepping the production route chunks (the eight listed in
.next/app-build-manifest.json for /page) for unopened grammars:
irpf90, php-template, x86asm, zephir, protobuf, vim, accesslog all
ABSENT. The javascript grammar lands in 593.0652cd5d0561d1c1.js
(5164 B); python in 87.7d2dd063ca3f61f1.js (3345 B); each as its
own webpack chunk, not in the initial route payload.

Gutter pinned on horizontal scroll. Restructured CodeBody into a
flex row of two siblings: file-preview-codeblock-gutter-column
(aside, overflow-y only) and file-preview-codeblock-scroll (div,
overflow auto with the pre inside). The gutter is OUTSIDE the
horizontal scroll wrapper; scrollTop syncs vertically via a
scroll handler. Measured via Playwright: with scrollLeft=600, the
gutter's bounding-rect left moved 0px and its width stayed
23.625px (same before and after the scroll).

Blank lines. Removed the nbsp fallback in the per-line HTML; the
line container's min-height keeps the row reserved while line.text
is an empty string. Selection copy now yields real empty lines,
not U+00A0.

Inclusive boundary. shouldTruncate is now
totalLines >= maxLines || bytes >= maxBytes so a file of exactly
the cap is short-circuited. New test pins the boundary: 1500-line
content with default 1500 cap returns truncated: true.

Source inventory regenerated (4669 files); check:source,
check:standalone, both typechecks all pass; test:webapp is
1010/1010 (37 + 1 new boundary test).
…ailing newline on copy

Three real defects the previous round caught, all sharing one root
cause: the copy-button row was positioned only inside the scroll
column (so the gutter column started at y=0 while the scroll
column started at y=button-height+padding), and code rows had no
min-height (so they collapsed to 0 on blank lines, drifting the
whole layout as more blanks accumulated). Selection copy dropped
blanks AND the trailing newline for the same reason.

Align the two columns. The scroll column has a copy button
(m-1.5 margin + ~22 px content) plus a 6 px pre padding-top, so the
first code row sits at ~34+6 = 40 px from the wrapper top. The
gutter column mirrors that with `padding-top: 40px; padding-bottom:
6px` — the same exact mirror of the scroll column's vertical
layout, not a magic number that depends on font metrics. Measured
in the browser: with `with-blanks.js` (16 lines, 4 consecutive
blanks around line 9-12) every gutter row's `getBoundingClientRect`
top matches its code row's top exactly, dy = 0 across rows 1-16.

Every row is now 22 px tall. Added `min-height: 22px` to both
`.file-preview-codeblock-line` and `.file-preview-codeblock-gutter`.
Without it a `<div>` with empty `dangerouslySetInnerHTML` collapses
to 0 px and every subsequent row shifts up — by 22 px per blank.
With it the cumulative desync cannot occur at all. Blank lines
also carry an empty string in `line.text` (no `&nbsp;`), so
selection copy round-trips the original source bytes.

Trailing newline preserved. New `trailingNewline: boolean` flag on
the split result records whether the source content ended with
`\n`. `splitSourceLines` strips the trailing empty entry (so the
gutter doesn't show a phantom empty last row), so without the
flag the join would silently drop the final newline. Copy path
appends `'\n'` when the flag is true. New test pins both sides:
source ending in `\n` flags true; source without trailing `\n`
flags false.

Verified clipboard round-trip in the browser. Read
`with-blanks.js` (179 B) and `sample.js` (610 B) from the API,
clicked the copy button, read `navigator.clipboard.readText()`,
both equal the source byte-for-byte.

Gates: pnpm install --frozen-lockfile clean; webapp:typecheck,
repo typecheck, check:source (4669 files), check:standalone,
test:webapp 1011/1011 (37 + 1 new trailingNewline test),
webapp:build PASS.
…red header grid row

Two defects from re-verification of d3dc335, one blocking:

Selection copy dropped blank lines. Chromium's Selection
serialisation emits no newline for a block whose only child is
empty, so the blank row (an empty span after the nbsp removal)
contributed nothing: copying a 148-byte file with 4 blanks yielded
143 chars. Zero-width characters are not a fix — they land in the
clipboard as invisible junk. The copy event is now intercepted on
the pre and the clipboard is written from the split's recorded line
texts (the Copy button's source of truth), for the selected line
range, restoring the trailing newline when the selection reaches
the last row of a file that ends with one. The button path is
untouched and stays byte-exact.

The 40px gutter mirror drifted 1:1 with the header height. Forced
+22px of header padding put every row's dy at 22; a wrapped button
put it at 32. The wrapper is now a two-column grid whose row 1 is
the copy-button header spanning BOTH columns (grid-column: 1/-1):
whatever height the header takes pushes gutter and code down
equally, so the columns cannot drift by construction. The pre's
6px top padding became the grid's 6px row gap (applies to both
columns); the gutter column carries no vertical padding.

Both invariants are pinned by static-source tripwires in
code-highlight.test.ts (the suite has no DOM render harness):
copy-event wiring + clipboard rewrite on the pre, and the grid
header row with no padding-top anywhere in the gutter rule.
@fengzhi09
fengzhi09 merged commit b534225 into main Sep 28, 2026
18 checks passed
@fengzhi09
fengzhi09 deleted the feat/ide-code-preview branch September 28, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant