Moving the sidebar highlight (keyboard or hover) now shows that session in the content pane without opening it: its own tab comes forward, or a throwaway tab carries it. The layout, the saved layout and the daemon's focus report are all left untouched, and the keyboard stays in the list. Contract in docs/PREVIEW.md.
692 lines
27 KiB
TypeScript
692 lines
27 KiB
TypeScript
import { escapeHtml } from "./html.js";
|
|
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
|
import { openForceKillDialog } from "./killDialog.js";
|
|
import { openContextMenu } from "./contextMenu.js";
|
|
import { isTypingTarget, toggleHelp } from "./help.js";
|
|
import { CatalogStore } from "./events.js";
|
|
import type { InTheWay, Session } from "./types.js";
|
|
import {
|
|
abbreviateFolderName,
|
|
blockerStats,
|
|
customSessionName,
|
|
formatInTheWay,
|
|
MAX_SESSION_NAME_LEN,
|
|
normalizeSessionName,
|
|
sessionBlockers,
|
|
isProvisionalName,
|
|
sessionName,
|
|
} from "./types.js";
|
|
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
|
import {
|
|
activityColorSlug,
|
|
activityDurationLabel,
|
|
activityHeldMs,
|
|
META_LINE_MIN_HOLD_MS,
|
|
} from "./activity.js";
|
|
import type { TreeNode } from "./tree.js";
|
|
import { buildTree } from "./tree.js";
|
|
import { createPaletteSelect, createThemeToggle } from "./theme.js";
|
|
import type { DaemonLink } from "./daemonLink.js";
|
|
import { createStatusIndicator } from "./statusIndicator.js";
|
|
|
|
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
|
|
* multi-view controller lives; dispatched from every Spawn affordance. */
|
|
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
|
|
export function requestOpenSpawn(): void {
|
|
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
|
}
|
|
|
|
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
|
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
|
* connection pool. */
|
|
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
|
|
|
/** Ask the shell to hand the keyboard back to the session list — what Escape
|
|
* does when the pane's terminal is focused. Handled in main.ts. */
|
|
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
|
|
|
/** Ask the shell to open the spawn modal seeded from an existing session
|
|
* ("Spawn similar"). Detail is the session being cloned. */
|
|
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
|
|
|
/** A session row was clicked (issue #133). The row's `href` drives normal
|
|
* hash navigation, but a split/drag can move focus without ever touching
|
|
* `location.hash` — so a click back onto a session whose hash still
|
|
* matches fires no `hashchange` and the app never hears about it. This
|
|
* event is the click-driven path that doesn't depend on the hash actually
|
|
* changing. */
|
|
export const SESSION_SELECT_EVENT = "catalog-session-select";
|
|
|
|
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
|
|
* folder headers are skipped: a directory is not something to focus, and the
|
|
* cards under it are one row away anyway. A *collapsed* folder still counts —
|
|
* its header is the only row it has, so skipping it would leave the folder
|
|
* unreachable. Same rule as the TUI sidebar. */
|
|
const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]';
|
|
|
|
const collapsed = new Set<string>();
|
|
|
|
/** Session whose card is currently being renamed inline (#125). The tree
|
|
* re-renders on every catalog delta, and an activity update arriving mid-edit
|
|
* would otherwise replace the field the user is typing into. */
|
|
let renamingId: string | null = null;
|
|
|
|
// docs/SIDEBAR.md: the state word is never rendered — the rail's colour
|
|
// carries it. It survives only in tooltips/aria labels so meaning is not
|
|
// colour-only.
|
|
const STATE_WORDS: Record<string, string> = {
|
|
active: "Active",
|
|
idle: "Idle",
|
|
"awaiting-input": "Awaiting input",
|
|
error: "Error",
|
|
};
|
|
|
|
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
|
* full height. The row's own padding (`renderNode`) shifts outward with
|
|
* nesting depth, carrying the rail with it. */
|
|
function activityRailMarkup(slug: string, stateWord: string): string {
|
|
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
|
|
}
|
|
|
|
function metaLine(s: Session, now: number): string {
|
|
const dead = s.status === "Dead";
|
|
const blockers = sessionBlockers(s);
|
|
const slug = activityColorSlug(s.activity ?? "Idle");
|
|
const held = activityHeldMs(s.activity_since, now);
|
|
const duration =
|
|
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
|
|
|
const fragments = [
|
|
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
|
s.activity_detail
|
|
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
|
: "",
|
|
commandFragment(s.command),
|
|
blockers ? blockedFragment(blockers) : "",
|
|
dead ? `<span class="card-flag">exited</span>` : "",
|
|
];
|
|
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
|
}
|
|
|
|
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
|
|
* — with the spelled-out breakdown kept in the tooltip. */
|
|
function blockedFragment(blockers: InTheWay): string {
|
|
const stats = blockerStats(blockers)
|
|
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
|
|
.join("");
|
|
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
|
|
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
|
}
|
|
|
|
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
|
|
* the card. The program becomes its tool icon when we have a mark for it, and
|
|
* stays as its bare name when we do not. The full command line, arguments and
|
|
* all, stays in the tooltip either way. */
|
|
function commandFragment(command: string): string {
|
|
const { tool, program } = splitCommand(command);
|
|
const icon = tool ? toolIconSvg(tool) : null;
|
|
const text = icon ? "" : program;
|
|
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
|
}
|
|
|
|
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|
if (node.kind === "folder") {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "tree-folder";
|
|
|
|
if (node.name) {
|
|
const header = document.createElement("button");
|
|
header.type = "button";
|
|
header.className = "tree-folder-header";
|
|
header.style.paddingLeft = `${depth * 16}px`;
|
|
const isCollapsed = collapsed.has(node.path);
|
|
header.setAttribute("aria-expanded", String(!isCollapsed));
|
|
header.dataset.navKey = `folder:${node.path}`;
|
|
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
|
|
// glyph and the rotation never fight each other.
|
|
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
|
|
// segments to initials (`~/g/n/maestro`) so the dir name stays readable in
|
|
// a narrow sidebar; the full path lives in the tooltip.
|
|
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span>`;
|
|
header.addEventListener("click", () => {
|
|
if (collapsed.has(node.path)) collapsed.delete(node.path);
|
|
else collapsed.add(node.path);
|
|
// re-render handled by caller observing the store; trigger a refresh:
|
|
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
|
});
|
|
wrap.appendChild(header);
|
|
if (isCollapsed) return wrap;
|
|
}
|
|
|
|
for (const child of node.children) {
|
|
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
|
|
}
|
|
return wrap;
|
|
}
|
|
|
|
const s = node.session;
|
|
const now = Date.now();
|
|
const row = document.createElement("a");
|
|
row.className = "tree-leaf session-card";
|
|
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
|
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
row.dataset.sessionId = s.id;
|
|
row.dataset.navKey = `session:${s.id}`;
|
|
const running = s.status === "Running";
|
|
row.dataset.status = running ? "running" : "dead";
|
|
const name = sessionName(s);
|
|
const slug = activityColorSlug(s.activity ?? "Idle");
|
|
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
|
row.setAttribute(
|
|
"aria-label",
|
|
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
|
);
|
|
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
|
row.innerHTML = `
|
|
${activityRailMarkup(slug, stateWord)}
|
|
<span class="card-line1">
|
|
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
|
|
<span class="leaf-actions">
|
|
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
|
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
|
</span>
|
|
</span>
|
|
${metaLine(s, now)}
|
|
`;
|
|
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
|
similarBtn.addEventListener("click", (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
|
|
});
|
|
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
|
killBtn.addEventListener("click", (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
void requestKill(s, name);
|
|
});
|
|
row.addEventListener("click", () => {
|
|
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
|
});
|
|
row.addEventListener("contextmenu", (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
openContextMenu(ev.clientX, ev.clientY, [
|
|
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
|
{
|
|
label: "Spawn similar",
|
|
onSelect: () =>
|
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
|
},
|
|
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
|
]);
|
|
});
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* Kill on the first click (#81): the daemon is the one that decides whether a
|
|
* session may go, and it refuses over a worktree holding unsaved work. Only
|
|
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
|
* a forced kill. Anything else surfaces as an alert.
|
|
*/
|
|
async function requestKill(s: Session, name: string): Promise<void> {
|
|
try {
|
|
await killSession(s.id);
|
|
} catch (e) {
|
|
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
|
openForceKillDialog({
|
|
name,
|
|
blockers: e.unsavedWork ?? sessionBlockers(s),
|
|
reason: e.detail,
|
|
onForce: () => killSession(s.id, true),
|
|
});
|
|
return;
|
|
}
|
|
alert(`Failed to kill: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Swap the card's name for a text field (#125). Enter commits, Escape and
|
|
* blur cancel. An emptied field clears the name, so the card falls back to
|
|
* the command — the same rule the spawn form's blank name field follows.
|
|
*/
|
|
function beginRename(row: HTMLElement, s: Session): void {
|
|
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
|
|
if (!nameEl) return;
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.className = "leaf-name leaf-rename";
|
|
input.value = customSessionName(s) ?? "";
|
|
input.maxLength = MAX_SESSION_NAME_LEN;
|
|
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
|
|
// The card is an <a>, and the tree starts a drag on pointerdown: without
|
|
// these, clicking into the field navigates away or begins dragging the row.
|
|
for (const type of ["click", "pointerdown", "dblclick"]) {
|
|
input.addEventListener(type, (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
input.focus();
|
|
});
|
|
}
|
|
|
|
renamingId = s.id;
|
|
let settled = false;
|
|
const finish = () => {
|
|
if (settled) return false;
|
|
settled = true;
|
|
renamingId = null;
|
|
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
|
return true;
|
|
};
|
|
|
|
input.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") {
|
|
ev.preventDefault();
|
|
// Normalised here so what is sent is what the daemon will store.
|
|
const name = normalizeSessionName(input.value);
|
|
if (!finish()) return;
|
|
void renameSession(s.id, name).catch((e: Error) => {
|
|
alert(`Failed to rename: ${e.message}`);
|
|
});
|
|
} else if (ev.key === "Escape") {
|
|
ev.preventDefault();
|
|
finish();
|
|
}
|
|
});
|
|
input.addEventListener("blur", () => finish());
|
|
|
|
nameEl.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
|
|
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
|
const el = document.createElement("div");
|
|
el.className = "empty";
|
|
if (filtering) {
|
|
el.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">⌕</div>
|
|
<div class="empty-title">No matches</div>
|
|
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
|
return el;
|
|
}
|
|
el.innerHTML = `
|
|
<div class="empty-glyph" aria-hidden="true">+</div>
|
|
<div class="empty-title">No sessions yet</div>
|
|
<p class="empty-hint">Start your first session — it'll show up here.</p>
|
|
<div class="empty-actions"></div>`;
|
|
const spawn = document.createElement("button");
|
|
spawn.type = "button";
|
|
spawn.className = "btn btn-primary";
|
|
spawn.textContent = "+ Spawn session";
|
|
spawn.addEventListener("click", onSpawn);
|
|
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
|
return el;
|
|
}
|
|
|
|
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
|
* pane (quiet), and which one is focused (loud). */
|
|
export interface SidebarHighlights {
|
|
open: Set<string>;
|
|
focused: string | null;
|
|
}
|
|
|
|
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
|
export interface SidebarHandle {
|
|
/** The `<aside>` element to mount into the shell. */
|
|
el: HTMLElement;
|
|
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
|
* Back-compat shim over {@link setHighlights}. */
|
|
setActive(id: string | null): void;
|
|
/** Apply the full open/focused highlight state. */
|
|
setHighlights(h: SidebarHighlights): void;
|
|
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
|
focusList(): void;
|
|
/** Whether the session list currently holds DOM focus. */
|
|
hasListFocus(): boolean;
|
|
/** Tear down the store subscription + listeners. */
|
|
dispose(): void;
|
|
}
|
|
|
|
/**
|
|
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
|
|
* live session status line, and the folder-grouped session tree. Unlike
|
|
* the old full-screen catalog this is mounted once by the shell and stays put
|
|
* while the content pane swaps between sessions — so the store subscription and
|
|
* scroll position survive navigation. The active session is highlighted.
|
|
*/
|
|
export interface SidebarOptions {
|
|
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
|
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
|
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
|
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
|
* design kit, which render the sidebar without a daemon. */
|
|
link?: DaemonLink;
|
|
/** The highlight moved onto a session, or off every session (`null`): the
|
|
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
|
* `source` is there so a mouse sweep can be debounced differently from a
|
|
* keyboard step. */
|
|
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
|
|
}
|
|
|
|
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
|
const el = document.createElement("aside");
|
|
el.className = "sidebar";
|
|
|
|
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
|
|
// renders with absolute paths until it lands.
|
|
let homeDir: string | undefined;
|
|
|
|
const searchHeader = document.createElement("header");
|
|
searchHeader.className = "sidebar-search";
|
|
searchHeader.innerHTML = `
|
|
<input
|
|
type="search"
|
|
class="search"
|
|
placeholder="Search sessions…"
|
|
aria-label="Search sessions"
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
/>
|
|
`;
|
|
el.appendChild(searchHeader);
|
|
|
|
const treeHost = document.createElement("div");
|
|
treeHost.className = "tree";
|
|
el.appendChild(treeHost);
|
|
|
|
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
|
|
// rather than the sidebar's own scrolling content, so they stay reachable
|
|
// regardless of tree scroll position.
|
|
const footer = document.createElement("div");
|
|
footer.className = "sidebar-footer";
|
|
const helpBtn = document.createElement("button");
|
|
helpBtn.type = "button";
|
|
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
|
helpBtn.textContent = "?";
|
|
helpBtn.title = "Keyboard shortcuts (?)";
|
|
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
|
helpBtn.addEventListener("click", () => toggleHelp());
|
|
footer.appendChild(helpBtn);
|
|
footer.appendChild(createThemeToggle());
|
|
footer.appendChild(createPaletteSelect());
|
|
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
|
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
|
if (import.meta.env.DEV) {
|
|
const kit = document.createElement("a");
|
|
kit.className = "btn btn-ghost";
|
|
kit.href = "?kit";
|
|
kit.textContent = "kit";
|
|
kit.title = "Open the design kit (DEV)";
|
|
footer.appendChild(kit);
|
|
}
|
|
// Daemon status sits beside the spawn button: colour for the link state,
|
|
// hover for the release the daemon is running.
|
|
const status = opts.link ? createStatusIndicator(opts.link) : null;
|
|
if (status) footer.appendChild(status.el);
|
|
const spawnBtn = document.createElement("button");
|
|
spawnBtn.type = "button";
|
|
spawnBtn.className = "btn btn-primary spawn-btn";
|
|
spawnBtn.textContent = "+ Spawn";
|
|
// The keyboard pair sits right here in the tooltip (#67).
|
|
spawnBtn.title = "Spawn form: n — fast-forward spawn: N";
|
|
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
|
footer.appendChild(spawnBtn);
|
|
el.appendChild(footer);
|
|
|
|
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
|
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
|
treeHost.addEventListener("pointerdown", (e) => {
|
|
if (e.button !== 0 || !opts.onLeafDrag) return;
|
|
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
|
|
const id = leaf?.dataset.sessionId;
|
|
if (id) opts.onLeafDrag(id, e);
|
|
});
|
|
// The rows are <a> elements, so the browser's native link drag-and-drop would
|
|
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
|
|
// until release. Suppress it so the pointer-based drag runs from the start.
|
|
treeHost.addEventListener("dragstart", (e) => {
|
|
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
|
|
});
|
|
|
|
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
|
|
// width as a :root CSS var the shell grid reads; clamped to a sane range.
|
|
const SIDEBAR_W_KEY = "maestro-sidebar-w";
|
|
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
|
|
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
|
|
const handle = document.createElement("div");
|
|
handle.className = "sidebar-resize";
|
|
handle.setAttribute("aria-hidden", "true");
|
|
el.appendChild(handle);
|
|
let dragStartX = 0;
|
|
let dragStartW = 0;
|
|
let dragging = false;
|
|
handle.addEventListener("pointerdown", (e) => {
|
|
dragging = true;
|
|
dragStartX = e.clientX;
|
|
dragStartW = el.getBoundingClientRect().width;
|
|
handle.setPointerCapture(e.pointerId);
|
|
document.body.style.userSelect = "none";
|
|
e.preventDefault();
|
|
});
|
|
handle.addEventListener("pointermove", (e) => {
|
|
if (!dragging) return;
|
|
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
|
|
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
|
|
});
|
|
const endDrag = (e: PointerEvent) => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
try {
|
|
handle.releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
// pointer may already be released
|
|
}
|
|
document.body.style.userSelect = "";
|
|
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
|
|
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
|
|
};
|
|
handle.addEventListener("pointerup", endDrag);
|
|
handle.addEventListener("pointercancel", endDrag);
|
|
|
|
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
|
let filter = "";
|
|
search.addEventListener("input", () => {
|
|
filter = search.value;
|
|
refresh();
|
|
});
|
|
|
|
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
|
const applyHighlights = () => {
|
|
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
|
const id = n.dataset.sessionId;
|
|
if (!id) continue;
|
|
const focused = highlights.focused === id;
|
|
const open = highlights.open.has(id);
|
|
n.classList.toggle("is-active", focused);
|
|
n.classList.toggle("is-open", open && !focused);
|
|
}
|
|
};
|
|
|
|
// ---- keyboard navigation ----
|
|
// The walk reads the rendered DOM rather than a parallel model, so it can
|
|
// never disagree with what is on screen. Rows carry a `data-nav-key`
|
|
// (`session:<id>` / `folder:<path>`) that survives a re-render.
|
|
let navKey: string | null = null;
|
|
|
|
const navRows = (): HTMLElement[] => [
|
|
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
|
|
];
|
|
|
|
const rowFor = (key: string | null): HTMLElement | undefined =>
|
|
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
|
|
|
|
const applyNav = () => {
|
|
const rows = navRows();
|
|
// A row that went away (session killed, folder collapsed) drops the
|
|
// highlight rather than leaving it pointing at nothing.
|
|
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
|
|
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
|
};
|
|
|
|
const sessionOf = (key: string | null): string | null =>
|
|
key?.startsWith("session:") ? key.slice("session:".length) : null;
|
|
|
|
const moveNav = (delta: number) => {
|
|
const rows = navRows();
|
|
if (rows.length === 0) return;
|
|
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
|
|
// Nothing highlighted: start one off the end we are walking from, so the
|
|
// first step lands on the first (or last) row.
|
|
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
|
|
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
|
|
navKey = next.dataset.navKey ?? null;
|
|
applyNav();
|
|
next.scrollIntoView({ block: "nearest" });
|
|
opts.onPreview?.(sessionOf(navKey), "key");
|
|
};
|
|
|
|
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
|
const activateNav = () => {
|
|
const row = rowFor(navKey);
|
|
if (!row) return;
|
|
if (row.classList.contains("tree-leaf")) {
|
|
row.click();
|
|
return;
|
|
}
|
|
// Expanding replaces the folder's header in the walk with the rows it was
|
|
// hiding, so land on the first of them instead of losing the highlight.
|
|
const key = navKey;
|
|
row.click();
|
|
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
|
|
(h) => h.dataset.navKey === key,
|
|
);
|
|
const revealed = header?.parentElement
|
|
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
|
|
: undefined;
|
|
navKey = revealed?.dataset.navKey ?? null;
|
|
applyNav();
|
|
};
|
|
|
|
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
|
// out: there is no hover there, and a tap already opens the session.
|
|
let hovered: string | null = null;
|
|
const setHovered = (id: string | null) => {
|
|
if (hovered === id) return;
|
|
hovered = id;
|
|
opts.onPreview?.(id, "hover");
|
|
};
|
|
treeHost.addEventListener("pointerover", (e) => {
|
|
if (e.pointerType === "touch") return;
|
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
|
setHovered(row?.dataset.sessionId ?? null);
|
|
});
|
|
treeHost.addEventListener("pointerleave", (e) => {
|
|
if (e.pointerType === "touch") return;
|
|
setHovered(null);
|
|
});
|
|
|
|
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
|
// keyboard stays here while j/k runs.
|
|
treeHost.tabIndex = -1;
|
|
// The keyboard walk's preview belongs to the list having the keyboard: once
|
|
// it doesn't, the content pane goes back to what the operator actually opened.
|
|
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
|
|
treeHost.addEventListener("keydown", (e) => {
|
|
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
|
// The inline rename field lives inside a row and owns its own keys.
|
|
if (isTypingTarget(e.target)) return;
|
|
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
|
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
|
else if (e.key === "Enter") activateNav();
|
|
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
|
else return;
|
|
e.preventDefault();
|
|
});
|
|
|
|
const refresh = () => {
|
|
// Rebuilding the tree now would blow away the field being typed into.
|
|
if (renamingId !== null) return;
|
|
const state = store.snapshot();
|
|
const sessions = [...state.sessions.values()];
|
|
const tree = buildTree(sessions, homeDir, filter);
|
|
treeHost.replaceChildren();
|
|
if (sessions.length === 0) {
|
|
treeHost.appendChild(
|
|
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
|
);
|
|
return;
|
|
}
|
|
if (tree.kind === "folder") {
|
|
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
|
}
|
|
applyHighlights();
|
|
applyNav();
|
|
};
|
|
|
|
void getDaemonInfo()
|
|
.then((info) => {
|
|
homeDir = info.home_dir;
|
|
refresh();
|
|
})
|
|
.catch(() => {
|
|
/* absolute paths are a fine fallback */
|
|
});
|
|
|
|
const rerenderHandler = () => refresh();
|
|
document.addEventListener("catalog-rerender", rerenderHandler);
|
|
|
|
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
|
// multi-view controller can seed the spawn modal with the session's dir/command.
|
|
|
|
const unsubscribe = store.subscribe(refresh);
|
|
|
|
// Contract granularity: re-render at least every 30s while visible so the
|
|
// duration cutover points land within one tick.
|
|
const clock = setInterval(() => {
|
|
if (document.visibilityState !== "visible") return;
|
|
refresh();
|
|
}, 30_000);
|
|
|
|
return {
|
|
el,
|
|
setActive(id: string | null) {
|
|
highlights = {
|
|
open: id ? new Set([id]) : new Set(),
|
|
focused: id,
|
|
};
|
|
applyHighlights();
|
|
},
|
|
setHighlights(h: SidebarHighlights) {
|
|
highlights = h;
|
|
applyHighlights();
|
|
},
|
|
focusList() {
|
|
// Start from the session on screen — that is where the user's attention
|
|
// already is — falling back to the standing highlight, then the top row.
|
|
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
|
|
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
|
|
navKey = start?.dataset.navKey ?? null;
|
|
applyNav();
|
|
treeHost.focus();
|
|
start?.scrollIntoView({ block: "nearest" });
|
|
},
|
|
hasListFocus() {
|
|
return treeHost.contains(document.activeElement);
|
|
},
|
|
dispose() {
|
|
clearInterval(clock);
|
|
status?.dispose();
|
|
// An unfinished rename would otherwise leave every later sidebar
|
|
// refusing to refresh.
|
|
renamingId = null;
|
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
|
unsubscribe();
|
|
},
|
|
};
|
|
}
|