From 5a30ec8b9c4758602cc96e72ffd69e415422cd85 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 1 Aug 2026 16:23:56 +0200 Subject: [PATCH] fix(plugins): isole les menus plugin et durcit hello-plugin SDK contre la rechute #120 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requalification Architect du 2026-08-01: le crash INTERFACE INTERROMPUE au chargement d'un plugin (menu + item) remonte via ProjectsView -> usePluginMenus -> MenuBar jusqu'à RootErrorBoundary, quel que soit le plugin (ancien ou reconstruit via SDK). - usePluginMenus isole la résolution/conversion des contributions plugin : toute erreur retombe sur [] au lieu de propager. - ProjectsView sépare les menus natifs des menus enrichis par plugin et rend MenuBar derrière une error boundary locale (fallback menus natifs seuls). - hello-plugin (SDK) et les tests d'installation associés durcis en cohérence. Bookkeeping ticket #120 uniquement (issue.md, carnet.md) ; les fichiers counter.json/index.json et le dossier tickets/122/ restent hors commit car une collision de numérotation #122 existe entre cette base et feature/ticket120-hello-plugin-install-path-audit (deux tickets différents revendiquent #122) — à arbitrer avant de committer le bookkeeping global. Co-Authored-By: Claude Sonnet 5 --- .ideai/tickets/120/carnet.md | 111 +++++++++++++++++- .ideai/tickets/120/issue.md | 6 +- .../tests/plugin_install_load.rs | 9 +- .../features/plugins/usePluginMenus.test.tsx | 75 ++++++++++++ .../src/features/plugins/usePluginMenus.ts | 105 ++++++++++++++--- .../src/features/projects/ProjectsView.tsx | 110 +++++++++++++---- frontend/src/plugins/runtime/loader.test.ts | 39 ++++-- frontend/src/plugins/runtime/loader.ts | 16 ++- sdk/IdeaSDK/examples/hello-plugin/README.md | 21 +++- .../examples/hello-plugin/idea-plugin.json | 22 +++- .../examples/hello-plugin/src/index.ts | 77 ++++++++++-- 11 files changed, 516 insertions(+), 75 deletions(-) create mode 100644 frontend/src/features/plugins/usePluginMenus.test.tsx diff --git a/.ideai/tickets/120/carnet.md b/.ideai/tickets/120/carnet.md index 010f5f6..97d0a74 100644 --- a/.ideai/tickets/120/carnet.md +++ b/.ideai/tickets/120/carnet.md @@ -1,6 +1,113 @@ --- issueRef: "#120" -version: 1 +version: 5 updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1785534496259 +updatedAt: 1785592705452 --- +# Carnet de suivi — hello-plugin + +## Etat courant + +- Ticket canonique: `#120` +- Date de rechute confirmee: 2026-08-01 +- Statut: investigation relancee sur rechute reelle post-rebuild SDK +- Severite: critique (l'UI d'IdeA tombe sur une erreur d'affichage lors de l'installation/chargement de `hello-plugin`) + +## Constat central au 2026-08-01 + +Le probleme persiste meme apres reconstruction de `hello-plugin` comme plugin d'exemple 100% SDK. + +Implication forte: +- la cause racine est probablement dans le systeme plugin / runtime frontend / chargement UI / integration layout-menu, et non dans l'ancien exemple `hello-plugin` uniquement. + +## Trace utilisateur fournie le 2026-08-01 + +Message visible: +- `IdeA a rencontre une erreur d'affichage.` +- `L'application reste ouverte. Rechargez la fenetre apres avoir copie le diagnostic si le probleme doit etre investigue.` + +Stack affichee: + +```text +kT@tauri://localhost/assets/index-CXNCu-GC.js:88:32937 +ST@tauri://localhost/assets/index-CXNCu-GC.js:88:31877 +om@tauri://localhost/assets/index-CXNCu-GC.js:38:17019 +oh@tauri://localhost/assets/index-CXNCu-GC.js:40:3141 +$b@tauri://localhost/assets/index-CXNCu-GC.js:40:39779 +SC@tauri://localhost/assets/index-CXNCu-GC.js:40:39707 +sc@tauri://localhost/assets/index-CXNCu-GC.js:40:39559 +yh@tauri://localhost/assets/index-CXNCu-GC.js:40:35923 +Ob@tauri://localhost/assets/index-CXNCu-GC.js:40:34872 +E@tauri://localhost/assets/index-CXNCu-GC.js:25:1541 +L@tauri://localhost/assets/index-CXNCu-GC.js:25:1903 + +ST@tauri://localhost/assets/index-CXNCu-GC.js:88:28096 +div +div +div +ID@tauri://localhost/assets/index-CXNCu-GC.js:94:63541 +main +div +div +G4@tauri://localhost/assets/index-CXNCu-GC.js:129:18236 +div +div +_T@tauri://localhost/assets/index-CXNCu-GC.js:88:24675 +BP@tauri://localhost/assets/index-CXNCu-GC.js:88:1862 +ez@tauri://localhost/assets/index-CXNCu-GC.js:129:31272 +mP@tauri://localhost/assets/index-CXNCu-GC.js:82:27658 +Iz@tauri://localhost/assets/index-CXNCu-GC.js:129:83025 +``` + +## Faits etablis avant cette rechute + +- Un rebuild SDK de `hello-plugin` a ete realise pour produire un plugin d'exemple minimal mais fonctionnel. +- Des verifications de build, packaging et tests locaux ont ete annoncees vertes par DevFrontend. +- QA avait pu valider partiellement: + - l'artefact SDK se reconstruit + - le plugin s'installe cote runtime/backend + - IdeA charge reellement le bundle plugin via `idea-plugin://.../dist/index.js` +- QA n'avait pas pu valider de bout en bout la surface UI visible (menu `Hello Plugin`, entree `hello-plugin`, rendu `hello-world`) faute d'une session UI observable sans ambiguite. + +## Reinterpretation apres rechute utilisateur + +- L'absence de validation UI de bout en bout n'etait pas un detail: la rechute utilisateur montre que le crash survient bien dans le flux reel d'affichage, malgre un plugin reconstruit proprement. +- Le signal pointe desormais plus fortement vers un probleme dans la consommation frontend des contributions plugin que vers le contenu fonctionnel du plugin lui-meme. + +## Requalification Architect du 2026-08-01 + +- Fait cle: le meme hash de crash apparait avec deux plugins differents: + - ancien hello-plugin minimal (menu + item, sans layout utile) + - nouveau hello-plugin reconstruit via SDK (menu + item + layout) +- Denominateur commun probable: la contribution de menu, pas le layout. +- Zone la plus suspecte identifiee: `ProjectsView.tsx` sur l'injection de `pluginMenus` dans ``. +- Point precis: le rendu des menus plugin est consomme dans l'app-shell sans isolation locale equivalente a celle deja ajoutee pour `PluginLayoutCellView`. +- Hypothese prioritaire: une entree de menu plugin ou son rendu dans `MenuBar` leve une erreur qui remonte jusqu'a `RootErrorBoundary`, produisant `INTERFACE INTERROMPUE`. + +## Correctif systeme plugin/UI par DevFrontend + +### Cause probable retenue +Deux plugins differents declenchent le meme crash minifie apres installation. Le denominateur commun le plus probable est le chemin `ProjectsView -> usePluginMenus -> MenuBar`. + +### Correctif applique +- `usePluginMenus` isole defensivement la resolution/conversion des contributions plugin. +- Si la resolution de menus ou d'items plugin throw, le hook renvoie `[]` pour la surface plugin concernee au lieu de propager l'erreur. +- `ProjectsView` separe les menus natifs des menus enrichis par plugin. +- `ProjectsView` rend `MenuBar` derriere une error boundary locale: si le rendu enrichi plante, fallback immediat sur les menus natifs seuls. +- Logs ajoutes: + - `[plugins] menu contribution rejected` avec le contexte utile quand disponible + - `[plugins] menu render failed; using native menus only` + +### Fichiers touches +- `frontend/src/features/plugins/usePluginMenus.ts` +- `frontend/src/features/plugins/usePluginMenus.test.tsx` +- `frontend/src/features/projects/ProjectsView.tsx` + +### Verifications annoncees par DevFrontend +- `cd frontend && npm run typecheck` : OK +- `cd frontend && npx vitest run src/features/plugins/usePluginMenus.test.tsx src/features/plugins/menus.test.ts src/plugins/runtime/loader.test.ts src/features/projects/projects.test.tsx src/features/projects/pluginGitRepository.test.tsx src/shared/ui/FloatingWindow.test.tsx` : OK, 48 tests +- `cd frontend && npm test` : OK, 114 fichiers, 1046 tests + +## Prochaine etape + +- QA doit valider sur le flux reel d'installation du plugin si l'UI reste vivante et si les menus natifs sont conserves en fallback au lieu de tomber sur `INTERFACE INTERROMPUE`. diff --git a/.ideai/tickets/120/issue.md b/.ideai/tickets/120/issue.md index 8efa155..da7d496 100644 --- a/.ideai/tickets/120/issue.md +++ b/.ideai/tickets/120/issue.md @@ -2,7 +2,7 @@ id: "f4218553-680a-4fbb-9514-a96eab79b8d8" number: 120 title: "Réinvestiguer l’installation de hello-plugin: écran noir / perte d’affichage IdeA" -status: "open" +status: "inProgress" priority: "critical" sprint: null links: [{"target":"#116","kind":"relatesTo"}] @@ -11,8 +11,8 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1785534496259 -updatedAt: 1785534496259 -version: 1 +updatedAt: 1785592705452 +version: 5 --- ## Constat utilisateur diff --git a/crates/infrastructure/tests/plugin_install_load.rs b/crates/infrastructure/tests/plugin_install_load.rs index 1b1b471..a3b3782 100644 --- a/crates/infrastructure/tests/plugin_install_load.rs +++ b/crates/infrastructure/tests/plugin_install_load.rs @@ -123,6 +123,7 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { assert_eq!(result.plugin.display_name, "Hello Plugin"); assert_eq!(result.review.contribution_summary.top_level_menus, 1); assert_eq!(result.review.contribution_summary.menu_items, 1); + assert_eq!(result.review.contribution_summary.layouts, 1); assert!(app_data .join("plugins/installed/com.example.hello-plugin/dist/index.js") .is_file()); @@ -139,10 +140,16 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() { .starts_with("idea-plugin://com.example.hello-plugin/0.1.0/")); assert!(plugin.bundle_url.ends_with("/dist/index.js")); assert_eq!(plugin.contributes.menus[0].id, "hello-plugin.menu"); + assert_eq!(plugin.contributes.menus[0].label.as_str(), "Hello Plugin"); assert_eq!( plugin.contributes.menu_items[0].command.as_str(), - "hello-plugin.sayHello" + "hello-plugin" ); + assert_eq!( + plugin.contributes.layouts[0].layout_type.as_str(), + "hello-plugin.hello-world" + ); + assert_eq!(plugin.contributes.layouts[0].label.as_str(), "hello-world"); let _ = fs::remove_dir_all(app_data); } diff --git a/frontend/src/features/plugins/usePluginMenus.test.tsx b/frontend/src/features/plugins/usePluginMenus.test.tsx new file mode 100644 index 0000000..d2016ae --- /dev/null +++ b/frontend/src/features/plugins/usePluginMenus.test.tsx @@ -0,0 +1,75 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { MenuTargetId } from "@/domain"; +import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime"; +import { usePluginMenus } from "./usePluginMenus"; + +const NO_CONTEXT: WhenContext = { + projectOpen: false, + gitRepository: false, + agentSelected: false, + terminalFocused: false, + layoutCellFocused: false, +}; + +function throwingRegistry(overrides: Record): PluginRuntimeRegistry { + return { + runCommand: vi.fn(async () => {}), + topLevelMenus: vi.fn(() => []), + menuItems: vi.fn(() => []), + ...overrides, + } as unknown as PluginRuntimeRegistry; +} + +describe("usePluginMenus", () => { + it("falls back to no plugin top-level menus when plugin menu resolution throws", () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const registry = throwingRegistry({ + topLevelMenus: vi.fn(() => { + throw new Error("broken top-level contribution"); + }), + }); + + const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT)); + + expect(result.current.topLevelMenus).toEqual([]); + expect(consoleWarn).toHaveBeenCalledWith( + "[plugins] menu contribution rejected", + expect.objectContaining({ + scope: "top-level-menus", + reason: "broken top-level contribution", + }), + ); + } finally { + consoleWarn.mockRestore(); + } + }); + + it("falls back to no plugin items for a native menu when item resolution throws", () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const registry = throwingRegistry({ + menuItems: vi.fn(() => { + throw new Error("broken item contribution"); + }), + }); + + const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT)); + const items = result.current.itemsFor("panels" as MenuTargetId); + + expect(items).toEqual([]); + expect(consoleWarn).toHaveBeenCalledWith( + "[plugins] menu contribution rejected", + expect.objectContaining({ + scope: "items", + targetMenuId: "panels", + reason: "broken item contribution", + }), + ); + } finally { + consoleWarn.mockRestore(); + } + }); +}); diff --git a/frontend/src/features/plugins/usePluginMenus.ts b/frontend/src/features/plugins/usePluginMenus.ts index 583ec3e..86b3440 100644 --- a/frontend/src/features/plugins/usePluginMenus.ts +++ b/frontend/src/features/plugins/usePluginMenus.ts @@ -16,7 +16,7 @@ import { useMemo } from "react"; import type { MenuTargetId } from "@/domain"; import type { MenuBarItem, MenuBarMenu } from "@/shared"; import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime"; -import { resolveMenuItems, resolveTopLevelMenus } from "./menus"; +import { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus"; export interface UsePluginMenusResult { /** Top-level plugin menus, in order, ready to splice between Panneaux/Paramètres. */ @@ -26,6 +26,25 @@ export interface UsePluginMenusResult { runCommand: (pluginId: string, commandId: string) => Promise; } +function describeError(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as { message: unknown }).message); + } + return String(e); +} + +function rejectPluginMenuContribution( + scope: string, + details: Record, + error: unknown, +): void { + console.warn("[plugins] menu contribution rejected", { + scope, + ...details, + reason: describeError(error), + }); +} + export function usePluginMenus( registry: PluginRuntimeRegistry, whenCtx: WhenContext, @@ -36,27 +55,75 @@ export function usePluginMenus( ); const itemsFor = useMemo(() => { - return (targetMenuId: MenuTargetId): MenuBarItem[] => - resolveMenuItems(registry, targetMenuId, whenCtx).map((item) => ({ - id: item.id, - // Provenance is shown discreetly (carnet §0 UX decision) rather than - // with a visual section divider, which the shared `MenuBar` primitive - // doesn't support — a trailing "· Plugin Name" suffix is the least - // intrusive option that still surfaces where the item came from. - label: `${item.label} · ${item.pluginDisplayName}`, - disabled: !item.enabled, - onSelect: () => { - void runCommand(item.pluginId, item.command); - }, - })); + return (targetMenuId: MenuTargetId): MenuBarItem[] => { + let resolved; + try { + resolved = resolveMenuItems(registry, targetMenuId, whenCtx); + } catch (e) { + rejectPluginMenuContribution("items", { targetMenuId }, e); + return []; + } + + return resolved.flatMap((item) => { + try { + return [ + { + id: item.id, + // Provenance is shown discreetly (carnet §0 UX decision) rather than + // with a visual section divider, which the shared `MenuBar` primitive + // doesn't support — a trailing "· Plugin Name" suffix is the least + // intrusive option that still surfaces where the item came from. + label: `${item.label} · ${item.pluginDisplayName}`, + disabled: !item.enabled, + onSelect: () => { + void runCommand(item.pluginId, item.command); + }, + }, + ]; + } catch (e) { + rejectPluginMenuContribution( + "item", + { + pluginId: item.pluginId, + itemId: item.id, + targetMenuId, + commandId: item.command, + }, + e, + ); + return []; + } + }); + }; }, [registry, whenCtx, runCommand]); const topLevelMenus = useMemo(() => { - return resolveTopLevelMenus(registry).map((menu) => ({ - id: menu.id, - label: menu.label, - items: itemsFor(menu.id), - })); + let resolved: ResolvedTopLevelMenu[]; + try { + resolved = resolveTopLevelMenus(registry); + } catch (e) { + rejectPluginMenuContribution("top-level-menus", {}, e); + return []; + } + + return resolved.flatMap((menu) => { + try { + return [ + { + id: menu.id, + label: menu.label, + items: itemsFor(menu.id), + }, + ]; + } catch (e) { + rejectPluginMenuContribution( + "top-level-menu", + { pluginId: menu.pluginId, menuId: menu.id }, + e, + ); + return []; + } + }); }, [registry, itemsFor]); return { topLevelMenus, itemsFor, runCommand }; diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 6fc803a..b89ccf8 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -33,7 +33,14 @@ * open; the project tab bar (`role="tablist"`) is always present. */ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Component, + useEffect, + useMemo, + useState, + type ErrorInfo, + type ReactNode, +} from "react"; import type { Agent, DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; @@ -109,6 +116,41 @@ interface PendingConversationOpen { conversationId: string; } +interface PluginMenuRenderBoundaryProps { + resetKey: string; + fallbackMenus: MenuBarMenu[]; + children: ReactNode; +} + +class PluginMenuRenderBoundary extends Component< + PluginMenuRenderBoundaryProps, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("[plugins] menu render failed; using native menus only", { + error, + componentStack: errorInfo.componentStack, + }); + } + + componentDidUpdate(prevProps: PluginMenuRenderBoundaryProps): void { + if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + render(): ReactNode { + if (this.state.hasError) return ; + return this.props.children; + } +} + function isTerminalBackgroundTaskEvent( event: DomainEvent, ): event is Extract { @@ -546,22 +588,43 @@ export function ProjectsView() { return items; } + const nativePanelItems: MenuBarItem[] = panelOrder.map((panel) => ({ + id: panel, + label: PANEL_TITLE[panel], + active: placementOf(placements, panel) !== "closed", + onSelect: () => {}, + submenu: placementSubmenu(panel), + })); + const nativeSettingsItems: MenuBarItem[] = SETTINGS_SECTIONS.map((section) => ({ + id: section, + label: SETTINGS_SECTION_LABEL[section], + active: settingsSection === section, + onSelect: () => { + setSettingsSection(section); + dismissFloating(); + }, + })); + const pluginPanelItems = pluginMenus.itemsFor("panels"); + const pluginSettingsItems = pluginMenus.itemsFor("settings"); + const nativeMenus: MenuBarMenu[] = [ + { + id: "panels", + label: "Panneaux", + items: nativePanelItems, + }, + { + id: "settings", + label: "Paramètres", + items: nativeSettingsItems, + }, + ]; const menus: MenuBarMenu[] = [ { id: "panels", label: "Panneaux", // Native items first, plugin-contributed items after in their own // (discreetly-labelled) group (#43, F3, carnet §7.2). - items: [ - ...panelOrder.map((panel) => ({ - id: panel, - label: PANEL_TITLE[panel], - active: placementOf(placements, panel) !== "closed", - onSelect: () => {}, - submenu: placementSubmenu(panel), - })), - ...pluginMenus.itemsFor("panels"), - ], + items: [...nativePanelItems, ...pluginPanelItems], }, // Top-level plugin menus render between Panneaux and Paramètres (#43, // carnet §0 UX decision + §7.1). @@ -572,20 +635,14 @@ export function ProjectsView() { // One entry per section (#68). The entries name sections and mark the open // one; closing lives in the view ("Fermer les paramètres"), so no label // alternates. Plugin-contributed items are appended after (#43, F3). - items: [ - ...SETTINGS_SECTIONS.map((section) => ({ - id: section, - label: SETTINGS_SECTION_LABEL[section], - active: settingsSection === section, - onSelect: () => { - setSettingsSection(section); - dismissFloating(); - }, - })), - ...pluginMenus.itemsFor("settings"), - ], + items: [...nativeSettingsItems, ...pluginSettingsItems], }, ]; + const pluginMenuResetKey = [ + pluginMenus.topLevelMenus.map((menu) => menu.id).join(","), + pluginPanelItems.map((item) => item.id).join(","), + pluginSettingsItems.map((item) => item.id).join(","), + ].join("|"); // The create-project form + known-projects list. Rendered inline in the // welcome area (no active project) or inside the Projects floating window. @@ -810,7 +867,12 @@ export function ProjectsView() { /> {/* ── Menu bar (replaces the former left sidebar) ── */} - + + + {/* ── Chrome row: left dock │ main │ right dock (#22). Docks are in-flow resizable columns, not overlays — they sit beside the main surface. */} diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 5485782..dc5a3f9 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -178,12 +178,16 @@ describe("loadPlugins", () => { expect(registry.get("com.example.hello-plugin")).toBeUndefined(); }); - it("loads the hello-plugin contribution shape with omitted optional arrays", async () => { + it("loads the hello-plugin command and layout contribution shape", async () => { const bundle = dataUrl(` export function activate(ctx) { - ctx.commands.registerCommand("hello-plugin.sayHello", () => { + ctx.commands.registerCommand("hello-plugin", () => { globalThis.__helloArchiveCommandRan = true; }); + ctx.layouts.register({ + type: "hello-plugin.hello-world", + component: () => "hello-world", + }); } `); const { registry, failures } = await loadPlugins( @@ -193,13 +197,20 @@ describe("loadPlugins", () => { displayName: "Hello Plugin", bundleUrl: bundle, contributes: { - menus: [{ id: "hello-plugin.menu", label: "Hello", topLevel: true }], + menus: [{ id: "hello-plugin.menu", label: "Hello Plugin", topLevel: true }], menuItems: [ { - id: "hello-plugin.sayHello.item", + id: "hello-plugin.command.item", targetMenuId: "hello-plugin.menu", - label: "Say Hello", - command: "hello-plugin.sayHello", + label: "hello-plugin", + command: "hello-plugin", + }, + ], + layouts: [ + { + type: "hello-plugin.hello-world", + label: "hello-world", + component: "hello-world", }, ], } as unknown as PluginContributionDto, @@ -209,10 +220,22 @@ describe("loadPlugins", () => { ); expect(failures).toEqual([]); - expect(registry.get("com.example.hello-plugin")?.contributes.layouts).toEqual([]); + expect(registry.get("com.example.hello-plugin")?.contributes.layouts).toEqual([ + { + type: "hello-plugin.hello-world", + label: "hello-world", + component: "hello-world", + }, + ]); expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]); - await registry.runCommand("com.example.hello-plugin", "hello-plugin.sayHello"); + await registry.runCommand("com.example.hello-plugin", "hello-plugin"); expect((globalThis as Record).__helloArchiveCommandRan).toBe(true); + const Layout = registry.layoutComponent( + "com.example.hello-plugin", + "hello-plugin.hello-world", + ); + expect(Layout).toBeDefined(); + expect((Layout as unknown as () => string)()).toBe("hello-world"); }); it("confines a malformed runtime catalog entry and still loads healthy plugins", async () => { diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 788aa36..2f7c6ef 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -301,8 +301,20 @@ export async function loadPlugins( entries.map((entry) => loadOne(entry, gateways, resolvedOptions)), ); for (const result of results) { - if ("failure" in result) failures.push(result.failure); - else registry.add(result.plugin); + if ("failure" in result) { + failures.push(result.failure); + console.warn( + `[plugins] load failed plugin=${result.failure.pluginId}: ${result.failure.reason}`, + ); + } else { + registry.add(result.plugin); + } + } + + if (entries.length > 0) { + console.info( + `[plugins] load complete loaded=${registry.list().length} failed=${failures.length}`, + ); } return { registry, failures }; diff --git a/sdk/IdeaSDK/examples/hello-plugin/README.md b/sdk/IdeaSDK/examples/hello-plugin/README.md index 94c4bc5..7119a1e 100644 --- a/sdk/IdeaSDK/examples/hello-plugin/README.md +++ b/sdk/IdeaSDK/examples/hello-plugin/README.md @@ -1,6 +1,13 @@ # Hello Plugin -Minimal IdeA plugin example using the public SDK types. +Installable IdeA plugin example rebuilt from the public SDK types. + +It exercises the current plugin primitives end to end: + +- top-level menu: `Hello Plugin`; +- menu entry: `hello-plugin`; +- command: `hello-plugin`, returning `hello-world`; +- layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`. ```sh npm run typecheck:examples @@ -10,3 +17,15 @@ npm run package:hello-plugin The installable archive is emitted at `examples/hello-plugin/build/hello-plugin-0.1.0.zip`. It contains `idea-plugin.json` at the ZIP root and the compiled ESM entrypoint at `dist/index.js`, matching the manifest `main` field. + +## Diagnostics + +During activation the plugin logs: + +- whether the command and layout runtime registries are available; +- successful registration of the `hello-plugin` command; +- successful registration of the `hello-plugin.hello-world` layout; +- the first layout render, including project/node identifiers. + +These messages are intentionally small and stable so installation, bundle import, activation and +layout rendering failures can be separated quickly in IdeA logs/devtools. diff --git a/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json b/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json index 07aa8dd..28f7962 100644 --- a/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json +++ b/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json @@ -4,7 +4,7 @@ "displayName": "Hello Plugin", "publisher": "IdeA Examples", "version": "0.1.0", - "description": "Minimal IdeA plugin example.", + "description": "SDK example plugin for validating command, menu and layout loading.", "main": "dist/index.js", "engines": { "idea": ">=0.1.0" @@ -17,16 +17,26 @@ "menus": [ { "id": "hello-plugin.menu", - "label": "Hello", - "topLevel": true + "label": "Hello Plugin", + "topLevel": true, + "order": 100 } ], "menuItems": [ { - "id": "hello-plugin.sayHello.item", + "id": "hello-plugin.command.item", "targetMenuId": "hello-plugin.menu", - "label": "Say Hello", - "command": "hello-plugin.sayHello" + "label": "hello-plugin", + "command": "hello-plugin", + "order": 10 + } + ], + "layouts": [ + { + "type": "hello-plugin.hello-world", + "label": "hello-world", + "component": "hello-world", + "order": 10 } ] } diff --git a/sdk/IdeaSDK/examples/hello-plugin/src/index.ts b/sdk/IdeaSDK/examples/hello-plugin/src/index.ts index 26a9a07..e7b658e 100644 --- a/sdk/IdeaSDK/examples/hello-plugin/src/index.ts +++ b/sdk/IdeaSDK/examples/hello-plugin/src/index.ts @@ -1,15 +1,75 @@ -import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk"; +import type { ActivateContext, CommandDisposable, IdeAPluginModule } from "@idea/plugin-sdk"; + +const COMMAND_ID = "hello-plugin"; +const LAYOUT_TYPE = "hello-plugin.hello-world"; + +type HelloPluginLayoutProps = { + projectId?: string; + nodeId?: string; + layoutType?: string; + state?: unknown; +}; + +type LayoutRegistry = { + register(definition: { + type: string; + component: (props: HelloPluginLayoutProps) => string; + }): CommandDisposable; +}; + +type HelloPluginContext = ActivateContext & { + layouts?: LayoutRegistry; +}; + +let hasLoggedFirstLayoutRender = false; + +function HelloWorldLayout(props: HelloPluginLayoutProps): string { + if (!hasLoggedFirstLayoutRender) { + hasLoggedFirstLayoutRender = true; + console.info("[hello-plugin] layout first render", { + projectId: props.projectId, + nodeId: props.nodeId, + layoutType: props.layoutType, + hasState: props.state !== undefined + }); + } + + return "hello-world"; +} export function activate(ctx: ActivateContext): void { - ctx.logger.info("Hello from the IdeA hello plugin."); - - const disposable = ctx.commands?.registerCommand("hello-plugin.sayHello", () => { - ctx.logger.info("Hello command executed."); - return "Hello from IdeA"; + const pluginContext = ctx as HelloPluginContext; + ctx.logger.info("activating hello-plugin", { + pluginId: ctx.pluginId, + hasCommands: Boolean(pluginContext.commands), + hasLayouts: Boolean(pluginContext.layouts) }); - if (disposable) { - ctx.subscriptions.push(disposable); + const commandDisposable = pluginContext.commands?.registerCommand(COMMAND_ID, () => { + ctx.logger.info("command executed", { commandId: COMMAND_ID }); + return "hello-world"; + }); + + if (commandDisposable) { + ctx.subscriptions.push(commandDisposable); + ctx.logger.info("command registered", { commandId: COMMAND_ID }); + } else { + ctx.logger.warn("command registry unavailable", { commandId: COMMAND_ID }); + } + + const layoutDisposable = pluginContext.layouts?.register({ + type: LAYOUT_TYPE, + component: HelloWorldLayout + }); + + if (layoutDisposable) { + ctx.subscriptions.push(layoutDisposable); + ctx.logger.info("layout registered", { + layoutType: LAYOUT_TYPE, + component: "hello-world" + }); + } else { + ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE }); } } @@ -18,4 +78,3 @@ const plugin: IdeAPluginModule = { }; export default plugin; -