feat(frontend): système de plugins — runtime, menus, layouts custom (#43)
Lots F1-F4 : runtime de chargement/registre plugin, extension des menus existants, panneau de gestion des plugins, types de layout custom (sélecteur, fallback, cellule dédiée) branchés sur le port plugin. Suite npm typecheck/test verte (947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -45,6 +45,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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
169
frontend/src/features/projects/pluginGitRepository.test.tsx
Normal file
169
frontend/src/features/projects/pluginGitRepository.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user