feat(ui): syntax-highlight the read-only source previews (#1284)

The read-only previews beside Problems, Show Usages and TODO results are syntax-coloured from one whole-file parse (capped for very large files), so a line inside a block comment or a multi-line string is coloured as such; the pane renders plain first and recolours when the parse lands. SourcePreview takes the colouring as a colorize prop (the editor's previewColors), keeping src/ui free of editor imports, and the tok-* colours live once in global.css instead of a copy per view, with the theme's comment style.

Co-authored-by: qtvbwfn <[email protected]>
Цей коміт міститься в:
Qingтаqtvbwfn authored and GitHub committed 2026-11-05 00:55:03 +08:00
1 джерело f4fa2b42f6
коміт 78a3768ec1
15 files changed
+297 -293

No files matched your search

+69 -1
Переглянути файл
@@ -1,11 +1,77 @@
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { previewColors } from "@/editor/lineTokens";
import { SourcePreview } from "./SourcePreview";
/** A stable loader reference (a new function reloads, like the real module-level ones). */
const loadOf = (content: string) => () => Promise.resolve(content);
const firstTok = (container: HTMLElement) => container.querySelector<HTMLElement>("[class*='tok-']");
describe("SourcePreview", () => {
it("colours lines with the whole file as context", async () => {
const { container } = render(
<SourcePreview
colorize={previewColors}
path="/p/a.ts"
line={1}
load={loadOf('const lib = "x";\n/* block\ncomment */')}
/>,
);
await waitFor(() => expect(container.querySelector(".tok-string")).toBeTruthy(), { timeout: 5000 });
expect(container.querySelector(".tok-string")?.textContent).toBe('"x"');
// `comment */` only reads as a comment tail when the file was parsed whole.
expect(container.querySelector('[data-line="2"] .tok-comment')?.textContent).toBe("comment */");
});
it("marks ranges inside tokens without losing text", async () => {
const content = 'const lib = "xyz";';
const { container } = render(
<SourcePreview
colorize={previewColors}
path="/p/a.ts"
line={0}
load={loadOf(content)}
highlights={[{ line: 0, ranges: [[13, 15]] }]}
/>,
);
await waitFor(() => expect(container.querySelector("mark .tok-string")).toBeTruthy(), { timeout: 5000 });
// `xy` sits inside the string token: marked, while the rest stays coloured.
expect(container.querySelector("mark")?.textContent).toBe("xy");
expect(container.querySelector("mark .tok-string")?.textContent).toBe("xy");
expect(container.textContent).toBe(`1${content}`);
});
it("leaves unknown languages plain", async () => {
const content = 'const lib = "x";';
const { container } = render(
<SourcePreview colorize={previewColors} path="/p/a.unknownext" line={0} load={loadOf(content)} />,
);
await screen.findByText(content);
expect(firstTok(container)).toBeNull();
expect(container.textContent).toContain(content);
});
it("stays plain beyond the parse cap", async () => {
const filler = "// filler line to pass the parse cap\n".repeat(16000);
const { container } = render(
<SourcePreview colorize={previewColors} path="/p/big.ts" line={0} load={loadOf(`${filler}const lib = "x";`)} />,
);
await waitFor(() => expect(container.querySelector('[data-line="0"]')?.textContent).toContain("filler"));
expect(firstTok(container)).toBeNull();
});
it("recolours when the file changes", async () => {
const load = loadOf('const lib = "x";');
const { container, rerender } = render(
<SourcePreview colorize={previewColors} path="/p/a.ts" line={0} load={load} />,
);
await waitFor(() => expect(container.querySelector(".tok-string")).toBeTruthy(), { timeout: 5000 });
rerender(<SourcePreview colorize={previewColors} path="/p/b.unknownext" line={0} load={load} />);
await screen.findByText('const lib = "x";');
expect(firstTok(container)).toBeNull();
});
it("renders the loaded lines around the current one", async () => {
const { container } = render(
<SourcePreview
@@ -21,7 +87,9 @@ describe("SourcePreview", () => {
});
it("makes its text selectable as a selection boundary", async () => {
const { container } = render(<SourcePreview path="/p/a.ts" line={0} load={loadOf("// TODO")} />);
const { container } = render(
<SourcePreview colorize={previewColors} path="/p/a.ts" line={0} load={loadOf("// TODO")} />,
);
await screen.findByText("// TODO");
// The scroll pane is a selection scope: drags stay inside, text stays copyable.
// Set by an effect, which can run after the text is on screen.
+115 -8
Переглянути файл
@@ -2,10 +2,17 @@
* Read-only source view around a line, with line numbers and highlighted
* ranges: the preview pane beside result trees (TODO, Problems, Inspection
* Results…). The caller supplies the text loader (open buffer or disk).
*
* With `colorize` (the editor's whole-file highlighter), lines are syntax-coloured
* from one parse of the file, so a line inside a block comment or multi-line
* string is coloured as such, like the result lines of Find in Files / Show
* Usages. Files beyond `MAX_PARSE` stay plain rather than colouring the window
* with the wrong block-comment context.
*/
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { T } from "./T";
import { tokenSpans, type SourceToken } from "./tokenSpans";
import styles from "./SourcePreview.module.css";
import { useSelectionBoundary } from "./useSelectionBoundary";
import { tx } from "@/platform/i18n";
@@ -19,6 +26,15 @@ export interface SourceHighlight {
/** Lines rendered on each side of the current one. */
const WINDOW = 300;
/** Files larger than this aren't parsed for colouring (same cap as `lineTokens`). */
const MAX_PARSE = 500_000;
/** Whole-file highlighter over the parsed text, or null when it stays plain. */
type Highlighter = ((from: number, to: number) => SourceToken[]) | null;
/** Parses `text` (the file at `path`) for syntax colours; null leaves it plain. */
export type SourceColorizer = (text: string, path: string) => Promise<Highlighter>;
export interface SourcePreviewProps {
path: string | null;
/** 0-based line to centre and mark as current. */
@@ -28,12 +44,23 @@ export interface SourcePreviewProps {
load: (path: string) => Promise<string>;
/** Bump to reload the same path. */
version?: number;
/** Stable (module-level) syntax colouring; without it lines stay plain. */
colorize?: SourceColorizer;
className?: string;
}
export function SourcePreview({ path, line, highlights = [], load, version = 0, className }: SourcePreviewProps) {
export function SourcePreview({
path,
line,
highlights = [],
load,
version = 0,
colorize,
className,
}: SourcePreviewProps) {
const [text, setText] = useState<{ path: string; lines: string[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const [highlighter, setHighlighter] = useState<{ path: string; fn: Highlighter } | null>(null);
const current = text && text.path === path ? text : null;
useEffect(() => {
@@ -56,6 +83,67 @@ export function SourcePreview({ path, line, highlights = [], load, version = 0,
};
}, [path, version, load]);
// One whole-file parse per loaded text: `current` changes identity only when
// another text loads, not when the centred line moves. Grammar loading is
// async, so the pane renders plain first and recolours when the parse lands;
// `alive` drops late results after a fast switch to another file.
useEffect(() => {
if (!current) return;
let alive = true;
const { path } = current;
const full = current.lines.join("\n");
const build = !colorize || full.length > MAX_PARSE ? Promise.resolve(null) : colorize(full, path);
void build.then((fn) => {
if (alive) setHighlighter({ path, fn });
});
return () => {
alive = false;
};
}, [current, colorize]);
// Offsets of every line in the newline-normalised text, so whole-file token
// ranges can be sliced per rendered line.
const offsets = useMemo(() => {
if (!current) return null;
const o = new Array<number>(current.lines.length);
let at = 0;
for (let i = 0; i < current.lines.length; i++) {
o[i] = at;
at += current.lines[i]!.length + 1;
}
return o;
}, [current]);
// The window's whole-file token ranges, sliced per line into in-line offsets.
const tokensByLine = useMemo(() => {
if (!current || !offsets) return null;
const fn = highlighter?.path === current.path ? highlighter.fn : null;
if (!fn) return null;
const lines = current.lines;
const start = Math.max(0, line - WINDOW);
const end = Math.min(lines.length, line + WINDOW);
const last = lines.length - 1;
const map = new Map<number, SourceToken[]>();
let li = start;
const from = offsets[start]!;
const to = end < offsets.length ? offsets[end]! : offsets[last]! + (lines[last]?.length ?? 0);
for (const t of fn(from, to)) {
// A token may span lines (a block comment); clip it into each covered line.
while (li + 1 < end && offsets[li + 1]! <= t.from) li++;
for (let n = li; n < end && offsets[n]! < t.to; n++) {
const at = offsets[n]!;
const s = Math.max(t.from, at) - at;
const e = Math.min(t.to, at + (lines[n]?.length ?? 0)) - at;
if (e > s) {
const spans = map.get(n) ?? [];
spans.push({ from: s, to: e, cls: t.cls });
map.set(n, spans);
}
}
}
return map;
}, [current, offsets, highlighter, line]);
if (!path)
return (
<div className={clsx(styles.preview, styles.empty, className)}>
@@ -65,7 +153,16 @@ export function SourcePreview({ path, line, highlights = [], load, version = 0,
if (error && !current) return <div className={clsx(styles.preview, styles.empty, className)}>{error}</div>;
if (!current) return <div className={clsx(styles.preview, className)} />;
return <PreviewBody path={path} line={line} lines={current.lines} highlights={highlights} className={className} />;
return (
<PreviewBody
path={path}
line={line}
lines={current.lines}
tokensByLine={tokensByLine}
highlights={highlights}
className={className}
/>
);
}
/**
@@ -77,12 +174,14 @@ function PreviewBody({
path,
line,
lines,
tokensByLine,
highlights,
className,
}: {
path: string;
line: number;
lines: string[];
tokensByLine: Map<number, SourceToken[]> | null;
highlights: readonly SourceHighlight[];
className?: string;
}) {
@@ -103,10 +202,13 @@ function PreviewBody({
for (let n = start; n < end; n++) {
const content = lines[n] ?? "";
const ranges = byLine.get(n);
const lineTokens = tokensByLine?.get(n);
rows.push(
<div key={n} data-line={n} className={clsx(styles.codeLine, n === line && styles.currentLine)}>
<span className={styles.gutter}>{n + 1}</span>
<span className={styles.code}>{ranges ? highlight(content, ranges) : content || " "}</span>
<span className={styles.code}>
{ranges || lineTokens?.length ? highlight(content, ranges ?? [], lineTokens ?? null) : content || " "}
</span>
</div>,
);
}
@@ -117,21 +219,26 @@ function PreviewBody({
);
}
function highlight(text: string, ranges: ReadonlyArray<readonly [number, number]>): ReactNode[] {
/** `text` with `ranges` marked up; every segment coloured by the line's `tokens`. */
function highlight(
text: string,
ranges: ReadonlyArray<readonly [number, number]>,
tokens: readonly SourceToken[] | null,
): ReactNode[] {
const parts: ReactNode[] = [];
let pos = 0;
[...ranges]
.sort((a, b) => a[0] - b[0])
.forEach(([s, e], i) => {
if (s < pos || e <= s) return;
if (s > pos) parts.push(text.slice(pos, s));
if (s > pos) parts.push(...tokenSpans(text, pos, s, tokens, `g${i}:`));
parts.push(
<mark key={i} className={styles.match}>
{text.slice(s, e)}
{tokenSpans(text, s, e, tokens, `m${i}:`)}
</mark>,
);
pos = e;
});
if (pos < text.length) parts.push(text.slice(pos));
if (pos < text.length) parts.push(...tokenSpans(text, pos, text.length, tokens, "t:"));
return parts;
}
+39
Переглянути файл
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
/** A syntax-coloured span of a text: `[from, to)` with its `tok-*` class names. */
export interface SourceToken {
from: number;
to: number;
cls: string;
}
/**
* `text` between `from` and `to` as spans with the tokens' `tok-*` classes
* (styled in global.css); `key` prefixes the React keys.
*/
export function tokenSpans(
text: string,
from: number,
to: number,
tokens: readonly SourceToken[] | null,
key = "",
): ReactNode[] {
if (!tokens?.length) return [text.slice(from, to)];
const out: ReactNode[] = [];
let pos = from;
for (const t of tokens) {
if (t.to <= pos) continue;
if (t.from >= to) break;
const s = Math.max(t.from, pos);
const e = Math.min(t.to, to);
if (s > pos) out.push(text.slice(pos, s));
out.push(
<span key={`${key}${s}`} className={t.cls}>
{text.slice(s, e)}
</span>,
);
pos = e;
}
if (pos < to) out.push(text.slice(pos, to));
return out;
}