// 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): 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, 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 | 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) ); }); }