Repository navigation
feat(webui): IDE-grade code preview — line numbers, lazy per-language highlighting (webui-parity slice 22) - #64
Merged
Merged
Conversation
…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 ` `), 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.user-select: noneso line numbers never enter a copy.languagefield (no re-guessing); unknown, empty or whitespace languages fall back to plain monospace with a badge, never an error or a blank.Two implementation details worth calling out
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: thehighlight.jsroot entry auto-registers every grammar on load. The fix washighlight.js/lib/coreplus aswitchof literalimport()branches. Verified at the artefact level: unopened grammars are absent from the entire shipped output, and opening a.jsfetches exactly its own chunk.getSelection().toString()stays lossy by design. Thecopyevent is intercepted and the clipboard is rewritten from the split's recorded line texts, which is what Ctrl+C actually delivers.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.
.envis 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:webapp1013/1013 ·webapp:typecheck0 · repotypecheck0 ·check:source✓ ·check:standalone✓ after root build · full server suite 1946 pass / 0 fail / 2 skipped on a freshly builtwebapp/out·pnpm install --frozen-lockfileclean on a fresh clone