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 Palhas 8269bcabcc feat(maestro): sort dormant sessions after live ones in the sidebar
docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
sidebar tree, so a rebooted host's live work never sorts under a pile
of dormant cards.
2026-09-04 09:19:03 +01:00

99 lines
3.5 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, sessionName, sessionTreePath } 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;
/** docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
* ordering, so live work always sorts above it regardless of name. */
function dormantRank(node: TreeNode): number {
return node.kind === "leaf" && node.session.status === "Dormant" ? 1 : 0;
}
export function compareNodes(a: TreeNode, b: TreeNode): number {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
const rank = dormantRank(a) - dormantRank(b);
if (rank !== 0) return rank;
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): TreeNode {
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
for (const s of sessions) {
// Grouped by repository, so a worktree session lands under its repo
// rather than in a folder of its own (#99).
const cwd = sessionTreePath(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
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);
// The root's sole child (usually `~`) has no sibling to distinguish it
// from, so it folds into every one of its own children, not just a lone
// one — unlike the chain merge below, and only here at the very top.
if (root.children.length === 1 && root.children[0].kind === "folder") {
const only = root.children[0];
root.children = only.children.map((c) =>
c.kind === "folder" ? { ...c, name: `${only.name}/${c.name}` } : c,
);
}
// 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;
}