Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba83c3d8f |
@@ -1,47 +0,0 @@
|
|||||||
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
|
|
||||||
// corners, full card height. It paints for every card, Idle included, and it
|
|
||||||
// carries the accessible name of the state so status is never colour-only.
|
|
||||||
|
|
||||||
import { activityColorSlug } from "../../activity.js";
|
|
||||||
import type { ActivityState } from "../../types.js";
|
|
||||||
|
|
||||||
export const STATE_WORDS: Record<string, string> = {
|
|
||||||
active: "Active",
|
|
||||||
idle: "Idle",
|
|
||||||
"awaiting-input": "Awaiting input",
|
|
||||||
error: "Error",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Rail fill per state, and the motion the state earns. */
|
|
||||||
const RAIL_CLASS: Record<string, string> = {
|
|
||||||
active: "bg-live",
|
|
||||||
idle: "bg-border-strong",
|
|
||||||
"awaiting-input": "bg-warn rail-blink",
|
|
||||||
error: "bg-danger rail-flash",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Duration text inherits the rail's colour (docs/SIDEBAR.md). */
|
|
||||||
export const DURATION_CLASS: Record<string, string> = {
|
|
||||||
active: "text-live",
|
|
||||||
idle: "text-muted",
|
|
||||||
"awaiting-input": "text-warn",
|
|
||||||
error: "text-danger",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function stateWordFor(activity: ActivityState | string | undefined): string {
|
|
||||||
return STATE_WORDS[activityColorSlug(activity ?? "Idle")] ?? STATE_WORDS.idle;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivityRail({ activity }: { activity: ActivityState | string | undefined }) {
|
|
||||||
const slug = activityColorSlug(activity ?? "Idle");
|
|
||||||
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
|
|
||||||
data-activity={slug}
|
|
||||||
role="img"
|
|
||||||
title={word}
|
|
||||||
aria-label={word}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
|
|
||||||
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
|
||||||
label: string;
|
|
||||||
danger?: boolean;
|
|
||||||
onSelect(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMenuProps {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
items: ContextMenuItem[];
|
|
||||||
onClose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
const onDown = (e: Event) => {
|
|
||||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
document.addEventListener("pointerdown", onDown, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
document.removeEventListener("pointerdown", onDown, true);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="menu"
|
|
||||||
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
|
|
||||||
style={{ left: x, top: y }}
|
|
||||||
>
|
|
||||||
{items.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.label}
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
|
|
||||||
item.danger ? "text-danger" : "text-ink"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
item.onSelect();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// A collapsible directory in the sidebar tree. Only a *collapsed* header is a
|
|
||||||
// keyboard-walk stop (docs/SIDEBAR.md); an expanded one is decoration in front
|
|
||||||
// of the cards it holds, so it carries no highlight of its own.
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { splitFolderLabel } from "./model.js";
|
|
||||||
|
|
||||||
export interface FolderNodeProps {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
depth: number;
|
|
||||||
collapsed: boolean;
|
|
||||||
highlighted: boolean;
|
|
||||||
onToggle(): void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FolderNode(props: FolderNodeProps) {
|
|
||||||
const { stem, last } = splitFolderLabel(props.name);
|
|
||||||
return (
|
|
||||||
<div className="tree-folder">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`tree-folder-header flex w-full items-center gap-sp-1 py-[2px] text-fs-11 text-muted ${
|
|
||||||
props.highlighted ? "ring-1 ring-accent-ring" : ""
|
|
||||||
}`}
|
|
||||||
style={{ paddingLeft: props.depth * 16 }}
|
|
||||||
aria-expanded={!props.collapsed}
|
|
||||||
data-nav-key={`folder:${props.path}`}
|
|
||||||
data-highlighted={props.highlighted ? "true" : undefined}
|
|
||||||
title={props.name}
|
|
||||||
onClick={props.onToggle}
|
|
||||||
>
|
|
||||||
{/* One glyph, rotated when collapsed, so the mark and the rotation
|
|
||||||
never disagree about which way the folder points. */}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`inline-block transition-transform ${props.collapsed ? "-rotate-90" : ""}`}
|
|
||||||
>
|
|
||||||
▾
|
|
||||||
</span>
|
|
||||||
<span className="truncate">
|
|
||||||
{stem && <span className="text-faint">{stem}/</span>}
|
|
||||||
{last}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{!props.collapsed && props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
|
|
||||||
// confirmation names what the card last saw in the way — the daemon refuses an
|
|
||||||
// unforced kill over such a tree, and saying so beforehand is cheaper than the
|
|
||||||
// refusal. Cancel holds the focus so a stray Enter kills nothing.
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
import type { InTheWay } from "../../types.js";
|
|
||||||
import { formatInTheWay } from "../../types.js";
|
|
||||||
|
|
||||||
export interface KillConfirmProps {
|
|
||||||
name: string;
|
|
||||||
blockers: InTheWay | null;
|
|
||||||
/** Set when the daemon already refused an unforced kill: this pass forces. */
|
|
||||||
force?: boolean;
|
|
||||||
onConfirm(): void;
|
|
||||||
onCancel(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KillConfirm(props: KillConfirmProps) {
|
|
||||||
const cancel = useRef<HTMLButtonElement>(null);
|
|
||||||
useEffect(() => cancel.current?.focus(), []);
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={props.force ? "Force kill session" : "Kill session"}
|
|
||||||
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") props.onCancel();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
|
|
||||||
<p className="text-fs-13 text-ink">
|
|
||||||
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
|
|
||||||
</p>
|
|
||||||
{props.blockers && (
|
|
||||||
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-sp-3 flex justify-end gap-sp-2">
|
|
||||||
<button
|
|
||||||
ref={cancel}
|
|
||||||
type="button"
|
|
||||||
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
|
|
||||||
onClick={props.onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
|
|
||||||
onClick={props.onConfirm}
|
|
||||||
>
|
|
||||||
{props.force ? "Force kill" : "Kill"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { Session } from "../../types.js";
|
|
||||||
import { Sidebar } from "./Sidebar.js";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
function session(id: string, over: Partial<Session> = {}): Session {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
cwd: "/home/n/alpha",
|
|
||||||
command: "hx src/",
|
|
||||||
env: {},
|
|
||||||
metadata: { name: id },
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-08-24T10:00:00Z",
|
|
||||||
...over,
|
|
||||||
} as Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The card element for a session, by the `data-session-id` the row carries. */
|
|
||||||
function card(id: string): HTMLElement {
|
|
||||||
const el = document.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
|
||||||
if (!el) throw new Error(`no card for ${id}`);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tree(): HTMLElement {
|
|
||||||
return document.querySelector<HTMLElement>(".tree")!;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SessionCard layout", () => {
|
|
||||||
it("renders two lines: the name, then a meta line naming the program", () => {
|
|
||||||
render(<Sidebar sessions={[session("alpha")]} />);
|
|
||||||
|
|
||||||
const row = card("alpha");
|
|
||||||
expect(row.textContent).toContain("alpha");
|
|
||||||
const meta = row.querySelector(".card-line2")!;
|
|
||||||
// The program only — never its arguments.
|
|
||||||
expect(meta.textContent).toContain("hx");
|
|
||||||
expect(meta.textContent).not.toContain("src/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows no duration under the 30s hold, and one above it", () => {
|
|
||||||
const now = Date.parse("2026-08-24T10:00:00Z");
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
|
|
||||||
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
|
|
||||||
|
|
||||||
render(<Sidebar sessions={[fresh, held]} />);
|
|
||||||
|
|
||||||
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
|
|
||||||
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
|
|
||||||
render(
|
|
||||||
<Sidebar
|
|
||||||
sessions={[
|
|
||||||
session("idle"),
|
|
||||||
session("await", { activity: "AwaitingInput" }),
|
|
||||||
session("err", { activity: "Error" }),
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
|
|
||||||
expect(rail("idle").dataset.activity).toBe("idle");
|
|
||||||
expect(rail("idle").className).not.toContain("rail-");
|
|
||||||
expect(rail("await").className).toContain("rail-blink");
|
|
||||||
expect(rail("err").className).toContain("rail-flash");
|
|
||||||
// Status never rests on colour alone.
|
|
||||||
expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
|
|
||||||
render(
|
|
||||||
<Sidebar
|
|
||||||
sessions={[
|
|
||||||
session("alpha", {
|
|
||||||
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
|
|
||||||
expect(blocked.textContent).toBe("+124-18?3");
|
|
||||||
expect(blocked.title).toContain("2 uncommitted files");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dims a provisional name and leaves a settled one alone", () => {
|
|
||||||
render(
|
|
||||||
<Sidebar
|
|
||||||
sessions={[
|
|
||||||
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
|
|
||||||
session("user", { metadata: { name: "user", name_source: "User" } }),
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
|
|
||||||
expect(card("user").textContent).toContain("user");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("keyboard walk", () => {
|
|
||||||
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
|
|
||||||
|
|
||||||
it("j and k step the highlight through the cards", () => {
|
|
||||||
render(<Sidebar sessions={two} />);
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
expect(card("a").dataset.highlighted).toBe("true");
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
expect(card("b").dataset.highlighted).toBe("true");
|
|
||||||
fireEvent.keyDown(tree(), { key: "k" });
|
|
||||||
expect(card("a").dataset.highlighted).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
|
|
||||||
render(<Sidebar sessions={two} />);
|
|
||||||
|
|
||||||
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
|
|
||||||
fireEvent.click(header);
|
|
||||||
expect(header.getAttribute("aria-expanded")).toBe("false");
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
expect(
|
|
||||||
document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!.dataset.highlighted,
|
|
||||||
).toBe("true");
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
|
||||||
expect(card("a").dataset.highlighted).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
const onFocusPane = vi.fn();
|
|
||||||
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
|
||||||
expect(onSelect).toHaveBeenCalledWith("a");
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "Tab" });
|
|
||||||
expect(onFocusPane).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("previews the session the walk lands on", () => {
|
|
||||||
const onPreview = vi.fn();
|
|
||||||
render(<Sidebar sessions={two} onPreview={onPreview} />);
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
expect(onPreview).toHaveBeenCalledWith("a");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("x asks to kill the highlighted session and r renames it", () => {
|
|
||||||
const onKill = vi.fn();
|
|
||||||
render(<Sidebar sessions={two} onKill={onKill} />);
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "j" });
|
|
||||||
fireEvent.keyDown(tree(), { key: "x" });
|
|
||||||
expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
|
|
||||||
|
|
||||||
fireEvent.keyDown(tree(), { key: "r" });
|
|
||||||
expect(card("a").querySelector(".leaf-rename")).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("context menu", () => {
|
|
||||||
it("offers rename, spawn similar and kill", () => {
|
|
||||||
const onSpawnSimilar = vi.fn();
|
|
||||||
const onKill = vi.fn();
|
|
||||||
render(
|
|
||||||
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.contextMenu(card("alpha"));
|
|
||||||
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent);
|
|
||||||
expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Spawn similar"));
|
|
||||||
expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renames in place: Enter commits the field, Escape cancels", () => {
|
|
||||||
const onRename = vi.fn();
|
|
||||||
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
|
|
||||||
|
|
||||||
fireEvent.contextMenu(card("alpha"));
|
|
||||||
fireEvent.click(screen.getByText("Rename…"));
|
|
||||||
|
|
||||||
const input = card("alpha").querySelector<HTMLInputElement>(".leaf-rename")!;
|
|
||||||
expect(input.value).toBe("alpha");
|
|
||||||
fireEvent.change(input, { target: { value: " renamed " } });
|
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
|
||||||
expect(onRename).toHaveBeenCalledWith("alpha", "renamed");
|
|
||||||
expect(card("alpha").querySelector(".leaf-rename")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("search", () => {
|
|
||||||
it("filters the tree down to matching sessions", () => {
|
|
||||||
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
|
|
||||||
|
|
||||||
expect(document.querySelector('[data-session-id="alpha"]')).toBeNull();
|
|
||||||
expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty state", () => {
|
|
||||||
it("offers a spawn when there are no sessions at all", () => {
|
|
||||||
const onSpawn = vi.fn();
|
|
||||||
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
|
||||||
expect(onSpawn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
// 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 { 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 { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
|
|
||||||
import { FolderNode } from "./FolderNode.js";
|
|
||||||
import { KillConfirm } from "./KillConfirm.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;
|
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
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 && (
|
|
||||||
<KillConfirm
|
|
||||||
name={sessionName(kill.session)}
|
|
||||||
blockers={kill.blockers}
|
|
||||||
force={kill.force}
|
|
||||||
onCancel={() => setKill(null)}
|
|
||||||
onConfirm={() => {
|
|
||||||
const { session: s, force } = kill;
|
|
||||||
setKill(null);
|
|
||||||
void killSession(s.id, force).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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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) => (s as { sessions: Map<string, Session> }).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;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// The sidebar's search field: a substring filter over the tree (name, command
|
|
||||||
// and cwd). Escape clears it and hands the keyboard back to the list.
|
|
||||||
|
|
||||||
export interface SidebarSearchProps {
|
|
||||||
value: string;
|
|
||||||
onChange(value: string): void;
|
|
||||||
/** Escape on an already-empty field — the list wants the keyboard back. */
|
|
||||||
onDismiss?(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarSearch({ value, onChange, onDismiss }: SidebarSearchProps) {
|
|
||||||
return (
|
|
||||||
<header className="sidebar-search p-sp-2">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
className="search w-full rounded-r-1 border border-border bg-surface-2 px-sp-2 py-sp-1 text-fs-12 text-ink"
|
|
||||||
placeholder="Search sessions…"
|
|
||||||
aria-label="Search sessions"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="off"
|
|
||||||
spellCheck={false}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "Escape") return;
|
|
||||||
e.preventDefault();
|
|
||||||
if (value) onChange("");
|
|
||||||
else onDismiss?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { Sidebar, type SidebarProps } from "./Sidebar.js";
|
|
||||||
export { SessionCard, type SessionCardProps } from "./SessionCard.js";
|
|
||||||
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
|
|
||||||
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
|
|
||||||
export { ActivityRail } from "./ActivityRail.js";
|
|
||||||
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
|
|
||||||
export { KillConfirm } from "./KillConfirm.js";
|
|
||||||
export * from "./model.js";
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { buildTree } from "../../tree.js";
|
|
||||||
import type { Session } from "../../types.js";
|
|
||||||
import { filterSessions, firstRowUnder, navRows, splitFolderLabel } from "./model.js";
|
|
||||||
|
|
||||||
function session(id: string, cwd: string, command = "claude"): Session {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
cwd,
|
|
||||||
command,
|
|
||||||
env: {},
|
|
||||||
metadata: {},
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-08-24T10:00:00Z",
|
|
||||||
} as Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("navRows", () => {
|
|
||||||
const tree = buildTree([session("a", "/one"), session("b", "/two")], undefined);
|
|
||||||
|
|
||||||
it("stops on every card and skips expanded folder headers", () => {
|
|
||||||
expect(navRows(tree, new Set()).map((r) => r.key)).toEqual(["session:a", "session:b"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops on a collapsed folder header instead of the cards it hides", () => {
|
|
||||||
expect(navRows(tree, new Set(["one"])).map((r) => r.key)).toEqual([
|
|
||||||
"folder:one",
|
|
||||||
"session:b",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the first row an expanded folder reveals", () => {
|
|
||||||
expect(firstRowUnder(tree, new Set(), "one")?.key).toBe("session:a");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("splitFolderLabel", () => {
|
|
||||||
it("keeps a shallow path whole", () => {
|
|
||||||
expect(splitFolderLabel("~/maestro")).toEqual({ stem: null, last: "~/maestro" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initials the leading segments of a deep path", () => {
|
|
||||||
expect(splitFolderLabel("~/git.naps.pt/naps62/maestro")).toEqual({
|
|
||||||
stem: "~/g/n",
|
|
||||||
last: "maestro",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("filterSessions", () => {
|
|
||||||
const all = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
|
|
||||||
|
|
||||||
it("returns everything for a blank query", () => {
|
|
||||||
expect(filterSessions(all, " ")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the cwd and the command, case-insensitively", () => {
|
|
||||||
expect(filterSessions(all, "ALPHA").map((s) => s.id)).toEqual(["a"]);
|
|
||||||
expect(filterSessions(all, "bash").map((s) => s.id)).toEqual(["b"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
// 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)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/* Motion for the activity rail (docs/SIDEBAR.md): a hard blink while
|
|
||||||
AwaitingInput, a fast flash while Error. Active and Idle are solid and
|
|
||||||
still — colour alone is the signal. Kept as real CSS rather than Tailwind
|
|
||||||
utilities because both need @keyframes of their own. */
|
|
||||||
|
|
||||||
@keyframes maestro-rail-blink {
|
|
||||||
0%,
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50.01%,
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes maestro-rail-flash {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
15% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
45% {
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-blink {
|
|
||||||
animation: maestro-rail-blink 1.2s steps(1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-flash {
|
|
||||||
animation: maestro-rail-flash 1.4s ease-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.rail-blink,
|
|
||||||
.rail-flash {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
/** A clock that ticks at least every `intervalMs` while the page is visible.
|
|
||||||
* docs/SIDEBAR.md asks for a re-render at least every 30s so the duration's
|
|
||||||
* cutover points land within one tick. */
|
|
||||||
export function useNow(intervalMs = 30_000): number {
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
setNow(Date.now());
|
|
||||||
}, intervalMs);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [intervalMs]);
|
|
||||||
return now;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
|
||||||
|
// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
|
||||||
|
// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
|
||||||
|
// folds into the very next key, tapped here or typed on the soft keyboard,
|
||||||
|
// then disarms.
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
|
||||||
|
|
||||||
|
type Modifier = "ctrl" | "alt";
|
||||||
|
/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
|
||||||
|
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
||||||
|
|
||||||
|
type KeyDef =
|
||||||
|
| { label: string; mod: Modifier }
|
||||||
|
| { label: string; cursor: CursorFinal }
|
||||||
|
| { label: string; tilde: number }
|
||||||
|
| { label: string; char: string }
|
||||||
|
| { label: string; seq: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row-major into an 8-column / 2-row grid (#7):
|
||||||
|
*
|
||||||
|
* Esc Tab Home ↑ End PgUp - /
|
||||||
|
* Ctrl Alt ← ↓ → PgDn | ~
|
||||||
|
*/
|
||||||
|
const KEY_DEFS: KeyDef[] = [
|
||||||
|
{ label: "Esc", seq: "\x1b" },
|
||||||
|
{ label: "Tab", seq: "\t" },
|
||||||
|
{ label: "Home", cursor: "H" },
|
||||||
|
{ label: "↑", cursor: "A" },
|
||||||
|
{ label: "End", cursor: "F" },
|
||||||
|
{ label: "PgUp", tilde: 5 },
|
||||||
|
{ label: "-", char: "-" },
|
||||||
|
{ label: "/", char: "/" },
|
||||||
|
{ label: "Ctrl", mod: "ctrl" },
|
||||||
|
{ label: "Alt", mod: "alt" },
|
||||||
|
{ label: "←", cursor: "D" },
|
||||||
|
{ label: "↓", cursor: "B" },
|
||||||
|
{ label: "→", cursor: "C" },
|
||||||
|
{ label: "PgDn", tilde: 6 },
|
||||||
|
{ label: "|", char: "|" },
|
||||||
|
{ label: "~", char: "~" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
|
||||||
|
* character before it reaches the PTY — used by the terminal's own `onData`
|
||||||
|
* handler, not by this component's own button clicks.
|
||||||
|
*/
|
||||||
|
export function useKeyBarModifiers() {
|
||||||
|
const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
const rerender = useCallback(() => setTick((n) => n + 1), []);
|
||||||
|
|
||||||
|
const setMod = useCallback(
|
||||||
|
(mod: Modifier, on: boolean) => {
|
||||||
|
armedRef.current[mod] = on;
|
||||||
|
rerender();
|
||||||
|
},
|
||||||
|
[rerender],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearMods = useCallback(() => {
|
||||||
|
if (!armedRef.current.ctrl && !armedRef.current.alt) return;
|
||||||
|
armedRef.current = { ctrl: false, alt: false };
|
||||||
|
rerender();
|
||||||
|
}, [rerender]);
|
||||||
|
|
||||||
|
const consume = useCallback((data: string): string => {
|
||||||
|
const { ctrl, alt } = armedRef.current;
|
||||||
|
if ((ctrl || alt) && data.length === 1) {
|
||||||
|
const out = applyModsToChar(data, ctrl, alt);
|
||||||
|
armedRef.current = { ctrl: false, alt: false };
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { armed: armedRef.current, setMod, clearMods, consume };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyBarProps {
|
||||||
|
armed: { ctrl: boolean; alt: boolean };
|
||||||
|
onToggleMod(mod: Modifier): void;
|
||||||
|
onClearMods(): void;
|
||||||
|
onSend(data: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Special keys"
|
||||||
|
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
|
||||||
|
>
|
||||||
|
{KEY_DEFS.map((def) => {
|
||||||
|
if ("mod" in def) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={def.label}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={armed[def.mod]}
|
||||||
|
aria-label={`${def.label} modifier`}
|
||||||
|
className="min-w-8 rounded px-2 py-1 text-xs aria-pressed:bg-emerald-500/40"
|
||||||
|
onClick={() => onToggleMod(def.mod)}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bytes = () => {
|
||||||
|
if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
|
||||||
|
if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
|
||||||
|
if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
|
||||||
|
return def.seq;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={def.label}
|
||||||
|
type="button"
|
||||||
|
className="min-w-8 rounded px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
onSend(bytes());
|
||||||
|
onClearMods();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
||||||
|
import { attachWsUrl } from "../../routes.js";
|
||||||
|
import type { ClientControl } from "../../types.js";
|
||||||
|
|
||||||
|
class FakeTerminal {
|
||||||
|
static instances: FakeTerminal[] = [];
|
||||||
|
cols = 80;
|
||||||
|
rows = 24;
|
||||||
|
element: HTMLElement | undefined = undefined;
|
||||||
|
textarea: HTMLTextAreaElement | null = null;
|
||||||
|
disposed = false;
|
||||||
|
focus = vi.fn();
|
||||||
|
private dataCb: ((d: string) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(public opts: unknown) {
|
||||||
|
FakeTerminal.instances.push(this);
|
||||||
|
}
|
||||||
|
loadAddon(): void {}
|
||||||
|
open(container: HTMLElement): void {
|
||||||
|
container.appendChild(document.createElement("div"));
|
||||||
|
}
|
||||||
|
onData(cb: (d: string) => void) {
|
||||||
|
this.dataCb = cb;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
}
|
||||||
|
emitData(d: string): void {
|
||||||
|
this.dataCb?.(d);
|
||||||
|
}
|
||||||
|
onResize() {
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
}
|
||||||
|
onSelectionChange() {
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
}
|
||||||
|
write(_data: Uint8Array, cb?: () => void): void {
|
||||||
|
cb?.();
|
||||||
|
}
|
||||||
|
scrollToBottom(): void {}
|
||||||
|
resize(cols: number, rows: number): void {
|
||||||
|
this.cols = cols;
|
||||||
|
this.rows = rows;
|
||||||
|
}
|
||||||
|
clearTextureAtlas(): void {}
|
||||||
|
getSelection(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeFitAddon {
|
||||||
|
fit = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWebglAddon {
|
||||||
|
static instances: FakeWebglAddon[] = [];
|
||||||
|
disposed = false;
|
||||||
|
private lossCb: (() => void) | null = null;
|
||||||
|
constructor() {
|
||||||
|
FakeWebglAddon.instances.push(this);
|
||||||
|
}
|
||||||
|
onContextLoss(cb: () => void): void {
|
||||||
|
this.lossCb = cb;
|
||||||
|
}
|
||||||
|
triggerLoss(): void {
|
||||||
|
this.lossCb?.();
|
||||||
|
}
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyEnabled = vi.fn(() => true);
|
||||||
|
const policyNoteLoss = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
|
||||||
|
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
|
||||||
|
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
|
||||||
|
vi.mock("../../webglRenderer.js", () => ({
|
||||||
|
WebglPolicy: vi.fn().mockImplementation(() => ({
|
||||||
|
enabled: policyEnabled,
|
||||||
|
noteContextLoss: policyNoteLoss,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
static CONNECTING = 0;
|
||||||
|
static CLOSING = 2;
|
||||||
|
static CLOSED = 3;
|
||||||
|
static instances: FakeWebSocket[] = [];
|
||||||
|
readyState = FakeWebSocket.CONNECTING;
|
||||||
|
binaryType = "";
|
||||||
|
sent: (string | ArrayBufferView)[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: unknown }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(public url: string) {
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
send(data: string | ArrayBufferView): void {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
close(): void {
|
||||||
|
this.readyState = FakeWebSocket.CLOSED;
|
||||||
|
}
|
||||||
|
open(): void {
|
||||||
|
this.readyState = FakeWebSocket.OPEN;
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
drop(code = 1006, reason = ""): void {
|
||||||
|
this.readyState = FakeWebSocket.CLOSED;
|
||||||
|
this.onclose?.({ code, reason });
|
||||||
|
}
|
||||||
|
lastControl(): ClientControl {
|
||||||
|
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeTerminal.instances = [];
|
||||||
|
FakeWebglAddon.instances = [];
|
||||||
|
FakeWebSocket.instances = [];
|
||||||
|
policyEnabled.mockReturnValue(true);
|
||||||
|
policyEnabled.mockClear();
|
||||||
|
policyNoteLoss.mockClear();
|
||||||
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
|
||||||
|
const { TerminalPane } = await import("./TerminalPane.js");
|
||||||
|
|
||||||
|
describe("TerminalPane lifecycle", () => {
|
||||||
|
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
expect(FakeTerminal.instances).toHaveLength(1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the terminal size once the socket opens", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
const sock = FakeWebSocket.instances[0];
|
||||||
|
|
||||||
|
sock.open();
|
||||||
|
|
||||||
|
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disposes the terminal and closes the socket on unmount", () => {
|
||||||
|
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
||||||
|
const term = FakeTerminal.instances[0];
|
||||||
|
const sock = FakeWebSocket.instances[0];
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(term.disposed).toBe(true);
|
||||||
|
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnects to a fresh socket for a new sessionId", () => {
|
||||||
|
const { rerender } = render(<TerminalPane sessionId="s1" />);
|
||||||
|
rerender(<TerminalPane sessionId="s2" />);
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||||
|
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
|
||||||
|
expect(FakeTerminal.instances[0].disposed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminalPane reconnect backoff", () => {
|
||||||
|
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
|
||||||
|
const expectStillWaiting = (ms: number, countBefore: number) => {
|
||||||
|
vi.advanceTimersByTime(ms - 1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(countBefore);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the backoff once a reconnected socket opens", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
FakeWebSocket.instances[0].drop(1006, "");
|
||||||
|
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
|
||||||
|
FakeWebSocket.instances[1].open();
|
||||||
|
FakeWebSocket.instances[1].drop(1006, "");
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry a clean close, and reports it closed", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onClosed = vi.fn();
|
||||||
|
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
||||||
|
|
||||||
|
FakeWebSocket.instances[0].drop(1000, "");
|
||||||
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(onClosed).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry an application-level rejection, and writes it into the terminal", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onClosed = vi.fn();
|
||||||
|
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
||||||
|
const term = FakeTerminal.instances[0];
|
||||||
|
const writeSpy = vi.spyOn(term, "write");
|
||||||
|
|
||||||
|
FakeWebSocket.instances[0].drop(4004, "unknown session");
|
||||||
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(onClosed).not.toHaveBeenCalled();
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a caller-supplied shouldReconnect policy", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onClosed = vi.fn();
|
||||||
|
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
|
||||||
|
|
||||||
|
FakeWebSocket.instances[0].drop(1006, "");
|
||||||
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(onClosed).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminalPane WebGL context budget", () => {
|
||||||
|
it("acquires a WebGL context on mount, shared policy allowing", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
expect(policyEnabled).toHaveBeenCalled();
|
||||||
|
expect(FakeWebglAddon.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never creates a context once the shared budget is exhausted", () => {
|
||||||
|
policyEnabled.mockReturnValue(false);
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
expect(FakeWebglAddon.instances).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases its context on unmount, handing the budget back", () => {
|
||||||
|
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
||||||
|
const addon = FakeWebglAddon.instances[0];
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(addon.disposed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a context loss and re-acquires while still mounted", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
const first = FakeWebglAddon.instances[0];
|
||||||
|
|
||||||
|
first.triggerLoss();
|
||||||
|
|
||||||
|
expect(policyNoteLoss).toHaveBeenCalledOnce();
|
||||||
|
expect(first.disposed).toBe(true);
|
||||||
|
expect(FakeWebglAddon.instances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-acquire a lost context once the budget is spent", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
const first = FakeWebglAddon.instances[0];
|
||||||
|
policyEnabled.mockReturnValue(false);
|
||||||
|
|
||||||
|
first.triggerLoss();
|
||||||
|
|
||||||
|
expect(FakeWebglAddon.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminalPane imperative handle", () => {
|
||||||
|
it("sendInput writes bytes to the PTY only once the socket is open", () => {
|
||||||
|
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
||||||
|
render(<TerminalPane sessionId="s1" ref={ref} />);
|
||||||
|
const sock = FakeWebSocket.instances[0];
|
||||||
|
|
||||||
|
ref.current!.sendInput("nope");
|
||||||
|
expect(sock.sent).toHaveLength(0);
|
||||||
|
|
||||||
|
sock.open();
|
||||||
|
sock.sent.length = 0; // drop the resize control frame from open()
|
||||||
|
ref.current!.sendInput("hi");
|
||||||
|
|
||||||
|
expect(sock.sent).toHaveLength(1);
|
||||||
|
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focusTerminal focuses the underlying xterm instance", () => {
|
||||||
|
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
||||||
|
render(<TerminalPane sessionId="s1" ref={ref} />);
|
||||||
|
|
||||||
|
ref.current!.focusTerminal();
|
||||||
|
|
||||||
|
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes typed keystrokes through to the socket", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
const sock = FakeWebSocket.instances[0];
|
||||||
|
sock.open();
|
||||||
|
sock.sent.length = 0;
|
||||||
|
|
||||||
|
FakeTerminal.instances[0].emitData("q");
|
||||||
|
|
||||||
|
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
// React port of ../../attach.ts: an xterm terminal bound to the daemon's
|
||||||
|
// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
|
||||||
|
// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
|
||||||
|
// button, sessions drawer) stays out of scope here — this is the thin
|
||||||
|
// ref-based xterm wrapper the issue asked for; a header composes around it.
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
||||||
|
import { attachWsUrl } from "../../routes.js";
|
||||||
|
import type { ClientControl, Uuid } from "../../types.js";
|
||||||
|
import { WebglPolicy } from "../../webglRenderer.js";
|
||||||
|
import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
|
||||||
|
|
||||||
|
/** One budget for the whole page — every mounted `TerminalPane` shares it, the
|
||||||
|
* same reasoning as attach.ts's own module-level policy. */
|
||||||
|
const webglPolicy = new WebglPolicy();
|
||||||
|
|
||||||
|
export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
||||||
|
|
||||||
|
/** Application-defined close codes (daemon rejection) and the clean-close
|
||||||
|
* code — retrying either would only loop. Mirrors reconnect.ts's
|
||||||
|
* `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
|
||||||
|
function defaultShouldReconnect(code: number): boolean {
|
||||||
|
return code < 4000 && code !== 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalPaneProps {
|
||||||
|
sessionId: Uuid;
|
||||||
|
/** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
|
||||||
|
focus?: boolean;
|
||||||
|
/** Asked on every close: should the socket be retried? */
|
||||||
|
shouldReconnect?: (code: number) => boolean;
|
||||||
|
/** The socket closed for good (retry declined, or the daemon rejected the
|
||||||
|
* session) — not called on an ordinary unmount. */
|
||||||
|
onClosed?: () => void;
|
||||||
|
/** Connection status changes, for a header/status badge to render. */
|
||||||
|
onStatusChange?: (status: TerminalStatus, detail?: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalPaneHandle {
|
||||||
|
focusTerminal(): void;
|
||||||
|
sendInput(data: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
||||||
|
function TerminalPane(
|
||||||
|
{ sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sendInputRef = useRef<(data: string) => void>(() => {});
|
||||||
|
const focusTerminalRef = useRef<() => void>(() => {});
|
||||||
|
const keyBar = useKeyBarModifiers();
|
||||||
|
const [keybarActive, setKeybarActive] = useState(false);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
focusTerminal: () => focusTerminalRef.current(),
|
||||||
|
sendInput: (data: string) => sendInputRef.current(data),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const body = bodyRef.current;
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily:
|
||||||
|
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
convertEol: false,
|
||||||
|
theme: {
|
||||||
|
background: "#121419",
|
||||||
|
foreground: "#e6e8ec",
|
||||||
|
cursor: "#5fe3a1",
|
||||||
|
cursorAccent: "#121419",
|
||||||
|
selectionBackground: "#2b6b4f",
|
||||||
|
},
|
||||||
|
scrollback: 5000,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(body);
|
||||||
|
|
||||||
|
let webgl: WebglAddon | null = null;
|
||||||
|
let dprMedia: MediaQueryList | null = null;
|
||||||
|
|
||||||
|
const onDprChange = () => {
|
||||||
|
try {
|
||||||
|
term.clearTextureAtlas();
|
||||||
|
} catch {
|
||||||
|
// ignore — nothing to rebuild if the renderer already went away
|
||||||
|
}
|
||||||
|
watchDpr();
|
||||||
|
};
|
||||||
|
|
||||||
|
function watchDpr(): void {
|
||||||
|
dprMedia?.removeEventListener("change", onDprChange);
|
||||||
|
dprMedia = null;
|
||||||
|
try {
|
||||||
|
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
|
||||||
|
dprMedia?.addEventListener("change", onDprChange);
|
||||||
|
} catch {
|
||||||
|
// ignore — a browser without `resolution` media queries just keeps
|
||||||
|
// the atlas it has
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseWebgl(): void {
|
||||||
|
dprMedia?.removeEventListener("change", onDprChange);
|
||||||
|
dprMedia = null;
|
||||||
|
if (!webgl) return;
|
||||||
|
const addon = webgl;
|
||||||
|
webgl = null;
|
||||||
|
try {
|
||||||
|
addon.dispose();
|
||||||
|
} catch {
|
||||||
|
// ignore — a lost context can throw on the way out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireWebgl(): void {
|
||||||
|
if (webgl || disposed || !webglPolicy.enabled()) return;
|
||||||
|
try {
|
||||||
|
const addon = new WebglAddon();
|
||||||
|
addon.onContextLoss(() => {
|
||||||
|
webglPolicy.noteContextLoss();
|
||||||
|
releaseWebgl();
|
||||||
|
if (mounted) acquireWebgl();
|
||||||
|
});
|
||||||
|
term.loadAddon(addon);
|
||||||
|
webgl = addon;
|
||||||
|
watchDpr();
|
||||||
|
} catch {
|
||||||
|
webglPolicy.noteContextLoss();
|
||||||
|
webgl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = false;
|
||||||
|
const tryFit = () => {
|
||||||
|
if (!mounted) return;
|
||||||
|
try {
|
||||||
|
const el2 = term.element;
|
||||||
|
const parent = el2?.parentElement;
|
||||||
|
const core = (
|
||||||
|
term as unknown as {
|
||||||
|
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
|
||||||
|
}
|
||||||
|
)._core;
|
||||||
|
const cell = core?._renderService?.dimensions?.css?.cell;
|
||||||
|
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
|
||||||
|
fit.fit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
|
||||||
|
let availW: number;
|
||||||
|
let availH: number;
|
||||||
|
if (vp && vp.clientWidth > 0) {
|
||||||
|
availW = vp.clientWidth;
|
||||||
|
availH = vp.clientHeight;
|
||||||
|
} else {
|
||||||
|
const ps = getComputedStyle(parent);
|
||||||
|
const ts = getComputedStyle(el2);
|
||||||
|
availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
|
||||||
|
availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
|
||||||
|
}
|
||||||
|
const cols = Math.max(2, Math.floor(availW / cell.width));
|
||||||
|
const rows = Math.max(1, Math.floor(availH / cell.height));
|
||||||
|
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
// ignore — fit fails if the container is detached or zero-sized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ws: WebSocket;
|
||||||
|
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
let awaitingReplay = true;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const sendControl = (msg: ClientControl) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendInput = (data: string) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(new TextEncoder().encode(data));
|
||||||
|
};
|
||||||
|
sendInputRef.current = sendInput;
|
||||||
|
focusTerminalRef.current = () => term.focus();
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
tryFit();
|
||||||
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
|
};
|
||||||
|
|
||||||
|
function connect(): void {
|
||||||
|
awaitingReplay = true;
|
||||||
|
ws = new WebSocket(attachWsUrl(sessionId));
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
|
onStatusChange?.("open");
|
||||||
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
|
if (mounted && focus) term.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
try {
|
||||||
|
JSON.parse(ev.data) as ClientControl;
|
||||||
|
} catch {
|
||||||
|
// ignore unknown control frames
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
|
const replay = awaitingReplay;
|
||||||
|
awaitingReplay = false;
|
||||||
|
term.write(new Uint8Array(ev.data), () => {
|
||||||
|
if (replay) term.scrollToBottom();
|
||||||
|
});
|
||||||
|
} else if (ev.data instanceof Blob) {
|
||||||
|
void ev.data.arrayBuffer().then((b) => {
|
||||||
|
const replay = awaitingReplay;
|
||||||
|
awaitingReplay = false;
|
||||||
|
term.write(new Uint8Array(b), () => {
|
||||||
|
if (replay) term.scrollToBottom();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
onStatusChange?.("error");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
const reason = ev.reason?.trim();
|
||||||
|
if (ev.code >= 4000 && reason && !disposed) {
|
||||||
|
onStatusChange?.("error", reason);
|
||||||
|
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
if (shouldReconnect(ev.code)) {
|
||||||
|
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||||
|
onStatusChange?.("reconnecting");
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChange?.("closed", String(ev.code));
|
||||||
|
onClosed?.();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChange?.("connecting");
|
||||||
|
connect();
|
||||||
|
|
||||||
|
const dataDisp = term.onData((data) => {
|
||||||
|
sendInput(keyBar.consume(data));
|
||||||
|
});
|
||||||
|
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||||
|
sendControl({ type: "Resize", cols, rows });
|
||||||
|
});
|
||||||
|
const selectionDisp = term.onSelectionChange(() => {
|
||||||
|
const text = term.getSelection();
|
||||||
|
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const ta = term.textarea;
|
||||||
|
const onTaFocus = () => {
|
||||||
|
setKeybarActive(true);
|
||||||
|
requestAnimationFrame(tryFit);
|
||||||
|
};
|
||||||
|
const onTaBlur = () => {
|
||||||
|
setKeybarActive(false);
|
||||||
|
requestAnimationFrame(tryFit);
|
||||||
|
};
|
||||||
|
ta?.addEventListener("focus", onTaFocus);
|
||||||
|
ta?.addEventListener("blur", onTaBlur);
|
||||||
|
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
const visual = window.visualViewport ?? null;
|
||||||
|
visual?.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => tryFit());
|
||||||
|
ro.observe(body);
|
||||||
|
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
|
||||||
|
|
||||||
|
mounted = true;
|
||||||
|
acquireWebgl();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
tryFit();
|
||||||
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
|
if (focus && ws.readyState === WebSocket.OPEN) term.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
mounted = false;
|
||||||
|
if (reconnectTimer !== null) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
visual?.removeEventListener("resize", onResize);
|
||||||
|
ta?.removeEventListener("focus", onTaFocus);
|
||||||
|
ta?.removeEventListener("blur", onTaBlur);
|
||||||
|
dataDisp.dispose();
|
||||||
|
resizeDisp.dispose();
|
||||||
|
selectionDisp.dispose();
|
||||||
|
releaseWebgl();
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
term.dispose();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
|
||||||
|
// a session change is a fresh connection, not a prop-driven re-render
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
|
||||||
|
<div ref={bodyRef} className="min-h-0 flex-1" />
|
||||||
|
{keybarActive && (
|
||||||
|
<KeyBar
|
||||||
|
armed={keyBar.armed}
|
||||||
|
onToggleMod={(mod) => keyBar.setMod(mod, !keyBar.armed[mod])}
|
||||||
|
onClearMods={keyBar.clearMods}
|
||||||
|
onSend={(data) => sendInputRef.current(data)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user