From 3dac5b8bdcd58159e58f5d6fff9c54be74ae1c2d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:18:11 +0800 Subject: [PATCH] fix(plugins): report Problems to plugins in remote projects (#2223) --- crates/ide-host/src/plugins/remote_host.rs | 5 ++ crates/ide-host/src/rpc.rs | 4 + crates/ide-host/tests/remote_plugins.rs | 75 ++++++++++++++++++- .../code-insight/problemsReport.test.ts | 54 ++++++++++++- src/features/code-insight/problemsReport.ts | 12 ++- src/features/mcp/index.ts | 10 ++- src/features/mcp/reporters.test.ts | 19 ++++- src/platform/remoteSettings.test.ts | 27 +++++++ 8 files changed, 199 insertions(+), 7 deletions(-) diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index de1f3775c..524b387b9 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -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(); diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index f8b5e75f1..ca92ee633 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -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. diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs index e2bc8e289..16205f71d 100644 --- a/crates/ide-host/tests/remote_plugins.rs +++ b/crates/ide-host/tests/remote_plugins.rs @@ -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)] diff --git a/src/features/code-insight/problemsReport.test.ts b/src/features/code-insight/problemsReport.test.ts index 8432745ba..1d95a4452 100644 --- a/src/features/code-insight/problemsReport.test.ts +++ b/src/features/code-insight/problemsReport.test.ts @@ -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()), @@ -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)]); diff --git a/src/features/code-insight/problemsReport.ts b/src/features/code-insight/problemsReport.ts index 707b95ab7..1c4c79350 100644 --- a/src/features/code-insight/problemsReport.ts +++ b/src/features/code-insight/problemsReport.ts @@ -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 | 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); diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts index a64faeef5..0d56f41f4 100644 --- a/src/features/mcp/index.ts +++ b/src/features/mcp/index.ts @@ -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); diff --git a/src/features/mcp/reporters.test.ts b/src/features/mcp/reporters.test.ts index 4741eb2c7..8246df1a3 100644 --- a/src/features/mcp/reporters.test.ts +++ b/src/features/mcp/reporters.test.ts @@ -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) => 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); diff --git a/src/platform/remoteSettings.test.ts b/src/platform/remoteSettings.test.ts index 604b04af5..ad127752b 100644 --- a/src/platform/remoteSettings.test.ts +++ b/src/platform/remoteSettings.test.ts @@ -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: [] }] }, {