Add split-pane layout store and navigation shortcuts
This commit is contained in:
@@ -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%",
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user