Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai 4d596c7943 fix(plugins): don't block Find Usages on a failed plugin index batch
A plugin indexer batch that failed left its paths in the dirty set, so every
later Find Usages and rename failed with "Plugin indexes are still updating"
although nothing was running. Failed paths now move to a failed set that is
retried before the next usages or refactoring. Usages go ahead with a
"may be incomplete" note while an index is not up to date; refactoring still
refuses, with a message that says the index failed rather than that it is
updating.
2026-11-12 18:36:39 +08:00
8 змінених файлів з 103 додано та 22 видалено

No files matched your search

+2 -2
Переглянути файл
@@ -56,7 +56,7 @@ async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
async function prepareIndexes(view: EditorView, path: string): Promise<boolean> {
const before = view.state.doc;
try {
await synchronizeDocumentIndexes(path);
await synchronizeDocumentIndexes(path, "refactor");
return view.state.doc === before;
} catch (e) {
reportLspError(view, e, true);
@@ -269,7 +269,7 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
let renamed;
const before = view.state.doc;
try {
await synchronizeDocumentIndexes(doc.path);
await synchronizeDocumentIndexes(doc.path, "refactor");
if (renameRequest !== request || view.state.doc !== before) return;
renamed = await lspRequest(() => commands.lspRename(request, doc.doc, pos, newName));
if (renameRequest !== request) return;
+18 -1
Переглянути файл
@@ -160,7 +160,7 @@ describe("findUsages", () => {
const pending = findUsages();
const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } });
try {
await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs"));
await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs", "usages"));
expect(lspReferences).not.toHaveBeenCalled();
expect(channels).toHaveLength(allocated);
release();
@@ -181,6 +181,23 @@ describe("findUsages", () => {
});
});
describe("findUsages with a contributed index that is not up to date", () => {
it("still searches, and says plugin usages may be missing", async () => {
const off = registerDocumentIndexSync(async () => false);
const at = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null });
try {
await findUsages();
const search = activeUsageSearch(useUsages.getState())!;
expect(search.groups).toHaveLength(1);
expect(search.note).toBe("May be incomplete: plugin indexes are still updating");
} finally {
off();
resetUsages();
}
});
});
describe("usage tabs", () => {
const search = (id: number) => ({
id,
+3 -1
Переглянути файл
@@ -6,6 +6,7 @@ import { clearLineTokens } from "@/editor/lineTokens";
import { showPendingHint } from "@/editor/lsp/hint";
import { incompleteNote } from "@/editor/lsp/lsp";
import { commands, IpcError, isCancelled, nextRequestId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
import { synchronizeDocumentIndexes, toolWindowService } from "@/workbench/contributions";
import {
@@ -230,7 +231,7 @@ export async function fetchUsages(
let note: string | null = null;
let librariesSkipped = false;
await caretSynced(c);
await synchronizeDocumentIndexes(c.path);
const indexed = await synchronizeDocumentIndexes(c.path, "usages");
if (c.view && c.view.state.doc !== before) throw new IpcError({ kind: "cancelled", message: "", data: null });
let settled = false;
const channel = new Channel<References>();
@@ -268,6 +269,7 @@ export async function fetchUsages(
locs = fb.locs;
note = fb.note;
}
if (!indexed) note ??= tx("May be incomplete: plugin indexes are still updating");
const files = await readManyFileLines(pathsToRead(locs, classified));
return { groups: groupUsages(locs, files, classified), note, librariesSkipped };
}
+31 -5
Переглянути файл
@@ -135,7 +135,7 @@ it("flushes pending changes before usages or rename without waiting for the debo
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockClear();
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await synchronizeDocumentIndexes("/project/A.xml");
await synchronizeDocumentIndexes("/project/A.xml", "refactor");
expect(mocks.index).toHaveBeenCalledExactlyOnceWith(["/project/A.xml"], expect.any(Number));
});
@@ -153,7 +153,7 @@ it("waits for an active batch, then indexes changes queued while it was running"
await vi.advanceTimersByTimeAsync(40);
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.java"], removed: [] });
let settled = false;
const waiting = synchronizeDocumentIndexes("/project/A.xml").then(() => {
const waiting = synchronizeDocumentIndexes("/project/A.xml", "refactor").then(() => {
settled = true;
});
await Promise.resolve();
@@ -168,7 +168,33 @@ it("refuses refactoring if a queued index batch failed", async () => {
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await expect(synchronizeDocumentIndexes("/project/A.xml")).rejects.toThrow("Plugin indexes are still updating");
await expect(synchronizeDocumentIndexes("/project/A.xml", "refactor")).rejects.toThrow(
"Plugin indexes could not be updated",
);
});
it("lets usages go ahead, marked incomplete, while a failed batch keeps failing", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
const failure = { status: "error", error: { kind: "unavailable", message: "indexer failed" } };
mocks.index.mockResolvedValue(failure);
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
// Every later search used to fail with "still updating" although nothing was running.
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(false);
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(false);
});
it("retries a failed batch before the next usages or refactoring", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
mocks.index.mockClear();
await expect(synchronizeDocumentIndexes("/project/A.java", "refactor")).resolves.toBe(true);
expect(mocks.index).toHaveBeenCalledWith(["/project/A.xml"], expect.any(Number));
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(true);
});
it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"])(
@@ -179,13 +205,13 @@ it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"
mocks.index.mockImplementationOnce(() => new Promise(() => {}));
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
await synchronizeDocumentIndexes(path);
await synchronizeDocumentIndexes(path, "refactor");
},
);
it("keeps unrelated language-server queries available when plugin discovery fails", async () => {
mocks.files.mockRejectedValue(new Error("Plugin file inventory is unavailable"));
off = startPluginNavigation();
await expect(synchronizeDocumentIndexes("/project/notes.txt")).resolves.toBeUndefined();
await expect(synchronizeDocumentIndexes("/project/notes.txt", "refactor")).resolves.toBe(true);
expect(mocks.index).not.toHaveBeenCalled();
});
+31 -6
Переглянути файл
@@ -22,9 +22,12 @@ export function startPluginNavigation(): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = new Set<string>();
const dirty = new Set<string>();
/** Paths whose last index batch failed: retried on the next usages or refactoring. */
const failed = new Set<string>();
const views = new Map<EditorView, string>();
let discovered = false;
let discovering = false;
let discoveryFailed = false;
const known = new Set<string>();
let signature = "";
let reportedError = false;
@@ -79,13 +82,22 @@ export function startPluginNavigation(): () => void {
await call(commands.pluginNavigationIndex(paths, id));
if (at === generation) {
paths.forEach((p) => {
failed.delete(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);
else if (at === generation) {
// Not "still updating": the batch is done, its paths just have no fresh index.
paths.forEach((p) => {
if (pending.has(p)) return;
dirty.delete(p);
failed.add(p);
});
report(error);
}
} finally {
request = null;
running = false;
@@ -120,12 +132,14 @@ export function startPluginNavigation(): () => void {
if (at !== generation) return;
fileExtensions = new Set(inventory.extensions);
discovered = !inventory.indexing;
discoveryFailed = false;
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;
discoveryFailed = true;
report(error);
} finally {
if (at === generation) discovering = false;
@@ -139,9 +153,11 @@ export function startPluginNavigation(): () => void {
clearTimeout(timer);
pending.clear();
dirty.clear();
failed.clear();
known.clear();
discovered = false;
discovering = false;
discoveryFailed = false;
discovery = null;
reportedError = false;
notify();
@@ -151,32 +167,41 @@ export function startPluginNavigation(): () => void {
.catch(() => {});
}
const offs = [
registerDocumentIndexSync(async (path) => {
registerDocumentIndexSync(async (path, purpose) => {
if (!ready()) return;
const root = useProject.getState().project!.root;
if (!path.startsWith(root.endsWith("/") ? root : `${root}/`)) return;
const at = generation;
await discover();
if (!eligible(path)) return;
if (failed.size) queue([...failed]);
while (ready() && at === generation && (pending.size || flushing)) {
clearTimeout(timer);
await flush();
}
if (at !== generation || !discovered || dirty.size) {
throw new Error(tx`Plugin indexes are still updating; try again shortly`);
const complete = at === generation && discovered && !dirty.size && !failed.size;
if (complete) return true;
if (purpose === "usages") return false;
if (at === generation && (failed.size || discoveryFailed)) {
throw new Error(tx`Plugin indexes could not be updated; see the plugin notification`);
}
throw new Error(tx`Plugin indexes are still updating; try again shortly`);
}),
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;
if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path) || failed.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)) };
return {
...rangeToOffsets(before, result.range),
targets: result.targets.filter((t) => !dirty.has(t.path) && !failed.has(t.path)),
};
}),
registerEditorContribution({
id: "plugins.navigation",
+3 -1
Переглянути файл
@@ -1344,5 +1344,7 @@
"Close Agent Task": "Close Agent Task",
"Close and New Agent Task": "Close and New Agent Task",
"Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.",
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page."
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page.",
"May be incomplete: plugin indexes are still updating": "May be incomplete: plugin indexes are still updating",
"Plugin indexes could not be updated; see the plugin notification": "Plugin indexes could not be updated; see the plugin notification"
}
+3 -1
Переглянути файл
@@ -7731,5 +7731,7 @@
"Download Remote Files Into Folder": "将远程文件下载到文件夹",
"Download Remote Host files into a local folder (arg: { server, paths, target })": "将远程主机中的文件下载到本地文件夹(参数:{ server, paths, target })",
"Delegate IDE build/run actions to Maven": "将 IDE 构建/运行操作委托给 Maven",
"Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "运行和调试时用 Maven(项目的 javac)构建 Maven 模块,而不是用 Java 语言服务器。编辑器中的问题仍来自语言服务器。"
"Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "运行和调试时用 Maven(项目的 javac)构建 Maven 模块,而不是用 Java 语言服务器。编辑器中的问题仍来自语言服务器。",
"May be incomplete: plugin indexes are still updating": "可能不完整:插件索引仍在更新",
"Plugin indexes could not be updated; see the plugin notification": "插件索引更新失败,详见插件通知"
}
+12 -5
Переглянути файл
@@ -1825,16 +1825,23 @@ export function addContributedFileTemplate(t: import("@/platform/fileTemplates")
return () => useContributedFileTemplates.setState((s) => ({ templates: s.templates.filter((x) => x !== t) }));
}
/** Flush contributed document indexes before workspace usages or refactoring. */
const documentIndexSync = new Set<(path: string) => Promise<void>>();
export function registerDocumentIndexSync(sync: (path: string) => Promise<void>): () => void {
/**
* Flush contributed document indexes before workspace usages or refactoring. A refactoring
* refuses (throws) while an index is incomplete; usages go ahead and resolve `false`, so the
* results can say they may be incomplete.
*/
export type DocumentIndexPurpose = "usages" | "refactor";
type DocumentIndexSync = (path: string, purpose: DocumentIndexPurpose) => Promise<boolean | void>;
const documentIndexSync = new Set<DocumentIndexSync>();
export function registerDocumentIndexSync(sync: DocumentIndexSync): () => void {
documentIndexSync.add(sync);
return () => {
documentIndexSync.delete(sync);
};
}
export async function synchronizeDocumentIndexes(path: string): Promise<void> {
await Promise.all([...documentIndexSync].map((sync) => sync(path)));
export async function synchronizeDocumentIndexes(path: string, purpose: DocumentIndexPurpose): Promise<boolean> {
const complete = await Promise.all([...documentIndexSync].map((sync) => sync(path, purpose)));
return complete.every((c) => c !== false);
}
/** Font zoom for non-CodeMirror surfaces, owned and released by their features. */