feat(runtime): résout react/react-dom vers l'instance host pour les layouts plugin

Le runtime SDK expose désormais react/react-dom résolus contre l'instance
hébergée par IdeA plutôt qu'une copie embarquée, pour que les layouts plugin
en JSX/hooks partagent le même arbre React que l'host. hello-plugin migre son
layout d'exemple en .tsx pour illustrer le contrat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 00:39:25 +02:00
parent 7e1bb5fd38
commit 15f930dd3b
11 changed files with 270 additions and 25 deletions

View File

@ -9,6 +9,16 @@
"version": "0.1.0",
"dependencies": {
"@idea/plugin-sdk": "file:../.."
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
}
},
"../..": {
@ -16,12 +26,77 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.5.0"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
}
},
"node_modules/@idea/plugin-sdk": {
"resolved": "../..",
"link": true
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"dev": true,
"license": "MIT"
}
}
}

View File

@ -9,6 +9,15 @@
},
"dependencies": {
"@idea/plugin-sdk": "file:../.."
},
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}

View File

@ -1,17 +0,0 @@
import type { PluginLayoutProps } from "@idea/plugin-sdk";
let hasLoggedFirstLayoutRender = false;
export function HelloWorldLayout(props: PluginLayoutProps): 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";
}

View File

@ -0,0 +1,58 @@
import type { PluginLayoutProps } from "@idea/plugin-sdk";
import type { ReactElement } from "react";
import { useMemo, useState } from "react";
let hasLoggedFirstLayoutRender = false;
export function HelloWorldLayout(props: PluginLayoutProps): ReactElement {
const [localClicks, setLocalClicks] = useState(0);
const hostState = useMemo(() => {
return typeof props.state === "object" && props.state !== null && !Array.isArray(props.state)
? (props.state as Record<string, unknown>)
: {};
}, [props.state]);
const persistedClicks =
typeof hostState.clicks === "number" && Number.isFinite(hostState.clicks)
? hostState.clicks
: 0;
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 (
<section style={{ display: "grid", gap: 12, fontFamily: "system-ui, sans-serif" }}>
<header>
<h2 style={{ margin: 0, fontSize: 16 }}>Hello Plugin</h2>
<p style={{ margin: "4px 0 0", color: "#667085", fontSize: 13 }}>
React layout rendered by IdeA host React.
</p>
</header>
<dl style={{ display: "grid", gap: 4, margin: 0, fontSize: 13 }}>
<div>
<dt style={{ color: "#667085" }}>Project</dt>
<dd style={{ margin: 0 }}>{props.projectId}</dd>
</div>
<div>
<dt style={{ color: "#667085" }}>Layout</dt>
<dd style={{ margin: 0 }}>{props.layoutType}</dd>
</div>
</dl>
<button
type="button"
onClick={() => {
setLocalClicks((current) => current + 1);
props.setState({ ...hostState, clicks: persistedClicks + 1 });
}}
>
Persist click {persistedClicks} / local click {localClicks}
</button>
</section>
);
}

View File

@ -15,7 +15,17 @@ export function activate(ctx: ActivateContext): void {
const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, async () => {
const commandRunCount = await recordCommandRun(ctx);
const feedback = await runHelloCommandTask(ctx);
const opened = await ctx.services?.windows.open({
layoutType: LAYOUT_TYPE,
state: { openedFromCommand: commandRunCount ?? null }
});
ctx.logger.info("command executed", { commandId: COMMAND_ID, commandRunCount, feedback });
if (opened) {
ctx.logger.info("layout window opened", {
label: opened.label,
alreadyOpen: opened.alreadyOpen
});
}
return feedback;
});

View File

@ -14,6 +14,7 @@
}
},
"include": [
"src/**/*.ts"
"src/**/*.ts",
"src/**/*.tsx"
]
}

View File

@ -3,6 +3,7 @@
"compilerOptions": {
"declaration": false,
"declarationMap": false,
"jsx": "react-jsx",
"noEmit": true,
"rootDir": "../..",
"paths": {
@ -13,6 +14,7 @@
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"../../src/**/*.ts"
]
}

65
package-lock.json generated
View File

@ -9,9 +9,74 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.5.0"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
}
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "@idea/plugin-sdk",
"version": "0.1.0",
"description": "Minimal public TypeScript SDK for IdeA plugins.",
"description": "Public TypeScript SDK for IdeA plugins.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@ -29,7 +29,15 @@
"sdk"
],
"license": "MIT",
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.5.0"
}
}

View File

@ -43,6 +43,7 @@ export type {
IdeAPluginModule,
JsonValue,
LayoutRegistry,
OpenPluginWindowOptions,
PluginLogger,
PluginLayoutAvailability,
PluginLayoutComponent,
@ -52,6 +53,7 @@ export type {
PluginLayoutState,
PluginServices,
PluginStorage,
PluginWindow,
ProjectConvention,
ProjectModule,
ProjectStructure,
@ -82,5 +84,6 @@ export type {
WorkspaceTextFile,
WorkspaceWatch,
WorkspaceWatchEvent,
WorkspaceWatchHandler
WorkspaceWatchHandler,
WindowService
} from "./runtime.js";

View File

@ -1,3 +1,5 @@
import type { ComponentType, ReactNode } from "react";
export interface ActivateContext {
pluginId: string;
logger: PluginLogger;
@ -47,7 +49,7 @@ export interface PluginStorage {
export type PluginLayoutState = JsonValue | undefined;
export type PluginLayoutAvailability = "available";
export type PluginLayoutRenderResult = unknown;
export type PluginLayoutRenderResult = ReactNode;
export interface PluginLayoutProps<TState extends PluginLayoutState = PluginLayoutState> {
/** Project currently hosting this layout cell. */
@ -64,9 +66,8 @@ export interface PluginLayoutProps<TState extends PluginLayoutState = PluginLayo
availability: PluginLayoutAvailability;
}
export type PluginLayoutComponent<TState extends PluginLayoutState = PluginLayoutState> = (
props: PluginLayoutProps<TState>,
) => PluginLayoutRenderResult;
export type PluginLayoutComponent<TState extends PluginLayoutState = PluginLayoutState> =
ComponentType<PluginLayoutProps<TState>>;
export interface PluginLayoutDefinition<TState extends PluginLayoutState = PluginLayoutState> {
/** Must match a layout `type` declared in this plugin's manifest. */
@ -87,6 +88,7 @@ export interface PluginServices {
events: EventService;
config: ConfigDocumentService;
terminal: TerminalService;
windows: WindowService;
}
export interface WorkspaceProject {
@ -570,3 +572,32 @@ export interface TerminalService {
/** Kills a PTY by id. */
close(sessionId: string): Promise<void>;
}
export interface OpenPluginWindowOptions {
/** Layout `type` declared by this plugin in `contributes.layouts`. */
layoutType: string;
/** Initial opaque state copied into the detached window surface. */
state?: JsonValue;
}
export interface PluginWindow {
label: string;
url: string;
alreadyOpen: boolean;
providerPluginDisplayName: string;
layoutLabel: string;
surface: {
pluginId: string;
layoutType: string;
state: JsonValue;
};
}
export interface WindowService {
/**
* Opens or focuses a detached IdeA OS window hosting one of this plugin's
* declared layout contributions. The host rejects layout ids absent from this
* plugin's manifest.
*/
open(options: OpenPluginWindowOptions): Promise<PluginWindow>;
}