feat(frontend): système de plugins — runtime, menus, layouts custom (#43)

Lots F1-F4 : runtime de chargement/registre plugin, extension des menus
existants, panneau de gestion des plugins, types de layout custom
(sélecteur, fallback, cellule dédiée) branchés sur le port plugin.
Suite npm typecheck/test verte (947/947).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:37:11 +02:00
parent bb35641715
commit ac726d075e
41 changed files with 3245 additions and 24 deletions

View File

@ -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(),
};
}

View File

@ -116,6 +116,11 @@ export class HttpSystemGateway implements SystemGateway {
return unsupportedOnWeb("Native folder picker");
}
pickArchiveFile(): Promise<string | null> {
// Desktop-only, same rationale as `pickFolder`.
return unsupportedOnWeb("Native file picker");
}
onAppExitWorkGuard(
_handler: (state: AppExitWorkGuardState) => void,
): Promise<Unsubscribe> {

View File

@ -12,6 +12,11 @@
import type {
EmbeddedServerStatus,
GatewayError,
PluginAdmin,
PluginInstallResult,
PluginReview,
PluginRuntimeContributionCatalog,
PluginUninstallResult,
ServerExposurePreview,
ServerExposureSettings,
Unsubscribe,
@ -20,7 +25,9 @@ import type {
DesktopServerGateway,
FocusedProject,
FocusedProjectGateway,
PluginGateway,
RemoteGateway,
ReviewPluginPackageInput,
ViewWindowClosed,
ViewWindowSnapshot,
WindowGateway,
@ -119,3 +126,37 @@ export class WebDesktopServerGateway implements DesktopServerGateway {
return Promise.resolve(() => {});
}
}
/**
* Web stub: the plugin system (#43) installs/loads full-trust local ESM
* bundles from the desktop filesystem — no server-side equivalent in V1. The
* web client must not manage or load plugins on behalf of the desktop app.
*/
export class WebPluginGateway implements PluginGateway {
async listPlugins(): Promise<PluginAdmin[]> {
return unsupportedOnWeb("Plugin management");
}
async reviewPackage(_input: ReviewPluginPackageInput): Promise<PluginReview> {
return unsupportedOnWeb("Plugin management");
}
async installFromArchive(_path: string): Promise<PluginInstallResult> {
return unsupportedOnWeb("Plugin management");
}
async installFromDirectory(_path: string): Promise<PluginInstallResult> {
return unsupportedOnWeb("Plugin management");
}
async setEnabled(_pluginId: string, _enabled: boolean): Promise<PluginAdmin> {
return unsupportedOnWeb("Plugin management");
}
async uninstall(_pluginId: string): Promise<PluginUninstallResult> {
return unsupportedOnWeb("Plugin management");
}
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
// No plugin bundles are ever loaded on the web client — an empty catalog
// lets the bootstrap loader run unconditionally without a transport check.
return { plugins: [] };
}
async openPluginsFolder(_pluginId?: string): Promise<void> {
return unsupportedOnWeb("Plugin management");
}
}

View File

@ -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,
};

View File

@ -38,6 +38,13 @@ import type {
PairedDevice,
PairingCode,
PermissionSet,
PluginAdmin,
PluginContributionSummary,
PluginInstallResult,
PluginLifecycleState,
PluginReview,
PluginRuntimeContributionCatalog,
PluginUninstallResult,
Project,
ProjectMcpToolPermissions,
ProjectPermissions,
@ -88,8 +95,10 @@ import type {
ProfileGateway,
ProjectGateway,
PermissionGateway,
PluginGateway,
ReattachResult,
RemoteGateway,
ReviewPluginPackageInput,
SkillGateway,
StoppedLiveAgent,
SystemGateway,
@ -184,6 +193,11 @@ export class MockSystemGateway implements SystemGateway {
return "/home/user/mock-project";
}
/** Returns a deterministic fake path — never opens a native dialog. */
async pickArchiveFile(): Promise<string | null> {
return "/home/user/mock-plugin.ideaplug";
}
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
/** Count of `confirmAppExit()` calls, for test assertions. */
confirmAppExitCallCount = 0;
@ -2923,6 +2937,114 @@ function mostRestrictive(
return rank[agent] >= rank[fallback] ? agent : fallback;
}
/**
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
* parallel): review before commit, install/enable/disable/uninstall, and a
* runtime catalog that only ever lists `enabled && !pendingUninstall` plugins.
*/
export class MockPluginGateway implements PluginGateway {
private plugins: PluginAdmin[] = [];
private seq = 0;
private summaryFor(_pluginId: string): PluginContributionSummary {
return { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 };
}
/** Test/dev helper: seed a plugin directly, bypassing install. */
_seedPlugin(plugin: PluginAdmin): void {
this.plugins.push(plugin);
}
async listPlugins(): Promise<PluginAdmin[]> {
return structuredClone(this.plugins);
}
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
this.seq += 1;
const label = input.path.split("/").pop() ?? input.path;
return {
id: `mock.plugin.${this.seq}`,
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
publisher: "Mock Publisher",
version: "0.1.0",
description: `Reviewed from ${input.sourceKind}: ${input.path}`,
trustLevel: "full",
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
issues: [],
installable: true,
};
}
private async install(
sourceKind: "archive" | "directory",
path: string,
): Promise<PluginInstallResult> {
this.seq += 1;
const label = path.split("/").pop() ?? path;
const plugin: PluginAdmin = {
id: `mock.plugin.${this.seq}`,
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
publisher: "Mock Publisher",
version: "0.1.0",
sourceKind,
sourceLabel: path,
lifecycleState: "enabled",
enabled: true,
pendingUninstall: false,
restartRequired: true,
trustLevel: "full",
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
};
this.plugins.push(plugin);
return { plugin: structuredClone(plugin), restartRequired: true };
}
installFromArchive(path: string): Promise<PluginInstallResult> {
return this.install("archive", path);
}
installFromDirectory(path: string): Promise<PluginInstallResult> {
return this.install("directory", path);
}
async setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
const plugin = this.plugins.find((p) => p.id === pluginId);
if (!plugin) {
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
throw err;
}
const state: PluginLifecycleState = enabled ? "enabled" : "disabled";
plugin.enabled = enabled;
plugin.lifecycleState = state;
plugin.restartRequired = true;
return structuredClone(plugin);
}
async uninstall(pluginId: string): Promise<PluginUninstallResult> {
const idx = this.plugins.findIndex((p) => p.id === pluginId);
if (idx === -1) {
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
throw err;
}
this.plugins.splice(idx, 1);
return { pluginId, restartRequired: true };
}
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
// Only enabled, non-pending-uninstall plugins are loadable at bootstrap
// (carnet §1.3) — the mock has no bundle to actually import, so this
// starts empty; tests seed `plugins` on `PluginRuntimeContributionCatalog`
// directly via a `MockPluginGateway` subclass/test double when a loader
// round-trip is needed.
return { plugins: [] };
}
async openPluginsFolder(_pluginId?: string): Promise<void> {
// No filesystem in the mock — no-op.
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
@ -2951,6 +3073,7 @@ export function createMockGateways(): Gateways {
window: new MockWindowGateway(),
focusedProject: new MockFocusedProjectGateway(),
uiPreferences: new MockUiPreferencesGateway(),
plugin: new MockPluginGateway(),
};
}

View File

@ -26,6 +26,7 @@ describe("createMockGateways", () => {
"memory",
"modelServer",
"permission",
"plugin",
"profile",
"project",
"remote",

View File

@ -0,0 +1,58 @@
/**
* Tauri adapter for {@link PluginGateway} (ticket #43, F1).
*
* Commands use snake_case (Tauri convention); payload keys are camelCase,
* consistent with the other adapters in this directory. Command names and
* envelope match the carnet §5 exactly — no contract improvised here.
*
* NOTE: The Tauri commands wired here are defined in the backend `app-tauri`
* crate (lots B1-B4, in progress in parallel on this branch). The mock gateway
* covers tests and offline dev today.
*/
import { invoke } from "@tauri-apps/api/core";
import type {
PluginAdmin,
PluginInstallResult,
PluginReview,
PluginRuntimeContributionCatalog,
PluginUninstallResult,
} from "@/domain";
import type { PluginGateway, ReviewPluginPackageInput } from "@/ports";
export class TauriPluginGateway implements PluginGateway {
listPlugins(): Promise<PluginAdmin[]> {
return invoke<PluginAdmin[]>("plugin_list_plugins");
}
reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
return invoke<PluginReview>("plugin_review_package", {
request: { sourceKind: input.sourceKind, path: input.path },
});
}
installFromArchive(path: string): Promise<PluginInstallResult> {
return invoke<PluginInstallResult>("plugin_install_from_archive", { path });
}
installFromDirectory(path: string): Promise<PluginInstallResult> {
return invoke<PluginInstallResult>("plugin_install_from_directory", { path });
}
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
return invoke<PluginAdmin>("plugin_set_enabled", { pluginId, enabled });
}
uninstall(pluginId: string): Promise<PluginUninstallResult> {
return invoke<PluginUninstallResult>("plugin_uninstall", { pluginId });
}
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
}
async openPluginsFolder(pluginId?: string): Promise<void> {
await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null });
}
}

View File

@ -41,6 +41,15 @@ export class TauriSystemGateway implements SystemGateway {
return typeof result === "string" ? result : null;
}
async pickArchiveFile(): Promise<string | null> {
const result = await open({
directory: false,
multiple: false,
filters: [{ name: "Plugin archive", extensions: ["ideaplug", "zip", "vsix"] }],
});
return typeof result === "string" ? result : null;
}
async onAppExitWorkGuard(
handler: (state: AppExitWorkGuardState) => void,
): Promise<Unsubscribe> {