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
91 lines
3.2 KiB
TypeScript
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)
|
|
);
|
|
});
|
|
}
|