feat(frontend): système de plugins — runtime, menus, layouts custom (#43)
Lots F1-F4 : runtime de chargement/registre plugin, extension des menus existants, panneau de gestion des plugins, types de layout custom (sélecteur, fallback, cellule dédiée) branchés sur le port plugin. Suite npm typecheck/test verte (947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
WebDesktopServerGateway,
|
||||
WebFocusedProjectGateway,
|
||||
WebPluginGateway,
|
||||
WebRemoteGateway,
|
||||
WebWindowGateway,
|
||||
} from "./unsupported";
|
||||
@ -139,6 +140,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
focusedProject: new WebFocusedProjectGateway(),
|
||||
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
plugin: new WebPluginGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -116,6 +116,11 @@ export class HttpSystemGateway implements SystemGateway {
|
||||
return unsupportedOnWeb("Native folder picker");
|
||||
}
|
||||
|
||||
pickArchiveFile(): Promise<string | null> {
|
||||
// Desktop-only, same rationale as `pickFolder`.
|
||||
return unsupportedOnWeb("Native file picker");
|
||||
}
|
||||
|
||||
onAppExitWorkGuard(
|
||||
_handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
|
||||
@ -12,6 +12,11 @@
|
||||
import type {
|
||||
EmbeddedServerStatus,
|
||||
GatewayError,
|
||||
PluginAdmin,
|
||||
PluginInstallResult,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginUninstallResult,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Unsubscribe,
|
||||
@ -20,7 +25,9 @@ import type {
|
||||
DesktopServerGateway,
|
||||
FocusedProject,
|
||||
FocusedProjectGateway,
|
||||
PluginGateway,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
ViewWindowClosed,
|
||||
ViewWindowSnapshot,
|
||||
WindowGateway,
|
||||
@ -119,3 +126,37 @@ export class WebDesktopServerGateway implements DesktopServerGateway {
|
||||
return Promise.resolve(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web stub: the plugin system (#43) installs/loads full-trust local ESM
|
||||
* bundles from the desktop filesystem — no server-side equivalent in V1. The
|
||||
* web client must not manage or load plugins on behalf of the desktop app.
|
||||
*/
|
||||
export class WebPluginGateway implements PluginGateway {
|
||||
async listPlugins(): Promise<PluginAdmin[]> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async reviewPackage(_input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async installFromArchive(_path: string): Promise<PluginInstallResult> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async installFromDirectory(_path: string): Promise<PluginInstallResult> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async setEnabled(_pluginId: string, _enabled: boolean): Promise<PluginAdmin> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async uninstall(_pluginId: string): Promise<PluginUninstallResult> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
// No plugin bundles are ever loaded on the web client — an empty catalog
|
||||
// lets the bootstrap loader run unconditionally without a transport check.
|
||||
return { plugins: [] };
|
||||
}
|
||||
async openPluginsFolder(_pluginId?: string): Promise<void> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ import { TauriTicketGateway } from "./ticket";
|
||||
import { TauriWindowGateway } from "./window";
|
||||
import { TauriFocusedProjectGateway } from "./focusedProject";
|
||||
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
||||
import { TauriPluginGateway } from "./plugin";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
const err: GatewayError = {
|
||||
@ -75,6 +76,7 @@ export function createTauriGateways(): Gateways {
|
||||
window: new TauriWindowGateway(),
|
||||
focusedProject: new TauriFocusedProjectGateway(),
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
plugin: new TauriPluginGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -101,4 +103,5 @@ export {
|
||||
TauriWindowGateway,
|
||||
TauriFocusedProjectGateway,
|
||||
LocalStorageUiPreferencesGateway,
|
||||
TauriPluginGateway,
|
||||
};
|
||||
|
||||
@ -38,6 +38,13 @@ import type {
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
PermissionSet,
|
||||
PluginAdmin,
|
||||
PluginContributionSummary,
|
||||
PluginInstallResult,
|
||||
PluginLifecycleState,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginUninstallResult,
|
||||
Project,
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
@ -88,8 +95,10 @@ import type {
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
PermissionGateway,
|
||||
PluginGateway,
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
SkillGateway,
|
||||
StoppedLiveAgent,
|
||||
SystemGateway,
|
||||
@ -184,6 +193,11 @@ export class MockSystemGateway implements SystemGateway {
|
||||
return "/home/user/mock-project";
|
||||
}
|
||||
|
||||
/** Returns a deterministic fake path — never opens a native dialog. */
|
||||
async pickArchiveFile(): Promise<string | null> {
|
||||
return "/home/user/mock-plugin.ideaplug";
|
||||
}
|
||||
|
||||
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
|
||||
/** Count of `confirmAppExit()` calls, for test assertions. */
|
||||
confirmAppExitCallCount = 0;
|
||||
@ -2923,6 +2937,114 @@ function mostRestrictive(
|
||||
return rank[agent] >= rank[fallback] ? agent : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
|
||||
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
|
||||
* parallel): review before commit, install/enable/disable/uninstall, and a
|
||||
* runtime catalog that only ever lists `enabled && !pendingUninstall` plugins.
|
||||
*/
|
||||
export class MockPluginGateway implements PluginGateway {
|
||||
private plugins: PluginAdmin[] = [];
|
||||
private seq = 0;
|
||||
|
||||
private summaryFor(_pluginId: string): PluginContributionSummary {
|
||||
return { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 };
|
||||
}
|
||||
|
||||
/** Test/dev helper: seed a plugin directly, bypassing install. */
|
||||
_seedPlugin(plugin: PluginAdmin): void {
|
||||
this.plugins.push(plugin);
|
||||
}
|
||||
|
||||
async listPlugins(): Promise<PluginAdmin[]> {
|
||||
return structuredClone(this.plugins);
|
||||
}
|
||||
|
||||
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||
this.seq += 1;
|
||||
const label = input.path.split("/").pop() ?? input.path;
|
||||
return {
|
||||
id: `mock.plugin.${this.seq}`,
|
||||
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
|
||||
publisher: "Mock Publisher",
|
||||
version: "0.1.0",
|
||||
description: `Reviewed from ${input.sourceKind}: ${input.path}`,
|
||||
trustLevel: "full",
|
||||
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
||||
issues: [],
|
||||
installable: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async install(
|
||||
sourceKind: "archive" | "directory",
|
||||
path: string,
|
||||
): Promise<PluginInstallResult> {
|
||||
this.seq += 1;
|
||||
const label = path.split("/").pop() ?? path;
|
||||
const plugin: PluginAdmin = {
|
||||
id: `mock.plugin.${this.seq}`,
|
||||
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
|
||||
publisher: "Mock Publisher",
|
||||
version: "0.1.0",
|
||||
sourceKind,
|
||||
sourceLabel: path,
|
||||
lifecycleState: "enabled",
|
||||
enabled: true,
|
||||
pendingUninstall: false,
|
||||
restartRequired: true,
|
||||
trustLevel: "full",
|
||||
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
||||
};
|
||||
this.plugins.push(plugin);
|
||||
return { plugin: structuredClone(plugin), restartRequired: true };
|
||||
}
|
||||
|
||||
installFromArchive(path: string): Promise<PluginInstallResult> {
|
||||
return this.install("archive", path);
|
||||
}
|
||||
|
||||
installFromDirectory(path: string): Promise<PluginInstallResult> {
|
||||
return this.install("directory", path);
|
||||
}
|
||||
|
||||
async setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
|
||||
const plugin = this.plugins.find((p) => p.id === pluginId);
|
||||
if (!plugin) {
|
||||
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
|
||||
throw err;
|
||||
}
|
||||
const state: PluginLifecycleState = enabled ? "enabled" : "disabled";
|
||||
plugin.enabled = enabled;
|
||||
plugin.lifecycleState = state;
|
||||
plugin.restartRequired = true;
|
||||
return structuredClone(plugin);
|
||||
}
|
||||
|
||||
async uninstall(pluginId: string): Promise<PluginUninstallResult> {
|
||||
const idx = this.plugins.findIndex((p) => p.id === pluginId);
|
||||
if (idx === -1) {
|
||||
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
|
||||
throw err;
|
||||
}
|
||||
this.plugins.splice(idx, 1);
|
||||
return { pluginId, restartRequired: true };
|
||||
}
|
||||
|
||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
// Only enabled, non-pending-uninstall plugins are loadable at bootstrap
|
||||
// (carnet §1.3) — the mock has no bundle to actually import, so this
|
||||
// starts empty; tests seed `plugins` on `PluginRuntimeContributionCatalog`
|
||||
// directly via a `MockPluginGateway` subclass/test double when a loader
|
||||
// round-trip is needed.
|
||||
return { plugins: [] };
|
||||
}
|
||||
|
||||
async openPluginsFolder(_pluginId?: string): Promise<void> {
|
||||
// No filesystem in the mock — no-op.
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
export function createMockGateways(): Gateways {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
@ -2951,6 +3073,7 @@ export function createMockGateways(): Gateways {
|
||||
window: new MockWindowGateway(),
|
||||
focusedProject: new MockFocusedProjectGateway(),
|
||||
uiPreferences: new MockUiPreferencesGateway(),
|
||||
plugin: new MockPluginGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ describe("createMockGateways", () => {
|
||||
"memory",
|
||||
"modelServer",
|
||||
"permission",
|
||||
"plugin",
|
||||
"profile",
|
||||
"project",
|
||||
"remote",
|
||||
|
||||
58
frontend/src/adapters/plugin.ts
Normal file
58
frontend/src/adapters/plugin.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tauri adapter for {@link PluginGateway} (ticket #43, F1).
|
||||
*
|
||||
* Commands use snake_case (Tauri convention); payload keys are camelCase,
|
||||
* consistent with the other adapters in this directory. Command names and
|
||||
* envelope match the carnet §5 exactly — no contract improvised here.
|
||||
*
|
||||
* NOTE: The Tauri commands wired here are defined in the backend `app-tauri`
|
||||
* crate (lots B1-B4, in progress in parallel on this branch). The mock gateway
|
||||
* covers tests and offline dev today.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
PluginAdmin,
|
||||
PluginInstallResult,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginUninstallResult,
|
||||
} from "@/domain";
|
||||
import type { PluginGateway, ReviewPluginPackageInput } from "@/ports";
|
||||
|
||||
export class TauriPluginGateway implements PluginGateway {
|
||||
listPlugins(): Promise<PluginAdmin[]> {
|
||||
return invoke<PluginAdmin[]>("plugin_list_plugins");
|
||||
}
|
||||
|
||||
reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||
return invoke<PluginReview>("plugin_review_package", {
|
||||
request: { sourceKind: input.sourceKind, path: input.path },
|
||||
});
|
||||
}
|
||||
|
||||
installFromArchive(path: string): Promise<PluginInstallResult> {
|
||||
return invoke<PluginInstallResult>("plugin_install_from_archive", { path });
|
||||
}
|
||||
|
||||
installFromDirectory(path: string): Promise<PluginInstallResult> {
|
||||
return invoke<PluginInstallResult>("plugin_install_from_directory", { path });
|
||||
}
|
||||
|
||||
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
|
||||
return invoke<PluginAdmin>("plugin_set_enabled", { pluginId, enabled });
|
||||
}
|
||||
|
||||
uninstall(pluginId: string): Promise<PluginUninstallResult> {
|
||||
return invoke<PluginUninstallResult>("plugin_uninstall", { pluginId });
|
||||
}
|
||||
|
||||
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
|
||||
}
|
||||
|
||||
async openPluginsFolder(pluginId?: string): Promise<void> {
|
||||
await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null });
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,15 @@ export class TauriSystemGateway implements SystemGateway {
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async pickArchiveFile(): Promise<string | null> {
|
||||
const result = await open({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
filters: [{ name: "Plugin archive", extensions: ["ideaplug", "zip", "vsix"] }],
|
||||
});
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async onAppExitWorkGuard(
|
||||
handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
|
||||
@ -11,6 +11,7 @@ import { ProjectsView } from "@/features/projects";
|
||||
import { FirstRunWizard } from "@/features/first-run";
|
||||
import { AnnouncementsProvider } from "@/features/announcements";
|
||||
import { AppExitConfirmDialog } from "@/features/appExit";
|
||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||
import { Panel, Spinner, Toolbar } from "@/shared";
|
||||
import { useGateways, shouldUseMock } from "./di";
|
||||
|
||||
@ -65,6 +66,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<AnnouncementsProvider>
|
||||
<PluginRuntimeProvider>
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
{/* ── Header ── */}
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
@ -116,6 +118,7 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
<AppExitConfirmDialog />
|
||||
</PluginRuntimeProvider>
|
||||
</AnnouncementsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -837,11 +837,18 @@ export interface GridContainer {
|
||||
/**
|
||||
* A node in the layout tree. Tagged on `type` with the payload under `node`,
|
||||
* matching the backend `#[serde(tag = "type", content = "node")]`.
|
||||
*
|
||||
* `customPluginLayout` (#43, F4, carnet v2 §3) is a true top-level variant —
|
||||
* a plugin-provided layout occupies a slot in the tree at the same conceptual
|
||||
* level as a terminal leaf, a split or a grid, never a field bolted onto
|
||||
* `LeafCell` (that shape was tried and explicitly retired by Architect after
|
||||
* QA flagged the ambiguity — see `CustomPluginLayoutCell`'s doc comment).
|
||||
*/
|
||||
export type LayoutNode =
|
||||
| { type: "leaf"; node: LeafCell }
|
||||
| { type: "split"; node: SplitContainer }
|
||||
| { type: "grid"; node: GridContainer };
|
||||
| { type: "grid"; node: GridContainer }
|
||||
| { type: "customPluginLayout"; node: CustomPluginLayoutCell };
|
||||
|
||||
/** The root of a tab's terminal layout. */
|
||||
export interface LayoutTree {
|
||||
@ -1448,3 +1455,190 @@ export interface PairingCode {
|
||||
export function normalizePairingCode(raw: string): string {
|
||||
return raw.replace(/[\s-]+/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin system (ticket #43) — mirrors of the backend DTOs described in the
|
||||
// carnet §§2, 5, 6, 7. Full-trust, locally-installed, globally-scoped plugins
|
||||
// contributing menus/menu-items/layouts/MCP servers via a pre-compiled ESM
|
||||
// bundle loaded at bootstrap. See `@/plugins/runtime` for the loader/registry
|
||||
// and `@/features/plugins` for the admin surface + menu/layout integration.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Where an installed plugin package came from (admin display only). */
|
||||
export type PluginSourceKind = "archive" | "directory";
|
||||
|
||||
/** Persisted lifecycle state of an installed plugin (carnet §1.4). */
|
||||
export type PluginLifecycleState =
|
||||
| "enabled"
|
||||
| "disabled"
|
||||
| "pending-enable"
|
||||
| "pending-disable"
|
||||
| "pending-uninstall"
|
||||
| "invalid";
|
||||
|
||||
/** Counts of what a plugin declares, shown in the admin list (carnet §5). */
|
||||
export interface PluginContributionSummary {
|
||||
topLevelMenus: number;
|
||||
menuItems: number;
|
||||
layouts: number;
|
||||
mcpServers: number;
|
||||
}
|
||||
|
||||
/** One installed plugin, as shown in `Paramètres > Plugins` (mirror of `PluginAdminDto`). */
|
||||
export interface PluginAdmin {
|
||||
id: string;
|
||||
displayName: string;
|
||||
publisher?: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
sourceKind: PluginSourceKind;
|
||||
sourceLabel?: string;
|
||||
lifecycleState: PluginLifecycleState;
|
||||
enabled: boolean;
|
||||
pendingEnableState?: boolean;
|
||||
pendingUninstall: boolean;
|
||||
restartRequired: boolean;
|
||||
trustLevel: "full";
|
||||
contributionSummary: PluginContributionSummary;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A manifest validation issue surfaced during pre-install review. */
|
||||
export interface PluginReviewIssue {
|
||||
severity: "error" | "warning";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Pre-install review of a candidate package (carnet §4, `ReviewPluginPackage`). */
|
||||
export interface PluginReview {
|
||||
id: string;
|
||||
displayName: string;
|
||||
publisher?: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
trustLevel: "full";
|
||||
contributionSummary: PluginContributionSummary;
|
||||
issues: PluginReviewIssue[];
|
||||
/** False when an `error`-severity issue makes install unsafe/impossible. */
|
||||
installable: boolean;
|
||||
}
|
||||
|
||||
/** Outcome of `install_from_archive` / `install_from_directory`. */
|
||||
export interface PluginInstallResult {
|
||||
plugin: PluginAdmin;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
/** Outcome of `uninstall`. */
|
||||
export interface PluginUninstallResult {
|
||||
pluginId: string;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
/** Contribution declarations for one plugin, as consumed by the runtime loader. */
|
||||
export interface PluginContributionDto {
|
||||
menus: PluginTopLevelMenuContribution[];
|
||||
menuItems: PluginMenuItemContribution[];
|
||||
layouts: PluginLayoutContribution[];
|
||||
mcpServers: PluginMcpServerSummary[];
|
||||
}
|
||||
|
||||
/** One entry of `plugin_list_runtime_contributions` (mirror of `PluginRuntimePluginDto`). */
|
||||
export interface PluginRuntimePlugin {
|
||||
id: string;
|
||||
displayName: string;
|
||||
publisher?: string;
|
||||
version: string;
|
||||
bundleUrl: string;
|
||||
iconUrl?: string;
|
||||
contentHash: string;
|
||||
contributes: PluginContributionDto;
|
||||
}
|
||||
|
||||
/** Bootstrap catalog fetched once, before loading any plugin bundle. */
|
||||
export interface PluginRuntimeContributionCatalog {
|
||||
plugins: PluginRuntimePlugin[];
|
||||
}
|
||||
|
||||
/** Manifest declaration of a top-level menu (carnet §7.1). */
|
||||
export interface PluginTopLevelMenuContribution {
|
||||
id: string;
|
||||
label: string;
|
||||
topLevel: true;
|
||||
order?: number;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** Native + plugin menu ids an item can target (carnet §7.2). */
|
||||
export type MenuTargetId = "panels" | "settings" | `plugin:${string}`;
|
||||
|
||||
/** Manifest declaration of a menu item contributed into an existing/plugin menu. */
|
||||
export interface PluginMenuItemContribution {
|
||||
id: string;
|
||||
targetMenuId: MenuTargetId;
|
||||
label: string;
|
||||
command: string;
|
||||
order?: number;
|
||||
icon?: string;
|
||||
when?: string;
|
||||
}
|
||||
|
||||
/** A menu item resolved for rendering — enablement already evaluated (carnet §7.2). */
|
||||
export interface ResolvedPluginMenuItem {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginDisplayName: string;
|
||||
targetMenuId: MenuTargetId;
|
||||
label: string;
|
||||
command: string;
|
||||
enabled: boolean;
|
||||
disabledReason?: string;
|
||||
groupLabel?: string;
|
||||
order: number;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
/** Manifest declaration of a custom React layout (carnet §7.3). */
|
||||
export interface PluginLayoutContribution {
|
||||
type: string;
|
||||
label: string;
|
||||
component: string;
|
||||
order?: number;
|
||||
icon?: string;
|
||||
when?: string;
|
||||
}
|
||||
|
||||
/** Whether a persisted plugin layout can currently be rendered by its provider. */
|
||||
export type PluginLayoutAvailability =
|
||||
| "available"
|
||||
| "plugin-disabled"
|
||||
| "plugin-missing"
|
||||
| "incompatible";
|
||||
|
||||
/**
|
||||
* Opaque, domain-persisted identity + state of a `customPluginLayout`
|
||||
* top-level {@link LayoutNode} variant (#43, carnet v2 §3.2 — the canonical
|
||||
* schema, mirroring the backend `CustomPluginLayoutCell` exactly: `id`,
|
||||
* `pluginId`, `layoutType`, `state`, camelCase). No display name travels on
|
||||
* the wire — the UI derives it from the plugin runtime registry/admin list at
|
||||
* render time, never persists it here.
|
||||
*
|
||||
* A previous frontend-only shape nested this under `LeafCell.pluginLayout`
|
||||
* with a `kind`/`nodeId`/`providerPluginId` field naming; that shape was
|
||||
* never part of the IPC/persistence contract and has been retired — do not
|
||||
* reintroduce it.
|
||||
*/
|
||||
export interface CustomPluginLayoutCell {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
layoutType: string;
|
||||
state: unknown;
|
||||
}
|
||||
|
||||
/** Manifest declaration of an MCP server the plugin supervises (carnet §7.4, summary only). */
|
||||
export interface PluginMcpServerSummary {
|
||||
id: string;
|
||||
displayName: string;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
TargetAnnouncementsOverlay,
|
||||
useTargetAnnouncements,
|
||||
} from "@/features/announcements";
|
||||
import { PluginLayoutCellView } from "@/features/plugins";
|
||||
import {
|
||||
modelServerOverlayText,
|
||||
describeModelServerDownload,
|
||||
@ -79,9 +80,21 @@ interface LayoutGridProps {
|
||||
layoutId?: string;
|
||||
/** Opens the read-only canonical transcript for a conversation. */
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
/**
|
||||
* Navigates to `Paramètres > Plugins` (#43, F4) — the "Ouvrir Plugins" action
|
||||
* of a plugin layout's unavailable fallback. Threaded down to
|
||||
* `PluginLayoutCellView` the same way `onOpenConversation` is.
|
||||
*/
|
||||
onOpenPluginsSettings?: () => void;
|
||||
}
|
||||
|
||||
export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: LayoutGridProps) {
|
||||
export function LayoutGrid({
|
||||
projectId,
|
||||
cwd,
|
||||
layoutId,
|
||||
onOpenConversation,
|
||||
onOpenPluginsSettings,
|
||||
}: LayoutGridProps) {
|
||||
const vm = useLayout(projectId, layoutId);
|
||||
const work = useProjectWorkState(projectId);
|
||||
|
||||
@ -117,6 +130,7 @@ export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: Lay
|
||||
workState={work.state}
|
||||
refreshWorkState={work.refresh}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -132,6 +146,7 @@ interface NodeViewProps {
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
onOpenPluginsSettings?: () => void;
|
||||
}
|
||||
|
||||
function NodeView({
|
||||
@ -143,8 +158,24 @@ function NodeView({
|
||||
workState,
|
||||
refreshWorkState,
|
||||
onOpenConversation,
|
||||
onOpenPluginsSettings,
|
||||
}: NodeViewProps) {
|
||||
switch (node.type) {
|
||||
case "customPluginLayout":
|
||||
// A true top-level `LayoutNode` variant (#43, F4, carnet v2 §3) — a
|
||||
// plugin layout occupies a slot in the tree at the same level as a
|
||||
// terminal leaf, split or grid. Rendered separately from `LeafView`
|
||||
// (which owns a lot of terminal-only concerns — write-portal,
|
||||
// model-server overlay, agent dropdown — none of which apply here).
|
||||
return (
|
||||
<PluginLayoutCellView
|
||||
projectId={projectId}
|
||||
cell={node.node}
|
||||
onStateChange={(nextState) => vm.setPluginLayoutState(node.node.id, nextState)}
|
||||
onOpenPlugins={() => onOpenPluginsSettings?.()}
|
||||
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
|
||||
/>
|
||||
);
|
||||
case "leaf":
|
||||
return (
|
||||
<LeafView
|
||||
@ -172,6 +203,7 @@ function NodeView({
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
/>
|
||||
);
|
||||
case "grid":
|
||||
@ -184,6 +216,7 @@ function NodeView({
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1153,6 +1186,7 @@ interface SplitViewProps {
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
onOpenPluginsSettings?: () => void;
|
||||
}
|
||||
|
||||
function SplitView({
|
||||
@ -1163,6 +1197,7 @@ function SplitView({
|
||||
workState,
|
||||
refreshWorkState,
|
||||
onOpenConversation,
|
||||
onOpenPluginsSettings,
|
||||
}: SplitViewProps) {
|
||||
const isRow = split.direction === "row";
|
||||
const baseWeights = split.children.map((c) => c.weight);
|
||||
@ -1207,6 +1242,7 @@ function SplitView({
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
parentSplit={{
|
||||
container: split.id,
|
||||
index: i,
|
||||
@ -1301,6 +1337,7 @@ interface GridViewProps {
|
||||
workState: ProjectWorkState | null;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
onOpenPluginsSettings?: () => void;
|
||||
}
|
||||
|
||||
function GridView({
|
||||
@ -1311,6 +1348,7 @@ function GridView({
|
||||
workState,
|
||||
refreshWorkState,
|
||||
onOpenConversation,
|
||||
onOpenPluginsSettings,
|
||||
}: GridViewProps) {
|
||||
const cols = normalizeWeights(grid.colWeights)
|
||||
.map((p) => `${p}fr`)
|
||||
@ -1351,6 +1389,7 @@ function GridView({
|
||||
workState={workState}
|
||||
refreshWorkState={refreshWorkState}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onOpenPluginsSettings={onOpenPluginsSettings}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { LayoutInfo } from "@/domain";
|
||||
import { cn } from "@/shared";
|
||||
import { PluginLayoutSelectorSection, usePluginRuntime } from "@/features/plugins";
|
||||
import { useLayouts } from "./useLayouts";
|
||||
|
||||
interface LayoutTabsProps {
|
||||
@ -43,6 +44,10 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Show/hide the create-kind dropdown.
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
||||
const { registry: pluginRegistry } = usePluginRuntime();
|
||||
// Plugin layouts need a `create_layout` backend extension that doesn't exist
|
||||
// yet (#43, F4 open point) — surfaced rather than silently swallowed.
|
||||
const [pluginLayoutNotice, setPluginLayoutNotice] = useState<string | null>(null);
|
||||
|
||||
async function handleSelect(id: string) {
|
||||
// The effect above propagates the new active layout (id + kind) to the parent.
|
||||
@ -196,13 +201,22 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
||||
>
|
||||
⎇ Git graph
|
||||
</button>
|
||||
<PluginLayoutSelectorSection
|
||||
registry={pluginRegistry}
|
||||
onSelect={(choice) => {
|
||||
setShowCreateMenu(false);
|
||||
setPluginLayoutNotice(
|
||||
`« ${choice.layout.label} » (${choice.pluginDisplayName}) : la création de layouts plugins nécessite une extension backend pas encore livrée.`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vm.error && (
|
||||
{(vm.error || pluginLayoutNotice) && (
|
||||
<span className="ml-2 text-xs text-danger" role="alert">
|
||||
{vm.error}
|
||||
{vm.error ?? pluginLayoutNotice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -13,7 +13,9 @@ import {
|
||||
droppedSessions,
|
||||
leaves,
|
||||
normalizeWeights,
|
||||
replaceCustomPluginLayoutWithTerminal,
|
||||
resizeAdjacent,
|
||||
setCustomPluginLayoutState,
|
||||
singleLeafTree,
|
||||
splitOp,
|
||||
} from "./layout";
|
||||
@ -403,3 +405,109 @@ describe("splitOp", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #43, F4 — the `customPluginLayout` top-level `LayoutNode` variant, cadré
|
||||
// (carnet v2 §3.2) as the exact backend serde shape
|
||||
// (`#[serde(tag = "type", content = "node")]`, camelCase). These JSON literals
|
||||
// are the carnet's own worked examples, parsed with `JSON.parse` + a type
|
||||
// assertion (never hand-built as TS object literals) so this test actually
|
||||
// exercises "a layout JSON produced by Rust", not just the TS type shape.
|
||||
describe("customPluginLayout — parsing a backend-shaped JSON tree", () => {
|
||||
const ROOT_PLUGIN_LAYOUT_JSON = `{
|
||||
"root": {
|
||||
"type": "customPluginLayout",
|
||||
"node": {
|
||||
"id": "018f0c5a-2b4b-70d4-a7c2-300000000001",
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"layoutType": "dev.acme.gitgraph.layout",
|
||||
"state": { "branchFilter": "main" }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const SPLIT_WITH_PLUGIN_LAYOUT_JSON = `{
|
||||
"root": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "split-1",
|
||||
"direction": "row",
|
||||
"children": [
|
||||
{
|
||||
"weight": 1,
|
||||
"node": { "type": "leaf", "node": { "id": "terminal-1" } }
|
||||
},
|
||||
{
|
||||
"weight": 1,
|
||||
"node": {
|
||||
"type": "customPluginLayout",
|
||||
"node": {
|
||||
"id": "plugin-cell-1",
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"layoutType": "dev.acme.gitgraph.layout",
|
||||
"state": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
it("parses a root-level customPluginLayout node with its opaque state intact", () => {
|
||||
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||
expect(tree.root.type).toBe("customPluginLayout");
|
||||
if (tree.root.type !== "customPluginLayout") throw new Error("unreachable");
|
||||
expect(tree.root.node).toEqual({
|
||||
id: "018f0c5a-2b4b-70d4-a7c2-300000000001",
|
||||
pluginId: "dev.acme.gitgraph",
|
||||
layoutType: "dev.acme.gitgraph.layout",
|
||||
state: { branchFilter: "main" },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a customPluginLayout node nested as a split child alongside a terminal leaf", () => {
|
||||
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||
expect(tree.root.type).toBe("split");
|
||||
if (tree.root.type !== "split") throw new Error("unreachable");
|
||||
const [terminalChild, pluginChild] = tree.root.node.children;
|
||||
expect(terminalChild.node).toEqual({ type: "leaf", node: { id: "terminal-1" } });
|
||||
expect(pluginChild.node.type).toBe("customPluginLayout");
|
||||
if (pluginChild.node.type !== "customPluginLayout") throw new Error("unreachable");
|
||||
expect(pluginChild.node.node.pluginId).toBe("dev.acme.gitgraph");
|
||||
});
|
||||
|
||||
it("leaves() does not pick up a customPluginLayout node as a terminal leaf", () => {
|
||||
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||
expect(leaves(tree).map((l) => l.id)).toEqual(["terminal-1"]);
|
||||
});
|
||||
|
||||
it("setCustomPluginLayoutState patches only the matching node's state, leaving the rest of the tree untouched", () => {
|
||||
const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||
const updated = setCustomPluginLayoutState(tree, "plugin-cell-1", { branchFilter: "feature/x" });
|
||||
if (updated.root.type !== "split") throw new Error("unreachable");
|
||||
const [terminalChild, pluginChild] = updated.root.node.children;
|
||||
expect(terminalChild.node).toEqual({ type: "leaf", node: { id: "terminal-1" } });
|
||||
if (pluginChild.node.type !== "customPluginLayout") throw new Error("unreachable");
|
||||
expect(pluginChild.node.node.state).toEqual({ branchFilter: "feature/x" });
|
||||
expect(pluginChild.node.node.id).toBe("plugin-cell-1");
|
||||
expect(pluginChild.node.node.pluginId).toBe("dev.acme.gitgraph");
|
||||
});
|
||||
|
||||
it("setCustomPluginLayoutState is a no-op when the node id isn't found", () => {
|
||||
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||
const updated = setCustomPluginLayoutState(tree, "does-not-exist", { x: 1 });
|
||||
expect(updated).toEqual(tree);
|
||||
});
|
||||
|
||||
it("replaceCustomPluginLayoutWithTerminal swaps the node for a blank terminal leaf of the same id", () => {
|
||||
const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||
const updated = replaceCustomPluginLayoutWithTerminal(
|
||||
tree,
|
||||
"018f0c5a-2b4b-70d4-a7c2-300000000001",
|
||||
);
|
||||
expect(updated.root).toEqual({
|
||||
type: "leaf",
|
||||
node: { id: "018f0c5a-2b4b-70d4-a7c2-300000000001" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -91,6 +91,10 @@ function mapNode(node: LayoutNode, f: (n: LayoutNode) => LayoutNode): LayoutNode
|
||||
case "leaf":
|
||||
rebuilt = node;
|
||||
break;
|
||||
case "customPluginLayout":
|
||||
// No children to recurse into — same leaf-like treatment as "leaf".
|
||||
rebuilt = node;
|
||||
break;
|
||||
case "split":
|
||||
rebuilt = {
|
||||
type: "split",
|
||||
@ -319,6 +323,52 @@ export function droppedSessions(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally patches a `customPluginLayout` node's opaque `state` (#43, F4).
|
||||
*
|
||||
* Client-side only: the backend has no `LayoutOperation` variant to persist
|
||||
* plugin layout state yet (carnet v2 §3.5 — no backend refonte expected for
|
||||
* F4), so this does NOT call `LayoutGateway.mutateLayout`. It's the same
|
||||
* "real, in-session, not yet cross-restart-persisted" contract every plugin
|
||||
* component's `setState` gets: the tree re-renders with the new state
|
||||
* immediately, but a reload re-fetches the last **persisted** value from the
|
||||
* backend. Returns `tree` unchanged if no such node is found.
|
||||
*/
|
||||
export function setCustomPluginLayoutState(
|
||||
tree: LayoutTree,
|
||||
nodeId: string,
|
||||
state: unknown,
|
||||
): LayoutTree {
|
||||
return {
|
||||
root: mapNode(tree.root, (n) => {
|
||||
if (n.type === "customPluginLayout" && n.node.id === nodeId) {
|
||||
return { type: "customPluginLayout", node: { ...n.node, state } };
|
||||
}
|
||||
return n;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally replaces a `customPluginLayout` node with a blank terminal leaf of
|
||||
* the same id (#43, F4 "Choisir un autre layout" fallback action). Same
|
||||
* client-side-only contract as {@link setCustomPluginLayoutState} — no
|
||||
* backend operation exists to persist the node-kind change yet.
|
||||
*/
|
||||
export function replaceCustomPluginLayoutWithTerminal(
|
||||
tree: LayoutTree,
|
||||
nodeId: string,
|
||||
): LayoutTree {
|
||||
return {
|
||||
root: mapNode(tree.root, (n) => {
|
||||
if (n.type === "customPluginLayout" && n.node.id === nodeId) {
|
||||
return { type: "leaf", node: { id: nodeId } };
|
||||
}
|
||||
return n;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: builds a `split` operation splitting `target` in `direction`. */
|
||||
export function splitOp(target: string, direction: Direction): LayoutOperation {
|
||||
return {
|
||||
|
||||
@ -17,7 +17,12 @@ import type {
|
||||
LayoutTree,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { leaves, splitOp } from "./layout";
|
||||
import {
|
||||
leaves,
|
||||
replaceCustomPluginLayoutWithTerminal,
|
||||
setCustomPluginLayoutState,
|
||||
splitOp,
|
||||
} from "./layout";
|
||||
|
||||
/** What the layout grid UI needs from this hook. */
|
||||
export interface LayoutViewModel {
|
||||
@ -59,6 +64,19 @@ export interface LayoutViewModel {
|
||||
* to persist the id assigned at first launch so the next open resumes it.
|
||||
*/
|
||||
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
|
||||
/**
|
||||
* Patches a `customPluginLayout` node's opaque state locally (#43, F4) —
|
||||
* in-session only, no backend `LayoutOperation` for this exists yet (carnet
|
||||
* v2 §3.5). The tree re-renders immediately; a reload re-fetches the last
|
||||
* value actually persisted by the backend.
|
||||
*/
|
||||
setPluginLayoutState: (nodeId: string, state: unknown) => void;
|
||||
/**
|
||||
* "Choisir un autre layout" fallback action (#43, F4): locally swaps a
|
||||
* `customPluginLayout` node for a blank terminal leaf of the same id.
|
||||
* Same client-side-only contract as {@link setPluginLayoutState}.
|
||||
*/
|
||||
replacePluginLayoutWithTerminal: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
@ -246,6 +264,19 @@ export function useLayout(
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const setPluginLayoutState = useCallback(
|
||||
(nodeId: string, state: unknown) => {
|
||||
setLayout((prev) => (prev ? setCustomPluginLayoutState(prev, nodeId, state) : prev));
|
||||
setLayoutVersion((v) => v + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const replacePluginLayoutWithTerminal = useCallback((nodeId: string) => {
|
||||
setLayout((prev) => (prev ? replaceCustomPluginLayoutWithTerminal(prev, nodeId) : prev));
|
||||
setLayoutVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
layout,
|
||||
layoutVersion,
|
||||
@ -259,5 +290,7 @@ export function useLayout(
|
||||
setCellAgent,
|
||||
attachLiveAgentToCell,
|
||||
setCellConversation,
|
||||
setPluginLayoutState,
|
||||
replacePluginLayoutWithTerminal,
|
||||
};
|
||||
}
|
||||
|
||||
76
frontend/src/features/plugins/PluginConfirmDialog.tsx
Normal file
76
frontend/src/features/plugins/PluginConfirmDialog.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* A small modal confirmation, local to the plugins feature (ticket #43, F2).
|
||||
* Mirrors `features/devices/ConfirmDialog` (not shared — that dialog is itself
|
||||
* feature-local by design, see its header comment); duplicated rather than
|
||||
* cross-imported to keep each feature's public surface to its own `index.ts`.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button, zIndex } from "@/shared";
|
||||
|
||||
interface PluginConfirmDialogProps {
|
||||
title: string;
|
||||
body: string;
|
||||
confirmLabel: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PluginConfirmDialog({
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
danger = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: PluginConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cancelRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
|
||||
style={{ zIndex: zIndex.floatingWindow }}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-content">{title}</h3>
|
||||
<p className="whitespace-pre-line text-sm text-muted">{body}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button ref={cancelRef} size="sm" variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={danger ? "danger" : "primary"}
|
||||
loading={busy}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
frontend/src/features/plugins/PluginLayoutCellView.test.tsx
Normal file
113
frontend/src/features/plugins/PluginLayoutCellView.test.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria:
|
||||
* "rendu composant mock, state roundtrip, fallback sans mutation du layout").
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import type { CustomPluginLayoutCell } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type LoadedPlugin,
|
||||
type PluginLayoutProps,
|
||||
} from "@/plugins/runtime";
|
||||
import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
|
||||
import { PluginLayoutCellView } from "./PluginLayoutCellView";
|
||||
|
||||
function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayoutCell {
|
||||
return {
|
||||
id: "leaf-1",
|
||||
pluginId: "dev.acme.gitgraph",
|
||||
layoutType: "dev.acme.gitgraph.layout",
|
||||
state: { commits: 3 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function MockLayoutComponent(props: PluginLayoutProps) {
|
||||
return (
|
||||
<div>
|
||||
<p data-testid="state">{JSON.stringify(props.state)}</p>
|
||||
<button onClick={() => props.setState({ commits: 4 })}>bump</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function stubPlugin(): LoadedPlugin {
|
||||
const contributes = {
|
||||
menus: [],
|
||||
menuItems: [],
|
||||
layouts: [{ type: "dev.acme.gitgraph.layout", label: "Git Graph", component: "GitGraphLayout" }],
|
||||
mcpServers: [],
|
||||
};
|
||||
const layouts = new PluginLayoutRegistry("dev.acme.gitgraph", new Set(["dev.acme.gitgraph.layout"]));
|
||||
layouts.register({ type: "dev.acme.gitgraph.layout", component: MockLayoutComponent });
|
||||
return {
|
||||
pluginId: "dev.acme.gitgraph",
|
||||
displayName: "Git Graph",
|
||||
contributes,
|
||||
commands: new PluginCommandRegistry("dev.acme.gitgraph", new Set()),
|
||||
layouts,
|
||||
menu: new PluginMenuRegistry("dev.acme.gitgraph"),
|
||||
dispose: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function renderCell(
|
||||
registry: PluginRuntimeRegistry,
|
||||
props: Partial<{ cell: CustomPluginLayoutCell; onStateChange: (s: unknown) => void }> = {},
|
||||
) {
|
||||
const gateways: Gateways = createMockGateways();
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
||||
<PluginLayoutCellView
|
||||
projectId="proj-1"
|
||||
cell={props.cell ?? cell()}
|
||||
onStateChange={props.onStateChange ?? (() => {})}
|
||||
onOpenPlugins={() => {}}
|
||||
onChooseAnotherLayout={() => {}}
|
||||
/>
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PluginLayoutCellView", () => {
|
||||
it("renders the mock registered component when the provider is loaded", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(stubPlugin());
|
||||
renderCell(registry);
|
||||
expect(screen.getByTestId("state").textContent).toBe(JSON.stringify({ commits: 3 }));
|
||||
});
|
||||
|
||||
it("round-trips state through setState → onStateChange", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(stubPlugin());
|
||||
let lastState: unknown = null;
|
||||
renderCell(registry, {
|
||||
onStateChange: (s) => {
|
||||
lastState = s;
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "bump" }));
|
||||
expect(lastState).toEqual({ commits: 4 });
|
||||
});
|
||||
|
||||
it("renders the non-destructive fallback when the provider is not loaded, without mutating the cell", () => {
|
||||
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
|
||||
const testCell = cell();
|
||||
renderCell(registry, { cell: testCell });
|
||||
|
||||
expect(screen.getByText("Layout indisponible")).toBeTruthy();
|
||||
// The cell object itself is untouched — the domain identity/state survive.
|
||||
expect(testCell).toEqual(cell());
|
||||
});
|
||||
});
|
||||
73
frontend/src/features/plugins/PluginLayoutCellView.tsx
Normal file
73
frontend/src/features/plugins/PluginLayoutCellView.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* `PluginLayoutCellView` — renders a `customPluginLayout` top-level
|
||||
* {@link LayoutNode} (ticket #43, F4, carnet v2 §3): the plugin's registered
|
||||
* React component when its provider is loaded and declares the type,
|
||||
* otherwise {@link PluginLayoutFallback}.
|
||||
*
|
||||
* State is opaque to the domain (`unknown`) and round-trips through
|
||||
* `LayoutGateway` exactly like any other node field — `setState` here is the
|
||||
* only way a plugin component is meant to persist it (never direct gateway
|
||||
* calls from inside a plugin component).
|
||||
*/
|
||||
|
||||
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||
import { PluginLayoutFallback } from "./PluginLayoutFallback";
|
||||
import { resolvePluginLayoutAvailability } from "./layoutAvailability";
|
||||
|
||||
interface PluginLayoutCellViewProps {
|
||||
projectId: string;
|
||||
cell: CustomPluginLayoutCell;
|
||||
installedPlugins?: PluginAdmin[];
|
||||
onStateChange: (nextState: unknown) => void;
|
||||
onOpenPlugins: () => void;
|
||||
onChooseAnotherLayout: () => void;
|
||||
}
|
||||
|
||||
export function PluginLayoutCellView({
|
||||
projectId,
|
||||
cell,
|
||||
installedPlugins,
|
||||
onStateChange,
|
||||
onOpenPlugins,
|
||||
onChooseAnotherLayout,
|
||||
}: PluginLayoutCellViewProps) {
|
||||
const { registry } = usePluginRuntime();
|
||||
const gateways = useGateways();
|
||||
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
|
||||
|
||||
if (availability !== "available") {
|
||||
const providerDisplayName =
|
||||
registry.get(cell.pluginId)?.displayName ??
|
||||
installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName;
|
||||
return (
|
||||
<PluginLayoutFallback
|
||||
cell={cell}
|
||||
availability={availability}
|
||||
providerDisplayName={providerDisplayName}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onChooseAnotherLayout={onChooseAnotherLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = registry.layoutComponent(cell.pluginId, cell.layoutType)!;
|
||||
return (
|
||||
<Component
|
||||
projectId={projectId}
|
||||
nodeId={cell.id}
|
||||
layoutType={cell.layoutType}
|
||||
state={cell.state}
|
||||
setState={onStateChange}
|
||||
availability="available"
|
||||
gateways={{
|
||||
project: gateways.project,
|
||||
git: gateways.git,
|
||||
terminal: gateways.terminal,
|
||||
agents: gateways.agent,
|
||||
system: gateways.system,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
frontend/src/features/plugins/PluginLayoutFallback.tsx
Normal file
56
frontend/src/features/plugins/PluginLayoutFallback.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* `PluginLayoutFallback` — shown in place of a plugin-provided layout when its
|
||||
* provider is unavailable (ticket #43, F4, carnet v2 §3.4): "l'UI rend `Layout
|
||||
* indisponible`, provenance si connue, actions `Ouvrir Plugins` et `Choisir un
|
||||
* autre layout`." Never mutates the persisted layout — the domain keeps the
|
||||
* cell's opaque identity/state untouched so it recovers automatically if the
|
||||
* plugin comes back (re-enabled/reinstalled) on a later restart.
|
||||
*
|
||||
* The canonical `CustomPluginLayoutCell` payload (carnet v2 §3.2) carries no
|
||||
* display name on the wire — only `pluginId`. The caller resolves a friendly
|
||||
* name from the plugin admin list/runtime registry when it can; this falls
|
||||
* back to the raw `pluginId` so provenance is still always shown ("si
|
||||
* connue" is satisfied by the id itself, which is always known).
|
||||
*/
|
||||
|
||||
import type { CustomPluginLayoutCell, PluginLayoutAvailability } from "@/domain";
|
||||
import { Button, Panel } from "@/shared";
|
||||
|
||||
const REASON_LABEL: Record<Exclude<PluginLayoutAvailability, "available">, string> = {
|
||||
"plugin-disabled": "Le plugin fournisseur est désactivé.",
|
||||
"plugin-missing": "Le plugin fournisseur n'est pas installé.",
|
||||
incompatible: "Le plugin fournisseur ne déclare plus ce type de layout.",
|
||||
};
|
||||
|
||||
interface PluginLayoutFallbackProps {
|
||||
cell: CustomPluginLayoutCell;
|
||||
availability: Exclude<PluginLayoutAvailability, "available">;
|
||||
/** Friendly provider name, resolved by the caller; falls back to `cell.pluginId`. */
|
||||
providerDisplayName?: string;
|
||||
onOpenPlugins: () => void;
|
||||
onChooseAnotherLayout: () => void;
|
||||
}
|
||||
|
||||
export function PluginLayoutFallback({
|
||||
cell,
|
||||
availability,
|
||||
providerDisplayName,
|
||||
onOpenPlugins,
|
||||
onChooseAnotherLayout,
|
||||
}: PluginLayoutFallbackProps) {
|
||||
return (
|
||||
<Panel className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="text-sm font-semibold text-content">Layout indisponible</p>
|
||||
<p className="text-xs text-muted">{REASON_LABEL[availability]}</p>
|
||||
<p className="text-xs text-faint">Fourni par « {providerDisplayName ?? cell.pluginId} »</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onOpenPlugins}>
|
||||
Ouvrir Plugins
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onChooseAnotherLayout}>
|
||||
Choisir un autre layout
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* F4 — `PluginLayoutSelectorSection` (carnet §10 F4 acceptance criteria:
|
||||
* "layout disabled non proposé comme nouveau choix").
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type LoadedPlugin,
|
||||
} from "@/plugins/runtime";
|
||||
import { PluginLayoutSelectorSection, listPluginLayoutChoices } from "./PluginLayoutSelectorSection";
|
||||
|
||||
function stubPlugin(pluginId: string, displayName: string, layoutType: string): LoadedPlugin {
|
||||
const contributes = {
|
||||
menus: [],
|
||||
menuItems: [],
|
||||
layouts: [{ type: layoutType, label: `${displayName} layout`, component: "X" }],
|
||||
mcpServers: [],
|
||||
};
|
||||
return {
|
||||
pluginId,
|
||||
displayName,
|
||||
contributes,
|
||||
commands: new PluginCommandRegistry(pluginId, new Set()),
|
||||
layouts: new PluginLayoutRegistry(pluginId, new Set([layoutType])),
|
||||
menu: new PluginMenuRegistry(pluginId),
|
||||
dispose: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => {
|
||||
it("only lists loaded (enabled) plugins' layouts — a disabled plugin is never in the registry", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(stubPlugin("dev.acme.one", "One", "dev.acme.one.layout"));
|
||||
// "dev.acme.two" is disabled ⇒ never loaded ⇒ never added to the registry
|
||||
// (carnet §1.3) — nothing to filter here beyond what's already loaded.
|
||||
|
||||
const choices = listPluginLayoutChoices(registry);
|
||||
expect(choices).toHaveLength(1);
|
||||
expect(choices[0].pluginId).toBe("dev.acme.one");
|
||||
});
|
||||
|
||||
it("renders nothing when there are no plugin layouts", () => {
|
||||
const { container } = render(
|
||||
<PluginLayoutSelectorSection registry={new PluginRuntimeRegistry()} onSelect={() => {}} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("calls onSelect with the chosen plugin layout", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(stubPlugin("dev.acme.one", "One", "dev.acme.one.layout"));
|
||||
let selected: string | null = null;
|
||||
render(
|
||||
<PluginLayoutSelectorSection
|
||||
registry={registry}
|
||||
onSelect={(choice) => {
|
||||
selected = choice.layout.type;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /One layout/ }));
|
||||
expect(selected).toBe("dev.acme.one.layout");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* `PluginLayoutSelectorSection` — the "Layouts plugins" section building block
|
||||
* (ticket #43, F4, carnet §10: "Section `Layouts plugins` dans le sélecteur").
|
||||
*
|
||||
* Lists every plugin layout contribution currently loaded (disabled/missing
|
||||
* plugins never appear — carnet §1.3: only `enabled` plugins are loaded, so
|
||||
* there is nothing to filter here beyond what the registry already omits).
|
||||
*
|
||||
* Presentational only; not yet mounted in a layout-creation flow. The
|
||||
* existing "layout" concept in this codebase (`LayoutTabs`, `LayoutKind`) is
|
||||
* a whole-tab kind (`"terminal" | "gitGraph"`) picked via a fixed two-item
|
||||
* dropdown, backed by a `create(name, kind)` Tauri command that only knows
|
||||
* those two kinds. Wiring an actual "create a plugin layout" action needs a
|
||||
* backend `LayoutKind`/`create_layout` extension (carnet §10 flags F4 as
|
||||
* "DevFrontend + DevBackend si ajustement DTO layout") — this component is
|
||||
* the frontend half, ready to drop into that flow once the DTO lands; see the
|
||||
* F4 delivery report's open point.
|
||||
*/
|
||||
|
||||
import type { PluginLayoutContribution } from "@/domain";
|
||||
import type { PluginRuntimeRegistry } from "@/plugins/runtime";
|
||||
|
||||
export interface PluginLayoutChoice {
|
||||
pluginId: string;
|
||||
pluginDisplayName: string;
|
||||
layout: PluginLayoutContribution;
|
||||
}
|
||||
|
||||
export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): PluginLayoutChoice[] {
|
||||
return registry
|
||||
.layoutContributions()
|
||||
.map(({ pluginId, pluginDisplayName, layout }) => ({ pluginId, pluginDisplayName, layout }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.layout.order ?? 0) - (b.layout.order ?? 0) ||
|
||||
a.pluginDisplayName.localeCompare(b.pluginDisplayName) ||
|
||||
a.layout.label.localeCompare(b.layout.label),
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginLayoutSelectorSectionProps {
|
||||
registry: PluginRuntimeRegistry;
|
||||
onSelect: (choice: PluginLayoutChoice) => void;
|
||||
}
|
||||
|
||||
export function PluginLayoutSelectorSection({
|
||||
registry,
|
||||
onSelect,
|
||||
}: PluginLayoutSelectorSectionProps) {
|
||||
const choices = listPluginLayoutChoices(registry);
|
||||
if (choices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div role="group" aria-label="Layouts plugins">
|
||||
<p className="px-3 pt-2 text-[0.65rem] font-semibold uppercase tracking-wide text-faint">
|
||||
Layouts plugins
|
||||
</p>
|
||||
{choices.map((choice) => (
|
||||
<button
|
||||
key={`${choice.pluginId}:${choice.layout.type}`}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={`create ${choice.layout.label} layout`}
|
||||
onClick={() => onSelect(choice)}
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
||||
>
|
||||
<span>{choice.layout.label}</span>
|
||||
<span className="text-xs text-faint">{choice.pluginDisplayName}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/src/features/plugins/PluginRuntimeProvider.tsx
Normal file
99
frontend/src/features/plugins/PluginRuntimeProvider.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* `PluginRuntimeProvider` — bootstraps the plugin runtime once per app session
|
||||
* (ticket #43, F1, carnet §1.3): fetches the runtime catalog (already filtered
|
||||
* to `enabled && !pendingUninstall` by the backend) and loads every bundle via
|
||||
* {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry}
|
||||
* to the rest of the tree (menus in F3, layouts in F4).
|
||||
*
|
||||
* Mounted once near the app root, inside `<DIProvider>` (it reads gateways via
|
||||
* `useGateways()`). Never re-runs within a session — carnet §1.3: disable/
|
||||
* uninstall only mask contributions in-session; the actual reload happens on
|
||||
* the next app start (a fresh mount of this provider).
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { loadPlugins, PluginRuntimeRegistry, type PluginLoadFailure } from "@/plugins/runtime";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface PluginRuntimeContextValue {
|
||||
registry: PluginRuntimeRegistry;
|
||||
failures: PluginLoadFailure[];
|
||||
/** True until the initial catalog fetch + bundle loads have settled. */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default value used outside a `<PluginRuntimeProvider>` (e.g. `ProjectsView`
|
||||
* rendered directly in tests/Storybook without the full app shell): an empty,
|
||||
* already-settled registry rather than a hard requirement to wrap every call
|
||||
* site. Plugin contributions are strictly additive, so their absence must
|
||||
* never be a reason a surface fails to render.
|
||||
*/
|
||||
const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [],
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const PluginRuntimeContext = createContext<PluginRuntimeContextValue>(EMPTY_PLUGIN_RUNTIME);
|
||||
|
||||
interface PluginRuntimeProviderProps {
|
||||
children: ReactNode;
|
||||
/** Test/Storybook escape hatch — skips the gateway fetch and uses this value as-is. */
|
||||
value?: PluginRuntimeContextValue;
|
||||
}
|
||||
|
||||
export function PluginRuntimeProvider({ children, value: injected }: PluginRuntimeProviderProps) {
|
||||
const gateways = useGateways();
|
||||
const [value, setValue] = useState<PluginRuntimeContextValue>(
|
||||
injected ?? {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [],
|
||||
loading: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (injected) return;
|
||||
let cancelled = false;
|
||||
gateways.plugin
|
||||
.listRuntimeContributions()
|
||||
.then((catalog) =>
|
||||
loadPlugins(catalog.plugins, {
|
||||
project: gateways.project,
|
||||
git: gateways.git,
|
||||
terminal: gateways.terminal,
|
||||
agents: gateways.agent,
|
||||
system: gateways.system,
|
||||
}),
|
||||
)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setValue({ registry: result.registry, failures: result.failures, loading: false });
|
||||
})
|
||||
.catch(() => {
|
||||
// No plugin gateway / catalog fetch failed: run with zero plugins
|
||||
// rather than blocking the app (full-trust plugins are additive).
|
||||
if (!cancelled) setValue((prev) => ({ ...prev, loading: false }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Gateways are a stable singleton for the app session (from `useGateways`);
|
||||
// re-running on every render would reload every plugin bundle.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PluginRuntimeContext.Provider value={value}>{children}</PluginRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the loaded plugin runtime registry. Outside a `<PluginRuntimeProvider>`
|
||||
* this is the empty registry (see {@link EMPTY_PLUGIN_RUNTIME}), never a throw.
|
||||
*/
|
||||
export function usePluginRuntime(): PluginRuntimeContextValue {
|
||||
return useContext(PluginRuntimeContext);
|
||||
}
|
||||
241
frontend/src/features/plugins/PluginsPanel.tsx
Normal file
241
frontend/src/features/plugins/PluginsPanel.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* `PluginsPanel` — the `Paramètres > Plugins` admin surface (ticket #43, F2).
|
||||
*
|
||||
* Pure presentation; all behaviour comes from {@link usePlugins}. List of
|
||||
* installed plugins (name/publisher/version/source/state/restartRequired/
|
||||
* trust), install from archive/directory with a pre-install review step
|
||||
* (manifest summary + explicit full-trust mention), enable/disable/uninstall
|
||||
* with confirmation, and readable `pending`/`invalid`/error states.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PluginAdmin, PluginLifecycleState, PluginReview } from "@/domain";
|
||||
import { Button, Panel } from "@/shared";
|
||||
import { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog";
|
||||
import { usePlugins } from "./usePlugins";
|
||||
|
||||
const STATE_LABEL: Record<PluginLifecycleState, string> = {
|
||||
enabled: "Activé",
|
||||
disabled: "Désactivé",
|
||||
"pending-enable": "Activation en attente",
|
||||
"pending-disable": "Désactivation en attente",
|
||||
"pending-uninstall": "Désinstallation en attente",
|
||||
invalid: "Invalide",
|
||||
};
|
||||
|
||||
type PendingAction =
|
||||
| { kind: "disable"; plugin: PluginAdmin }
|
||||
| { kind: "uninstall"; plugin: PluginAdmin };
|
||||
|
||||
interface InstallFlow {
|
||||
sourceKind: "archive" | "directory";
|
||||
path: string;
|
||||
review: PluginReview;
|
||||
}
|
||||
|
||||
export function PluginsPanel() {
|
||||
const vm = usePlugins();
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [installFlow, setInstallFlow] = useState<InstallFlow | null>(null);
|
||||
const [installFlowError, setInstallFlowError] = useState<string | null>(null);
|
||||
|
||||
async function startInstallFromArchive() {
|
||||
setInstallFlowError(null);
|
||||
const path = await vm.pickArchiveFile();
|
||||
if (!path) return;
|
||||
const review = await vm.reviewArchive(path);
|
||||
if (!review) {
|
||||
setInstallFlowError("La revue du paquet a échoué.");
|
||||
return;
|
||||
}
|
||||
setInstallFlow({ sourceKind: "archive", path, review });
|
||||
}
|
||||
|
||||
async function startInstallFromDirectory() {
|
||||
setInstallFlowError(null);
|
||||
const path = await vm.pickDirectory();
|
||||
if (!path) return;
|
||||
const review = await vm.reviewDirectory(path);
|
||||
if (!review) {
|
||||
setInstallFlowError("La revue du paquet a échoué.");
|
||||
return;
|
||||
}
|
||||
setInstallFlow({ sourceKind: "directory", path, review });
|
||||
}
|
||||
|
||||
async function confirmInstall() {
|
||||
if (!installFlow) return;
|
||||
const ok =
|
||||
installFlow.sourceKind === "archive"
|
||||
? await vm.installFromArchive(installFlow.path)
|
||||
: await vm.installFromDirectory(installFlow.path);
|
||||
if (ok) setInstallFlow(null);
|
||||
}
|
||||
|
||||
async function confirmPendingAction() {
|
||||
if (!pendingAction) return;
|
||||
if (pendingAction.kind === "disable") {
|
||||
await vm.setEnabled(pendingAction.plugin.id, false);
|
||||
} else {
|
||||
await vm.uninstall(pendingAction.plugin.id);
|
||||
}
|
||||
setPendingAction(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-content">Plugins</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
|
||||
Installer depuis une archive…
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void startInstallFromDirectory()}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Installer depuis un dossier…
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(vm.error || installFlowError) && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{vm.error ?? installFlowError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vm.plugins.length === 0 ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">Aucun plugin installé.</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{vm.plugins.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Panel className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{p.displayName}</span>
|
||||
<span className="text-xs text-faint">v{p.version}</span>
|
||||
{p.publisher && <span className="text-xs text-faint">· {p.publisher}</span>}
|
||||
<span className="rounded bg-raised px-1.5 py-0.5 text-[0.65rem] uppercase text-muted">
|
||||
full-trust
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<span
|
||||
aria-label="plugin state"
|
||||
className={
|
||||
p.lifecycleState === "invalid" ? "text-danger" : undefined
|
||||
}
|
||||
>
|
||||
{STATE_LABEL[p.lifecycleState]}
|
||||
</span>
|
||||
{p.restartRequired && (
|
||||
<span className="text-warning">Redémarrage requis</span>
|
||||
)}
|
||||
{p.sourceLabel && <span>· {p.sourceLabel}</span>}
|
||||
</div>
|
||||
{p.error && (
|
||||
<p className="text-xs text-danger" role="alert">
|
||||
{p.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{p.enabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPendingAction({ kind: "disable", plugin: p })}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Désactiver
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void vm.setEnabled(p.id, true)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Activer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void vm.openPluginsFolder(p.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Ouvrir le dossier
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => setPendingAction({ kind: "uninstall", plugin: p })}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Désinstaller
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{installFlow && (
|
||||
<ConfirmDialog
|
||||
title={`Installer "${installFlow.review.displayName}" ?`}
|
||||
body={reviewSummary(installFlow.review)}
|
||||
confirmLabel={installFlow.review.installable ? "Installer" : "Corriger avant d'installer"}
|
||||
busy={vm.busy}
|
||||
danger={!installFlow.review.installable}
|
||||
onConfirm={() => {
|
||||
if (installFlow.review.installable) void confirmInstall();
|
||||
else setInstallFlow(null);
|
||||
}}
|
||||
onCancel={() => setInstallFlow(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingAction && (
|
||||
<ConfirmDialog
|
||||
title={
|
||||
pendingAction.kind === "disable"
|
||||
? `Désactiver "${pendingAction.plugin.displayName}" ?`
|
||||
: `Désinstaller "${pendingAction.plugin.displayName}" ?`
|
||||
}
|
||||
body={
|
||||
pendingAction.kind === "disable"
|
||||
? "Ses contributions seront masquées immédiatement ; un redémarrage sera peut-être nécessaire pour purger complètement le plugin."
|
||||
: "Le plugin, ses fichiers et ses contributions seront supprimés après redémarrage."
|
||||
}
|
||||
confirmLabel={pendingAction.kind === "disable" ? "Désactiver" : "Désinstaller"}
|
||||
danger={pendingAction.kind === "uninstall"}
|
||||
busy={vm.busy}
|
||||
onConfirm={() => void confirmPendingAction()}
|
||||
onCancel={() => setPendingAction(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function reviewSummary(review: PluginReview): string {
|
||||
const { contributionSummary: c } = review;
|
||||
const parts = [
|
||||
`Éditeur : ${review.publisher ?? "inconnu"}`,
|
||||
`Version : ${review.version}`,
|
||||
review.description ?? null,
|
||||
`Confiance : full-trust — ce plugin peut exécuter du code arbitraire dans IdeA.`,
|
||||
`Contributions : ${c.topLevelMenus} menu(s), ${c.menuItems} item(s), ${c.layouts} layout(s), ${c.mcpServers} serveur(s) MCP.`,
|
||||
...review.issues.map((i) => `${i.severity === "error" ? "Erreur" : "Avertissement"} : ${i.message}`),
|
||||
].filter((p): p is string => Boolean(p));
|
||||
return parts.join("\n");
|
||||
}
|
||||
13
frontend/src/features/plugins/index.ts
Normal file
13
frontend/src/features/plugins/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
|
||||
export { PluginsPanel } from "./PluginsPanel";
|
||||
export { usePlugins, type PluginsViewModel } from "./usePlugins";
|
||||
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
|
||||
export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus";
|
||||
export { resolvePluginLayoutAvailability } from "./layoutAvailability";
|
||||
export { PluginLayoutFallback } from "./PluginLayoutFallback";
|
||||
export { PluginLayoutCellView } from "./PluginLayoutCellView";
|
||||
export {
|
||||
PluginLayoutSelectorSection,
|
||||
listPluginLayoutChoices,
|
||||
type PluginLayoutChoice,
|
||||
} from "./PluginLayoutSelectorSection";
|
||||
81
frontend/src/features/plugins/layoutAvailability.test.ts
Normal file
81
frontend/src/features/plugins/layoutAvailability.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type LoadedPlugin,
|
||||
} from "@/plugins/runtime";
|
||||
import { resolvePluginLayoutAvailability } from "./layoutAvailability";
|
||||
|
||||
function stubPlugin(pluginId: string, layoutTypes: string[]): LoadedPlugin {
|
||||
const contributes = {
|
||||
menus: [],
|
||||
menuItems: [],
|
||||
layouts: layoutTypes.map((type) => ({ type, label: type, component: type })),
|
||||
mcpServers: [],
|
||||
};
|
||||
const layouts = new PluginLayoutRegistry(pluginId, new Set(layoutTypes));
|
||||
for (const type of layoutTypes) {
|
||||
layouts.register({ type, component: () => null });
|
||||
}
|
||||
return {
|
||||
pluginId,
|
||||
displayName: pluginId,
|
||||
contributes,
|
||||
commands: new PluginCommandRegistry(pluginId, new Set()),
|
||||
layouts,
|
||||
menu: new PluginMenuRegistry(pluginId),
|
||||
dispose: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayoutCell {
|
||||
return {
|
||||
id: "leaf-1",
|
||||
pluginId: "dev.acme.gitgraph",
|
||||
layoutType: "dev.acme.gitgraph.layout",
|
||||
state: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolvePluginLayoutAvailability", () => {
|
||||
it("is available when the provider is loaded and declares the layout type", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(stubPlugin("dev.acme.gitgraph", ["dev.acme.gitgraph.layout"]));
|
||||
expect(resolvePluginLayoutAvailability(registry, cell())).toBe("available");
|
||||
});
|
||||
|
||||
it("is incompatible when the provider is loaded but no longer declares the type", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(stubPlugin("dev.acme.gitgraph", ["some.other.type"]));
|
||||
expect(resolvePluginLayoutAvailability(registry, cell())).toBe("incompatible");
|
||||
});
|
||||
|
||||
it("is plugin-missing when the provider is not loaded and not in the admin list", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
expect(resolvePluginLayoutAvailability(registry, cell())).toBe("plugin-missing");
|
||||
});
|
||||
|
||||
it("is plugin-disabled when the provider is not loaded but known-disabled in the admin list", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
const admin: PluginAdmin[] = [
|
||||
{
|
||||
id: "dev.acme.gitgraph",
|
||||
displayName: "Git Graph",
|
||||
version: "1.0.0",
|
||||
sourceKind: "archive",
|
||||
lifecycleState: "disabled",
|
||||
enabled: false,
|
||||
pendingUninstall: false,
|
||||
restartRequired: false,
|
||||
trustLevel: "full",
|
||||
contributionSummary: { topLevelMenus: 0, menuItems: 0, layouts: 1, mcpServers: 0 },
|
||||
},
|
||||
];
|
||||
expect(resolvePluginLayoutAvailability(registry, cell(), admin)).toBe("plugin-disabled");
|
||||
});
|
||||
});
|
||||
36
frontend/src/features/plugins/layoutAvailability.ts
Normal file
36
frontend/src/features/plugins/layoutAvailability.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Plugin layout availability resolution (ticket #43, F4, carnet v2 §3.4).
|
||||
*
|
||||
* A `customPluginLayout` top-level {@link LayoutNode} is opaque domain state —
|
||||
* the provider plugin may be missing, disabled, or incompatible with the
|
||||
* running IdeA version at any given session. Availability is never stored in
|
||||
* the layout itself; it's derived here, purely, from the loaded runtime
|
||||
* registry, so it's trivially testable without React.
|
||||
*/
|
||||
|
||||
import type { CustomPluginLayoutCell, PluginAdmin, PluginLayoutAvailability } from "@/domain";
|
||||
import type { PluginRuntimeRegistry } from "@/plugins/runtime";
|
||||
|
||||
export function resolvePluginLayoutAvailability(
|
||||
registry: PluginRuntimeRegistry,
|
||||
cell: CustomPluginLayoutCell,
|
||||
/**
|
||||
* Optional admin list (from `PluginGateway.listPlugins()`) to tell "known but
|
||||
* disabled" apart from "not installed at all" — the runtime registry alone
|
||||
* only ever holds *loaded* (enabled) plugins (carnet §1.3), so it can't make
|
||||
* that distinction by itself. Omit it to fall back to "plugin-missing" for
|
||||
* both cases (still correct, just less precise fallback copy).
|
||||
*/
|
||||
installedPlugins?: PluginAdmin[],
|
||||
): PluginLayoutAvailability {
|
||||
const plugin = registry.get(cell.pluginId);
|
||||
if (!plugin) {
|
||||
const known = installedPlugins?.find((p) => p.id === cell.pluginId);
|
||||
return known && !known.enabled ? "plugin-disabled" : "plugin-missing";
|
||||
}
|
||||
const declaresType = plugin.contributes.layouts.some((l) => l.type === cell.layoutType);
|
||||
if (!declaresType) return "incompatible";
|
||||
const component = plugin.layouts.get(cell.layoutType);
|
||||
if (!component) return "incompatible";
|
||||
return "available";
|
||||
}
|
||||
155
frontend/src/features/plugins/menus.test.ts
Normal file
155
frontend/src/features/plugins/menus.test.ts
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* F3 — plugin menu contribution resolution (ticket #43, carnet §10 F3
|
||||
* acceptance criteria: "ordre déterministe, disabledReason, plugin disabled
|
||||
* absent, command handler appelé").
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginContributionDto } from "@/domain";
|
||||
import { PluginRuntimeRegistry, type LoadedPlugin, type WhenContext } from "@/plugins/runtime";
|
||||
import { PluginCommandRegistry, PluginLayoutRegistry, PluginMenuRegistry } from "@/plugins/runtime";
|
||||
import { resolveMenuItems, resolveTopLevelMenus } from "./menus";
|
||||
|
||||
const NO_CONTEXT: WhenContext = {
|
||||
projectOpen: false,
|
||||
gitRepository: false,
|
||||
agentSelected: false,
|
||||
terminalFocused: false,
|
||||
layoutCellFocused: false,
|
||||
};
|
||||
|
||||
function stubPlugin(pluginId: string, displayName: string, contributes: PluginContributionDto): LoadedPlugin {
|
||||
return {
|
||||
pluginId,
|
||||
displayName,
|
||||
contributes,
|
||||
commands: new PluginCommandRegistry(pluginId, new Set(contributes.menuItems.map((i) => i.command))),
|
||||
layouts: new PluginLayoutRegistry(pluginId, new Set(contributes.layouts.map((l) => l.type))),
|
||||
menu: new PluginMenuRegistry(pluginId),
|
||||
dispose: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function empty(): PluginContributionDto {
|
||||
return { menus: [], menuItems: [], layouts: [], mcpServers: [] };
|
||||
}
|
||||
|
||||
describe("resolveTopLevelMenus", () => {
|
||||
it("sorts by order, then plugin display name, then label", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(
|
||||
stubPlugin("dev.b", "Beta", {
|
||||
...empty(),
|
||||
menus: [{ id: "b.menu", label: "B Menu", topLevel: true, order: 0 }],
|
||||
}),
|
||||
);
|
||||
registry.add(
|
||||
stubPlugin("dev.a", "Alpha", {
|
||||
...empty(),
|
||||
menus: [{ id: "a.menu", label: "A Menu", topLevel: true, order: 0 }],
|
||||
}),
|
||||
);
|
||||
registry.add(
|
||||
stubPlugin("dev.z", "Zulu", {
|
||||
...empty(),
|
||||
menus: [{ id: "z.menu", label: "Z Menu", topLevel: true, order: -1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const resolved = resolveTopLevelMenus(registry);
|
||||
expect(resolved.map((m) => m.label)).toEqual(["Z Menu", "A Menu", "B Menu"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMenuItems", () => {
|
||||
it("only returns items targeting the requested menu, and only from loaded (i.e. enabled) plugins", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(
|
||||
stubPlugin("dev.acme", "Acme", {
|
||||
...empty(),
|
||||
menuItems: [
|
||||
{ id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" },
|
||||
{ id: "dev.acme.b", targetMenuId: "settings", label: "Open B", command: "dev.acme.b.cmd" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
// A disabled plugin never appears in the registry at all (carnet §1.3) —
|
||||
// simulated here by simply not adding it.
|
||||
|
||||
const panelsItems = resolveMenuItems(registry, "panels", NO_CONTEXT);
|
||||
expect(panelsItems).toHaveLength(1);
|
||||
expect(panelsItems[0].label).toBe("Open A");
|
||||
|
||||
const settingsItems = resolveMenuItems(registry, "settings", NO_CONTEXT);
|
||||
expect(settingsItems).toHaveLength(1);
|
||||
expect(settingsItems[0].label).toBe("Open B");
|
||||
});
|
||||
|
||||
it("evaluates `when` and disables with a diagnostic reason on failure, enables on success", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(
|
||||
stubPlugin("dev.acme", "Acme", {
|
||||
...empty(),
|
||||
menuItems: [
|
||||
{
|
||||
id: "dev.acme.needs-git",
|
||||
targetMenuId: "panels",
|
||||
label: "Needs git",
|
||||
command: "dev.acme.git.cmd",
|
||||
when: "gitRepository",
|
||||
},
|
||||
{
|
||||
id: "dev.acme.broken-when",
|
||||
targetMenuId: "panels",
|
||||
label: "Broken when",
|
||||
command: "dev.acme.broken.cmd",
|
||||
when: "notAVariable",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const withoutGit = resolveMenuItems(registry, "panels", NO_CONTEXT);
|
||||
const needsGit = withoutGit.find((i) => i.label === "Needs git")!;
|
||||
expect(needsGit.enabled).toBe(false);
|
||||
expect(needsGit.disabledReason).toBeUndefined();
|
||||
|
||||
const withGit = resolveMenuItems(registry, "panels", { ...NO_CONTEXT, gitRepository: true });
|
||||
expect(withGit.find((i) => i.label === "Needs git")!.enabled).toBe(true);
|
||||
|
||||
const broken = withoutGit.find((i) => i.label === "Broken when")!;
|
||||
expect(broken.enabled).toBe(false);
|
||||
expect(broken.disabledReason).toMatch(/unknown variable/);
|
||||
});
|
||||
|
||||
it("sorts resolved items deterministically", () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(
|
||||
stubPlugin("dev.acme", "Acme", {
|
||||
...empty(),
|
||||
menuItems: [
|
||||
{ id: "dev.acme.z", targetMenuId: "panels", label: "Z item", command: "dev.acme.z.cmd", order: 1 },
|
||||
{ id: "dev.acme.a", targetMenuId: "panels", label: "A item", command: "dev.acme.a.cmd", order: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const items = resolveMenuItems(registry, "panels", NO_CONTEXT);
|
||||
expect(items.map((i) => i.label)).toEqual(["A item", "Z item"]);
|
||||
});
|
||||
|
||||
it("dispatches a command via the registry's runCommand", async () => {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
let ran = false;
|
||||
const plugin = stubPlugin("dev.acme", "Acme", {
|
||||
...empty(),
|
||||
menuItems: [{ id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" }],
|
||||
});
|
||||
plugin.commands.register("dev.acme.a.cmd", () => {
|
||||
ran = true;
|
||||
});
|
||||
registry.add(plugin);
|
||||
|
||||
await registry.runCommand("dev.acme", "dev.acme.a.cmd");
|
||||
expect(ran).toBe(true);
|
||||
});
|
||||
});
|
||||
81
frontend/src/features/plugins/menus.ts
Normal file
81
frontend/src/features/plugins/menus.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Plugin menu contribution resolution (ticket #43, F3, carnet §7.1/§7.2).
|
||||
*
|
||||
* Pure functions over a {@link PluginRuntimeRegistry} snapshot — no React, so
|
||||
* they're trivially unit-testable. `ProjectsView` (the menu bar owner) calls
|
||||
* these to build the extra `MenuBarMenu`/`MenuBarItem` entries it splices in
|
||||
* between/into the native menus.
|
||||
*/
|
||||
|
||||
import type { MenuTargetId, ResolvedPluginMenuItem } from "@/domain";
|
||||
import { evaluateWhen, type PluginRuntimeRegistry, type WhenContext } from "@/plugins/runtime";
|
||||
|
||||
export interface ResolvedTopLevelMenu {
|
||||
id: `plugin:${string}`;
|
||||
pluginId: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level plugin menus, sorted by `order` then plugin display name then
|
||||
* label (carnet §7.1) — rendered between `Panneaux` and `Paramètres`.
|
||||
*/
|
||||
export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedTopLevelMenu[] {
|
||||
return registry
|
||||
.topLevelMenus()
|
||||
.map(({ pluginId, pluginDisplayName, menu }) => ({
|
||||
id: `plugin:${menu.id}` as const,
|
||||
pluginId,
|
||||
pluginDisplayName,
|
||||
label: menu.label,
|
||||
icon: menu.icon,
|
||||
order: menu.order ?? 0,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.order - b.order ||
|
||||
a.pluginDisplayName.localeCompare(b.pluginDisplayName) ||
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu items contributed into one target menu (native `panels`/`settings` or a
|
||||
* plugin's own top-level menu, `plugin:<menuId>`), `when`-evaluated and sorted
|
||||
* by `order` then plugin display name then label (carnet §7.2). A disabled
|
||||
* plugin contributes nothing (its entries are simply absent from the
|
||||
* registry, carnet §1.3) — this function has nothing extra to filter for that.
|
||||
*/
|
||||
export function resolveMenuItems(
|
||||
registry: PluginRuntimeRegistry,
|
||||
targetMenuId: MenuTargetId,
|
||||
whenCtx: WhenContext,
|
||||
): ResolvedPluginMenuItem[] {
|
||||
return registry
|
||||
.menuItems()
|
||||
.filter(({ item }) => item.targetMenuId === targetMenuId)
|
||||
.map(({ pluginId, pluginDisplayName, item }) => {
|
||||
const result = evaluateWhen(item.when, whenCtx);
|
||||
return {
|
||||
id: item.id,
|
||||
pluginId,
|
||||
pluginDisplayName,
|
||||
targetMenuId: item.targetMenuId,
|
||||
label: item.label,
|
||||
command: item.command,
|
||||
enabled: result.ok ? result.value : false,
|
||||
disabledReason: result.ok ? undefined : result.reason,
|
||||
groupLabel: pluginDisplayName,
|
||||
order: item.order ?? 0,
|
||||
iconUrl: item.icon,
|
||||
} satisfies ResolvedPluginMenuItem;
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.order - b.order ||
|
||||
a.pluginDisplayName.localeCompare(b.pluginDisplayName) ||
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
}
|
||||
102
frontend/src/features/plugins/plugins.test.tsx
Normal file
102
frontend/src/features/plugins/plugins.test.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* F2 — `PluginsPanel` (ticket #43, carnet §10 F2 acceptance criteria): install
|
||||
* from archive/directory with pre-install review, full-trust mention visible,
|
||||
* enable/disable/uninstall flows, and `restartRequired` rendered.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
|
||||
|
||||
import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { PluginsPanel } from "./PluginsPanel";
|
||||
|
||||
function renderPanel(plugin?: MockPluginGateway, system?: MockSystemGateway) {
|
||||
const p = plugin ?? new MockPluginGateway();
|
||||
const s = system ?? new MockSystemGateway();
|
||||
const gateways = { plugin: p, system: s } as unknown as Gateways;
|
||||
return {
|
||||
plugin: p,
|
||||
system: s,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginsPanel />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("PluginsPanel", () => {
|
||||
it("shows an empty state with no plugins installed", async () => {
|
||||
renderPanel();
|
||||
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("installs from an archive via the review dialog, mentioning full-trust", async () => {
|
||||
renderPanel();
|
||||
await screen.findByText("Aucun plugin installé.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Installer depuis une archive…" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText(/full-trust/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Installer" }));
|
||||
|
||||
// The installed plugin now shows in the list, restart required (carnet §1.4).
|
||||
expect(await screen.findByText("mock-plugin")).toBeTruthy();
|
||||
expect(screen.getByText("Redémarrage requis")).toBeTruthy();
|
||||
expect(screen.getByText("Activé")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables an installed plugin after confirmation", async () => {
|
||||
const plugin = new MockPluginGateway();
|
||||
plugin._seedPlugin({
|
||||
id: "dev.acme.one",
|
||||
displayName: "Acme One",
|
||||
version: "1.0.0",
|
||||
sourceKind: "archive",
|
||||
lifecycleState: "enabled",
|
||||
enabled: true,
|
||||
pendingUninstall: false,
|
||||
restartRequired: false,
|
||||
trustLevel: "full",
|
||||
contributionSummary: { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 },
|
||||
});
|
||||
renderPanel(plugin);
|
||||
|
||||
await screen.findByText("Acme One");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Désactiver" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Désactiver" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Désactivé")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("uninstalls an installed plugin after confirmation", async () => {
|
||||
const plugin = new MockPluginGateway();
|
||||
plugin._seedPlugin({
|
||||
id: "dev.acme.two",
|
||||
displayName: "Acme Two",
|
||||
version: "1.0.0",
|
||||
sourceKind: "directory",
|
||||
lifecycleState: "enabled",
|
||||
enabled: true,
|
||||
pendingUninstall: false,
|
||||
restartRequired: false,
|
||||
trustLevel: "full",
|
||||
contributionSummary: { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 },
|
||||
});
|
||||
renderPanel(plugin);
|
||||
|
||||
await screen.findByText("Acme Two");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Désinstaller" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Désinstaller" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Acme Two")).toBeNull());
|
||||
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
63
frontend/src/features/plugins/usePluginMenus.ts
Normal file
63
frontend/src/features/plugins/usePluginMenus.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `usePluginMenus` — turns the loaded plugin runtime registry into ready-to-
|
||||
* splice `MenuBarMenu`/`MenuBarItem` entries for `ProjectsView`'s menu bar
|
||||
* (ticket #43, F3, carnet §7.1/§7.2).
|
||||
*
|
||||
* `whenCtx` is supplied by the caller (`ProjectsView` knows `projectOpen`
|
||||
* directly; git/agent/terminal/cell-focus signals are best-effort — see the
|
||||
* F3 gap noted in the delivery report: there is no existing global
|
||||
* agent-selected/terminal-focused/layout-cell-focused signal at the menu-bar
|
||||
* level in this codebase today, so those three default to `false` until a
|
||||
* future lot threads real focus state up to this level).
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export interface UsePluginMenusResult {
|
||||
/** Top-level plugin menus, in order, ready to splice between Panneaux/Paramètres. */
|
||||
topLevelMenus: MenuBarMenu[];
|
||||
/** Resolved+ordered `MenuBarItem`s to append to a native menu's items. */
|
||||
itemsFor: (targetMenuId: MenuTargetId) => MenuBarItem[];
|
||||
runCommand: (pluginId: string, commandId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePluginMenus(
|
||||
registry: PluginRuntimeRegistry,
|
||||
whenCtx: WhenContext,
|
||||
): UsePluginMenusResult {
|
||||
const runCommand = useMemo(
|
||||
() => (pluginId: string, commandId: string) => registry.runCommand(pluginId, commandId),
|
||||
[registry],
|
||||
);
|
||||
|
||||
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);
|
||||
},
|
||||
}));
|
||||
}, [registry, whenCtx, runCommand]);
|
||||
|
||||
const topLevelMenus = useMemo<MenuBarMenu[]>(() => {
|
||||
return resolveTopLevelMenus(registry).map((menu) => ({
|
||||
id: menu.id,
|
||||
label: menu.label,
|
||||
items: itemsFor(menu.id),
|
||||
}));
|
||||
}, [registry, itemsFor]);
|
||||
|
||||
return { topLevelMenus, itemsFor, runCommand };
|
||||
}
|
||||
181
frontend/src/features/plugins/usePlugins.ts
Normal file
181
frontend/src/features/plugins/usePlugins.ts
Normal file
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* `usePlugins` — view-model hook for the `Paramètres > Plugins` admin surface
|
||||
* (ticket #43, F2). Consumes {@link PluginGateway} exclusively; never touches
|
||||
* `invoke()` (ARCHITECTURE §1.3), so it is fully testable with the mock.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError, PluginAdmin, PluginReview } from "@/domain";
|
||||
import type { ReviewPluginPackageInput } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface PluginsViewModel {
|
||||
plugins: PluginAdmin[];
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
reviewArchive: (path: string) => Promise<PluginReview | null>;
|
||||
reviewDirectory: (path: string) => Promise<PluginReview | null>;
|
||||
installFromArchive: (path: string) => Promise<boolean>;
|
||||
installFromDirectory: (path: string) => Promise<boolean>;
|
||||
setEnabled: (pluginId: string, enabled: boolean) => Promise<void>;
|
||||
uninstall: (pluginId: string) => Promise<void>;
|
||||
openPluginsFolder: (pluginId?: string) => Promise<void>;
|
||||
pickArchiveFile: () => Promise<string | null>;
|
||||
pickDirectory: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function usePlugins(): PluginsViewModel {
|
||||
const { plugin, system } = useGateways();
|
||||
|
||||
const [plugins, setPlugins] = useState<PluginAdmin[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setPlugins(await plugin.listPlugins());
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [plugin]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const review = useCallback(
|
||||
async (input: ReviewPluginPackageInput): Promise<PluginReview | null> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await plugin.reviewPackage(input);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[plugin],
|
||||
);
|
||||
|
||||
const reviewArchive = useCallback(
|
||||
(path: string) => review({ sourceKind: "archive", path }),
|
||||
[review],
|
||||
);
|
||||
const reviewDirectory = useCallback(
|
||||
(path: string) => review({ sourceKind: "directory", path }),
|
||||
[review],
|
||||
);
|
||||
|
||||
const installFromArchive = useCallback(
|
||||
async (path: string): Promise<boolean> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await plugin.installFromArchive(path);
|
||||
setPlugins((prev) => [...prev.filter((p) => p.id !== result.plugin.id), result.plugin]);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[plugin],
|
||||
);
|
||||
|
||||
const installFromDirectory = useCallback(
|
||||
async (path: string): Promise<boolean> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await plugin.installFromDirectory(path);
|
||||
setPlugins((prev) => [...prev.filter((p) => p.id !== result.plugin.id), result.plugin]);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[plugin],
|
||||
);
|
||||
|
||||
const setEnabled = useCallback(
|
||||
async (pluginId: string, enabled: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await plugin.setEnabled(pluginId, enabled);
|
||||
setPlugins((prev) => prev.map((p) => (p.id === pluginId ? updated : p)));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[plugin],
|
||||
);
|
||||
|
||||
const uninstall = useCallback(
|
||||
async (pluginId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await plugin.uninstall(pluginId);
|
||||
setPlugins((prev) => prev.filter((p) => p.id !== pluginId));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[plugin],
|
||||
);
|
||||
|
||||
const openPluginsFolder = useCallback(
|
||||
async (pluginId?: string) => {
|
||||
try {
|
||||
await plugin.openPluginsFolder(pluginId);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
},
|
||||
[plugin],
|
||||
);
|
||||
|
||||
const pickArchiveFile = useCallback(() => system.pickArchiveFile(), [system]);
|
||||
const pickDirectory = useCallback(() => system.pickFolder(), [system]);
|
||||
|
||||
return {
|
||||
plugins,
|
||||
error,
|
||||
busy,
|
||||
refresh,
|
||||
reviewArchive,
|
||||
reviewDirectory,
|
||||
installFromArchive,
|
||||
installFromDirectory,
|
||||
setEnabled,
|
||||
uninstall,
|
||||
openPluginsFolder,
|
||||
pickArchiveFile,
|
||||
pickDirectory,
|
||||
};
|
||||
}
|
||||
@ -45,6 +45,8 @@ import {
|
||||
type SettingsSection,
|
||||
} from "@/features/settings";
|
||||
import { GitGraphView } from "@/features/git";
|
||||
import { usePluginMenus, usePluginRuntime } from "@/features/plugins";
|
||||
import type { WhenContext } from "@/plugins/runtime";
|
||||
import {
|
||||
Button,
|
||||
DockRegion,
|
||||
@ -112,7 +114,7 @@ function isTerminalBackgroundTaskEvent(
|
||||
|
||||
export function ProjectsView() {
|
||||
const vm = useProjects();
|
||||
const { system, window: windowGateway, focusedProject } = useGateways();
|
||||
const { system, window: windowGateway, focusedProject, git } = useGateways();
|
||||
const [name, setName] = useState("");
|
||||
const [root, setRoot] = useState("");
|
||||
// Placement of every open view (#22): each panel is "closed" (absent),
|
||||
@ -144,6 +146,45 @@ export function ProjectsView() {
|
||||
|
||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||
|
||||
// `gitRepository` signal for the plugin `when` mini-language (#43, F3 —
|
||||
// Architect-mandated, blocking for #43 closure): whether the *current*
|
||||
// project is a git repository, checked for real via `GitGateway.branches`
|
||||
// (a non-repo project rejects this call; a repo resolves it). Re-checked on
|
||||
// every project switch. `agentSelected`/`terminalFocused`/
|
||||
// `layoutCellFocused` remain accepted v1 debt — no global focus signal
|
||||
// exists at the menu-bar level yet — and stay `false` (see the F3 report's
|
||||
// open point).
|
||||
const [gitRepository, setGitRepository] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setGitRepository(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
git
|
||||
.branches(active.id)
|
||||
.then(() => {
|
||||
if (!cancelled) setGitRepository(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGitRepository(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [git, active?.id]);
|
||||
|
||||
// Plugin contribution menus (#43, F3).
|
||||
const { registry: pluginRegistry } = usePluginRuntime();
|
||||
const pluginWhenCtx: WhenContext = {
|
||||
projectOpen: active !== null,
|
||||
gitRepository,
|
||||
agentSelected: false,
|
||||
terminalFocused: false,
|
||||
layoutCellFocused: false,
|
||||
};
|
||||
const pluginMenus = usePluginMenus(pluginRegistry, pluginWhenCtx);
|
||||
|
||||
// Reset the active layout whenever the active project changes. `activeLayout`
|
||||
// is only repopulated asynchronously by `LayoutTabs` (which re-fetches the new
|
||||
// project's layouts). Without this reset, the stale id of the *previous*
|
||||
@ -391,29 +432,40 @@ export function ProjectsView() {
|
||||
{
|
||||
id: "panels",
|
||||
label: "Panneaux",
|
||||
items: panelOrder.map((panel) => ({
|
||||
id: panel,
|
||||
label: PANEL_TITLE[panel],
|
||||
active: placementOf(placements, panel) !== "closed",
|
||||
onSelect: () => {},
|
||||
submenu: placementSubmenu(panel),
|
||||
})),
|
||||
// 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"),
|
||||
],
|
||||
},
|
||||
// Top-level plugin menus render between Panneaux and Paramètres (#43,
|
||||
// carnet §0 UX decision + §7.1).
|
||||
...pluginMenus.topLevelMenus,
|
||||
{
|
||||
id: "settings",
|
||||
label: "Paramètres",
|
||||
// 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.
|
||||
items: SETTINGS_SECTIONS.map((section) => ({
|
||||
id: section,
|
||||
label: SETTINGS_SECTION_LABEL[section],
|
||||
active: settingsSection === section,
|
||||
onSelect: () => {
|
||||
setSettingsSection(section);
|
||||
dismissFloating();
|
||||
},
|
||||
})),
|
||||
// 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"),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -691,6 +743,10 @@ export function ProjectsView() {
|
||||
cwd={active.root}
|
||||
layoutId={activeLayout?.id}
|
||||
onOpenConversation={openConversation}
|
||||
onOpenPluginsSettings={() => {
|
||||
setSettingsSection("plugins");
|
||||
dismissFloating();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
169
frontend/src/features/projects/pluginGitRepository.test.tsx
Normal file
169
frontend/src/features/projects/pluginGitRepository.test.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* #43, F3 — `ProjectsView` wires the `gitRepository` `when` signal to a real
|
||||
* check (`GitGateway.branches`), not a hardcoded `false` (Architect-mandated,
|
||||
* blocking for #43 closure). This exercises a plugin menu item declared
|
||||
* `when: "projectOpen && gitRepository"` end-to-end: disabled for a project
|
||||
* whose git check fails (not a repo), enabled for one whose check succeeds.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockDesktopServerGateway,
|
||||
MockProfileGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
MockWindowGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { GatewayError, GitBranches, GitCommit, GitFileStatus, GraphCommit } from "@/domain";
|
||||
import type { Gateways, GitGateway } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type LoadedPlugin,
|
||||
} from "@/plugins/runtime";
|
||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
/** Minimal `GitGateway` double whose `branches()` always rejects — "not a repo". */
|
||||
class NonRepoGitGateway implements GitGateway {
|
||||
status(): Promise<GitFileStatus[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
stage(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
unstage(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
commit(): Promise<GitCommit> {
|
||||
return Promise.reject({ code: "NOT_IMPLEMENTED", message: "n/a" } satisfies GatewayError);
|
||||
}
|
||||
branches(): Promise<GitBranches> {
|
||||
const err: GatewayError = { code: "NOT_A_REPOSITORY", message: "not a git repository" };
|
||||
return Promise.reject(err);
|
||||
}
|
||||
checkout(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
log(): Promise<GitCommit[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
init(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
graph(): Promise<GraphCommit[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal `GitGateway` double whose `branches()` always resolves — a real repo. */
|
||||
class RealRepoGitGateway extends NonRepoGitGateway {
|
||||
override branches(): Promise<GitBranches> {
|
||||
return Promise.resolve({ branches: ["main"], current: "main" });
|
||||
}
|
||||
}
|
||||
|
||||
function gitAwarePlugin(): LoadedPlugin {
|
||||
const contributes = {
|
||||
menus: [],
|
||||
menuItems: [
|
||||
{
|
||||
id: "dev.acme.gitgraph.open",
|
||||
targetMenuId: "panels" as const,
|
||||
label: "Ouvrir le graphe Git",
|
||||
command: "dev.acme.gitgraph.open",
|
||||
when: "projectOpen && gitRepository",
|
||||
},
|
||||
],
|
||||
layouts: [],
|
||||
mcpServers: [],
|
||||
};
|
||||
return {
|
||||
pluginId: "dev.acme.gitgraph",
|
||||
displayName: "Git Graph",
|
||||
contributes,
|
||||
commands: new PluginCommandRegistry(
|
||||
"dev.acme.gitgraph",
|
||||
new Set(["dev.acme.gitgraph.open"]),
|
||||
),
|
||||
layouts: new PluginLayoutRegistry("dev.acme.gitgraph", new Set()),
|
||||
menu: new PluginMenuRegistry("dev.acme.gitgraph"),
|
||||
dispose: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function renderWithPlugin(git: GitGateway) {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const gateways = {
|
||||
system: new MockSystemGateway(),
|
||||
project: new MockProjectGateway(),
|
||||
agent: agentGateway,
|
||||
profile: new MockProfileGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
git,
|
||||
workState: new MockWorkStateGateway(),
|
||||
window: new MockWindowGateway(),
|
||||
desktopServer: new MockDesktopServerGateway(),
|
||||
} as unknown as Gateways;
|
||||
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(gitAwarePlugin());
|
||||
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
||||
<ProjectsView />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForIdle() {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement).disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
}
|
||||
|
||||
async function createProject(name: string, root: string) {
|
||||
await waitForIdle();
|
||||
fireEvent.change(screen.getByLabelText("project name"), { target: { value: name } });
|
||||
fireEvent.change(screen.getByLabelText("project root"), { target: { value: root } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create project" }));
|
||||
}
|
||||
|
||||
function panelsMenuItem(): HTMLButtonElement {
|
||||
return screen.getByRole("button", { name: /Ouvrir le graphe Git/ }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("ProjectsView — plugin `gitRepository` when-context wiring", () => {
|
||||
it("disables the item when the project's git check fails (not a repo)", async () => {
|
||||
renderWithPlugin(new NonRepoGitGateway());
|
||||
await createProject("alpha", "/home/me/non-repo");
|
||||
await screen.findByRole("tab");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||
await waitFor(() => {
|
||||
expect(panelsMenuItem().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables the item once the project's git check succeeds (real repo)", async () => {
|
||||
renderWithPlugin(new RealRepoGitGateway());
|
||||
await createProject("beta", "/home/me/real-repo");
|
||||
await screen.findByRole("tab");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||
await waitFor(() => {
|
||||
expect(panelsMenuItem().disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -24,16 +24,18 @@
|
||||
import { Button, cn } from "@/shared";
|
||||
import { ProfilesSettings } from "@/features/first-run";
|
||||
import { DevicesScreen } from "@/features/devices";
|
||||
import { PluginsPanel } from "@/features/plugins";
|
||||
import { DeploymentSettings } from "./DeploymentSettings";
|
||||
|
||||
/** The Settings sections, in menu/nav order. */
|
||||
export type SettingsSection = "aiProfiles" | "deployment" | "devices";
|
||||
export type SettingsSection = "aiProfiles" | "deployment" | "devices" | "plugins";
|
||||
|
||||
/** Human labels, shared by the nav column and the `Settings` menu (ticket #78 — French). */
|
||||
export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
|
||||
aiProfiles: "Profils IA",
|
||||
deployment: "Déploiement",
|
||||
devices: "Appareils",
|
||||
plugins: "Plugins",
|
||||
};
|
||||
|
||||
/** Section order — the single source of truth for both nav and menu. */
|
||||
@ -41,6 +43,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
"aiProfiles",
|
||||
"deployment",
|
||||
"devices",
|
||||
"plugins",
|
||||
];
|
||||
|
||||
interface SettingsViewProps {
|
||||
@ -93,6 +96,8 @@ export function SettingsView({
|
||||
<ProfilesSettings />
|
||||
) : section === "deployment" ? (
|
||||
<DeploymentSettings />
|
||||
) : section === "plugins" ? (
|
||||
<PluginsPanel />
|
||||
) : (
|
||||
// No `onSessionEnded`: the desktop app hosts the server and is never
|
||||
// itself a paired device, so it cannot revoke its own session.
|
||||
|
||||
21
frontend/src/plugins/runtime/index.ts
Normal file
21
frontend/src/plugins/runtime/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type Disposable,
|
||||
type LoadedPlugin,
|
||||
type PluginCommandHandler,
|
||||
type PluginGatewaySet,
|
||||
type PluginLayoutDefinition,
|
||||
type PluginLayoutProps,
|
||||
} from "./registry";
|
||||
export {
|
||||
loadPlugins,
|
||||
type IdeaPluginContext,
|
||||
type IdeaPluginModule,
|
||||
type PluginActivation,
|
||||
type PluginLoadFailure,
|
||||
type PluginLoadResult,
|
||||
} from "./loader";
|
||||
export { evaluateWhen, type WhenContext, type WhenEvalResult, type WhenVariable } from "./when";
|
||||
160
frontend/src/plugins/runtime/loader.test.ts
Normal file
160
frontend/src/plugins/runtime/loader.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* F1 — plugin bootstrap loader tests (ticket #43, carnet §6/§10 F1 acceptance
|
||||
* criteria: "plugins mock chargés, registration refusée si non déclarée,
|
||||
* dispose appelé, disabled absent du bootstrap").
|
||||
*
|
||||
* Bundles are loaded via a real dynamic `import()` of `data:` URLs (supported
|
||||
* by Node's ESM loader, which Vitest runs on) so the loader is exercised
|
||||
* exactly as it runs against the `idea-plugin://...` protocol in production —
|
||||
* no mocking of `import()` itself.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
||||
import { loadPlugins } from "./loader";
|
||||
import type { PluginGatewaySet } from "./loader";
|
||||
|
||||
function dataUrl(source: string): string {
|
||||
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
}
|
||||
|
||||
function emptyContributes(): PluginContributionDto {
|
||||
return { menus: [], menuItems: [], layouts: [], mcpServers: [] };
|
||||
}
|
||||
|
||||
const gateways = {} as PluginGatewaySet;
|
||||
|
||||
function entry(overrides: Partial<PluginRuntimePlugin> & { bundleUrl: string }): PluginRuntimePlugin {
|
||||
return {
|
||||
id: "mock.plugin",
|
||||
displayName: "Mock Plugin",
|
||||
version: "1.0.0",
|
||||
contentHash: "abc123",
|
||||
contributes: emptyContributes(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("loadPlugins", () => {
|
||||
it("loads a well-formed plugin bundle and calls activate(ctx)", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
globalThis.__activatedWith = ctx.pluginId;
|
||||
return {};
|
||||
}
|
||||
`);
|
||||
const { registry, failures } = await loadPlugins(
|
||||
[entry({ id: "dev.acme.one", displayName: "One", bundleUrl: bundle })],
|
||||
gateways,
|
||||
);
|
||||
expect(failures).toEqual([]);
|
||||
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.one"]);
|
||||
expect((globalThis as Record<string, unknown>).__activatedWith).toBe("dev.acme.one");
|
||||
});
|
||||
|
||||
it("refuses to register a command not declared in the manifest", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
let caught = null;
|
||||
try {
|
||||
ctx.commands.register("dev.acme.undeclared.cmd", () => {});
|
||||
} catch (e) {
|
||||
caught = String(e);
|
||||
}
|
||||
globalThis.__registerError = caught;
|
||||
return {};
|
||||
}
|
||||
`);
|
||||
const { failures } = await loadPlugins(
|
||||
[
|
||||
entry({
|
||||
id: "dev.acme.two",
|
||||
displayName: "Two",
|
||||
bundleUrl: bundle,
|
||||
contributes: emptyContributes(),
|
||||
}),
|
||||
],
|
||||
gateways,
|
||||
);
|
||||
expect(failures).toEqual([]);
|
||||
expect((globalThis as Record<string, unknown>).__registerError).toMatch(
|
||||
/not declared by any menu item/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts registering a command declared via a menu item contribution", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
ctx.commands.register("dev.acme.three.open", () => {
|
||||
globalThis.__ranCommand = true;
|
||||
});
|
||||
return {};
|
||||
}
|
||||
`);
|
||||
const { registry, failures } = await loadPlugins(
|
||||
[
|
||||
entry({
|
||||
id: "dev.acme.three",
|
||||
displayName: "Three",
|
||||
bundleUrl: bundle,
|
||||
contributes: {
|
||||
...emptyContributes(),
|
||||
menuItems: [
|
||||
{
|
||||
id: "dev.acme.three.item",
|
||||
targetMenuId: "panels",
|
||||
label: "Open",
|
||||
command: "dev.acme.three.open",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
gateways,
|
||||
);
|
||||
expect(failures).toEqual([]);
|
||||
await registry.runCommand("dev.acme.three", "dev.acme.three.open");
|
||||
expect((globalThis as Record<string, unknown>).__ranCommand).toBe(true);
|
||||
});
|
||||
|
||||
it("calls dispose() on removal (best-effort)", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
return {
|
||||
dispose: () => {
|
||||
globalThis.__disposed = ctx.pluginId;
|
||||
},
|
||||
};
|
||||
}
|
||||
`);
|
||||
const { registry } = await loadPlugins(
|
||||
[entry({ id: "dev.acme.four", displayName: "Four", bundleUrl: bundle })],
|
||||
gateways,
|
||||
);
|
||||
await registry.remove("dev.acme.four");
|
||||
expect((globalThis as Record<string, unknown>).__disposed).toBe("dev.acme.four");
|
||||
expect(registry.get("dev.acme.four")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects a failure instead of throwing when a bundle has no activate()", async () => {
|
||||
const bundle = dataUrl(`export const notAPlugin = true;`);
|
||||
const { registry, failures } = await loadPlugins(
|
||||
[entry({ id: "dev.acme.five", displayName: "Five", bundleUrl: bundle })],
|
||||
gateways,
|
||||
);
|
||||
expect(registry.list()).toEqual([]);
|
||||
expect(failures).toEqual([
|
||||
{ pluginId: "dev.acme.five", reason: expect.stringContaining("activate") },
|
||||
]);
|
||||
});
|
||||
|
||||
it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => {
|
||||
// The backend contract (carnet §1.3) filters the catalog to
|
||||
// `enabled && !pendingUninstall` before the loader ever sees it; the
|
||||
// loader itself has nothing more to filter — an empty catalog loads
|
||||
// nothing.
|
||||
const { registry, failures } = await loadPlugins([], gateways);
|
||||
expect(registry.list()).toEqual([]);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
150
frontend/src/plugins/runtime/loader.ts
Normal file
150
frontend/src/plugins/runtime/loader.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6).
|
||||
*
|
||||
* At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
|
||||
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
|
||||
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the stable
|
||||
* gateways the plugin context exposes. For each entry it dynamically imports
|
||||
* the bundle URL, validates the module shape, and calls `activate(ctx)`,
|
||||
* scoping the command/layout registries to exactly the ids declared in that
|
||||
* plugin's manifest (enforced by {@link PluginCommandRegistry}/
|
||||
* {@link PluginLayoutRegistry} themselves).
|
||||
*
|
||||
* A single plugin failing to load/activate must not break the rest of the
|
||||
* app or the other plugins — each is loaded independently and failures are
|
||||
* collected, never thrown past `loadPlugins`.
|
||||
*/
|
||||
|
||||
import type { PluginRuntimePlugin } from "@/domain";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type LoadedPlugin,
|
||||
type PluginGatewaySet,
|
||||
} from "./registry";
|
||||
|
||||
export type { PluginGatewaySet } from "./registry";
|
||||
|
||||
export interface IdeaPluginContext extends PluginGatewaySet {
|
||||
pluginId: string;
|
||||
pluginDisplayName: string;
|
||||
version: string;
|
||||
commands: PluginCommandRegistry;
|
||||
layouts: PluginLayoutRegistry;
|
||||
menu: PluginMenuRegistry;
|
||||
}
|
||||
|
||||
export interface PluginActivation {
|
||||
dispose?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface IdeaPluginModule {
|
||||
activate(ctx: IdeaPluginContext): PluginActivation | Promise<PluginActivation>;
|
||||
}
|
||||
|
||||
export interface PluginLoadFailure {
|
||||
pluginId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PluginLoadResult {
|
||||
registry: PluginRuntimeRegistry;
|
||||
failures: PluginLoadFailure[];
|
||||
}
|
||||
|
||||
function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
||||
return (
|
||||
typeof mod === "object" &&
|
||||
mod !== null &&
|
||||
"activate" in mod &&
|
||||
typeof (mod as { activate: unknown }).activate === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOne(
|
||||
entry: PluginRuntimePlugin,
|
||||
gateways: PluginGatewaySet,
|
||||
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
|
||||
try {
|
||||
// The bundle URL is a plugin-scoped, content-hashed local protocol URL
|
||||
// served by the backend (carnet §1.3) — never a disk path or arbitrary
|
||||
// remote URL, and the content hash busts the module cache after updates.
|
||||
const mod: unknown = await import(/* @vite-ignore */ entry.bundleUrl);
|
||||
if (!isIdeaPluginModule(mod)) {
|
||||
return {
|
||||
failure: {
|
||||
pluginId: entry.id,
|
||||
reason: `bundle does not export an "activate(ctx)" function`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const declaredCommandIds = new Set(entry.contributes.menuItems.map((item) => item.command));
|
||||
const declaredLayoutTypes = new Set(entry.contributes.layouts.map((layout) => layout.type));
|
||||
const commands = new PluginCommandRegistry(entry.id, declaredCommandIds);
|
||||
const layouts = new PluginLayoutRegistry(entry.id, declaredLayoutTypes);
|
||||
const menu = new PluginMenuRegistry(entry.id);
|
||||
|
||||
const ctx: IdeaPluginContext = {
|
||||
pluginId: entry.id,
|
||||
pluginDisplayName: entry.displayName,
|
||||
version: entry.version,
|
||||
commands,
|
||||
layouts,
|
||||
menu,
|
||||
...gateways,
|
||||
};
|
||||
|
||||
const activation = await mod.activate(ctx);
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
pluginId: entry.id,
|
||||
displayName: entry.displayName,
|
||||
contributes: entry.contributes,
|
||||
commands,
|
||||
layouts,
|
||||
menu,
|
||||
dispose: async () => {
|
||||
// Best-effort, full-trust (carnet §1.3) — a broken `dispose()` must not
|
||||
// prevent removing the plugin from the runtime registry.
|
||||
try {
|
||||
await activation.dispose?.();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
};
|
||||
return { plugin };
|
||||
} catch (e) {
|
||||
return {
|
||||
failure: {
|
||||
pluginId: entry.id,
|
||||
reason: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
|
||||
* Called once at app bootstrap (and never again in the same session — carnet
|
||||
* §1.3: disable/uninstall only masks contributions in-session, the actual
|
||||
* unload happens on next restart via a fresh `loadPlugins` call).
|
||||
*/
|
||||
export async function loadPlugins(
|
||||
catalogPlugins: PluginRuntimePlugin[],
|
||||
gateways: PluginGatewaySet,
|
||||
): Promise<PluginLoadResult> {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
const failures: PluginLoadFailure[] = [];
|
||||
|
||||
const results = await Promise.all(catalogPlugins.map((entry) => loadOne(entry, gateways)));
|
||||
for (const result of results) {
|
||||
if ("failure" in result) failures.push(result.failure);
|
||||
else registry.add(result.plugin);
|
||||
}
|
||||
|
||||
return { registry, failures };
|
||||
}
|
||||
228
frontend/src/plugins/runtime/registry.ts
Normal file
228
frontend/src/plugins/runtime/registry.ts
Normal file
@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Plugin runtime registries (ticket #43, F1, carnet §6/§7) — the in-memory,
|
||||
* per-session home for whatever a loaded plugin bundle registers via its
|
||||
* `activate(ctx)` call. Rebuilt from scratch on every app boot (carnet §1.3):
|
||||
* nothing here is persisted, the domain only persists declarative contribution
|
||||
* metadata and (for layouts) opaque cell state.
|
||||
*
|
||||
* Each registry enforces the "declared ids only" rule (carnet §6): a plugin can
|
||||
* only register a command/menu item/layout whose id was declared in its own
|
||||
* manifest `contributes`. This is the runtime half of that contract; the other
|
||||
* half (manifest validation) lives in the backend.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type {
|
||||
PluginContributionDto,
|
||||
PluginLayoutContribution,
|
||||
PluginMenuItemContribution,
|
||||
PluginTopLevelMenuContribution,
|
||||
} from "@/domain";
|
||||
import type { AgentGateway, GitGateway, ProjectGateway, SystemGateway, TerminalGateway } from "@/ports";
|
||||
|
||||
/** The stable gateways a plugin's `activate(ctx)` is allowed to reach (carnet §6). */
|
||||
export interface PluginGatewaySet {
|
||||
project: ProjectGateway;
|
||||
git: GitGateway;
|
||||
terminal: TerminalGateway;
|
||||
agents: AgentGateway;
|
||||
system: SystemGateway;
|
||||
}
|
||||
|
||||
/** A disposable handle returned by every `register*` call. */
|
||||
export interface Disposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type PluginCommandHandler = (...args: unknown[]) => void | Promise<void>;
|
||||
|
||||
/** Commands a plugin registers in `activate(ctx)`, dispatched by menu items. */
|
||||
export class PluginCommandRegistry {
|
||||
private handlers = new Map<string, PluginCommandHandler>();
|
||||
|
||||
constructor(
|
||||
private readonly pluginId: string,
|
||||
private readonly declaredCommandIds: ReadonlySet<string>,
|
||||
) {}
|
||||
|
||||
register(commandId: string, handler: PluginCommandHandler): Disposable {
|
||||
if (!this.declaredCommandIds.has(commandId)) {
|
||||
throw new Error(
|
||||
`plugin "${this.pluginId}" tried to register command "${commandId}" ` +
|
||||
"which is not declared by any menu item in its manifest",
|
||||
);
|
||||
}
|
||||
this.handlers.set(commandId, handler);
|
||||
return {
|
||||
dispose: () => {
|
||||
if (this.handlers.get(commandId) === handler) this.handlers.delete(commandId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs a registered command; a no-op (never throws) if none is registered. */
|
||||
async run(commandId: string, ...args: unknown[]): Promise<void> {
|
||||
const handler = this.handlers.get(commandId);
|
||||
if (!handler) return;
|
||||
await handler(...args);
|
||||
}
|
||||
|
||||
has(commandId: string): boolean {
|
||||
return this.handlers.has(commandId);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginLayoutProps {
|
||||
projectId: string;
|
||||
nodeId: string;
|
||||
layoutType: string;
|
||||
state: unknown;
|
||||
setState(next: unknown): void;
|
||||
availability: "available";
|
||||
gateways: PluginGatewaySet;
|
||||
}
|
||||
|
||||
export interface PluginLayoutDefinition {
|
||||
type: string;
|
||||
component: ComponentType<PluginLayoutProps>;
|
||||
}
|
||||
|
||||
/** Custom React layout components a plugin registers, keyed by declared `type`. */
|
||||
export class PluginLayoutRegistry {
|
||||
private components = new Map<string, ComponentType<PluginLayoutProps>>();
|
||||
|
||||
constructor(
|
||||
private readonly pluginId: string,
|
||||
private readonly declaredLayoutTypes: ReadonlySet<string>,
|
||||
) {}
|
||||
|
||||
register(def: PluginLayoutDefinition): Disposable {
|
||||
if (!this.declaredLayoutTypes.has(def.type)) {
|
||||
throw new Error(
|
||||
`plugin "${this.pluginId}" tried to register layout type "${def.type}" ` +
|
||||
"which is not declared in its manifest",
|
||||
);
|
||||
}
|
||||
this.components.set(def.type, def.component);
|
||||
return {
|
||||
dispose: () => {
|
||||
if (this.components.get(def.type) === def.component) {
|
||||
this.components.delete(def.type);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get(layoutType: string): ComponentType<PluginLayoutProps> | undefined {
|
||||
return this.components.get(layoutType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu registration surface handed to `activate(ctx)`. V1 scope: a plugin
|
||||
* declares its menus/items in the manifest (carnet §7.1/§7.2); nothing dynamic
|
||||
* is registered here at runtime beyond commands, so this is intentionally a
|
||||
* thin marker object today — kept as its own class so a future dynamic-menu
|
||||
* capability doesn't change the `IdeaPluginContext` shape.
|
||||
*/
|
||||
export class PluginMenuRegistry {
|
||||
constructor(private readonly pluginId: string) {}
|
||||
|
||||
/** Present for parity with the carnet's `ctx.menu` — no dynamic ops in V1. */
|
||||
get ownerPluginId(): string {
|
||||
return this.pluginId;
|
||||
}
|
||||
}
|
||||
|
||||
/** One loaded plugin's registries + the manifest contribution it was scoped to. */
|
||||
export interface LoadedPlugin {
|
||||
pluginId: string;
|
||||
displayName: string;
|
||||
contributes: PluginContributionDto;
|
||||
commands: PluginCommandRegistry;
|
||||
layouts: PluginLayoutRegistry;
|
||||
menu: PluginMenuRegistry;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate, session-scoped registry every loaded plugin's contributions land
|
||||
* in. `PluginRuntimeRegistry` itself never imports bundles (see `loader.ts`);
|
||||
* it just holds what has already been loaded and offers lookup/removal.
|
||||
*/
|
||||
export class PluginRuntimeRegistry {
|
||||
private loaded = new Map<string, LoadedPlugin>();
|
||||
|
||||
add(plugin: LoadedPlugin): void {
|
||||
this.loaded.set(plugin.pluginId, plugin);
|
||||
}
|
||||
|
||||
/** Best-effort: calls `dispose()` then removes the plugin from the registry. */
|
||||
async remove(pluginId: string): Promise<void> {
|
||||
const plugin = this.loaded.get(pluginId);
|
||||
if (!plugin) return;
|
||||
try {
|
||||
await plugin.dispose();
|
||||
} finally {
|
||||
this.loaded.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
get(pluginId: string): LoadedPlugin | undefined {
|
||||
return this.loaded.get(pluginId);
|
||||
}
|
||||
|
||||
list(): LoadedPlugin[] {
|
||||
return [...this.loaded.values()];
|
||||
}
|
||||
|
||||
/** All top-level menu contributions across every loaded plugin. */
|
||||
topLevelMenus(): Array<{ pluginId: string; pluginDisplayName: string; menu: PluginTopLevelMenuContribution }> {
|
||||
return this.list().flatMap((p) =>
|
||||
p.contributes.menus.map((menu) => ({
|
||||
pluginId: p.pluginId,
|
||||
pluginDisplayName: p.displayName,
|
||||
menu,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** All menu-item contributions across every loaded plugin. */
|
||||
menuItems(): Array<{ pluginId: string; pluginDisplayName: string; item: PluginMenuItemContribution }> {
|
||||
return this.list().flatMap((p) =>
|
||||
p.contributes.menuItems.map((item) => ({
|
||||
pluginId: p.pluginId,
|
||||
pluginDisplayName: p.displayName,
|
||||
item,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Layout component for a given `(pluginId, layoutType)` pair, if loaded. */
|
||||
layoutComponent(
|
||||
pluginId: string,
|
||||
layoutType: string,
|
||||
): ComponentType<PluginLayoutProps> | undefined {
|
||||
return this.loaded.get(pluginId)?.layouts.get(layoutType);
|
||||
}
|
||||
|
||||
/** All layout contributions across every loaded plugin (for the layout selector). */
|
||||
layoutContributions(): Array<{
|
||||
pluginId: string;
|
||||
pluginDisplayName: string;
|
||||
layout: PluginLayoutContribution;
|
||||
}> {
|
||||
return this.list().flatMap((p) =>
|
||||
p.contributes.layouts.map((layout) => ({
|
||||
pluginId: p.pluginId,
|
||||
pluginDisplayName: p.displayName,
|
||||
layout,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise<void> {
|
||||
await this.loaded.get(pluginId)?.commands.run(commandId, ...args);
|
||||
}
|
||||
}
|
||||
44
frontend/src/plugins/runtime/when.test.ts
Normal file
44
frontend/src/plugins/runtime/when.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { evaluateWhen, type WhenContext } from "./when";
|
||||
|
||||
const allFalse: WhenContext = {
|
||||
projectOpen: false,
|
||||
gitRepository: false,
|
||||
agentSelected: false,
|
||||
terminalFocused: false,
|
||||
layoutCellFocused: false,
|
||||
};
|
||||
|
||||
describe("evaluateWhen", () => {
|
||||
it("treats an absent expression as always true", () => {
|
||||
expect(evaluateWhen(undefined, allFalse)).toEqual({ ok: true, value: true });
|
||||
});
|
||||
|
||||
it("reads a single variable", () => {
|
||||
expect(evaluateWhen("projectOpen", { ...allFalse, projectOpen: true })).toEqual({
|
||||
ok: true,
|
||||
value: true,
|
||||
});
|
||||
expect(evaluateWhen("projectOpen", allFalse)).toEqual({ ok: true, value: false });
|
||||
});
|
||||
|
||||
it("evaluates &&, ||, ! and parentheses with correct precedence", () => {
|
||||
const ctx: WhenContext = { ...allFalse, projectOpen: true, gitRepository: false };
|
||||
expect(evaluateWhen("projectOpen && gitRepository", ctx)).toEqual({ ok: true, value: false });
|
||||
expect(evaluateWhen("projectOpen || gitRepository", ctx)).toEqual({ ok: true, value: true });
|
||||
expect(evaluateWhen("!gitRepository && projectOpen", ctx)).toEqual({ ok: true, value: true });
|
||||
expect(evaluateWhen("!(projectOpen && gitRepository)", ctx)).toEqual({ ok: true, value: true });
|
||||
});
|
||||
|
||||
it("disables (does not crash) on an unknown variable", () => {
|
||||
const result = evaluateWhen("somethingUnknown", allFalse);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("disables (does not crash) on malformed syntax", () => {
|
||||
expect(evaluateWhen("projectOpen &&", allFalse).ok).toBe(false);
|
||||
expect(evaluateWhen("(projectOpen", allFalse).ok).toBe(false);
|
||||
expect(evaluateWhen("projectOpen $ gitRepository", allFalse).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
158
frontend/src/plugins/runtime/when.ts
Normal file
158
frontend/src/plugins/runtime/when.ts
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* `when` mini-language evaluator (ticket #43, carnet §7.2) — deliberately
|
||||
* limited, evaluated by IdeA itself (never handed to the plugin as code):
|
||||
* boolean variables (`projectOpen`, `gitRepository`, `agentSelected`,
|
||||
* `terminalFocused`, `layoutCellFocused`), `&&`/`||`/`!`, and parentheses.
|
||||
*
|
||||
* An invalid expression never throws past this module: the contribution using
|
||||
* it is disabled with a diagnostic reason instead (carnet: "Toute expression
|
||||
* invalide => contribution disabled + raison diagnostic, pas crash").
|
||||
*/
|
||||
|
||||
export type WhenVariable =
|
||||
| "projectOpen"
|
||||
| "gitRepository"
|
||||
| "agentSelected"
|
||||
| "terminalFocused"
|
||||
| "layoutCellFocused";
|
||||
|
||||
const VARIABLES: ReadonlySet<string> = new Set([
|
||||
"projectOpen",
|
||||
"gitRepository",
|
||||
"agentSelected",
|
||||
"terminalFocused",
|
||||
"layoutCellFocused",
|
||||
]);
|
||||
|
||||
export type WhenContext = Record<WhenVariable, boolean>;
|
||||
|
||||
export type WhenEvalResult =
|
||||
| { ok: true; value: boolean }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
type Token =
|
||||
| { kind: "var"; name: string }
|
||||
| { kind: "and" | "or" | "not" | "lparen" | "rparen" };
|
||||
|
||||
function tokenize(expr: string): Token[] | null {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < expr.length) {
|
||||
const ch = expr[i];
|
||||
if (ch === " " || ch === "\t") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(") {
|
||||
tokens.push({ kind: "lparen" });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ")") {
|
||||
tokens.push({ kind: "rparen" });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "!") {
|
||||
tokens.push({ kind: "not" });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (expr.startsWith("&&", i)) {
|
||||
tokens.push({ kind: "and" });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (expr.startsWith("||", i)) {
|
||||
tokens.push({ kind: "or" });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const match = /^[A-Za-z][A-Za-z0-9]*/.exec(expr.slice(i));
|
||||
if (match) {
|
||||
tokens.push({ kind: "var", name: match[0] });
|
||||
i += match[0].length;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Recursive-descent parser/evaluator, precedence: `!` > `&&` > `||`. */
|
||||
class Parser {
|
||||
private pos = 0;
|
||||
constructor(private readonly tokens: Token[]) {}
|
||||
|
||||
private peek(): Token | undefined {
|
||||
return this.tokens[this.pos];
|
||||
}
|
||||
|
||||
private next(): Token | undefined {
|
||||
return this.tokens[this.pos++];
|
||||
}
|
||||
|
||||
parseOr(ctx: WhenContext): boolean {
|
||||
let value = this.parseAnd(ctx);
|
||||
while (this.peek()?.kind === "or") {
|
||||
this.next();
|
||||
const rhs = this.parseAnd(ctx);
|
||||
value = value || rhs;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseAnd(ctx: WhenContext): boolean {
|
||||
let value = this.parseUnary(ctx);
|
||||
while (this.peek()?.kind === "and") {
|
||||
this.next();
|
||||
const rhs = this.parseUnary(ctx);
|
||||
value = value && rhs;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseUnary(ctx: WhenContext): boolean {
|
||||
if (this.peek()?.kind === "not") {
|
||||
this.next();
|
||||
return !this.parseUnary(ctx);
|
||||
}
|
||||
return this.parseAtom(ctx);
|
||||
}
|
||||
|
||||
private parseAtom(ctx: WhenContext): boolean {
|
||||
const tok = this.next();
|
||||
if (!tok) throw new Error("unexpected end of expression");
|
||||
if (tok.kind === "lparen") {
|
||||
const value = this.parseOr(ctx);
|
||||
const close = this.next();
|
||||
if (close?.kind !== "rparen") throw new Error("expected \")\"");
|
||||
return value;
|
||||
}
|
||||
if (tok.kind === "var") {
|
||||
if (!VARIABLES.has(tok.name)) throw new Error(`unknown variable "${tok.name}"`);
|
||||
return ctx[tok.name as WhenVariable];
|
||||
}
|
||||
throw new Error("expected a variable, \"!\" or \"(\"");
|
||||
}
|
||||
|
||||
finished(): boolean {
|
||||
return this.pos >= this.tokens.length;
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluates a `when` expression; `undefined` means "no condition" ⇒ always true. */
|
||||
export function evaluateWhen(expr: string | undefined, ctx: WhenContext): WhenEvalResult {
|
||||
if (expr === undefined || expr.trim() === "") return { ok: true, value: true };
|
||||
const tokens = tokenize(expr);
|
||||
if (!tokens) return { ok: false, reason: `invalid "when" expression: ${expr}` };
|
||||
try {
|
||||
const parser = new Parser(tokens);
|
||||
const value = parser.parseOr(ctx);
|
||||
if (!parser.finished()) throw new Error("unexpected trailing tokens");
|
||||
return { ok: true, value };
|
||||
} catch (e) {
|
||||
const reason = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, reason: `invalid "when" expression "${expr}": ${reason}` };
|
||||
}
|
||||
}
|
||||
@ -39,6 +39,12 @@ import type {
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
PermissionSet,
|
||||
PluginAdmin,
|
||||
PluginInstallResult,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginSourceKind,
|
||||
PluginUninstallResult,
|
||||
ProjectMcpToolPermissions,
|
||||
PageDirection,
|
||||
Project,
|
||||
@ -77,6 +83,12 @@ export interface SystemGateway {
|
||||
* sites go through this port; the Tauri plugin is only imported in the adapter.
|
||||
*/
|
||||
pickFolder(): Promise<string | null>;
|
||||
/**
|
||||
* Opens a native file picker for a single local archive (plugin install from
|
||||
* archive, carnet §1.4/§9) and returns the chosen path, or `null` if the user
|
||||
* cancelled. Same sanctioned-picker rule as {@link pickFolder}.
|
||||
*/
|
||||
pickArchiveFile(): Promise<string | null>;
|
||||
/**
|
||||
* Subscribes to the app-exit work-in-progress guard (ticket #83): fired when
|
||||
* closing the main window is intercepted because it would interrupt active
|
||||
@ -1175,6 +1187,28 @@ export interface UiPreferencesGateway {
|
||||
* The full set of gateways the app depends on, injected via the DI provider.
|
||||
* The composition (real vs mock) is chosen in `app/`.
|
||||
*/
|
||||
/** Input to `reviewPackage` — a candidate package not yet committed to the store. */
|
||||
export interface ReviewPluginPackageInput {
|
||||
sourceKind: PluginSourceKind;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin system gateway (ticket #43, F1) — admin CRUD + the bootstrap catalog
|
||||
* the runtime loader consumes. Mirrors the Tauri commands in carnet §5
|
||||
* (`plugin_*`); no DTO shape is re-derived here beyond what the carnet froze.
|
||||
*/
|
||||
export interface PluginGateway {
|
||||
listPlugins(): Promise<PluginAdmin[]>;
|
||||
reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview>;
|
||||
installFromArchive(path: string): Promise<PluginInstallResult>;
|
||||
installFromDirectory(path: string): Promise<PluginInstallResult>;
|
||||
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin>;
|
||||
uninstall(pluginId: string): Promise<PluginUninstallResult>;
|
||||
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog>;
|
||||
openPluginsFolder(pluginId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Gateways {
|
||||
system: SystemGateway;
|
||||
agent: AgentGateway;
|
||||
@ -1199,4 +1233,5 @@ export interface Gateways {
|
||||
window: WindowGateway;
|
||||
focusedProject: FocusedProjectGateway;
|
||||
uiPreferences: UiPreferencesGateway;
|
||||
plugin: PluginGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user