Post-React-migration regressions: spawn submit button moved to modal footer, sidebar footer restored (help/theme/palette/status/spawn), session card stripped of redundant Tailwind, action buttons use proper leaf-act/leaf-actions CSS structure for hover-only visibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
// React port of the sidebar tree (catalog.ts). The contract it renders is
|
|
// docs/SIDEBAR.md: two-line cards behind an activity rail, folder-grouped by
|
|
// repository, walked with j/k.
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useStore } from "zustand";
|
|
|
|
import { ApiRequestError, killSession, renameSession } from "../../api.js";
|
|
import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js";
|
|
import type { InTheWay, Session } from "../../types.js";
|
|
import { sessionBlockers, sessionName } from "../../types.js";
|
|
import { buildTree, type TreeNode } from "../../tree.js";
|
|
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, requestToggleHelp } from "../../uiEvents.js";
|
|
import { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js";
|
|
import type { DaemonLinkStoreApi, DaemonLinkState } from "../daemon/DaemonLink.js";
|
|
import { formatRelease } from "../daemon/DaemonLink.js";
|
|
import { useTheme, PALETTES, type Palette } from "../../contexts/ThemeContext.js";
|
|
import { FolderNode } from "./FolderNode.js";
|
|
import { KillDialog } from "../dialogs/KillDialog.js";
|
|
import { SessionCard } from "./SessionCard.js";
|
|
import { SidebarSearch } from "./SidebarSearch.js";
|
|
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
|
|
import { useNow } from "./useNow.js";
|
|
import "./sidebar.css";
|
|
|
|
export interface SidebarProps {
|
|
/** Live catalog. Either this or `sessions` supplies the tree. */
|
|
store?: CatalogStoreApi;
|
|
/** Static session list — what tests and the design kit render from. */
|
|
sessions?: Session[];
|
|
/** The daemon's home dir, so cwds abbreviate to `~/…`. */
|
|
homeDir?: string;
|
|
/** Showing in the focused pane. */
|
|
activeId?: string | null;
|
|
/** Open in some pane, focused one included. */
|
|
openIds?: ReadonlySet<string>;
|
|
/** Previewing (docs/PREVIEW.md) — a view, never durable state. */
|
|
previewId?: string | null;
|
|
onSelect?(id: string): void;
|
|
/** The highlight moved onto a session, or off every session. */
|
|
onPreview?(id: string | null): void;
|
|
onSpawnSimilar?(session: Session): void;
|
|
onSpawn?(): void;
|
|
/** Fast-forward spawn (`N`). */
|
|
onSpawnDefaults?(): void;
|
|
/** Tab: hand the keyboard back to the pane. */
|
|
onFocusPane?(): void;
|
|
onHelp?(): void;
|
|
/** The daemon-link store, for the status indicator in the footer. */
|
|
linkStore?: DaemonLinkStoreApi;
|
|
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
|
|
onKill?(session: Session): void;
|
|
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
|
|
onRename?(id: string, name: string): void;
|
|
}
|
|
|
|
interface MenuState {
|
|
x: number;
|
|
y: number;
|
|
session: Session;
|
|
}
|
|
|
|
interface KillState {
|
|
session: Session;
|
|
blockers: InTheWay | null;
|
|
force: boolean;
|
|
/** The daemon's refusal message, once an unforced kill has been refused. */
|
|
reason?: string;
|
|
}
|
|
|
|
export function Sidebar(props: SidebarProps) {
|
|
const sessions = useSessions(props);
|
|
const now = useNow();
|
|
|
|
const [query, setQuery] = useState("");
|
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());
|
|
const [navKey, setNavKey] = useState<string | null>(null);
|
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
const [menu, setMenu] = useState<MenuState | null>(null);
|
|
const [kill, setKill] = useState<KillState | null>(null);
|
|
const treeRef = useRef<HTMLDivElement>(null);
|
|
|
|
const tree = useMemo(
|
|
() => buildTree(filterSessions(sessions, query), props.homeDir),
|
|
[sessions, query, props.homeDir],
|
|
);
|
|
const rows = useMemo(() => navRows(tree, collapsed), [tree, collapsed]);
|
|
|
|
// A row that went away — session killed, folder collapsed over it — drops
|
|
// the highlight rather than leaving it pointing at nothing.
|
|
useEffect(() => {
|
|
if (navKey !== null && !rows.some((r) => r.key === navKey)) setNavKey(null);
|
|
}, [rows, navKey]);
|
|
|
|
const preview = props.onPreview;
|
|
const previewOf = useCallback(
|
|
(key: string | null) => {
|
|
preview?.(key?.startsWith("session:") ? key.slice("session:".length) : null);
|
|
},
|
|
[preview],
|
|
);
|
|
|
|
const moveNav = useCallback(
|
|
(delta: number) => {
|
|
if (rows.length === 0) return;
|
|
const cur = rows.findIndex((r) => r.key === navKey);
|
|
// Nothing highlighted: start one off the end being walked 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];
|
|
setNavKey(next.key);
|
|
previewOf(next.key);
|
|
},
|
|
[rows, navKey, previewOf],
|
|
);
|
|
|
|
const toggleFolder = useCallback((path: string) => {
|
|
setCollapsed((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(path)) next.delete(path);
|
|
else next.add(path);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
/** Enter: open the highlighted session, or expand the highlighted folder and
|
|
* land on the first row it was hiding. */
|
|
const activateNav = useCallback(() => {
|
|
const row = rows.find((r) => r.key === navKey);
|
|
if (!row) return;
|
|
if (row.kind === "session") {
|
|
props.onSelect?.(row.id);
|
|
return;
|
|
}
|
|
const expanded = new Set(collapsed);
|
|
expanded.delete(row.path);
|
|
setCollapsed(expanded);
|
|
const revealed = firstRowUnder(tree, expanded, row.path);
|
|
setNavKey(revealed?.key ?? null);
|
|
}, [rows, navKey, collapsed, tree, props]);
|
|
|
|
const beginKill = useCallback(
|
|
(s: Session) => {
|
|
if (props.onKill) {
|
|
props.onKill(s);
|
|
return;
|
|
}
|
|
setKill({ session: s, blockers: sessionBlockers(s), force: false });
|
|
},
|
|
[props],
|
|
);
|
|
|
|
const commitRename = useCallback(
|
|
(id: string, name: string) => {
|
|
setRenamingId(null);
|
|
if (props.onRename) props.onRename(id, name);
|
|
else void renameSession(id, name).catch(() => {});
|
|
},
|
|
[props],
|
|
);
|
|
|
|
/** The session behind the highlighted row, when the highlight is on a card. */
|
|
const highlightedSession = useCallback((): Session | undefined => {
|
|
const row = rows.find((r) => r.key === navKey);
|
|
if (row?.kind !== "session") return undefined;
|
|
return sessions.find((s) => s.id === row.id);
|
|
}, [rows, navKey, sessions]);
|
|
|
|
// The command bar's action page raises these rather than reimplementing
|
|
// either flow (docs/COMMAND-BAR.md): a kill from the bar goes through the
|
|
// same confirm-and-blockers dialog the card's ✕ does, and a rename from the
|
|
// bar opens the card's own inline field.
|
|
useEffect(() => {
|
|
const onKillRequest = (e: Event) => {
|
|
const s = (e as CustomEvent<Session>).detail;
|
|
if (s) beginKill(s);
|
|
};
|
|
const onRenameRequest = (e: Event) => {
|
|
const s = (e as CustomEvent<Session>).detail;
|
|
if (s) setRenamingId(s.id);
|
|
};
|
|
document.addEventListener(KILL_SESSION_EVENT, onKillRequest);
|
|
document.addEventListener(RENAME_SESSION_EVENT, onRenameRequest);
|
|
return () => {
|
|
document.removeEventListener(KILL_SESSION_EVENT, onKillRequest);
|
|
document.removeEventListener(RENAME_SESSION_EVENT, onRenameRequest);
|
|
};
|
|
}, [beginKill]);
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
// The inline rename field owns its own keys; the walk stays out of it.
|
|
if (e.target instanceof HTMLInputElement) return;
|
|
switch (e.key) {
|
|
case "j":
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
moveNav(1);
|
|
return;
|
|
case "k":
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
moveNav(-1);
|
|
return;
|
|
case "Enter":
|
|
e.preventDefault();
|
|
activateNav();
|
|
return;
|
|
case "Tab":
|
|
e.preventDefault();
|
|
props.onFocusPane?.();
|
|
return;
|
|
case "c": {
|
|
const row = rows.find((r) => r.key === navKey);
|
|
if (row?.kind !== "folder") return;
|
|
e.preventDefault();
|
|
toggleFolder(row.path);
|
|
return;
|
|
}
|
|
case "r": {
|
|
const s = highlightedSession();
|
|
if (!s) return;
|
|
e.preventDefault();
|
|
setRenamingId(s.id);
|
|
return;
|
|
}
|
|
case "x": {
|
|
const s = highlightedSession();
|
|
if (!s) return;
|
|
e.preventDefault();
|
|
beginKill(s);
|
|
return;
|
|
}
|
|
case "n":
|
|
e.preventDefault();
|
|
props.onSpawn?.();
|
|
return;
|
|
case "N":
|
|
e.preventDefault();
|
|
props.onSpawnDefaults?.();
|
|
return;
|
|
case "?":
|
|
e.preventDefault();
|
|
props.onHelp?.();
|
|
return;
|
|
}
|
|
};
|
|
|
|
const menuItems = (s: Session): ContextMenuItem[] => [
|
|
{ label: "Rename…", onSelect: () => setRenamingId(s.id) },
|
|
{ label: "Spawn similar", onSelect: () => props.onSpawnSimilar?.(s) },
|
|
{ label: "Kill session", danger: true, onSelect: () => beginKill(s) },
|
|
];
|
|
|
|
const renderNode = (node: TreeNode, depth: number): React.ReactNode => {
|
|
if (node.kind === "leaf") {
|
|
const s = node.session;
|
|
return (
|
|
<SessionCard
|
|
key={s.id}
|
|
session={s}
|
|
depth={depth}
|
|
now={now}
|
|
highlighted={navKey === sessionKey(s.id)}
|
|
active={props.activeId === s.id}
|
|
open={(props.openIds?.has(s.id) ?? false) && props.activeId !== s.id}
|
|
previewing={props.previewId === s.id && props.activeId !== s.id}
|
|
renaming={renamingId === s.id}
|
|
onSelect={() => props.onSelect?.(s.id)}
|
|
onOpenMenu={(x, y) => setMenu({ x, y, session: s })}
|
|
onSpawnSimilar={() => props.onSpawnSimilar?.(s)}
|
|
onKill={() => beginKill(s)}
|
|
onRenameCommit={(name) => commitRename(s.id, name)}
|
|
onRenameCancel={() => setRenamingId(null)}
|
|
/>
|
|
);
|
|
}
|
|
if (!node.name) {
|
|
return node.children.map((c) => renderNode(c, depth));
|
|
}
|
|
return (
|
|
<FolderNode
|
|
key={node.path}
|
|
name={node.name}
|
|
path={node.path}
|
|
depth={depth}
|
|
collapsed={collapsed.has(node.path)}
|
|
highlighted={navKey === `folder:${node.path}`}
|
|
onToggle={() => toggleFolder(node.path)}
|
|
>
|
|
{node.children.map((c) => renderNode(c, depth + 1))}
|
|
</FolderNode>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<aside className="sidebar flex h-full min-h-0 flex-col bg-surface">
|
|
<SidebarSearch
|
|
value={query}
|
|
onChange={setQuery}
|
|
onDismiss={() => treeRef.current?.focus()}
|
|
/>
|
|
<div
|
|
ref={treeRef}
|
|
className="tree min-h-0 flex-1 overflow-y-auto outline-none"
|
|
tabIndex={-1}
|
|
role="tree"
|
|
aria-label="Sessions"
|
|
onKeyDown={onKeyDown}
|
|
// The keyboard walk's preview belongs to the list holding the
|
|
// keyboard: once it doesn't, the pane goes back to what was opened.
|
|
onBlur={() => preview?.(null)}
|
|
>
|
|
{sessions.length === 0 ? (
|
|
<EmptyState onSpawn={() => props.onSpawn?.()} />
|
|
) : (
|
|
renderNode(tree, 0)
|
|
)}
|
|
</div>
|
|
{menu && (
|
|
<ContextMenu
|
|
x={menu.x}
|
|
y={menu.y}
|
|
items={menuItems(menu.session)}
|
|
onClose={() => setMenu(null)}
|
|
/>
|
|
)}
|
|
{kill && (
|
|
<KillDialog
|
|
name={sessionName(kill.session)}
|
|
blockers={kill.blockers}
|
|
force={kill.force}
|
|
reason={kill.reason}
|
|
onCancel={() => setKill(null)}
|
|
onConfirm={async () => {
|
|
const { session: s, force } = kill;
|
|
try {
|
|
await killSession(s.id, force);
|
|
setKill(null);
|
|
} catch (e: unknown) {
|
|
// The daemon refuses an unforced kill over a worktree holding
|
|
// unsaved work; that refusal needs its own prompt
|
|
// (docs/WORKTREES.md).
|
|
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
|
setKill({
|
|
session: s,
|
|
blockers: e.unsavedWork ?? sessionBlockers(s),
|
|
force: true,
|
|
reason: e.message,
|
|
});
|
|
return;
|
|
}
|
|
throw e;
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
<SidebarFooter
|
|
onSpawn={() => props.onSpawn?.()}
|
|
linkStore={props.linkStore}
|
|
/>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
/** The tree's one empty state: no sessions at all. */
|
|
function EmptyState({ onSpawn }: { onSpawn(): void }) {
|
|
return (
|
|
<div className="empty flex flex-col items-center gap-sp-2 p-sp-6 text-center">
|
|
<div aria-hidden="true" className="text-fs-20 text-faint">
|
|
+
|
|
</div>
|
|
<div className="text-fs-13 text-ink">No sessions yet</div>
|
|
<p className="text-fs-12 text-muted">Start your first session — it'll show up here.</p>
|
|
<button
|
|
type="button"
|
|
className="rounded-r-1 bg-accent px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
|
|
onClick={onSpawn}
|
|
>
|
|
+ Spawn session
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Sessions from the store when one is wired, else the static prop. */
|
|
function useSessions(props: SidebarProps): Session[] {
|
|
const fromStore = useStore(
|
|
// `useStore` needs a store every render; the no-store case is served by a
|
|
// stable empty stand-in whose result is thrown away below.
|
|
props.store ?? EMPTY_STORE,
|
|
(s: CatalogStore) => s.sessions,
|
|
);
|
|
return useMemo(
|
|
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
|
|
[props.store, props.sessions, fromStore],
|
|
);
|
|
}
|
|
|
|
// One frozen state object, not a fresh one per call: `useStore` reads the
|
|
// snapshot on every render and a new Map each time would never settle.
|
|
const EMPTY_STATE = { sessions: new Map<string, Session>() };
|
|
const EMPTY_STORE = {
|
|
getState: () => EMPTY_STATE,
|
|
getInitialState: () => EMPTY_STATE,
|
|
setState: () => {},
|
|
subscribe: () => () => {},
|
|
} as unknown as CatalogStoreApi;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sidebar footer: help, theme toggle, palette picker, status dot, spawn.
|
|
// Mirrors the vanilla catalog.ts footer that the React migration dropped.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SidebarFooter({
|
|
onSpawn,
|
|
linkStore,
|
|
}: {
|
|
onSpawn(): void;
|
|
linkStore?: DaemonLinkStoreApi;
|
|
}) {
|
|
const { theme, toggleTheme, palette, setPalette } = useTheme();
|
|
|
|
return (
|
|
<div className="sidebar-footer">
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-icon help-btn"
|
|
title="Keyboard shortcuts (?)"
|
|
aria-label="Keyboard shortcuts"
|
|
onClick={requestToggleHelp}
|
|
>
|
|
?
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-icon"
|
|
title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
|
|
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
|
|
onClick={toggleTheme}
|
|
>
|
|
{theme === "dark" ? "☀" : "☾"}
|
|
</button>
|
|
<select
|
|
className="palette-select"
|
|
value={palette}
|
|
aria-label="Colour palette"
|
|
onChange={(e) => setPalette(e.currentTarget.value as Palette)}
|
|
>
|
|
{PALETTES.map((p) => (
|
|
<option key={p} value={p}>
|
|
{p}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{linkStore && <StatusDot store={linkStore} />}
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary spawn-btn"
|
|
title="Spawn form: n — fast-forward spawn: N"
|
|
onClick={onSpawn}
|
|
>
|
|
+ Spawn
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Daemon status indicator: a coloured dot whose tooltip names the release. */
|
|
function StatusDot({ store }: { store: DaemonLinkStoreApi }) {
|
|
const state = useStore(store, (s: DaemonLinkState) => s.state);
|
|
const build = useStore(store, (s: DaemonLinkState) => s.build);
|
|
const hostname = useStore(store, (s: DaemonLinkState) => s.hostname);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="status-chip btn btn-ghost btn-icon"
|
|
data-state={state}
|
|
aria-label={`Daemon ${state}`}
|
|
>
|
|
<span className="status-dot" />
|
|
<span className="status-tip">
|
|
<span>{hostname ?? "maestro"}</span>
|
|
<span className="text-faint">{formatRelease(build)}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|