Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
280 lines
8.7 KiB
TypeScript
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;
|
|
}
|