Add split-pane layout store and navigation shortcuts

This commit is contained in:
Mohamed Boudra
2026-03-18 01:36:54 +07:00
parent 30dd54a318
commit 30a225ce9f
22 changed files with 4710 additions and 375 deletions
@@ -0,0 +1,154 @@
import { useCallback, useRef, useState } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export interface ResizeHandleProps {
direction: "horizontal" | "vertical";
groupId: string;
index: number;
sizes: number[];
onResizeSplit: (groupId: string, sizes: number[]) => void;
}
interface PointerState {
containerSize: number;
pointerStart: number;
leftSize: number;
rightSize: number;
}
export function ResizeHandle({
direction,
groupId,
index,
sizes,
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const pointerStateRef = useRef<PointerState | null>(null);
const [hovered, setHovered] = useState(false);
const handlePointerDown = useCallback(
(event: any) => {
const handleElement = event.currentTarget as HTMLElement | null;
const containerElement = handleElement?.parentElement ?? null;
if (!containerElement) {
return;
}
const rect = containerElement.getBoundingClientRect();
const containerSize = direction === "horizontal" ? rect.width : rect.height;
if (containerSize <= 0) {
return;
}
pointerStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
leftSize: sizes[index] ?? 0,
rightSize: sizes[index + 1] ?? 0,
};
const previousCursor = document.body.style.cursor;
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
document.body.style.cursor = nextCursor;
event.preventDefault();
function cleanup() {
pointerStateRef.current = null;
document.body.style.cursor = previousCursor;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(moveEvent: PointerEvent) {
const pointerState = pointerStateRef.current;
if (!pointerState) {
return;
}
const pointerCurrent =
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
const nextSizes = sizes.slice();
nextSizes[index] = pointerState.leftSize + deltaRatio;
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
onResizeSplit(groupId, nextSizes);
}
function handlePointerUp() {
cleanup();
}
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[direction, groupId, index, onResizeSplit, sizes]
);
return (
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={[
styles.handle,
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
hovered && {
backgroundColor: theme.colors.surface2,
},
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
} as any,
]}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
setHovered(true);
}}
onPointerLeave={() => {
setHovered(false);
}}
>
<View
style={[
styles.handleGrip,
direction === "horizontal" ? styles.handleGripHorizontal : styles.handleGripVertical,
{
backgroundColor: hovered ? theme.colors.accent : theme.colors.border,
},
]}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
handle: {
position: "relative",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
backgroundColor: "transparent",
},
handleHorizontal: {
width: 4,
alignSelf: "stretch",
},
handleVertical: {
height: 4,
width: "100%",
},
handleGrip: {
opacity: 0.6,
borderRadius: theme.borderRadius.full,
},
handleGripHorizontal: {
width: 2,
height: "100%",
},
handleGripVertical: {
width: "100%",
height: 2,
},
}));
@@ -12,6 +12,9 @@ export function SortableInlineList<T>({
onDragEnd?: (data: T[]) => void;
useDragHandle?: boolean;
disabled?: boolean;
externalDndContext?: boolean;
activeId?: string | null;
getItemData?: (item: T, index: number) => Record<string, unknown>;
}): ReactElement {
return (
<>
@@ -33,6 +33,7 @@ function SortableItem<T>({
activeId,
useDragHandle,
disabled,
itemData,
}: {
id: string;
item: T;
@@ -41,6 +42,7 @@ function SortableItem<T>({
activeId: string | null;
useDragHandle: boolean;
disabled: boolean;
itemData?: Record<string, unknown>;
}): ReactElement {
const {
attributes,
@@ -50,7 +52,7 @@ function SortableItem<T>({
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
} = useSortable({ id, disabled, data: itemData });
const drag = useCallback(() => {
// dnd-kit handles drag initiation via listeners
@@ -107,6 +109,9 @@ export function SortableInlineList<T>({
disabled = false,
activationDistance = 8,
onDragBegin,
externalDndContext = false,
activeId: externalActiveId = null,
getItemData,
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
@@ -116,10 +121,13 @@ export function SortableInlineList<T>({
disabled?: boolean;
activationDistance?: number;
onDragBegin?: () => void;
externalDndContext?: boolean;
activeId?: string | null;
getItemData?: (item: T, index: number) => Record<string, unknown>;
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const items = externalDndContext ? data : dragItems ?? data;
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -175,6 +183,31 @@ export function SortableInlineList<T>({
const ids = items.map((item, index) => keyExtractor(item, index));
const renderedItems = (
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={externalDndContext ? externalActiveId : activeId}
useDragHandle={useDragHandle}
disabled={disabled}
itemData={getItemData?.(item, index)}
/>
);
})}
</SortableContext>
);
if (externalDndContext) {
return renderedItems;
}
return (
<DndContext
sensors={sensors}
@@ -183,23 +216,7 @@ export function SortableInlineList<T>({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
disabled={disabled}
/>
);
})}
</SortableContext>
{renderedItems}
</DndContext>
);
}
@@ -0,0 +1,663 @@
import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
pointerWithin,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
interface SplitContainerProps {
layout: WorkspaceLayout;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
killTerminalPending: boolean;
killTerminalId: string | null;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (optionId: "__new_tab_agent__") => void;
newTabAgentOptionId?: "__new_tab_agent__";
onOpenPaneTab: (input: { paneId: string; target: WorkspaceTabTarget }) => void;
onRetargetTab: (tabId: string, target: WorkspaceTabTarget) => void;
onOpenWorkspaceFile: (input: { paneId: string; filePath: string }) => void;
onFocusPane: (paneId: string) => void;
onSplitPane: (input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
}
interface WorkspaceTabDragData {
kind: "workspace-tab";
paneId: string;
tabId: string;
}
interface SplitPaneDropData {
kind: "split-pane-drop";
paneId: string;
}
interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout" | "workspaceKey" | "uiTabs"> {
node: SplitNode;
tabsById: Map<string, WorkspaceTab>;
focusedPaneId: string;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
onDropPreviewChange: (hover: SplitDropZoneHover | null) => void;
}
interface SplitPaneViewProps
extends Omit<
SplitNodeViewProps,
| "node"
| "tabsById"
| "focusedPaneId"
| "activeDragTabId"
| "showDropZones"
| "dropPreview"
| "onDropPreviewChange"
| "onSplitPane"
| "onMoveTabToPane"
| "onResizeSplit"
> {
pane: SplitPane;
tabsById: Map<string, WorkspaceTab>;
focused: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
onDropPreviewChange: (hover: SplitDropZoneHover | null) => void;
}
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab"
);
if (tabHits.length > 0) {
return tabHits;
}
const paneHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop"
);
if (paneHits.length > 0) {
return paneHits;
}
return closestCenter(args);
};
export function SplitContainer({
layout,
normalizedServerId,
normalizedWorkspaceId,
uiTabs,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId = "__new_tab_agent__",
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane,
onSplitPane,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
}: SplitContainerProps) {
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const tabsById = useMemo(() => {
const next = new Map<string, WorkspaceTab>();
for (const tab of uiTabs) {
next.set(tab.tabId, tab);
}
return next;
}, [uiTabs]);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current as WorkspaceTabDragData | undefined;
if (data?.kind !== "workspace-tab") {
setActiveDragTabId(null);
setDropPreview(null);
return;
}
setActiveDragTabId(data.tabId);
}, []);
const handleDragCancel = useCallback(() => {
setActiveDragTabId(null);
setDropPreview(null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
| SplitPaneDropData
| undefined;
setActiveDragTabId(null);
if (activeData?.kind !== "workspace-tab" || !event.over) {
setDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const sourcePane = panesById.get(activeData.paneId) ?? null;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
setDropPreview(null);
return;
}
const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById);
const targetTabs = getPaneTabDescriptors(targetPane, tabsById);
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
if (sourceIndex < 0 || targetIndex < 0) {
setDropPreview(null);
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== targetIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, targetIndex);
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
}
setDropPreview(null);
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(targetIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
setDropPreview(null);
return;
}
if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) {
if (dropPreview.position === "center") {
if (activeData.paneId !== overData.paneId) {
onMoveTabToPane(activeData.tabId, overData.paneId);
}
setDropPreview(null);
return;
}
onSplitPane({
tabId: activeData.tabId,
targetPaneId: overData.paneId,
position: dropPreview.position,
});
}
setDropPreview(null);
},
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById]
);
return (
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={layout.root}
tabsById={tabsById}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
onDropPreviewChange={setDropPreview}
/>
</DndContext>
);
}
function SplitNodeView({
node,
tabsById,
focusedPaneId,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId,
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane,
onSplitPane,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
onDropPreviewChange,
}: SplitNodeViewProps) {
if (node.kind === "pane") {
return (
<SplitPaneView
pane={node.pane}
tabsById={tabsById}
focused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
onDropPreviewChange={onDropPreviewChange}
/>
);
}
return (
<View
style={[
styles.group,
node.group.direction === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
]}
>
{node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}>
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}>
<SplitNodeView
node={child}
tabsById={tabsById}
focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
onDropPreviewChange={onDropPreviewChange}
/>
</View>
{index < node.group.children.length - 1 ? (
<ResizeHandle
direction={node.group.direction}
groupId={node.group.id}
index={index}
sizes={node.group.sizes}
onResizeSplit={onResizeSplit}
/>
) : null}
</Fragment>
))}
</View>
);
}
function SplitPaneView({
pane,
tabsById,
focused,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId,
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
onDropPreviewChange,
}: SplitPaneViewProps) {
const { theme } = useUnistyles();
const paneTabs = useMemo(() => getPaneTabDescriptors(pane, tabsById), [pane, tabsById]);
const paneModel = useMemo(
() =>
deriveWorkspaceTabModel({
tabs: paneTabs.map((tab) => ({
tabId: tab.tabId,
target: tab.target,
createdAt: 0,
})),
focusedTabId: pane.focusedTabId,
}),
[pane.focusedTabId, paneTabs]
);
const activeTabDescriptor = paneModel.activeTab
? paneTabs.find((tab) => tab.tabId === paneModel.activeTab?.descriptor.tabId) ?? null
: null;
return (
<View
style={[
styles.pane,
{
borderColor: focused ? theme.colors.borderAccent : theme.colors.border,
},
]}
onPointerDownCapture={() => {
onFocusPane(pane.id);
}}
>
<View
style={[
styles.paneTabs,
focused
? {
borderTopColor: theme.colors.accent,
}
: null,
]}
>
<WorkspaceDesktopTabsRow
paneId={pane.id}
tabs={paneTabs}
activeTabKey={activeTabDescriptor?.key ?? ""}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
}}
externalDndContext
activeDragTabId={activeDragTabId}
/>
</View>
<View style={styles.paneContent}>
{activeTabDescriptor ? (
<WorkspacePaneContent
tab={activeTabDescriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onOpenTab={(target) => {
onOpenPaneTab({
paneId: pane.id,
target,
});
}}
onCloseCurrentTab={() => {
void onCloseTab(activeTabDescriptor.tabId);
}}
onRetargetCurrentTab={(target) => {
onRetargetTab(activeTabDescriptor.tabId, target);
}}
onOpenWorkspaceFile={(filePath) => {
onOpenWorkspaceFile({
paneId: pane.id,
filePath,
});
}}
/>
) : (
renderPaneEmptyState?.() ?? null
)}
</View>
<SplitDropZone
paneId={pane.id}
active={showDropZones}
preview={dropPreview}
onHoverChange={onDropPreviewChange}
/>
</View>
);
}
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
const next = new Map<string, SplitPane>();
function visit(current: SplitNode) {
if (current.kind === "pane") {
next.set(current.pane.id, current.pane);
return;
}
for (const child of current.group.children) {
visit(child);
}
}
visit(node);
return next;
}
function getPaneTabDescriptors(
pane: SplitPane,
tabsById: Map<string, WorkspaceTab>
): WorkspaceTabDescriptor[] {
const next: WorkspaceTabDescriptor[] = [];
for (const tabId of pane.tabIds) {
const tab = tabsById.get(tabId);
if (!tab) {
continue;
}
next.push({
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
});
}
return next;
}
function getNodeKey(node: SplitNode): string {
if (node.kind === "pane") {
return node.pane.id;
}
return node.group.id;
}
const styles = StyleSheet.create((theme) => ({
group: {
flex: 1,
minWidth: 0,
minHeight: 0,
},
groupHorizontal: {
flexDirection: "row",
},
groupVertical: {
flexDirection: "column",
},
groupChild: {
flexBasis: 0,
minWidth: 0,
minHeight: 0,
},
pane: {
position: "relative",
flex: 1,
minWidth: 0,
minHeight: 0,
backgroundColor: theme.colors.surface0,
borderWidth: 1,
overflow: "hidden",
},
paneTabs: {
borderTopWidth: 2,
borderTopColor: "transparent",
},
paneContent: {
flex: 1,
minWidth: 0,
minHeight: 0,
},
}));
@@ -0,0 +1,214 @@
import { useCallback, useMemo, useState } from "react";
import { useDroppable } from "@dnd-kit/core";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom";
export interface SplitDropZoneHover {
paneId: string;
position: SplitDropZonePosition;
}
export interface SplitDropZoneProps {
paneId: string;
active: boolean;
preview: SplitDropZoneHover | null;
onHoverChange: (hover: SplitDropZoneHover | null) => void;
}
interface LayoutSize {
width: number;
height: number;
}
const EDGE_RATIO = 0.15;
const CENTER_RATIO = 0.4;
export function buildSplitDropZoneId(paneId: string): string {
return `split-pane-drop:${paneId}`;
}
export function SplitDropZone({
paneId,
active,
preview,
onHoverChange,
}: SplitDropZoneProps) {
const { theme } = useUnistyles();
const [layoutSize, setLayoutSize] = useState<LayoutSize>({ width: 0, height: 0 });
const { setNodeRef, isOver } = useDroppable({
id: buildSplitDropZoneId(paneId),
disabled: !active,
data: {
kind: "split-pane-drop",
paneId,
},
});
const handleLayout = useCallback((event: LayoutChangeEvent) => {
const width = Math.round(event.nativeEvent.layout.width);
const height = Math.round(event.nativeEvent.layout.height);
setLayoutSize((current) =>
current.width === width && current.height === height ? current : { width, height }
);
}, []);
const updateHover = useCallback(
(event: any) => {
if (!active || layoutSize.width <= 0 || layoutSize.height <= 0) {
return;
}
const locationX = Number(event.nativeEvent.locationX ?? 0);
const locationY = Number(event.nativeEvent.locationY ?? 0);
onHoverChange({
paneId,
position: resolveDropPosition({
width: layoutSize.width,
height: layoutSize.height,
x: locationX,
y: locationY,
}),
});
},
[active, layoutSize.height, layoutSize.width, onHoverChange, paneId]
);
const previewStyle = useMemo(() => {
if (!preview || preview.paneId !== paneId) {
return null;
}
return [
styles.preview,
getPreviewStyle(preview.position),
{
backgroundColor: theme.colors.accent,
},
];
}, [paneId, preview, theme.colors.accent]);
if (!active) {
return null;
}
return (
<View
ref={setNodeRef as any}
style={[styles.overlay, isOver && styles.overlayActive]}
onLayout={handleLayout}
onPointerEnter={updateHover}
onPointerMove={updateHover}
onPointerLeave={() => {
if (preview?.paneId === paneId) {
onHoverChange(null);
}
}}
>
{previewStyle ? <View pointerEvents="none" style={previewStyle} /> : null}
</View>
);
}
function resolveDropPosition(input: {
width: number;
height: number;
x: number;
y: number;
}): SplitDropZonePosition {
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
const insideCenterX =
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
const insideCenterY =
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
if (insideCenterX && insideCenterY) {
return "center";
}
const edgeThresholdX = input.width * EDGE_RATIO;
const edgeThresholdY = input.height * EDGE_RATIO;
if (input.x <= edgeThresholdX) {
return "left";
}
if (input.x >= input.width - edgeThresholdX) {
return "right";
}
if (input.y <= edgeThresholdY) {
return "top";
}
if (input.y >= input.height - edgeThresholdY) {
return "bottom";
}
const distances = [
{ position: "left", distance: input.x },
{ position: "right", distance: input.width - input.x },
{ position: "top", distance: input.y },
{ position: "bottom", distance: input.height - input.y },
] satisfies Array<{ position: Exclude<SplitDropZonePosition, "center">; distance: number }>;
distances.sort((left, right) => left.distance - right.distance);
return distances[0]?.position ?? "center";
}
function getPreviewStyle(position: SplitDropZonePosition) {
if (position === "left") {
return styles.previewLeft;
}
if (position === "right") {
return styles.previewRight;
}
if (position === "top") {
return styles.previewTop;
}
if (position === "bottom") {
return styles.previewBottom;
}
return styles.previewCenter;
}
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 40,
},
overlayActive: {
backgroundColor: theme.colors.surface0,
opacity: 0.02,
},
preview: {
position: "absolute",
borderRadius: theme.borderRadius.md,
opacity: 0.16,
},
previewLeft: {
left: 0,
top: 0,
bottom: 0,
width: "50%",
},
previewRight: {
right: 0,
top: 0,
bottom: 0,
width: "50%",
},
previewTop: {
left: 0,
top: 0,
right: 0,
height: "50%",
},
previewBottom: {
left: 0,
right: 0,
bottom: 0,
height: "50%",
},
previewCenter: {
left: "30%",
top: "30%",
right: "30%",
bottom: "30%",
},
}));
@@ -165,6 +165,21 @@ export function useKeyboardShortcuts({
scope: "workspace",
delta: input.payload.delta,
});
case "workspace.pane.split.right":
case "workspace.pane.split.down":
case "workspace.pane.focus.left":
case "workspace.pane.focus.right":
case "workspace.pane.focus.up":
case "workspace.pane.focus.down":
case "workspace.pane.move-tab.left":
case "workspace.pane.move-tab.right":
case "workspace.pane.move-tab.up":
case "workspace.pane.move-tab.down":
case "workspace.pane.close":
return keyboardActionDispatcher.dispatch({
id: input.action,
scope: "workspace",
});
case "workspace.navigate.index":
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
return false;
+11
View File
@@ -18,6 +18,17 @@ export type KeyboardActionId =
| "workspace.tab.close.current"
| "workspace.tab.navigate.index"
| "workspace.tab.navigate.relative"
| "workspace.pane.split.right"
| "workspace.pane.split.down"
| "workspace.pane.focus.left"
| "workspace.pane.focus.right"
| "workspace.pane.focus.up"
| "workspace.pane.focus.down"
| "workspace.pane.move-tab.left"
| "workspace.pane.move-tab.right"
| "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down"
| "workspace.pane.close"
| "workspace.navigate.index"
| "workspace.navigate.relative"
| "sidebar.toggle.left"
@@ -13,7 +13,18 @@ export type KeyboardActionId =
| "workspace.tab.new"
| "workspace.tab.close-current"
| "workspace.tab.navigate-index"
| "workspace.tab.navigate-relative";
| "workspace.tab.navigate-relative"
| "workspace.pane.split.right"
| "workspace.pane.split.down"
| "workspace.pane.focus.left"
| "workspace.pane.focus.right"
| "workspace.pane.focus.up"
| "workspace.pane.focus.down"
| "workspace.pane.move-tab.left"
| "workspace.pane.move-tab.right"
| "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down"
| "workspace.pane.close";
export type KeyboardActionDefinition =
| { id: "message-input.focus"; scope: KeyboardActionScope }
@@ -24,7 +35,18 @@ export type KeyboardActionDefinition =
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 };
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.left"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.close"; scope: KeyboardActionScope };
export type KeyboardActionHandler = {
handlerId: string;
@@ -169,6 +169,42 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isTauri: true },
action: "workspace.tab.close.current",
},
{
name: "matches Cmd+Backslash to split pane right on macOS",
event: { key: "\\", code: "Backslash", metaKey: true },
context: { isMac: true },
action: "workspace.pane.split.right",
},
{
name: "matches Cmd+Shift+Backslash to split pane down on macOS",
event: { key: "|", code: "Backslash", metaKey: true, shiftKey: true },
context: { isMac: true },
action: "workspace.pane.split.down",
},
{
name: "matches Cmd+Shift+ArrowRight to focus pane right on macOS",
event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true },
context: { isMac: true },
action: "workspace.pane.focus.right",
},
{
name: "matches Cmd+Shift+Alt+ArrowDown to move tab down on macOS",
event: {
key: "ArrowDown",
code: "ArrowDown",
metaKey: true,
shiftKey: true,
altKey: true,
},
context: { isMac: true },
action: "workspace.pane.move-tab.down",
},
{
name: "matches Cmd+Shift+W to close pane on macOS",
event: { key: "W", code: "KeyW", metaKey: true, shiftKey: true },
context: { isMac: true },
action: "workspace.pane.close",
},
{
name: "matches Cmd+B sidebar toggle on macOS",
event: { key: "b", code: "KeyB", metaKey: true },
@@ -242,6 +278,11 @@ describe("keyboard-shortcuts", () => {
event: { key: "d", code: "KeyD", metaKey: true },
context: { isMac: true, focusScope: "terminal" },
},
{
name: "does not bind pane shortcuts on non-mac platforms",
event: { key: "\\", code: "Backslash", ctrlKey: true },
context: { isMac: false },
},
{
name: "keeps space typing available in message input",
event: { key: " ", code: "Space" },
@@ -278,6 +319,8 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
},
{
@@ -289,6 +332,8 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["mod", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
},
{
@@ -71,6 +71,10 @@ function isMod(event: KeyboardEvent): boolean {
return event.metaKey || event.ctrlKey;
}
function isMacCommand(event: KeyboardEvent): boolean {
return event.metaKey && !event.ctrlKey;
}
function parseDigit(event: KeyboardEvent): number | null {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
@@ -373,6 +377,204 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
keys: ["alt", "shift", "]"],
},
},
{
id: "workspace-pane-split-right-cmd-backslash",
action: "workspace.pane.split.right",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
!event.shiftKey &&
event.code === "Backslash",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-split-right",
section: "global",
label: "Split pane right",
keys: ["mod", "\\"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-split-down-cmd-shift-backslash",
action: "workspace.pane.split.down",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "Backslash",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-split-down",
section: "global",
label: "Split pane down",
keys: ["mod", "shift", "\\"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-focus-left-cmd-shift-left",
action: "workspace.pane.focus.left",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "ArrowLeft",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-focus-left",
section: "global",
label: "Focus pane left",
keys: ["mod", "shift", "Left"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-focus-right-cmd-shift-right",
action: "workspace.pane.focus.right",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "ArrowRight",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-focus-right",
section: "global",
label: "Focus pane right",
keys: ["mod", "shift", "Right"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-focus-up-cmd-shift-up",
action: "workspace.pane.focus.up",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "ArrowUp",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-focus-up",
section: "global",
label: "Focus pane up",
keys: ["mod", "shift", "Up"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-focus-down-cmd-shift-down",
action: "workspace.pane.focus.down",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "ArrowDown",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-focus-down",
section: "global",
label: "Focus pane down",
keys: ["mod", "shift", "Down"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-move-tab-left-cmd-shift-alt-left",
action: "workspace.pane.move-tab.left",
matches: (event) =>
isMacCommand(event) &&
event.altKey &&
event.shiftKey &&
event.code === "ArrowLeft",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-move-tab-left",
section: "global",
label: "Move tab left",
keys: ["mod", "shift", "alt", "Left"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-move-tab-right-cmd-shift-alt-right",
action: "workspace.pane.move-tab.right",
matches: (event) =>
isMacCommand(event) &&
event.altKey &&
event.shiftKey &&
event.code === "ArrowRight",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-move-tab-right",
section: "global",
label: "Move tab right",
keys: ["mod", "shift", "alt", "Right"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-move-tab-up-cmd-shift-alt-up",
action: "workspace.pane.move-tab.up",
matches: (event) =>
isMacCommand(event) &&
event.altKey &&
event.shiftKey &&
event.code === "ArrowUp",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-move-tab-up",
section: "global",
label: "Move tab up",
keys: ["mod", "shift", "alt", "Up"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-move-tab-down-cmd-shift-alt-down",
action: "workspace.pane.move-tab.down",
matches: (event) =>
isMacCommand(event) &&
event.altKey &&
event.shiftKey &&
event.code === "ArrowDown",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-move-tab-down",
section: "global",
label: "Move tab down",
keys: ["mod", "shift", "alt", "Down"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-close-cmd-shift-w",
action: "workspace.pane.close",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-close",
section: "global",
label: "Close pane",
keys: ["mod", "shift", "W"],
when: (context) => context.isMac,
},
},
{
id: "command-center-toggle",
action: "command-center.toggle",
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
import { Plus, X } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -27,6 +27,7 @@ const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
type NewTabOptionId = "__new_tab_agent__";
type WorkspaceDesktopTabsRowProps = {
paneId?: string;
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
normalizedServerId: string;
@@ -47,6 +48,8 @@ type WorkspaceDesktopTabsRowProps = {
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
newTabAgentOptionId: NewTabOptionId;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
externalDndContext?: boolean;
activeDragTabId?: string | null;
};
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
@@ -59,7 +62,7 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "file") {
return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path;
}
return "";
return "Agent";
}
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
@@ -96,7 +99,6 @@ function TabChip({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onPresentationChange,
dragHandleProps,
}: {
tab: WorkspaceTabDescriptor;
@@ -119,19 +121,14 @@ function TabChip({
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onPresentationChange: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
dragHandleProps: any;
}) {
const { theme } = useUnistyles();
const presentation = useWorkspaceTabPresentation({
tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
useEffect(() => {
onPresentationChange(tab.key, presentation);
}, [onPresentationChange, presentation, tab.key]);
const { theme } = useUnistyles();
const tooltipLabel =
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
@@ -281,6 +278,7 @@ function TabChip({
}
export function WorkspaceDesktopTabsRow({
paneId,
tabs,
activeTabKey,
normalizedServerId,
@@ -301,47 +299,12 @@ export function WorkspaceDesktopTabsRow({
onSelectNewTabOption,
newTabAgentOptionId,
onReorderTabs,
externalDndContext = false,
activeDragTabId = null,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [presentationsByKey, setPresentationsByKey] = useState<Map<string, WorkspaceTabPresentation>>(
() => new Map()
);
const handlePresentationChange = useCallback(
(tabKey: string, presentation: WorkspaceTabPresentation) => {
setPresentationsByKey((current) => {
const existing = current.get(tabKey);
if (
existing?.label === presentation.label &&
existing?.subtitle === presentation.subtitle &&
existing?.titleState === presentation.titleState &&
existing?.statusBucket === presentation.statusBucket &&
existing?.icon === presentation.icon
) {
return current;
}
const next = new Map(current);
next.set(tabKey, presentation);
return next;
});
},
[]
);
useEffect(() => {
setPresentationsByKey((current) => {
const next = new Map<string, WorkspaceTabPresentation>();
for (const tab of tabs) {
const presentation = current.get(tab.key);
if (presentation) {
next.set(tab.key, presentation);
}
}
return next.size === current.size ? current : next;
});
}, [tabs]);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
@@ -371,14 +334,10 @@ export function WorkspaceDesktopTabsRow({
const tabLabelLengths = useMemo(
() =>
tabs.map((tab) => {
const presentation = presentationsByKey.get(tab.key);
if (presentation?.titleState === "loading") {
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
}
const label = presentation?.label ?? getFallbackTabLabel(tab);
const label = getFallbackTabLabel(tab);
return label.length;
}),
[layoutMetrics.estimatedCharWidth, presentationsByKey, tabs]
[tabs]
);
const { layout } = useWorkspaceTabLayout({
@@ -410,6 +369,17 @@ export function WorkspaceDesktopTabsRow({
useDragHandle
disabled={tabs.length < 2}
onDragEnd={onReorderTabs}
externalDndContext={externalDndContext}
activeId={activeDragTabId}
getItemData={
paneId
? (tab) => ({
kind: "workspace-tab",
paneId,
tabId: tab.tabId,
})
: undefined
}
renderItem={({ item: tab, index, dragHandleProps }) => {
const isActive = tab.key === activeTabKey;
const isCloseHovered = hoveredCloseTabKey === tab.key;
@@ -452,7 +422,6 @@ export function WorkspaceDesktopTabsRow({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onPresentationChange={handlePresentationChange}
dragHandleProps={dragHandleProps}
/>
);
@@ -0,0 +1,47 @@
import invariant from "tiny-invariant";
import { PaneProvider } from "@/panels/pane-context";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export interface WorkspacePaneContentProps {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
onOpenWorkspaceFile: (filePath: string) => void;
}
export function WorkspacePaneContent({
tab,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab,
onCloseCurrentTab,
onRetargetCurrentTab,
onOpenWorkspaceFile,
}: WorkspacePaneContentProps) {
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
const Component = registration.component;
return (
<PaneProvider
value={{
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
closeCurrentTab: onCloseCurrentTab,
retargetCurrentTab: onRetargetCurrentTab,
openFileInWorkspace: onOpenWorkspaceFile,
}}
>
<Component key={`${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`} />
</PaneProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -23,8 +23,7 @@ describe("deriveWorkspaceTabModel", () => {
];
const model = deriveWorkspaceTabModel({
tabs: uiTabs,
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
tabs: [uiTabs[0]!, uiTabs[2]!, uiTabs[1]!],
});
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
@@ -43,11 +42,10 @@ describe("deriveWorkspaceTabModel", () => {
it("applies stored order and appends unordered tabs deterministically", () => {
const model = deriveWorkspaceTabModel({
tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
],
tabOrder: ["terminal_term-1", "agent_agent-b"],
});
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
@@ -63,7 +61,6 @@ describe("deriveWorkspaceTabModel", () => {
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
],
tabOrder: ["agent_agent-a", "agent_agent-b"],
};
expect(
@@ -82,7 +79,6 @@ describe("deriveWorkspaceTabModel", () => {
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
],
tabOrder: ["agent_agent-a", "agent_agent-b"],
focusedTabId: "agent_agent-a",
preferredTarget: { kind: "agent", agentId: "agent-b" },
});
@@ -100,7 +96,6 @@ describe("deriveWorkspaceTabModel", () => {
createdAt: 1,
},
],
tabOrder: ["draft_abc"],
preferredTarget: { kind: "agent", agentId: "agent-1" },
});
@@ -123,7 +118,6 @@ describe("deriveWorkspaceTabModel", () => {
createdAt: 2,
},
],
tabOrder: [],
});
expect(model.tabs).toHaveLength(1);
@@ -111,52 +111,29 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
export function deriveWorkspaceTabModel(input: {
tabs: WorkspaceTab[];
tabOrder: string[];
focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null;
}): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>();
const tabs: WorkspaceDerivedTab[] = [];
const openTabIds = new Set<string>();
const normalizedTabs = input.tabs
.map((tab) => normalizeWorkspaceTab(tab))
.filter((tab): tab is WorkspaceTab => tab !== null)
.sort((left, right) => left.createdAt - right.createdAt);
for (const tab of input.tabs) {
const normalizedTab = normalizeWorkspaceTab(tab);
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
continue;
}
for (const tab of normalizedTabs) {
tabsById.set(tab.tabId, {
openTabIds.add(normalizedTab.tabId);
tabs.push({
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
key: normalizedTab.tabId,
tabId: normalizedTab.tabId,
kind: normalizedTab.target.kind,
target: normalizedTab.target,
},
});
}
const orderedTabIds: string[] = [];
const used = new Set<string>();
for (const tabId of input.tabOrder) {
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedTabId || used.has(normalizedTabId) || !tabsById.has(normalizedTabId)) {
continue;
}
used.add(normalizedTabId);
orderedTabIds.push(normalizedTabId);
}
for (const tabId of tabsById.keys()) {
if (used.has(tabId)) {
continue;
}
used.add(tabId);
orderedTabIds.push(tabId);
}
const tabs = orderedTabIds
.map((tabId) => tabsById.get(tabId) ?? null)
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
const focusedTabId = trimNonEmpty(input.focusedTabId);
const preferredTarget = input.preferredTarget ?? null;
const preferredTabId = (() => {
@@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from "react";
import { useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { Check } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -34,15 +34,26 @@ export function useWorkspaceTabPresentation(input: {
workspaceId: input.workspaceId,
});
return {
key: input.tab.key,
kind: input.tab.kind,
label: descriptor.label,
subtitle: descriptor.subtitle,
titleState: descriptor.titleState,
icon: descriptor.icon,
statusBucket: descriptor.statusBucket,
};
return useMemo(
() => ({
key: input.tab.key,
kind: input.tab.kind,
label: descriptor.label,
subtitle: descriptor.subtitle,
titleState: descriptor.titleState,
icon: descriptor.icon,
statusBucket: descriptor.statusBucket,
}),
[
descriptor.icon,
descriptor.label,
descriptor.statusBucket,
descriptor.subtitle,
descriptor.titleState,
input.tab.key,
input.tab.kind,
]
);
}
type WorkspaceTabIconProps = {
@@ -0,0 +1,610 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllPanes,
collectAllTabs,
createDefaultLayout,
findPaneById,
findPaneContainingTab,
getTreeDepth,
insertSplit,
removePaneFromTree,
removeTabFromTree,
useWorkspaceLayoutStore,
type SplitNode,
} from "@/stores/workspace-layout-store";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
function createTab(tabId: string): WorkspaceTab {
return {
tabId,
target: { kind: "draft", draftId: tabId },
createdAt: 1,
};
}
function createPane(input: {
id: string;
tabIds: string[];
focusedTabId?: string | null;
}): SplitNode {
const tabs = input.tabIds.map((tabId) => createTab(tabId));
return {
kind: "pane",
pane: {
id: input.id,
tabIds: input.tabIds,
focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null,
tabs,
} as any,
};
}
function createWorkspaceKey(): string {
const key = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
expect(key).toBeTruthy();
return key as string;
}
function expectGroup(node: SplitNode): Extract<SplitNode, { kind: "group" }> {
expect(node.kind).toBe("group");
return node as Extract<SplitNode, { kind: "group" }>;
}
describe("workspace-layout-store helpers", () => {
it("finds panes and tabs across nested groups", () => {
const root: SplitNode = {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.4, 0.6],
children: [
createPane({ id: "left", tabIds: ["tab-a", "tab-b"], focusedTabId: "tab-a" }),
{
kind: "group",
group: {
id: "group-right",
direction: "vertical",
sizes: [0.5, 0.5],
children: [
createPane({ id: "top-right", tabIds: ["tab-c"] }),
createPane({ id: "bottom-right", tabIds: ["tab-d"] }),
],
},
},
],
},
};
expect(findPaneById(root, "top-right")?.tabIds).toEqual(["tab-c"]);
expect(findPaneContainingTab(root, "tab-b")?.id).toBe("left");
expect(getTreeDepth(root)).toBe(3);
expect(collectAllPanes(root).map((pane) => pane.id)).toEqual([
"left",
"top-right",
"bottom-right",
]);
expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual([
"tab-a",
"tab-b",
"tab-c",
"tab-d",
]);
});
});
describe("workspace-layout-store tree transforms", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("insertSplit reuses a matching parent direction instead of nesting", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
const root: SplitNode = {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.25, 0.75],
children: [
createPane({ id: "left", tabIds: ["tab-a"] }),
createPane({ id: "right", tabIds: ["tab-b", "tab-c"] }),
],
},
};
const nextRoot = insertSplit(root, "right", "tab-c", "right");
const nextGroup = expectGroup(nextRoot);
expect(nextGroup.group.direction).toBe("horizontal");
expect(nextGroup.group.children).toHaveLength(3);
expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]);
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([
"left",
"right",
"pane_11111111-1111-1111-1111-111111111111",
]);
expect(findPaneById(nextRoot, "right")?.tabIds).toEqual(["tab-b"]);
expect(findPaneById(nextRoot, "pane_11111111-1111-1111-1111-111111111111")?.tabIds).toEqual([
"tab-c",
]);
});
it("removePaneFromTree unwraps single-child groups and renormalizes siblings", () => {
const root: SplitNode = {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.2, 0.8],
children: [
createPane({ id: "left", tabIds: ["tab-a"] }),
{
kind: "group",
group: {
id: "group-right",
direction: "vertical",
sizes: [0.5, 0.5],
children: [
createPane({ id: "top-right", tabIds: ["tab-b"] }),
createPane({ id: "bottom-right", tabIds: ["tab-c"] }),
],
},
},
],
},
};
const nextRoot = removePaneFromTree(root, "top-right");
const nextGroup = expectGroup(nextRoot);
expect(nextGroup.group.sizes).toEqual([0.2, 0.8]);
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual(["left", "bottom-right"]);
expect(nextGroup.group.children[1]).toEqual(createPane({ id: "bottom-right", tabIds: ["tab-c"] }));
});
it("removeTabFromTree collapses empty panes but keeps the final root pane", () => {
const splitRoot: SplitNode = {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.5, 0.5],
children: [
createPane({ id: "left", tabIds: ["tab-a"] }),
createPane({ id: "right", tabIds: ["tab-b"] }),
],
},
};
const collapsed = removeTabFromTree(splitRoot, "tab-a");
expect(collapsed).toEqual(createPane({ id: "right", tabIds: ["tab-b"] }));
const singlePaneRoot = createPane({ id: "main", tabIds: ["tab-a"] });
const emptied = removeTabFromTree(singlePaneRoot, "tab-a");
expect(emptied).toEqual(createPane({ id: "main", tabIds: [], focusedTabId: null }));
});
});
describe("workspace-layout-store actions", () => {
beforeEach(() => {
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
vi.restoreAllMocks();
});
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
expect(splitPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
store.focusPane(workspaceKey, "main");
const duplicateTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
expect(duplicateTabId).toBe(secondTabId);
expect(layout.focusedPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
"file_/repo/worktree/a.ts",
"file_/repo/worktree/b.ts",
]);
});
it("focusTab moves workspace focus to the pane containing the tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const fileTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const terminalTabId = store.openTab(workspaceKey, { kind: "terminal", terminalId: "term-1" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: terminalTabId!,
targetPaneId: "main",
position: "right",
});
store.focusTab(workspaceKey, fileTabId!);
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(layout.focusedPaneId).toBe("main");
store.focusTab(workspaceKey, terminalTabId!);
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId);
});
it("retargetTab updates the existing tab target without moving it to a different pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12121212-1212-1212-1212-121212121212"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
const nextTabId = store.retargetTab(workspaceKey, secondTabId!, {
kind: "agent",
agentId: "agent-1",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
const splitPane = findPaneById(layout.root, splitPaneId!);
const retargetedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === secondTabId);
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
expect(nextTabId).toBe(secondTabId);
expect(splitPane?.tabIds).toEqual([secondTabId!]);
expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId);
expect(retargetedTab).toEqual({
tabId: secondTabId,
target: { kind: "agent", agentId: "agent-1" },
createdAt: expect.any(Number),
});
});
it("reorderTabs reorders tabs within the focused pane", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(findPaneById(layout.root, "main")).toEqual({
id: "main",
tabIds: [thirdTabId!, firstTabId!, secondTabId!],
focusedTabId: thirdTabId,
tabs: [
{ tabId: thirdTabId, target: { kind: "file", path: "/repo/worktree/c.ts" }, createdAt: expect.any(Number) },
{ tabId: firstTabId, target: { kind: "file", path: "/repo/worktree/a.ts" }, createdAt: expect.any(Number) },
{ tabId: secondTabId, target: { kind: "file", path: "/repo/worktree/b.ts" }, createdAt: expect.any(Number) },
],
});
});
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"34343434-3434-3434-3434-343434343434"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
const fourthTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: thirdTabId!,
targetPaneId: "main",
position: "right",
});
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
store.focusPane(workspaceKey, "main");
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, splitPaneId!)).toEqual({
id: splitPaneId,
tabIds: [fourthTabId!, thirdTabId!],
focusedTabId: fourthTabId,
tabs: [
{ tabId: fourthTabId, target: { kind: "file", path: "/repo/worktree/d.ts" }, createdAt: expect.any(Number) },
{ tabId: thirdTabId, target: { kind: "file", path: "/repo/worktree/c.ts" }, createdAt: expect.any(Number) },
],
});
});
it("focusPane switches workspace focus to a different pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"56565656-5656-5656-5656-565656565656"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
store.focusPane(workspaceKey, "main");
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(layout.focusedPaneId).toBe("main");
store.focusPane(workspaceKey, splitPaneId!);
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
expect(layout.focusedPaneId).toBe(splitPaneId);
});
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"cccccccc-cccc-cccc-cccc-cccccccccccc"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
store.closeTab(workspaceKey, secondTabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
expect(layout.focusedPaneId).toBe("main");
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
});
it("splitPane enforces the maximum depth of four", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const a = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
const d = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" });
const e = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/e.ts" });
expect(a).toBeTruthy();
const pane1 = store.splitPane(workspaceKey, {
tabId: b!,
targetPaneId: "main",
position: "right",
});
const pane2 = store.splitPane(workspaceKey, {
tabId: c!,
targetPaneId: pane1!,
position: "bottom",
});
const pane3 = store.splitPane(workspaceKey, {
tabId: d!,
targetPaneId: pane2!,
position: "right",
});
const pane4 = store.splitPane(workspaceKey, {
tabId: e!,
targetPaneId: pane3!,
position: "bottom",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
expect(pane4).toBeNull();
expect(getTreeDepth(layout.root)).toBe(4);
});
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"dddddddd-dddd-dddd-dddd-dddddddddddd"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const leftTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const rightTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: rightTabId!,
targetPaneId: "main",
position: "right",
});
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
expect(findPaneById(layout.root, splitPaneId!)?.tabIds).toEqual([
"file_/repo/worktree/b.ts",
"file_/repo/worktree/a.ts",
]);
});
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
const paneBId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
const paneCId = store.splitPane(workspaceKey, {
tabId: thirdTabId!,
targetPaneId: paneBId!,
position: "bottom",
});
store.closeTab(workspaceKey, secondTabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
expect(paneCId).toBe("pane_89898989-8989-8989-8989-898989898989");
expect(layout.focusedPaneId).toBe(paneCId);
expect(rootGroup.group.direction).toBe("horizontal");
expect(rootGroup.group.children).toEqual([
createPane({ id: "main", tabIds: ["file_/repo/worktree/a.ts"] }),
createPane({ id: paneCId!, tabIds: ["file_/repo/worktree/c.ts"] }),
]);
expect(rootGroup.group.sizes).toEqual([0.5, 0.5]);
});
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"abababab-abab-abab-abab-abababababab"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
store.focusPane(workspaceKey, "main");
const duplicateTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
expect(duplicateTabId).toBe(secondTabId);
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
"file_/repo/worktree/a.ts",
"file_/repo/worktree/b.ts",
]);
});
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const a = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
expect(a).toBeTruthy();
const rightPaneId = store.splitPane(workspaceKey, {
tabId: b!,
targetPaneId: "main",
position: "right",
});
const farRightPaneId = store.splitPane(workspaceKey, {
tabId: c!,
targetPaneId: rightPaneId!,
position: "right",
});
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
const splitGroup = expectGroup(splitRoot);
store.resizeSplit(workspaceKey, splitGroup.group.id, [0.01, 0.01, 0.98]);
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
const resizedGroup = expectGroup(resizedRoot);
const total = resizedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
expect(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff");
expect(resizedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
expect(resizedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
expect(resizedGroup.group.sizes[2]).toBeGreaterThanOrEqual(0.1);
expect(total).toBeCloseTo(1, 10);
});
it("closing the last tab keeps a single empty pane in the layout", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const tabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.closeTab(workspaceKey, tabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(layout).toEqual(createDefaultLayout());
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { SplitNode } from "@/stores/workspace-layout-store";
import { findAdjacentPane } from "./split-navigation";
function createPaneNode(id: string): SplitNode {
return {
kind: "pane",
pane: {
id,
tabIds: [],
focusedTabId: null,
},
};
}
function createGroupNode(input: {
direction: "horizontal" | "vertical";
sizes: number[];
children: SplitNode[];
}): SplitNode {
return {
kind: "group",
group: {
id: `${input.direction}-group`,
direction: input.direction,
sizes: input.sizes,
children: input.children,
},
};
}
describe("findAdjacentPane", () => {
it("finds direct horizontal and vertical neighbors in nested layouts", () => {
const root = createGroupNode({
direction: "horizontal",
sizes: [0.25, 0.5, 0.25],
children: [
createPaneNode("left"),
createGroupNode({
direction: "vertical",
sizes: [0.5, 0.5],
children: [createPaneNode("top-middle"), createPaneNode("bottom-middle")],
}),
createPaneNode("right"),
],
});
expect(findAdjacentPane(root, "top-middle", "left")).toBe("left");
expect(findAdjacentPane(root, "top-middle", "right")).toBe("right");
expect(findAdjacentPane(root, "top-middle", "down")).toBe("bottom-middle");
expect(findAdjacentPane(root, "bottom-middle", "up")).toBe("top-middle");
});
it("returns null when there is no pane in the requested direction", () => {
const root = createGroupNode({
direction: "horizontal",
sizes: [0.5, 0.5],
children: [createPaneNode("left"), createPaneNode("right")],
});
expect(findAdjacentPane(root, "left", "left")).toBeNull();
expect(findAdjacentPane(root, "right", "right")).toBeNull();
expect(findAdjacentPane(root, "left", "up")).toBeNull();
});
it("prefers the closest overlapping pane when multiple candidates exist", () => {
const root = createGroupNode({
direction: "vertical",
sizes: [0.5, 0.5],
children: [
createPaneNode("top"),
createGroupNode({
direction: "horizontal",
sizes: [0.5, 0.5],
children: [createPaneNode("bottom-left"), createPaneNode("bottom-right")],
}),
],
});
expect(findAdjacentPane(root, "top", "down")).toBe("bottom-left");
expect(findAdjacentPane(root, "bottom-right", "up")).toBe("top");
});
});
+251
View File
@@ -0,0 +1,251 @@
import type { SplitNode } from "@/stores/workspace-layout-store";
const ROOT_MIN = 0;
const ROOT_MAX = 1;
const FLOAT_TOLERANCE = 0.000001;
export interface PaneBounds {
paneId: string;
left: number;
top: number;
right: number;
bottom: number;
centerX: number;
centerY: number;
}
interface PaneCandidate {
paneId: string;
primaryDistance: number;
secondaryDistance: number;
centerDistance: number;
overlap: number;
}
export function findAdjacentPane(
root: SplitNode,
focusedPaneId: string,
direction: "left" | "right" | "up" | "down"
): string | null {
const panes = collectPaneBounds(root, {
left: ROOT_MIN,
top: ROOT_MIN,
right: ROOT_MAX,
bottom: ROOT_MAX,
});
const focusedPane = panes.find((pane) => pane.paneId === focusedPaneId) ?? null;
if (!focusedPane) {
return null;
}
const candidates = panes
.filter((pane) => pane.paneId !== focusedPaneId)
.map((pane) => buildCandidate({ pane, focusedPane, direction }))
.filter((candidate): candidate is PaneCandidate => candidate !== null)
.sort(compareCandidates);
return candidates[0]?.paneId ?? null;
}
function compareCandidates(left: PaneCandidate, right: PaneCandidate): number {
if (left.primaryDistance !== right.primaryDistance) {
return left.primaryDistance - right.primaryDistance;
}
if (left.secondaryDistance !== right.secondaryDistance) {
return left.secondaryDistance - right.secondaryDistance;
}
if (left.overlap !== right.overlap) {
return right.overlap - left.overlap;
}
if (left.centerDistance !== right.centerDistance) {
return left.centerDistance - right.centerDistance;
}
return left.paneId.localeCompare(right.paneId);
}
function buildCandidate(input: {
pane: PaneBounds;
focusedPane: PaneBounds;
direction: "left" | "right" | "up" | "down";
}): PaneCandidate | null {
const { pane, focusedPane, direction } = input;
if (direction === "left") {
const primaryDistance = focusedPane.left - pane.right;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
}),
centerDistance: Math.abs(pane.centerY - focusedPane.centerY),
overlap,
};
}
if (direction === "right") {
const primaryDistance = pane.left - focusedPane.right;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
}),
centerDistance: Math.abs(pane.centerY - focusedPane.centerY),
overlap,
};
}
if (direction === "up") {
const primaryDistance = focusedPane.top - pane.bottom;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
}),
centerDistance: Math.abs(pane.centerX - focusedPane.centerX),
overlap,
};
}
const primaryDistance = pane.top - focusedPane.bottom;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
}),
centerDistance: Math.abs(pane.centerX - focusedPane.centerX),
overlap,
};
}
function collectPaneBounds(
node: SplitNode,
bounds: { left: number; top: number; right: number; bottom: number }
): PaneBounds[] {
if (node.kind === "pane") {
return [
{
paneId: node.pane.id,
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
centerX: (bounds.left + bounds.right) / 2,
centerY: (bounds.top + bounds.bottom) / 2,
},
];
}
const panes: PaneBounds[] = [];
const totalWidth = bounds.right - bounds.left;
const totalHeight = bounds.bottom - bounds.top;
let offset = 0;
for (let index = 0; index < node.group.children.length; index += 1) {
const child = node.group.children[index];
const size = node.group.sizes[index] ?? 0;
if (node.group.direction === "horizontal") {
const childLeft = bounds.left + totalWidth * offset;
offset += size;
const childRight = bounds.left + totalWidth * offset;
panes.push(
...collectPaneBounds(child, {
left: childLeft,
top: bounds.top,
right: childRight,
bottom: bounds.bottom,
})
);
continue;
}
const childTop = bounds.top + totalHeight * offset;
offset += size;
const childBottom = bounds.top + totalHeight * offset;
panes.push(
...collectPaneBounds(child, {
left: bounds.left,
top: childTop,
right: bounds.right,
bottom: childBottom,
})
);
}
return panes;
}
function getGapLength(input: {
startA: number;
endA: number;
startB: number;
endB: number;
}): number {
const { startA, endA, startB, endB } = input;
if (endA < startB) {
return startB - endA;
}
if (endB < startA) {
return startA - endB;
}
return 0;
}
function getOverlapLength(input: {
startA: number;
endA: number;
startB: number;
endB: number;
}): number {
const { startA, endA, startB, endB } = input;
return Math.max(0, Math.min(endA, endB) - Math.max(startA, startB));
}