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:
@ -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 (
|
||||
<>
|
||||
<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)", () => {
|
||||
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(
|
||||
<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 () => {
|
||||
const plugin = new MutableRuntimeCatalogGateway();
|
||||
const system = new MockSystemGateway();
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<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(
|
||||
scope: string,
|
||||
details: Record<string, unknown>,
|
||||
@ -57,18 +95,32 @@ export function usePluginMenus(
|
||||
registry: PluginRuntimeRegistry,
|
||||
whenCtx: WhenContext,
|
||||
): UsePluginMenusResult {
|
||||
const { tr } = useI18n();
|
||||
const [commandError, setCommandError] = useState<PluginMenuCommandError | null>(null);
|
||||
const clearCommandError = useCallback(() => setCommandError(null), []);
|
||||
const runCommand = useCallback(
|
||||
async (pluginId: string, commandId: string): Promise<unknown> => {
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user