fix(plugins): report Problems to plugins in remote projects (#2223)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 09:18:11 +08:00
1 джерело fd8b4ff14c
коміт 3dac5b8bdc
8 files changed
+199 -7

No files matched your search

+53 -1
Переглянути файл
@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Diagnostic, FileProblems } from "@/bindings";
const state = vi.hoisted(() => ({
projectChanged: () => {},
remoteChanged: () => {},
report: vi.fn(),
}));
const reports = vi.hoisted(() => [] as [boolean, FileProblems[]][]);
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
@@ -8,12 +13,29 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({
commands: {
mcpReportProblems: async (reset: boolean, files: FileProblems[]) => {
reports.push([reset, files]);
await state.report();
},
},
}));
vi.mock("@/platform/project", () => ({
useProject: { getState: () => ({ project: { root: "/p" } }), subscribe: () => () => {} },
useProject: {
getState: () => ({ project: { root: "/p" } }),
subscribe: (fn: (s: unknown, prev: unknown) => void) => {
state.projectChanged = () => fn({ project: { root: "/p" } }, { project: null });
return () => {};
},
},
}));
vi.mock("@/platform/remote", () => ({
isRemoteWindow: () => true,
useRemote: {
subscribe: (fn: (s: unknown, prev: unknown) => void) => {
state.remoteChanged = () => fn({ session: {} }, { session: {} });
return () => {};
},
},
}));
const { applyDiagnosticsEvent, clearProblems } = await import("./problemsStore");
@@ -35,6 +57,7 @@ describe("reportProblems", () => {
vi.useFakeTimers();
clearProblems();
reports.length = 0;
state.report.mockReset();
applyDiagnosticsEvent("/p/a.rs", "ra", [d(1), d(2)]);
stop = reportProblems();
});
@@ -43,6 +66,35 @@ describe("reportProblems", () => {
vi.useRealTimers();
});
it.each(["projectChanged", "remoteChanged"] as const)("resends the full snapshot after %s", async (change) => {
await vi.advanceTimersByTimeAsync(1000);
state[change]();
await vi.advanceTimersByTimeAsync(1000);
expect(summary()).toEqual([
[true, [["/p/a.rs", 2]]],
[true, [["/p/a.rs", 2]]],
]);
});
it("retries a failed delta with the full snapshot", async () => {
await vi.advanceTimersByTimeAsync(1000);
state.report.mockRejectedValueOnce(new Error("disconnected"));
applyDiagnosticsEvent("/p/b.rs", "ra", [d(0)]);
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
expect(summary()).toEqual([
[true, [["/p/a.rs", 2]]],
[false, [["/p/b.rs", 1]]],
[
true,
[
["/p/a.rs", 2],
["/p/b.rs", 1],
],
],
]);
});
it("sends everything first, then only the files that changed once diagnostics settle", async () => {
await vi.advanceTimersByTimeAsync(1000);
applyDiagnosticsEvent("/p/b.rs", "ra", [d(0)]);
+10 -2
Переглянути файл
@@ -1,7 +1,7 @@
/** Reports changed files from the Problems view to the built-in MCP host. */
import { commands, call, type FileProblems } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { useRemote } from "@/platform/remote";
import { useProblems, type ProblemsMap } from "./problemsStore";
const REPORT_MS = 1000;
@@ -13,7 +13,7 @@ export function reportProblems(): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
if (isRemoteWindow() || !useProject.getState().project) return;
if (!useProject.getState().project) return;
const map = useProblems.getState().map;
const files: FileProblems[] = [];
for (const [path, sources] of map)
@@ -42,8 +42,16 @@ export function reportProblems(): () => void {
schedule();
}
});
const offRemote = useRemote.subscribe((s, prev) => {
if (s.session !== prev.session) {
reset = true;
reported = new Map();
schedule();
}
});
return () => {
disposed = true;
offRemote();
offProject();
off();
if (timer) clearTimeout(timer);
+9 -1
Переглянути файл
@@ -2,7 +2,7 @@
import { activeEditor, onEditorEvent, openEditorPaths, useEditorRegistry } from "@/editor/api";
import { commands, call, onIdeEvent, ideEventsReady } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { isRemoteWindow, useRemote } from "@/platform/remote";
import { registerOverlay } from "@/workbench/contributions";
import { McpConfirmHost } from "./ConfirmDialog";
@@ -85,6 +85,13 @@ export function activate(): () => void {
schedule();
}
});
const offRemote = useRemote.subscribe((s, prev) => {
if (s.session !== prev.session) {
lastPaths = undefined;
lastEditor = undefined;
schedule();
}
});
window.addEventListener("focus", schedule);
window.addEventListener("blur", schedule);
return () => {
@@ -96,6 +103,7 @@ export function activate(): () => void {
window.removeEventListener("beforeunload", unload);
offRegistry();
offEvents();
offRemote();
offProject();
window.removeEventListener("focus", schedule);
window.removeEventListener("blur", schedule);
+18 -1
Переглянути файл
@@ -7,6 +7,7 @@ const state = vi.hoisted(() => ({
registryChanged: () => {},
editorChanged: () => {},
projectChanged: () => {},
remoteChanged: () => {},
reportOpen: vi.fn(async () => undefined),
reportFocused: vi.fn(async () => undefined),
}));
@@ -44,7 +45,15 @@ vi.mock("@/platform/project", () => ({
},
},
}));
vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => state.remote }));
vi.mock("@/platform/remote", () => ({
isRemoteWindow: () => state.remote,
useRemote: {
subscribe: (fn: (s: unknown, prev: unknown) => void) => {
state.remoteChanged = () => fn({ session: {} }, { session: {} });
return () => {};
},
},
}));
vi.mock("@/platform/ipc", () => ({
call: (promise: Promise<unknown>) => promise,
onIdeEvent: () => () => {},
@@ -106,6 +115,14 @@ describe("MCP editor reports", () => {
expect(state.reportOpen).toHaveBeenCalledTimes(2);
expect(state.reportFocused).toHaveBeenCalledTimes(2);
});
it("resends unchanged editors after reconnecting at the same root", async () => {
state.remote = true;
await vi.advanceTimersByTimeAsync(300);
state.remoteChanged();
await vi.advanceTimersByTimeAsync(300);
expect(state.reportOpen).toHaveBeenCalledTimes(2);
expect(state.reportFocused).toHaveBeenCalledTimes(2);
});
it("forwards remote editor reports and stops after disposal", async () => {
state.remote = true;
await vi.advanceTimersByTimeAsync(300);
+27
Переглянути файл
@@ -56,6 +56,33 @@ afterEach(() => {
});
describe("remote client settings", () => {
it("routes window reports through the commands advertised by the remote host", async () => {
enableRemote({
target: { kind: "ssh", config: "test" },
root: "/remote",
host: "test",
os: "linux",
commands: ["mcp_set_open_editors", "mcp_set_focused_editor", "mcp_report_problems"],
});
invoke.mockResolvedValue("null");
await call(commands.mcpSetOpenEditors(["/remote/a.txt"]));
await call(commands.mcpSetFocusedEditor(null, true));
const files = [{ path: "/remote/a.txt", items: [] }];
await call(commands.mcpReportProblems(true, files));
await call(commands.mcpReportProblems(false, files));
const reports = invoke.mock.calls.map(([cmd, args]) => [
cmd,
(args as { cmd: string }).cmd,
JSON.parse((args as { args: string }).args),
]);
expect(reports).toEqual([
["remote_invoke", "mcp_set_open_editors", { paths: ["/remote/a.txt"] }],
["remote_invoke", "mcp_set_focused_editor", { editor: null, focused: true }],
["remote_invoke", "mcp_report_problems", { reset: true, files }],
["remote_invoke", "mcp_report_problems", { reset: false, files }],
]);
});
it.each([
{ gradle: { dir: "app", projects: [] }, expected: [{ dir: "app", projects: [] }] },
{