Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the 23 views it drove are gone; App.tsx composes the ported components — sidebar, multi-view, command bar, spawn modal, help and restart overlays, daemon link, drawer — around the catalog and layout stores. The pure parts of four deleted modules moved next to the components that still use them: the spawn-form model and spawnSimilar into components/spawn, the transcript's markdown/format helpers and the diff into components/structured, the help overlay's grouping into components/dialogs. Document-level event names now live in uiEvents.ts. Deduplicated two pairs the migration left side by side: the sidebar uses dialogs/KillDialog and common/ContextMenu. It also listens for the command bar's kill/rename events, which nothing had picked up since catalog.ts went away. styles.css stays as the token and base layer, with `#app` renamed to `#root` and the pane-body rules re-keyed off the classes the React panes actually carry. Closes #253
272 lines
9.9 KiB
TypeScript
272 lines
9.9 KiB
TypeScript
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
|
|
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
|
|
// single-pane collapse stay with whichever issue wires this into the shell
|
|
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
|
|
|
import type { ReactNode } from "react";
|
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
import { useStore } from "zustand";
|
|
|
|
import { activeTab, type Dir, type Node } from "../../layout.js";
|
|
import { usePreview } from "../../hooks/usePreview.js";
|
|
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
|
import type { Session, Uuid } from "../../types.js";
|
|
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
|
|
import { PaneTitle } from "./PaneTitle.js";
|
|
import { TabBar } from "./TabBar.js";
|
|
|
|
/** Reaching the focused pane from outside the tree — what the shell's
|
|
* leader chords need (focus.toggle, the literal-byte escape hatches). */
|
|
export interface MultiViewHandle {
|
|
/** Hand the keyboard to the focused pane's terminal (or composer). */
|
|
focusPane(): void;
|
|
/** Type into the focused pane. A structured pane has no PTY and ignores it. */
|
|
sendInput(data: string): void;
|
|
}
|
|
|
|
export interface MultiViewProps {
|
|
store: LayoutStoreApi;
|
|
/** Live sessions, keyed by id — drives pane titles and (once known) boot
|
|
* reconciliation. Passing `undefined` defers reconciliation. */
|
|
sessions?: ReadonlyMap<Uuid, Session>;
|
|
onDisconnect?: (sessionId: Uuid) => void;
|
|
emptyState?: ReactNode;
|
|
}
|
|
|
|
export const MultiView = forwardRef<MultiViewHandle, MultiViewProps>(function MultiView(
|
|
{ store, sessions, onDisconnect, emptyState },
|
|
ref,
|
|
) {
|
|
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
|
const tab = activeTab(layout);
|
|
|
|
// Live pane handles, keyed by leaf. Read at call time (not render time) so
|
|
// the chords always reach whatever is focused right now.
|
|
const panes = useRef(new Map<string, PaneContainerHandle>()).current;
|
|
const focusedLeafRef = useRef<string | null>(null);
|
|
focusedLeafRef.current = tab.focusedLeafId;
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
focusPane: () => panes.get(focusedLeafRef.current ?? "")?.focusTerminal(),
|
|
sendInput: (data: string) => panes.get(focusedLeafRef.current ?? "")?.sendInput(data),
|
|
}),
|
|
[panes],
|
|
);
|
|
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
|
|
const isPreviewing = previewedSessionId !== null;
|
|
|
|
const reconciledRef = useRef(false);
|
|
useEffect(() => {
|
|
if (reconciledRef.current || !sessions) return;
|
|
reconciledRef.current = true;
|
|
store.getState().reconcile(new Set(sessions.keys()));
|
|
}, [sessions, store]);
|
|
|
|
return (
|
|
<div className="mv flex h-full min-h-0 flex-col">
|
|
<TabBar store={store} />
|
|
<div className="mv-canvas relative min-h-0 flex-1">
|
|
{tab.root ? (
|
|
<SplitNode
|
|
node={tab.root}
|
|
tab={tab}
|
|
panes={panes}
|
|
store={store}
|
|
sessions={sessions}
|
|
onDisconnect={onDisconnect}
|
|
previewedSessionId={previewedSessionId}
|
|
commitPreview={commitPreview}
|
|
/>
|
|
) : (
|
|
(emptyState ?? (
|
|
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
|
No session open — pick one from the sidebar, or spawn a new one.
|
|
</div>
|
|
))
|
|
)}
|
|
{isPreviewing && (
|
|
<>
|
|
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
|
|
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
|
|
preview
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface TreeProps {
|
|
tab: { id: string; focusedLeafId: string | null };
|
|
/** Pane handles by leaf id — each `Pane` registers and unregisters its own. */
|
|
panes: Map<string, PaneContainerHandle>;
|
|
store: LayoutStoreApi;
|
|
sessions?: ReadonlyMap<Uuid, Session>;
|
|
onDisconnect?: (sessionId: Uuid) => void;
|
|
previewedSessionId: Uuid | null;
|
|
commitPreview: (sessionId: Uuid) => void;
|
|
}
|
|
|
|
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
|
if (node.kind === "leaf") {
|
|
return <Pane leaf={node} isFocused={rest.tab.focusedLeafId === node.id} {...rest} />;
|
|
}
|
|
return <SplitContainer node={node} {...rest} />;
|
|
}
|
|
|
|
function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { kind: "split" }> }) {
|
|
const slotA = useRef<HTMLDivElement | null>(null);
|
|
const slotB = useRef<HTMLDivElement | null>(null);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const onDividerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
const divider = e.currentTarget;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
divider.setPointerCapture(e.pointerId);
|
|
const rect = container.getBoundingClientRect();
|
|
const move = (ev: PointerEvent) => {
|
|
const frac =
|
|
node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height;
|
|
node.ratio = Math.max(0.1, Math.min(0.9, frac));
|
|
if (slotA.current) slotA.current.style.flexGrow = String(node.ratio);
|
|
if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio);
|
|
};
|
|
const up = (ev: PointerEvent) => {
|
|
try {
|
|
divider.releasePointerCapture(ev.pointerId);
|
|
} catch {
|
|
// already released
|
|
}
|
|
divider.removeEventListener("pointermove", move);
|
|
divider.removeEventListener("pointerup", up);
|
|
rest.store.getState().commitLayout();
|
|
};
|
|
divider.addEventListener("pointermove", move);
|
|
divider.addEventListener("pointerup", up);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`mv-split flex h-full min-h-0 w-full ${node.dir === "row" ? "flex-row" : "flex-col"}`}
|
|
>
|
|
<div ref={slotA} className="mv-slot min-h-0 min-w-0" style={{ flex: `${node.ratio} 1 0` }}>
|
|
<SplitNode node={node.a} {...rest} />
|
|
</div>
|
|
<div
|
|
className={
|
|
"mv-divider flex-none bg-border-strong hover:bg-accent " +
|
|
(node.dir === "row" ? "w-[3px] cursor-col-resize" : "h-[3px] cursor-row-resize")
|
|
}
|
|
onPointerDown={onDividerPointerDown}
|
|
/>
|
|
<div ref={slotB} className="mv-slot min-h-0 min-w-0" style={{ flex: `${1 - node.ratio} 1 0` }}>
|
|
<SplitNode node={node.b} {...rest} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Pane({
|
|
leaf,
|
|
isFocused,
|
|
panes,
|
|
store,
|
|
sessions,
|
|
onDisconnect,
|
|
previewedSessionId,
|
|
commitPreview,
|
|
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
|
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
|
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
|
|
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
|
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
|
|
}
|
|
data-leaf-id={leaf.id}
|
|
data-session-id={leaf.sessionId ?? undefined}
|
|
onPointerDownCapture={() => {
|
|
// Clicking into a previewed pane is the operator choosing it
|
|
// (docs/PREVIEW.md).
|
|
if (isPreview) commitPreview(leaf.sessionId!);
|
|
store.getState().focusLeaf(leaf.id);
|
|
}}
|
|
>
|
|
<div
|
|
className={
|
|
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
|
|
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
|
|
}
|
|
>
|
|
{leaf.sessionId ? (
|
|
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
|
) : (
|
|
<span className="text-faint">Empty pane</span>
|
|
)}
|
|
<div className="mv-pane-tools flex items-center gap-sp-1">
|
|
{leaf.sessionId && onDisconnect && (
|
|
<button
|
|
type="button"
|
|
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
|
title="Disconnect"
|
|
onClick={() => onDisconnect(leaf.sessionId!)}
|
|
>
|
|
⏻
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
|
title="Split right"
|
|
onClick={() => splitFocused("row")}
|
|
>
|
|
▥
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
|
title="Split down"
|
|
onClick={() => splitFocused("col")}
|
|
>
|
|
▤
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
|
title="Close pane"
|
|
onClick={() => store.getState().closePane(leaf.id)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mv-pane-host min-h-0 flex-1">
|
|
{leaf.sessionId ? (
|
|
<PaneContainer
|
|
ref={(handle) => {
|
|
if (handle) panes.set(leaf.id, handle);
|
|
else panes.delete(leaf.id);
|
|
}}
|
|
sessionId={leaf.sessionId}
|
|
session={session}
|
|
focus={isFocused && previewedSessionId === null}
|
|
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
|
/>
|
|
) : (
|
|
<div className="mv-pane-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
|
Empty pane — pick a session from the sidebar, or spawn one.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|