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

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