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

+5
Переглянути файл
@@ -617,6 +617,11 @@ impl RemotePlugins {
c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?);
Ok(())
});
d.add("mcp_report_problems", |c| async move {
c.host.project(&c.window)?;
c.host.mcp_reports.report_problems(&c.window, c.arg("reset")?, c.arg("files")?);
Ok(())
});
let h = host.clone();
d.add("plugin_decoration_sets", move |_c| {
let h = h.clone();
+4
Переглянути файл
@@ -286,6 +286,10 @@ where
let p = Peer::connect(reader, writer, handler);
let _ = peer.set(p.clone());
p.closed().await;
// Reports belong to the disconnected desktop, even while the host shuts down.
for window in host.projects.read().keys() {
host.mcp_reports.forget_window(window);
}
}
/// Sends a window's events to the client as `event` notifications.
+73 -2
Переглянути файл
@@ -396,7 +396,7 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, dispatcher, peer_slot, sr, sw));
let server = tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client { desktop: Some(desktop.clone()), ..Client::default() });
let peer = Peer::connect(cr, cw, client.clone());
@@ -416,6 +416,53 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
assert!(editors.as_str().unwrap().contains("active: true"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert_eq!(problems.as_str(), Some("[]"));
let other_file = project.join("other.txt");
std::fs::write(&other_file, "other text").unwrap();
let outside_file = tmp.path().join("outside.txt");
std::fs::write(&outside_file, "outside").unwrap();
let problem = |path: &Path, message: &str| {
json!({"path": path, "items": [{
"range": {"start": {"line": 0, "col": 1}, "end": {"line": 0, "col": 3}},
"severity": "warning", "message": message, "source": "remote-check", "code": null
}]})
};
let files = json!([
problem(&remote_file, "remote warning"),
problem(&other_file, "other warning"),
problem(&outside_file, "outside warning")
]);
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
for path in ["remote.txt", remote_file.to_str().unwrap()] {
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(!problems.contains("other warning"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
}
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(!problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
invoke(
&peer,
"mcp_report_problems",
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
)
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("replacement warning"));
assert!(!problems.as_str().unwrap().contains("other warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
let sets = invoke(&peer, "plugin_decoration_sets", json!({})).await.unwrap();
@@ -466,9 +513,33 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
stream.read_to_string(&mut response).await.unwrap();
assert!(response.contains("You can close this tab"));
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
invoke(&peer, "close_project", json!({})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
peer.kill();
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
let plugin_host = plugins.host().clone();
let result = tokio::task::spawn_blocking(move || {
plugin_host
.run_command(
"api14",
"problems",
None,
ide_plugin::Invocation {
client: Some("main".into()),
workspace_root: Some(project),
..Default::default()
},
)
.unwrap()
})
.await
.unwrap();
assert_eq!(result.as_deref(), Some("[]"));
plugins.host().shutdown();
desktop.shutdown();
peer.kill();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+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: [] }] },
{