Files
IdeA/frontend/src/features/layout/layout.ts
Blomios 307ae71857 feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
2026-06-06 01:27:01 +02:00

280 lines
8.7 KiB
TypeScript

/**
* 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;
}