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
265 lines
8.2 KiB
TypeScript
265 lines
8.2 KiB
TypeScript
// docs/SIDEBAR.md: a session card is always two lines — the name, then the
|
|
// adaptive meta line — with the activity rail in the gutter beside them.
|
|
|
|
import { useEffect, useRef, type MouseEvent } from "react";
|
|
|
|
import {
|
|
activityDurationLabel,
|
|
activityHeldMs,
|
|
META_LINE_MIN_HOLD_MS,
|
|
} from "../../activity.js";
|
|
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
|
|
import type { InTheWay, Session } from "../../types.js";
|
|
import {
|
|
blockerStats,
|
|
customSessionName,
|
|
formatInTheWay,
|
|
isProvisionalName,
|
|
MAX_SESSION_NAME_LEN,
|
|
normalizeSessionName,
|
|
sessionBlockers,
|
|
sessionName,
|
|
} from "../../types.js";
|
|
import { ActivityRail, DURATION_CLASS, stateWordFor } from "./ActivityRail.js";
|
|
import { activityColorSlug } from "../../activity.js";
|
|
|
|
export interface SessionCardProps {
|
|
session: Session;
|
|
/** Nesting depth; the row's padding (and so the rail) shifts outward with it. */
|
|
depth: number;
|
|
/** Clock the duration is measured against — the sidebar ticks it. */
|
|
now: number;
|
|
highlighted: boolean;
|
|
/** Showing in the focused pane. */
|
|
active: boolean;
|
|
/** Open in some pane, not the focused one. */
|
|
open: boolean;
|
|
previewing: boolean;
|
|
renaming: boolean;
|
|
onSelect(): void;
|
|
onOpenMenu(x: number, y: number): void;
|
|
onSpawnSimilar(): void;
|
|
onKill(): void;
|
|
onRenameCommit(name: string): void;
|
|
onRenameCancel(): void;
|
|
}
|
|
|
|
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
|
|
* wins. Written as one ordered run so the precedence is the order it reads in. */
|
|
function rowFill(p: SessionCardProps): string {
|
|
if (p.active) return "bg-accent-fill";
|
|
if (p.previewing) return "bg-preview-tint";
|
|
if (p.open) return "bg-accent/[0.06]";
|
|
if (p.highlighted) return "bg-preview-tint";
|
|
return "";
|
|
}
|
|
|
|
export function SessionCard(props: SessionCardProps) {
|
|
const { session: s, depth, now, renaming } = props;
|
|
const name = sessionName(s);
|
|
const running = s.status === "Running";
|
|
const word = stateWordFor(s.activity);
|
|
|
|
return (
|
|
<a
|
|
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
|
|
running ? "" : "opacity-60"
|
|
}`}
|
|
href={`#/attach/${encodeURIComponent(s.id)}`}
|
|
style={{ paddingLeft: depth * 16 + 8 }}
|
|
data-session-id={s.id}
|
|
data-nav-key={`session:${s.id}`}
|
|
data-status={running ? "running" : "dead"}
|
|
data-highlighted={props.highlighted ? "true" : undefined}
|
|
aria-label={`${name}, ${running ? "running" : "exited"}, ${word}`}
|
|
onClick={(e) => {
|
|
if (renaming) return;
|
|
e.preventDefault();
|
|
props.onSelect();
|
|
}}
|
|
onContextMenu={(e: MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
props.onOpenMenu(e.clientX, e.clientY);
|
|
}}
|
|
>
|
|
<ActivityRail activity={s.activity} />
|
|
<span className="flex min-w-0 items-center gap-sp-2">
|
|
{renaming ? (
|
|
<RenameField
|
|
session={s}
|
|
onCommit={props.onRenameCommit}
|
|
onCancel={props.onRenameCancel}
|
|
/>
|
|
) : (
|
|
<span
|
|
className={`min-w-0 flex-1 truncate text-fs-13 ${
|
|
isProvisionalName(s) ? "text-muted" : "text-ink"
|
|
}`}
|
|
>
|
|
{name}
|
|
</span>
|
|
)}
|
|
<span className="flex flex-none items-center gap-[2px]">
|
|
<button
|
|
type="button"
|
|
className="leaf-clone text-faint hover:text-ink"
|
|
title="Spawn similar"
|
|
aria-label={`Spawn similar to ${name}`}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
props.onSpawnSimilar();
|
|
}}
|
|
>
|
|
⧉
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="leaf-kill text-faint hover:text-danger"
|
|
title="Kill session"
|
|
aria-label={`Kill ${name}`}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
props.onKill();
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</span>
|
|
</span>
|
|
<MetaLine session={s} now={now} />
|
|
</a>
|
|
);
|
|
}
|
|
|
|
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`.
|
|
* The line itself is unconditional; only its fragments are adaptive. */
|
|
export function MetaLine({ session: s, now }: { session: Session; now: number }) {
|
|
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 blockers = sessionBlockers(s);
|
|
|
|
return (
|
|
<span
|
|
className="card-line2 flex min-w-0 items-center gap-sp-2 text-fs-11"
|
|
data-activity={slug}
|
|
>
|
|
{duration && (
|
|
<span className={`activity-duration flex-none ${DURATION_CLASS[slug] ?? "text-muted"}`}>
|
|
{duration}
|
|
</span>
|
|
)}
|
|
{s.activity_detail && (
|
|
<span className="activity-detail min-w-0 flex-1 truncate text-faint">
|
|
{s.activity_detail}
|
|
</span>
|
|
)}
|
|
<CommandFragment command={s.command} />
|
|
{blockers && <BlockedFragment blockers={blockers} />}
|
|
{s.status === "Dead" && <span className="flex-none text-faint">exited</span>}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** docs/SIDEBAR.md: the card names the program only, as its tool mark when we
|
|
* have one. The full command line stays in the tooltip either way. */
|
|
function CommandFragment({ command }: { command: string }) {
|
|
const { tool, program } = splitCommand(command);
|
|
const icon = tool ? toolIconSvg(tool) : null;
|
|
return (
|
|
<span
|
|
className="leaf-cmd flex min-w-0 flex-none items-center gap-[4px] truncate text-faint"
|
|
title={command}
|
|
>
|
|
{icon ? (
|
|
<span className="contents" dangerouslySetInnerHTML={{ __html: icon }} />
|
|
) : (
|
|
<span className="truncate">{program}</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Blocked work in diff notation — `+124 -18 ?3 ↑2`. Only `+` and `-` take
|
|
* colour; the spelled-out breakdown survives as the tooltip. */
|
|
function BlockedFragment({ blockers }: { blockers: InTheWay }) {
|
|
const stats = blockerStats(blockers);
|
|
return (
|
|
<span
|
|
className="leaf-blocked flex flex-none items-center gap-[4px] text-faint"
|
|
title={`Kill will be refused unless forced — ${formatInTheWay(blockers)}`}
|
|
>
|
|
{stats.map((st) => (
|
|
<span
|
|
key={st.kind}
|
|
data-stat={st.kind}
|
|
className={
|
|
st.kind === "add" ? "text-live" : st.kind === "del" ? "text-danger" : "text-muted"
|
|
}
|
|
>
|
|
{st.text}
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Inline rename (#125): Enter commits, Escape and blur cancel, an emptied
|
|
* field clears the name so the command fallback resumes. It starts from the
|
|
* session's own name, never from the command it falls back to. */
|
|
function RenameField({
|
|
session: s,
|
|
onCommit,
|
|
onCancel,
|
|
}: {
|
|
session: Session;
|
|
onCommit(name: string): void;
|
|
onCancel(): void;
|
|
}) {
|
|
const ref = useRef<HTMLInputElement>(null);
|
|
const settled = useRef(false);
|
|
|
|
useEffect(() => {
|
|
ref.current?.focus();
|
|
ref.current?.select();
|
|
}, []);
|
|
|
|
return (
|
|
<input
|
|
ref={ref}
|
|
type="text"
|
|
className="leaf-rename min-w-0 flex-1 bg-surface-2 text-fs-13 text-ink"
|
|
defaultValue={customSessionName(s) ?? ""}
|
|
maxLength={MAX_SESSION_NAME_LEN}
|
|
aria-label={`Rename ${sessionName(s)}`}
|
|
// The card is an <a> the tree also drags from: without these, clicking
|
|
// into the field navigates away or starts a drag.
|
|
onClick={(e) => e.stopPropagation()}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (settled.current) return;
|
|
settled.current = true;
|
|
onCommit(normalizeSessionName(e.currentTarget.value));
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
if (settled.current) return;
|
|
settled.current = true;
|
|
onCancel();
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
if (settled.current) return;
|
|
settled.current = true;
|
|
onCancel();
|
|
}}
|
|
/>
|
|
);
|
|
}
|