mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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 <5848026+qtvbwfn@users.noreply.github.com>
This commit is contained in:
@@ -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(
|
||||
<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;
|
||||
}
|
||||
export { tokenSpans } from "@/ui/tokenSpans";
|
||||
|
||||
/** Whole-file syntax colours for `SourcePreview` (its `colorize` prop). */
|
||||
export const previewColors: SourceColorizer = (text, path) => codeHighlighter(text, languageForPath(path));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<SourcePreview path={usage?.path ?? null} line={usage?.line ?? 0} highlights={highlights} load={previewText} />
|
||||
<SourcePreview
|
||||
path={usage?.path ?? null}
|
||||
line={usage?.line ?? 0}
|
||||
highlights={highlights}
|
||||
load={previewText}
|
||||
colorize={previewColors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <SourcePreview path={path} line={line} highlights={highlights} load={fallbackLoad} />;
|
||||
if (!path || readOnly)
|
||||
return (
|
||||
<SourcePreview path={path} line={line} highlights={highlights} load={fallbackLoad} colorize={previewColors} />
|
||||
);
|
||||
if (error)
|
||||
return (
|
||||
<div className={styles.previewError}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user