diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx index f16908d9..ff54dc7c 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx @@ -11,12 +11,16 @@ import { import { DIProvider } from "@/app/di"; import type { CustomPluginLayoutCell, + PluginContributionDto, PluginRuntimeContributionCatalog, PluginRuntimePlugin, } from "@/domain"; +import type { WhenContext } from "@/plugins/runtime"; +import { MenuBar } from "@/shared"; import { usePluginRuntime } from "./PluginRuntimeProvider"; import { PluginLayoutCellView } from "./PluginLayoutCellView"; import { PluginRuntimeProvider } from "./PluginRuntimeProvider"; +import { usePluginMenus } from "./usePluginMenus"; function dataUrl(source: string): string { return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; @@ -78,6 +82,28 @@ function RuntimeProbe({ onMount }: { onMount: () => void }) { ); } +const OPEN_PROJECT_CONTEXT: WhenContext = { + projectOpen: true, + gitRepository: true, + agentSelected: false, + terminalFocused: false, + layoutCellFocused: false, +}; + +function MenuRuntimeProbe() { + const { registry, loading } = usePluginRuntime(); + const menus = usePluginMenus(registry, OPEN_PROJECT_CONTEXT); + return ( + <> + + {loading ? "loading" : (registry.list()[0]?.displayName ?? "none")} + + + {menus.commandError &&

{menus.commandError.message}

} + + ); +} + describe("PluginRuntimeProvider soft refresh (#279)", () => { it("reloads contributions after install and reload events without remounting its workspace", async () => { const plugin = new MutableRuntimeCatalogGateway(); @@ -145,6 +171,86 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => { expect(plugin.calls).toBe(4); }); + it("dispatches the V2 handler after plugin reload when menu and command ids stay unchanged (#336)", async () => { + const calls: string[] = []; + (globalThis as typeof globalThis & { __IDEA_MENU_RELOAD_CALLS__?: string[] }) + .__IDEA_MENU_RELOAD_CALLS__ = calls; + const plugin = new MutableRuntimeCatalogGateway(); + const system = new MockSystemGateway(); + const focusedProject = new MockFocusedProjectGateway(); + await focusedProject.setFocusedProject({ id: "project-1", name: "Alpha", root: "/alpha" }); + const contributions: PluginContributionDto = { + menus: [{ id: "git-menu", label: "Git", topLevel: true, order: 10 }], + menuItems: [{ + id: "open.item", + targetMenuId: "plugin:git-menu", + label: "Open repositories", + command: "git.open", + order: 10, + when: "projectOpen", + }], + layouts: [], + mcpServers: [], + }; + const bundle = (version: string) => dataUrl(` + export function activate(ctx) { + ctx.subscriptions.push(ctx.commands.registerCommand("git.open", () => { + globalThis.__IDEA_MENU_RELOAD_CALLS__.push("${version}"); + })); + } + `); + plugin.catalog = { + plugins: [runtimePlugin({ + displayName: "Git runtime V1", + contentHash: "menu-v1", + bundleUrl: bundle("v1"), + contributes: contributions, + })], + }; + const gateways = { ...createMockGateways(), plugin, system, focusedProject }; + + try { + render( + + + + + , + ); + + expect(await screen.findByText("Git runtime V1")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Git" })); + fireEvent.click(screen.getByRole("button", { name: "Open repositories · Git runtime V1" })); + await waitFor(() => expect(calls).toEqual(["v1"])); + + plugin.catalog = { + plugins: [runtimePlugin({ + displayName: "Git runtime V2", + version: "2.0.0", + contentHash: "menu-v2", + bundleUrl: bundle("v2"), + contributes: contributions, + })], + }; + system.emit({ + type: "pluginReloaded", + pluginId: "dev.acme.refreshable", + oldContentHash: "menu-v1", + newContentHash: "menu-v2", + changed: true, + }); + + expect(await screen.findByText("Git runtime V2")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Git" })); + fireEvent.click(screen.getByRole("button", { name: "Open repositories · Git runtime V2" })); + await waitFor(() => expect(calls).toEqual(["v1", "v2"])); + expect(screen.queryByRole("alert")).toBeNull(); + } finally { + delete (globalThis as typeof globalThis & { __IDEA_MENU_RELOAD_CALLS__?: string[] }) + .__IDEA_MENU_RELOAD_CALLS__; + } + }); + it("ignores a custom plugin layout ref when its contribution disappears", async () => { const plugin = new MutableRuntimeCatalogGateway(); const system = new MockSystemGateway(); diff --git a/frontend/src/features/plugins/usePluginMenus.test.tsx b/frontend/src/features/plugins/usePluginMenus.test.tsx index 4561acf3..22d2c1ce 100644 --- a/frontend/src/features/plugins/usePluginMenus.test.tsx +++ b/frontend/src/features/plugins/usePluginMenus.test.tsx @@ -107,8 +107,9 @@ describe("usePluginMenus", () => { } }); - it("keeps a missing command handler as a silent no-op", async () => { + it("surfaces a missing command handler after plugin reload", async () => { const runCommandStrict = vi.fn(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const registry = throwingRegistry({ runCommandStrict, get: vi.fn(() => ({ commands: { has: () => false } })), @@ -118,6 +119,43 @@ describe("usePluginMenus", () => { await act(() => result.current.runCommand("dev.acme.git", "git.missing")); expect(runCommandStrict).not.toHaveBeenCalled(); - expect(result.current.commandError).toBeNull(); + expect(result.current.commandError).toEqual({ + pluginId: "dev.acme.git", + commandId: "git.missing", + message: "Commande indisponible après le rechargement du plugin. Réessayez après avoir rechargé le plugin.", + }); + expect(consoleError).toHaveBeenCalledWith( + "[plugins] menu command handler unavailable", + expect.objectContaining({ pluginId: "dev.acme.git", commandId: "git.missing" }), + ); + consoleError.mockRestore(); + }); + + it.each([ + [ + { status: "skipped", reason: "services-unavailable", message: "raw services payload" }, + "Le service Git n’est pas disponible pour le moment. Réessayez dans quelques instants.", + ], + [ + { status: "skipped", reason: "no-focused-project", message: "raw project payload" }, + "Sélectionnez un projet pour utiliser cette commande.", + ], + [ + { status: "failed", message: "raw internal failure payload" }, + "La commande Git n’a pas pu être exécutée. Réessayez.", + ], + ])("maps structured command feedback to a minimal visible message", async (feedback, message) => { + const runCommandStrict = vi.fn(async () => feedback); + const registry = throwingRegistry({ runCommandStrict }); + const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT)); + + await act(() => result.current.runCommand("dev.idea.git-plugin", "git-plugin.open-dashboard")); + + expect(result.current.commandError).toEqual({ + pluginId: "dev.idea.git-plugin", + commandId: "git-plugin.open-dashboard", + message, + }); + expect(result.current.commandError?.message).not.toContain("raw"); }); }); diff --git a/frontend/src/features/plugins/usePluginMenus.ts b/frontend/src/features/plugins/usePluginMenus.ts index 4ca579f4..feadc722 100644 --- a/frontend/src/features/plugins/usePluginMenus.ts +++ b/frontend/src/features/plugins/usePluginMenus.ts @@ -14,6 +14,7 @@ import { useCallback, useMemo, useState } from "react"; import type { MenuTargetId } from "@/domain"; +import { useI18n } from "@/i18n"; import type { MenuBarItem, MenuBarMenu } from "@/shared"; import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime"; import { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus"; @@ -41,6 +42,43 @@ function describeError(e: unknown): string { return String(e); } +function objectOrNull(value: unknown): Record | null { + return value !== null && typeof value === "object" + ? value as Record + : null; +} + +function structuredCommandFeedbackMessage( + value: unknown, + tr: (french: string, english: string) => string, +): string | null { + const feedback = objectOrNull(value); + if (!feedback) return null; + if (feedback.status === "failed") { + return tr( + "La commande Git n’a pas pu être exécutée. Réessayez.", + "The Git command could not be run. Try again.", + ); + } + if (feedback.status !== "skipped") return null; + if (feedback.reason === "services-unavailable") { + return tr( + "Le service Git n’est pas disponible pour le moment. Réessayez dans quelques instants.", + "The Git service is currently unavailable. Try again in a few moments.", + ); + } + if (feedback.reason === "no-focused-project") { + return tr( + "Sélectionnez un projet pour utiliser cette commande.", + "Select a project to use this command.", + ); + } + return tr( + "La commande du plugin n’est pas disponible pour le moment. Réessayez.", + "The plugin command is currently unavailable. Try again.", + ); +} + function rejectPluginMenuContribution( scope: string, details: Record, @@ -57,18 +95,32 @@ export function usePluginMenus( registry: PluginRuntimeRegistry, whenCtx: WhenContext, ): UsePluginMenusResult { + const { tr } = useI18n(); const [commandError, setCommandError] = useState(null); const clearCommandError = useCallback(() => setCommandError(null), []); const runCommand = useCallback( async (pluginId: string, commandId: string): Promise => { setCommandError(null); + if (!registry.get(pluginId)?.commands.has(commandId)) { + const failure = { + pluginId, + commandId, + message: tr( + "Commande indisponible après le rechargement du plugin. Réessayez après avoir rechargé le plugin.", + "Command unavailable after the plugin reload. Try again after reloading the plugin.", + ), + }; + console.error("[plugins] menu command handler unavailable", failure); + setCommandError(failure); + return undefined; + } try { - // Preserve the SDK's historical no-op for a declared menu command whose - // handler was not registered, but do not use the lenient dispatcher for - // real handlers: it logs and swallows rejected promises, which made - // native-window failures invisible to the menu host (#329). - if (!registry.get(pluginId)?.commands.has(commandId)) return undefined; - return await registry.runCommandStrict(pluginId, commandId); + const result = await registry.runCommandStrict(pluginId, commandId); + const feedbackMessage = structuredCommandFeedbackMessage(result, tr); + if (feedbackMessage) { + setCommandError({ pluginId, commandId, message: feedbackMessage }); + } + return result; } catch (error) { const failure = { pluginId, commandId, message: describeError(error) }; console.error("[plugins] menu command rejected", failure); @@ -76,7 +128,7 @@ export function usePluginMenus( return undefined; } }, - [registry], + [registry, tr], ); const itemsFor = useMemo(() => {