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

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