feat: add terminal tabs and split pane controls to workspace UI
This commit is contained in:
@@ -541,6 +541,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||
alignSelf: "flex-start",
|
||||
padding: theme.spacing[2],
|
||||
paddingTop: 0,
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -624,7 +625,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
|
||||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginHorizontal: -6,
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
@@ -635,8 +636,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
pressable: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: "transparent",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
overflow: "hidden",
|
||||
@@ -660,16 +660,18 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderRadius: 11,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: theme.spacing[2],
|
||||
marginRight: theme.spacing[1],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.88,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 0,
|
||||
},
|
||||
labelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
labelLoading: {
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.72,
|
||||
@@ -681,6 +683,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
secondaryLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
shimmerText: {
|
||||
color: "transparent",
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -710,6 +715,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
pressableExpanded: {
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
@@ -941,6 +948,22 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
parent: any,
|
||||
styles: any,
|
||||
) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View
|
||||
key={node.key}
|
||||
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
link: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
@@ -1594,12 +1617,23 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
[isExpanded, isInteractive]
|
||||
);
|
||||
|
||||
const isActive = isHovered || isExpanded;
|
||||
|
||||
const labelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.label,
|
||||
isActive && expandableBadgeStylesheet.labelActive,
|
||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||
],
|
||||
[isLoading]
|
||||
[isActive, isLoading]
|
||||
);
|
||||
|
||||
const secondaryLabelStyle = useMemo(
|
||||
() => [
|
||||
expandableBadgeStylesheet.secondaryLabel,
|
||||
isActive && expandableBadgeStylesheet.secondaryLabelActive,
|
||||
],
|
||||
[isActive]
|
||||
);
|
||||
|
||||
const shimmerLabelTextStyle = useMemo(
|
||||
@@ -1670,7 +1704,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const IconComponent = icon;
|
||||
const iconColor = isError
|
||||
? theme.colors.destructive
|
||||
: theme.colors.mutedForeground;
|
||||
: isActive
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground;
|
||||
|
||||
let iconNode: ReactNode = null;
|
||||
if (isError) {
|
||||
@@ -1717,7 +1753,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Text>
|
||||
{secondaryLabel ? (
|
||||
<Text
|
||||
style={expandableBadgeStylesheet.secondaryLabel}
|
||||
style={secondaryLabelStyle}
|
||||
numberOfLines={1}
|
||||
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
|
||||
>
|
||||
@@ -1812,7 +1848,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
{isInteractive && isHovered ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
color={theme.colors.foreground}
|
||||
style={chevronStyle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { View, Text } from "react-native";
|
||||
import { Platform, View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import {
|
||||
@@ -63,6 +63,7 @@ interface SplitContainerProps {
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
|
||||
onNewTerminalTab: () => void;
|
||||
newTabAgentOptionId?: "__new_tab_agent__";
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
@@ -74,6 +75,10 @@ interface SplitContainerProps {
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
onSplitPaneEmpty: (input: {
|
||||
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;
|
||||
@@ -110,7 +115,6 @@ interface SplitPaneViewProps
|
||||
| "activeDragTabId"
|
||||
| "showDropZones"
|
||||
| "dropPreview"
|
||||
| "onSplitPane"
|
||||
| "onMoveTabToPane"
|
||||
| "onResizeSplit"
|
||||
> {
|
||||
@@ -161,10 +165,12 @@ export function SplitContainer({
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId = "__new_tab_agent__",
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
@@ -360,10 +366,12 @@ export function SplitContainer({
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
@@ -479,10 +487,12 @@ function SplitNodeView({
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
@@ -513,9 +523,12 @@ function SplitNodeView({
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
@@ -560,10 +573,12 @@ function SplitNodeView({
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
@@ -608,9 +623,12 @@ function SplitPaneView({
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
activeDragTabId,
|
||||
@@ -618,6 +636,7 @@ function SplitPaneView({
|
||||
dropPreview,
|
||||
}: SplitPaneViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const paneRef = useRef<View | null>(null);
|
||||
const paneState = useMemo(
|
||||
() =>
|
||||
deriveWorkspacePaneState({
|
||||
@@ -677,8 +696,37 @@ function SplitPaneView({
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
|
||||
const paneElement = paneRef.current as unknown as HTMLElement | null;
|
||||
if (
|
||||
!paneElement ||
|
||||
typeof paneElement.addEventListener !== "function" ||
|
||||
typeof paneElement.removeEventListener !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePaneInteraction = () => {
|
||||
onFocusPane(pane.id);
|
||||
};
|
||||
|
||||
paneElement.addEventListener("pointerdown", handlePaneInteraction, true);
|
||||
paneElement.addEventListener("focusin", handlePaneInteraction, true);
|
||||
|
||||
return () => {
|
||||
paneElement.removeEventListener("pointerdown", handlePaneInteraction, true);
|
||||
paneElement.removeEventListener("focusin", handlePaneInteraction, true);
|
||||
};
|
||||
}, [onFocusPane, pane.id]);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={paneRef}
|
||||
collapsable={false}
|
||||
style={styles.pane}
|
||||
onFocus={() => {
|
||||
onFocusPane(pane.id);
|
||||
@@ -704,10 +752,13 @@ function SplitPaneView({
|
||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
||||
onReorderTabs={(nextTabs) => {
|
||||
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
|
||||
}}
|
||||
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
|
||||
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
|
||||
externalDndContext
|
||||
activeDragTabId={activeDragTabId}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Columns2, Plus, Rows2, SquareTerminal, X } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import {
|
||||
@@ -57,6 +57,9 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
onSelectNewTabOption: (selection: NewTabSelection) => void;
|
||||
newTabAgentOptionId: NewTabOptionId;
|
||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||
onNewTerminalTab: () => void;
|
||||
onSplitRight: () => void;
|
||||
onSplitDown: () => void;
|
||||
externalDndContext?: boolean;
|
||||
activeDragTabId?: string | null;
|
||||
};
|
||||
@@ -270,6 +273,9 @@ export function WorkspaceDesktopTabsRow({
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
onReorderTabs,
|
||||
onNewTerminalTab,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
externalDndContext = false,
|
||||
activeDragTabId = null,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
@@ -410,6 +416,54 @@ export function WorkspaceDesktopTabsRow({
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onNewTerminalTab}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New terminal tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onSplitRight}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Split pane right"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onSplitDown}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Split pane down"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -514,11 +568,13 @@ function ResolvedDesktopTabChip({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
tabsContainer: {
|
||||
minWidth: 0,
|
||||
height: theme.spacing[2] * 2 + theme.iconSize.sm + theme.spacing[1],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
overflow: "visible",
|
||||
},
|
||||
tabsScroll: {
|
||||
minWidth: 0,
|
||||
@@ -536,7 +592,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
tabsActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
tab: {
|
||||
@@ -613,8 +668,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
newTabActionButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -781,6 +781,7 @@ function WorkspaceScreenContent({
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
|
||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
@@ -2017,9 +2018,13 @@ function WorkspaceScreenContent({
|
||||
onFocusPane={(paneId) => {
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
}}
|
||||
onNewTerminalTab={handleCreateTerminal}
|
||||
onSplitPane={(input) => {
|
||||
splitWorkspacePane(persistenceKey, input);
|
||||
}}
|
||||
onSplitPaneEmpty={(input) => {
|
||||
splitWorkspacePaneEmpty(persistenceKey, input);
|
||||
}}
|
||||
onMoveTabToPane={(tabId, toPaneId) => {
|
||||
moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId);
|
||||
}}
|
||||
|
||||
@@ -145,6 +145,14 @@ interface SplitPaneInLayoutResult {
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
interface SplitPaneEmptyInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
maxTreeDepth: number;
|
||||
}
|
||||
|
||||
interface MoveTabToPaneInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
@@ -1066,6 +1074,60 @@ export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLay
|
||||
};
|
||||
}
|
||||
|
||||
export function splitPaneEmptyInLayout(input: SplitPaneEmptyInLayoutInput): SplitPaneInLayoutResult | null {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
if (!findPaneById(layout.root, input.targetPaneId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical";
|
||||
const insertAfter = input.position === "right" || input.position === "bottom";
|
||||
|
||||
const targetPath = findPanePathById(layout.root, input.targetPaneId);
|
||||
invariant(targetPath, `Target pane not found: ${input.targetPaneId}`);
|
||||
const targetNode = getNodeAtPath(layout.root, targetPath);
|
||||
invariant(targetNode.kind === "pane", "Expected target pane");
|
||||
|
||||
const newPaneId = input.createNodeId("pane");
|
||||
const newPaneNode = createPaneNode({ id: newPaneId });
|
||||
|
||||
const parentPath = targetPath.slice(0, -1);
|
||||
const targetIndex = targetPath[targetPath.length - 1] ?? 0;
|
||||
const parentNode = parentPath.length > 0 ? getNodeAtPath(layout.root, parentPath) : null;
|
||||
|
||||
let nextRoot: SplitNodeInternal;
|
||||
if (parentNode?.kind === "group" && parentNode.group.direction === direction) {
|
||||
const targetSize = parentNode.group.sizes[targetIndex] ?? 0;
|
||||
const nextSizes = parentNode.group.sizes.slice();
|
||||
const insertIndex = insertAfter ? targetIndex + 1 : targetIndex;
|
||||
nextSizes.splice(insertIndex, 0, targetSize / 2);
|
||||
nextSizes[targetIndex + (insertAfter ? 0 : 1)] = targetSize / 2;
|
||||
nextRoot = replaceNodeAtPath(layout.root, parentPath, () =>
|
||||
insertChildIntoGroup(parentNode, { index: insertIndex, node: newPaneNode, sizes: nextSizes })
|
||||
);
|
||||
} else {
|
||||
const newGroup = createGroupNode({
|
||||
id: input.createNodeId("group"),
|
||||
direction,
|
||||
children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode],
|
||||
sizes: [0.5, 0.5],
|
||||
});
|
||||
nextRoot = replaceNodeAtPath(layout.root, targetPath, () => newGroup);
|
||||
}
|
||||
|
||||
if (getTreeDepth(nextRoot) > input.maxTreeDepth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
paneId: newPaneId,
|
||||
layout: {
|
||||
root: nextRoot,
|
||||
focusedPaneId: newPaneId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const sourcePane = findPaneContainingTab(layout.root, input.tabId);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
reorderFocusedPaneTabsInLayout,
|
||||
reorderPaneTabsInLayout,
|
||||
retargetTabInLayout,
|
||||
splitPaneEmptyInLayout,
|
||||
splitPaneInLayout,
|
||||
type SplitGroup,
|
||||
type SplitNode,
|
||||
@@ -66,6 +67,13 @@ interface WorkspaceLayoutStore {
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}
|
||||
) => string | null;
|
||||
splitPaneEmpty: (
|
||||
workspaceKey: string,
|
||||
input: {
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}
|
||||
) => string | null;
|
||||
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
|
||||
focusPane: (workspaceKey: string, paneId: string) => void;
|
||||
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
||||
@@ -252,6 +260,39 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
|
||||
return result.paneId;
|
||||
},
|
||||
splitPaneEmpty: (workspaceKey, input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTargetPaneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = splitPaneEmptyInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
targetPaneId: normalizedTargetPaneId,
|
||||
position: input.position,
|
||||
maxTreeDepth: MAX_TREE_DEPTH,
|
||||
createNodeId: (prefix) => {
|
||||
const randomValue =
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}_${randomValue}`;
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.paneId;
|
||||
},
|
||||
moveTabToPane: (workspaceKey, tabId, toPaneId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
|
||||
Reference in New Issue
Block a user