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:
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user