This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/tree.ts
T
Miguel PalhasandClaude Opus 5 031c80a40b
ci / rust (pull_request) Successful in 2m28s
ci / web (pull_request) Successful in 23s
ci / web (push) Successful in 26s
ci / rust (push) Successful in 1m38s
test(maestro): give the SPA a test runner and a reachable pure core
The browser app had no tests and no way to run any: package.json shipped
dev/build/preview/typecheck, and CI installed Node and pnpm but never ran
anything with them. Its densest logic was module-private, so even with a
runner none of it was reachable.

Extracts four pure modules out of DOM-heavy ones — tree.ts (directory
grouping), keys.ts (terminal escape encoding), paths.ts (completion
arithmetic), html.ts (one escaper, replacing four copies). Extraction
rather than `export` because attach.ts imports xterm's CSS at module
level, which a node test cannot load.

CatalogStore takes an injectable socket factory, scheduler and refetch, so
the reducer and the backoff schedule run without a server. snapshot() now
returns a copy: it returned the live object while events mutated it in
place, so listeners could not diff and every event rebuilt the whole tree.

36 tests across 6 files, and a `web` CI job running typecheck, test and
build. vitest is pinned to 2.x — 4.x requires vite >= 6 and this is vite 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:08:14 +01:00

85 lines
2.8 KiB
TypeScript

// Directory grouping for the session catalog. Pure: no DOM, no module state,
// so it can be tested directly.
import type { Session } from "./types.js";
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
export interface TreeFolder {
kind: "folder";
name: string;
path: string;
children: TreeNode[];
}
export interface TreeLeaf {
kind: "leaf";
session: Session;
}
export type TreeNode = TreeFolder | TreeLeaf;
export function compareNodes(a: TreeNode, b: TreeNode): number {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
const an = a.kind === "folder" ? a.name : sessionName(a.session);
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
return an.localeCompare(bn);
}
export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
const cwd = sessionCwd(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
if (lc) {
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
if (!haystack.includes(lc)) continue;
}
const parts = dir ? dir.split("/").filter(Boolean) : [];
let cursor = root;
for (const part of parts) {
const path = cursor.path ? `${cursor.path}/${part}` : part;
let next = cursor.children.find(
(n): n is TreeFolder => n.kind === "folder" && n.name === part,
);
if (!next) {
next = { kind: "folder", name: part, path, children: [] };
cursor.children.push(next);
}
cursor = next;
}
cursor.children.push({ kind: "leaf", session: s });
}
const sortRec = (n: TreeFolder) => {
n.children.sort(compareNodes);
for (const c of n.children) if (c.kind === "folder") sortRec(c);
};
sortRec(root);
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
// one compact folder rather than four nested ones.
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
return root;
}
/** Fold a folder with a single folder-child into one node (`a` + `b` → `a/b`),
* repeating down the chain, then recurse into the surviving branches. The merged
* node keeps the deepest path so collapse/expand state stays stable as it grows. */
export function mergeLinear(folder: TreeFolder): TreeFolder {
let cur = folder;
while (cur.children.length === 1 && cur.children[0].kind === "folder") {
const child = cur.children[0];
cur = {
kind: "folder",
name: `${cur.name}/${child.name}`,
path: child.path,
children: child.children,
};
}
cur.children = cur.children.map((c) =>
c.kind === "folder" ? mergeLinear(c) : c,
);
return cur;
}