fix(plugins): isole les menus plugin et durcit hello-plugin SDK contre la rechute #120
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 <noreply@anthropic.com>
This commit is contained in:
@ -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 `<MenuBar>`.
|
||||
- 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`.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
75
frontend/src/features/plugins/usePluginMenus.test.tsx
Normal file
75
frontend/src/features/plugins/usePluginMenus.test.tsx
Normal file
@ -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<string, unknown>): 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
error: unknown,
|
||||
): void {
|
||||
console.warn("[plugins] menu contribution rejected", {
|
||||
scope,
|
||||
...details,
|
||||
reason: describeError(error),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePluginMenus(
|
||||
registry: PluginRuntimeRegistry,
|
||||
whenCtx: WhenContext,
|
||||
@ -36,8 +55,19 @@ export function usePluginMenus(
|
||||
);
|
||||
|
||||
const itemsFor = useMemo(() => {
|
||||
return (targetMenuId: MenuTargetId): MenuBarItem[] =>
|
||||
resolveMenuItems(registry, targetMenuId, whenCtx).map((item) => ({
|
||||
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
|
||||
@ -48,15 +78,52 @@ export function usePluginMenus(
|
||||
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<MenuBarMenu[]>(() => {
|
||||
return resolveTopLevelMenus(registry).map((menu) => ({
|
||||
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 };
|
||||
|
||||
@ -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 <MenuBar menus={this.props.fallbackMenus} />;
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalBackgroundTaskEvent(
|
||||
event: DomainEvent,
|
||||
): event is Extract<DomainEvent, { type: "backgroundTaskChanged" }> {
|
||||
@ -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) ── */}
|
||||
<PluginMenuRenderBoundary
|
||||
resetKey={pluginMenuResetKey}
|
||||
fallbackMenus={nativeMenus}
|
||||
>
|
||||
<MenuBar menus={menus} />
|
||||
</PluginMenuRenderBoundary>
|
||||
|
||||
{/* ── Chrome row: left dock │ main │ right dock (#22). Docks are in-flow
|
||||
resizable columns, not overlays — they sit beside the main surface. */}
|
||||
|
||||
@ -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<string, unknown>).__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 () => {
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user