дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(plugins): report Problems to plugins in remote projects (#2223)
Цей коміт міститься в:
1 джерело
fd8b4ff14c
коміт
3dac5b8bdc
8 files changed
+199
-7
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [] }] },
|
||||
{
|
||||
|
||||
Reference in new issue
Заблокувати користувача