merge(frontend): fix #336 — fiabilise le dispatch des commandes de menus plugin après hot reload, garde d'enregistrement + feedback i18n (QA verte)

This commit is contained in:
2026-09-12 14:03:17 +02:00
3 changed files with 205 additions and 9 deletions

View File

@ -11,12 +11,16 @@ import {
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import type { import type {
CustomPluginLayoutCell, CustomPluginLayoutCell,
PluginContributionDto,
PluginRuntimeContributionCatalog, PluginRuntimeContributionCatalog,
PluginRuntimePlugin, PluginRuntimePlugin,
} from "@/domain"; } from "@/domain";
import type { WhenContext } from "@/plugins/runtime";
import { MenuBar } from "@/shared";
import { usePluginRuntime } from "./PluginRuntimeProvider"; import { usePluginRuntime } from "./PluginRuntimeProvider";
import { PluginLayoutCellView } from "./PluginLayoutCellView"; import { PluginLayoutCellView } from "./PluginLayoutCellView";
import { PluginRuntimeProvider } from "./PluginRuntimeProvider"; import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
import { usePluginMenus } from "./usePluginMenus";
function dataUrl(source: string): string { function dataUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; 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 (
<>
<span data-testid="menu-runtime-plugin">
{loading ? "loading" : (registry.list()[0]?.displayName ?? "none")}
</span>
<MenuBar menus={menus.topLevelMenus} />
{menus.commandError && <p role="alert">{menus.commandError.message}</p>}
</>
);
}
describe("PluginRuntimeProvider soft refresh (#279)", () => { describe("PluginRuntimeProvider soft refresh (#279)", () => {
it("reloads contributions after install and reload events without remounting its workspace", async () => { it("reloads contributions after install and reload events without remounting its workspace", async () => {
const plugin = new MutableRuntimeCatalogGateway(); const plugin = new MutableRuntimeCatalogGateway();
@ -145,6 +171,86 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
expect(plugin.calls).toBe(4); 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(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
<MenuRuntimeProbe />
</PluginRuntimeProvider>
</DIProvider>,
);
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 () => { it("ignores a custom plugin layout ref when its contribution disappears", async () => {
const plugin = new MutableRuntimeCatalogGateway(); const plugin = new MutableRuntimeCatalogGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();

View File

@ -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 runCommandStrict = vi.fn();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const registry = throwingRegistry({ const registry = throwingRegistry({
runCommandStrict, runCommandStrict,
get: vi.fn(() => ({ commands: { has: () => false } })), get: vi.fn(() => ({ commands: { has: () => false } })),
@ -118,6 +119,43 @@ describe("usePluginMenus", () => {
await act(() => result.current.runCommand("dev.acme.git", "git.missing")); await act(() => result.current.runCommand("dev.acme.git", "git.missing"));
expect(runCommandStrict).not.toHaveBeenCalled(); 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");
}); });
}); });

View File

@ -14,6 +14,7 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import type { MenuTargetId } from "@/domain"; import type { MenuTargetId } from "@/domain";
import { useI18n } from "@/i18n";
import type { MenuBarItem, MenuBarMenu } from "@/shared"; import type { MenuBarItem, MenuBarMenu } from "@/shared";
import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime"; import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime";
import { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus"; import { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
@ -41,6 +42,43 @@ function describeError(e: unknown): string {
return String(e); return String(e);
} }
function objectOrNull(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object"
? value as Record<string, unknown>
: 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( function rejectPluginMenuContribution(
scope: string, scope: string,
details: Record<string, unknown>, details: Record<string, unknown>,
@ -57,18 +95,32 @@ export function usePluginMenus(
registry: PluginRuntimeRegistry, registry: PluginRuntimeRegistry,
whenCtx: WhenContext, whenCtx: WhenContext,
): UsePluginMenusResult { ): UsePluginMenusResult {
const { tr } = useI18n();
const [commandError, setCommandError] = useState<PluginMenuCommandError | null>(null); const [commandError, setCommandError] = useState<PluginMenuCommandError | null>(null);
const clearCommandError = useCallback(() => setCommandError(null), []); const clearCommandError = useCallback(() => setCommandError(null), []);
const runCommand = useCallback( const runCommand = useCallback(
async (pluginId: string, commandId: string): Promise<unknown> => { async (pluginId: string, commandId: string): Promise<unknown> => {
setCommandError(null); 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 { try {
// Preserve the SDK's historical no-op for a declared menu command whose const result = await registry.runCommandStrict(pluginId, commandId);
// handler was not registered, but do not use the lenient dispatcher for const feedbackMessage = structuredCommandFeedbackMessage(result, tr);
// real handlers: it logs and swallows rejected promises, which made if (feedbackMessage) {
// native-window failures invisible to the menu host (#329). setCommandError({ pluginId, commandId, message: feedbackMessage });
if (!registry.get(pluginId)?.commands.has(commandId)) return undefined; }
return await registry.runCommandStrict(pluginId, commandId); return result;
} catch (error) { } catch (error) {
const failure = { pluginId, commandId, message: describeError(error) }; const failure = { pluginId, commandId, message: describeError(error) };
console.error("[plugins] menu command rejected", failure); console.error("[plugins] menu command rejected", failure);
@ -76,7 +128,7 @@ export function usePluginMenus(
return undefined; return undefined;
} }
}, },
[registry], [registry, tr],
); );
const itemsFor = useMemo(() => { const itemsFor = useMemo(() => {