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/components/sidebar/model.ts
T
Miguel Palhas e8e98baf4d feat(maestro): port the sidebar to React
Ports catalog.ts's tree renderer to components under
src/components/sidebar/, following docs/SIDEBAR.md: two-line cards
behind a 5px activity rail, adaptive meta line (duration, detail,
program, diff stat), collapsible folders, j/k walk, inline rename and a
right-click menu.

The nav walk reads a pure model (model.ts) rather than the DOM, so the
rule that an expanded folder header is skipped and a collapsed one is
not is testable without a render. Rail motion needs @keyframes, so it
stays in sidebar.css rather than the Tailwind config.

Closes #244
2026-09-09 00:21:13 +01:00

91 lines
3.2 KiB
TypeScript

// Pure model helpers behind the React sidebar. Kept out of the components so
// the walk order and the folder label can be tested without a DOM.
import type { Session } from "../../types.js";
import { sessionCwd, sessionName } from "../../types.js";
import type { TreeNode } from "../../tree.js";
/** A row the keyboard walk can land on (docs/SIDEBAR.md "Keyboard walk"):
* every session card, and a folder header only while it is collapsed. */
export type NavRow =
| { key: string; kind: "folder"; path: string }
| { key: string; kind: "session"; id: string };
export function folderKey(path: string): string {
return `folder:${path}`;
}
export function sessionKey(id: string): string {
return `session:${id}`;
}
/** The walkable rows in visual order. An expanded folder contributes nothing
* itself — its cards are one row away — while a collapsed one is the only row
* it has, so skipping it would leave the folder unreachable. */
export function navRows(root: TreeNode, collapsed: ReadonlySet<string>): NavRow[] {
const out: NavRow[] = [];
const walk = (node: TreeNode): void => {
if (node.kind === "leaf") {
out.push({ key: sessionKey(node.session.id), kind: "session", id: node.session.id });
return;
}
if (node.name && collapsed.has(node.path)) {
out.push({ key: folderKey(node.path), kind: "folder", path: node.path });
return;
}
for (const child of node.children) walk(child);
};
for (const child of root.kind === "folder" ? root.children : [root]) walk(child);
return out;
}
/** The first walkable row inside `path`, i.e. where the highlight lands when
* Enter expands that folder. */
export function firstRowUnder(
root: TreeNode,
collapsed: ReadonlySet<string>,
path: string,
): NavRow | undefined {
const found = findFolder(root, path);
if (!found) return undefined;
return navRows({ ...found, name: "" }, collapsed)[0];
}
function findFolder(node: TreeNode, path: string): Extract<TreeNode, { kind: "folder" }> | null {
if (node.kind === "leaf") return null;
if (node.path === path && node.name) return node;
for (const child of node.children) {
const hit = findFolder(child, path);
if (hit) return hit;
}
return null;
}
/** Structured form of `abbreviateFolderName`: a deep path shows its leading
* segments as initials so the tail stays readable in a narrow sidebar. */
export function splitFolderLabel(name: string): { stem: string | null; last: string } {
const parts = name.split("/");
if (parts.length <= 2) return { stem: null, last: name };
const last = parts[parts.length - 1];
const stem = parts
.slice(0, -1)
.map((p) => (p === "~" ? "~" : (Array.from(p)[0] ?? "")))
.join("/");
return { stem, last };
}
/** Case-insensitive substring filter over a session's name, command and cwd —
* what the sidebar's own search field narrows the tree with. */
export function filterSessions(sessions: Session[], query: string): Session[] {
const q = query.trim().toLowerCase();
if (!q) return sessions;
return sessions.filter((s) => {
const cwd = sessionCwd(s) ?? "";
return (
sessionName(s).toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q) ||
cwd.toLowerCase().includes(q)
);
});
}