diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 41f0b6f..3d8e536 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -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(), }; } diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index cb9d670..7744def 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -116,6 +116,11 @@ export class HttpSystemGateway implements SystemGateway { return unsupportedOnWeb("Native folder picker"); } + pickArchiveFile(): Promise { + // Desktop-only, same rationale as `pickFolder`. + return unsupportedOnWeb("Native file picker"); + } + onAppExitWorkGuard( _handler: (state: AppExitWorkGuardState) => void, ): Promise { diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index 7add974..4ab25f5 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -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 { + return unsupportedOnWeb("Plugin management"); + } + async reviewPackage(_input: ReviewPluginPackageInput): Promise { + return unsupportedOnWeb("Plugin management"); + } + async installFromArchive(_path: string): Promise { + return unsupportedOnWeb("Plugin management"); + } + async installFromDirectory(_path: string): Promise { + return unsupportedOnWeb("Plugin management"); + } + async setEnabled(_pluginId: string, _enabled: boolean): Promise { + return unsupportedOnWeb("Plugin management"); + } + async uninstall(_pluginId: string): Promise { + return unsupportedOnWeb("Plugin management"); + } + async listRuntimeContributions(): Promise { + // 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 { + return unsupportedOnWeb("Plugin management"); + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 873cb48..5be61a9 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -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, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index f5e5e99..5a7fe4b 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { + 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 { + return structuredClone(this.plugins); + } + + async reviewPackage(input: ReviewPluginPackageInput): Promise { + 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 { + 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 { + return this.install("archive", path); + } + + installFromDirectory(path: string): Promise { + return this.install("directory", path); + } + + async setEnabled(pluginId: string, enabled: boolean): Promise { + 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 { + 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 { + // 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 { + // 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(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index e56ca7a..3d7a5a7 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -26,6 +26,7 @@ describe("createMockGateways", () => { "memory", "modelServer", "permission", + "plugin", "profile", "project", "remote", diff --git a/frontend/src/adapters/plugin.ts b/frontend/src/adapters/plugin.ts new file mode 100644 index 0000000..6499f8f --- /dev/null +++ b/frontend/src/adapters/plugin.ts @@ -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 { + return invoke("plugin_list_plugins"); + } + + reviewPackage(input: ReviewPluginPackageInput): Promise { + return invoke("plugin_review_package", { + request: { sourceKind: input.sourceKind, path: input.path }, + }); + } + + installFromArchive(path: string): Promise { + return invoke("plugin_install_from_archive", { path }); + } + + installFromDirectory(path: string): Promise { + return invoke("plugin_install_from_directory", { path }); + } + + setEnabled(pluginId: string, enabled: boolean): Promise { + return invoke("plugin_set_enabled", { pluginId, enabled }); + } + + uninstall(pluginId: string): Promise { + return invoke("plugin_uninstall", { pluginId }); + } + + listRuntimeContributions(): Promise { + return invoke("plugin_list_runtime_contributions"); + } + + async openPluginsFolder(pluginId?: string): Promise { + await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null }); + } +} diff --git a/frontend/src/adapters/system.ts b/frontend/src/adapters/system.ts index df9da29..c2d9f02 100644 --- a/frontend/src/adapters/system.ts +++ b/frontend/src/adapters/system.ts @@ -41,6 +41,15 @@ export class TauriSystemGateway implements SystemGateway { return typeof result === "string" ? result : null; } + async pickArchiveFile(): Promise { + 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 { diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 4b42458..a470d06 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -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 ( +
{/* ── Header ── */}
@@ -116,6 +118,7 @@ export function App() {
+
); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index de88748..cb81204 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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; +} diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 64ad1df..7ae6c5c 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -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} /> ); @@ -132,6 +146,7 @@ interface NodeViewProps { workState: ProjectWorkState | null; refreshWorkState: () => Promise; 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 ( + vm.setPluginLayoutState(node.node.id, nextState)} + onOpenPlugins={() => onOpenPluginsSettings?.()} + onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)} + /> + ); case "leaf": return ( ); 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; 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; 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} /> ))} diff --git a/frontend/src/features/layout/LayoutTabs.tsx b/frontend/src/features/layout/LayoutTabs.tsx index 7bce1bd..93c9cd1 100644 --- a/frontend/src/features/layout/LayoutTabs.tsx +++ b/frontend/src/features/layout/LayoutTabs.tsx @@ -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(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(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 + { + setShowCreateMenu(false); + setPluginLayoutNotice( + `« ${choice.layout.label} » (${choice.pluginDisplayName}) : la création de layouts plugins nécessite une extension backend pas encore livrée.`, + ); + }} + /> )} - {vm.error && ( + {(vm.error || pluginLayoutNotice) && ( - {vm.error} + {vm.error ?? pluginLayoutNotice} )} diff --git a/frontend/src/features/layout/layout.test.ts b/frontend/src/features/layout/layout.test.ts index 0ea97b4..821af09 100644 --- a/frontend/src/features/layout/layout.test.ts +++ b/frontend/src/features/layout/layout.test.ts @@ -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" }, + }); + }); +}); diff --git a/frontend/src/features/layout/layout.ts b/frontend/src/features/layout/layout.ts index 12bc2cf..89b354f 100644 --- a/frontend/src/features/layout/layout.ts +++ b/frontend/src/features/layout/layout.ts @@ -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 { diff --git a/frontend/src/features/layout/useLayout.ts b/frontend/src/features/layout/useLayout.ts index e50f152..22dd052 100644 --- a/frontend/src/features/layout/useLayout.ts +++ b/frontend/src/features/layout/useLayout.ts @@ -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; + /** + * 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, }; } diff --git a/frontend/src/features/plugins/PluginConfirmDialog.tsx b/frontend/src/features/plugins/PluginConfirmDialog.tsx new file mode 100644 index 0000000..e3a8188 --- /dev/null +++ b/frontend/src/features/plugins/PluginConfirmDialog.tsx @@ -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; + onCancel: () => void; +} + +export function PluginConfirmDialog({ + title, + body, + confirmLabel, + danger = false, + busy = false, + onConfirm, + onCancel, +}: PluginConfirmDialogProps) { + const cancelRef = useRef(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 ( +
+
e.stopPropagation()} + className="flex w-full max-w-md flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl" + > +

{title}

+

{body}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx new file mode 100644 index 0000000..a3d20d4 --- /dev/null +++ b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx @@ -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 { + return { + id: "leaf-1", + pluginId: "dev.acme.gitgraph", + layoutType: "dev.acme.gitgraph.layout", + state: { commits: 3 }, + ...overrides, + }; +} + +function MockLayoutComponent(props: PluginLayoutProps) { + return ( +
+

{JSON.stringify(props.state)}

+ +
+ ); +} + +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( + + + {})} + onOpenPlugins={() => {}} + onChooseAnotherLayout={() => {}} + /> + + , + ); +} + +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()); + }); +}); diff --git a/frontend/src/features/plugins/PluginLayoutCellView.tsx b/frontend/src/features/plugins/PluginLayoutCellView.tsx new file mode 100644 index 0000000..beafa59 --- /dev/null +++ b/frontend/src/features/plugins/PluginLayoutCellView.tsx @@ -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 ( + + ); + } + + const Component = registry.layoutComponent(cell.pluginId, cell.layoutType)!; + return ( + + ); +} diff --git a/frontend/src/features/plugins/PluginLayoutFallback.tsx b/frontend/src/features/plugins/PluginLayoutFallback.tsx new file mode 100644 index 0000000..b92a403 --- /dev/null +++ b/frontend/src/features/plugins/PluginLayoutFallback.tsx @@ -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, 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; + /** 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 ( + +

Layout indisponible

+

{REASON_LABEL[availability]}

+

Fourni par « {providerDisplayName ?? cell.pluginId} »

+
+ + +
+
+ ); +} diff --git a/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx b/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx new file mode 100644 index 0000000..f026c67 --- /dev/null +++ b/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx @@ -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( + {}} />, + ); + 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( + { + selected = choice.layout.type; + }} + />, + ); + fireEvent.click(screen.getByRole("menuitem", { name: /One layout/ })); + expect(selected).toBe("dev.acme.one.layout"); + }); +}); diff --git a/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx b/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx new file mode 100644 index 0000000..fb740e5 --- /dev/null +++ b/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx @@ -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 ( +
+

+ Layouts plugins +

+ {choices.map((choice) => ( + + ))} +
+ ); +} diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx new file mode 100644 index 0000000..026ee17 --- /dev/null +++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx @@ -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 `` (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 `` (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(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( + 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 ( + {children} + ); +} + +/** + * Reads the loaded plugin runtime registry. Outside a `` + * this is the empty registry (see {@link EMPTY_PLUGIN_RUNTIME}), never a throw. + */ +export function usePluginRuntime(): PluginRuntimeContextValue { + return useContext(PluginRuntimeContext); +} diff --git a/frontend/src/features/plugins/PluginsPanel.tsx b/frontend/src/features/plugins/PluginsPanel.tsx new file mode 100644 index 0000000..f623820 --- /dev/null +++ b/frontend/src/features/plugins/PluginsPanel.tsx @@ -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 = { + 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(null); + const [installFlow, setInstallFlow] = useState(null); + const [installFlowError, setInstallFlowError] = useState(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 ( +
+
+

Plugins

+
+ + +
+
+ + {(vm.error || installFlowError) && ( +

+ {vm.error ?? installFlowError} +

+ )} + + {vm.plugins.length === 0 ? ( + +

Aucun plugin installé.

+
+ ) : ( +
    + {vm.plugins.map((p) => ( +
  • + +
    +
    + {p.displayName} + v{p.version} + {p.publisher && · {p.publisher}} + + full-trust + +
    +
    + + {STATE_LABEL[p.lifecycleState]} + + {p.restartRequired && ( + Redémarrage requis + )} + {p.sourceLabel && · {p.sourceLabel}} +
    + {p.error && ( +

    + {p.error} +

    + )} +
    +
    + {p.enabled ? ( + + ) : ( + + )} + + +
    +
    +
  • + ))} +
+ )} + + {installFlow && ( + { + if (installFlow.review.installable) void confirmInstall(); + else setInstallFlow(null); + }} + onCancel={() => setInstallFlow(null)} + /> + )} + + {pendingAction && ( + void confirmPendingAction()} + onCancel={() => setPendingAction(null)} + /> + )} +
+ ); +} + +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"); +} diff --git a/frontend/src/features/plugins/index.ts b/frontend/src/features/plugins/index.ts new file mode 100644 index 0000000..b54b369 --- /dev/null +++ b/frontend/src/features/plugins/index.ts @@ -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"; diff --git a/frontend/src/features/plugins/layoutAvailability.test.ts b/frontend/src/features/plugins/layoutAvailability.test.ts new file mode 100644 index 0000000..f6c6585 --- /dev/null +++ b/frontend/src/features/plugins/layoutAvailability.test.ts @@ -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 { + 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"); + }); +}); diff --git a/frontend/src/features/plugins/layoutAvailability.ts b/frontend/src/features/plugins/layoutAvailability.ts new file mode 100644 index 0000000..835f911 --- /dev/null +++ b/frontend/src/features/plugins/layoutAvailability.ts @@ -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"; +} diff --git a/frontend/src/features/plugins/menus.test.ts b/frontend/src/features/plugins/menus.test.ts new file mode 100644 index 0000000..0cfcae5 --- /dev/null +++ b/frontend/src/features/plugins/menus.test.ts @@ -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); + }); +}); diff --git a/frontend/src/features/plugins/menus.ts b/frontend/src/features/plugins/menus.ts new file mode 100644 index 0000000..32253dd --- /dev/null +++ b/frontend/src/features/plugins/menus.ts @@ -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:`), `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), + ); +} diff --git a/frontend/src/features/plugins/plugins.test.tsx b/frontend/src/features/plugins/plugins.test.tsx new file mode 100644 index 0000000..0692c72 --- /dev/null +++ b/frontend/src/features/plugins/plugins.test.tsx @@ -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( + + + , + ), + }; +} + +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(); + }); +}); diff --git a/frontend/src/features/plugins/usePluginMenus.ts b/frontend/src/features/plugins/usePluginMenus.ts new file mode 100644 index 0000000..583ec3e --- /dev/null +++ b/frontend/src/features/plugins/usePluginMenus.ts @@ -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; +} + +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(() => { + return resolveTopLevelMenus(registry).map((menu) => ({ + id: menu.id, + label: menu.label, + items: itemsFor(menu.id), + })); + }, [registry, itemsFor]); + + return { topLevelMenus, itemsFor, runCommand }; +} diff --git a/frontend/src/features/plugins/usePlugins.ts b/frontend/src/features/plugins/usePlugins.ts new file mode 100644 index 0000000..2814468 --- /dev/null +++ b/frontend/src/features/plugins/usePlugins.ts @@ -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; + reviewArchive: (path: string) => Promise; + reviewDirectory: (path: string) => Promise; + installFromArchive: (path: string) => Promise; + installFromDirectory: (path: string) => Promise; + setEnabled: (pluginId: string, enabled: boolean) => Promise; + uninstall: (pluginId: string) => Promise; + openPluginsFolder: (pluginId?: string) => Promise; + pickArchiveFile: () => Promise; + pickDirectory: () => Promise; +} + +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([]); + const [error, setError] = useState(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 => { + 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 => { + 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 => { + 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, + }; +} diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 3af2ad4..6ee10b8 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -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(); + }} /> )} diff --git a/frontend/src/features/projects/pluginGitRepository.test.tsx b/frontend/src/features/projects/pluginGitRepository.test.tsx new file mode 100644 index 0000000..703ab4f --- /dev/null +++ b/frontend/src/features/projects/pluginGitRepository.test.tsx @@ -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 { + return Promise.resolve([]); + } + stage(): Promise { + return Promise.resolve(); + } + unstage(): Promise { + return Promise.resolve(); + } + commit(): Promise { + return Promise.reject({ code: "NOT_IMPLEMENTED", message: "n/a" } satisfies GatewayError); + } + branches(): Promise { + const err: GatewayError = { code: "NOT_A_REPOSITORY", message: "not a git repository" }; + return Promise.reject(err); + } + checkout(): Promise { + return Promise.resolve(); + } + log(): Promise { + return Promise.resolve([]); + } + init(): Promise { + return Promise.resolve(); + } + graph(): Promise { + return Promise.resolve([]); + } +} + +/** Minimal `GitGateway` double whose `branches()` always resolves — a real repo. */ +class RealRepoGitGateway extends NonRepoGitGateway { + override branches(): Promise { + 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( + + + + + , + ); +} + +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); + }); + }); +}); diff --git a/frontend/src/features/settings/SettingsView.tsx b/frontend/src/features/settings/SettingsView.tsx index 92cf453..97255b8 100644 --- a/frontend/src/features/settings/SettingsView.tsx +++ b/frontend/src/features/settings/SettingsView.tsx @@ -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 = { 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({ ) : section === "deployment" ? ( + ) : section === "plugins" ? ( + ) : ( // No `onSessionEnded`: the desktop app hosts the server and is never // itself a paired device, so it cannot revoke its own session. diff --git a/frontend/src/plugins/runtime/index.ts b/frontend/src/plugins/runtime/index.ts new file mode 100644 index 0000000..89f6cd0 --- /dev/null +++ b/frontend/src/plugins/runtime/index.ts @@ -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"; diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts new file mode 100644 index 0000000..d489291 --- /dev/null +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -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 & { 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).__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).__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).__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).__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([]); + }); +}); diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts new file mode 100644 index 0000000..fd4c6c0 --- /dev/null +++ b/frontend/src/plugins/runtime/loader.ts @@ -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; +} + +export interface IdeaPluginModule { + activate(ctx: IdeaPluginContext): PluginActivation | Promise; +} + +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 { + 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 }; +} diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts new file mode 100644 index 0000000..3d444ae --- /dev/null +++ b/frontend/src/plugins/runtime/registry.ts @@ -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; + +/** Commands a plugin registers in `activate(ctx)`, dispatched by menu items. */ +export class PluginCommandRegistry { + private handlers = new Map(); + + constructor( + private readonly pluginId: string, + private readonly declaredCommandIds: ReadonlySet, + ) {} + + 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 { + 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; +} + +/** Custom React layout components a plugin registers, keyed by declared `type`. */ +export class PluginLayoutRegistry { + private components = new Map>(); + + constructor( + private readonly pluginId: string, + private readonly declaredLayoutTypes: ReadonlySet, + ) {} + + 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 | 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; +} + +/** + * 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(); + + 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 { + 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 | 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 { + await this.loaded.get(pluginId)?.commands.run(commandId, ...args); + } +} diff --git a/frontend/src/plugins/runtime/when.test.ts b/frontend/src/plugins/runtime/when.test.ts new file mode 100644 index 0000000..634b8f8 --- /dev/null +++ b/frontend/src/plugins/runtime/when.test.ts @@ -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); + }); +}); diff --git a/frontend/src/plugins/runtime/when.ts b/frontend/src/plugins/runtime/when.ts new file mode 100644 index 0000000..013ae00 --- /dev/null +++ b/frontend/src/plugins/runtime/when.ts @@ -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 = new Set([ + "projectOpen", + "gitRepository", + "agentSelected", + "terminalFocused", + "layoutCellFocused", +]); + +export type WhenContext = Record; + +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}` }; + } +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index c4b822d..72cfa02 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -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; + /** + * 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; /** * 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; + reviewPackage(input: ReviewPluginPackageInput): Promise; + installFromArchive(path: string): Promise; + installFromDirectory(path: string): Promise; + setEnabled(pluginId: string, enabled: boolean): Promise; + uninstall(pluginId: string): Promise; + listRuntimeContributions(): Promise; + openPluginsFolder(pluginId?: string): Promise; +} + export interface Gateways { system: SystemGateway; agent: AgentGateway; @@ -1199,4 +1233,5 @@ export interface Gateways { window: WindowGateway; focusedProject: FocusedProjectGateway; uiPreferences: UiPreferencesGateway; + plugin: PluginGateway; }