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