feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
103
frontend/src/features/projects/ProjectLauncher.tsx
Normal file
103
frontend/src/features/projects/ProjectLauncher.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* `ProjectLauncher` — the empty-state screen shown when no project tab is open.
|
||||
*
|
||||
* Renders the project creation form and the known-projects list centred in the
|
||||
* available space. All behaviour comes from the `vm` prop (a `useProjects`
|
||||
* view-model slice); no hooks called here — presentation only.
|
||||
*/
|
||||
|
||||
import type { Project } from "@/domain";
|
||||
import { Button, Input, Panel } from "@/shared";
|
||||
|
||||
export interface ProjectLauncherProps {
|
||||
/** Form field values */
|
||||
name: string;
|
||||
root: string;
|
||||
onNameChange: (v: string) => void;
|
||||
onRootChange: (v: string) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
canCreate: boolean;
|
||||
busy: boolean;
|
||||
onRefresh: () => void;
|
||||
projects: Project[];
|
||||
onOpen: (id: string) => void;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function ProjectLauncher({
|
||||
name,
|
||||
root,
|
||||
onNameChange,
|
||||
onRootChange,
|
||||
onSubmit,
|
||||
canCreate,
|
||||
busy,
|
||||
onRefresh,
|
||||
projects,
|
||||
onOpen,
|
||||
error,
|
||||
}: ProjectLauncherProps) {
|
||||
return (
|
||||
<div className="flex flex-1 items-start justify-center p-8">
|
||||
<div className="flex w-full max-w-2xl flex-col gap-6">
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-content">New project</h2>
|
||||
<form onSubmit={onSubmit} className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
aria-label="project name"
|
||||
placeholder="Project name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
<Input
|
||||
aria-label="project root"
|
||||
placeholder="/absolute/project/root"
|
||||
value={root}
|
||||
onChange={(e) => onRootChange(e.target.value)}
|
||||
className="min-w-80 flex-1"
|
||||
/>
|
||||
<Button type="submit" variant="primary" disabled={!canCreate}>
|
||||
Create project
|
||||
</Button>
|
||||
<Button type="button" onClick={onRefresh} disabled={busy}>
|
||||
Refresh
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Panel title="Known projects">
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-sm text-muted">No projects yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{projects.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="font-medium text-content">{p.name}</span>
|
||||
<code className="truncate text-xs text-muted">{p.root}</code>
|
||||
</span>
|
||||
<Button size="sm" onClick={() => onOpen(p.id)}>
|
||||
Open
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/src/features/projects/ProjectTabs.tsx
Normal file
59
frontend/src/features/projects/ProjectTabs.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* `ProjectTabs` — the project tab bar rendered below the app header.
|
||||
*
|
||||
* Shows one tab per open project with a close control, and a "+" button to
|
||||
* return to the launcher. Purely presentational: all state lives in the
|
||||
* parent (ProjectsView via useProjects).
|
||||
*/
|
||||
|
||||
import type { TabItem } from "@/shared";
|
||||
import { Button, Tabs, cn } from "@/shared";
|
||||
|
||||
export interface ProjectTabsProps {
|
||||
items: TabItem[];
|
||||
activeTabId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
/** Whether the launcher overlay is currently visible (no active tab). */
|
||||
showingLauncher: boolean;
|
||||
onShowLauncher: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectTabs({
|
||||
items,
|
||||
activeTabId,
|
||||
onSelect,
|
||||
onClose,
|
||||
showingLauncher,
|
||||
onShowLauncher,
|
||||
className,
|
||||
}: ProjectTabsProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 border-b border-border bg-surface px-2 py-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
items={items}
|
||||
value={activeTabId}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showingLauncher ? "secondary" : "ghost"}
|
||||
onClick={onShowLauncher}
|
||||
aria-label="open project launcher"
|
||||
className="shrink-0"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
frontend/src/features/projects/ProjectsView.tsx
Normal file
303
frontend/src/features/projects/ProjectsView.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
/**
|
||||
* `ProjectsView` — the top-level project surface (L2 / L11).
|
||||
*
|
||||
* IDE layout (full remaining height):
|
||||
*
|
||||
* ┌───────────────────────────────────────────────────────┐
|
||||
* │ PROJECT TABS [ alpha × ][ beta × ] [+] │
|
||||
* ├───────────────┬───────────────────────────────────────┤
|
||||
* │ SIDEBAR ≈320px│ MAIN — LayoutGrid (fills remaining) │
|
||||
* │ [Proj][Agents]│ — or welcome screen when no active │
|
||||
* │ [Tmpl][Git] │ │
|
||||
* │ panel body │ │
|
||||
* └───────────────┴───────────────────────────────────────┘
|
||||
*
|
||||
* The "Projects" sidebar tab always hosts the create-project form and the
|
||||
* known-projects list, ensuring they are always in the DOM (required by tests).
|
||||
*
|
||||
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
|
||||
* `@/shared`; no inline styles, no `invoke()`.
|
||||
*
|
||||
* **Test-contract** — the following query hooks from `projects.test.tsx` are
|
||||
* always present in the DOM regardless of which sidebar tab is active:
|
||||
* - getByLabelText("project name") / ("project root") — form inputs
|
||||
* - getByRole("button", { name: "Create project" }) / "Refresh" / "Open" / "close <name>"
|
||||
* - getByRole("tablist") + getAllByRole("tab") + aria-selected — project tabs
|
||||
* - getByText("No projects yet.") / "No open tabs." — empty states
|
||||
* - getAllByRole("listitem") — known-projects list
|
||||
* - getByRole("alert") — error display
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { LayoutInfo } from "@/domain";
|
||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
import { GitPanel, GitGraphView } from "@/features/git";
|
||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useProjects } from "./useProjects";
|
||||
|
||||
type SidebarTab = "projects" | "agents" | "templates" | "git";
|
||||
|
||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||
{ id: "projects", label: "Projects" },
|
||||
{ id: "agents", label: "Agents" },
|
||||
{ id: "templates", label: "Templates" },
|
||||
{ id: "git", label: "Git" },
|
||||
];
|
||||
|
||||
export function ProjectsView() {
|
||||
const vm = useProjects();
|
||||
const { system } = useGateways();
|
||||
const [name, setName] = useState("");
|
||||
const [root, setRoot] = useState("");
|
||||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("projects");
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
// The active layout (id + kind), reported by LayoutTabs — the single source of
|
||||
// truth. `kind` decides whether the main area is the terminal grid or the git
|
||||
// graph view.
|
||||
const [activeLayout, setActiveLayout] = useState<LayoutInfo | null>(null);
|
||||
|
||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||
|
||||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||||
|
||||
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canCreate) return;
|
||||
await vm.createProject(name.trim(), root.trim());
|
||||
setName("");
|
||||
setRoot("");
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
const picked = await system.pickFolder();
|
||||
if (picked !== null) setRoot(picked);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* ── Error alert ── */}
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-2 shrink-0 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Project tab bar ── */}
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1">
|
||||
{vm.openTabs.length === 0 ? (
|
||||
<p className="px-2 text-sm text-muted">No open tabs.</p>
|
||||
) : (
|
||||
<Tabs
|
||||
items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))}
|
||||
value={vm.activeTabId}
|
||||
onSelect={(id) => vm.activateTab(id)}
|
||||
onClose={(id) => void vm.closeTab(id)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── IDE body: sidebar + main ── */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ── Collapsed sidebar: a thin rail with an expand button ── */}
|
||||
{sidebarCollapsed && (
|
||||
<aside className="flex w-9 shrink-0 flex-col items-center border-r border-border bg-surface py-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="expand sidebar"
|
||||
title="Expand sidebar"
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-muted hover:bg-raised hover:text-content"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* ── Sidebar ── */}
|
||||
<aside
|
||||
className={cn(
|
||||
"flex w-80 shrink-0 flex-col border-r border-border bg-surface",
|
||||
sidebarCollapsed && "hidden",
|
||||
)}
|
||||
>
|
||||
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs) */}
|
||||
<div className="flex shrink-0 items-stretch border-b border-border">
|
||||
{SIDEBAR_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
aria-selected={sidebarTab === t.id}
|
||||
onClick={() => setSidebarTab(t.id)}
|
||||
className={cn(
|
||||
"flex-1 px-2 py-2 text-xs font-medium transition-colors",
|
||||
sidebarTab === t.id
|
||||
? "border-b-2 border-primary text-content"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="shrink-0 px-2 text-muted hover:text-content"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar panel body */}
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{/* Projects panel */}
|
||||
{sidebarTab === "projects" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Create form */}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
New project
|
||||
</h3>
|
||||
<Input
|
||||
aria-label="project name"
|
||||
placeholder="Project name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label="project root"
|
||||
placeholder="/absolute/project/root"
|
||||
value={root}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="browse project folder"
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Browse…
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canCreate}
|
||||
className="flex-1"
|
||||
>
|
||||
Create project
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void vm.refresh()}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Known projects list */}
|
||||
<Panel title="Known projects">
|
||||
{vm.projects.length === 0 ? (
|
||||
<p className="text-sm text-muted">No projects yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.projects.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-2 py-2 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="font-medium text-content">{p.name}</span>
|
||||
<code className="truncate text-xs text-muted">{p.root}</code>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void vm.openProject(p.id)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agents panel — only rendered when active project exists */}
|
||||
{sidebarTab === "agents" && active && (
|
||||
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
||||
)}
|
||||
{sidebarTab === "agents" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to manage agents.</p>
|
||||
)}
|
||||
|
||||
{/* Templates panel */}
|
||||
{sidebarTab === "templates" && active && (
|
||||
<TemplatesPanel projectId={active.id} />
|
||||
)}
|
||||
{sidebarTab === "templates" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to manage templates.</p>
|
||||
)}
|
||||
|
||||
{/* Git panel */}
|
||||
{sidebarTab === "git" && active && (
|
||||
<GitPanel projectId={active.id} />
|
||||
)}
|
||||
{sidebarTab === "git" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to manage git.</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Main: terminal grid or git graph (fills remaining height) ── */}
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
{active ? (
|
||||
<>
|
||||
<LayoutTabs
|
||||
projectId={active.id}
|
||||
onActiveLayoutChange={setActiveLayout}
|
||||
/>
|
||||
{activeLayoutKind === "gitGraph" ? (
|
||||
<GitGraphView
|
||||
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
|
||||
projectId={active.id}
|
||||
/>
|
||||
) : (
|
||||
<LayoutGrid
|
||||
key={`${active.id}-${activeLayout?.id ?? "default"}`}
|
||||
projectId={active.id}
|
||||
cwd={active.root}
|
||||
layoutId={activeLayout?.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-muted">
|
||||
<p className="text-sm">Select or create a project to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/features/projects/index.ts
Normal file
5
frontend/src/features/projects/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/** Projects feature (L2): create/list/open projects, basic tab bar. */
|
||||
|
||||
export { ProjectsView } from "./ProjectsView";
|
||||
export { useProjects } from "./useProjects";
|
||||
export type { ProjectsViewModel } from "./useProjects";
|
||||
243
frontend/src/features/projects/projects.test.tsx
Normal file
243
frontend/src/features/projects/projects.test.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
/**
|
||||
* L2 — the projects feature wired to the stateful `MockProjectGateway` via the
|
||||
* real `DIProvider`. Covers create → list/tab, open → tab, close → remove,
|
||||
* duplicate root (INVALID) and unknown id (NOT_FOUND) error handling.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
/** Renders the view behind a DI provider seeded with a fresh project gateway. */
|
||||
function renderView(
|
||||
project: MockProjectGateway = new MockProjectGateway(),
|
||||
system: MockSystemGateway = new MockSystemGateway(),
|
||||
) {
|
||||
// The project gateway drives the primary assertions; agent + profile stubs are
|
||||
// required because ProjectsView now renders AgentsPanel for the active tab.
|
||||
// template gateway is required because ProjectsView now renders TemplatesPanel.
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const gateways = {
|
||||
system,
|
||||
project,
|
||||
agent: agentGateway,
|
||||
profile: new MockProfileGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
git: new MockGitGateway(),
|
||||
} as unknown as Gateways;
|
||||
return {
|
||||
project,
|
||||
system,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectsView />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Waits for the initial async refresh to settle (Refresh button re-enabled). */
|
||||
async function waitForIdle() {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
}
|
||||
|
||||
/** Fills the create form and submits it (assumes the form is idle/enabled). */
|
||||
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 tablist() {
|
||||
return screen.getByRole("tablist");
|
||||
}
|
||||
|
||||
describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
it("creating a project adds it to the known list and opens a tab", async () => {
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText("No projects yet.")).toBeTruthy();
|
||||
|
||||
await createProject("alpha", "/home/me/alpha");
|
||||
|
||||
// The created project shows as an active tab once the request settles.
|
||||
const tab = await screen.findByRole("tab");
|
||||
expect(tab.getAttribute("aria-selected")).toBe("true");
|
||||
expect(within(tab).getByText("alpha")).toBeTruthy();
|
||||
// And the root is rendered (known-projects list + active-tab panel).
|
||||
expect(screen.getAllByText("/home/me/alpha").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("opening a project creates a tab and makes it the active one", async () => {
|
||||
// Seed two projects directly through the stateful gateway.
|
||||
const project = new MockProjectGateway();
|
||||
const a = await project.createProject("alpha", "/p/a");
|
||||
const b = await project.createProject("beta", "/p/b");
|
||||
renderView(project);
|
||||
|
||||
// Wait for the initial refresh to list both.
|
||||
await screen.findByText("/p/a");
|
||||
await screen.findByText("/p/b");
|
||||
|
||||
// Open alpha, then beta.
|
||||
const items = screen.getAllByRole("listitem");
|
||||
const openA = within(
|
||||
items.find((li) => within(li).queryByText("alpha"))!,
|
||||
).getByRole("button", { name: "Open" });
|
||||
fireEvent.click(openA);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
|
||||
const openB = within(
|
||||
items.find((li) => within(li).queryByText("beta"))!,
|
||||
).getByRole("button", { name: "Open" });
|
||||
fireEvent.click(openB);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(within(tablist()).getAllByRole("tab")).toHaveLength(2),
|
||||
);
|
||||
|
||||
// The last-opened tab (beta) is active.
|
||||
const tabs = within(tablist()).getAllByRole("tab");
|
||||
const betaTab = tabs.find((t) => within(t).queryByText("beta"))!;
|
||||
const alphaTab = tabs.find((t) => within(t).queryByText("alpha"))!;
|
||||
expect(betaTab.getAttribute("aria-selected")).toBe("true");
|
||||
expect(alphaTab.getAttribute("aria-selected")).toBe("false");
|
||||
|
||||
// Ignore unused binding lint for `a`/`b`.
|
||||
expect([a.id, b.id]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("closing a tab removes it from the tab bar", async () => {
|
||||
renderView();
|
||||
await createProject("alpha", "/home/me/alpha");
|
||||
|
||||
await screen.findByRole("tab");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No open tabs.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("creating a project with a duplicate root surfaces an INVALID error", async () => {
|
||||
renderView();
|
||||
|
||||
await createProject("alpha", "/dup/root");
|
||||
await screen.findByRole("tab"); // first project opened as a tab
|
||||
|
||||
// Second project at the same root.
|
||||
await createProject("beta", "/dup/root");
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(/already exists/i);
|
||||
});
|
||||
|
||||
it("sidebar tab switch shows the selected panel and hides others", async () => {
|
||||
// Set up a project so that agent/template/git panels are reachable.
|
||||
const project = new MockProjectGateway();
|
||||
await project.createProject("alpha", "/p/a");
|
||||
renderView(project);
|
||||
|
||||
// Open alpha so the sidebar panels become active.
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
await waitFor(() =>
|
||||
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
|
||||
);
|
||||
|
||||
// Default sidebar tab is "Projects" — the form inputs must be accessible.
|
||||
expect(screen.getByLabelText("project name")).toBeTruthy();
|
||||
expect(screen.getByLabelText("project root")).toBeTruthy();
|
||||
|
||||
// Switch to Agents tab — agent form should be visible.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
|
||||
// Agent panel renders an "agent name" input; project form is now hidden.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("agent name")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("project name")).toBeNull();
|
||||
|
||||
// Switch to Templates tab — the "New template" button should be visible.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Templates" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("agent name")).toBeNull();
|
||||
|
||||
// Switch to Git tab — commit message input should be visible.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Git" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("commit message")).toBeTruthy(),
|
||||
);
|
||||
|
||||
// Switch back to Projects tab — form is accessible again.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Projects" }));
|
||||
expect(screen.getByLabelText("project name")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Browse… button calls pickFolder and fills the root field with the returned path", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
renderView(new MockProjectGateway(), system);
|
||||
await waitForIdle();
|
||||
|
||||
// Root field starts empty.
|
||||
const rootInput = screen.getByLabelText("project root") as HTMLInputElement;
|
||||
expect(rootInput.value).toBe("");
|
||||
|
||||
// Click Browse… — the mock returns "/home/user/mock-project".
|
||||
fireEvent.click(screen.getByRole("button", { name: "browse project folder" }));
|
||||
|
||||
// Wait for the async pickFolder to fill the field.
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("project root") as HTMLInputElement).value).toBe(
|
||||
"/home/user/mock-project",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("opening an unknown project id surfaces a NOT_FOUND error", async () => {
|
||||
// Stub a stale list entry whose id the gateway won't resolve.
|
||||
const project = new MockProjectGateway();
|
||||
project.listProjects = async () => [
|
||||
{
|
||||
id: "ghost-id",
|
||||
name: "ghost",
|
||||
root: "/p/ghost",
|
||||
remote: { kind: "local" },
|
||||
createdAt: 0,
|
||||
},
|
||||
];
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/ghost");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(/not found/i);
|
||||
expect(screen.getByText("No open tabs.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
145
frontend/src/features/projects/useProjects.ts
Normal file
145
frontend/src/features/projects/useProjects.ts
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* `useProjects` — view-model hook for the projects feature (L2).
|
||||
*
|
||||
* Owns the projects feature state (known projects + open tabs) and exposes the
|
||||
* actions the UI triggers. It consumes the {@link ProjectGateway} exclusively;
|
||||
* it never touches `invoke()` or `@tauri-apps/api`, keeping the component layer
|
||||
* testable with a mock gateway (ARCHITECTURE §1.3).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError, Project } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the projects UI needs from this hook. */
|
||||
export interface ProjectsViewModel {
|
||||
/** All projects known to the registry. */
|
||||
projects: Project[];
|
||||
/** Projects currently open as tabs (in open order). */
|
||||
openTabs: Project[];
|
||||
/** Id of the active tab, or `null` when none is open. */
|
||||
activeTabId: string | null;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Reloads the known-projects list from the registry. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Creates a project, refreshes the list and opens it as a tab. */
|
||||
createProject: (name: string, root: string) => Promise<void>;
|
||||
/** Opens a project as a tab (no-op if already open; just activates it). */
|
||||
openProject: (projectId: string) => Promise<void>;
|
||||
/** Closes a tab (does not delete the project from the registry). */
|
||||
closeTab: (projectId: string) => Promise<void>;
|
||||
/** Activates an already-open tab. */
|
||||
activateTab: (projectId: string) => void;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function useProjects(): ProjectsViewModel {
|
||||
const { project } = useGateways();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [openTabs, setOpenTabs] = useState<Project[]>([]);
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProjects(await project.listProjects());
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const addTab = useCallback((p: Project) => {
|
||||
setOpenTabs((prev) =>
|
||||
prev.some((t) => t.id === p.id) ? prev : [...prev, p],
|
||||
);
|
||||
setActiveTabId(p.id);
|
||||
}, []);
|
||||
|
||||
const openProject = useCallback(
|
||||
async (projectId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const opened = await project.openProject(projectId);
|
||||
addTab(opened);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[project, addTab],
|
||||
);
|
||||
|
||||
const createProject = useCallback(
|
||||
async (name: string, root: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await project.createProject(name, root);
|
||||
setProjects((prev) => [...prev, created]);
|
||||
addTab(created);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[project, addTab],
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
async (projectId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await project.closeProject(projectId);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
setOpenTabs((prev) => {
|
||||
const next = prev.filter((t) => t.id !== projectId);
|
||||
setActiveTabId((current) =>
|
||||
current === projectId ? (next.at(-1)?.id ?? null) : current,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[project],
|
||||
);
|
||||
|
||||
const activateTab = useCallback((projectId: string) => {
|
||||
setActiveTabId(projectId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
projects,
|
||||
openTabs,
|
||||
activeTabId,
|
||||
error,
|
||||
busy,
|
||||
refresh,
|
||||
createProject,
|
||||
openProject,
|
||||
closeTab,
|
||||
activateTab,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user