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

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