import { type ComponentType, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode, type Ref, useEffect, useEffectEvent, useLayoutEffect, useState, useSyncExternalStore, } from "react"; import { createPortal, flushSync } from "react-dom"; import type { TabRename, WindowState } from "@ace/host/protocol"; import * as Split from "@ace/split-tabs"; import { Button, ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@ace/ui"; import { IconFileDiff, IconMessage, IconPencil, IconPlus, IconRows, IconSplitView, IconTerminal, IconX, } from "@ace/ui/icons"; import { host } from "../host"; import { Bar as Header } from "./bar"; import { Grip } from "./grip"; import { Rename } from "./rename"; import { CHAT, type Content, content, fallback, type Kind, type Meta, read, write, } from "./storage"; import { type DndDrag, type DndDrop, useDnd } from "./use-dnd"; import { useResize } from "./use-resize"; export { CHAT, type Content }; type Icon = ComponentType<{ className?: string }>; type Choice = Exclude; type Item = { uid: string; name: string; kind: Kind; chat: boolean; icon: Icon; }; /** Renders a tab's content; `update` merges into the tab's persisted content. */ export type Render = ( data: Content, uid: string, active: boolean, update: (patch: Partial) => void, ) => ReactNode; type Props = { /** Where the layout persists: one per channel. */ id: string; name: string; /** The chat new Diff and Terminal tabs show. */ chat: number; /** Whether the chat's lane has changes, once known. */ changed?: boolean; render: Render; /** Notified when a tab is closed, so owners can release its resources. */ onTabClose?: (uid: string, data: Content) => void; /** Receives the function that shows the chat's Diff tab, opening one beside the chat if needed. */ opener?: { current?: () => void }; }; const MIN = 350; const CHAT_MIN = 450; // The old app also offered Plan, Browse, and Browser Preview; they return when their backends do. const CHOICES: Choice[] = ["chat", "diff", "terminal"]; const ICONS: Record = { blank: IconSplitView, chat: IconMessage, diff: IconFileDiff, terminal: IconTerminal, }; const TITLES: Record = { blank: "Blank", chat: "Chat", diff: "Diff", terminal: "Terminal", }; function minimum(tab?: Split.Tab) { return tab === CHAT ? CHAT_MIN : MIN; } function title(uid: string, name: string, data: Content, count = 1, total = 1) { if (uid === CHAT) return name; if (data.name) return data.name; const label = TITLES[data.type]; return total > 1 ? `${label} ${count}` : label; } function item( uid: string, name: string, data: Content = fallback(uid), count?: number, total?: number, ): Item { return { uid, name: title(uid, name, data, count, total), kind: data.type, chat: uid === CHAT, icon: ICONS[data.type], }; } function list(tabs: string[], name: string, meta: Meta) { const totals = new Map(); for (const uid of tabs) { if (uid === CHAT) continue; const type = (meta[uid] || fallback(uid)).type; totals.set(type, (totals.get(type) || 0) + 1); } const counts = new Map(); return new Map(tabs.map((uid) => { const value = meta[uid] || fallback(uid); const count = uid === CHAT ? 1 : (counts.get(value.type) || 0) + 1; counts.set(value.type, count); return [uid, item(uid, name, value, count, totals.get(value.type))]; })); } function hint(side?: Split.Side) { if (side === "left") return "inset-y-2 left-2 w-1/2"; if (side === "right") return "inset-y-2 right-2 w-1/2"; if (side === "top") return "inset-x-2 top-2 h-1/2"; if (side === "bottom") return "inset-x-2 bottom-2 h-1/2"; return "inset-2"; } function top(pane: Split.Pane) { return pane.area[0][0] === "RS"; } function column(pane: Split.Pane) { const col = pane.area[1]; return `${col[0]} / ${col[1]}`; } function focus(uid: string) { document.querySelector(`[data-layout-tab="${CSS.escape(uid)}"]`)?.focus(); } function Blank({ item, onChoose }: { item: Item; onChoose: (choice: Choice) => void }) { const Icon = item.icon; if (item.kind === "blank") { return (
{CHOICES.map((choice) => { const Icon = ICONS[choice]; return ( ); })}
); } return (
{item.name}
); } function Add({ onAdd }: { onAdd: (choice: Choice) => void }) { return ( } > {CHOICES.map((choice) => { const Icon = ICONS[choice]; return ( onAdd(choice)}> {TITLES[choice]} ); })} ); } function Ghost({ item, drag, ref }: { item: Item; drag: DndDrag; ref: Ref }) { const Icon = item.icon; return (
{item.name}
); } function Tool({ label, tip, disabled, children, onClick, }: { label: string; tip: string; disabled: boolean; children: ReactNode; onClick: () => void; }) { return ( } /> {tip} ); } function Tools({ split, can, }: { split: (side: "right" | "bottom") => void; can: (side: "right" | "bottom") => boolean; }) { const right = can("right"); const down = can("bottom"); return (
split("right")} > split("bottom")} >
); } type TabProps = { item: Item; pane: string; selected: boolean; dragging: boolean; insert: boolean; flat: boolean; onSelect: (id: string) => void; onClose: (id: string) => void; onRename: (id: string, name: string) => void; onStart: (event: PointerEvent, id: string) => void; onNav: (event: KeyboardEvent) => void; }; function Tab({ item, pane, selected, dragging, insert, flat, onSelect, onClose, onRename, onStart, onNav, }: TabProps) { const id = item.uid; const Icon = item.icon; const [open, setOpen] = useState(false); function rename(event: MouseEvent) { event.stopPropagation(); if (item.chat) return; setOpen(true); } function close(event: MouseEvent) { event.stopPropagation(); if (item.chat) return; onClose(id); } function cancel() { setOpen(false); requestAnimationFrame(() => focus(id)); } function save(name: string) { if (item.chat) return; onRename(id, name); setOpen(false); } const tab = ( ); return ( <>
{!item.chat && ( )}
Rename Close
); } type BarProps = { pane: Split.Pane; items: Map; flat: boolean; drag?: DndDrag; drop?: DndDrop; edge?: boolean; line?: boolean; start?: boolean; end?: boolean; style?: CSSProperties; onFork: (side: "right" | "bottom") => void; can: (side: "right" | "bottom") => boolean; onSelect: (id: string) => void; onAdd: (choice: Choice) => void; onClose: (id: string) => void; onRename: (id: string, name: string) => void; onStart: (event: PointerEvent, id: string) => void; onNav: (event: KeyboardEvent, pane: Split.Pane) => void; }; function Bar({ pane, items, flat, drag, drop, edge, line = true, start, end, style, onFork, can, onSelect, onAdd, onClose, onRename, onStart, onNav, }: BarProps) { const before = drop?.kind === "tab" ? drop.before : undefined; return (
{pane.tabs.map((id) => { const item = items.get(id); if (!item) return null; return ( onNav(event, pane)} /> ); })} {drop?.kind === "bar" && ( )}
{!flat && }
); } type PaneProps = BarProps & { meta: Meta; focused: boolean; header: boolean; render: Render; onFocus: () => void; onChoose: (uid: string, choice: Choice) => void; onUpdate: (uid: string, patch: Partial) => void; }; function Pane(props: PaneProps) { const { pane, items, meta, focused, drop, header, render, onFocus, onChoose, onUpdate } = props; const hover = drop?.kind === "pane" ? drop : undefined; const blocked = !!hover?.side && !hover.valid; return (
{!header && }
{pane.tabs.map((id) => { const item = items.get(id); if (!item) return null; const active = pane.active === id; const data = meta[id] || fallback(id); const node = data.type === "blank" ? null : render(data, id, active, (patch) => onUpdate(id, patch)); return (
{node || onChoose(id, choice)} />}
); })} {hover && (
{blocked && "Too small"}
)}
); } function useLayout({ id, name, chat, changed, onTabClose, opener }: Props) { const [data] = useState(() => read(id)); const [api] = useState(() => Split.create(data.state, { min: minimum })); const [meta, setMeta] = useState(data.meta); const [diff, setDiff] = useState(data.diff); const state = useSyncExternalStore(api.subscribe, api.get, api.get); const status = useSyncExternalStore(host.subscribe, () => host.status); const { view, css, grips, grid, flat, desktop, box, start: resize } = useResize( api, state, minimum, ); const fit = { width: box.width, height: box.height, min: minimum } satisfies Split.Fit; const dnd = useDnd(api, flat, fit); const items = list(view.tabs, name, meta); const ghost = dnd.drag; const drag = ghost ? items.get(ghost.uid) || item(ghost.uid, name, meta[ghost.uid]) : undefined; useEffect(() => { write(id, state, meta, diff); }, [id, state, meta, diff]); const snapshot = useEffectEvent((): WindowState => { const active = new Set(view.panes.map((pane) => pane.active)); return { channel: { id, name }, tabs: [...items.values()].map((item) => ({ id: item.uid, name: item.name, type: item.kind, active: active.has(item.uid), })), }; }); const persist = useEffectEvent(() => write(id, api.get(), meta, diff)); const command = useEffectEvent((request: TabRename) => { if (request.expires <= Date.now()) throw new Error("Tab rename expired"); if (request.channel !== id) throw new Error("This window is showing another channel"); if (!api.get().tabs.includes(request.tab)) throw new Error("Tab is no longer open"); if (request.tab === CHAT) throw new Error("Rename the channel to change its Chat tab"); // The host must acknowledge the name that the client has actually rendered and saved. flushSync(() => rename(request.tab, request.name, false)); persist(); return snapshot(); }); useLayoutEffect(() => { host.onRename = command; return () => { host.onRename = undefined; host.request({ op: "window", value: null }).catch(() => {}); }; }, []); useEffect(() => { if (status !== "open") return; host.request({ op: "window", value: snapshot() }).catch(() => {}); }, [id, name, state, meta, flat, status]); // New changes open a Diff tab behind the Chat tab once; closing it keeps it closed until the // changes clear and new ones appear. useEffect(() => { if (changed === undefined) return; if (!changed) return setDiff(false); if (diff) return; setDiff(true); if (Object.values(meta).some((value) => value.type === "diff" && value.chat === chat)) return; const uid = `tab-${api.get().seq + 1}`; const pane = Split.host(api.get(), CHAT)?.uid; if (!api.open({ tab: uid, to: { pane }, background: true })) return; setMeta((meta) => ({ ...meta, [uid]: content("diff", chat) })); }, [api, chat, changed, diff, meta]); const reveal = useEffectEvent(() => { const tabs = api.get().tabs; const uid = tabs.find((uid) => { const value = meta[uid]; return value?.type === "diff" && value.chat === chat; }); if (uid) return activate(uid); const pane = Split.host(api.get(), CHAT); if (pane) add(pane, "diff"); }); useLayoutEffect(() => { if (!opener) return; opener.current = reveal; return () => void (opener.current = undefined); }, [opener]); function nav(event: KeyboardEvent, pane: Split.Pane) { const tabs = pane.tabs; if (tabs.length < 2) return; const i = pane.active ? tabs.indexOf(pane.active) : 0; let next = i; if (event.key === "ArrowLeft") next = (i - 1 + tabs.length) % tabs.length; else if (event.key === "ArrowRight") next = (i + 1) % tabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = tabs.length - 1; else return; event.preventDefault(); const uid = tabs[next]!; api.select({ tab: uid, pane: flat ? undefined : pane.uid }); focus(uid); } const heads = view.panes.filter(top); const frame = { gridTemplateColumns: css.gridTemplateColumns, gridTemplateRows: "2rem minmax(0, 1fr)", } satisfies CSSProperties; const body = { gridColumn: "1 / -1", gridRow: "2", gridTemplateColumns: "subgrid", gridTemplateRows: css.gridTemplateRows, } satisfies CSSProperties; function choose(uid: string, pane: Split.Pane) { if (dnd.consume()) return; api.select({ tab: uid, pane: flat ? undefined : pane.uid }); } function allow(pane: Split.Pane, side: "right" | "bottom") { return Split.can(state, { ...fit, open: { to: { pane: pane.uid, side } } }); } function activate(uid: string) { const pane = Split.host(api.get(), uid); api.select({ tab: uid, pane: flat ? undefined : pane?.uid }); requestAnimationFrame(() => focus(uid)); } function add(pane: Split.Pane, choice: Choice | "blank", side?: "right" | "bottom") { // There is one Chat tab per chat, so choosing Chat shows it rather than opening another. if (choice === "chat") return activate(CHAT); const uid = `tab-${api.get().seq + 1}`; const to: Split.Target | undefined = side ? { pane: pane.uid, side } : flat ? undefined : { pane: pane.uid }; if (!api.open({ tab: uid, to, ...(side ? { fit } : {}) })) return; setMeta((meta) => ({ ...meta, [uid]: content(choice, chat) })); requestAnimationFrame(() => focus(uid)); } function swap(uid: string, choice: Choice) { if (choice === "chat") { close(uid); return activate(CHAT); } setMeta((meta) => ({ ...meta, [uid]: content(choice, chat) })); requestAnimationFrame(() => focus(uid)); } function close(uid: string) { if (!api.close({ tab: uid })) return; const data = meta[uid]; setMeta((meta) => { const next = { ...meta }; delete next[uid]; return next; }); if (data) onTabClose?.(uid, data); } function rename(uid: string, name: string, restoreFocus = true) { if (uid === CHAT) return; const label = name.trim() || undefined; if (label && label.length > 80) throw new Error("Tab names must be 80 characters or fewer"); setMeta((meta) => { const value = meta[uid] || fallback(uid); return value.name === label ? meta : { ...meta, [uid]: { ...value, name: label } }; }); if (restoreFocus) requestAnimationFrame(() => focus(uid)); } function update(uid: string, patch: Partial) { setMeta((meta) => { const value = meta[uid]; if (!value) return meta; return { ...meta, [uid]: { ...value, ...patch } as Content }; }); } return { api, meta, view, grips, grid, flat, desktop, resize, dnd, items, ghost, drag, nav, heads, frame, body, choose, allow, add, swap, close, rename, update, }; } /** The original Ace tabbed layout: panes split on a grid, each with a strip of draggable tabs. */ export function Layout(props: Props) { const { api, meta, view, grips, grid, flat, desktop, resize, dnd, items, ghost, drag, nav, heads, frame, body, choose, allow, add, swap, close, rename, update, } = useLayout(props); function bar(pane: Split.Pane) { return { pane, items, flat, drag: ghost, drop: dnd.drop?.pane === pane.uid ? dnd.drop : undefined, onFork: (side: "right" | "bottom") => add(pane, "blank", side), can: (side: "right" | "bottom") => allow(pane, side), onSelect: (uid: string) => choose(uid, pane), onAdd: (choice: Choice) => add(pane, choice), onClose: close, onRename: rename, onStart: (event: PointerEvent, uid: string) => dnd.start(event, uid, pane.uid), onNav: nav, } satisfies BarProps; } return (
{heads.map((pane) => ( ))}
{view.panes.map((pane) => ( { if (!flat) api.focus({ pane: pane.uid }); }} onChoose={swap} onUpdate={update} /> ))} {desktop && grips.map((handle) => ( ))}
{drag && ghost && createPortal(, document.body)}
); }