feat(webui): add tabbed pane workbench (#5322)

This commit is contained in:
chengyongru
2026-08-12 14:45:12 +08:00
committed by GitHub
parent 1656664a47
commit 4b5319b760
38 changed files with 5706 additions and 527 deletions
@@ -0,0 +1,830 @@
import {
Columns2,
Grid2X2,
PanelLeft,
PanelsTopLeft,
Plus,
Rows2,
type LucideIcon,
} from "lucide-react";
import {
type FocusEvent,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
createWorkbenchLayoutGeometry,
resizeHandleRatio,
resizeHandleStyle,
splitRatioBounds,
type WorkbenchResizeHandle,
} from "@/components/workbench/workbench-layout";
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
export interface WorkbenchPane {
key: string;
reactKey?: string;
title: string;
}
interface PaneRenderContext {
active: boolean;
headerPortalTarget: HTMLElement | null | undefined;
composerPortalTarget: HTMLElement | null | undefined;
headerActions: ReactNode;
}
interface PaneWorkbenchProps {
panes: WorkbenchPane[];
activePaneKey: string;
layout: WorkbenchLayout;
chrome?: boolean;
showLayoutControl: boolean;
addPaneDisabled?: boolean;
addPaneDisabledLabel?: string;
onActivatePane: (key: string) => void;
onAddPane: () => void;
onLayoutChange: (layout: WorkbenchLayout) => void;
onPaneOrderChange: (paneKeys: string[]) => void;
splitRatios?: number[];
onSplitRatiosChange?: (splitRatios: number[]) => void;
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
}
const LAYOUT_MOTION_DURATION_MS = 260;
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
const EMPTY_SPLIT_RATIOS: number[] = [];
const IGNORE_SPLIT_RATIO_CHANGE = () => {};
const LAYOUT_CONTROLS: Array<{
icon: LucideIcon;
layout: WorkbenchLayout;
label: string;
}> = [
{ icon: Columns2, layout: "columns", label: "Columns" },
{ icon: Rows2, layout: "rows", label: "Rows" },
{ icon: Grid2X2, layout: "grid", label: "Grid" },
{ icon: PanelsTopLeft, layout: "bsp", label: "BSP" },
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
];
function isPaneAction(target: EventTarget | null): boolean {
return target instanceof Element
&& target.closest("[data-workbench-pane-action]") !== null;
}
function movePaneToSlot(
paneKeys: readonly string[],
paneKey: string,
targetKey: string,
): string[] {
const fromIndex = paneKeys.indexOf(paneKey);
const targetIndex = paneKeys.indexOf(targetKey);
if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return [...paneKeys];
const next = paneKeys.filter((key) => key !== paneKey);
next.splice(targetIndex, 0, paneKey);
return next;
}
function paneSlotAtPoint(
rects: readonly DOMRect[],
x: number,
y: number,
): number | null {
for (const [index, rect] of rects.entries()) {
if (
x >= rect.left
&& x <= rect.right
&& y >= rect.top
&& y <= rect.bottom
) return index;
}
return null;
}
function paneInDirection(
rects: ReadonlyMap<string, DOMRect>,
paneKey: string,
direction: "left" | "right" | "up" | "down",
): string | null {
const source = rects.get(paneKey);
if (!source) return null;
const sourceX = source.left + source.width / 2;
const sourceY = source.top + source.height / 2;
let best: { key: string; score: number } | null = null;
for (const [key, rect] of rects) {
if (key === paneKey) continue;
const deltaX = rect.left + rect.width / 2 - sourceX;
const deltaY = rect.top + rect.height / 2 - sourceY;
const primary = direction === "left"
? -deltaX
: direction === "right"
? deltaX
: direction === "up"
? -deltaY
: deltaY;
if (primary <= 1) continue;
const cross = direction === "left" || direction === "right"
? Math.abs(deltaY)
: Math.abs(deltaX);
const score = primary + cross * 0.35;
if (!best || score < best.score) best = { key, score };
}
return best?.key ?? null;
}
function HeaderIconButton({
disabled,
disabledLabel,
icon: Icon,
label,
onClick,
}: {
disabled?: boolean;
disabledLabel?: string;
icon: LucideIcon;
label: string;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={label}
title={disabled ? disabledLabel : undefined}
onClick={onClick}
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Icon className="h-4 w-4" aria-hidden />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{disabled ? disabledLabel ?? label : label}</TooltipContent>
</Tooltip>
);
}
export function PaneWorkbench({
panes,
activePaneKey,
layout,
chrome = true,
showLayoutControl,
addPaneDisabled = false,
addPaneDisabledLabel,
onActivatePane,
onAddPane,
onLayoutChange,
onPaneOrderChange,
splitRatios = EMPTY_SPLIT_RATIOS,
onSplitRatiosChange = IGNORE_SPLIT_RATIO_CHANGE,
renderPane,
}: PaneWorkbenchProps) {
const { t } = useTranslation();
const compact = useMediaQuery("(max-width: 767px)");
const effectiveLayout = layout;
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
const gridRef = useRef<HTMLDivElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>());
const sourceSplitRatiosKey = splitRatios.join("\u0000");
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
const previewSplitRatiosRef = useRef(splitRatios);
const resizeGestureRef = useRef<{
pointerId: number;
handle: WorkbenchResizeHandle;
changed: boolean;
} | null>(null);
const [resizingRatioIndex, setResizingRatioIndex] = useState<number | null>(null);
const sourcePaneOrder = useMemo(
() => panes.map((pane) => pane.key),
[panes],
);
const sourcePaneOrderKey = sourcePaneOrder.join("\u0000");
const [previewPaneKeys, setPreviewPaneKeys] = useState(sourcePaneOrder);
const previewPaneKeysRef = useRef(sourcePaneOrder);
const dragGestureRef = useRef<{
pointerId: number;
paneKey: string;
startX: number;
startY: number;
slotRects: DOMRect[];
started: boolean;
} | null>(null);
const [draggingPaneKey, setDraggingPaneKey] = useState<string | null>(null);
const orderedPanes = useMemo(() => {
const byKey = new Map(panes.map((pane) => [pane.key, pane]));
return [
...previewPaneKeys.map((key) => byKey.get(key)).filter(
(pane): pane is WorkbenchPane => pane !== undefined,
),
...panes.filter((pane) => !previewPaneKeys.includes(pane.key)),
];
}, [panes, previewPaneKeys]);
const displayedPanes = useMemo(() => {
if (!compact) return orderedPanes;
const activePane = orderedPanes.find((pane) => pane.key === activePaneKey)
?? orderedPanes[0];
return activePane ? [activePane] : [];
}, [activePaneKey, compact, orderedPanes]);
const paneOrder = displayedPanes.map((pane) => pane.key).join("\u0000");
useEffect(() => {
if (dragGestureRef.current) return;
previewPaneKeysRef.current = sourcePaneOrder;
setPreviewPaneKeys(sourcePaneOrder);
}, [sourcePaneOrder, sourcePaneOrderKey]);
useEffect(() => {
if (resizeGestureRef.current) return;
previewSplitRatiosRef.current = splitRatios;
setPreviewSplitRatios(splitRatios);
}, [sourceSplitRatiosKey, splitRatios]);
const measurePanes = useCallback(() => {
const rects = new Map<string, DOMRect>();
for (const [key, element] of paneRefs.current) {
if (!element.hidden) rects.set(key, element.getBoundingClientRect());
}
return rects;
}, []);
const captureLayout = useCallback(() => {
pendingRectsRef.current = measurePanes();
for (const animation of animationsRef.current.values()) animation.cancel();
animationsRef.current.clear();
}, [measurePanes]);
useLayoutEffect(() => {
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
pendingRectsRef.current = null;
const nextRects = measurePanes();
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
for (const [key, nextRect] of nextRects) {
const previousRect = previousRects.get(key);
const element = paneRefs.current.get(key);
if (!element) continue;
if (!previousRect) {
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
const animation = element.animate(
[
{ opacity: 0, transform: "translateY(5px) scale(0.995)" },
{ opacity: 1, transform: "translateY(0) scale(1)" },
],
{
duration: 180,
easing: LAYOUT_MOTION_EASING,
fill: "backwards",
},
);
animationsRef.current.set(key, animation);
animation.addEventListener("finish", () => {
if (animationsRef.current.get(key) === animation) {
animationsRef.current.delete(key);
}
}, { once: true });
continue;
}
if (previousRect.width === 0 || previousRect.height === 0) continue;
const deltaX = previousRect.left - nextRect.left;
const deltaY = previousRect.top - nextRect.top;
const scaleX = previousRect.width / nextRect.width;
const scaleY = previousRect.height / nextRect.height;
if (
Math.abs(deltaX) < 0.5
&& Math.abs(deltaY) < 0.5
&& Math.abs(scaleX - 1) < 0.002
&& Math.abs(scaleY - 1) < 0.002
) {
continue;
}
if (typeof element.animate !== "function") continue;
const animation = element.animate(
[
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
{ transform: "translate(0, 0) scale(1, 1)" },
],
{
duration: LAYOUT_MOTION_DURATION_MS,
easing: LAYOUT_MOTION_EASING,
},
);
animationsRef.current.set(key, animation);
animation.addEventListener("finish", () => {
if (animationsRef.current.get(key) === animation) {
animationsRef.current.delete(key);
}
}, { once: true });
}
}
lastRectsRef.current = nextRects;
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
useEffect(() => () => {
for (const animation of animationsRef.current.values()) animation.cancel();
}, []);
const activatePane = useCallback((key: string, target: EventTarget | null) => {
if (key === activePaneKey || isPaneAction(target)) return;
captureLayout();
onActivatePane(key);
}, [activePaneKey, captureLayout, onActivatePane]);
const handlePanePointerDown = useCallback((
key: string,
event: ReactPointerEvent<HTMLElement>,
) => {
activatePane(key, event.target);
}, [activatePane]);
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
activatePane(key, event.target);
}, [activatePane]);
const applyPaneOrder = useCallback((paneKey: string, targetKey: string) => {
const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey);
if (next.every((key, index) => previewPaneKeysRef.current[index] === key)) return;
captureLayout();
previewPaneKeysRef.current = next;
setPreviewPaneKeys(next);
onPaneOrderChange(next);
}, [captureLayout, onPaneOrderChange]);
const handleMovePointerDown = useCallback((
paneKey: string,
event: ReactPointerEvent<HTMLButtonElement>,
) => {
if (event.button !== 0 || compact || panes.length < 2) return;
const paneRects = measurePanes();
dragGestureRef.current = {
pointerId: event.pointerId,
paneKey,
startX: event.clientX,
startY: event.clientY,
slotRects: previewPaneKeysRef.current
.map((key) => paneRects.get(key))
.filter((rect): rect is DOMRect => rect !== undefined),
started: false,
};
setDraggingPaneKey(paneKey);
}, [compact, measurePanes, panes.length]);
const handleMovePointerMove = useCallback((event: globalThis.PointerEvent) => {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
const distance = Math.hypot(
event.clientX - gesture.startX,
event.clientY - gesture.startY,
);
if (!gesture.started) {
if (distance < 8) return;
gesture.started = true;
}
event.preventDefault();
const targetIndex = paneSlotAtPoint(gesture.slotRects, event.clientX, event.clientY);
const targetKey = targetIndex === null
? null
: previewPaneKeysRef.current[targetIndex] ?? null;
if (targetKey) applyPaneOrder(gesture.paneKey, targetKey);
}, [applyPaneOrder]);
const finishMoveGesture = useCallback(() => {
if (!dragGestureRef.current) return;
dragGestureRef.current = null;
setDraggingPaneKey(null);
}, []);
useEffect(() => {
if (!draggingPaneKey) return;
const handlePointerMove = (event: globalThis.PointerEvent) => {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
if ((event.buttons & 1) === 0) {
finishMoveGesture();
return;
}
handleMovePointerMove(event);
};
const handlePointerEnd = (event: globalThis.PointerEvent) => {
if (dragGestureRef.current?.pointerId === event.pointerId) finishMoveGesture();
};
const root = document.documentElement;
const previousCursor = root.style.cursor;
root.style.cursor = "grabbing";
window.addEventListener("pointermove", handlePointerMove, { passive: false });
window.addEventListener("pointerup", handlePointerEnd);
window.addEventListener("pointercancel", handlePointerEnd);
window.addEventListener("blur", finishMoveGesture);
return () => {
root.style.cursor = previousCursor;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerEnd);
window.removeEventListener("blur", finishMoveGesture);
};
}, [draggingPaneKey, finishMoveGesture, handleMovePointerMove]);
const handleMoveKeyDown = useCallback((
paneKey: string,
event: KeyboardEvent<HTMLButtonElement>,
) => {
const direction = (() => {
switch (event.key) {
case "ArrowLeft": return "left";
case "ArrowRight": return "right";
case "ArrowUp": return "up";
case "ArrowDown": return "down";
default: return null;
}
})();
if (!direction) return;
const targetKey = paneInDirection(measurePanes(), paneKey, direction);
if (!targetKey) return;
event.preventDefault();
const next = movePaneToSlot(previewPaneKeysRef.current, paneKey, targetKey);
captureLayout();
previewPaneKeysRef.current = next;
setPreviewPaneKeys(next);
onPaneOrderChange(next);
}, [captureLayout, measurePanes, onPaneOrderChange]);
const layoutGeometry = useMemo(() => createWorkbenchLayoutGeometry(
effectiveLayout,
displayedPanes.length,
previewSplitRatios,
), [displayedPanes.length, effectiveLayout, previewSplitRatios]);
const handleResizePointerDown = useCallback((
handle: WorkbenchResizeHandle,
event: ReactPointerEvent<HTMLButtonElement>,
) => {
if (event.button !== 0 || compact) return;
event.preventDefault();
event.stopPropagation();
previewSplitRatiosRef.current = layoutGeometry.splitRatios;
setPreviewSplitRatios(layoutGeometry.splitRatios);
resizeGestureRef.current = {
pointerId: event.pointerId,
handle,
changed: false,
};
setResizingRatioIndex(handle.ratioIndex);
}, [compact, layoutGeometry.splitRatios]);
const handleResizePointerMove = useCallback((event: globalThis.PointerEvent) => {
const gesture = resizeGestureRef.current;
const grid = gridRef.current;
if (!gesture || !grid || gesture.pointerId !== event.pointerId) return;
const rect = grid.getBoundingClientRect();
const axisExtent = gesture.handle.axis === "vertical" ? rect.width : rect.height;
if (axisExtent <= 0) return;
const normalizedPosition = gesture.handle.axis === "vertical"
? (event.clientX - rect.left) / rect.width
: (event.clientY - rect.top) / rect.height;
const ratio = resizeHandleRatio(gesture.handle, normalizedPosition, axisExtent);
const current = previewSplitRatiosRef.current;
if (Math.abs((current[gesture.handle.ratioIndex] ?? 0) - ratio) < 0.0001) return;
event.preventDefault();
const next = [...current];
next[gesture.handle.ratioIndex] = ratio;
gesture.changed = true;
previewSplitRatiosRef.current = next;
setPreviewSplitRatios(next);
}, []);
const finishResizeGesture = useCallback(() => {
const gesture = resizeGestureRef.current;
if (!gesture) return;
resizeGestureRef.current = null;
setResizingRatioIndex(null);
if (gesture.changed) onSplitRatiosChange([...previewSplitRatiosRef.current]);
}, [onSplitRatiosChange]);
useEffect(() => {
if (resizingRatioIndex === null) return;
const handlePointerMove = (event: globalThis.PointerEvent) => {
const gesture = resizeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
if ((event.buttons & 1) === 0) {
finishResizeGesture();
return;
}
handleResizePointerMove(event);
};
const handlePointerEnd = (event: globalThis.PointerEvent) => {
if (resizeGestureRef.current?.pointerId === event.pointerId) finishResizeGesture();
};
const root = document.documentElement;
const previousCursor = root.style.cursor;
const previousUserSelect = root.style.userSelect;
root.style.cursor = resizeGestureRef.current?.handle.axis === "vertical"
? "col-resize"
: "row-resize";
root.style.userSelect = "none";
window.addEventListener("pointermove", handlePointerMove, { passive: false });
window.addEventListener("pointerup", handlePointerEnd);
window.addEventListener("pointercancel", handlePointerEnd);
window.addEventListener("blur", finishResizeGesture);
return () => {
root.style.cursor = previousCursor;
root.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerEnd);
window.removeEventListener("blur", finishResizeGesture);
};
}, [finishResizeGesture, handleResizePointerMove, resizingRatioIndex]);
const handleResizeKeyDown = useCallback((
handle: WorkbenchResizeHandle,
event: KeyboardEvent<HTMLButtonElement>,
) => {
const decreasing = handle.axis === "vertical" ? event.key === "ArrowLeft" : event.key === "ArrowUp";
const increasing = handle.axis === "vertical" ? event.key === "ArrowRight" : event.key === "ArrowDown";
if (!decreasing && !increasing && event.key !== "Home" && event.key !== "End") return;
const rect = gridRef.current?.getBoundingClientRect();
const axisExtent = handle.axis === "vertical" ? rect?.width : rect?.height;
const bounds = splitRatioBounds(handle, axisExtent && axisExtent > 0 ? axisExtent : 1000);
const current = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
const step = event.shiftKey ? 0.1 : 0.03;
const nextRatio = event.key === "Home"
? bounds.min
: event.key === "End"
? bounds.max
: Math.min(bounds.max, Math.max(bounds.min, current + (increasing ? step : -step)));
event.preventDefault();
const next = [...layoutGeometry.splitRatios];
next[handle.ratioIndex] = nextRatio;
previewSplitRatiosRef.current = next;
setPreviewSplitRatios(next);
onSplitRatiosChange(next);
}, [layoutGeometry.splitRatios, onSplitRatiosChange]);
const gridStyle = layoutGeometry.gridStyle;
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
?? LAYOUT_CONTROLS[0];
const headerActions = chrome && !compact ? (
<div
data-workbench-pane-action
className="host-no-drag flex items-center gap-0.5"
>
{showLayoutControl ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("workbench.layout", {
defaultValue: "Pane layout",
})}
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<currentLayout.icon className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuLabel>
{t("workbench.layout", { defaultValue: "Pane layout" })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={layout}
onValueChange={(value) => {
const next = value as WorkbenchLayout;
if (next === layout) return;
captureLayout();
onLayoutChange(next);
}}
>
{LAYOUT_CONTROLS.map((control) => (
<DropdownMenuRadioItem
key={control.layout}
value={control.layout}
>
<control.icon aria-hidden />
{t(`workbench.layouts.${control.layout}`, {
defaultValue: control.label,
})}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<HeaderIconButton
disabled={addPaneDisabled}
disabledLabel={addPaneDisabledLabel}
icon={Plus}
label={t("workbench.addPane", { defaultValue: "Add pane" })}
onClick={() => {
captureLayout();
onAddPane();
}}
/>
</div>
) : null;
return (
<section
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
>
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
{chrome ? (
<header className="shrink-0 bg-background">
<div
ref={setHeaderPortalTarget}
data-testid="workbench-header-host"
/>
</header>
) : null}
<div className="relative min-h-0 flex-1 bg-background">
<div
ref={gridRef}
data-testid="pane-grid"
data-layout={effectiveLayout}
className={cn(
"grid h-full min-h-0 min-w-0 overflow-hidden",
chrome && displayedPanes.length > 1 && "gap-px bg-border/55",
)}
style={gridStyle}
>
{displayedPanes.map((pane, index) => {
const active = pane.key === activePaneKey;
return (
<section
key={pane.reactKey ?? pane.key}
ref={(element) => {
if (element) paneRefs.current.set(pane.key, element);
else paneRefs.current.delete(pane.key);
}}
aria-label={pane.title}
data-active={active ? "true" : "false"}
data-dragging={draggingPaneKey === pane.key ? "true" : undefined}
data-testid={`workbench-pane-${pane.key}`}
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
style={layoutGeometry.paneStyles[index]}
>
{renderPane(pane, {
active,
headerPortalTarget: chrome ? headerPortalTarget : undefined,
composerPortalTarget: chrome ? composerPortalTarget : undefined,
headerActions,
})}
{chrome && panes.length > 1 && !compact ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={active ? 0 : -1}
aria-hidden={active ? undefined : true}
aria-label={active ? t("workbench.movePane", {
defaultValue: "Move {{title}} pane",
title: pane.title,
}) : undefined}
aria-keyshortcuts={active ? "ArrowLeft ArrowRight ArrowUp ArrowDown" : undefined}
data-workbench-pane-action
data-workbench-move-handle
data-testid={`pane-move-handle-${pane.key}`}
onPointerDown={(event) => handleMovePointerDown(pane.key, event)}
onKeyDown={(event) => handleMoveKeyDown(pane.key, event)}
className={cn(
"group host-no-drag absolute bottom-0 left-1/2 z-30 flex h-6 w-12 -translate-x-1/2 touch-none items-center justify-center rounded-full",
"cursor-grab focus-visible:outline-none active:cursor-grabbing",
"transition-opacity duration-100 motion-reduce:transition-none",
active ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
)}
>
<span
aria-hidden
className={cn(
"h-[3px] w-9 rounded-full bg-foreground/35 transition-[height,background-color] duration-100 motion-reduce:transition-none",
"group-focus-visible:h-[5px] group-focus-visible:bg-foreground/70",
draggingPaneKey === pane.key
? "bg-foreground/65"
: "group-hover:bg-foreground/50",
)}
/>
</button>
</TooltipTrigger>
{active ? (
<TooltipContent side="top">
{t("workbench.movePaneHint", {
defaultValue: "Drag to move · Arrow keys also work",
})}
</TooltipContent>
) : null}
</Tooltip>
) : null}
</section>
);
})}
</div>
{chrome && panes.length > 1 && !compact ? layoutGeometry.resizeHandles.map(
(handle, index) => {
const ratio = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
const vertical = handle.axis === "vertical";
const bounds = splitRatioBounds(handle, 1000);
return (
<button
key={`${handle.axis}-${handle.ratioIndex}`}
type="button"
role="separator"
aria-orientation={handle.axis}
aria-label={t("workbench.resizePaneBoundary", {
defaultValue: "Resize pane boundary {{index}}",
index: index + 1,
})}
aria-valuemin={Math.round(bounds.min * 100)}
aria-valuemax={Math.round(bounds.max * 100)}
aria-valuenow={Math.round(ratio * 100)}
aria-keyshortcuts={vertical
? "ArrowLeft ArrowRight Home End"
: "ArrowUp ArrowDown Home End"}
data-workbench-pane-action
data-workbench-resize-handle={handle.ratioIndex}
onPointerDown={(event) => handleResizePointerDown(handle, event)}
onKeyDown={(event) => handleResizeKeyDown(handle, event)}
className={cn(
"group host-no-drag absolute z-20 touch-none border-0 bg-transparent p-0 focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
vertical ? "w-3 cursor-col-resize" : "h-3 cursor-row-resize",
)}
style={resizeHandleStyle(handle)}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute bg-transparent transition-colors duration-100 motion-reduce:transition-none",
"group-hover:bg-foreground/20 group-focus-visible:bg-foreground/25",
resizingRatioIndex === handle.ratioIndex && "bg-foreground/30",
vertical
? "inset-y-0 left-1/2 w-px -translate-x-1/2"
: "inset-x-0 top-1/2 h-px -translate-y-1/2",
)}
/>
</button>
);
},
) : null}
</div>
{chrome ? (
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
<div
ref={setComposerPortalTarget}
data-testid="workbench-composer-host"
className="mx-auto w-full max-w-[58rem]"
/>
</footer>
) : null}
</TooltipProvider>
</section>
);
}
@@ -0,0 +1,432 @@
import type { CSSProperties } from "react";
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
interface PaneCell {
xStart: number;
xEnd: number;
yStart: number;
yEnd: number;
}
interface Track {
start: number;
end: number;
}
export interface WorkbenchResizeHandle {
axis: "horizontal" | "vertical";
ratioIndex: number;
position: number;
crossStart: number;
crossEnd: number;
localStart: number;
localEnd: number;
beforeUnitCount: number;
afterUnitCount: number;
}
export interface WorkbenchLayoutGeometry {
gridStyle: CSSProperties;
paneStyles: Array<CSSProperties | undefined>;
resizeHandles: WorkbenchResizeHandle[];
splitRatios: number[];
}
const MIN_RATIO = 0.05;
const MAX_RATIO = 0.95;
const MIN_PANE_EXTENT_PX = 160;
const MAIN_PANE_RATIO = 1.65 / 2.65;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function ratioAt(
ratios: readonly number[],
index: number,
fallback: number,
): number {
const value = ratios[index];
return Number.isFinite(value) ? clamp(value, MIN_RATIO, MAX_RATIO) : fallback;
}
function tracksTemplate(tracks: readonly Track[]): string {
return tracks
.map((track) => {
const weight = Number(((track.end - track.start) * 1000).toFixed(6));
return `minmax(0, ${weight}fr)`;
})
.join(" ");
}
function sequentialTracks(
count: number,
ratios: readonly number[],
ratioOffset: number,
): { tracks: Track[]; handles: WorkbenchResizeHandle[]; resolvedRatios: number[] } {
const tracks: Track[] = [];
const handles: WorkbenchResizeHandle[] = [];
const resolvedRatios: number[] = [];
let start = 0;
for (let index = 0; index < count - 1; index += 1) {
const remainingPaneCount = count - index;
const ratio = ratioAt(ratios, ratioOffset + index, 1 / remainingPaneCount);
const end = start + (1 - start) * ratio;
tracks.push({ start, end });
handles.push({
axis: "vertical",
ratioIndex: ratioOffset + index,
position: end,
crossStart: 0,
crossEnd: 1,
localStart: start,
localEnd: 1,
beforeUnitCount: 1,
afterUnitCount: remainingPaneCount - 1,
});
resolvedRatios.push(ratio);
start = end;
}
tracks.push({ start, end: 1 });
return { tracks, handles, resolvedRatios };
}
function cellStyle(
columnStart: number,
columnEnd: number,
rowStart: number,
rowEnd: number,
): CSSProperties {
return {
gridColumn: `${columnStart} / ${columnEnd}`,
gridRow: `${rowStart} / ${rowEnd}`,
};
}
function singlePaneGeometry(): WorkbenchLayoutGeometry {
return {
gridStyle: {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: "minmax(0, 1fr)",
},
paneStyles: [undefined],
resizeHandles: [],
splitRatios: [],
};
}
function columnsGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const split = sequentialTracks(paneCount, ratios, 0);
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(split.tracks),
gridTemplateRows: "minmax(0, 1fr)",
},
paneStyles: split.tracks.map((_, index) => cellStyle(index + 1, index + 2, 1, 2)),
resizeHandles: split.handles,
splitRatios: split.resolvedRatios,
};
}
function rowsGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const split = sequentialTracks(paneCount, ratios, 0);
return {
gridStyle: {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: tracksTemplate(split.tracks),
},
paneStyles: split.tracks.map((_, index) => cellStyle(1, 2, index + 1, index + 2)),
resizeHandles: split.handles.map((handle) => ({
...handle,
axis: "horizontal",
})),
splitRatios: split.resolvedRatios,
};
}
function gridGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const columnCount = Math.ceil(Math.sqrt(paneCount));
const rowCount = Math.ceil(paneCount / columnCount);
const columns = sequentialTracks(columnCount, ratios, 0);
const rows = sequentialTracks(rowCount, ratios, columnCount - 1);
const paneCells = Array.from({ length: paneCount }, (_, index) => ({
column: index % columnCount,
row: Math.floor(index / columnCount),
}));
const verticalHandles = columns.handles.map((handle, boundaryIndex) => ({
...handle,
beforeUnitCount: boundaryIndex + 1,
afterUnitCount: columnCount - boundaryIndex - 1,
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
const horizontalHandles = rows.handles.map((handle, boundaryIndex) => ({
...handle,
axis: "horizontal" as const,
beforeUnitCount: boundaryIndex + 1,
afterUnitCount: rowCount - boundaryIndex - 1,
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(columns.tracks),
gridTemplateRows: tracksTemplate(rows.tracks),
},
paneStyles: paneCells.map((cell) => cellStyle(
cell.column + 1,
cell.column + 2,
cell.row + 1,
cell.row + 2,
)),
resizeHandles: [...verticalHandles, ...horizontalHandles],
splitRatios: [...columns.resolvedRatios, ...rows.resolvedRatios],
};
}
function mainStackGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const columnRatio = ratioAt(ratios, 0, MAIN_PANE_RATIO);
const stack = sequentialTracks(paneCount - 1, ratios, 1);
const verticalHandle: WorkbenchResizeHandle = {
axis: "vertical",
ratioIndex: 0,
position: columnRatio,
crossStart: 0,
crossEnd: 1,
localStart: 0,
localEnd: 1,
beforeUnitCount: 1,
afterUnitCount: 1,
};
const stackHandles = stack.handles.map((handle) => ({
...handle,
axis: "horizontal" as const,
crossStart: columnRatio,
}));
return {
gridStyle: {
gridTemplateColumns: tracksTemplate([
{ start: 0, end: columnRatio },
{ start: columnRatio, end: 1 },
]),
gridTemplateRows: tracksTemplate(stack.tracks),
},
paneStyles: [
cellStyle(1, 2, 1, paneCount),
...stack.tracks.map((_, index) => cellStyle(2, 3, index + 1, index + 2)),
],
resizeHandles: [verticalHandle, ...stackHandles],
splitRatios: [columnRatio, ...stack.resolvedRatios],
};
}
function uniqueBoundaries(values: readonly number[]): number[] {
return Array.from(new Set(values.map((value) => value.toFixed(8))))
.map(Number)
.sort((left, right) => left - right);
}
function boundaryIndex(boundaries: readonly number[], value: number): number {
return boundaries.findIndex((candidate) => Math.abs(candidate - value) < 0.0000001);
}
function axisUnitCount(cells: readonly PaneCell[], axis: "horizontal" | "vertical"): number {
const boundaries = uniqueBoundaries(cells.flatMap((cell) => axis === "vertical"
? [cell.xStart, cell.xEnd]
: [cell.yStart, cell.yEnd]));
return Math.max(1, boundaries.length - 1);
}
function bspGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const cells: PaneCell[] = [{ xStart: 0, xEnd: 1, yStart: 0, yEnd: 1 }];
const handles: WorkbenchResizeHandle[] = [];
const resolvedRatios: number[] = [];
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
const leaf = cells.pop();
if (!leaf) break;
const ratioIndex = paneIndex - 1;
const ratio = ratioAt(ratios, ratioIndex, 0.5);
resolvedRatios.push(ratio);
if (paneIndex % 2 === 1) {
const position = leaf.xStart + (leaf.xEnd - leaf.xStart) * ratio;
cells.push(
{ ...leaf, xEnd: position },
{ ...leaf, xStart: position },
);
handles.push({
axis: "vertical",
ratioIndex,
position,
crossStart: leaf.yStart,
crossEnd: leaf.yEnd,
localStart: leaf.xStart,
localEnd: leaf.xEnd,
beforeUnitCount: 0,
afterUnitCount: 0,
});
} else {
const position = leaf.yStart + (leaf.yEnd - leaf.yStart) * ratio;
cells.push(
{ ...leaf, yEnd: position },
{ ...leaf, yStart: position },
);
handles.push({
axis: "horizontal",
ratioIndex,
position,
crossStart: leaf.xStart,
crossEnd: leaf.xEnd,
localStart: leaf.yStart,
localEnd: leaf.yEnd,
beforeUnitCount: 0,
afterUnitCount: 0,
});
}
}
for (const handle of handles) {
if (handle.axis === "vertical") {
const beforeCells = cells.filter((cell) => (
cell.xStart >= handle.localStart
&& cell.xEnd <= handle.position + Number.EPSILON
&& cell.yStart >= handle.crossStart
&& cell.yEnd <= handle.crossEnd
));
const afterCells = cells.filter((cell) => (
cell.xStart >= handle.position - Number.EPSILON
&& cell.xEnd <= handle.localEnd
&& cell.yStart >= handle.crossStart
&& cell.yEnd <= handle.crossEnd
));
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
} else {
const beforeCells = cells.filter((cell) => (
cell.yStart >= handle.localStart
&& cell.yEnd <= handle.position + Number.EPSILON
&& cell.xStart >= handle.crossStart
&& cell.xEnd <= handle.crossEnd
));
const afterCells = cells.filter((cell) => (
cell.yStart >= handle.position - Number.EPSILON
&& cell.yEnd <= handle.localEnd
&& cell.xStart >= handle.crossStart
&& cell.xEnd <= handle.crossEnd
));
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
}
}
const columnBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.xStart, cell.xEnd]));
const rowBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.yStart, cell.yEnd]));
const columnTracks = columnBoundaries.slice(0, -1).map((start, index) => ({
start,
end: columnBoundaries[index + 1],
}));
const rowTracks = rowBoundaries.slice(0, -1).map((start, index) => ({
start,
end: rowBoundaries[index + 1],
}));
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(columnTracks),
gridTemplateRows: tracksTemplate(rowTracks),
},
paneStyles: cells.map((cell) => cellStyle(
boundaryIndex(columnBoundaries, cell.xStart) + 1,
boundaryIndex(columnBoundaries, cell.xEnd) + 1,
boundaryIndex(rowBoundaries, cell.yStart) + 1,
boundaryIndex(rowBoundaries, cell.yEnd) + 1,
)),
resizeHandles: handles,
splitRatios: resolvedRatios,
};
}
export function createWorkbenchLayoutGeometry(
layout: WorkbenchLayout,
paneCount: number,
splitRatios: readonly number[],
): WorkbenchLayoutGeometry {
const count = Math.max(1, paneCount);
if (count === 1) return singlePaneGeometry();
switch (layout) {
case "columns":
return columnsGeometry(count, splitRatios);
case "rows":
return rowsGeometry(count, splitRatios);
case "grid":
return gridGeometry(count, splitRatios);
case "main-stack":
return mainStackGeometry(count, splitRatios);
case "bsp":
return bspGeometry(count, splitRatios);
}
}
export function splitRatioBounds(
handle: WorkbenchResizeHandle,
axisExtentPx: number,
): { min: number; max: number } {
const localExtentPx = Math.max(
1,
axisExtentPx * Math.max(0.01, handle.localEnd - handle.localStart),
);
const paneCount = Math.max(2, handle.beforeUnitCount + handle.afterUnitCount);
const paneExtentPx = Math.min(
MIN_PANE_EXTENT_PX,
localExtentPx * 0.8 / paneCount,
);
const min = Math.max(MIN_RATIO, paneExtentPx * handle.beforeUnitCount / localExtentPx);
const max = Math.min(
MAX_RATIO,
1 - paneExtentPx * handle.afterUnitCount / localExtentPx,
);
return min <= max ? { min, max } : { min: 0.4, max: 0.6 };
}
export function resizeHandleRatio(
handle: WorkbenchResizeHandle,
normalizedPosition: number,
axisExtentPx: number,
): number {
const localRatio = (normalizedPosition - handle.localStart)
/ Math.max(0.01, handle.localEnd - handle.localStart);
const bounds = splitRatioBounds(handle, axisExtentPx);
return Number(clamp(localRatio, bounds.min, bounds.max).toFixed(4));
}
export function resizeHandleStyle(handle: WorkbenchResizeHandle): CSSProperties {
if (handle.axis === "vertical") {
return {
left: `${handle.position * 100}%`,
top: `${handle.crossStart * 100}%`,
height: `${(handle.crossEnd - handle.crossStart) * 100}%`,
transform: "translateX(-50%)",
};
}
return {
top: `${handle.position * 100}%`,
left: `${handle.crossStart * 100}%`,
width: `${(handle.crossEnd - handle.crossStart) * 100}%`,
transform: "translateY(-50%)",
};
}
@@ -0,0 +1,431 @@
import type {
WorkbenchLayout,
WorkbenchState,
WorkbenchTabState,
} from "@/lib/types";
export type {
WorkbenchLayout,
WorkbenchState,
WorkbenchTabState,
} from "@/lib/types";
export const MAX_WORKBENCH_PANES = 4;
export const WORKBENCH_LAYOUTS = [
"columns",
"rows",
"grid",
"bsp",
"main-stack",
] as const;
export interface WorkbenchTabMatch {
tabKey: string;
tab: WorkbenchTabState;
}
export interface OrderedWorkbenchTab extends WorkbenchTabMatch {
paneKeys: string[];
updatedAt: string | null;
}
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
version: 1,
tabs: {},
};
function isLayout(value: unknown): value is WorkbenchLayout {
return typeof value === "string"
&& (WORKBENCH_LAYOUTS as readonly string[]).includes(value);
}
function uniqueKeys(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return Array.from(new Set(
value.filter((key): key is string => typeof key === "string" && key.length > 0),
));
}
function normalizeTitle(value: unknown): string | null {
if (typeof value !== "string") return null;
const title = value.trim();
return title || null;
}
function normalizeSplitRatios(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value
.filter((ratio): ratio is number => typeof ratio === "number" && Number.isFinite(ratio))
.slice(0, MAX_WORKBENCH_PANES - 1)
.map((ratio) => Number(Math.min(0.95, Math.max(0.05, ratio)).toFixed(4)));
}
function normalizeTab(value: unknown): WorkbenchTabState {
const candidate = value && typeof value === "object"
? value as Partial<WorkbenchTabState>
: {};
const paneKeys = uniqueKeys(candidate.paneKeys).slice(0, MAX_WORKBENCH_PANES);
const requestedLayoutPaneKeys = uniqueKeys(candidate.layoutPaneKeys)
.filter((key) => paneKeys.includes(key));
const layoutPaneKeys = [
...requestedLayoutPaneKeys,
...paneKeys.filter((key) => !requestedLayoutPaneKeys.includes(key)),
];
return {
explicit: candidate.explicit === true,
title: normalizeTitle(candidate.title),
paneKeys,
layoutPaneKeys,
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
splitRatios: normalizeSplitRatios(candidate.splitRatios),
};
}
function standaloneTabKeyBase(paneKey: string): string {
return `tab:${paneKey}`;
}
function availableStandaloneTabKey(
tabs: Readonly<Record<string, WorkbenchTabState>>,
paneKey: string,
): string {
const base = standaloneTabKeyBase(paneKey);
if (!tabs[base]) return base;
let suffix = 2;
while (tabs[`${base}:${suffix}`]) suffix += 1;
return `${base}:${suffix}`;
}
function defaultWorkbenchTab(
paneKey: string,
title: string | null = null,
): WorkbenchTabState {
return {
explicit: false,
title: normalizeTitle(title),
paneKeys: [paneKey],
layoutPaneKeys: [paneKey],
layout: "columns",
splitRatios: [],
};
}
export function normalizeWorkbenchState(raw: unknown): WorkbenchState {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return EMPTY_WORKBENCH_STATE;
}
const parsed = raw as { version?: unknown; tabs?: unknown };
if (parsed.version !== 1 || !parsed.tabs
|| typeof parsed.tabs !== "object" || Array.isArray(parsed.tabs)) {
return EMPTY_WORKBENCH_STATE;
}
return {
version: 1,
tabs: Object.fromEntries(
Object.entries(parsed.tabs)
.map(([tabKey, tab]) => [tabKey, normalizeTab(tab)] as const)
.filter(([, tab]) => tab.paneKeys.length > 1 || tab.explicit),
),
};
}
export function workbenchTab(
state: WorkbenchState,
tabKey: string,
): WorkbenchTabState | null {
return state.tabs[tabKey] ?? null;
}
export function workbenchTabForPane(
state: WorkbenchState,
paneKey: string,
): WorkbenchTabMatch {
const match = Object.entries(state.tabs).find(([, tab]) => tab.paneKeys.includes(paneKey));
if (match) return { tabKey: match[0], tab: match[1] };
return {
tabKey: availableStandaloneTabKey(state.tabs, paneKey),
tab: defaultWorkbenchTab(paneKey),
};
}
function updateTab(
state: WorkbenchState,
tabKey: string,
update: (tab: WorkbenchTabState) => WorkbenchTabState,
): WorkbenchState {
const current = state.tabs[tabKey];
if (!current) return state;
const next = update(current);
if (next === current) return state;
return {
version: 1,
tabs: {
...state.tabs,
[tabKey]: next,
},
};
}
export function addWorkbenchPane(
state: WorkbenchState,
anchorPaneKey: string,
paneKey: string,
): WorkbenchState {
if (!anchorPaneKey || !paneKey || anchorPaneKey === paneKey) return state;
const target = workbenchTabForPane(state, anchorPaneKey);
if (state.tabs[target.tabKey]) return attachWorkbenchPane(state, target.tabKey, paneKey);
const withTarget = {
version: 1 as const,
tabs: {
...state.tabs,
[target.tabKey]: target.tab,
},
};
return attachWorkbenchPane(withTarget, target.tabKey, paneKey);
}
export function createWorkbenchTab(
state: WorkbenchState,
paneKey: string,
): WorkbenchState {
if (!paneKey) return state;
const match = workbenchTabForPane(state, paneKey);
const persisted = state.tabs[match.tabKey];
if (persisted) {
return updateTab(state, match.tabKey, (tab) => (
tab.explicit ? tab : { ...tab, explicit: true }
));
}
return {
version: 1,
tabs: {
...state.tabs,
[match.tabKey]: { ...match.tab, explicit: true },
},
};
}
export function detachWorkbenchPane(
state: WorkbenchState,
tabKey: string,
paneKey: string,
): WorkbenchState {
const tab = state.tabs[tabKey];
if (!tab || !tab.paneKeys.includes(paneKey)) return state;
if (tab.paneKeys.length === 1) {
const tabs = { ...state.tabs };
delete tabs[tabKey];
return { version: 1, tabs };
}
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
const layoutPaneKeys = tab.layoutPaneKeys.filter((key) => key !== paneKey);
const tabs = { ...state.tabs };
const nextTab = {
...tab,
paneKeys,
layoutPaneKeys,
splitRatios: [],
};
if (nextTab.explicit || paneKeys.length > 1) tabs[tabKey] = nextTab;
else delete tabs[tabKey];
return {
version: 1,
tabs,
};
}
export function dissolveWorkbenchTab(
state: WorkbenchState,
tabKey: string,
): WorkbenchState {
const tab = state.tabs[tabKey];
if (!tab) return state;
const tabs = { ...state.tabs };
delete tabs[tabKey];
return { version: 1, tabs };
}
export function attachWorkbenchPane(
state: WorkbenchState,
targetTabKey: string,
paneKey: string,
): WorkbenchState {
if (!targetTabKey || !paneKey) return state;
const target = state.tabs[targetTabKey];
if (!target) return state;
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
tab.paneKeys.includes(paneKey)
));
const sourceTabKey = sourceEntry?.[0];
const sourceTab = sourceEntry?.[1];
if (sourceTabKey === targetTabKey) {
return state;
}
if (!target.paneKeys.includes(paneKey) && target.paneKeys.length >= MAX_WORKBENCH_PANES) {
return state;
}
const tabs = { ...state.tabs };
if (sourceTabKey && sourceTab) {
const sourcePaneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
const sourceLayoutPaneKeys = sourceTab.layoutPaneKeys.filter((key) => key !== paneKey);
if (sourcePaneKeys.length === 0 || (!sourceTab.explicit && sourcePaneKeys.length === 1)) {
delete tabs[sourceTabKey];
} else {
tabs[sourceTabKey] = {
...sourceTab,
paneKeys: sourcePaneKeys,
layoutPaneKeys: sourceLayoutPaneKeys,
splitRatios: [],
};
}
}
const nextTarget = tabs[targetTabKey];
if (!nextTarget) return state;
const paneKeys = nextTarget.paneKeys.includes(paneKey)
? nextTarget.paneKeys
: [...nextTarget.paneKeys, paneKey];
const layoutPaneKeys = nextTarget.layoutPaneKeys.includes(paneKey)
? nextTarget.layoutPaneKeys
: [...nextTarget.layoutPaneKeys, paneKey];
tabs[targetTabKey] = {
...nextTarget,
paneKeys,
layoutPaneKeys,
splitRatios: [],
};
return { version: 1, tabs };
}
export function renameWorkbenchTab(
state: WorkbenchState,
tabKey: string,
title: string,
): WorkbenchState {
const normalized = normalizeTitle(title);
if (!normalized) return state;
return updateTab(state, tabKey, (tab) => (
tab.title === normalized ? tab : { ...tab, title: normalized }
));
}
export function setWorkbenchLayout(
state: WorkbenchState,
tabKey: string,
layout: WorkbenchLayout,
): WorkbenchState {
return updateTab(state, tabKey, (tab) => (
tab.layout === layout ? tab : { ...tab, layout, splitRatios: [] }
));
}
export function setWorkbenchSplitRatios(
state: WorkbenchState,
tabKey: string,
splitRatios: readonly number[],
): WorkbenchState {
return updateTab(state, tabKey, (tab) => {
const normalized = normalizeSplitRatios(splitRatios);
return normalized.length === tab.splitRatios.length
&& normalized.every((ratio, index) => ratio === tab.splitRatios[index])
? tab
: { ...tab, splitRatios: normalized };
});
}
export function setWorkbenchPaneLayoutOrder(
state: WorkbenchState,
tabKey: string,
paneKeys: readonly string[],
): WorkbenchState {
return updateTab(state, tabKey, (tab) => {
const requested = uniqueKeys(paneKeys).filter((key) => tab.paneKeys.includes(key));
const layoutPaneKeys = [
...requested,
...tab.paneKeys.filter((key) => !requested.includes(key)),
];
return layoutPaneKeys.every((key, index) => tab.layoutPaneKeys[index] === key)
? tab
: { ...tab, layoutPaneKeys };
});
}
export function reconcileWorkbench(
state: WorkbenchState,
validKeys: ReadonlySet<string>,
): WorkbenchState {
const tabs: Record<string, WorkbenchTabState> = {};
const claimedPaneKeys = new Set<string>();
for (const [tabKey, tab] of Object.entries(state.tabs)) {
const paneKeys = tab.paneKeys
.filter((key) => validKeys.has(key) && !claimedPaneKeys.has(key))
.slice(0, MAX_WORKBENCH_PANES);
if (paneKeys.length === 0) continue;
const nextTab = {
...tab,
paneKeys,
layoutPaneKeys: [
...tab.layoutPaneKeys.filter((key) => paneKeys.includes(key)),
...paneKeys.filter((key) => !tab.layoutPaneKeys.includes(key)),
],
splitRatios: paneKeys.length === tab.paneKeys.length
&& paneKeys.every((key, index) => key === tab.paneKeys[index])
? tab.splitRatios
: [],
};
if (!nextTab.explicit && paneKeys.length === 1) continue;
for (const paneKey of paneKeys) claimedPaneKeys.add(paneKey);
tabs[tabKey] = nextTab;
}
return JSON.stringify(state.tabs) === JSON.stringify(tabs)
? state
: { version: 1, tabs };
}
export function orderWorkbenchTabs(
state: WorkbenchState,
orderedSessionKeys: readonly string[],
updatedAtByKey: ReadonlyMap<string, string | null | undefined>,
): OrderedWorkbenchTab[] {
const rank = new Map(orderedSessionKeys.map((key, index) => [key, index]));
const validKeys = new Set(orderedSessionKeys);
const reconciled = reconcileWorkbench(state, validKeys);
const projectedTabs = { ...reconciled.tabs };
const claimedPaneKeys = new Set(
Object.values(projectedTabs).flatMap((tab) => tab.paneKeys),
);
for (const paneKey of orderedSessionKeys) {
if (claimedPaneKeys.has(paneKey)) continue;
const tabKey = availableStandaloneTabKey(projectedTabs, paneKey);
projectedTabs[tabKey] = defaultWorkbenchTab(paneKey);
}
const tabs = Object.entries(projectedTabs).map(([tabKey, tab]) => {
const paneKeys = tab.paneKeys
.filter((key) => validKeys.has(key))
.sort((left, right) => (rank.get(left) ?? Infinity) - (rank.get(right) ?? Infinity));
const updatedAt = paneKeys.reduce<string | null>((latest, paneKey) => {
const candidate = updatedAtByKey.get(paneKey) ?? null;
return dateToTime(candidate) > dateToTime(latest) ? candidate : latest;
}, null);
return { tabKey, tab, paneKeys, updatedAt };
});
return tabs.sort((left, right) => {
const updateOrder = dateToTime(right.updatedAt) - dateToTime(left.updatedAt);
if (updateOrder !== 0) return updateOrder;
const leftRank = Math.min(...left.paneKeys.map((key) => rank.get(key) ?? Infinity));
const rightRank = Math.min(...right.paneKeys.map((key) => rank.get(key) ?? Infinity));
return leftRank - rightRank;
});
}
function dateToTime(value: string | null | undefined): number {
const timestamp = Date.parse(value ?? "");
return Number.isFinite(timestamp) ? timestamp : 0;
}