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