дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
9069c322e8
коміт
ed08b96382
79 files changed
+3039
-46
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача