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; +}