feat(frontend): système de plugins — runtime, menus, layouts custom (#43)
Lots F1-F4 : runtime de chargement/registre plugin, extension des menus existants, panneau de gestion des plugins, types de layout custom (sélecteur, fallback, cellule dédiée) branchés sur le port plugin. Suite npm typecheck/test verte (947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
WebDesktopServerGateway,
|
||||
WebFocusedProjectGateway,
|
||||
WebPluginGateway,
|
||||
WebRemoteGateway,
|
||||
WebWindowGateway,
|
||||
} from "./unsupported";
|
||||
@ -139,6 +140,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
focusedProject: new WebFocusedProjectGateway(),
|
||||
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
plugin: new WebPluginGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -116,6 +116,11 @@ export class HttpSystemGateway implements SystemGateway {
|
||||
return unsupportedOnWeb("Native folder picker");
|
||||
}
|
||||
|
||||
pickArchiveFile(): Promise<string | null> {
|
||||
// Desktop-only, same rationale as `pickFolder`.
|
||||
return unsupportedOnWeb("Native file picker");
|
||||
}
|
||||
|
||||
onAppExitWorkGuard(
|
||||
_handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
|
||||
@ -12,6 +12,11 @@
|
||||
import type {
|
||||
EmbeddedServerStatus,
|
||||
GatewayError,
|
||||
PluginAdmin,
|
||||
PluginInstallResult,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginUninstallResult,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Unsubscribe,
|
||||
@ -20,7 +25,9 @@ import type {
|
||||
DesktopServerGateway,
|
||||
FocusedProject,
|
||||
FocusedProjectGateway,
|
||||
PluginGateway,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
ViewWindowClosed,
|
||||
ViewWindowSnapshot,
|
||||
WindowGateway,
|
||||
@ -119,3 +126,37 @@ export class WebDesktopServerGateway implements DesktopServerGateway {
|
||||
return Promise.resolve(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web stub: the plugin system (#43) installs/loads full-trust local ESM
|
||||
* bundles from the desktop filesystem — no server-side equivalent in V1. The
|
||||
* web client must not manage or load plugins on behalf of the desktop app.
|
||||
*/
|
||||
export class WebPluginGateway implements PluginGateway {
|
||||
async listPlugins(): Promise<PluginAdmin[]> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async reviewPackage(_input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async installFromArchive(_path: string): Promise<PluginInstallResult> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async installFromDirectory(_path: string): Promise<PluginInstallResult> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async setEnabled(_pluginId: string, _enabled: boolean): Promise<PluginAdmin> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async uninstall(_pluginId: string): Promise<PluginUninstallResult> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
// No plugin bundles are ever loaded on the web client — an empty catalog
|
||||
// lets the bootstrap loader run unconditionally without a transport check.
|
||||
return { plugins: [] };
|
||||
}
|
||||
async openPluginsFolder(_pluginId?: string): Promise<void> {
|
||||
return unsupportedOnWeb("Plugin management");
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ import { TauriTicketGateway } from "./ticket";
|
||||
import { TauriWindowGateway } from "./window";
|
||||
import { TauriFocusedProjectGateway } from "./focusedProject";
|
||||
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
||||
import { TauriPluginGateway } from "./plugin";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
const err: GatewayError = {
|
||||
@ -75,6 +76,7 @@ export function createTauriGateways(): Gateways {
|
||||
window: new TauriWindowGateway(),
|
||||
focusedProject: new TauriFocusedProjectGateway(),
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
plugin: new TauriPluginGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -101,4 +103,5 @@ export {
|
||||
TauriWindowGateway,
|
||||
TauriFocusedProjectGateway,
|
||||
LocalStorageUiPreferencesGateway,
|
||||
TauriPluginGateway,
|
||||
};
|
||||
|
||||
@ -38,6 +38,13 @@ import type {
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
PermissionSet,
|
||||
PluginAdmin,
|
||||
PluginContributionSummary,
|
||||
PluginInstallResult,
|
||||
PluginLifecycleState,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginUninstallResult,
|
||||
Project,
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
@ -88,8 +95,10 @@ import type {
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
PermissionGateway,
|
||||
PluginGateway,
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
SkillGateway,
|
||||
StoppedLiveAgent,
|
||||
SystemGateway,
|
||||
@ -184,6 +193,11 @@ export class MockSystemGateway implements SystemGateway {
|
||||
return "/home/user/mock-project";
|
||||
}
|
||||
|
||||
/** Returns a deterministic fake path — never opens a native dialog. */
|
||||
async pickArchiveFile(): Promise<string | null> {
|
||||
return "/home/user/mock-plugin.ideaplug";
|
||||
}
|
||||
|
||||
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
|
||||
/** Count of `confirmAppExit()` calls, for test assertions. */
|
||||
confirmAppExitCallCount = 0;
|
||||
@ -2923,6 +2937,114 @@ function mostRestrictive(
|
||||
return rank[agent] >= rank[fallback] ? agent : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
|
||||
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
|
||||
* parallel): review before commit, install/enable/disable/uninstall, and a
|
||||
* runtime catalog that only ever lists `enabled && !pendingUninstall` plugins.
|
||||
*/
|
||||
export class MockPluginGateway implements PluginGateway {
|
||||
private plugins: PluginAdmin[] = [];
|
||||
private seq = 0;
|
||||
|
||||
private summaryFor(_pluginId: string): PluginContributionSummary {
|
||||
return { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 };
|
||||
}
|
||||
|
||||
/** Test/dev helper: seed a plugin directly, bypassing install. */
|
||||
_seedPlugin(plugin: PluginAdmin): void {
|
||||
this.plugins.push(plugin);
|
||||
}
|
||||
|
||||
async listPlugins(): Promise<PluginAdmin[]> {
|
||||
return structuredClone(this.plugins);
|
||||
}
|
||||
|
||||
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||
this.seq += 1;
|
||||
const label = input.path.split("/").pop() ?? input.path;
|
||||
return {
|
||||
id: `mock.plugin.${this.seq}`,
|
||||
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
|
||||
publisher: "Mock Publisher",
|
||||
version: "0.1.0",
|
||||
description: `Reviewed from ${input.sourceKind}: ${input.path}`,
|
||||
trustLevel: "full",
|
||||
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
||||
issues: [],
|
||||
installable: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async install(
|
||||
sourceKind: "archive" | "directory",
|
||||
path: string,
|
||||
): Promise<PluginInstallResult> {
|
||||
this.seq += 1;
|
||||
const label = path.split("/").pop() ?? path;
|
||||
const plugin: PluginAdmin = {
|
||||
id: `mock.plugin.${this.seq}`,
|
||||
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
|
||||
publisher: "Mock Publisher",
|
||||
version: "0.1.0",
|
||||
sourceKind,
|
||||
sourceLabel: path,
|
||||
lifecycleState: "enabled",
|
||||
enabled: true,
|
||||
pendingUninstall: false,
|
||||
restartRequired: true,
|
||||
trustLevel: "full",
|
||||
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
||||
};
|
||||
this.plugins.push(plugin);
|
||||
return { plugin: structuredClone(plugin), restartRequired: true };
|
||||
}
|
||||
|
||||
installFromArchive(path: string): Promise<PluginInstallResult> {
|
||||
return this.install("archive", path);
|
||||
}
|
||||
|
||||
installFromDirectory(path: string): Promise<PluginInstallResult> {
|
||||
return this.install("directory", path);
|
||||
}
|
||||
|
||||
async setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
|
||||
const plugin = this.plugins.find((p) => p.id === pluginId);
|
||||
if (!plugin) {
|
||||
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
|
||||
throw err;
|
||||
}
|
||||
const state: PluginLifecycleState = enabled ? "enabled" : "disabled";
|
||||
plugin.enabled = enabled;
|
||||
plugin.lifecycleState = state;
|
||||
plugin.restartRequired = true;
|
||||
return structuredClone(plugin);
|
||||
}
|
||||
|
||||
async uninstall(pluginId: string): Promise<PluginUninstallResult> {
|
||||
const idx = this.plugins.findIndex((p) => p.id === pluginId);
|
||||
if (idx === -1) {
|
||||
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
|
||||
throw err;
|
||||
}
|
||||
this.plugins.splice(idx, 1);
|
||||
return { pluginId, restartRequired: true };
|
||||
}
|
||||
|
||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
// Only enabled, non-pending-uninstall plugins are loadable at bootstrap
|
||||
// (carnet §1.3) — the mock has no bundle to actually import, so this
|
||||
// starts empty; tests seed `plugins` on `PluginRuntimeContributionCatalog`
|
||||
// directly via a `MockPluginGateway` subclass/test double when a loader
|
||||
// round-trip is needed.
|
||||
return { plugins: [] };
|
||||
}
|
||||
|
||||
async openPluginsFolder(_pluginId?: string): Promise<void> {
|
||||
// No filesystem in the mock — no-op.
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
export function createMockGateways(): Gateways {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
@ -2951,6 +3073,7 @@ export function createMockGateways(): Gateways {
|
||||
window: new MockWindowGateway(),
|
||||
focusedProject: new MockFocusedProjectGateway(),
|
||||
uiPreferences: new MockUiPreferencesGateway(),
|
||||
plugin: new MockPluginGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ describe("createMockGateways", () => {
|
||||
"memory",
|
||||
"modelServer",
|
||||
"permission",
|
||||
"plugin",
|
||||
"profile",
|
||||
"project",
|
||||
"remote",
|
||||
|
||||
58
frontend/src/adapters/plugin.ts
Normal file
58
frontend/src/adapters/plugin.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tauri adapter for {@link PluginGateway} (ticket #43, F1).
|
||||
*
|
||||
* Commands use snake_case (Tauri convention); payload keys are camelCase,
|
||||
* consistent with the other adapters in this directory. Command names and
|
||||
* envelope match the carnet §5 exactly — no contract improvised here.
|
||||
*
|
||||
* NOTE: The Tauri commands wired here are defined in the backend `app-tauri`
|
||||
* crate (lots B1-B4, in progress in parallel on this branch). The mock gateway
|
||||
* covers tests and offline dev today.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
PluginAdmin,
|
||||
PluginInstallResult,
|
||||
PluginReview,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginUninstallResult,
|
||||
} from "@/domain";
|
||||
import type { PluginGateway, ReviewPluginPackageInput } from "@/ports";
|
||||
|
||||
export class TauriPluginGateway implements PluginGateway {
|
||||
listPlugins(): Promise<PluginAdmin[]> {
|
||||
return invoke<PluginAdmin[]>("plugin_list_plugins");
|
||||
}
|
||||
|
||||
reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||
return invoke<PluginReview>("plugin_review_package", {
|
||||
request: { sourceKind: input.sourceKind, path: input.path },
|
||||
});
|
||||
}
|
||||
|
||||
installFromArchive(path: string): Promise<PluginInstallResult> {
|
||||
return invoke<PluginInstallResult>("plugin_install_from_archive", { path });
|
||||
}
|
||||
|
||||
installFromDirectory(path: string): Promise<PluginInstallResult> {
|
||||
return invoke<PluginInstallResult>("plugin_install_from_directory", { path });
|
||||
}
|
||||
|
||||
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
|
||||
return invoke<PluginAdmin>("plugin_set_enabled", { pluginId, enabled });
|
||||
}
|
||||
|
||||
uninstall(pluginId: string): Promise<PluginUninstallResult> {
|
||||
return invoke<PluginUninstallResult>("plugin_uninstall", { pluginId });
|
||||
}
|
||||
|
||||
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
|
||||
}
|
||||
|
||||
async openPluginsFolder(pluginId?: string): Promise<void> {
|
||||
await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null });
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,15 @@ export class TauriSystemGateway implements SystemGateway {
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async pickArchiveFile(): Promise<string | null> {
|
||||
const result = await open({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
filters: [{ name: "Plugin archive", extensions: ["ideaplug", "zip", "vsix"] }],
|
||||
});
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async onAppExitWorkGuard(
|
||||
handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
|
||||
Reference in New Issue
Block a user