Wrap long code lines instead of scrolling sideways (#219)

Code files in the viewer scrolled horizontally for any line wider than
the pane. Wrap them instead, in both the read-only view and the editor.

Wrapping breaks the old gutter's assumption that source line N is visual
row N, so each line becomes its own row carrying its own number:
highlightLines() splits the refractor tree per source line, reopening
tokens that straddle a newline.

In the editor the overlay's numbers are absolutely positioned out of the
line box — an in-flow number is an atomic inline offering a break
opportunity the textarea lacks, which desynced the two layers on long
URLs and base64. Every code element also spells out its font: Preflight
isn't imported, so the UA `pre, code { font-family: monospace }` beats an
inherited font and would give the layers different fonts and `ch` units.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Myles Anderson
2026-08-19 17:02:33 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent d16359ce19
commit 38e9943df6
8 changed files with 1266 additions and 119 deletions
+1034
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
html { background: #ffffff; }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-CFPQ5Tuj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-wpJeURKR.css">
<script type="module" crossorigin src="/assets/index-Bx_pvhOP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_dWCzRy.css">
</head>
<body>
<div
+23
View File
@@ -0,0 +1,23 @@
// Layout the read-only code view and the editor's highlighted overlay must
// agree on: the two render the same file side by side (the overlay sits under a
// transparent textarea), so any divergence in font metrics, wrapping or gutter
// width shows up as text that doesn't line up.
// Preflight isn't imported (see tailwind.css), so the UA's
// `pre, code { font-family: monospace }` beats an inherited font — every code
// element needs these spelled out rather than relying on an ancestor.
export const CODE_TEXT_CLASS_NAME = "font-mono text-sm leading-[1.55] [tab-size:4]";
export const CODE_WRAP_CLASS_NAME = "whitespace-pre-wrap break-words";
export const CODE_GUTTER_CLASS_NAME = "file-view-gutter text-right text-muted select-none";
/** Column positions in `ch`, so they track the mono font: the rule sits at
* `ruleCh` (the number is right-aligned just inside it), code starts at
* `codeCh` — 2ch past the rule, which is where both views pad their code. Only
* meaningful on an element with CODE_TEXT_CLASS_NAME. */
export function codeGutter(lineCount: number): { ruleCh: number; codeCh: number } {
const ruleCh = String(lineCount).length + 2;
// The 2ch gap is spelled `pl-[2ch]` on CodeView's code column.
return { ruleCh, codeCh: ruleCh + 2 };
}
+76 -55
View File
@@ -1,12 +1,20 @@
// Inline source editor: a transparent <textarea> layered over the same
// refractor-highlighted <pre> + gutter that CodeView renders read-only, so
// syntax colors stay live while typing. It IS the view for editable files —
// there's no separate mode, you just click and type. The textarea owns input,
// caret and selection; the highlighted pre and gutter are scroll-synced to it.
// refractor-highlighted lines CodeView renders read-only, so syntax colors stay
// live while typing. It IS the view for editable files — there's no separate
// mode, you just click and type. The textarea owns input, caret and selection;
// the highlighted overlay is scroll-synced to it. Line numbers are absolutely
// positioned out of the overlay's line boxes: anything in flow there is a wrap
// opportunity the textarea doesn't have, which desyncs the two layers.
// Token colors apply under a `.file-view` ancestor (see CodeView).
import { useLayoutEffect, useMemo, useRef } from "react";
import {
CODE_GUTTER_CLASS_NAME,
CODE_TEXT_CLASS_NAME,
CODE_WRAP_CLASS_NAME,
codeGutter,
} from "../codeLayout";
import { detectSyntaxLanguageFromFilePath } from "../syntaxLanguage";
import { highlight } from "../syntaxHighlight";
import { highlightLines, isBlankLine } from "../syntaxHighlight";
export function CodeEditor({
value,
@@ -32,46 +40,42 @@ export function CodeEditor({
scrollRequest?: number;
onScrollRequestHandled?: () => void;
}) {
const rendered = useMemo(
() => highlight(value, detectSyntaxLanguageFromFilePath(path)),
// A trailing newline opens a new (empty) line the caret can sit on, so unlike
// the read-only view every "\n" gets a row.
const lines = useMemo(
() => highlightLines(value, detectSyntaxLanguageFromFilePath(path)),
[value, path],
);
// A trailing newline opens a new (empty) line the caret can sit on, so unlike
// the read-only view every "\n" counts toward the gutter.
const lineCount = value ? value.split("\n").length : 1;
const { ruleCh, codeCh } = codeGutter(lines.length);
const taRef = useRef<HTMLTextAreaElement>(null);
const preRef = useRef<HTMLPreElement>(null);
const gutterRef = useRef<HTMLPreElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
// Keep the highlighted layer and the gutter pinned to the textarea's scroll.
// Keep the highlighted layer pinned to the textarea's scroll.
const syncScroll = () => {
const ta = taRef.current;
if (!ta) return;
if (preRef.current) {
preRef.current.scrollTop = ta.scrollTop;
preRef.current.scrollLeft = ta.scrollLeft;
}
if (gutterRef.current) gutterRef.current.scrollTop = ta.scrollTop;
if (ta && overlayRef.current) overlayRef.current.scrollTop = ta.scrollTop;
};
// Re-sync after content changes relayout (e.g. a newline shifts scrollHeight).
useLayoutEffect(syncScroll, [value]);
// On open via a `file:line` chip, park the caret on that line and center it.
// Re-runs when the file changes (path) or a new chip targets the open file
// (scrollRequest), mirroring CodeView's re-navigation.
// (scrollRequest) — which is also what makes it land: the first run sees the
// draft before FileViewer's passive effect has seeded it, and clearing the
// request re-runs this against real content.
useLayoutEffect(() => {
const ta = taRef.current;
if (!ta || !highlightLine) return;
const cs = getComputedStyle(ta);
const padTop = Number.parseFloat(cs.paddingTop) || 0;
const lineH = Number.parseFloat(cs.lineHeight) || 0;
const target = Math.min(Math.max(Math.trunc(highlightLine), 1), lineCount);
const lines = value.split("\n");
const text = value.split("\n");
const target = Math.min(Math.max(Math.trunc(highlightLine), 1), text.length);
let caret = 0;
for (let i = 0; i < target - 1; i++) caret += lines[i].length + 1;
for (let i = 0; i < target - 1; i++) caret += text[i].length + 1;
ta.setSelectionRange(caret, caret);
if (lineH) ta.scrollTop = Math.max(0, padTop + (target - 1) * lineH - ta.clientHeight / 2);
// The overlay wraps exactly like the textarea, so its row is where the line
// actually sits — line-height arithmetic would miss by every wrap.
const row = overlayRef.current?.querySelector<HTMLElement>(`[data-line="${target}"]`);
if (row) ta.scrollTop = Math.max(0, row.offsetTop - ta.clientHeight / 2);
syncScroll();
onScrollRequestHandled?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -96,38 +100,55 @@ export function CodeEditor({
}
};
// Both layers must reserve the scrollbar, or the textarea is narrower than the
// overlay on classic-scrollbar platforms and the two wrap at different columns.
const layerClassName = `absolute inset-0 m-0 py-3.5 pr-4 ${CODE_TEXT_CLASS_NAME} ${CODE_WRAP_CLASS_NAME} [scrollbar-gutter:stable]`;
return (
<div className="file-view-editwrap flex items-stretch h-full min-h-0 relative">
<pre
ref={gutterRef}
className="file-view-gutter m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-3.5 pr-2.5 text-right text-muted select-none bg-background border-r border-r-border-variant shrink-0 overflow-hidden"
<div className={`file-view-editwrap relative h-full min-h-0 ${CODE_TEXT_CLASS_NAME}`}>
<div
className="absolute left-0 top-0 bottom-0 border-r border-r-border-variant pointer-events-none"
style={{ width: `${ruleCh}ch` }}
aria-hidden="true"
/>
<div
ref={overlayRef}
className={`file-view-code ${layerClassName} overflow-hidden pointer-events-none`}
aria-hidden="true"
>
{Array.from({ length: lineCount }, (_, i) => i + 1).join("\n")}
</pre>
<div className="relative flex-1 min-w-0">
<pre
ref={preRef}
className="file-view-code absolute inset-0 m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-4 pr-4 [tab-size:4] whitespace-pre overflow-hidden pointer-events-none"
aria-hidden="true"
>
<code>{rendered}</code>
</pre>
<textarea
ref={taRef}
className="file-view-editarea absolute inset-0 m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-4 pr-4 [tab-size:4] whitespace-pre overflow-auto resize-none border-0 bg-transparent text-transparent caret-[var(--text)] outline-none"
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={syncScroll}
onKeyDown={onKeyDown}
onBlur={onBlur}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
wrap="off"
/>
{lines.map((line, i) => (
<div
key={i}
/* The scroll target for `file:line` — measured, so it's on the row. */
data-line={i + 1}
className="relative"
style={{ paddingLeft: `${codeCh}ch` }}
>
<span
className={`${CODE_GUTTER_CLASS_NAME} absolute left-0 pr-[1ch]`}
style={{ width: `${ruleCh}ch` }}
>
{i + 1}
</span>
{/* Out-of-flow numbers leave a blank line with no line box at all. */}
{isBlankLine(line) ? <br /> : line}
</div>
))}
</div>
<textarea
ref={taRef}
className={`file-view-editarea ${layerClassName} overflow-y-auto overflow-x-hidden resize-none border-0 bg-transparent text-transparent caret-[var(--text)] outline-none`}
style={{ paddingLeft: `${codeCh}ch` }}
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={syncScroll}
onKeyDown={onKeyDown}
onBlur={onBlur}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</div>
);
}
+72 -56
View File
@@ -1,9 +1,15 @@
// Shared source-code block: line-number gutter + refractor-highlighted
// content, used by the repo file viewer and the Artifacts tab preview. Style
// scoping note: syntax token colors apply under a `.file-view` ancestor.
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef } from "react";
import {
CODE_GUTTER_CLASS_NAME,
CODE_TEXT_CLASS_NAME,
CODE_WRAP_CLASS_NAME,
codeGutter,
} from "../codeLayout";
import { detectSyntaxLanguageFromFilePath } from "../syntaxLanguage";
import { highlight } from "../syntaxHighlight";
import { highlightLines, isBlankLine } from "../syntaxHighlight";
export function CodeView({
text,
@@ -19,72 +25,82 @@ export function CodeView({
scrollRequest?: number;
onScrollRequestHandled?: () => void;
}) {
const rendered = useMemo(
() => highlight(text, detectSyntaxLanguageFromFilePath(path)),
[text, path],
);
// One number per source line; a trailing newline ends a line, it doesn't
// start an empty one.
const lineCount = text ? text.split("\n").length - (text.endsWith("\n") ? 1 : 0) : 0;
const targetLine = highlightLine && lineCount > 0
? Math.min(Math.max(Math.trunc(highlightLine), 1), lineCount)
: undefined;
// A trailing newline ends a line, it doesn't start an empty one. Empty files
// render no rows at all — a lone gutter is just a stray bordered strip.
const lines = useMemo(() => {
if (!text) return [];
// CR is a segment break under pre-wrap, so any CR-bearing file would
// render every row double-height.
const source = text.replace(/\r\n?/g, "\n");
const all = highlightLines(source, detectSyntaxLanguageFromFilePath(path));
return source.endsWith("\n") ? all.slice(0, -1) : all;
}, [text, path]);
const targetLine =
highlightLine && lines.length > 0
? Math.min(Math.max(Math.trunc(highlightLine), 1), lines.length)
: undefined;
const codeRef = useRef<HTMLPreElement>(null);
const bandRef = useRef<HTMLDivElement>(null);
// Highlight band geometry, measured from the code's real font metrics so it
// lands on the same row as the gutter number (both share padding/line-height).
const [band, setBand] = useState<{ line: number; top: number; height: number } | null>(null);
const targetRowRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const code = codeRef.current;
if (!targetLine || !code) {
setBand(null);
return;
}
const cs = getComputedStyle(code);
const padTop = Number.parseFloat(cs.paddingTop) || 0;
const lineH = Number.parseFloat(cs.lineHeight) || 0;
if (!lineH) {
setBand(null);
return;
}
setBand({ line: targetLine, top: padTop + (targetLine - 1) * lineH, height: lineH });
// `rendered` is a dep so the band re-measures/re-scrolls once new file
// content has laid out, not against the previous file's metrics.
}, [rendered, targetLine]);
// Center the highlighted line in the scroll viewport once its band exists.
useLayoutEffect(() => {
// Center the highlighted line in the scroll viewport. Passive, unlike the
// editor's: this scrolls the shared viewport, which FileViewer restores in a
// layout effect that runs after ours and would otherwise win.
useEffect(() => {
if (scrollRequest === undefined) return;
if (band && band.line === targetLine) {
bandRef.current?.scrollIntoView({ block: "center" });
if (targetLine) {
targetRowRef.current?.scrollIntoView({ block: "center" });
onScrollRequestHandled?.();
} else if (lineCount === 0) {
} else if (lines.length === 0) {
onScrollRequestHandled?.();
}
}, [band, lineCount, onScrollRequestHandled, scrollRequest, targetLine]);
}, [lines.length, onScrollRequestHandled, scrollRequest, targetLine]);
const { ruleCh } = codeGutter(lines.length);
// Rows are memoized so an unrelated parent re-render (chat streaming) doesn't
// reconcile every line of the open file.
const rows = useMemo(
() =>
lines.map((line, i) => (
<div
key={i}
ref={i + 1 === targetLine ? targetRowRef : undefined}
className={`file-view-line flex items-stretch ${
i + 1 === targetLine
? "file-view-line-highlight bg-[color-mix(in_srgb,_var(--primary)_16%,_transparent)] shadow-[inset_2px_0_0_var(--primary)]"
: ""
}`}
>
{/* The number is generated content so a drag-selection over the
code never picks it up. */}
<span
data-line={i + 1}
className={`${CODE_GUTTER_CLASS_NAME} before:content-[attr(data-line)] shrink-0 pr-[1ch]`}
style={{ width: `${ruleCh}ch` }}
aria-hidden="true"
/>
<code
className={`file-view-code flex-1 min-w-0 pl-[2ch] pr-4 ${CODE_TEXT_CLASS_NAME} ${CODE_WRAP_CLASS_NAME}`}
>
{/* An empty <code> serializes to nothing, dropping blank lines
from a copied selection. */}
{isBlankLine(line) ? <br /> : line}
</code>
</div>
)),
[lines, ruleCh, targetLine],
);
return (
<div className="file-view-codewrap flex items-start min-w-max relative">
{/* No numbers for an empty file — an empty gutter is just a stray
bordered strip. */}
{lineCount > 0 && (
<pre className="file-view-gutter m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-3.5 pr-2.5 text-right text-muted select-none sticky left-0 bg-background border-r border-r-border-variant shrink-0" aria-hidden="true">
{Array.from({ length: lineCount }, (_, i) => i + 1).join("\n")}
</pre>
)}
<pre className="file-view-code m-0 pt-3.5 pb-3.5 font-mono text-sm leading-[1.55] pl-4 pr-4 [tab-size:4] min-w-max" ref={codeRef}>
<code>{rendered}</code>
</pre>
{band && (
<div className={`file-view-codewrap relative py-3.5 ${CODE_TEXT_CLASS_NAME}`}>
{lines.length > 0 && (
<div
ref={bandRef}
className="file-view-line-highlight absolute left-0 right-0 pointer-events-none bg-[color-mix(in_srgb,_var(--primary)_16%,_transparent)] shadow-[inset_2px_0_0_var(--primary)]"
style={{ top: band.top, height: band.height }}
className="absolute left-0 top-0 bottom-0 border-r border-r-border-variant pointer-events-none"
style={{ width: `${ruleCh}ch` }}
aria-hidden="true"
/>
)}
{rows}
</div>
);
}
+2
View File
@@ -259,6 +259,8 @@ export function FileViewer({
};
}, [projectId, path, source, sessionId, gitRef, nonce]);
// Stays a layout effect: the code views scroll to a `file:line` target in
// passive effects, which run after this and so win over the restore.
useLayoutEffect(() => {
const body = bodyRef.current;
const position = scrollPositionRef.current;
+56 -6
View File
@@ -13,6 +13,17 @@ interface HastNode {
children?: HastNode[];
}
/** Tokenize `code`, best-effort: null when the language isn't registered, the
* input is too large, or tokenizing throws — callers fall back to plain text. */
function tokenize(code: string, lang: string | null, maxBytes: number): HastNode[] | null {
if (!lang || !refractor.registered(lang) || code.length > maxBytes) return null;
try {
return refractor.highlight(code, lang).children as HastNode[];
} catch {
return null;
}
}
function hastToReact(node: HastNode, key: number): ReactNode {
if (node.type === "text") return node.value ?? "";
if (node.type !== "element") return null;
@@ -26,10 +37,49 @@ function hastToReact(node: HastNode, key: number): ReactNode {
/** Highlight `code` in `lang`, best-effort: returns the raw string when the
* language isn't registered, the input is too large, or tokenizing throws. */
export function highlight(code: string, lang: string | null, maxBytes = 300_000): ReactNode {
if (!lang || !refractor.registered(lang) || code.length > maxBytes) return code;
try {
return (refractor.highlight(code, lang).children as HastNode[]).map(hastToReact);
} catch {
return code;
}
return tokenize(code, lang, maxBytes)?.map(hastToReact) ?? code;
}
/** Highlight `code` and split it into one node per source line, so callers can
* pair each line with a gutter number that stays put when the line wraps. */
export function highlightLines(code: string, lang: string | null, maxBytes = 300_000): ReactNode[] {
const tokens = tokenize(code, lang, maxBytes);
if (!tokens) return code.split("\n");
const lines: ReactNode[][] = [];
let line: ReactNode[] = [];
// Class names of the token spans enclosing the text being emitted; a token
// that straddles a newline is reopened on the next line.
const open: string[] = [];
let key = 0;
const emit = (text: string) => {
let node: ReactNode = text;
for (let i = open.length - 1; i >= 0; i--) {
node = <span key={key++} className={open[i]}>{node}</span>;
}
line.push(node);
};
const walk = (node: HastNode) => {
if (node.type === "text") {
(node.value ?? "").split("\n").forEach((part, i) => {
if (i > 0) {
lines.push(line);
line = [];
}
if (part) emit(part);
});
return;
}
if (node.type !== "element") return;
open.push((node.properties?.className ?? []).join(" "));
(node.children ?? []).forEach(walk);
open.pop();
};
tokens.forEach(walk);
lines.push(line);
return lines;
}
/** A `highlightLines` entry with no content — an empty source line. */
export function isBlankLine(line: ReactNode): boolean {
return Array.isArray(line) ? line.length === 0 : line === "";
}