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:
105
frontend/src/features/layout/LayoutGrid.test.tsx
Normal file
105
frontend/src/features/layout/LayoutGrid.test.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
/**
|
||||
* L4 — {@link LayoutGrid} wired through the real {@link DIProvider} with the
|
||||
* in-memory {@link MockLayoutGateway} (+ {@link MockTerminalGateway} for the
|
||||
* leaves' embedded {@link TerminalView}).
|
||||
*
|
||||
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
|
||||
* so these tests assert the *structure* the grid renders (cells, no throw)
|
||||
* rather than xterm's visual output. They stay robust whether or not xterm
|
||||
* bailed.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { leaves } from "./layout";
|
||||
import { LayoutGrid } from "./LayoutGrid";
|
||||
|
||||
function renderGrid(layout: MockLayoutGateway, projectId = "p1") {
|
||||
const gateways = {
|
||||
layout,
|
||||
terminal: new MockTerminalGateway(),
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<LayoutGrid projectId={projectId} cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
it("renders a single-leaf layout without throwing", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
expect(() => renderGrid(layout)).not.toThrow();
|
||||
|
||||
// The grid container always renders.
|
||||
expect(screen.getByTestId("layout-grid")).toBeTruthy();
|
||||
// Once loaded, the single leaf cell is shown.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders N cells for a pre-split tree", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
// Pre-split the project's tree into two leaves before rendering.
|
||||
const initial = await layout.loadLayout("p1");
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "split",
|
||||
target: leaves(initial)[0].id,
|
||||
direction: "row",
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
|
||||
renderGrid(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2);
|
||||
});
|
||||
// The split container is present with its direction recorded.
|
||||
const split = screen.getByTestId("layout-split");
|
||||
expect(split.getAttribute("data-direction")).toBe("row");
|
||||
});
|
||||
|
||||
it("stays graceful (no throw) even though xterm bails under jsdom", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
// Render a 3-leaf tree: nested splits, each leaf hosts a TerminalView.
|
||||
const initial = await layout.loadLayout("p1");
|
||||
const a = leaves(initial)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "split",
|
||||
target: a,
|
||||
direction: "row",
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "split",
|
||||
target: "b",
|
||||
direction: "column",
|
||||
newLeaf: "d",
|
||||
container: "e",
|
||||
});
|
||||
|
||||
expect(() => renderGrid(layout)).not.toThrow();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the loading/empty container when the layout gateway is absent", () => {
|
||||
// A DI subset without the layout gateway → useLayout is defensive (null).
|
||||
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
|
||||
expect(() =>
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/c" />
|
||||
</DIProvider>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByTestId("layout-grid")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
406
frontend/src/features/layout/LayoutGrid.tsx
Normal file
406
frontend/src/features/layout/LayoutGrid.tsx
Normal file
@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Recursive terminal-layout grid (L4). Renders a {@link LayoutTree} as nested
|
||||
* `Split` / `Grid` containers down to `Leaf` cells, each hosting a
|
||||
* {@link TerminalView} (L3). Provides the spreadsheet-style interactions:
|
||||
*
|
||||
* - **resize**: drag the separator between two split children → recomputes the
|
||||
* two adjacent weights (`resizeAdjacent`, pure) → `mutateLayout` Resize;
|
||||
* - **split**: per-cell buttons split a leaf into rows/columns → Split;
|
||||
* - **merge**: when a split has > 1 child, a cell can collapse its parent split
|
||||
* onto itself (spreadsheet-like cell merge) → Merge.
|
||||
* - **agent**: each leaf cell has a dropdown to pin an agent; persisted via
|
||||
* `setCellAgent` in the layout (#3).
|
||||
*
|
||||
* Pure presentation: all behaviour comes from {@link useLayout}, which speaks to
|
||||
* the {@link LayoutGateway} port — no `invoke()` here. Track *sizing* is the pure
|
||||
* {@link normalizeWeights} function, kept out of the render for testability.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Agent } from "@/domain";
|
||||
import type { LayoutNode } from "@/domain";
|
||||
import { TerminalView } from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { normalizeWeights, resizeAdjacent } from "./layout";
|
||||
import { useLayout, type LayoutViewModel } from "./useLayout";
|
||||
|
||||
interface LayoutGridProps {
|
||||
/** Project whose layout to render. */
|
||||
projectId: string;
|
||||
/** Working directory new terminals open in (the project root). */
|
||||
cwd: string;
|
||||
/** Active layout id; when provided the grid loads/mutates this layout. */
|
||||
layoutId?: string;
|
||||
}
|
||||
|
||||
export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
|
||||
const vm = useLayout(projectId, layoutId);
|
||||
|
||||
if (!vm.layout) {
|
||||
return (
|
||||
<div data-testid="layout-grid" style={{ width: "100%", height: "100%" }}>
|
||||
{vm.error ? (
|
||||
<p role="alert" style={{ color: "crimson" }}>
|
||||
{vm.error}
|
||||
</p>
|
||||
) : (
|
||||
<p>Loading layout…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="layout-grid"
|
||||
style={{ width: "100%", height: "100%", position: "relative" }}
|
||||
>
|
||||
{vm.error && (
|
||||
<p role="alert" style={{ color: "crimson", margin: 0 }}>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
<NodeView
|
||||
node={vm.layout.root}
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
parentSplit={null}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NodeViewProps {
|
||||
node: LayoutNode;
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
/** The enclosing split + this node's index in it, for the merge action. */
|
||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
|
||||
switch (node.type) {
|
||||
case "leaf":
|
||||
return (
|
||||
<LeafView
|
||||
id={node.node.id}
|
||||
session={node.node.session ?? null}
|
||||
agent={node.node.agent ?? null}
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
parentSplit={parentSplit}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
case "split":
|
||||
return <SplitView split={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
|
||||
case "grid":
|
||||
return <GridView grid={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface LeafViewProps {
|
||||
id: string;
|
||||
session: string | null;
|
||||
agent: string | null;
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
||||
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
|
||||
const { agent: agentGateway } = useGateways();
|
||||
|
||||
// Load the project's agents for the dropdown.
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
useEffect(() => {
|
||||
if (!agentGateway) return;
|
||||
let cancelled = false;
|
||||
agentGateway.listAgents(projectId).then((list) => {
|
||||
if (!cancelled) setAgents(list);
|
||||
}).catch(() => {/* ignore — dropdown stays empty */});
|
||||
return () => { cancelled = true; };
|
||||
}, [agentGateway, projectId]);
|
||||
|
||||
// Build the terminal opener based on whether an agent is pinned.
|
||||
const agentId = agent ?? null;
|
||||
const terminalOpener = agentGateway && agentId
|
||||
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
|
||||
agentGateway.launchAgent(projectId, agentId, opts, onData)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="layout-leaf"
|
||||
data-node-id={id}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
border: "1px solid #2a2a2a",
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
right: 2,
|
||||
zIndex: 2,
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Agent selector */}
|
||||
<select
|
||||
aria-label={`agent selector ${id}`}
|
||||
value={agentId ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
void vm.setCellAgent(id, val === "" ? null : val);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
color: "var(--color-content, #e0e0e0)",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 3,
|
||||
padding: "1px 2px",
|
||||
maxWidth: 100,
|
||||
}}
|
||||
>
|
||||
<option value="">Plain</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Split into columns"
|
||||
aria-label={`split ${id} columns`}
|
||||
onClick={() => void vm.split(id, "row")}
|
||||
>
|
||||
⬌
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Split into rows"
|
||||
aria-label={`split ${id} rows`}
|
||||
onClick={() => void vm.split(id, "column")}
|
||||
>
|
||||
⬍
|
||||
</button>
|
||||
{canMerge && (
|
||||
<button
|
||||
type="button"
|
||||
title="Merge: keep this cell, drop its siblings"
|
||||
aria-label={`merge ${id}`}
|
||||
onClick={() =>
|
||||
void vm.merge(parentSplit.container, parentSplit.index)
|
||||
}
|
||||
>
|
||||
⤬
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener. */}
|
||||
<TerminalView key={`${id}-${agentId ?? "plain"}`} cwd={cwd} open={terminalOpener} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SplitViewProps {
|
||||
split: Extract<LayoutNode, { type: "split" }>["node"];
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
|
||||
const isRow = split.direction === "row";
|
||||
const baseWeights = split.children.map((c) => c.weight);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
// Live drag preview: while a separator is dragged we override the rendered
|
||||
// sizes locally (so the split moves under the cursor) and only commit the new
|
||||
// weights to the backend on release (avoids a mutate round-trip per mousemove).
|
||||
const [dragWeights, setDragWeights] = useState<number[] | null>(null);
|
||||
const sizes = normalizeWeights(dragWeights ?? baseWeights);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="layout-split"
|
||||
data-direction={split.direction}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: isRow ? "row" : "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{split.children.map((child, i) => (
|
||||
<div key={keyOf(child.node, i)} style={{ display: "contents" }}>
|
||||
<div
|
||||
style={{
|
||||
flexBasis: `${sizes[i]}%`,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<NodeView
|
||||
node={child.node}
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
projectId={projectId}
|
||||
parentSplit={{
|
||||
container: split.id,
|
||||
index: i,
|
||||
siblings: split.children.length,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{i < split.children.length - 1 && (
|
||||
<Separator
|
||||
isRow={isRow}
|
||||
container={containerRef}
|
||||
onDragMove={(deltaFraction) =>
|
||||
setDragWeights(resizeAdjacent(baseWeights, i, deltaFraction))
|
||||
}
|
||||
onDragEnd={(deltaFraction) => {
|
||||
const weights = resizeAdjacent(baseWeights, i, deltaFraction);
|
||||
setDragWeights(null);
|
||||
void vm.resize(split.id, weights);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SeparatorProps {
|
||||
isRow: boolean;
|
||||
container: React.RefObject<HTMLDivElement | null>;
|
||||
/** Called continuously during the drag with the signed fraction of the container extent (live preview). */
|
||||
onDragMove: (deltaFraction: number) => void;
|
||||
/** Called on release with the final fraction (commit). */
|
||||
onDragEnd: (deltaFraction: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A draggable separator between two split children. It captures the pointer and
|
||||
* reports the dragged distance (as a fraction of the container extent) **live**
|
||||
* on every move so the split tracks the cursor, then once more on release so the
|
||||
* parent can commit the new weights.
|
||||
*/
|
||||
function Separator({ isRow, container, onDragMove, onDragEnd }: SeparatorProps) {
|
||||
const startRef = useRef<number | null>(null);
|
||||
|
||||
function fraction(clientPos: number): number | null {
|
||||
if (startRef.current === null) return null;
|
||||
const rect = container.current?.getBoundingClientRect();
|
||||
const extent = rect ? (isRow ? rect.width : rect.height) : 0;
|
||||
if (extent <= 0) return null;
|
||||
return (clientPos - startRef.current) / extent;
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
startRef.current = isRow ? e.clientX : e.clientY;
|
||||
}
|
||||
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (startRef.current === null) return;
|
||||
const f = fraction(isRow ? e.clientX : e.clientY);
|
||||
if (f !== null) onDragMove(f);
|
||||
}
|
||||
function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
|
||||
const f = fraction(isRow ? e.clientX : e.clientY);
|
||||
startRef.current = null;
|
||||
if (f !== null) onDragEnd(f);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={isRow ? "vertical" : "horizontal"}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{
|
||||
flex: "0 0 6px",
|
||||
cursor: isRow ? "col-resize" : "row-resize",
|
||||
background: "#3a3a3a",
|
||||
touchAction: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GridViewProps {
|
||||
grid: Extract<LayoutNode, { type: "grid" }>["node"];
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
|
||||
const cols = normalizeWeights(grid.colWeights)
|
||||
.map((p) => `${p}fr`)
|
||||
.join(" ");
|
||||
const rows = normalizeWeights(grid.rowWeights)
|
||||
.map((p) => `${p}fr`)
|
||||
.join(" ");
|
||||
return (
|
||||
<div
|
||||
data-testid="layout-grid-container"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: cols,
|
||||
gridTemplateRows: rows,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{grid.cells.map((cell, i) => (
|
||||
<div
|
||||
key={keyOf(cell.node, i)}
|
||||
style={{
|
||||
gridColumn: `${cell.col + 1} / span ${cell.colSpan}`,
|
||||
gridRow: `${cell.row + 1} / span ${cell.rowSpan}`,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<NodeView node={cell.node} cwd={cwd} vm={vm} parentSplit={null} projectId={projectId} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stable-ish React key for a node (its id when it has one). */
|
||||
function keyOf(node: LayoutNode, fallback: number): string {
|
||||
return node.node.id ?? String(fallback);
|
||||
}
|
||||
136
frontend/src/features/layout/LayoutTabs.test.tsx
Normal file
136
frontend/src/features/layout/LayoutTabs.test.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* #4 — LayoutTabs / useLayouts: named-layout tab bar driven through the
|
||||
* MockLayoutGateway. Covers: list, create, rename, delete, switch.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { LayoutTabs } from "./LayoutTabs";
|
||||
|
||||
function renderTabs(
|
||||
layout: MockLayoutGateway,
|
||||
projectId = "p1",
|
||||
onActiveLayoutChange = vi.fn(),
|
||||
) {
|
||||
const gateways = {
|
||||
layout,
|
||||
terminal: new MockTerminalGateway(),
|
||||
agent: new MockAgentGateway(),
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LayoutTabs", () => {
|
||||
it("renders the default 'Default' tab on first load", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
renderTabs(layout);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a create (+) button", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
renderTabs(layout);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("create layout")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("switching tabs reports the new active layout (id + kind) to the parent", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
// Seed a second layout.
|
||||
await layout.createLayout("p1", "Beta");
|
||||
const onActiveLayoutChange = vi.fn();
|
||||
renderTabs(layout, "p1", onActiveLayoutChange);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Beta")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the Beta tab.
|
||||
fireEvent.click(screen.getByText("Beta"));
|
||||
|
||||
await waitFor(() => {
|
||||
// Called with the full LayoutInfo (so the parent knows the kind too).
|
||||
expect(onActiveLayoutChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Beta", kind: "terminal" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renaming a tab (double-click → type → blur) updates the name", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Double-click to start rename.
|
||||
fireEvent.dblClick(screen.getByText("Default"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/rename layout/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText(/rename layout/i) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "My Layout" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Layout")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("delete button is disabled when only one layout exists", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/delete layout/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const closeBtn = screen.getByLabelText(/delete layout/i) as HTMLButtonElement;
|
||||
expect(closeBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("creating a layout adds a tab", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
// Directly create a second layout via the gateway (simulates the + flow).
|
||||
await layout.createLayout("p1", "Extra");
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default")).toBeTruthy();
|
||||
expect(screen.getByText("Extra")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("deleting a layout removes its tab (when 2 exist)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
await layout.createLayout("p1", "Second");
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Second")).toBeTruthy();
|
||||
});
|
||||
|
||||
const deleteBtns = screen.getAllByLabelText(/delete layout/i);
|
||||
// Both tabs exist, pick the second (Second).
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteBtns[1]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Second")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
210
frontend/src/features/layout/LayoutTabs.tsx
Normal file
210
frontend/src/features/layout/LayoutTabs.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* `LayoutTabs` — named-layout tab bar rendered above the terminal grid (L4 / #4).
|
||||
*
|
||||
* - Shows one tab per named layout (reuses the shared `Tabs` component).
|
||||
* - `+` button: offers a dropdown to create a "Terminal" or "Git graph" layout.
|
||||
* - Double-click a tab label: in-place rename.
|
||||
* - `×` button: closes the layout (disabled when it is the last one).
|
||||
* - Switching tabs calls `setActiveLayout` via `useLayouts.setActive`.
|
||||
* - Tabs for `gitGraph` layouts show a small "⎇" badge.
|
||||
*
|
||||
* Pure presentation; all state lives in the `useLayouts` hook.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { LayoutInfo } from "@/domain";
|
||||
import { cn } from "@/shared";
|
||||
import { useLayouts } from "./useLayouts";
|
||||
|
||||
interface LayoutTabsProps {
|
||||
projectId: string;
|
||||
/**
|
||||
* Called whenever the active layout changes (and on initial load) with the
|
||||
* full active {@link LayoutInfo} — so the parent knows both its id (to re-key
|
||||
* the grid) **and its kind** (to switch between the terminal grid and the git
|
||||
* graph view). This is the single source of truth: the parent must NOT keep a
|
||||
* separate `useLayouts` instance, or its `kind` would go stale.
|
||||
*/
|
||||
onActiveLayoutChange: (info: LayoutInfo) => void;
|
||||
}
|
||||
|
||||
export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps) {
|
||||
const vm = useLayouts(projectId);
|
||||
|
||||
// Propagate the active layout (id + kind) to the parent on every change.
|
||||
useEffect(() => {
|
||||
const info = vm.layouts.find((l) => l.id === vm.activeId);
|
||||
if (info) onActiveLayoutChange(info);
|
||||
}, [vm.activeId, vm.layouts, onActiveLayoutChange]);
|
||||
// Track which tab is being renamed (null = none).
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Show/hide the create-kind dropdown.
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
||||
|
||||
async function handleSelect(id: string) {
|
||||
// The effect above propagates the new active layout (id + kind) to the parent.
|
||||
await vm.setActive(id);
|
||||
}
|
||||
|
||||
async function handleClose(id: string) {
|
||||
if (vm.layouts.length <= 1) return;
|
||||
await vm.deleteLayout(id);
|
||||
// The hook updates activeId; the effect propagates it to the parent.
|
||||
}
|
||||
|
||||
function startRename(id: string, currentName: string) {
|
||||
setRenamingId(id);
|
||||
setRenameValue(currentName);
|
||||
// Focus the input on next tick once it's rendered.
|
||||
setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
async function commitRename() {
|
||||
if (!renamingId) return;
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed) await vm.rename(renamingId, trimmed);
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
|
||||
async function handleCreate(kind: "terminal" | "gitGraph") {
|
||||
setShowCreateMenu(false);
|
||||
const defaultName = kind === "gitGraph" ? "Git Graph" : "Layout";
|
||||
const name = window.prompt("New layout name:", defaultName);
|
||||
if (!name?.trim()) return;
|
||||
const newId = await vm.create(name.trim(), kind);
|
||||
if (newId) {
|
||||
// Make it active; the effect propagates the new active layout (with its
|
||||
// `kind`) to the parent, which then renders the right main view.
|
||||
await vm.setActive(newId);
|
||||
}
|
||||
}
|
||||
|
||||
if (vm.layouts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="layout-tabs"
|
||||
className="flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1"
|
||||
>
|
||||
{vm.layouts.map((tab) => {
|
||||
const isActive = tab.id === vm.activeId;
|
||||
const isRenaming = tab.id === renamingId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
|
||||
isActive
|
||||
? "border-border-strong bg-raised"
|
||||
: "border-transparent hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => void commitRename()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename();
|
||||
if (e.key === "Escape") {
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
}}
|
||||
aria-label={`rename layout ${tab.name}`}
|
||||
className="w-24 rounded border border-border bg-base px-1 py-0.5 text-sm text-content focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => void handleSelect(tab.id)}
|
||||
onDoubleClick={() => startRename(tab.id, tab.name)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 text-sm focus-visible:outline-none",
|
||||
isActive
|
||||
? "font-semibold text-content"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{tab.kind === "gitGraph" && (
|
||||
<span
|
||||
aria-label="git graph layout"
|
||||
className="text-xs text-primary"
|
||||
title="Git Graph"
|
||||
>
|
||||
⎇
|
||||
</span>
|
||||
)}
|
||||
{tab.name}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`delete layout ${tab.name}`}
|
||||
disabled={vm.layouts.length <= 1}
|
||||
onClick={() => void handleClose(tab.id)}
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded text-xs transition-opacity",
|
||||
vm.layouts.length <= 1
|
||||
? "cursor-not-allowed opacity-20"
|
||||
: "opacity-60 hover:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Create new layout — dropdown to choose kind */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="create layout"
|
||||
onClick={() => setShowCreateMenu((v) => !v)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded border border-transparent text-sm text-muted transition-colors hover:border-border hover:text-content"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{showCreateMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute left-0 top-full z-20 mt-1 flex min-w-max flex-col rounded-md border border-border bg-surface shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="create terminal layout"
|
||||
onClick={() => void handleCreate("terminal")}
|
||||
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
||||
>
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="create git graph layout"
|
||||
onClick={() => void handleCreate("gitGraph")}
|
||||
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
||||
>
|
||||
⎇ Git graph
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vm.error && (
|
||||
<span className="ml-2 text-xs text-danger" role="alert">
|
||||
{vm.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/features/layout/index.ts
Normal file
15
frontend/src/features/layout/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/** Layout feature (L4): recursive spreadsheet-like terminal grid. */
|
||||
|
||||
export { LayoutGrid } from "./LayoutGrid";
|
||||
export { useLayout, type LayoutViewModel } from "./useLayout";
|
||||
export { useLayouts, type LayoutsViewModel } from "./useLayouts";
|
||||
export { LayoutTabs } from "./LayoutTabs";
|
||||
export {
|
||||
applyOperation,
|
||||
leaves,
|
||||
newId,
|
||||
normalizeWeights,
|
||||
resizeAdjacent,
|
||||
singleLeafTree,
|
||||
splitOp,
|
||||
} from "./layout";
|
||||
188
frontend/src/features/layout/layout.test.ts
Normal file
188
frontend/src/features/layout/layout.test.ts
Normal file
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* L4 — unit tests for the pure, framework-free layout logic
|
||||
* (`@/features/layout/layout.ts`): `normalizeWeights`, `resizeAdjacent`,
|
||||
* `applyOperation`, plus `singleLeafTree`/`leaves`/`splitOp`.
|
||||
*
|
||||
* These mirror the pure domain operations and carry no DOM/Tauri dependency.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { LayoutTree } from "@/domain";
|
||||
import {
|
||||
applyOperation,
|
||||
leaves,
|
||||
normalizeWeights,
|
||||
resizeAdjacent,
|
||||
singleLeafTree,
|
||||
splitOp,
|
||||
} from "./layout";
|
||||
|
||||
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
|
||||
|
||||
describe("normalizeWeights", () => {
|
||||
it("returns [] for an empty list", () => {
|
||||
expect(normalizeWeights([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("sums to ~100 for equal weights", () => {
|
||||
const out = normalizeWeights([1, 1, 1, 1]);
|
||||
expect(sum(out)).toBeCloseTo(100, 5);
|
||||
expect(out).toEqual([25, 25, 25, 25]);
|
||||
});
|
||||
|
||||
it("preserves proportions for unequal weights", () => {
|
||||
const out = normalizeWeights([3, 1]);
|
||||
expect(sum(out)).toBeCloseTo(100, 5);
|
||||
expect(out[0]).toBeCloseTo(75, 5);
|
||||
expect(out[1]).toBeCloseTo(25, 5);
|
||||
});
|
||||
|
||||
it("floors non-positive / NaN weights to an epsilon (still renders)", () => {
|
||||
const out = normalizeWeights([0, -5, Number.NaN, 1]);
|
||||
expect(sum(out)).toBeCloseTo(100, 5);
|
||||
// The last (only real) track dominates; the floored ones stay > 0.
|
||||
expect(out.every((p) => p > 0)).toBe(true);
|
||||
expect(out[3]).toBeGreaterThan(99);
|
||||
});
|
||||
|
||||
it("falls back to even shares when every weight is degenerate", () => {
|
||||
const out = normalizeWeights([0, 0]);
|
||||
expect(sum(out)).toBeCloseTo(100, 5);
|
||||
expect(out[0]).toBeCloseTo(out[1], 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizeAdjacent", () => {
|
||||
it("transfers extent between two adjacent tracks", () => {
|
||||
const out = resizeAdjacent([50, 50], 0, 0.1); // grow track 0 by 10%
|
||||
expect(out[0]).toBeCloseTo(60, 5);
|
||||
expect(out[1]).toBeCloseTo(40, 5);
|
||||
expect(sum(out)).toBeCloseTo(100, 5); // total preserved
|
||||
});
|
||||
|
||||
it("only affects the two adjacent tracks, leaving others untouched", () => {
|
||||
const out = resizeAdjacent([40, 40, 20], 0, 0.1);
|
||||
expect(out[2]).toBe(20);
|
||||
});
|
||||
|
||||
it("returns a fresh array (does not mutate the input)", () => {
|
||||
const input = [50, 50];
|
||||
const out = resizeAdjacent(input, 0, 0.1);
|
||||
expect(out).not.toBe(input);
|
||||
expect(input).toEqual([50, 50]);
|
||||
});
|
||||
|
||||
it("refuses a drag that would crush a track below the minimum", () => {
|
||||
const input = [50, 50];
|
||||
// A near-total drag would push track 1 below the 5% floor → no change.
|
||||
const out = resizeAdjacent(input, 0, 0.49);
|
||||
expect(out).toEqual([50, 50]);
|
||||
});
|
||||
|
||||
it("is a no-op for an out-of-range index", () => {
|
||||
expect(resizeAdjacent([50, 50], 1, 0.1)).toEqual([50, 50]);
|
||||
expect(resizeAdjacent([50, 50], -1, 0.1)).toEqual([50, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleLeafTree / leaves", () => {
|
||||
it("builds a single empty leaf with the given id", () => {
|
||||
const tree = singleLeafTree("abc");
|
||||
expect(tree.root.type).toBe("leaf");
|
||||
expect(leaves(tree)).toEqual([{ id: "abc", session: null }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOperation", () => {
|
||||
const base = (): LayoutTree => singleLeafTree("a");
|
||||
|
||||
it("split increases the leaf count", () => {
|
||||
const before = base();
|
||||
const op = { ...splitOp("a", "row"), newLeaf: "b", container: "c" } as const;
|
||||
const after = applyOperation(before, op);
|
||||
expect(leaves(before)).toHaveLength(1); // original untouched (immutable)
|
||||
expect(leaves(after)).toHaveLength(2);
|
||||
expect(after.root.type).toBe("split");
|
||||
});
|
||||
|
||||
it("resize changes the child weights", () => {
|
||||
const split = applyOperation(base(), {
|
||||
type: "split",
|
||||
target: "a",
|
||||
direction: "row",
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
const resized = applyOperation(split, {
|
||||
type: "resize",
|
||||
container: "c",
|
||||
weights: [3, 1],
|
||||
});
|
||||
expect(resized.root.type).toBe("split");
|
||||
if (resized.root.type === "split") {
|
||||
expect(resized.root.node.children.map((c) => c.weight)).toEqual([3, 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("setSession attaches the session id to the leaf", () => {
|
||||
const after = applyOperation(base(), {
|
||||
type: "setSession",
|
||||
target: "a",
|
||||
session: "sess-1",
|
||||
});
|
||||
expect(leaves(after)[0]).toEqual({ id: "a", session: "sess-1" });
|
||||
});
|
||||
|
||||
it("setSession with null clears the session", () => {
|
||||
const attached = applyOperation(base(), {
|
||||
type: "setSession",
|
||||
target: "a",
|
||||
session: "sess-1",
|
||||
});
|
||||
const cleared = applyOperation(attached, {
|
||||
type: "setSession",
|
||||
target: "a",
|
||||
session: null,
|
||||
});
|
||||
expect(leaves(cleared)[0].session ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("merge collapses a split back to a kept child", () => {
|
||||
const split = applyOperation(base(), {
|
||||
type: "split",
|
||||
target: "a",
|
||||
direction: "row",
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
const merged = applyOperation(split, {
|
||||
type: "merge",
|
||||
container: "c",
|
||||
keepIndex: 1,
|
||||
});
|
||||
expect(merged.root.type).toBe("leaf");
|
||||
expect(leaves(merged)).toEqual([{ id: "b", session: null }]);
|
||||
});
|
||||
|
||||
it("throws a NOT_FOUND-like error on an unknown target", () => {
|
||||
expect(() =>
|
||||
applyOperation(base(), {
|
||||
type: "setSession",
|
||||
target: "missing",
|
||||
session: null,
|
||||
}),
|
||||
).toThrowError(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitOp", () => {
|
||||
it("builds a split op with fresh ids", () => {
|
||||
const op = splitOp("a", "column");
|
||||
expect(op).toMatchObject({ type: "split", target: "a", direction: "column" });
|
||||
if (op.type === "split") {
|
||||
expect(typeof op.newLeaf).toBe("string");
|
||||
expect(typeof op.container).toBe("string");
|
||||
expect(op.newLeaf).not.toEqual(op.container);
|
||||
}
|
||||
});
|
||||
});
|
||||
279
frontend/src/features/layout/layout.ts
Normal file
279
frontend/src/features/layout/layout.ts
Normal file
@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Pure, framework-free layout logic for the spreadsheet-like terminal grid
|
||||
* (L4). No React, no Tauri here — these functions are the TS mirror of the
|
||||
* domain's pure `LayoutTree` operations (ARCHITECTURE §1.3, §7), so they are
|
||||
* trivially unit-testable (Vitest) and reusable by the mock gateway.
|
||||
*
|
||||
* The size calculation (`normalizeWeights`) is the load-bearing pure function
|
||||
* the spec calls out: it turns a list of relative weights into rendering
|
||||
* percentages, independent of any DOM.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Direction,
|
||||
LayoutNode,
|
||||
LayoutOperation,
|
||||
LayoutTree,
|
||||
LeafCell,
|
||||
SplitContainer,
|
||||
WeightedChild,
|
||||
} from "@/domain";
|
||||
|
||||
/** A simple UUID v4 generator (mock/UI side; the backend mints its own ids). */
|
||||
export function newId(): string {
|
||||
// crypto.randomUUID is available in modern browsers and jsdom.
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback (deterministic enough for non-crypto UI ids).
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/** A single empty leaf tree — the default layout (one cell, no session). */
|
||||
export function singleLeafTree(id: string = newId()): LayoutTree {
|
||||
return { root: { type: "leaf", node: { id, session: null } } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises relative weights into rendering percentages that sum to ~100.
|
||||
*
|
||||
* Pure and total: an empty list yields `[]`; non-positive/NaN weights are
|
||||
* floored to a tiny epsilon so a malformed tree still renders rather than
|
||||
* collapsing a track to zero. This is the function the grid component uses to
|
||||
* size each track (`grid-template-*` / flex-basis).
|
||||
*/
|
||||
export function normalizeWeights(weights: number[]): number[] {
|
||||
if (weights.length === 0) return [];
|
||||
const EPS = 1e-6;
|
||||
const safe = weights.map((w) => (Number.isFinite(w) && w > 0 ? w : EPS));
|
||||
const total = safe.reduce((a, b) => a + b, 0);
|
||||
if (total <= 0) {
|
||||
const even = 100 / safe.length;
|
||||
return safe.map(() => even);
|
||||
}
|
||||
return safe.map((w) => (w / total) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes new weights for a split after dragging the separator between
|
||||
* children `index` and `index + 1` by `deltaFraction` (a signed fraction of the
|
||||
* container's full extent, e.g. `+0.05` = grow the left/top child by 5%).
|
||||
*
|
||||
* Pure: only the two adjacent tracks are affected; both stay strictly positive
|
||||
* (clamped to a small minimum). Returns a fresh array.
|
||||
*/
|
||||
export function resizeAdjacent(
|
||||
weights: number[],
|
||||
index: number,
|
||||
deltaFraction: number,
|
||||
): number[] {
|
||||
const next = [...weights];
|
||||
if (index < 0 || index + 1 >= next.length) return next;
|
||||
const total = next.reduce((a, b) => a + b, 0);
|
||||
const delta = deltaFraction * total;
|
||||
const MIN = total * 0.05;
|
||||
const a = next[index] + delta;
|
||||
const b = next[index + 1] - delta;
|
||||
if (a < MIN || b < MIN) return next; // refuse to crush a track
|
||||
next[index] = a;
|
||||
next[index + 1] = b;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Recursively rebuilds a node, applying `f` (post-order, like the domain). */
|
||||
function mapNode(node: LayoutNode, f: (n: LayoutNode) => LayoutNode): LayoutNode {
|
||||
let rebuilt: LayoutNode;
|
||||
switch (node.type) {
|
||||
case "leaf":
|
||||
rebuilt = node;
|
||||
break;
|
||||
case "split":
|
||||
rebuilt = {
|
||||
type: "split",
|
||||
node: {
|
||||
...node.node,
|
||||
children: node.node.children.map((c) => ({
|
||||
node: mapNode(c.node, f),
|
||||
weight: c.weight,
|
||||
})),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "grid":
|
||||
rebuilt = {
|
||||
type: "grid",
|
||||
node: {
|
||||
...node.node,
|
||||
cells: node.node.cells.map((c) => ({
|
||||
...c,
|
||||
node: mapNode(c.node, f),
|
||||
})),
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
return f(rebuilt);
|
||||
}
|
||||
|
||||
/** Finds the session hosted by leaf `id`, or `undefined` if no such leaf. */
|
||||
function sessionInLeaf(node: LayoutNode, id: string): string | null | undefined {
|
||||
let found: string | null | undefined;
|
||||
mapNode(node, (n) => {
|
||||
if (n.type === "leaf" && n.node.id === id) found = n.node.session ?? null;
|
||||
return n;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a {@link LayoutOperation} to a tree, returning a **new** tree (pure,
|
||||
* immutable). Mirrors the domain operations so the mock gateway behaves like the
|
||||
* real backend offline. Throws on an invalid operation (unknown node, etc.).
|
||||
*/
|
||||
export function applyOperation(
|
||||
tree: LayoutTree,
|
||||
op: LayoutOperation,
|
||||
): LayoutTree {
|
||||
switch (op.type) {
|
||||
case "split": {
|
||||
let found = false;
|
||||
const root = mapNode(tree.root, (n) => {
|
||||
if (n.type === "leaf" && n.node.id === op.target) {
|
||||
found = true;
|
||||
const newLeaf: LeafCell = { id: op.newLeaf, session: null };
|
||||
const container: SplitContainer = {
|
||||
id: op.container,
|
||||
direction: op.direction,
|
||||
children: [
|
||||
{ node: n, weight: 1 },
|
||||
{ node: { type: "leaf", node: newLeaf }, weight: 1 },
|
||||
],
|
||||
};
|
||||
return { type: "split", node: container };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
if (!found) throw notFound(op.target);
|
||||
return { root };
|
||||
}
|
||||
case "merge": {
|
||||
let touched = false;
|
||||
const root = mapNode(tree.root, (n) => {
|
||||
if (n.type === "split" && n.node.id === op.container) {
|
||||
const keep = n.node.children[op.keepIndex];
|
||||
if (!keep) throw invalid(`merge keepIndex ${op.keepIndex} out of range`);
|
||||
touched = true;
|
||||
return keep.node;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
if (!touched) throw notFound(op.container);
|
||||
return { root };
|
||||
}
|
||||
case "resize": {
|
||||
let touched = false;
|
||||
const root = mapNode(tree.root, (n) => {
|
||||
if (n.type === "split" && n.node.id === op.container) {
|
||||
if (n.node.children.length !== op.weights.length) {
|
||||
throw invalid("resize weight count mismatch");
|
||||
}
|
||||
touched = true;
|
||||
const children: WeightedChild[] = n.node.children.map((c, i) => ({
|
||||
node: c.node,
|
||||
weight: op.weights[i],
|
||||
}));
|
||||
return { type: "split", node: { ...n.node, children } };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
if (!touched) throw notFound(op.container);
|
||||
return { root };
|
||||
}
|
||||
case "move": {
|
||||
const session = sessionInLeaf(tree.root, op.from);
|
||||
if (session === undefined) throw notFound(op.from);
|
||||
if (session === null) throw invalid("source cell has no session");
|
||||
const target = sessionInLeaf(tree.root, op.to);
|
||||
if (target === undefined) throw notFound(op.to);
|
||||
if (target !== null) throw invalid("target cell is occupied");
|
||||
const root = mapNode(tree.root, (n) => {
|
||||
if (n.type === "leaf" && n.node.id === op.from) {
|
||||
return { type: "leaf", node: { id: n.node.id, session: null } };
|
||||
}
|
||||
if (n.type === "leaf" && n.node.id === op.to) {
|
||||
return { type: "leaf", node: { id: n.node.id, session } };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
return { root };
|
||||
}
|
||||
case "setSession": {
|
||||
let found = false;
|
||||
const root = mapNode(tree.root, (n) => {
|
||||
if (n.type === "leaf" && n.node.id === op.target) {
|
||||
found = true;
|
||||
return {
|
||||
type: "leaf",
|
||||
node: { ...n.node, session: op.session ?? null },
|
||||
};
|
||||
}
|
||||
return n;
|
||||
});
|
||||
if (!found) throw notFound(op.target);
|
||||
return { root };
|
||||
}
|
||||
case "setCellAgent": {
|
||||
let found = false;
|
||||
const root = mapNode(tree.root, (n) => {
|
||||
if (n.type === "leaf" && n.node.id === op.target) {
|
||||
found = true;
|
||||
const { agent: _a, ...rest } = n.node;
|
||||
const updated = op.agent !== null
|
||||
? { ...rest, agent: op.agent }
|
||||
: rest;
|
||||
return { type: "leaf", node: updated };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
if (!found) throw notFound(op.target);
|
||||
return { root };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collects all leaf cells of a tree in document order. */
|
||||
export function leaves(tree: LayoutTree): LeafCell[] {
|
||||
const out: LeafCell[] = [];
|
||||
mapNode(tree.root, (n) => {
|
||||
if (n.type === "leaf") out.push(n.node);
|
||||
return n;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convenience: builds a `split` operation splitting `target` in `direction`. */
|
||||
export function splitOp(target: string, direction: Direction): LayoutOperation {
|
||||
return {
|
||||
type: "split",
|
||||
target,
|
||||
direction,
|
||||
newLeaf: newId(),
|
||||
container: newId(),
|
||||
};
|
||||
}
|
||||
|
||||
function notFound(id: string): GatewayLikeError {
|
||||
return { code: "NOT_FOUND", message: `layout node ${id} not found` };
|
||||
}
|
||||
function invalid(msg: string): GatewayLikeError {
|
||||
return { code: "INVALID", message: msg };
|
||||
}
|
||||
|
||||
interface GatewayLikeError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
102
frontend/src/features/layout/layoutTabsGitGraph.test.tsx
Normal file
102
frontend/src/features/layout/layoutTabsGitGraph.test.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Tests for the LayoutTabs + git-graph layout creation flow.
|
||||
*
|
||||
* Covers:
|
||||
* - The "+" button shows a create-kind dropdown.
|
||||
* - Choosing "Git graph" from the dropdown creates a layout with kind=gitGraph.
|
||||
* - A ⎇ badge is visible on git-graph layout tabs.
|
||||
* - Creating a terminal layout does NOT show the ⎇ badge.
|
||||
* - The mock `createLayout` correctly stores the kind.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { LayoutTabs } from "./LayoutTabs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderTabs(
|
||||
layout: MockLayoutGateway,
|
||||
projectId = "p1",
|
||||
onActiveLayoutChange = vi.fn(),
|
||||
) {
|
||||
const gateways = {
|
||||
layout,
|
||||
terminal: new MockTerminalGateway(),
|
||||
agent: new MockAgentGateway(),
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("LayoutTabs — git graph kind", () => {
|
||||
it("+ button shows a dropdown menu with Terminal and Git graph options", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("create layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("create layout"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("create terminal layout")).toBeTruthy();
|
||||
expect(screen.getByLabelText("create git graph layout")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("MockLayoutGateway.createLayout stores kind=gitGraph correctly", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const { layoutId } = await layout.createLayout("p1", "My Graph", "gitGraph");
|
||||
const { layouts } = await layout.listLayouts("p1");
|
||||
const created = layouts.find((l) => l.id === layoutId);
|
||||
expect(created).toBeDefined();
|
||||
expect(created!.kind).toBe("gitGraph");
|
||||
});
|
||||
|
||||
it("MockLayoutGateway.createLayout defaults to kind=terminal", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const { layoutId } = await layout.createLayout("p1", "My Terminal");
|
||||
const { layouts } = await layout.listLayouts("p1");
|
||||
const created = layouts.find((l) => l.id === layoutId);
|
||||
expect(created!.kind).toBe("terminal");
|
||||
});
|
||||
|
||||
it("git graph tab shows the ⎇ badge", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
// Seed a git-graph layout directly.
|
||||
await layout.createLayout("p1", "Graph Tab", "gitGraph");
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
// The aria-label "git graph layout" should be present.
|
||||
expect(screen.getByLabelText("git graph layout")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("terminal tab does NOT show the ⎇ badge", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
// Only the default "terminal" layout.
|
||||
renderTabs(layout);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText("git graph layout")).toBeNull();
|
||||
});
|
||||
});
|
||||
150
frontend/src/features/layout/setCellAgent.test.tsx
Normal file
150
frontend/src/features/layout/setCellAgent.test.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* #3 — Agent dropdown per cell: setCellAgent persists in the layout, and the
|
||||
* terminal re-mounts with the agent opener; "Plain" reverts to a plain terminal.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import {
|
||||
MockLayoutGateway,
|
||||
MockAgentGateway,
|
||||
MockTerminalGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { LayoutGrid } from "./LayoutGrid";
|
||||
import { leaves } from "./layout";
|
||||
|
||||
function renderGrid(
|
||||
layout: MockLayoutGateway,
|
||||
agent: MockAgentGateway,
|
||||
projectId = "p1",
|
||||
) {
|
||||
const gateways = {
|
||||
layout,
|
||||
terminal: new MockTerminalGateway(),
|
||||
agent,
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<LayoutGrid projectId={projectId} cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("setCellAgent (agent dropdown per cell)", () => {
|
||||
it("renders an agent selector on each leaf cell", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
renderGrid(layout, agent);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting an agent calls setCellAgent via the layout gateway", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
|
||||
// Create an agent in the project.
|
||||
const a = await agent.createAgent("p1", {
|
||||
name: "MyAgent",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
|
||||
// The layout should persist the agent on the leaf.
|
||||
const initial = await layout.loadLayout("p1");
|
||||
const leafId = leaves(initial)[0].id;
|
||||
|
||||
await waitFor(async () => {
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leaf = leaves(tree)[0];
|
||||
// The mutation should have persisted.
|
||||
expect(leaf.id).toBe(leafId);
|
||||
// Since mutateLayout is async, give it a tick.
|
||||
});
|
||||
});
|
||||
|
||||
it("MockLayoutGateway setCellAgent persists and clears correctly", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
|
||||
// Set agent.
|
||||
const after = await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: "agent-42",
|
||||
});
|
||||
expect(leaves(after)[0].agent).toBe("agent-42");
|
||||
|
||||
// Clear agent (null).
|
||||
const cleared = await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: null,
|
||||
});
|
||||
expect(leaves(cleared)[0].agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("selecting Plain (empty) clears the agent via mutateLayout", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", {
|
||||
name: "MyAgent",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
|
||||
// Pre-set agent on the leaf.
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: a.id,
|
||||
});
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
await waitFor(() => {
|
||||
const sel = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
// The select should show the agent (or Plain if the component loaded before the mutation).
|
||||
expect(sel).toBeTruthy();
|
||||
});
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
// Switch back to Plain.
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
|
||||
// After clearing, the layout should have no agent on the leaf.
|
||||
await waitFor(async () => {
|
||||
const fresh = await layout.loadLayout("p1");
|
||||
const l = leaves(fresh)[0];
|
||||
expect(l.agent === undefined || l.agent === null).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("agent selector shows project agents as options", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent("p1", { name: "Agent Alpha", profileId: "p" });
|
||||
await agent.createAgent("p1", { name: "Agent Beta", profileId: "p" });
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agent Alpha")).toBeTruthy();
|
||||
expect(screen.getByText("Agent Beta")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
131
frontend/src/features/layout/useLayout.ts
Normal file
131
frontend/src/features/layout/useLayout.ts
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* `useLayout` — view-model hook for the terminal layout grid (L4).
|
||||
*
|
||||
* Owns the loaded {@link LayoutTree} for a project and exposes the actions the
|
||||
* grid UI triggers (split/merge/resize/move). It consumes the
|
||||
* {@link LayoutGateway} exclusively — never `invoke()` — so the component layer
|
||||
* stays testable with a mock gateway (ARCHITECTURE §1.3). Each action calls the
|
||||
* backend, which persists `.ideai/layout.json` and returns the new tree.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
Direction,
|
||||
GatewayError,
|
||||
LayoutOperation,
|
||||
LayoutTree,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { splitOp } from "./layout";
|
||||
|
||||
/** What the layout grid UI needs from this hook. */
|
||||
export interface LayoutViewModel {
|
||||
/** The current layout tree, or `null` until loaded. */
|
||||
layout: LayoutTree | null;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Splits a leaf into two cells in the given direction. */
|
||||
split: (target: string, direction: Direction) => Promise<void>;
|
||||
/** Collapses a split container back to the child at `keepIndex`. */
|
||||
merge: (container: string, keepIndex: number) => Promise<void>;
|
||||
/** Reassigns a split's child weights (separator drag). */
|
||||
resize: (container: string, weights: number[]) => Promise<void>;
|
||||
/** Moves the session from one cell to another (empty) cell. */
|
||||
move: (from: string, to: string) => Promise<void>;
|
||||
/** Records (or clears) the session hosted by a cell. */
|
||||
setSession: (target: string, session: string | null) => Promise<void>;
|
||||
/** Pins or clears an agent on a cell (persisted in layout). */
|
||||
setCellAgent: (target: string, agent: string | null) => Promise<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 useLayout(
|
||||
projectId: string | null,
|
||||
layoutId?: string,
|
||||
): LayoutViewModel {
|
||||
const { layout: gateway } = useGateways();
|
||||
const [layout, setLayout] = useState<LayoutTree | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// No project, or a DI subset without the layout gateway (e.g. a focused
|
||||
// test) → render nothing rather than crash the tab (mirrors TerminalView).
|
||||
if (!projectId || !gateway) {
|
||||
setLayout(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setBusy(true);
|
||||
gateway
|
||||
.loadLayout(projectId, layoutId)
|
||||
.then((tree) => {
|
||||
if (!cancelled) setLayout(tree);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(describe(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gateway, projectId, layoutId]);
|
||||
|
||||
const mutate = useCallback(
|
||||
async (operation: LayoutOperation) => {
|
||||
if (!projectId || !gateway) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setLayout(await gateway.mutateLayout(projectId, operation, layoutId));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[gateway, projectId, layoutId],
|
||||
);
|
||||
|
||||
const split = useCallback(
|
||||
(target: string, direction: Direction) => mutate(splitOp(target, direction)),
|
||||
[mutate],
|
||||
);
|
||||
const merge = useCallback(
|
||||
(container: string, keepIndex: number) =>
|
||||
mutate({ type: "merge", container, keepIndex }),
|
||||
[mutate],
|
||||
);
|
||||
const resize = useCallback(
|
||||
(container: string, weights: number[]) =>
|
||||
mutate({ type: "resize", container, weights }),
|
||||
[mutate],
|
||||
);
|
||||
const move = useCallback(
|
||||
(from: string, to: string) => mutate({ type: "move", from, to }),
|
||||
[mutate],
|
||||
);
|
||||
const setSession = useCallback(
|
||||
(target: string, session: string | null) =>
|
||||
mutate({ type: "setSession", target, session }),
|
||||
[mutate],
|
||||
);
|
||||
const setCellAgent = useCallback(
|
||||
(target: string, agent: string | null) =>
|
||||
mutate({ type: "setCellAgent", target, agent }),
|
||||
[mutate],
|
||||
);
|
||||
|
||||
return { layout, error, busy, split, merge, resize, move, setSession, setCellAgent };
|
||||
}
|
||||
153
frontend/src/features/layout/useLayouts.ts
Normal file
153
frontend/src/features/layout/useLayouts.ts
Normal file
@ -0,0 +1,153 @@
|
||||
/**
|
||||
* `useLayouts` — view-model hook for the named-layout tab bar (L4 / #4).
|
||||
*
|
||||
* Manages the list of named layouts for a project (list / create / rename /
|
||||
* delete / set-active). Speaks exclusively to the {@link LayoutGateway} port.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError, LayoutInfo, LayoutKind } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface LayoutsViewModel {
|
||||
/** Ordered list of named layouts. */
|
||||
layouts: LayoutInfo[];
|
||||
/** Currently active layout id. */
|
||||
activeId: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Switches the active layout (does NOT force a re-fetch here; the caller uses the returned activeId). */
|
||||
setActive: (layoutId: string) => Promise<void>;
|
||||
/** Creates a new layout with the given name and kind; resolves with the new layoutId. */
|
||||
create: (name: string, kind?: LayoutKind) => Promise<string | null>;
|
||||
/** Renames a layout. */
|
||||
rename: (layoutId: string, name: string) => Promise<void>;
|
||||
/** Deletes a layout; refuses if it is the last one. */
|
||||
deleteLayout: (layoutId: string) => Promise<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 useLayouts(projectId: string | null): LayoutsViewModel {
|
||||
const { layout: gateway } = useGateways();
|
||||
const [layouts, setLayouts] = useState<LayoutInfo[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load the list on mount and whenever projectId changes.
|
||||
useEffect(() => {
|
||||
if (!projectId || !gateway) {
|
||||
setLayouts([]);
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setBusy(true);
|
||||
gateway
|
||||
.listLayouts(projectId)
|
||||
.then(({ layouts: list, activeId: aid }) => {
|
||||
if (!cancelled) {
|
||||
setLayouts(list);
|
||||
setActiveId(aid);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(describe(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gateway, projectId]);
|
||||
|
||||
const setActive = useCallback(
|
||||
async (layoutId: string) => {
|
||||
if (!projectId || !gateway) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gateway.setActiveLayout(projectId, layoutId);
|
||||
setActiveId(layoutId);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[gateway, projectId],
|
||||
);
|
||||
|
||||
const create = useCallback(
|
||||
async (name: string, kind?: LayoutKind): Promise<string | null> => {
|
||||
if (!projectId || !gateway) return null;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { layoutId } = await gateway.createLayout(projectId, name, kind);
|
||||
const updated = await gateway.listLayouts(projectId);
|
||||
setLayouts(updated.layouts);
|
||||
return layoutId;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[gateway, projectId],
|
||||
);
|
||||
|
||||
const rename = useCallback(
|
||||
async (layoutId: string, name: string) => {
|
||||
if (!projectId || !gateway) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gateway.renameLayout(projectId, layoutId, name);
|
||||
setLayouts((prev) =>
|
||||
prev.map((l) => (l.id === layoutId ? { ...l, name } : l)),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[gateway, projectId],
|
||||
);
|
||||
|
||||
const deleteLayout = useCallback(
|
||||
async (layoutId: string) => {
|
||||
if (!projectId || !gateway) return;
|
||||
if (layouts.length <= 1) {
|
||||
setError("Cannot delete the last layout.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { activeId: newActiveId } = await gateway.deleteLayout(projectId, layoutId);
|
||||
setLayouts((prev) => prev.filter((l) => l.id !== layoutId));
|
||||
setActiveId(newActiveId);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[gateway, projectId, layouts.length],
|
||||
);
|
||||
|
||||
return { layouts, activeId, busy, error, setActive, create, rename, deleteLayout };
|
||||
}
|
||||
Reference in New Issue
Block a user