feat(plugins): add background document-indexer API (#1021)

Plugin API 1.2 gains an optional document-indexer export (plugin-indexing world). A plugin returns per document the keys it defines and refers to, aliases between key prefixes and gutter markers; the host indexes a trusted project in cancellable background batches, keeps the project-wide graph, and answers Go to Declaration / Ctrl-click and multi-target gutter markers from memory without calling the plugin again. Edits and file changes invalidate the affected entries; reload, disable or a failing runtime drops the plugin's graph.

The interface is limited to cross-file associations: icons chosen by file content and embedded-language regions are separate concerns for later interfaces. The plugin navigation choosers' titles are translated.

Co-authored-by: qjlqyf <[email protected]>
Цей коміт міститься в:
qiaojianlong authored and GitHub committed 2026-11-06 10:07:53 +08:00
1 джерело 9069c322e8
коміт ed08b96382
79 files changed
+3039 -46

No files matched your search

+4
Переглянути файл
@@ -34,6 +34,8 @@ import { startViewUi } from "./uiPoints";
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
import { IntellijThemesDialogHost } from "./IntellijThemesDialog";
import { startWorkspaceContains } from "./workspaceContains";
import { startIndexedMarkers } from "./indexedMarkers";
import { startPluginNavigation } from "./navigation";
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store";
function registerPluginsPage(): () => void {
@@ -143,6 +145,8 @@ export function activate(): () => void {
},
]),
startPluginBridge(),
startPluginNavigation(),
startIndexedMarkers(),
// Plugin custom editors (tab kind) and context menu entries.
startViewUi((input) => void editorService().openInput(input)),
startWorkspaceContains(),
+70
Переглянути файл
@@ -0,0 +1,70 @@
import { afterEach, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { offsetsToRange } from "@/editor/lsp/positions";
import type { FileMarkers } from "@/platform/ipc";
import { indexedMarkers } from "./indexedMarkers";
import { usePluginMarkers } from "./markerStore";
const calls = vi.hoisted(() => ({ navigate: vi.fn(), popup: vi.fn() }));
vi.mock("@/editor/lsp/navigation", () => ({ navigateTo: calls.navigate, locationItems: async () => [] }));
vi.mock("@/editor/lsp/popupStore", () => ({ openPopup: calls.popup }));
let view: EditorView | undefined;
afterEach(() => {
view?.destroy();
view = undefined;
usePluginMarkers.setState({ entries: new Map() });
vi.clearAllMocks();
});
const source = '<mapper>\n<select id="find">SELECT 1</select>\n</mapper>';
function setup() {
view = new EditorView({
parent: document.body,
state: EditorState.create({ doc: source, extensions: [indexedMarkers("/p/Query.xml")] }),
});
const range = (from: number, to: number) => offsetsToRange(view!.state.doc, from, to);
const target = { path: "/p/Query.java", range: range(0, 4) };
const at = source.indexOf("find");
const entry: FileMarkers = {
path: "/p/Query.xml",
version: 1,
markers: [{ range: range(at, at + 4), targets: [target] }],
};
usePluginMarkers.setState({ entries: new Map([[entry.path, entry]]) });
return { entry, target };
}
const button = () => view!.dom.querySelector<HTMLButtonElement>(".cm-plugin-mapping-button");
it("puts a button on the marker's line that goes to its target", async () => {
const { target } = setup();
await vi.waitFor(() => expect(button()).not.toBeNull());
expect(button()!.title).toContain("Query.java:1");
expect(button()!.getAttribute("aria-label")).toContain("Query.java:1");
button()!.click();
expect(calls.navigate).toHaveBeenCalledWith(target);
});
it("hides markers on edit and when the entry goes away", async () => {
setup();
await vi.waitFor(() => expect(button()).not.toBeNull());
view!.dispatch({ changes: { from: 0, insert: "\n" } });
expect(button()).toBeNull();
usePluginMarkers.setState({ entries: new Map() });
await vi.waitFor(() => expect(button()).toBeNull());
});
it("merges a line's targets and offers a chooser for several", async () => {
const { entry, target } = setup();
const other = { ...target, path: "/p/Other.java" };
usePluginMarkers.setState({
entries: new Map([
[entry.path, { ...entry, markers: [...entry.markers, { ...entry.markers[0]!, targets: [target, other] }] }],
]),
});
await vi.waitFor(() => expect(button()!.title).toContain("Other.java"));
expect(view!.dom.querySelectorAll(".cm-plugin-mapping-button")).toHaveLength(1);
button()!.click();
await vi.waitFor(() => expect(calls.popup).toHaveBeenCalled());
});
+129
Переглянути файл
@@ -0,0 +1,129 @@
/**
* Gutter markers from plugins' document indexers, resolved by the host from
* its cached graph. A click goes to the marker's targets (a chooser when there
* are several); an edit hides the markers until the edited text is indexed.
*/
import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import { registerEditorContribution } from "@/editor/api";
import { rangeToOffsets } from "@/editor/lsp/positions";
import { editorPrefs } from "@/platform/editorSettings";
import { useProject } from "@/platform/project";
import type { FileMarkers, Location } from "@/platform/ipc";
import { markerIcon } from "./lineMarkerGutter";
import { usePluginMarkers, watchPluginMarkers } from "./markerStore";
import { navigationButton } from "./navigationButton";
class MappingMarker extends GutterMarker {
constructor(readonly targets: Location[]) {
super();
}
override eq(other: GutterMarker) {
return other instanceof MappingMarker && JSON.stringify(other.targets) === JSON.stringify(this.targets);
}
override toDOM() {
return navigationButton(this.targets, markerIcon(null));
}
}
const setMarkers = StateEffect.define<RangeSet<GutterMarker>>();
const markersField = StateField.define<RangeSet<GutterMarker>>({
create: () => RangeSet.empty,
update(value, tr) {
if (tr.docChanged) value = RangeSet.empty;
for (const effect of tr.effects) if (effect.is(setMarkers)) value = effect.value;
return value;
},
});
/** One marker per line, with the targets of every marker on it. */
export function markersOf(view: EditorView, entry: FileMarkers | undefined): RangeSet<GutterMarker> {
if (!entry || !editorPrefs().showGutterIcons) return RangeSet.empty;
const doc = view.state.doc;
const rows = new Map<number, Location[]>();
for (const marker of entry.markers) {
const line = doc.lineAt(rangeToOffsets(doc, marker.range).from).from;
const targets = rows.get(line) ?? [];
for (const target of marker.targets)
if (!targets.some((t) => t.path === target.path && JSON.stringify(t.range) === JSON.stringify(target.range)))
targets.push(target);
rows.set(line, targets);
}
return RangeSet.of(
[...rows].map(([pos, targets]) => new MappingMarker(targets).range(pos)),
true,
);
}
export function indexedMarkers(path: string): Extension {
return [
markersField,
gutter({ class: "cm-plugin-mapping-gutter", markers: (v) => v.state.field(markersField) }),
EditorView.baseTheme({
".cm-plugin-mapping-button": {
border: "none",
padding: "0 2px",
background: "transparent",
color: "inherit",
cursor: "pointer",
height: "100%",
display: "flex",
alignItems: "center",
},
".cm-plugin-mapping-button svg": { width: "16px", height: "16px" },
".cm-plugin-mapping-button:focus-visible": { outline: "1px solid var(--accent)" },
}),
ViewPlugin.fromClass(
class {
last: FileMarkers | undefined;
queued = false;
disposed = false;
readonly off: () => void;
readonly unwatch: () => void;
readonly prefs: () => void;
constructor(readonly view: EditorView) {
this.off = usePluginMarkers.subscribe(() => this.schedule());
this.unwatch = watchPluginMarkers(path);
this.prefs = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings) {
this.last = undefined;
this.schedule();
}
});
this.schedule();
}
update(update: ViewUpdate) {
if (update.docChanged) this.last = undefined;
}
schedule() {
if (this.queued || this.disposed) return;
this.queued = true;
queueMicrotask(() => {
this.queued = false;
if (!this.disposed) this.refresh();
});
}
refresh() {
const entry = usePluginMarkers.getState().entries.get(path);
if (entry && entry === this.last) return;
this.last = entry;
this.view.dispatch({ effects: setMarkers.of(markersOf(this.view, entry)) });
}
destroy() {
this.disposed = true;
this.off();
this.unwatch();
this.prefs();
}
},
),
];
}
export function startIndexedMarkers() {
return registerEditorContribution({
id: "plugins.indexedMarkers",
order: 65,
extensions: (ctx) => (ctx.virtual || ctx.doc < 0 ? [] : indexedMarkers(ctx.path)),
});
}
+56
Переглянути файл
@@ -0,0 +1,56 @@
import { afterEach, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import type { PluginEvent } from "@/bindings";
import { pluginLineMarkerGutter } from "./lineMarkerGutter";
const mock = vi.hoisted(() => ({
markers: vi.fn(),
event: undefined as ((event: PluginEvent) => void) | undefined,
off: vi.fn(),
}));
vi.mock(import("@/platform/ipc"), async (original) => {
const actual = await original();
return { ...actual, commands: { ...actual.commands, pluginLineMarkers: mock.markers } };
});
vi.mock("./backend", () => ({
pluginsBackend: () => ({
onEvent: (fn: (event: PluginEvent) => void) => {
mock.event = fn;
return mock.off;
},
}),
}));
vi.mock("./navigationButton", () => ({ navigationButton: () => document.createElement("button") }));
let view: EditorView | undefined;
afterEach(() => {
view?.destroy();
view = undefined;
vi.useRealTimers();
vi.clearAllMocks();
});
it("withdraws old interactive markers on reload and does not refresh on idle status changes", async () => {
vi.useFakeTimers();
mock.markers.mockResolvedValue({
status: "ok",
data: [{ pluginId: "test", line: 0, icon: "navigate", tooltip: "old", target: null }],
});
view = new EditorView({
parent: document.body,
state: EditorState.create({ doc: "select", extensions: pluginLineMarkerGutter({ doc: 1 } as EditorDocContext) }),
});
await vi.advanceTimersByTimeAsync(450);
expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).not.toBeNull();
const calls = mock.markers.mock.calls.length;
mock.event!({ type: "commandsChanged" });
await vi.advanceTimersByTimeAsync(450);
expect(mock.markers).toHaveBeenCalledTimes(calls);
mock.markers.mockResolvedValue({ status: "ok", data: [] });
mock.event!({ type: "documentIndexesChanged" });
expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).toBeNull();
await vi.advanceTimersByTimeAsync(450);
expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).toBeNull();
view.destroy();
view = undefined;
expect(mock.off).toHaveBeenCalledOnce();
});
+18 -18
Переглянути файл
@@ -1,6 +1,5 @@
/**
* Gutter icons from plugins' `line-markers` providers (MyBatis' arrows
* between a mapper interface and its XML). Asked once per document version
* Gutter icons from plugins' `line-markers` providers. Asked once per document version
* (debounced); markers follow their lines in between; a click opens the
* marker's target. The gutter column exists only while there are markers,
* so editors without plugin providers keep their usual width.
@@ -8,7 +7,8 @@
import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { navigateTo } from "@/editor/lsp/navigation";
import { navigationButton } from "./navigationButton";
import { pluginsBackend } from "./backend";
import { commands, type LineMarkerInfo } from "@/platform/ipc";
import { gutterIconMarkup } from "@/ui";
import styles from "@/editor/navigation/LineMarkers.module.css";
@@ -58,14 +58,16 @@ class PluginMarker extends GutterMarker {
return (
other instanceof PluginMarker &&
other.marker.tooltip === this.marker.tooltip &&
other.marker.icon === this.marker.icon
other.marker.icon === this.marker.icon &&
JSON.stringify(other.marker.target) === JSON.stringify(this.marker.target)
);
}
override toDOM(): Node {
const el = document.createElement("div");
el.className = styles.marker!;
const el = this.marker.target
? navigationButton([this.marker.target], markerIcon(this.marker.icon))
: document.createElement("span");
el.className = `${styles.marker} cm-plugin-mapping-button`;
el.title = this.marker.tooltip;
el.setAttribute("role", "button");
el.setAttribute("aria-label", this.marker.tooltip);
el.innerHTML = gutterIconMarkup(markerIcon(this.marker.icon)) ?? "";
return el;
@@ -76,12 +78,20 @@ class MarkersPlugin {
private timer: ReturnType<typeof setTimeout> | null = null;
private generation = 0;
private shown = false;
private off: () => void;
constructor(
private view: EditorView,
private doc: number,
private column: Compartment,
) {
this.off = pluginsBackend().onEvent((event) => {
if (event.type !== "documentIndexesChanged") return;
this.generation++;
this.shown = false;
this.view.dispatch({ effects: [setMarkers.of([]), this.column.reconfigure([])] });
this.schedule();
});
this.schedule();
}
@@ -116,6 +126,7 @@ class MarkersPlugin {
}
destroy() {
this.off();
if (this.timer) clearTimeout(this.timer);
this.generation++;
}
@@ -128,17 +139,6 @@ const markerColumn: Extension = gutter({
view.state.field(markersField).map((m) => new PluginMarker(m.marker).range(m.pos)),
true,
),
domEventHandlers: {
mousedown: (view, block, event) => {
const e = event as MouseEvent;
if (e.button !== 0) return false;
const target = view.state.field(markersField).find((m) => m.pos === block.from && m.marker.target)?.marker.target;
if (!target) return false;
e.preventDefault();
void navigateTo(target);
return true;
},
},
});
export function pluginLineMarkerGutter(ctx: EditorDocContext): Extension {
+53
Переглянути файл
@@ -0,0 +1,53 @@
import { afterEach, expect, it, vi } from "vitest";
import { watchPluginMarkers, usePluginMarkers, invalidatePluginMarkers } from "./markerStore";
import { invalidateNavigationProviders } from "@/editor/navigationProviders";
const mock = vi.hoisted(() => vi.fn());
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
isTauri: () => true,
commands: { pluginNavigationMarkers: mock },
}));
const off: (() => void)[] = [];
afterEach(() => {
off.splice(0).forEach((f) => f());
vi.useRealTimers();
mock.mockReset();
});
it("batches visible paths, shares watchers and does no polling while idle", async () => {
vi.useFakeTimers();
mock.mockResolvedValue({ status: "ok", data: [] });
for (let i = 0; i < 130; i++) off.push(watchPluginMarkers(`/p/${i}.java`));
off.push(watchPluginMarkers("/p/0.java"));
await vi.advanceTimersByTimeAsync(250);
expect(mock.mock.calls.map((a) => a[0].length)).toEqual([64, 64, 2]);
await vi.advanceTimersByTimeAsync(5000);
expect(mock).toHaveBeenCalledTimes(3);
});
it("discards in-flight results after the project/provider epoch changes", async () => {
vi.useFakeTimers();
let finish!: (r: unknown) => void;
mock.mockImplementationOnce(() => new Promise((r) => (finish = r))).mockResolvedValue({ status: "ok", data: [] });
off.push(watchPluginMarkers("/old/A.xml"));
await vi.advanceTimersByTimeAsync(250);
invalidateNavigationProviders();
finish({ status: "ok", data: [{ path: "/old/A.xml", version: 1, markers: [] }] });
await vi.advanceTimersByTimeAsync(250);
expect(usePluginMarkers.getState().entries.size).toBe(0);
});
it("invalidates both changed files and markers targeting them", () => {
const range = { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } };
usePluginMarkers.setState({
entries: new Map([
[
"/p/A.xml",
{
path: "/p/A.xml",
version: 1,
markers: [{ range, targets: [{ path: "/p/A.java", range }] }],
},
],
]),
});
invalidatePluginMarkers(["/p/A.java"]);
expect(usePluginMarkers.getState().entries.size).toBe(0);
});
+83
Переглянути файл
@@ -0,0 +1,83 @@
/** Gutter markers of document indexers for the files open in editors: bounded IPC, no file reads. */
import { create } from "zustand";
import { call, commands, isTauri, type FileMarkers } from "@/platform/ipc";
import { navigationEpoch, onNavigationProvidersChanged } from "@/editor/navigationProviders";
export const usePluginMarkers = create<{ entries: ReadonlyMap<string, FileMarkers> }>(() => ({
entries: new Map(),
}));
const visible = new Map<string, number>();
let timer: ReturnType<typeof setTimeout> | undefined;
let running = false;
let session = 0;
let again = false;
let off: (() => void) | undefined;
function schedule() {
again = true;
if (!timer && !running)
timer = setTimeout(() => {
timer = undefined;
void refresh();
}, 200);
}
async function refresh() {
if (!isTauri() || running) return;
running = true;
again = false;
const epoch = navigationEpoch();
const current = session;
const paths = [...visible.keys()];
const entries = new Map<string, FileMarkers>();
try {
for (let i = 0; i < paths.length; i += 64) {
const results = await call(commands.pluginNavigationMarkers(paths.slice(i, i + 64)));
if (epoch !== navigationEpoch() || current !== session) return;
for (const result of results) {
if (!visible.has(result.path)) continue;
const previous = entries.get(result.path);
entries.set(
result.path,
previous ? { ...previous, markers: [...previous.markers, ...result.markers] } : result,
);
}
}
if (epoch === navigationEpoch() && current === session) usePluginMarkers.setState({ entries });
} catch {
// Plugin navigation may be unavailable during project close or reload.
if (epoch === navigationEpoch() && current === session) usePluginMarkers.setState({ entries: new Map() });
} finally {
running = false;
if (again) schedule();
}
}
export function watchPluginMarkers(path: string): () => void {
if (!path || !isTauri()) return () => {};
if (!visible.size)
off = onNavigationProvidersChanged(() => {
schedule();
});
visible.set(path, (visible.get(path) ?? 0) + 1);
schedule();
return () => {
const n = (visible.get(path) ?? 1) - 1;
if (n) visible.set(path, n);
else visible.delete(path);
if (!visible.size) {
session++;
off?.();
off = undefined;
clearTimeout(timer);
timer = undefined;
again = false;
usePluginMarkers.setState({ entries: new Map() });
}
};
}
export function invalidatePluginMarkers(paths?: readonly string[]) {
const entries = new Map(usePluginMarkers.getState().entries);
for (const [path, entry] of entries) {
if (!paths || paths.includes(path) || entry.markers.some((m) => m.targets.some((t) => paths.includes(t.path))))
entries.delete(path);
}
usePluginMarkers.setState({ entries });
}
+130
Переглянути файл
@@ -0,0 +1,130 @@
import { beforeEach, afterEach, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { useProject } from "@/platform/project";
import { usePlugins } from "./store";
import { useEditorRegistry } from "@/editor/api";
import { lspDoc } from "@/editor/lsp/lsp";
const mocks = vi.hoisted(() => ({
files: vi.fn(),
index: vi.fn(),
invalidate: vi.fn(),
cancel: vi.fn(),
events: new Map<string, (e: any) => void>(),
pluginEvent: null as null | ((e: any) => void),
}));
vi.mock("./backend", () => ({
pluginsBackend: () => ({
onEvent: (callback: (e: any) => void) => {
mocks.pluginEvent = callback;
return () => {
mocks.pluginEvent = null;
};
},
}),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
onIdeEvent: (type: string, callback: (e: any) => void) => {
mocks.events.set(type, callback);
return () => mocks.events.delete(type);
},
commands: {
...real.commands,
pluginNavigationFiles: mocks.files,
pluginNavigationIndex: mocks.index,
pluginNavigationInvalidate: mocks.invalidate,
cancelRequest: mocks.cancel,
},
};
});
const { startPluginNavigation } = await import("./navigation");
let off: () => void;
let view: EditorView | null = null;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocks.events.clear();
mocks.files.mockResolvedValue({
status: "ok",
data: { paths: ["/project/A.java", "/project/A.xml"], indexing: false, extensions: ["xml", "java"] },
});
mocks.index.mockResolvedValue({ status: "ok", data: null });
mocks.invalidate.mockResolvedValue({ status: "ok", data: null });
mocks.cancel.mockResolvedValue(undefined);
useProject.setState({ project: { root: "/project", trusted: true } as never });
usePlugins.setState({
plugins: [
{
id: "test",
version: "1",
status: "active",
contributions: [{ point: "documentIndexers", label: "xml, java" }],
} as never,
],
});
});
afterEach(() => {
off?.();
view?.destroy();
view = null;
vi.useRealTimers();
usePlugins.setState({ plugins: [] });
useProject.setState({ project: null });
});
it("prewarms once and indexes only changed paths after file events", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
expect(mocks.files).toHaveBeenCalledTimes(1);
expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.java", "/project/A.xml"]);
mocks.index.mockClear();
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(250);
expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.xml"]);
mocks.events.get("indexState")?.({ indexing: false, extensions: ["xml", "java"] });
await vi.advanceTimersByTimeAsync(100);
expect(mocks.files).toHaveBeenCalledTimes(1);
});
it("indexes unsaved editor changes and refreshes after same-version plugin reload", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
const ctx = { doc: 1, path: "/project/A.xml", language: "xml", uiMode: "XML", readOnly: false };
const extension = useEditorRegistry.getState().contributions.get("plugins.navigation")!;
view = new EditorView({
state: EditorState.create({ doc: "<mapper/>", extensions: [lspDoc.of(ctx), extension.extensions(ctx)] }),
});
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockClear();
view.dispatch({ changes: { from: 7, insert: " " } });
await vi.advanceTimersByTimeAsync(250);
expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.xml"]);
mocks.pluginEvent?.({ type: "documentIndexesChanged" });
await vi.advanceTimersByTimeAsync(250);
expect(mocks.invalidate).toHaveBeenCalledWith(null);
expect(mocks.files).toHaveBeenCalledTimes(2);
});
it("cancels outstanding work when project context changes", async () => {
mocks.index.mockImplementation(() => new Promise(() => {}));
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
useProject.setState({ project: null });
expect(mocks.cancel).toHaveBeenCalledTimes(1);
});
it("keeps warm indexes when API 1.0 reclaims an idle Wasm instance", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockClear();
mocks.pluginEvent?.({ type: "commandsChanged" });
usePlugins.setState({ plugins: usePlugins.getState().plugins.map((p) => ({ ...p, status: "inactive" })) });
await vi.advanceTimersByTimeAsync(250);
expect(mocks.files).toHaveBeenCalledTimes(1);
expect(mocks.index).not.toHaveBeenCalled();
});
+207
Переглянути файл
@@ -0,0 +1,207 @@
/** Background navigation indexing. Opening a project discovers files once;
* edits and filesystem events then enqueue only affected paths. */
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { registerEditorContribution, whenSynced } from "@/editor/api";
import { invalidateNavigationProviders, registerNavigationProvider } from "@/editor/navigationProviders";
import { docOf } from "@/editor/lsp/lsp";
import { offsetToPos, rangeToOffsets } from "@/editor/lsp/positions";
import { call, commands, isCancelled, isTauri, nextRequestId, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
import { invalidatePluginMarkers } from "./markerStore";
import { usePlugins } from "./store";
export function startPluginNavigation(): () => void {
if (!isTauri()) return () => {};
let generation = 0;
let disposed = false;
let running = false;
let request: number | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = new Set<string>();
const dirty = new Set<string>();
const views = new Map<EditorView, string>();
let discovered = false;
let discovering = false;
const known = new Set<string>();
let signature = "";
let reportedError = false;
function report(error: unknown) {
console.warn("Plugin navigation", error);
if (reportedError) return;
reportedError = true;
notifications().notify({
group: "plugins",
level: "warning",
title: "Plugin navigation indexing incomplete",
body: String(error),
});
}
let fileExtensions = new Set<string>();
const extensions = () => fileExtensions;
const eligible = (path: string) => extensions().has(path.split(".").pop() ?? "");
const ready = () =>
!disposed &&
!!useProject.getState().project?.trusted &&
usePlugins
.getState()
.plugins.some(
(p) =>
(p.status === "active" || p.status === "inactive") &&
p.contributions.some((c) => c.point === "documentIndexers"),
);
const notify = () => invalidateNavigationProviders();
async function flush() {
timer = undefined;
if (running || !ready() || !pending.size) return;
running = true;
const at = generation;
const paths = [...pending].slice(0, 8);
paths.forEach((p) => pending.delete(p));
const id = nextRequestId();
request = id;
try {
await Promise.all([...views].filter(([, p]) => paths.includes(p)).map(([v]) => whenSynced(v)));
if (at !== generation || !ready()) return;
await call(commands.pluginNavigationInvalidate(paths));
await call(commands.pluginNavigationIndex(paths, id));
if (at === generation) {
paths.forEach((p) => {
if (!pending.has(p)) dirty.delete(p);
});
notify();
}
} catch (error) {
if (at === generation && isCancelled(error)) paths.forEach((p) => pending.add(p));
else if (at === generation) report(error);
} finally {
request = null;
running = false;
if (pending.size && ready()) timer = setTimeout(() => void flush(), 20);
}
}
function queue(paths: string[], priority = false) {
const matching = paths.filter(eligible);
if (!matching.length || !ready()) return;
invalidatePluginMarkers(matching);
matching.forEach((p) => dirty.add(p));
pending = priority ? new Set([...matching, ...pending]) : new Set([...pending, ...matching]);
notify();
clearTimeout(timer);
timer = setTimeout(() => void flush(), priority ? 150 : 30);
}
async function discover() {
if (!ready() || discovered || discovering) return;
discovering = true;
const at = generation;
try {
const inventory = await call(commands.pluginNavigationFiles());
if (at !== generation) return;
fileExtensions = new Set(inventory.extensions);
discovered = !inventory.indexing;
const fresh = inventory.paths.filter((p) => !known.has(p));
inventory.paths.forEach((p) => known.add(p));
queue(fresh);
queue([...views.values()], true);
} catch (error) {
discovered = false;
report(error);
} finally {
if (at === generation) discovering = false;
if (!discovered && ready() && at === generation) setTimeout(() => void discover(), 1000);
}
}
function reset() {
invalidatePluginMarkers();
generation++;
if (request !== null) void commands.cancelRequest(request);
clearTimeout(timer);
pending.clear();
dirty.clear();
known.clear();
discovered = false;
discovering = false;
reportedError = false;
notify();
if (ready())
void call(commands.pluginNavigationInvalidate(null))
.then(discover)
.catch(() => {});
}
const offs = [
pluginsBackend().onEvent((event) => {
if (event.type === "documentIndexesChanged") reset();
}),
registerNavigationProvider(async (view, offset) => {
const doc = docOf(view);
if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path)) return null;
const before = view.state.doc;
await whenSynced(view);
const result = await call(commands.pluginNavigationGoto(doc.doc, offsetToPos(before, offset)));
if (!result || view.state.doc !== before) return null;
return { ...rangeToOffsets(before, result.range), targets: result.targets.filter((t) => !dirty.has(t.path)) };
}),
registerEditorContribution({
id: "plugins.navigation",
extensions: (ctx) =>
ctx.virtual || ctx.doc < 0
? []
: ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
views.set(view, ctx.path);
queue([ctx.path], true);
}
update(update: ViewUpdate) {
if (update.docChanged) queue([ctx.path], true);
}
destroy() {
views.delete(this.view);
queue([ctx.path]);
}
},
),
}),
onIdeEvent("fsChanged", (event) => {
const paths = [...event.created, ...event.modified, ...event.removed];
queue(paths);
}),
onIdeEvent("indexState", (event) => {
if (!event.indexing) void discover();
}),
onIdeEvent("projectRootsChanged", () => reset()),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) reset();
}),
usePlugins.subscribe((state) => {
const next = state.plugins
.map(
(p) =>
`${p.id}:${p.version}:${p.status === "active" || p.status === "inactive" ? "live" : p.status}:${JSON.stringify(p.contributions)}`,
)
.join("|");
if (next !== signature) {
signature = next;
reset();
}
}),
];
// Index completion events may predate feature activation; querying the current
// inventory now is safe, and an incomplete inventory is retried on IndexState.
signature = usePlugins
.getState()
.plugins.map(
(p) =>
`${p.id}:${p.version}:${p.status === "active" || p.status === "inactive" ? "live" : p.status}:${JSON.stringify(p.contributions)}`,
)
.join("|");
void discover();
return () => {
disposed = true;
reset();
offs.reverse().forEach((off) => off());
views.clear();
};
}
+34
Переглянути файл
@@ -0,0 +1,34 @@
import { tx } from "@/platform/i18n";
import { navigateTo, locationItems } from "@/editor/lsp/navigation";
import { openPopup } from "@/editor/lsp/popupStore";
import { gutterIconMarkup, isIconName } from "@/ui/icons";
import type { Location } from "@/platform/ipc";
async function openTargets(targets: Location[], element: HTMLElement) {
if (targets.length === 1) {
await navigateTo(targets[0]!);
return;
}
const rect = element.getBoundingClientRect();
openPopup({
kind: "list",
title: tx`Choose Mapping Target`,
anchor: { x: rect.left, y: rect.bottom + 2 },
items: await locationItems(targets),
});
}
/** Shared ordinary-click and keyboard navigation for interactive and indexed markers. */
export function navigationButton(targets: Location[], icon: string): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "cm-plugin-mapping-button";
button.title = targets.map((t) => `${t.path.split("/").pop()}:${t.range.start.line + 1}`).join("\n");
button.setAttribute("aria-label", tx`Go to mapping: ${button.title}`);
button.innerHTML = gutterIconMarkup(isIconName(icon) ? icon : "arrow-right") ?? "";
button.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
void openTargets(targets, button);
};
return button;
}