mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 14:58:39 +03:00
feat(webui): resize workbench panes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<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],
|
||||
@@ -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<string, DOMRect>();
|
||||
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<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 ? (
|
||||
@@ -629,8 +668,9 @@ export function PaneWorkbench({
|
||||
/>
|
||||
</header>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 bg-background">
|
||||
<div className="relative min-h-0 flex-1 bg-background">
|
||||
<div
|
||||
ref={gridRef}
|
||||
data-testid="pane-grid"
|
||||
data-layout={effectiveLayout}
|
||||
className={cn(
|
||||
@@ -658,7 +698,7 @@ export function PaneWorkbench({
|
||||
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={paneCellStyle(effectiveLayout, panes.length, index)}
|
||||
style={layoutGeometry.paneStyles[index]}
|
||||
>
|
||||
{renderPane(pane, {
|
||||
active,
|
||||
@@ -713,6 +753,53 @@ export function PaneWorkbench({
|
||||
);
|
||||
})}
|
||||
</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 ? (
|
||||
|
||||
@@ -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<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: 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%)",
|
||||
};
|
||||
}
|
||||
@@ -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<WorkbenchTabState>
|
||||
@@ -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
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"addPane": "ペインを追加",
|
||||
"movePane": "{{title}} ペインを移動",
|
||||
"movePaneHint": "ドラッグで移動 · 矢印キーでも移動できます",
|
||||
"resizePaneBoundary": "ペイン境界 {{index}} のサイズを変更",
|
||||
"promotePane": "{{title}} をメインペインにする",
|
||||
"paneActions": "{{title}} ペインの操作",
|
||||
"detachPane": "外す",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"addPane": "창 추가",
|
||||
"movePane": "{{title}} 창 이동",
|
||||
"movePaneHint": "드래그하여 이동 · 방향키로도 이동 가능",
|
||||
"resizePaneBoundary": "창 경계 {{index}} 크기 조절",
|
||||
"promotePane": "{{title}}을(를) 기본 창으로 설정",
|
||||
"paneActions": "{{title}} 창 작업",
|
||||
"detachPane": "제거",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ỡ",
|
||||
|
||||
@@ -1424,6 +1424,7 @@
|
||||
"addPane": "添加窗格",
|
||||
"movePane": "移动 {{title}} 窗格",
|
||||
"movePaneHint": "拖动换位 · 也可以使用方向键",
|
||||
"resizePaneBoundary": "调整窗格边界 {{index}}",
|
||||
"promotePane": "将 {{title}} 设为主窗格",
|
||||
"paneActions": "{{title}} 窗格操作",
|
||||
"detachPane": "移出",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"addPane": "新增窗格",
|
||||
"movePane": "移動 {{title}} 窗格",
|
||||
"movePaneHint": "拖曳換位 · 也可以使用方向鍵",
|
||||
"resizePaneBoundary": "調整窗格邊界 {{index}}",
|
||||
"promotePane": "將 {{title}} 設為主窗格",
|
||||
"paneActions": "{{title}} 窗格操作",
|
||||
"detachPane": "移出",
|
||||
|
||||
@@ -377,6 +377,7 @@ export interface WorkbenchTabState {
|
||||
layoutPaneKeys: string[];
|
||||
activePaneKey: string;
|
||||
layout: WorkbenchLayout;
|
||||
splitRatios: number[];
|
||||
}
|
||||
|
||||
export interface WorkbenchState {
|
||||
|
||||
@@ -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) => (
|
||||
<>
|
||||
<button type="button">Focus {pane.title}</button>
|
||||
@@ -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) => <span>{pane.title}</span>}
|
||||
/>
|
||||
);
|
||||
@@ -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(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
||||
|
||||
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(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
||||
|
||||
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(<WorkbenchHarness />);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user