diff --git a/src/editor/lineTokens.tsx b/src/editor/lineTokens.tsx
index 7fa21dff..0468f997 100644
--- a/src/editor/lineTokens.tsx
+++ b/src/editor/lineTokens.tsx
@@ -3,12 +3,13 @@
* `ChunkExtractor`: each file is parsed whole once, so a line inside a block
* comment or a multi-line string is coloured as such.
*/
-import { useEffect, useState, type ReactNode } from "react";
+import { useEffect, useState } from "react";
import { Text } from "@codemirror/state";
import { editorsFor } from "@/editor/api";
import { codeHighlighter, highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown";
import { call, commands } from "@/platform/ipc";
import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs";
+import type { SourceColorizer } from "@/ui/SourcePreview";
export type { CodeToken };
@@ -135,33 +136,7 @@ export function useLineTokens(source: LineSource | undefined, text: string): Cod
return loaded?.key === key ? loaded.tokens : peekLineTokens({ path, line, col }, text);
}
-/**
- * `text` between `from` and `to` as spans with the tokens' `tok-*` classes
- * (styled by the caller); `key` prefixes the React keys.
- */
-export function tokenSpans(
- text: string,
- from: number,
- to: number,
- tokens: readonly CodeToken[] | 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(
-
- {text.slice(s, e)}
- ,
- );
- pos = e;
- }
- if (pos < to) out.push(text.slice(pos, to));
- return out;
-}
+export { tokenSpans } from "@/ui/tokenSpans";
+
+/** Whole-file syntax colours for `SourcePreview` (its `colorize` prop). */
+export const previewColors: SourceColorizer = (text, path) => codeHighlighter(text, languageForPath(path));
diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css
index 56bc0c96..d1a7e801 100644
--- a/src/features/agents/Session.module.css
+++ b/src/features/agents/Session.module.css
@@ -307,44 +307,6 @@
text-decoration: underline;
}
-/* Lezer classHighlighter tokens in fenced code (same colours as the Markdown preview). */
-.markdown :global(.tok-keyword),
-.markdown :global(.tok-modifier),
-.markdown :global(.tok-controlKeyword),
-.markdown :global(.tok-operatorKeyword) {
- color: var(--syn-keyword);
-}
-.markdown :global(.tok-string),
-.markdown :global(.tok-string2) {
- color: var(--syn-string);
-}
-.markdown :global(.tok-number),
-.markdown :global(.tok-bool),
-.markdown :global(.tok-atom) {
- color: var(--syn-number);
-}
-.markdown :global(.tok-typeName),
-.markdown :global(.tok-className),
-.markdown :global(.tok-namespace) {
- color: var(--syn-type);
-}
-.markdown :global(.tok-comment) {
- color: var(--syn-comment);
-}
-.markdown :global(.tok-meta),
-.markdown :global(.tok-macroName) {
- color: var(--syn-annotation);
-}
-.markdown :global(.tok-propertyName) {
- color: var(--syn-property);
-}
-.markdown :global(.tok-variableName.tok-definition) {
- color: var(--syn-function);
-}
-.markdown :global(.tok-punctuation),
-.markdown :global(.tok-operator) {
- color: var(--syn-punct);
-}
.markdown :global(ul),
.markdown :global(ol) {
margin: 0 0 8px;
diff --git a/src/features/code-insight/ProblemsView.tsx b/src/features/code-insight/ProblemsView.tsx
index 26629205..abcde083 100644
--- a/src/features/code-insight/ProblemsView.tsx
+++ b/src/features/code-insight/ProblemsView.tsx
@@ -4,6 +4,7 @@ import { create } from "zustand";
import clsx from "clsx";
import type { Diagnostic, Severity } from "@/bindings";
import { useEditorRegistry } from "@/editor/api";
+import { previewColors } from "@/editor/lineTokens";
import { executeAction } from "@/platform/actions";
import { formatBinding, hasPrimaryModifier, hostBinding, shortcutLabel } from "@/platform/keymap";
import { basename, relativeDir, useProject } from "@/platform/project";
@@ -409,6 +410,7 @@ export function ProblemsView() {
line={selected?.d.range.start.line ?? 0}
highlights={selected ? [diagHighlight(selected.d)] : []}
load={previewText}
+ colorize={previewColors}
/>
diff --git a/src/features/code-insight/UsagesView.module.css b/src/features/code-insight/UsagesView.module.css
index 2b53680e..3a5dd124 100644
--- a/src/features/code-insight/UsagesView.module.css
+++ b/src/features/code-insight/UsagesView.module.css
@@ -95,40 +95,6 @@
color: var(--fg-search-match, inherit);
border-radius: 2px;
}
-/* Lezer classHighlighter tokens of usage lines (same colours as Find in Files results). */
-.preview :global(.tok-keyword),
-.preview :global(.tok-modifier),
-.preview :global(.tok-controlKeyword),
-.preview :global(.tok-operatorKeyword) {
- color: var(--syn-keyword);
-}
-.preview :global(.tok-string),
-.preview :global(.tok-string2) {
- color: var(--syn-string);
-}
-.preview :global(.tok-number),
-.preview :global(.tok-bool),
-.preview :global(.tok-atom) {
- color: var(--syn-number);
-}
-.preview :global(.tok-typeName),
-.preview :global(.tok-className),
-.preview :global(.tok-namespace) {
- color: var(--syn-type);
-}
-.preview :global(.tok-comment) {
- color: var(--syn-comment);
-}
-.preview :global(.tok-meta),
-.preview :global(.tok-macroName) {
- color: var(--syn-annotation);
-}
-.preview :global(.tok-propertyName) {
- color: var(--syn-property);
-}
-.preview :global(.tok-variableName.tok-definition) {
- color: var(--syn-function);
-}
.note {
min-width: 0;
diff --git a/src/features/code-insight/UsagesView.tsx b/src/features/code-insight/UsagesView.tsx
index 6e975e49..1f6faeb2 100644
--- a/src/features/code-insight/UsagesView.tsx
+++ b/src/features/code-insight/UsagesView.tsx
@@ -5,7 +5,7 @@
import { useCallback, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import clsx from "clsx";
import { create } from "zustand";
-import { tokenSpans, useLineTokens } from "@/editor/lineTokens";
+import { previewColors, tokenSpans, useLineTokens } from "@/editor/lineTokens";
import { formatMessage, useTx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
import { basename, relativeDir, useProject } from "@/platform/project";
@@ -399,6 +399,12 @@ function UsagesPreview({ node }: { node: Node | null }) {
return (group?.usages ?? []).map((u) => ({ line: u.line, ranges: [[u.col, u.endCol] as const] }));
}, [usage, search?.groups]);
return (
-
+
);
}
diff --git a/src/features/database/Database.module.css b/src/features/database/Database.module.css
index f3e233dd..e8d349d4 100644
--- a/src/features/database/Database.module.css
+++ b/src/features/database/Database.module.css
@@ -672,6 +672,11 @@
font-size: var(--fs-small);
color: var(--fg-secondary);
}
+/* SQL types (INT, VARCHAR) read as keywords, as in the SQL editor. */
+.sqlPreview :global(.tok-typeName),
+.codeText :global(.tok-typeName) {
+ color: var(--syn-keyword);
+}
.sqlPreview {
margin: 0;
padding: 8px 10px;
@@ -690,41 +695,6 @@
-webkit-user-select: text;
user-select: text;
}
-.sqlPreview :global(.tok-keyword),
-.codeText :global(.tok-keyword),
-.sqlPreview :global(.tok-operatorKeyword),
-.codeText :global(.tok-operatorKeyword) {
- color: var(--syn-keyword);
-}
-.sqlPreview :global(.tok-string),
-.codeText :global(.tok-string),
-.sqlPreview :global(.tok-string2),
-.codeText :global(.tok-string2) {
- color: var(--syn-string);
-}
-.sqlPreview :global(.tok-number),
-.codeText :global(.tok-number),
-.sqlPreview :global(.tok-bool),
-.codeText :global(.tok-bool),
-.sqlPreview :global(.tok-atom),
-.codeText :global(.tok-atom) {
- color: var(--syn-number);
-}
-.sqlPreview :global(.tok-typeName),
-.codeText :global(.tok-typeName) {
- color: var(--syn-keyword);
-}
-.sqlPreview :global(.tok-comment),
-.codeText :global(.tok-comment) {
- color: var(--syn-comment);
- font-style: italic;
-}
-.sqlPreview :global(.tok-punctuation),
-.codeText :global(.tok-punctuation),
-.sqlPreview :global(.tok-operator),
-.codeText :global(.tok-operator) {
- color: var(--syn-punct);
-}
.modifyBody {
display: flex;
flex-direction: column;
diff --git a/src/features/search/everywhere/SearchEverywhere.module.css b/src/features/search/everywhere/SearchEverywhere.module.css
index 4e573979..76a2445b 100644
--- a/src/features/search/everywhere/SearchEverywhere.module.css
+++ b/src/features/search/everywhere/SearchEverywhere.module.css
@@ -175,40 +175,6 @@
color: var(--fg);
tab-size: 2;
}
-/* Lezer classHighlighter tokens of text hits (same colours as the Markdown preview). */
-.code :global(.tok-keyword),
-.code :global(.tok-modifier),
-.code :global(.tok-controlKeyword),
-.code :global(.tok-operatorKeyword) {
- color: var(--syn-keyword);
-}
-.code :global(.tok-string),
-.code :global(.tok-string2) {
- color: var(--syn-string);
-}
-.code :global(.tok-number),
-.code :global(.tok-bool),
-.code :global(.tok-atom) {
- color: var(--syn-number);
-}
-.code :global(.tok-typeName),
-.code :global(.tok-className),
-.code :global(.tok-namespace) {
- color: var(--syn-type);
-}
-.code :global(.tok-comment) {
- color: var(--syn-comment);
-}
-.code :global(.tok-meta),
-.code :global(.tok-macroName) {
- color: var(--syn-annotation);
-}
-.code :global(.tok-propertyName) {
- color: var(--syn-property);
-}
-.code :global(.tok-variableName.tok-definition) {
- color: var(--syn-function);
-}
.repeat {
color: var(--fg-faint);
}
diff --git a/src/features/search/find/parts.module.css b/src/features/search/find/parts.module.css
index 4b3c0c3d..cc0ce7b5 100644
--- a/src/features/search/find/parts.module.css
+++ b/src/features/search/find/parts.module.css
@@ -1,40 +1,6 @@
.text {
white-space: pre;
}
-/* Lezer classHighlighter tokens of result lines (same colours as Search Everywhere's text hits). */
-.code :global(.tok-keyword),
-.code :global(.tok-modifier),
-.code :global(.tok-controlKeyword),
-.code :global(.tok-operatorKeyword) {
- color: var(--syn-keyword);
-}
-.code :global(.tok-string),
-.code :global(.tok-string2) {
- color: var(--syn-string);
-}
-.code :global(.tok-number),
-.code :global(.tok-bool),
-.code :global(.tok-atom) {
- color: var(--syn-number);
-}
-.code :global(.tok-typeName),
-.code :global(.tok-className),
-.code :global(.tok-namespace) {
- color: var(--syn-type);
-}
-.code :global(.tok-comment) {
- color: var(--syn-comment);
-}
-.code :global(.tok-meta),
-.code :global(.tok-macroName) {
- color: var(--syn-annotation);
-}
-.code :global(.tok-propertyName) {
- color: var(--syn-property);
-}
-.code :global(.tok-variableName.tok-definition) {
- color: var(--syn-function);
-}
.match {
background: var(--accent-soft);
color: var(--fg-strong);
diff --git a/src/features/search/recent/RecentPopups.module.css b/src/features/search/recent/RecentPopups.module.css
index 2d8a63d7..e34f5819 100644
--- a/src/features/search/recent/RecentPopups.module.css
+++ b/src/features/search/recent/RecentPopups.module.css
@@ -233,38 +233,3 @@
overflow: hidden;
text-overflow: ellipsis;
}
-/* Lezer classHighlighter tokens (same colours as Search Everywhere's text hits). */
-.code :global(.tok-keyword),
-.code :global(.tok-modifier),
-.code :global(.tok-controlKeyword),
-.code :global(.tok-operatorKeyword) {
- color: var(--syn-keyword);
-}
-.code :global(.tok-string),
-.code :global(.tok-string2) {
- color: var(--syn-string);
-}
-.code :global(.tok-number),
-.code :global(.tok-bool),
-.code :global(.tok-atom) {
- color: var(--syn-number);
-}
-.code :global(.tok-typeName),
-.code :global(.tok-className),
-.code :global(.tok-namespace) {
- color: var(--syn-type);
-}
-.code :global(.tok-comment) {
- color: var(--syn-comment);
- font-style: italic;
-}
-.code :global(.tok-meta),
-.code :global(.tok-macroName) {
- color: var(--syn-annotation);
-}
-.code :global(.tok-propertyName) {
- color: var(--syn-property);
-}
-.code :global(.tok-variableName.tok-definition) {
- color: var(--syn-function);
-}
diff --git a/src/features/todo/PreviewEditor.tsx b/src/features/todo/PreviewEditor.tsx
index dfb951a3..68beae4e 100644
--- a/src/features/todo/PreviewEditor.tsx
+++ b/src/features/todo/PreviewEditor.tsx
@@ -20,6 +20,7 @@ import { openideaTheme } from "@/editor/core/cm/theme";
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages";
import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api";
+import { previewColors } from "@/editor/lineTokens";
import { SourcePreview, T, type SourceHighlight } from "@/ui";
import { todoHighlightExtension } from "./highlight";
import styles from "./TodoView.module.css";
@@ -148,7 +149,10 @@ export function PreviewEditor({
view.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }) });
}, [line, col]);
- if (!path || readOnly) return ;
+ if (!path || readOnly)
+ return (
+
+ );
if (error)
return (
diff --git a/src/features/viewers/markdown/Markdown.module.css b/src/features/viewers/markdown/Markdown.module.css
index 421583c3..de3a9294 100644
--- a/src/features/viewers/markdown/Markdown.module.css
+++ b/src/features/viewers/markdown/Markdown.module.css
@@ -143,46 +143,6 @@
outline: 1px dashed var(--border-strong);
}
-/* Lezer classHighlighter tokens in fenced code (same colours as documentation popups). */
-.preview :global(.tok-keyword),
-.preview :global(.tok-modifier),
-.preview :global(.tok-controlKeyword),
-.preview :global(.tok-operatorKeyword) {
- color: var(--syn-keyword);
-}
-.preview :global(.tok-string),
-.preview :global(.tok-string2) {
- color: var(--syn-string);
-}
-.preview :global(.tok-number),
-.preview :global(.tok-bool),
-.preview :global(.tok-atom) {
- color: var(--syn-number);
-}
-.preview :global(.tok-typeName),
-.preview :global(.tok-className),
-.preview :global(.tok-namespace) {
- color: var(--syn-type);
-}
-.preview :global(.tok-comment) {
- color: var(--syn-comment);
- font-style: italic;
-}
-.preview :global(.tok-meta),
-.preview :global(.tok-macroName) {
- color: var(--syn-annotation);
-}
-.preview :global(.tok-propertyName) {
- color: var(--syn-property);
-}
-.preview :global(.tok-variableName.tok-definition) {
- color: var(--syn-function);
-}
-.preview :global(.tok-punctuation),
-.preview :global(.tok-operator) {
- color: var(--syn-punct);
-}
-
/* Mermaid diagrams: the source stays until the diagram is drawn, and next to an error. */
.preview :global([data-mermaid]) {
margin: 0 0 1em;
diff --git a/src/styles/global.css b/src/styles/global.css
index 40eb2703..9169b90b 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -168,6 +168,54 @@ a:hover {
font-family: var(--font-mono);
}
+/* Syntax colours for read-only previews and result lines: the `tok-*` classes
+ CodeMirror's `classHighlighter` emits (`codeHighlighter`/`tokenSpans`). One
+ shared set instead of a copy per view module; editor tooltips have the same
+ colours scoped to their DOM in editor/lsp/theme.ts. The main editor is
+ unaffected: it colours itself with the inline `HighlightStyle` in
+ editor/core/cm/highlight.ts. */
+.tok-keyword,
+.tok-modifier,
+.tok-controlKeyword,
+.tok-operatorKeyword {
+ color: var(--syn-keyword);
+}
+.tok-string,
+.tok-string2 {
+ color: var(--syn-string);
+}
+.tok-number,
+.tok-bool,
+.tok-atom {
+ color: var(--syn-number);
+}
+.tok-typeName,
+.tok-className,
+.tok-namespace {
+ color: var(--syn-type);
+}
+.tok-comment {
+ color: var(--syn-comment);
+ font-style: var(--syn-comment-style);
+}
+.tok-meta,
+.tok-macroName {
+ color: var(--syn-annotation);
+}
+.tok-propertyName {
+ color: var(--syn-property);
+}
+.tok-variableName.tok-definition {
+ color: var(--syn-function);
+}
+.tok-punctuation,
+.tok-operator {
+ color: var(--syn-punct);
+}
+.tok-invalid {
+ color: var(--syn-invalid);
+}
+
/* macOS-style overlay scrollbars (ScrollBar.Mac.thumbColor / hoverThumbColor) */
::-webkit-scrollbar {
width: 10px;
diff --git a/src/ui/SourcePreview.test.tsx b/src/ui/SourcePreview.test.tsx
index 15f442f3..00965639 100644
--- a/src/ui/SourcePreview.test.tsx
+++ b/src/ui/SourcePreview.test.tsx
@@ -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
("[class*='tok-']");
+
describe("SourcePreview", () => {
+ it("colours lines with the whole file as context", async () => {
+ const { container } = render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ await waitFor(() => expect(container.querySelector(".tok-string")).toBeTruthy(), { timeout: 5000 });
+ rerender();
+ await screen.findByText('const lib = "x";');
+ expect(firstTok(container)).toBeNull();
+ });
+
it("renders the loaded lines around the current one", async () => {
const { container } = render(
{
});
it("makes its text selectable as a selection boundary", async () => {
- const { container } = render();
+ const { container } = render(
+ ,
+ );
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.
diff --git a/src/ui/SourcePreview.tsx b/src/ui/SourcePreview.tsx
index fe4cbf21..6ae41756 100644
--- a/src/ui/SourcePreview.tsx
+++ b/src/ui/SourcePreview.tsx
@@ -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;
+
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;
/** 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(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(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();
+ 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 (
@@ -65,7 +153,16 @@ export function SourcePreview({ path, line, highlights = [], load, version = 0,
if (error && !current) return
{error}
;
if (!current) return
;
- return
;
+ return (
+
+ );
}
/**
@@ -77,12 +174,14 @@ function PreviewBody({
path,
line,
lines,
+ tokensByLine,
highlights,
className,
}: {
path: string;
line: number;
lines: string[];
+ tokensByLine: Map
| 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(
{n + 1}
- {ranges ? highlight(content, ranges) : content || " "}
+
+ {ranges || lineTokens?.length ? highlight(content, ranges ?? [], lineTokens ?? null) : content || " "}
+
,
);
}
@@ -117,21 +219,26 @@ function PreviewBody({
);
}
-function highlight(text: string, ranges: ReadonlyArray): ReactNode[] {
+/** `text` with `ranges` marked up; every segment coloured by the line's `tokens`. */
+function highlight(
+ text: string,
+ ranges: ReadonlyArray,
+ 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(
- {text.slice(s, e)}
+ {tokenSpans(text, s, e, tokens, `m${i}:`)}
,
);
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;
}
diff --git a/src/ui/tokenSpans.tsx b/src/ui/tokenSpans.tsx
new file mode 100644
index 00000000..9adb22e3
--- /dev/null
+++ b/src/ui/tokenSpans.tsx
@@ -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(
+
+ {text.slice(s, e)}
+ ,
+ );
+ pos = e;
+ }
+ if (pos < to) out.push(text.slice(pos, to));
+ return out;
+}