// 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; onDisconnect?: (sessionId: Uuid) => void; emptyState?: ReactNode; } export const MultiView = forwardRef(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()).current; const focusedLeafRef = useRef(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 (
{tab.root ? ( ) : ( (emptyState ?? (
No session open — pick one from the sidebar, or spawn a new one.
)) )} {isPreviewing && ( <>
preview
)}
); }); interface TreeProps { tab: { id: string; focusedLeafId: string | null }; /** Pane handles by leaf id — each `Pane` registers and unregisters its own. */ panes: Map; store: LayoutStoreApi; sessions?: ReadonlyMap; onDisconnect?: (sessionId: Uuid) => void; previewedSessionId: Uuid | null; commitPreview: (sessionId: Uuid) => void; } function SplitNode({ node, ...rest }: TreeProps & { node: Node }) { if (node.kind === "leaf") { return ; } return ; } function SplitContainer({ node, ...rest }: TreeProps & { node: Extract }) { const slotA = useRef(null); const slotB = useRef(null); const containerRef = useRef(null); const onDividerPointerDown = (e: React.PointerEvent) => { 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 (
); } function Pane({ leaf, isFocused, panes, store, sessions, onDisconnect, previewedSessionId, commitPreview, }: TreeProps & { leaf: Extract; 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 (
{ // Clicking into a previewed pane is the operator choosing it // (docs/PREVIEW.md). if (isPreview) commitPreview(leaf.sessionId!); store.getState().focusLeaf(leaf.id); }} >
{leaf.sessionId ? ( ) : ( Empty pane )}
{leaf.sessionId && onDisconnect && ( )}
{leaf.sessionId ? ( { 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!)} /> ) : (
Empty pane — pick a session from the sidebar, or spawn one.
)}
); }