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,7 @@ import {
TargetAnnouncementsOverlay,
useTargetAnnouncements,
} from "@/features/announcements";
import { PluginLayoutCellView } from "@/features/plugins";
import {
modelServerOverlayText,
describeModelServerDownload,
@ -79,9 +80,21 @@ interface LayoutGridProps {
layoutId?: string;
/** Opens the read-only canonical transcript for a conversation. */
onOpenConversation?: (conversationId: string) => void;
/**
* Navigates to `Paramètres > Plugins` (#43, F4) — the "Ouvrir Plugins" action
* of a plugin layout's unavailable fallback. Threaded down to
* `PluginLayoutCellView` the same way `onOpenConversation` is.
*/
onOpenPluginsSettings?: () => void;
}
export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: LayoutGridProps) {
export function LayoutGrid({
projectId,
cwd,
layoutId,
onOpenConversation,
onOpenPluginsSettings,
}: LayoutGridProps) {
const vm = useLayout(projectId, layoutId);
const work = useProjectWorkState(projectId);
@ -117,6 +130,7 @@ export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: Lay
workState={work.state}
refreshWorkState={work.refresh}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
</div>
);
@ -132,6 +146,7 @@ interface NodeViewProps {
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function NodeView({
@ -143,8 +158,24 @@ function NodeView({
workState,
refreshWorkState,
onOpenConversation,
onOpenPluginsSettings,
}: NodeViewProps) {
switch (node.type) {
case "customPluginLayout":
// A true top-level `LayoutNode` variant (#43, F4, carnet v2 §3) — a
// plugin layout occupies a slot in the tree at the same level as a
// terminal leaf, split or grid. Rendered separately from `LeafView`
// (which owns a lot of terminal-only concerns — write-portal,
// model-server overlay, agent dropdown — none of which apply here).
return (
<PluginLayoutCellView
projectId={projectId}
cell={node.node}
onStateChange={(nextState) => vm.setPluginLayoutState(node.node.id, nextState)}
onOpenPlugins={() => onOpenPluginsSettings?.()}
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
/>
);
case "leaf":
return (
<LeafView
@ -172,6 +203,7 @@ function NodeView({
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
);
case "grid":
@ -184,6 +216,7 @@ function NodeView({
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
);
}
@ -1153,6 +1186,7 @@ interface SplitViewProps {
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function SplitView({
@ -1163,6 +1197,7 @@ function SplitView({
workState,
refreshWorkState,
onOpenConversation,
onOpenPluginsSettings,
}: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
@ -1207,6 +1242,7 @@ function SplitView({
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
parentSplit={{
container: split.id,
index: i,
@ -1301,6 +1337,7 @@ interface GridViewProps {
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
onOpenPluginsSettings?: () => void;
}
function GridView({
@ -1311,6 +1348,7 @@ function GridView({
workState,
refreshWorkState,
onOpenConversation,
onOpenPluginsSettings,
}: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
@ -1351,6 +1389,7 @@ function GridView({
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
onOpenPluginsSettings={onOpenPluginsSettings}
/>
</div>
))}

View File

@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
import type { LayoutInfo } from "@/domain";
import { cn } from "@/shared";
import { PluginLayoutSelectorSection, usePluginRuntime } from "@/features/plugins";
import { useLayouts } from "./useLayouts";
interface LayoutTabsProps {
@ -43,6 +44,10 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
const renameInputRef = useRef<HTMLInputElement | null>(null);
// Show/hide the create-kind dropdown.
const [showCreateMenu, setShowCreateMenu] = useState(false);
const { registry: pluginRegistry } = usePluginRuntime();
// Plugin layouts need a `create_layout` backend extension that doesn't exist
// yet (#43, F4 open point) — surfaced rather than silently swallowed.
const [pluginLayoutNotice, setPluginLayoutNotice] = useState<string | null>(null);
async function handleSelect(id: string) {
// The effect above propagates the new active layout (id + kind) to the parent.
@ -196,13 +201,22 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
>
Git graph
</button>
<PluginLayoutSelectorSection
registry={pluginRegistry}
onSelect={(choice) => {
setShowCreateMenu(false);
setPluginLayoutNotice(
`« ${choice.layout.label} » (${choice.pluginDisplayName}) : la création de layouts plugins nécessite une extension backend pas encore livrée.`,
);
}}
/>
</div>
)}
</div>
{vm.error && (
{(vm.error || pluginLayoutNotice) && (
<span className="ml-2 text-xs text-danger" role="alert">
{vm.error}
{vm.error ?? pluginLayoutNotice}
</span>
)}
</div>

View File

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

View File

@ -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 {

View File

@ -17,7 +17,12 @@ import type {
LayoutTree,
} from "@/domain";
import { useGateways } from "@/app/di";
import { leaves, splitOp } from "./layout";
import {
leaves,
replaceCustomPluginLayoutWithTerminal,
setCustomPluginLayoutState,
splitOp,
} from "./layout";
/** What the layout grid UI needs from this hook. */
export interface LayoutViewModel {
@ -59,6 +64,19 @@ export interface LayoutViewModel {
* to persist the id assigned at first launch so the next open resumes it.
*/
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
/**
* Patches a `customPluginLayout` node's opaque state locally (#43, F4) —
* in-session only, no backend `LayoutOperation` for this exists yet (carnet
* v2 §3.5). The tree re-renders immediately; a reload re-fetches the last
* value actually persisted by the backend.
*/
setPluginLayoutState: (nodeId: string, state: unknown) => void;
/**
* "Choisir un autre layout" fallback action (#43, F4): locally swaps a
* `customPluginLayout` node for a blank terminal leaf of the same id.
* Same client-side-only contract as {@link setPluginLayoutState}.
*/
replacePluginLayoutWithTerminal: (nodeId: string) => void;
}
function describe(e: unknown): string {
@ -246,6 +264,19 @@ export function useLayout(
[mutate],
);
const setPluginLayoutState = useCallback(
(nodeId: string, state: unknown) => {
setLayout((prev) => (prev ? setCustomPluginLayoutState(prev, nodeId, state) : prev));
setLayoutVersion((v) => v + 1);
},
[],
);
const replacePluginLayoutWithTerminal = useCallback((nodeId: string) => {
setLayout((prev) => (prev ? replaceCustomPluginLayoutWithTerminal(prev, nodeId) : prev));
setLayoutVersion((v) => v + 1);
}, []);
return {
layout,
layoutVersion,
@ -259,5 +290,7 @@ export function useLayout(
setCellAgent,
attachLiveAgentToCell,
setCellConversation,
setPluginLayoutState,
replacePluginLayoutWithTerminal,
};
}

View File

@ -0,0 +1,76 @@
/**
* A small modal confirmation, local to the plugins feature (ticket #43, F2).
* Mirrors `features/devices/ConfirmDialog` (not shared — that dialog is itself
* feature-local by design, see its header comment); duplicated rather than
* cross-imported to keep each feature's public surface to its own `index.ts`.
*/
import { useEffect, useRef } from "react";
import { Button, zIndex } from "@/shared";
interface PluginConfirmDialogProps {
title: string;
body: string;
confirmLabel: string;
danger?: boolean;
busy?: boolean;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}
export function PluginConfirmDialog({
title,
body,
confirmLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}: PluginConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelRef.current?.focus();
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onCancel]);
return (
<div
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
style={{ zIndex: zIndex.floatingWindow }}
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-md flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
>
<h3 className="text-sm font-semibold text-content">{title}</h3>
<p className="whitespace-pre-line text-sm text-muted">{body}</p>
<div className="flex justify-end gap-2">
<Button ref={cancelRef} size="sm" variant="ghost" onClick={onCancel}>
Annuler
</Button>
<Button
size="sm"
variant={danger ? "danger" : "primary"}
loading={busy}
onClick={() => void onConfirm()}
>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
/**
* F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria:
* "rendu composant mock, state roundtrip, fallback sans mutation du layout").
*/
import { describe, expect, it } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { CustomPluginLayoutCell } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { createMockGateways } from "@/adapters/mock";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
type PluginLayoutProps,
} from "@/plugins/runtime";
import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
import { PluginLayoutCellView } from "./PluginLayoutCellView";
function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayoutCell {
return {
id: "leaf-1",
pluginId: "dev.acme.gitgraph",
layoutType: "dev.acme.gitgraph.layout",
state: { commits: 3 },
...overrides,
};
}
function MockLayoutComponent(props: PluginLayoutProps) {
return (
<div>
<p data-testid="state">{JSON.stringify(props.state)}</p>
<button onClick={() => props.setState({ commits: 4 })}>bump</button>
</div>
);
}
function stubPlugin(): LoadedPlugin {
const contributes = {
menus: [],
menuItems: [],
layouts: [{ type: "dev.acme.gitgraph.layout", label: "Git Graph", component: "GitGraphLayout" }],
mcpServers: [],
};
const layouts = new PluginLayoutRegistry("dev.acme.gitgraph", new Set(["dev.acme.gitgraph.layout"]));
layouts.register({ type: "dev.acme.gitgraph.layout", component: MockLayoutComponent });
return {
pluginId: "dev.acme.gitgraph",
displayName: "Git Graph",
contributes,
commands: new PluginCommandRegistry("dev.acme.gitgraph", new Set()),
layouts,
menu: new PluginMenuRegistry("dev.acme.gitgraph"),
dispose: async () => {},
};
}
function renderCell(
registry: PluginRuntimeRegistry,
props: Partial<{ cell: CustomPluginLayoutCell; onStateChange: (s: unknown) => void }> = {},
) {
const gateways: Gateways = createMockGateways();
return render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
<PluginLayoutCellView
projectId="proj-1"
cell={props.cell ?? cell()}
onStateChange={props.onStateChange ?? (() => {})}
onOpenPlugins={() => {}}
onChooseAnotherLayout={() => {}}
/>
</PluginRuntimeProvider>
</DIProvider>,
);
}
describe("PluginLayoutCellView", () => {
it("renders the mock registered component when the provider is loaded", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin());
renderCell(registry);
expect(screen.getByTestId("state").textContent).toBe(JSON.stringify({ commits: 3 }));
});
it("round-trips state through setState → onStateChange", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin());
let lastState: unknown = null;
renderCell(registry, {
onStateChange: (s) => {
lastState = s;
},
});
fireEvent.click(screen.getByRole("button", { name: "bump" }));
expect(lastState).toEqual({ commits: 4 });
});
it("renders the non-destructive fallback when the provider is not loaded, without mutating the cell", () => {
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
const testCell = cell();
renderCell(registry, { cell: testCell });
expect(screen.getByText("Layout indisponible")).toBeTruthy();
// The cell object itself is untouched — the domain identity/state survive.
expect(testCell).toEqual(cell());
});
});

View File

@ -0,0 +1,73 @@
/**
* `PluginLayoutCellView` — renders a `customPluginLayout` top-level
* {@link LayoutNode} (ticket #43, F4, carnet v2 §3): the plugin's registered
* React component when its provider is loaded and declares the type,
* otherwise {@link PluginLayoutFallback}.
*
* State is opaque to the domain (`unknown`) and round-trips through
* `LayoutGateway` exactly like any other node field — `setState` here is the
* only way a plugin component is meant to persist it (never direct gateway
* calls from inside a plugin component).
*/
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
import { useGateways } from "@/app/di";
import { usePluginRuntime } from "./PluginRuntimeProvider";
import { PluginLayoutFallback } from "./PluginLayoutFallback";
import { resolvePluginLayoutAvailability } from "./layoutAvailability";
interface PluginLayoutCellViewProps {
projectId: string;
cell: CustomPluginLayoutCell;
installedPlugins?: PluginAdmin[];
onStateChange: (nextState: unknown) => void;
onOpenPlugins: () => void;
onChooseAnotherLayout: () => void;
}
export function PluginLayoutCellView({
projectId,
cell,
installedPlugins,
onStateChange,
onOpenPlugins,
onChooseAnotherLayout,
}: PluginLayoutCellViewProps) {
const { registry } = usePluginRuntime();
const gateways = useGateways();
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
if (availability !== "available") {
const providerDisplayName =
registry.get(cell.pluginId)?.displayName ??
installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName;
return (
<PluginLayoutFallback
cell={cell}
availability={availability}
providerDisplayName={providerDisplayName}
onOpenPlugins={onOpenPlugins}
onChooseAnotherLayout={onChooseAnotherLayout}
/>
);
}
const Component = registry.layoutComponent(cell.pluginId, cell.layoutType)!;
return (
<Component
projectId={projectId}
nodeId={cell.id}
layoutType={cell.layoutType}
state={cell.state}
setState={onStateChange}
availability="available"
gateways={{
project: gateways.project,
git: gateways.git,
terminal: gateways.terminal,
agents: gateways.agent,
system: gateways.system,
}}
/>
);
}

View File

@ -0,0 +1,56 @@
/**
* `PluginLayoutFallback` — shown in place of a plugin-provided layout when its
* provider is unavailable (ticket #43, F4, carnet v2 §3.4): "l'UI rend `Layout
* indisponible`, provenance si connue, actions `Ouvrir Plugins` et `Choisir un
* autre layout`." Never mutates the persisted layout — the domain keeps the
* cell's opaque identity/state untouched so it recovers automatically if the
* plugin comes back (re-enabled/reinstalled) on a later restart.
*
* The canonical `CustomPluginLayoutCell` payload (carnet v2 §3.2) carries no
* display name on the wire — only `pluginId`. The caller resolves a friendly
* name from the plugin admin list/runtime registry when it can; this falls
* back to the raw `pluginId` so provenance is still always shown ("si
* connue" is satisfied by the id itself, which is always known).
*/
import type { CustomPluginLayoutCell, PluginLayoutAvailability } from "@/domain";
import { Button, Panel } from "@/shared";
const REASON_LABEL: Record<Exclude<PluginLayoutAvailability, "available">, string> = {
"plugin-disabled": "Le plugin fournisseur est désactivé.",
"plugin-missing": "Le plugin fournisseur n'est pas installé.",
incompatible: "Le plugin fournisseur ne déclare plus ce type de layout.",
};
interface PluginLayoutFallbackProps {
cell: CustomPluginLayoutCell;
availability: Exclude<PluginLayoutAvailability, "available">;
/** Friendly provider name, resolved by the caller; falls back to `cell.pluginId`. */
providerDisplayName?: string;
onOpenPlugins: () => void;
onChooseAnotherLayout: () => void;
}
export function PluginLayoutFallback({
cell,
availability,
providerDisplayName,
onOpenPlugins,
onChooseAnotherLayout,
}: PluginLayoutFallbackProps) {
return (
<Panel className="flex h-full flex-col items-center justify-center gap-2 text-center">
<p className="text-sm font-semibold text-content">Layout indisponible</p>
<p className="text-xs text-muted">{REASON_LABEL[availability]}</p>
<p className="text-xs text-faint">Fourni par « {providerDisplayName ?? cell.pluginId} »</p>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={onOpenPlugins}>
Ouvrir Plugins
</Button>
<Button size="sm" variant="ghost" onClick={onChooseAnotherLayout}>
Choisir un autre layout
</Button>
</div>
</Panel>
);
}

View File

@ -0,0 +1,69 @@
/**
* F4 — `PluginLayoutSelectorSection` (carnet §10 F4 acceptance criteria:
* "layout disabled non proposé comme nouveau choix").
*/
import { describe, expect, it } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
} from "@/plugins/runtime";
import { PluginLayoutSelectorSection, listPluginLayoutChoices } from "./PluginLayoutSelectorSection";
function stubPlugin(pluginId: string, displayName: string, layoutType: string): LoadedPlugin {
const contributes = {
menus: [],
menuItems: [],
layouts: [{ type: layoutType, label: `${displayName} layout`, component: "X" }],
mcpServers: [],
};
return {
pluginId,
displayName,
contributes,
commands: new PluginCommandRegistry(pluginId, new Set()),
layouts: new PluginLayoutRegistry(pluginId, new Set([layoutType])),
menu: new PluginMenuRegistry(pluginId),
dispose: async () => {},
};
}
describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => {
it("only lists loaded (enabled) plugins' layouts — a disabled plugin is never in the registry", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin("dev.acme.one", "One", "dev.acme.one.layout"));
// "dev.acme.two" is disabled ⇒ never loaded ⇒ never added to the registry
// (carnet §1.3) — nothing to filter here beyond what's already loaded.
const choices = listPluginLayoutChoices(registry);
expect(choices).toHaveLength(1);
expect(choices[0].pluginId).toBe("dev.acme.one");
});
it("renders nothing when there are no plugin layouts", () => {
const { container } = render(
<PluginLayoutSelectorSection registry={new PluginRuntimeRegistry()} onSelect={() => {}} />,
);
expect(container.innerHTML).toBe("");
});
it("calls onSelect with the chosen plugin layout", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin("dev.acme.one", "One", "dev.acme.one.layout"));
let selected: string | null = null;
render(
<PluginLayoutSelectorSection
registry={registry}
onSelect={(choice) => {
selected = choice.layout.type;
}}
/>,
);
fireEvent.click(screen.getByRole("menuitem", { name: /One layout/ }));
expect(selected).toBe("dev.acme.one.layout");
});
});

View File

@ -0,0 +1,73 @@
/**
* `PluginLayoutSelectorSection` — the "Layouts plugins" section building block
* (ticket #43, F4, carnet §10: "Section `Layouts plugins` dans le sélecteur").
*
* Lists every plugin layout contribution currently loaded (disabled/missing
* plugins never appear — carnet §1.3: only `enabled` plugins are loaded, so
* there is nothing to filter here beyond what the registry already omits).
*
* Presentational only; not yet mounted in a layout-creation flow. The
* existing "layout" concept in this codebase (`LayoutTabs`, `LayoutKind`) is
* a whole-tab kind (`"terminal" | "gitGraph"`) picked via a fixed two-item
* dropdown, backed by a `create(name, kind)` Tauri command that only knows
* those two kinds. Wiring an actual "create a plugin layout" action needs a
* backend `LayoutKind`/`create_layout` extension (carnet §10 flags F4 as
* "DevFrontend + DevBackend si ajustement DTO layout") — this component is
* the frontend half, ready to drop into that flow once the DTO lands; see the
* F4 delivery report's open point.
*/
import type { PluginLayoutContribution } from "@/domain";
import type { PluginRuntimeRegistry } from "@/plugins/runtime";
export interface PluginLayoutChoice {
pluginId: string;
pluginDisplayName: string;
layout: PluginLayoutContribution;
}
export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): PluginLayoutChoice[] {
return registry
.layoutContributions()
.map(({ pluginId, pluginDisplayName, layout }) => ({ pluginId, pluginDisplayName, layout }))
.sort(
(a, b) =>
(a.layout.order ?? 0) - (b.layout.order ?? 0) ||
a.pluginDisplayName.localeCompare(b.pluginDisplayName) ||
a.layout.label.localeCompare(b.layout.label),
);
}
interface PluginLayoutSelectorSectionProps {
registry: PluginRuntimeRegistry;
onSelect: (choice: PluginLayoutChoice) => void;
}
export function PluginLayoutSelectorSection({
registry,
onSelect,
}: PluginLayoutSelectorSectionProps) {
const choices = listPluginLayoutChoices(registry);
if (choices.length === 0) return null;
return (
<div role="group" aria-label="Layouts plugins">
<p className="px-3 pt-2 text-[0.65rem] font-semibold uppercase tracking-wide text-faint">
Layouts plugins
</p>
{choices.map((choice) => (
<button
key={`${choice.pluginId}:${choice.layout.type}`}
type="button"
role="menuitem"
aria-label={`create ${choice.layout.label} layout`}
onClick={() => onSelect(choice)}
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm text-content hover:bg-raised"
>
<span>{choice.layout.label}</span>
<span className="text-xs text-faint">{choice.pluginDisplayName}</span>
</button>
))}
</div>
);
}

View File

@ -0,0 +1,99 @@
/**
* `PluginRuntimeProvider` — bootstraps the plugin runtime once per app session
* (ticket #43, F1, carnet §1.3): fetches the runtime catalog (already filtered
* to `enabled && !pendingUninstall` by the backend) and loads every bundle via
* {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry}
* to the rest of the tree (menus in F3, layouts in F4).
*
* Mounted once near the app root, inside `<DIProvider>` (it reads gateways via
* `useGateways()`). Never re-runs within a session — carnet §1.3: disable/
* uninstall only mask contributions in-session; the actual reload happens on
* the next app start (a fresh mount of this provider).
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { loadPlugins, PluginRuntimeRegistry, type PluginLoadFailure } from "@/plugins/runtime";
import { useGateways } from "@/app/di";
export interface PluginRuntimeContextValue {
registry: PluginRuntimeRegistry;
failures: PluginLoadFailure[];
/** True until the initial catalog fetch + bundle loads have settled. */
loading: boolean;
}
/**
* Default value used outside a `<PluginRuntimeProvider>` (e.g. `ProjectsView`
* rendered directly in tests/Storybook without the full app shell): an empty,
* already-settled registry rather than a hard requirement to wrap every call
* site. Plugin contributions are strictly additive, so their absence must
* never be a reason a surface fails to render.
*/
const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = {
registry: new PluginRuntimeRegistry(),
failures: [],
loading: false,
};
const PluginRuntimeContext = createContext<PluginRuntimeContextValue>(EMPTY_PLUGIN_RUNTIME);
interface PluginRuntimeProviderProps {
children: ReactNode;
/** Test/Storybook escape hatch — skips the gateway fetch and uses this value as-is. */
value?: PluginRuntimeContextValue;
}
export function PluginRuntimeProvider({ children, value: injected }: PluginRuntimeProviderProps) {
const gateways = useGateways();
const [value, setValue] = useState<PluginRuntimeContextValue>(
injected ?? {
registry: new PluginRuntimeRegistry(),
failures: [],
loading: true,
},
);
useEffect(() => {
if (injected) return;
let cancelled = false;
gateways.plugin
.listRuntimeContributions()
.then((catalog) =>
loadPlugins(catalog.plugins, {
project: gateways.project,
git: gateways.git,
terminal: gateways.terminal,
agents: gateways.agent,
system: gateways.system,
}),
)
.then((result) => {
if (cancelled) return;
setValue({ registry: result.registry, failures: result.failures, loading: false });
})
.catch(() => {
// No plugin gateway / catalog fetch failed: run with zero plugins
// rather than blocking the app (full-trust plugins are additive).
if (!cancelled) setValue((prev) => ({ ...prev, loading: false }));
});
return () => {
cancelled = true;
};
// Gateways are a stable singleton for the app session (from `useGateways`);
// re-running on every render would reload every plugin bundle.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<PluginRuntimeContext.Provider value={value}>{children}</PluginRuntimeContext.Provider>
);
}
/**
* Reads the loaded plugin runtime registry. Outside a `<PluginRuntimeProvider>`
* this is the empty registry (see {@link EMPTY_PLUGIN_RUNTIME}), never a throw.
*/
export function usePluginRuntime(): PluginRuntimeContextValue {
return useContext(PluginRuntimeContext);
}

View File

@ -0,0 +1,241 @@
/**
* `PluginsPanel` — the `Paramètres > Plugins` admin surface (ticket #43, F2).
*
* Pure presentation; all behaviour comes from {@link usePlugins}. List of
* installed plugins (name/publisher/version/source/state/restartRequired/
* trust), install from archive/directory with a pre-install review step
* (manifest summary + explicit full-trust mention), enable/disable/uninstall
* with confirmation, and readable `pending`/`invalid`/error states.
*/
import { useState } from "react";
import type { PluginAdmin, PluginLifecycleState, PluginReview } from "@/domain";
import { Button, Panel } from "@/shared";
import { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog";
import { usePlugins } from "./usePlugins";
const STATE_LABEL: Record<PluginLifecycleState, string> = {
enabled: "Activé",
disabled: "Désactivé",
"pending-enable": "Activation en attente",
"pending-disable": "Désactivation en attente",
"pending-uninstall": "Désinstallation en attente",
invalid: "Invalide",
};
type PendingAction =
| { kind: "disable"; plugin: PluginAdmin }
| { kind: "uninstall"; plugin: PluginAdmin };
interface InstallFlow {
sourceKind: "archive" | "directory";
path: string;
review: PluginReview;
}
export function PluginsPanel() {
const vm = usePlugins();
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [installFlow, setInstallFlow] = useState<InstallFlow | null>(null);
const [installFlowError, setInstallFlowError] = useState<string | null>(null);
async function startInstallFromArchive() {
setInstallFlowError(null);
const path = await vm.pickArchiveFile();
if (!path) return;
const review = await vm.reviewArchive(path);
if (!review) {
setInstallFlowError("La revue du paquet a échoué.");
return;
}
setInstallFlow({ sourceKind: "archive", path, review });
}
async function startInstallFromDirectory() {
setInstallFlowError(null);
const path = await vm.pickDirectory();
if (!path) return;
const review = await vm.reviewDirectory(path);
if (!review) {
setInstallFlowError("La revue du paquet a échoué.");
return;
}
setInstallFlow({ sourceKind: "directory", path, review });
}
async function confirmInstall() {
if (!installFlow) return;
const ok =
installFlow.sourceKind === "archive"
? await vm.installFromArchive(installFlow.path)
: await vm.installFromDirectory(installFlow.path);
if (ok) setInstallFlow(null);
}
async function confirmPendingAction() {
if (!pendingAction) return;
if (pendingAction.kind === "disable") {
await vm.setEnabled(pendingAction.plugin.id, false);
} else {
await vm.uninstall(pendingAction.plugin.id);
}
setPendingAction(null);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-content">Plugins</h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
Installer depuis une archive
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => void startInstallFromDirectory()}
disabled={vm.busy}
>
Installer depuis un dossier
</Button>
</div>
</div>
{(vm.error || installFlowError) && (
<p role="alert" className="text-sm text-danger">
{vm.error ?? installFlowError}
</p>
)}
{vm.plugins.length === 0 ? (
<Panel>
<p className="text-sm text-muted">Aucun plugin installé.</p>
</Panel>
) : (
<ul className="flex flex-col gap-2">
{vm.plugins.map((p) => (
<li key={p.id}>
<Panel className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-content">{p.displayName}</span>
<span className="text-xs text-faint">v{p.version}</span>
{p.publisher && <span className="text-xs text-faint">· {p.publisher}</span>}
<span className="rounded bg-raised px-1.5 py-0.5 text-[0.65rem] uppercase text-muted">
full-trust
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted">
<span
aria-label="plugin state"
className={
p.lifecycleState === "invalid" ? "text-danger" : undefined
}
>
{STATE_LABEL[p.lifecycleState]}
</span>
{p.restartRequired && (
<span className="text-warning">Redémarrage requis</span>
)}
{p.sourceLabel && <span>· {p.sourceLabel}</span>}
</div>
{p.error && (
<p className="text-xs text-danger" role="alert">
{p.error}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{p.enabled ? (
<Button
size="sm"
variant="ghost"
onClick={() => setPendingAction({ kind: "disable", plugin: p })}
disabled={vm.busy}
>
Désactiver
</Button>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => void vm.setEnabled(p.id, true)}
disabled={vm.busy}
>
Activer
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => void vm.openPluginsFolder(p.id)}
disabled={vm.busy}
>
Ouvrir le dossier
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPendingAction({ kind: "uninstall", plugin: p })}
disabled={vm.busy}
>
Désinstaller
</Button>
</div>
</Panel>
</li>
))}
</ul>
)}
{installFlow && (
<ConfirmDialog
title={`Installer "${installFlow.review.displayName}" ?`}
body={reviewSummary(installFlow.review)}
confirmLabel={installFlow.review.installable ? "Installer" : "Corriger avant d'installer"}
busy={vm.busy}
danger={!installFlow.review.installable}
onConfirm={() => {
if (installFlow.review.installable) void confirmInstall();
else setInstallFlow(null);
}}
onCancel={() => setInstallFlow(null)}
/>
)}
{pendingAction && (
<ConfirmDialog
title={
pendingAction.kind === "disable"
? `Désactiver "${pendingAction.plugin.displayName}" ?`
: `Désinstaller "${pendingAction.plugin.displayName}" ?`
}
body={
pendingAction.kind === "disable"
? "Ses contributions seront masquées immédiatement ; un redémarrage sera peut-être nécessaire pour purger complètement le plugin."
: "Le plugin, ses fichiers et ses contributions seront supprimés après redémarrage."
}
confirmLabel={pendingAction.kind === "disable" ? "Désactiver" : "Désinstaller"}
danger={pendingAction.kind === "uninstall"}
busy={vm.busy}
onConfirm={() => void confirmPendingAction()}
onCancel={() => setPendingAction(null)}
/>
)}
</div>
);
}
function reviewSummary(review: PluginReview): string {
const { contributionSummary: c } = review;
const parts = [
`Éditeur : ${review.publisher ?? "inconnu"}`,
`Version : ${review.version}`,
review.description ?? null,
`Confiance : full-trust — ce plugin peut exécuter du code arbitraire dans IdeA.`,
`Contributions : ${c.topLevelMenus} menu(s), ${c.menuItems} item(s), ${c.layouts} layout(s), ${c.mcpServers} serveur(s) MCP.`,
...review.issues.map((i) => `${i.severity === "error" ? "Erreur" : "Avertissement"} : ${i.message}`),
].filter((p): p is string => Boolean(p));
return parts.join("\n");
}

View File

@ -0,0 +1,13 @@
export { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
export { PluginsPanel } from "./PluginsPanel";
export { usePlugins, type PluginsViewModel } from "./usePlugins";
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus";
export { resolvePluginLayoutAvailability } from "./layoutAvailability";
export { PluginLayoutFallback } from "./PluginLayoutFallback";
export { PluginLayoutCellView } from "./PluginLayoutCellView";
export {
PluginLayoutSelectorSection,
listPluginLayoutChoices,
type PluginLayoutChoice,
} from "./PluginLayoutSelectorSection";

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
} from "@/plugins/runtime";
import { resolvePluginLayoutAvailability } from "./layoutAvailability";
function stubPlugin(pluginId: string, layoutTypes: string[]): LoadedPlugin {
const contributes = {
menus: [],
menuItems: [],
layouts: layoutTypes.map((type) => ({ type, label: type, component: type })),
mcpServers: [],
};
const layouts = new PluginLayoutRegistry(pluginId, new Set(layoutTypes));
for (const type of layoutTypes) {
layouts.register({ type, component: () => null });
}
return {
pluginId,
displayName: pluginId,
contributes,
commands: new PluginCommandRegistry(pluginId, new Set()),
layouts,
menu: new PluginMenuRegistry(pluginId),
dispose: async () => {},
};
}
function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayoutCell {
return {
id: "leaf-1",
pluginId: "dev.acme.gitgraph",
layoutType: "dev.acme.gitgraph.layout",
state: null,
...overrides,
};
}
describe("resolvePluginLayoutAvailability", () => {
it("is available when the provider is loaded and declares the layout type", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin("dev.acme.gitgraph", ["dev.acme.gitgraph.layout"]));
expect(resolvePluginLayoutAvailability(registry, cell())).toBe("available");
});
it("is incompatible when the provider is loaded but no longer declares the type", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin("dev.acme.gitgraph", ["some.other.type"]));
expect(resolvePluginLayoutAvailability(registry, cell())).toBe("incompatible");
});
it("is plugin-missing when the provider is not loaded and not in the admin list", () => {
const registry = new PluginRuntimeRegistry();
expect(resolvePluginLayoutAvailability(registry, cell())).toBe("plugin-missing");
});
it("is plugin-disabled when the provider is not loaded but known-disabled in the admin list", () => {
const registry = new PluginRuntimeRegistry();
const admin: PluginAdmin[] = [
{
id: "dev.acme.gitgraph",
displayName: "Git Graph",
version: "1.0.0",
sourceKind: "archive",
lifecycleState: "disabled",
enabled: false,
pendingUninstall: false,
restartRequired: false,
trustLevel: "full",
contributionSummary: { topLevelMenus: 0, menuItems: 0, layouts: 1, mcpServers: 0 },
},
];
expect(resolvePluginLayoutAvailability(registry, cell(), admin)).toBe("plugin-disabled");
});
});

View File

@ -0,0 +1,36 @@
/**
* Plugin layout availability resolution (ticket #43, F4, carnet v2 §3.4).
*
* A `customPluginLayout` top-level {@link LayoutNode} is opaque domain state —
* the provider plugin may be missing, disabled, or incompatible with the
* running IdeA version at any given session. Availability is never stored in
* the layout itself; it's derived here, purely, from the loaded runtime
* registry, so it's trivially testable without React.
*/
import type { CustomPluginLayoutCell, PluginAdmin, PluginLayoutAvailability } from "@/domain";
import type { PluginRuntimeRegistry } from "@/plugins/runtime";
export function resolvePluginLayoutAvailability(
registry: PluginRuntimeRegistry,
cell: CustomPluginLayoutCell,
/**
* Optional admin list (from `PluginGateway.listPlugins()`) to tell "known but
* disabled" apart from "not installed at all" — the runtime registry alone
* only ever holds *loaded* (enabled) plugins (carnet §1.3), so it can't make
* that distinction by itself. Omit it to fall back to "plugin-missing" for
* both cases (still correct, just less precise fallback copy).
*/
installedPlugins?: PluginAdmin[],
): PluginLayoutAvailability {
const plugin = registry.get(cell.pluginId);
if (!plugin) {
const known = installedPlugins?.find((p) => p.id === cell.pluginId);
return known && !known.enabled ? "plugin-disabled" : "plugin-missing";
}
const declaresType = plugin.contributes.layouts.some((l) => l.type === cell.layoutType);
if (!declaresType) return "incompatible";
const component = plugin.layouts.get(cell.layoutType);
if (!component) return "incompatible";
return "available";
}

View File

@ -0,0 +1,155 @@
/**
* F3 — plugin menu contribution resolution (ticket #43, carnet §10 F3
* acceptance criteria: "ordre déterministe, disabledReason, plugin disabled
* absent, command handler appelé").
*/
import { describe, expect, it } from "vitest";
import type { PluginContributionDto } from "@/domain";
import { PluginRuntimeRegistry, type LoadedPlugin, type WhenContext } from "@/plugins/runtime";
import { PluginCommandRegistry, PluginLayoutRegistry, PluginMenuRegistry } from "@/plugins/runtime";
import { resolveMenuItems, resolveTopLevelMenus } from "./menus";
const NO_CONTEXT: WhenContext = {
projectOpen: false,
gitRepository: false,
agentSelected: false,
terminalFocused: false,
layoutCellFocused: false,
};
function stubPlugin(pluginId: string, displayName: string, contributes: PluginContributionDto): LoadedPlugin {
return {
pluginId,
displayName,
contributes,
commands: new PluginCommandRegistry(pluginId, new Set(contributes.menuItems.map((i) => i.command))),
layouts: new PluginLayoutRegistry(pluginId, new Set(contributes.layouts.map((l) => l.type))),
menu: new PluginMenuRegistry(pluginId),
dispose: async () => {},
};
}
function empty(): PluginContributionDto {
return { menus: [], menuItems: [], layouts: [], mcpServers: [] };
}
describe("resolveTopLevelMenus", () => {
it("sorts by order, then plugin display name, then label", () => {
const registry = new PluginRuntimeRegistry();
registry.add(
stubPlugin("dev.b", "Beta", {
...empty(),
menus: [{ id: "b.menu", label: "B Menu", topLevel: true, order: 0 }],
}),
);
registry.add(
stubPlugin("dev.a", "Alpha", {
...empty(),
menus: [{ id: "a.menu", label: "A Menu", topLevel: true, order: 0 }],
}),
);
registry.add(
stubPlugin("dev.z", "Zulu", {
...empty(),
menus: [{ id: "z.menu", label: "Z Menu", topLevel: true, order: -1 }],
}),
);
const resolved = resolveTopLevelMenus(registry);
expect(resolved.map((m) => m.label)).toEqual(["Z Menu", "A Menu", "B Menu"]);
});
});
describe("resolveMenuItems", () => {
it("only returns items targeting the requested menu, and only from loaded (i.e. enabled) plugins", () => {
const registry = new PluginRuntimeRegistry();
registry.add(
stubPlugin("dev.acme", "Acme", {
...empty(),
menuItems: [
{ id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" },
{ id: "dev.acme.b", targetMenuId: "settings", label: "Open B", command: "dev.acme.b.cmd" },
],
}),
);
// A disabled plugin never appears in the registry at all (carnet §1.3) —
// simulated here by simply not adding it.
const panelsItems = resolveMenuItems(registry, "panels", NO_CONTEXT);
expect(panelsItems).toHaveLength(1);
expect(panelsItems[0].label).toBe("Open A");
const settingsItems = resolveMenuItems(registry, "settings", NO_CONTEXT);
expect(settingsItems).toHaveLength(1);
expect(settingsItems[0].label).toBe("Open B");
});
it("evaluates `when` and disables with a diagnostic reason on failure, enables on success", () => {
const registry = new PluginRuntimeRegistry();
registry.add(
stubPlugin("dev.acme", "Acme", {
...empty(),
menuItems: [
{
id: "dev.acme.needs-git",
targetMenuId: "panels",
label: "Needs git",
command: "dev.acme.git.cmd",
when: "gitRepository",
},
{
id: "dev.acme.broken-when",
targetMenuId: "panels",
label: "Broken when",
command: "dev.acme.broken.cmd",
when: "notAVariable",
},
],
}),
);
const withoutGit = resolveMenuItems(registry, "panels", NO_CONTEXT);
const needsGit = withoutGit.find((i) => i.label === "Needs git")!;
expect(needsGit.enabled).toBe(false);
expect(needsGit.disabledReason).toBeUndefined();
const withGit = resolveMenuItems(registry, "panels", { ...NO_CONTEXT, gitRepository: true });
expect(withGit.find((i) => i.label === "Needs git")!.enabled).toBe(true);
const broken = withoutGit.find((i) => i.label === "Broken when")!;
expect(broken.enabled).toBe(false);
expect(broken.disabledReason).toMatch(/unknown variable/);
});
it("sorts resolved items deterministically", () => {
const registry = new PluginRuntimeRegistry();
registry.add(
stubPlugin("dev.acme", "Acme", {
...empty(),
menuItems: [
{ id: "dev.acme.z", targetMenuId: "panels", label: "Z item", command: "dev.acme.z.cmd", order: 1 },
{ id: "dev.acme.a", targetMenuId: "panels", label: "A item", command: "dev.acme.a.cmd", order: 0 },
],
}),
);
const items = resolveMenuItems(registry, "panels", NO_CONTEXT);
expect(items.map((i) => i.label)).toEqual(["A item", "Z item"]);
});
it("dispatches a command via the registry's runCommand", async () => {
const registry = new PluginRuntimeRegistry();
let ran = false;
const plugin = stubPlugin("dev.acme", "Acme", {
...empty(),
menuItems: [{ id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" }],
});
plugin.commands.register("dev.acme.a.cmd", () => {
ran = true;
});
registry.add(plugin);
await registry.runCommand("dev.acme", "dev.acme.a.cmd");
expect(ran).toBe(true);
});
});

View File

@ -0,0 +1,81 @@
/**
* Plugin menu contribution resolution (ticket #43, F3, carnet §7.1/§7.2).
*
* Pure functions over a {@link PluginRuntimeRegistry} snapshot — no React, so
* they're trivially unit-testable. `ProjectsView` (the menu bar owner) calls
* these to build the extra `MenuBarMenu`/`MenuBarItem` entries it splices in
* between/into the native menus.
*/
import type { MenuTargetId, ResolvedPluginMenuItem } from "@/domain";
import { evaluateWhen, type PluginRuntimeRegistry, type WhenContext } from "@/plugins/runtime";
export interface ResolvedTopLevelMenu {
id: `plugin:${string}`;
pluginId: string;
label: string;
icon?: string;
order: number;
}
/**
* Top-level plugin menus, sorted by `order` then plugin display name then
* label (carnet §7.1) — rendered between `Panneaux` and `Paramètres`.
*/
export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedTopLevelMenu[] {
return registry
.topLevelMenus()
.map(({ pluginId, pluginDisplayName, menu }) => ({
id: `plugin:${menu.id}` as const,
pluginId,
pluginDisplayName,
label: menu.label,
icon: menu.icon,
order: menu.order ?? 0,
}))
.sort(
(a, b) =>
a.order - b.order ||
a.pluginDisplayName.localeCompare(b.pluginDisplayName) ||
a.label.localeCompare(b.label),
);
}
/**
* Menu items contributed into one target menu (native `panels`/`settings` or a
* plugin's own top-level menu, `plugin:<menuId>`), `when`-evaluated and sorted
* by `order` then plugin display name then label (carnet §7.2). A disabled
* plugin contributes nothing (its entries are simply absent from the
* registry, carnet §1.3) — this function has nothing extra to filter for that.
*/
export function resolveMenuItems(
registry: PluginRuntimeRegistry,
targetMenuId: MenuTargetId,
whenCtx: WhenContext,
): ResolvedPluginMenuItem[] {
return registry
.menuItems()
.filter(({ item }) => item.targetMenuId === targetMenuId)
.map(({ pluginId, pluginDisplayName, item }) => {
const result = evaluateWhen(item.when, whenCtx);
return {
id: item.id,
pluginId,
pluginDisplayName,
targetMenuId: item.targetMenuId,
label: item.label,
command: item.command,
enabled: result.ok ? result.value : false,
disabledReason: result.ok ? undefined : result.reason,
groupLabel: pluginDisplayName,
order: item.order ?? 0,
iconUrl: item.icon,
} satisfies ResolvedPluginMenuItem;
})
.sort(
(a, b) =>
a.order - b.order ||
a.pluginDisplayName.localeCompare(b.pluginDisplayName) ||
a.label.localeCompare(b.label),
);
}

View File

@ -0,0 +1,102 @@
/**
* F2 — `PluginsPanel` (ticket #43, carnet §10 F2 acceptance criteria): install
* from archive/directory with pre-install review, full-trust mention visible,
* enable/disable/uninstall flows, and `restartRequired` rendered.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { PluginsPanel } from "./PluginsPanel";
function renderPanel(plugin?: MockPluginGateway, system?: MockSystemGateway) {
const p = plugin ?? new MockPluginGateway();
const s = system ?? new MockSystemGateway();
const gateways = { plugin: p, system: s } as unknown as Gateways;
return {
plugin: p,
system: s,
...render(
<DIProvider gateways={gateways}>
<PluginsPanel />
</DIProvider>,
),
};
}
describe("PluginsPanel", () => {
it("shows an empty state with no plugins installed", async () => {
renderPanel();
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
});
it("installs from an archive via the review dialog, mentioning full-trust", async () => {
renderPanel();
await screen.findByText("Aucun plugin installé.");
fireEvent.click(screen.getByRole("button", { name: "Installer depuis une archive…" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText(/full-trust/)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Installer" }));
// The installed plugin now shows in the list, restart required (carnet §1.4).
expect(await screen.findByText("mock-plugin")).toBeTruthy();
expect(screen.getByText("Redémarrage requis")).toBeTruthy();
expect(screen.getByText("Activé")).toBeTruthy();
});
it("disables an installed plugin after confirmation", async () => {
const plugin = new MockPluginGateway();
plugin._seedPlugin({
id: "dev.acme.one",
displayName: "Acme One",
version: "1.0.0",
sourceKind: "archive",
lifecycleState: "enabled",
enabled: true,
pendingUninstall: false,
restartRequired: false,
trustLevel: "full",
contributionSummary: { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 },
});
renderPanel(plugin);
await screen.findByText("Acme One");
fireEvent.click(screen.getByRole("button", { name: "Désactiver" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Désactiver" }));
await waitFor(() => expect(screen.getByText("Désactivé")).toBeTruthy());
});
it("uninstalls an installed plugin after confirmation", async () => {
const plugin = new MockPluginGateway();
plugin._seedPlugin({
id: "dev.acme.two",
displayName: "Acme Two",
version: "1.0.0",
sourceKind: "directory",
lifecycleState: "enabled",
enabled: true,
pendingUninstall: false,
restartRequired: false,
trustLevel: "full",
contributionSummary: { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 },
});
renderPanel(plugin);
await screen.findByText("Acme Two");
fireEvent.click(screen.getByRole("button", { name: "Désinstaller" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Désinstaller" }));
await waitFor(() => expect(screen.queryByText("Acme Two")).toBeNull());
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
});
});

View File

@ -0,0 +1,63 @@
/**
* `usePluginMenus` — turns the loaded plugin runtime registry into ready-to-
* splice `MenuBarMenu`/`MenuBarItem` entries for `ProjectsView`'s menu bar
* (ticket #43, F3, carnet §7.1/§7.2).
*
* `whenCtx` is supplied by the caller (`ProjectsView` knows `projectOpen`
* directly; git/agent/terminal/cell-focus signals are best-effort — see the
* F3 gap noted in the delivery report: there is no existing global
* agent-selected/terminal-focused/layout-cell-focused signal at the menu-bar
* level in this codebase today, so those three default to `false` until a
* future lot threads real focus state up to this level).
*/
import { useMemo } from "react";
import type { MenuTargetId } from "@/domain";
import type { MenuBarItem, MenuBarMenu } from "@/shared";
import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime";
import { resolveMenuItems, resolveTopLevelMenus } from "./menus";
export interface UsePluginMenusResult {
/** Top-level plugin menus, in order, ready to splice between Panneaux/Paramètres. */
topLevelMenus: MenuBarMenu[];
/** Resolved+ordered `MenuBarItem`s to append to a native menu's items. */
itemsFor: (targetMenuId: MenuTargetId) => MenuBarItem[];
runCommand: (pluginId: string, commandId: string) => Promise<void>;
}
export function usePluginMenus(
registry: PluginRuntimeRegistry,
whenCtx: WhenContext,
): UsePluginMenusResult {
const runCommand = useMemo(
() => (pluginId: string, commandId: string) => registry.runCommand(pluginId, commandId),
[registry],
);
const itemsFor = useMemo(() => {
return (targetMenuId: MenuTargetId): MenuBarItem[] =>
resolveMenuItems(registry, targetMenuId, whenCtx).map((item) => ({
id: item.id,
// Provenance is shown discreetly (carnet §0 UX decision) rather than
// with a visual section divider, which the shared `MenuBar` primitive
// doesn't support — a trailing "· Plugin Name" suffix is the least
// intrusive option that still surfaces where the item came from.
label: `${item.label} · ${item.pluginDisplayName}`,
disabled: !item.enabled,
onSelect: () => {
void runCommand(item.pluginId, item.command);
},
}));
}, [registry, whenCtx, runCommand]);
const topLevelMenus = useMemo<MenuBarMenu[]>(() => {
return resolveTopLevelMenus(registry).map((menu) => ({
id: menu.id,
label: menu.label,
items: itemsFor(menu.id),
}));
}, [registry, itemsFor]);
return { topLevelMenus, itemsFor, runCommand };
}

View File

@ -0,0 +1,181 @@
/**
* `usePlugins` — view-model hook for the `Paramètres > Plugins` admin surface
* (ticket #43, F2). Consumes {@link PluginGateway} exclusively; never touches
* `invoke()` (ARCHITECTURE §1.3), so it is fully testable with the mock.
*/
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, PluginAdmin, PluginReview } from "@/domain";
import type { ReviewPluginPackageInput } from "@/ports";
import { useGateways } from "@/app/di";
export interface PluginsViewModel {
plugins: PluginAdmin[];
error: string | null;
busy: boolean;
refresh: () => Promise<void>;
reviewArchive: (path: string) => Promise<PluginReview | null>;
reviewDirectory: (path: string) => Promise<PluginReview | null>;
installFromArchive: (path: string) => Promise<boolean>;
installFromDirectory: (path: string) => Promise<boolean>;
setEnabled: (pluginId: string, enabled: boolean) => Promise<void>;
uninstall: (pluginId: string) => Promise<void>;
openPluginsFolder: (pluginId?: string) => Promise<void>;
pickArchiveFile: () => Promise<string | null>;
pickDirectory: () => Promise<string | null>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function usePlugins(): PluginsViewModel {
const { plugin, system } = useGateways();
const [plugins, setPlugins] = useState<PluginAdmin[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
setPlugins(await plugin.listPlugins());
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [plugin]);
useEffect(() => {
void refresh();
}, [refresh]);
const review = useCallback(
async (input: ReviewPluginPackageInput): Promise<PluginReview | null> => {
setBusy(true);
setError(null);
try {
return await plugin.reviewPackage(input);
} catch (e) {
setError(describe(e));
return null;
} finally {
setBusy(false);
}
},
[plugin],
);
const reviewArchive = useCallback(
(path: string) => review({ sourceKind: "archive", path }),
[review],
);
const reviewDirectory = useCallback(
(path: string) => review({ sourceKind: "directory", path }),
[review],
);
const installFromArchive = useCallback(
async (path: string): Promise<boolean> => {
setBusy(true);
setError(null);
try {
const result = await plugin.installFromArchive(path);
setPlugins((prev) => [...prev.filter((p) => p.id !== result.plugin.id), result.plugin]);
return true;
} catch (e) {
setError(describe(e));
return false;
} finally {
setBusy(false);
}
},
[plugin],
);
const installFromDirectory = useCallback(
async (path: string): Promise<boolean> => {
setBusy(true);
setError(null);
try {
const result = await plugin.installFromDirectory(path);
setPlugins((prev) => [...prev.filter((p) => p.id !== result.plugin.id), result.plugin]);
return true;
} catch (e) {
setError(describe(e));
return false;
} finally {
setBusy(false);
}
},
[plugin],
);
const setEnabled = useCallback(
async (pluginId: string, enabled: boolean) => {
setBusy(true);
setError(null);
try {
const updated = await plugin.setEnabled(pluginId, enabled);
setPlugins((prev) => prev.map((p) => (p.id === pluginId ? updated : p)));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[plugin],
);
const uninstall = useCallback(
async (pluginId: string) => {
setBusy(true);
setError(null);
try {
await plugin.uninstall(pluginId);
setPlugins((prev) => prev.filter((p) => p.id !== pluginId));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[plugin],
);
const openPluginsFolder = useCallback(
async (pluginId?: string) => {
try {
await plugin.openPluginsFolder(pluginId);
} catch (e) {
setError(describe(e));
}
},
[plugin],
);
const pickArchiveFile = useCallback(() => system.pickArchiveFile(), [system]);
const pickDirectory = useCallback(() => system.pickFolder(), [system]);
return {
plugins,
error,
busy,
refresh,
reviewArchive,
reviewDirectory,
installFromArchive,
installFromDirectory,
setEnabled,
uninstall,
openPluginsFolder,
pickArchiveFile,
pickDirectory,
};
}

View File

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

View File

@ -0,0 +1,169 @@
/**
* #43, F3 — `ProjectsView` wires the `gitRepository` `when` signal to a real
* check (`GitGateway.branches`), not a hardcoded `false` (Architect-mandated,
* blocking for #43 closure). This exercises a plugin menu item declared
* `when: "projectOpen && gitRepository"` end-to-end: disabled for a project
* whose git check fails (not a repo), enabled for one whose check succeeds.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import {
MockAgentGateway,
MockDesktopServerGateway,
MockProfileGateway,
MockProjectGateway,
MockSystemGateway,
MockTemplateGateway,
MockWindowGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import type { GatewayError, GitBranches, GitCommit, GitFileStatus, GraphCommit } from "@/domain";
import type { Gateways, GitGateway } from "@/ports";
import { DIProvider } from "@/app/di";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
} from "@/plugins/runtime";
import { PluginRuntimeProvider } from "@/features/plugins";
import { ProjectsView } from "./ProjectsView";
/** Minimal `GitGateway` double whose `branches()` always rejects — "not a repo". */
class NonRepoGitGateway implements GitGateway {
status(): Promise<GitFileStatus[]> {
return Promise.resolve([]);
}
stage(): Promise<void> {
return Promise.resolve();
}
unstage(): Promise<void> {
return Promise.resolve();
}
commit(): Promise<GitCommit> {
return Promise.reject({ code: "NOT_IMPLEMENTED", message: "n/a" } satisfies GatewayError);
}
branches(): Promise<GitBranches> {
const err: GatewayError = { code: "NOT_A_REPOSITORY", message: "not a git repository" };
return Promise.reject(err);
}
checkout(): Promise<void> {
return Promise.resolve();
}
log(): Promise<GitCommit[]> {
return Promise.resolve([]);
}
init(): Promise<void> {
return Promise.resolve();
}
graph(): Promise<GraphCommit[]> {
return Promise.resolve([]);
}
}
/** Minimal `GitGateway` double whose `branches()` always resolves — a real repo. */
class RealRepoGitGateway extends NonRepoGitGateway {
override branches(): Promise<GitBranches> {
return Promise.resolve({ branches: ["main"], current: "main" });
}
}
function gitAwarePlugin(): LoadedPlugin {
const contributes = {
menus: [],
menuItems: [
{
id: "dev.acme.gitgraph.open",
targetMenuId: "panels" as const,
label: "Ouvrir le graphe Git",
command: "dev.acme.gitgraph.open",
when: "projectOpen && gitRepository",
},
],
layouts: [],
mcpServers: [],
};
return {
pluginId: "dev.acme.gitgraph",
displayName: "Git Graph",
contributes,
commands: new PluginCommandRegistry(
"dev.acme.gitgraph",
new Set(["dev.acme.gitgraph.open"]),
),
layouts: new PluginLayoutRegistry("dev.acme.gitgraph", new Set()),
menu: new PluginMenuRegistry("dev.acme.gitgraph"),
dispose: async () => {},
};
}
function renderWithPlugin(git: GitGateway) {
const agentGateway = new MockAgentGateway();
const gateways = {
system: new MockSystemGateway(),
project: new MockProjectGateway(),
agent: agentGateway,
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
git,
workState: new MockWorkStateGateway(),
window: new MockWindowGateway(),
desktopServer: new MockDesktopServerGateway(),
} as unknown as Gateways;
const registry = new PluginRuntimeRegistry();
registry.add(gitAwarePlugin());
return render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
<ProjectsView />
</PluginRuntimeProvider>
</DIProvider>,
);
}
async function waitForIdle() {
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement).disabled,
).toBe(false),
);
}
async function createProject(name: string, root: string) {
await waitForIdle();
fireEvent.change(screen.getByLabelText("project name"), { target: { value: name } });
fireEvent.change(screen.getByLabelText("project root"), { target: { value: root } });
fireEvent.click(screen.getByRole("button", { name: "Create project" }));
}
function panelsMenuItem(): HTMLButtonElement {
return screen.getByRole("button", { name: /Ouvrir le graphe Git/ }) as HTMLButtonElement;
}
describe("ProjectsView — plugin `gitRepository` when-context wiring", () => {
it("disables the item when the project's git check fails (not a repo)", async () => {
renderWithPlugin(new NonRepoGitGateway());
await createProject("alpha", "/home/me/non-repo");
await screen.findByRole("tab");
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
await waitFor(() => {
expect(panelsMenuItem().disabled).toBe(true);
});
});
it("enables the item once the project's git check succeeds (real repo)", async () => {
renderWithPlugin(new RealRepoGitGateway());
await createProject("beta", "/home/me/real-repo");
await screen.findByRole("tab");
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
await waitFor(() => {
expect(panelsMenuItem().disabled).toBe(false);
});
});
});

View File

@ -24,16 +24,18 @@
import { Button, cn } from "@/shared";
import { ProfilesSettings } from "@/features/first-run";
import { DevicesScreen } from "@/features/devices";
import { PluginsPanel } from "@/features/plugins";
import { DeploymentSettings } from "./DeploymentSettings";
/** The Settings sections, in menu/nav order. */
export type SettingsSection = "aiProfiles" | "deployment" | "devices";
export type SettingsSection = "aiProfiles" | "deployment" | "devices" | "plugins";
/** Human labels, shared by the nav column and the `Settings` menu (ticket #78 — French). */
export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
aiProfiles: "Profils IA",
deployment: "Déploiement",
devices: "Appareils",
plugins: "Plugins",
};
/** Section order — the single source of truth for both nav and menu. */
@ -41,6 +43,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
"aiProfiles",
"deployment",
"devices",
"plugins",
];
interface SettingsViewProps {
@ -93,6 +96,8 @@ export function SettingsView({
<ProfilesSettings />
) : section === "deployment" ? (
<DeploymentSettings />
) : section === "plugins" ? (
<PluginsPanel />
) : (
// No `onSessionEnded`: the desktop app hosts the server and is never
// itself a paired device, so it cannot revoke its own session.