дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat: Syntax-colour Find in Files and Show Usages result lines (#143)
Result lines in the Find in Files popup and tool window and in the Show Usages popup and Usages tool window are coloured like the editor, using the whole file as context, so a line inside a block comment or a multi-line string gets the right colour. The colouring lives in editor/lineTokens and all four lists share it; each file is parsed once and cached, and the parse runs in 8 ms time slices so large files don't block the UI thread. Lines that moved since the search, and files over 500k characters, are coloured on their own. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA Co-authored-by: jaw52 <[email protected]>
Цей коміт міститься в:
15 files changed
+466
-44
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock(import("@/platform/ipc"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
commands: { fsReadText: (path: string) => path } as never,
|
||||
call: (async () => '/*\n * lib\n */\nconst lib = "lib";\n') as never,
|
||||
}));
|
||||
vi.mock(import("@/editor/api"), async (importOriginal) => ({ ...(await importOriginal()), editorsFor: () => [] }));
|
||||
|
||||
const { MatchText } = await import("../find/parts");
|
||||
|
||||
describe("MatchText", () => {
|
||||
it("colours the line by its file's tokens under the match highlight", async () => {
|
||||
const { container } = render(
|
||||
<MatchText text={'const lib = "lib";'} ranges={[[6, 9]]} source={{ path: "/p/a.ts", line: 3, col: 0 }} />,
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector(".tok-keyword")?.textContent).toBe("const"));
|
||||
expect(container.querySelector("mark")?.textContent).toBe("lib");
|
||||
expect(container.querySelector(".tok-string")?.textContent).toBe('"lib"');
|
||||
expect(container.textContent).toBe('const lib = "lib";');
|
||||
});
|
||||
|
||||
it("colours a line inside a block comment as a comment", async () => {
|
||||
const { container } = render(
|
||||
<MatchText text="* lib" ranges={[[2, 5]]} source={{ path: "/p/a.ts", line: 1, col: 1 }} />,
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector("mark .tok-comment")?.textContent).toBe("lib"));
|
||||
});
|
||||
|
||||
it("stays plain without a source", () => {
|
||||
const { container } = render(<MatchText text="const lib" ranges={[[6, 9]]} />);
|
||||
expect(container.querySelector("[class^=tok-]")).toBeNull();
|
||||
expect(container.querySelector("mark")?.textContent).toBe("lib");
|
||||
});
|
||||
});
|
||||
@@ -685,6 +685,7 @@ function FindInFilesDialog() {
|
||||
<MatchText
|
||||
text={r.text}
|
||||
ranges={r.ranges}
|
||||
source={{ path: r.path, line: r.line, col: r.textCol }}
|
||||
active={st.active}
|
||||
replacement={s.replace && state !== "done" ? s.replacement : null}
|
||||
query={query}
|
||||
|
||||
@@ -65,6 +65,8 @@ type Node =
|
||||
col: number;
|
||||
endCol: number;
|
||||
text: string;
|
||||
/** Column of `text` in the real line. */
|
||||
textCol: number;
|
||||
ranges: Array<[number, number]>;
|
||||
starts: Array<{ line: number; col: number }>;
|
||||
};
|
||||
@@ -95,6 +97,7 @@ export function buildFindTree(files: readonly FileMatches[], groupByDirectory =
|
||||
col: first[0] + m.textOffset,
|
||||
endCol: first[1] + m.textOffset,
|
||||
text,
|
||||
textCol: lead + m.textOffset,
|
||||
ranges: m.ranges
|
||||
.map(([s, e]) => [Math.max(0, s - lead), Math.min(text.length, e - lead)] as [number, number])
|
||||
.filter(([s, e]) => e > s),
|
||||
@@ -531,6 +534,7 @@ function FindNodeContent({ node, state, tab }: { node: Node; state: TreeRowState
|
||||
<MatchText
|
||||
text={node.text}
|
||||
ranges={node.ranges}
|
||||
source={{ path: node.path, line: node.line, col: node.textCol }}
|
||||
active={state.selected}
|
||||
replacement={tab.replacement}
|
||||
query={tab.query}
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
.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);
|
||||
|
||||
@@ -3,6 +3,7 @@ import clsx from "clsx";
|
||||
import { EditorState, StateEffect, StateField, type Range, type Text } from "@codemirror/state";
|
||||
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
|
||||
import { codePreviewExtensions } from "@/editor/api";
|
||||
import { tokenSpans, useLineTokens, type LineSource } from "@/editor/lineTokens";
|
||||
import type { SearchQuery } from "@/platform/ipc";
|
||||
import { previewReplacement } from "./session";
|
||||
import { loadPreviewText } from "./preview";
|
||||
@@ -11,7 +12,10 @@ import styles from "./parts.module.css";
|
||||
import { T } from "@/ui";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
/** A result line with matches highlighted; in replace mode shows `old → new` inline. */
|
||||
/**
|
||||
* A result line with matches highlighted; in replace mode shows `old → new`
|
||||
* inline. With `source`, syntax-coloured with the whole file as context.
|
||||
*/
|
||||
export function MatchText({
|
||||
text,
|
||||
ranges,
|
||||
@@ -19,6 +23,7 @@ export function MatchText({
|
||||
replacement,
|
||||
query,
|
||||
preserveCase = false,
|
||||
source,
|
||||
}: {
|
||||
text: string;
|
||||
ranges: ReadonlyArray<readonly [number, number]>;
|
||||
@@ -26,12 +31,14 @@ export function MatchText({
|
||||
replacement?: string | null;
|
||||
query?: Pick<SearchQuery, "pattern" | "isRegex" | "caseSensitive"> | null;
|
||||
preserveCase?: boolean;
|
||||
source?: LineSource;
|
||||
}) {
|
||||
const tokens = useLineTokens(source, text);
|
||||
const parts: ReactNode[] = [];
|
||||
let pos = 0;
|
||||
ranges.forEach(([s, e], i) => {
|
||||
if (s < pos) return;
|
||||
if (s > pos) parts.push(text.slice(pos, s));
|
||||
if (s > pos) parts.push(...tokenSpans(text, pos, s, tokens, `g${i}:`));
|
||||
const matched = text.slice(s, e);
|
||||
if (replacement != null && query) {
|
||||
parts.push(
|
||||
@@ -45,14 +52,14 @@ export function MatchText({
|
||||
} else {
|
||||
parts.push(
|
||||
<mark key={i} className={clsx(styles.match, active && styles.matchActive)}>
|
||||
{matched}
|
||||
{tokenSpans(text, s, e, tokens, "")}
|
||||
</mark>,
|
||||
);
|
||||
}
|
||||
pos = e;
|
||||
});
|
||||
if (pos < text.length) parts.push(text.slice(pos));
|
||||
return <span className={styles.text}>{parts}</span>;
|
||||
if (pos < text.length) parts.push(...tokenSpans(text, pos, text.length, tokens, "end:"));
|
||||
return <span className={clsx(styles.text, styles.code)}>{parts}</span>;
|
||||
}
|
||||
|
||||
const WINDOW = 300;
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { editorsFor } from "@/editor/api";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
|
||||
const cache = new Map<string, Promise<string>>();
|
||||
import { clearLineTokens, readFileText } from "@/editor/lineTokens";
|
||||
|
||||
/**
|
||||
* File text for the preview pane: the live buffer of an open editor, else a
|
||||
* read-only backend read (doesn't open a document or touch recent files).
|
||||
* Shared with the result lines' syntax colours.
|
||||
*/
|
||||
export function loadPreviewText(path: string): Promise<string> {
|
||||
const view = editorsFor(path)[0]?.view;
|
||||
if (view) return Promise.resolve(view.state.doc.toString());
|
||||
let p = cache.get(path);
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
return call(commands.fsReadText(path));
|
||||
})();
|
||||
p.catch(() => cache.delete(path));
|
||||
cache.set(path, p);
|
||||
if (cache.size > 40) cache.delete(cache.keys().next().value!);
|
||||
}
|
||||
return p;
|
||||
return readFileText(path);
|
||||
}
|
||||
|
||||
export function clearPreviewCache() {
|
||||
cache.clear();
|
||||
clearLineTokens();
|
||||
}
|
||||
@@ -145,6 +145,8 @@ export interface MatchRow {
|
||||
line: number;
|
||||
/** Preview text (leading whitespace trimmed). */
|
||||
text: string;
|
||||
/** Column of `text` in the real line. */
|
||||
textCol: number;
|
||||
/** Highlight ranges within `text`. */
|
||||
ranges: Array<[number, number]>;
|
||||
/** First match column in the real line. */
|
||||
@@ -172,6 +174,7 @@ export function matchRows(files: readonly FileMatches[]): MatchRow[] {
|
||||
relPath: f.relPath,
|
||||
line: m.line,
|
||||
text,
|
||||
textCol: lead + m.textOffset,
|
||||
ranges,
|
||||
col: first[0] + m.textOffset,
|
||||
endCol: first[1] + m.textOffset,
|
||||
|
||||
Reference in new issue
Заблокувати користувача