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>
85 lines
2.8 KiB
TypeScript
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;
|
|
}
|