From d0240a9074c56da9a22cb2a747d1e912d5827de1 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 11 Aug 2026 18:38:39 +0800 Subject: [PATCH] feat(webui): resize workbench panes --- .../websocket/tests/test_websocket_channel.py | 4 + nanobot/webui/sidebar_state.py | 16 + tests/utils/test_webui_sidebar_state.py | 3 + webui/src/App.tsx | 11 + .../components/workbench/PaneWorkbench.tsx | 299 +++++++----- .../components/workbench/workbench-layout.ts | 434 ++++++++++++++++++ .../components/workbench/workbench-model.ts | 35 +- webui/src/i18n/locales/en/common.json | 1 + webui/src/i18n/locales/es/common.json | 1 + webui/src/i18n/locales/fr/common.json | 1 + webui/src/i18n/locales/id/common.json | 1 + webui/src/i18n/locales/ja/common.json | 1 + webui/src/i18n/locales/ko/common.json | 1 + webui/src/i18n/locales/pt-BR/common.json | 1 + webui/src/i18n/locales/vi/common.json | 1 + webui/src/i18n/locales/zh-CN/common.json | 1 + webui/src/i18n/locales/zh-TW/common.json | 1 + webui/src/lib/types.ts | 1 + webui/src/tests/pane-workbench.test.tsx | 57 ++- webui/src/tests/workbench-model.test.ts | 22 + 20 files changed, 781 insertions(+), 111 deletions(-) create mode 100644 webui/src/components/workbench/workbench-layout.ts diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 9c8e6d874..1a19b0e21 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -1062,6 +1062,7 @@ async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices( "layoutPaneKeys": ["websocket:b", "websocket:a"], "activePaneKey": "websocket:a", "layout": "columns", + "splitRatios": [0.35], } }, } @@ -1081,6 +1082,9 @@ async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices( "websocket:b", "websocket:a", ] + assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [ + 0.35 + ] @pytest.mark.asyncio diff --git a/nanobot/webui/sidebar_state.py b/nanobot/webui/sidebar_state.py index 049494ae4..1a0b80f53 100644 --- a/nanobot/webui/sidebar_state.py +++ b/nanobot/webui/sidebar_state.py @@ -8,6 +8,7 @@ does not modify agent sessions. from __future__ import annotations import json +import math import os import time from pathlib import Path @@ -79,6 +80,20 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]: return out +def _clean_split_ratios(value: Any) -> list[float]: + if not isinstance(value, list): + return [] + ratios: list[float] = [] + for raw_ratio in cast(list[Any], value)[: _MAX_WORKBENCH_PANES - 1]: + if isinstance(raw_ratio, bool) or not isinstance(raw_ratio, (int, float)): + continue + ratio = float(raw_ratio) + if not math.isfinite(ratio): + continue + ratios.append(round(min(0.95, max(0.05, ratio)), 4)) + return ratios + + def _clean_bool_map(value: Any) -> dict[str, bool]: if not isinstance(value, dict): return {} @@ -175,6 +190,7 @@ def _clean_workbench(value: Any) -> dict[str, Any]: active_pane_key if active_pane_key in pane_keys else pane_keys[0] ), "layout": layout, + "splitRatios": _clean_split_ratios(tab.get("splitRatios")), } return {"version": 1, "tabs": tabs} diff --git a/tests/utils/test_webui_sidebar_state.py b/tests/utils/test_webui_sidebar_state.py index 831b566ad..43779f157 100644 --- a/tests/utils/test_webui_sidebar_state.py +++ b/tests/utils/test_webui_sidebar_state.py @@ -41,6 +41,7 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None "layoutPaneKeys": ["websocket:b", "missing", "websocket:a"], "activePaneKey": "missing", "layout": "invalid-layout", + "splitRatios": [0.4, 2, "bad", float("nan")], }, "tab:websocket:b": { "paneKeys": ["websocket:b", "websocket:c"], @@ -75,6 +76,7 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None "layoutPaneKeys": ["websocket:b", "websocket:a"], "activePaneKey": "websocket:a", "layout": "columns", + "splitRatios": [0.4, 0.95], }, "tab:websocket:b": { "explicit": False, @@ -83,6 +85,7 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None "layoutPaneKeys": ["websocket:c"], "activePaneKey": "websocket:c", "layout": "bsp", + "splitRatios": [], }, }, } diff --git a/webui/src/App.tsx b/webui/src/App.tsx index ab8575267..58c7db163 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -31,6 +31,7 @@ import { renameWorkbenchTab, setWorkbenchLayout, setWorkbenchPaneLayoutOrder, + setWorkbenchSplitRatios, workbenchTab, workbenchTabForPane, type WorkbenchState, @@ -2400,6 +2401,9 @@ function Shell({ const renderedWorkbenchLayout = paneChromeEnabled && activeTabState ? activeTabState.layout : "columns"; + const renderedWorkbenchSplitRatios = paneChromeEnabled && activeTabState + ? activeTabState.splitRatios + : []; const sidebarPaneGroups = useMemo(() => { const sessionsByKey = new Map(sessions.map((session) => [session.key, session])); return Object.fromEntries(sidebarTabPresentations.map((presentation) => { @@ -2734,6 +2738,7 @@ function Shell({ panes={renderedWorkbenchPanes} activePaneKey={renderedActivePaneKey} layout={renderedWorkbenchLayout} + splitRatios={renderedWorkbenchSplitRatios} chrome={paneChromeEnabled} showLayoutControl={activeTabVisible} addPaneDisabled={creatingPane || activePaneLimitReached} @@ -2751,6 +2756,12 @@ function Shell({ setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys) )); }} + onSplitRatiosChange={(splitRatios) => { + if (!activeTabKey) return; + setWorkbenchState((current) => ( + setWorkbenchSplitRatios(current, activeTabKey, splitRatios) + )); + }} renderPane={(pane, context) => { if (!paneChromeEnabled) { return ( diff --git a/webui/src/components/workbench/PaneWorkbench.tsx b/webui/src/components/workbench/PaneWorkbench.tsx index b98f8bb01..e3b5f62df 100644 --- a/webui/src/components/workbench/PaneWorkbench.tsx +++ b/webui/src/components/workbench/PaneWorkbench.tsx @@ -8,7 +8,6 @@ import { type LucideIcon, } from "lucide-react"; import { - type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, @@ -38,6 +37,14 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + createWorkbenchLayoutGeometry, + type EffectiveWorkbenchLayout, + 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"; @@ -66,11 +73,15 @@ interface PaneWorkbenchProps { 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; @@ -84,108 +95,6 @@ const LAYOUT_CONTROLS: Array<{ { icon: PanelLeft, layout: "main-stack", label: "Main and stack" }, ]; -type EffectiveWorkbenchLayout = WorkbenchLayout | "compact"; - -function paneGridStyle(layout: EffectiveWorkbenchLayout, paneCount: number): CSSProperties { - const count = Math.max(1, paneCount); - switch (layout) { - case "columns": - return { - gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`, - gridTemplateRows: "minmax(0, 1fr)", - }; - case "rows": - return { - gridTemplateColumns: "minmax(0, 1fr)", - gridTemplateRows: `repeat(${count}, minmax(0, 1fr))`, - }; - case "grid": { - const columns = Math.ceil(Math.sqrt(count)); - const rows = Math.ceil(count / columns); - return { - gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, - gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`, - }; - } - case "bsp": - return { - gridTemplateColumns: "repeat(4, minmax(0, 1fr))", - gridTemplateRows: "repeat(4, minmax(0, 1fr))", - }; - case "main-stack": - return count === 1 - ? { - gridTemplateColumns: "minmax(0, 1fr)", - gridTemplateRows: "minmax(0, 1fr)", - } - : { - gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)", - gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`, - }; - case "compact": - return { - gridTemplateColumns: "minmax(0, 1fr)", - gridTemplateRows: "minmax(0, 1fr)", - }; - } -} - -interface BspCell { - columnStart: number; - columnEnd: number; - rowStart: number; - rowEnd: number; -} - -function bspPaneCells(paneCount: number): BspCell[] { - const cells: BspCell[] = [{ - columnStart: 1, - columnEnd: 5, - rowStart: 1, - rowEnd: 5, - }]; - for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) { - const leaf = cells.pop(); - if (!leaf) break; - if (paneIndex % 2 === 1) { - const midpoint = (leaf.columnStart + leaf.columnEnd) / 2; - cells.push( - { ...leaf, columnEnd: midpoint }, - { ...leaf, columnStart: midpoint }, - ); - } else { - const midpoint = (leaf.rowStart + leaf.rowEnd) / 2; - cells.push( - { ...leaf, rowEnd: midpoint }, - { ...leaf, rowStart: midpoint }, - ); - } - } - return cells; -} - -function paneCellStyle( - layout: EffectiveWorkbenchLayout, - paneCount: number, - index: number, -): CSSProperties | undefined { - if (layout === "bsp") { - const cell = bspPaneCells(paneCount)[index]; - return cell - ? { - gridColumn: `${cell.columnStart} / ${cell.columnEnd}`, - gridRow: `${cell.rowStart} / ${cell.rowEnd}`, - } - : undefined; - } - if (layout === "main-stack" && paneCount >= 2) { - return index === 0 - ? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` } - : { gridColumn: 2, gridRow: index }; - } - return undefined; -} - function isPaneAction(target: EventTarget | null): boolean { return target instanceof Element && target.closest("[data-workbench-pane-action]") !== null; @@ -293,6 +202,8 @@ export function PaneWorkbench({ onAddPane, onLayoutChange, onPaneOrderChange, + splitRatios = EMPTY_SPLIT_RATIOS, + onSplitRatiosChange = IGNORE_SPLIT_RATIO_CHANGE, renderPane, }: PaneWorkbenchProps) { const { t } = useTranslation(); @@ -300,10 +211,20 @@ export function PaneWorkbench({ const effectiveLayout: EffectiveWorkbenchLayout = compact ? "compact" : layout; const [headerPortalTarget, setHeaderPortalTarget] = useState(null); const [composerPortalTarget, setComposerPortalTarget] = useState(null); + const gridRef = useRef(null); const paneRefs = useRef(new Map()); const lastRectsRef = useRef(new Map()); const pendingRectsRef = useRef | null>(null); const animationsRef = useRef(new Map()); + 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(null); const sourcePaneOrder = useMemo( () => panes.map((pane) => pane.key), [panes], @@ -337,6 +258,12 @@ export function PaneWorkbench({ setPreviewPaneKeys(sourcePaneOrder); }, [sourcePaneOrder, sourcePaneOrderKey]); + useEffect(() => { + if (resizeGestureRef.current) return; + previewSplitRatiosRef.current = splitRatios; + setPreviewSplitRatios(splitRatios); + }, [sourceSplitRatiosKey, splitRatios]); + const measurePanes = useCallback(() => { const rects = new Map(); for (const [key, element] of paneRefs.current) { @@ -548,7 +475,119 @@ export function PaneWorkbench({ onPaneOrderChange(next); }, [captureLayout, measurePanes, onPaneOrderChange]); - const gridStyle = paneGridStyle(effectiveLayout, panes.length); + const layoutGeometry = useMemo(() => createWorkbenchLayoutGeometry( + effectiveLayout, + panes.length, + previewSplitRatios, + ), [effectiveLayout, panes.length, previewSplitRatios]); + + const handleResizePointerDown = useCallback(( + handle: WorkbenchResizeHandle, + event: ReactPointerEvent, + ) => { + 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, + ) => { + 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 ? ( @@ -629,8 +668,9 @@ export function PaneWorkbench({ /> ) : null} -
+
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={paneCellStyle(effectiveLayout, panes.length, index)} + style={layoutGeometry.paneStyles[index]} > {renderPane(pane, { active, @@ -713,6 +753,53 @@ export function PaneWorkbench({ ); })}
+ {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 ( + + ); + }, + ) : null}
{chrome ? ( diff --git a/webui/src/components/workbench/workbench-layout.ts b/webui/src/components/workbench/workbench-layout.ts new file mode 100644 index 000000000..bb33fde9d --- /dev/null +++ b/webui/src/components/workbench/workbench-layout.ts @@ -0,0 +1,434 @@ +import type { CSSProperties } from "react"; + +import type { WorkbenchLayout } from "@/components/workbench/workbench-model"; + +export type EffectiveWorkbenchLayout = WorkbenchLayout | "compact"; + +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; + 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: EffectiveWorkbenchLayout, + paneCount: number, + splitRatios: readonly number[], +): WorkbenchLayoutGeometry { + const count = Math.max(1, paneCount); + if (layout === "compact" || 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%)", + }; +} diff --git a/webui/src/components/workbench/workbench-model.ts b/webui/src/components/workbench/workbench-model.ts index ba456ee4b..947938c77 100644 --- a/webui/src/components/workbench/workbench-model.ts +++ b/webui/src/components/workbench/workbench-model.ts @@ -53,6 +53,14 @@ function normalizeTitle(value: unknown): string | null { 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 @@ -75,6 +83,7 @@ function normalizeTab(value: unknown): WorkbenchTabState { ? candidate.activePaneKey : paneKeys[0] ?? "", layout: isLayout(candidate.layout) ? candidate.layout : "columns", + splitRatios: normalizeSplitRatios(candidate.splitRatios), }; } @@ -104,6 +113,7 @@ function defaultWorkbenchTab( layoutPaneKeys: [paneKey], activePaneKey: paneKey, layout: "columns", + splitRatios: [], }; } @@ -222,6 +232,7 @@ export function detachWorkbenchPane( explicit: false, title: null, layout: "columns", + splitRatios: [], })) : state; } @@ -241,6 +252,7 @@ export function detachWorkbenchPane( activePaneKey: tab.activePaneKey === paneKey ? paneKeys[Math.min(index, paneKeys.length - 1)] : tab.activePaneKey, + splitRatios: [], }, [nextTabKey]: defaultWorkbenchTab(paneKey), }, @@ -260,6 +272,7 @@ export function dissolveWorkbenchTab( explicit: false, title: null, layout: "columns", + splitRatios: [], })) : state; } @@ -309,6 +322,7 @@ export function attachWorkbenchPane( activePaneKey: sourceTab.activePaneKey === paneKey ? sourcePaneKeys[Math.min(index, sourcePaneKeys.length - 1)] : sourceTab.activePaneKey, + splitRatios: [], }; } } @@ -326,6 +340,7 @@ export function attachWorkbenchPane( paneKeys, layoutPaneKeys, activePaneKey: paneKey, + splitRatios: [], }; return { version: 1, tabs }; } @@ -348,10 +363,24 @@ export function setWorkbenchLayout( layout: WorkbenchLayout, ): WorkbenchState { return updateTab(state, tabKey, (tab) => ( - tab.layout === layout ? tab : { ...tab, layout } + 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, @@ -392,6 +421,10 @@ export function reconcileWorkbench( activePaneKey: paneKeys.includes(tab.activePaneKey) ? tab.activePaneKey : paneKeys[0], + splitRatios: paneKeys.length === tab.paneKeys.length + && paneKeys.every((key, index) => key === tab.paneKeys[index]) + ? tab.splitRatios + : [], }; } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 26a0f5454..de4c1e91a 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -1424,6 +1424,7 @@ "addPane": "Add pane", "movePane": "Move {{title}} pane", "movePaneHint": "Drag to move · Arrow keys also work", + "resizePaneBoundary": "Resize pane boundary {{index}}", "promotePane": "Make {{title}} the primary pane", "paneActions": "{{title}} pane actions", "detachPane": "Remove", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 78d927633..3fd213d30 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -1411,6 +1411,7 @@ "addPane": "Añadir panel", "movePane": "Mover panel {{title}}", "movePaneHint": "Arrastra para mover · También puedes usar las flechas", + "resizePaneBoundary": "Redimensionar límite de panel {{index}}", "promotePane": "Convertir {{title}} en el panel principal", "paneActions": "Acciones del panel {{title}}", "detachPane": "Quitar", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 526c873ed..fdd55e524 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -1410,6 +1410,7 @@ "addPane": "Ajouter un volet", "movePane": "Déplacer le volet {{title}}", "movePaneHint": "Faites glisser pour déplacer · Les flèches fonctionnent aussi", + "resizePaneBoundary": "Redimensionner la séparation {{index}}", "promotePane": "Définir {{title}} comme volet principal", "paneActions": "Actions du volet {{title}}", "detachPane": "Retirer", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index ba259996a..c3acde9b1 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -1410,6 +1410,7 @@ "addPane": "Tambah panel", "movePane": "Pindahkan panel {{title}}", "movePaneHint": "Seret untuk memindahkan · Tombol panah juga dapat digunakan", + "resizePaneBoundary": "Ubah ukuran batas panel {{index}}", "promotePane": "Jadikan {{title}} panel utama", "paneActions": "Tindakan panel {{title}}", "detachPane": "Keluarkan", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 5a9d1af1b..d8758a99f 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -1410,6 +1410,7 @@ "addPane": "ペインを追加", "movePane": "{{title}} ペインを移動", "movePaneHint": "ドラッグで移動 · 矢印キーでも移動できます", + "resizePaneBoundary": "ペイン境界 {{index}} のサイズを変更", "promotePane": "{{title}} をメインペインにする", "paneActions": "{{title}} ペインの操作", "detachPane": "外す", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 840f48016..389c7731b 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -1410,6 +1410,7 @@ "addPane": "창 추가", "movePane": "{{title}} 창 이동", "movePaneHint": "드래그하여 이동 · 방향키로도 이동 가능", + "resizePaneBoundary": "창 경계 {{index}} 크기 조절", "promotePane": "{{title}}을(를) 기본 창으로 설정", "paneActions": "{{title}} 창 작업", "detachPane": "제거", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index a39f09214..90495f13a 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -1424,6 +1424,7 @@ "addPane": "Adicionar painel", "movePane": "Mover painel {{title}}", "movePaneHint": "Arraste para mover · As setas também funcionam", + "resizePaneBoundary": "Redimensionar limite do painel {{index}}", "promotePane": "Tornar {{title}} o painel principal", "paneActions": "Ações do painel {{title}}", "detachPane": "Remover", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 4e667accc..9ac94e817 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -1410,6 +1410,7 @@ "addPane": "Thêm khung", "movePane": "Di chuyển khung {{title}}", "movePaneHint": "Kéo để di chuyển · Cũng có thể dùng các phím mũi tên", + "resizePaneBoundary": "Đổi kích thước ranh giới khung {{index}}", "promotePane": "Đặt {{title}} làm khung chính", "paneActions": "Thao tác cho khung {{title}}", "detachPane": "Gỡ", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index deb66aae4..59111ff44 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -1424,6 +1424,7 @@ "addPane": "添加窗格", "movePane": "移动 {{title}} 窗格", "movePaneHint": "拖动换位 · 也可以使用方向键", + "resizePaneBoundary": "调整窗格边界 {{index}}", "promotePane": "将 {{title}} 设为主窗格", "paneActions": "{{title}} 窗格操作", "detachPane": "移出", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 60d12eddf..164bd9525 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -1410,6 +1410,7 @@ "addPane": "新增窗格", "movePane": "移動 {{title}} 窗格", "movePaneHint": "拖曳換位 · 也可以使用方向鍵", + "resizePaneBoundary": "調整窗格邊界 {{index}}", "promotePane": "將 {{title}} 設為主窗格", "paneActions": "{{title}} 窗格操作", "detachPane": "移出", diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 4b557ea96..218b2dea5 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -377,6 +377,7 @@ export interface WorkbenchTabState { layoutPaneKeys: string[]; activePaneKey: string; layout: WorkbenchLayout; + splitRatios: number[]; } export interface WorkbenchState { diff --git a/webui/src/tests/pane-workbench.test.tsx b/webui/src/tests/pane-workbench.test.tsx index d8816a631..c889e7b9c 100644 --- a/webui/src/tests/pane-workbench.test.tsx +++ b/webui/src/tests/pane-workbench.test.tsx @@ -32,9 +32,11 @@ function rect(left: number, top: number, width: number, height: number): DOMRect function WorkbenchHarness({ initialLayout = "columns", onPaneOrderChange = () => {}, + onSplitRatiosChange = () => {}, }: { initialLayout?: "columns" | "rows"; onPaneOrderChange?: (paneKeys: string[]) => void; + onSplitRatiosChange?: (splitRatios: number[]) => void; } = {}) { const [state, setState] = useState(() => { const initial = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "alpha"); @@ -55,6 +57,7 @@ function WorkbenchHarness({ panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))} activePaneKey={tab.activePaneKey} layout={tab.layout} + splitRatios={tab.splitRatios} showLayoutControl onActivatePane={(key) => setState((current) => ( focusWorkbenchPane(current, tabKey, key) @@ -69,6 +72,7 @@ function WorkbenchHarness({ setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys) )); }} + onSplitRatiosChange={onSplitRatiosChange} renderPane={(pane, context) => ( <> @@ -98,11 +102,13 @@ function BspWorkbenchHarness() { panes={panes} activePaneKey="delta" layout="bsp" + splitRatios={[]} showLayoutControl onActivatePane={vi.fn()} onAddPane={vi.fn()} onLayoutChange={vi.fn()} onPaneOrderChange={vi.fn()} + onSplitRatiosChange={vi.fn()} renderPane={(pane) => {pane.title}} /> ); @@ -129,6 +135,7 @@ describe("PaneWorkbench", () => { }))); HTMLElement.prototype.animate = animate; HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() { + if (this.dataset.testid === "pane-grid") return rect(0, 0, 1000, 1000); if (!this.classList.contains("workbench-pane")) { return originalGetBoundingClientRect.call(this); } @@ -250,6 +257,48 @@ describe("PaneWorkbench", () => { .toEqual(["Beta", "Alpha"]); }); + it("previews edge resizing locally and commits one ratio when dragging ends", () => { + const onSplitRatiosChange = vi.fn(); + render(); + + const grid = screen.getByTestId("pane-grid"); + const separator = screen.getByRole("separator", { + name: "Resize pane boundary 1", + }); + fireEvent.pointerDown(separator, { + button: 0, + pointerId: 7, + clientX: 500, + clientY: 400, + }); + fireEvent.pointerMove(window, { + buttons: 1, + pointerId: 7, + clientX: 700, + clientY: 400, + }); + + expect(grid.style.gridTemplateColumns) + .toBe("minmax(0, 700fr) minmax(0, 300fr)"); + expect(onSplitRatiosChange).not.toHaveBeenCalled(); + + fireEvent.pointerUp(window, { pointerId: 7, clientX: 700, clientY: 400 }); + expect(onSplitRatiosChange).toHaveBeenCalledOnce(); + expect(onSplitRatiosChange).toHaveBeenCalledWith([0.7]); + }); + + it("resizes a pane boundary with the matching arrow keys", () => { + const onSplitRatiosChange = vi.fn(); + render(); + + const separator = screen.getByRole("separator", { + name: "Resize pane boundary 1", + }); + fireEvent.keyDown(separator, { key: "ArrowLeft" }); + + expect(onSplitRatiosChange).toHaveBeenCalledWith([0.47]); + }); + it("keeps one shared layout control and animates geometry changes", async () => { render(); @@ -279,9 +328,9 @@ describe("PaneWorkbench", () => { const beta = screen.getByTestId("workbench-pane-beta"); const gamma = screen.getByTestId("workbench-pane-gamma"); const delta = screen.getByTestId("workbench-pane-delta"); - expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 3", "1 / 5"]); - expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["3 / 5", "1 / 3"]); - expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["3 / 4", "3 / 5"]); - expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["4 / 5", "3 / 5"]); + expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 2", "1 / 3"]); + expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["2 / 4", "1 / 2"]); + expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["2 / 3", "2 / 3"]); + expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["3 / 4", "2 / 3"]); }); }); diff --git a/webui/src/tests/workbench-model.test.ts b/webui/src/tests/workbench-model.test.ts index a58b6ef2f..84ef9556d 100644 --- a/webui/src/tests/workbench-model.test.ts +++ b/webui/src/tests/workbench-model.test.ts @@ -16,6 +16,7 @@ import { renameWorkbenchTab, setWorkbenchLayout, setWorkbenchPaneLayoutOrder, + setWorkbenchSplitRatios, workbenchTab, workbenchTabForPane, type WorkbenchState, @@ -41,6 +42,7 @@ describe("workbench model", () => { layoutPaneKeys: ["pane-a"], activePaneKey: "pane-a", layout: "columns", + splitRatios: [], }); }); @@ -60,6 +62,7 @@ describe("workbench model", () => { layoutPaneKeys: ["pane-a", "pane-c"], activePaneKey: "pane-c", layout: "main-stack", + splitRatios: [], }); expect(workbenchTab(state, betaTabKey)).toEqual({ explicit: false, @@ -68,6 +71,7 @@ describe("workbench model", () => { layoutPaneKeys: ["pane-b"], activePaneKey: "pane-b", layout: "columns", + splitRatios: [], }); }); @@ -117,6 +121,7 @@ describe("workbench model", () => { layoutPaneKeys: ["pane-a"], activePaneKey: "pane-a", layout: "columns", + splitRatios: [], }); expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]); expect(workbenchTabForPane(state, "pane-b").tab.paneKeys).toEqual(["pane-b"]); @@ -143,6 +148,7 @@ describe("workbench model", () => { layoutPaneKeys: ["pane-a"], activePaneKey: "pane-a", layout: "columns", + splitRatios: [], }); }); @@ -190,6 +196,21 @@ describe("workbench model", () => { expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]); }); + it("stores resize ratios in the tab and resets them when its geometry changes", () => { + let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); + const tabKey = workbenchTabForPane(state, "pane-a").tabKey; + state = addWorkbenchPane(state, tabKey, "pane-b"); + state = setWorkbenchSplitRatios(state, tabKey, [0.35]); + + expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([0.35]); + state = setWorkbenchLayout(state, tabKey, "rows"); + expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]); + + state = setWorkbenchSplitRatios(state, tabKey, [0.4]); + state = detachWorkbenchPane(state, tabKey, "pane-b"); + expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]); + }); + it("keeps each tab contiguous and ranks it by its latest updated pane", () => { let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a"); const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey; @@ -270,6 +291,7 @@ describe("workbench model", () => { layoutPaneKeys: ["pane-a", "pane-b"], activePaneKey: "pane-a", layout: "columns", + splitRatios: [], }); expect(workbenchTab(reconciled, "duplicate")).toBeNull(); expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]);