feat: add terminal tabs and split pane controls to workspace UI

This commit is contained in:
Mohamed Boudra
2026-03-18 19:55:04 +07:00
parent 5f2d4ac122
commit b763d4358e
6 changed files with 267 additions and 17 deletions
+46 -10
View File
@@ -541,6 +541,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
alignSelf: "flex-start", alignSelf: "flex-start",
padding: theme.spacing[2], padding: theme.spacing[2],
paddingTop: 0, paddingTop: 0,
marginTop: theme.spacing[2],
}, },
iconColor: { iconColor: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
@@ -624,7 +625,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
container: { container: {
marginHorizontal: theme.spacing[2], marginHorizontal: -6,
}, },
containerSpacing: { containerSpacing: {
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
@@ -635,8 +636,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
pressable: { pressable: {
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: "transparent",
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
overflow: "hidden", overflow: "hidden",
@@ -660,16 +660,18 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
borderRadius: 11, borderRadius: 11,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
marginRight: theme.spacing[2], marginRight: theme.spacing[1],
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
label: { label: {
color: theme.colors.foreground, color: theme.colors.foregroundMuted,
opacity: 0.88,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
flexShrink: 0, flexShrink: 0,
}, },
labelActive: {
color: theme.colors.foreground,
},
labelLoading: { labelLoading: {
color: theme.colors.foreground, color: theme.colors.foreground,
opacity: 0.72, opacity: 0.72,
@@ -681,6 +683,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
marginLeft: theme.spacing[2], marginLeft: theme.spacing[2],
}, },
secondaryLabelActive: {
color: theme.colors.foreground,
},
shimmerText: { shimmerText: {
color: "transparent", color: "transparent",
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
@@ -710,6 +715,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
overflow: "hidden", overflow: "hidden",
}, },
pressableExpanded: { pressableExpanded: {
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
borderBottomRightRadius: 0, borderBottomRightRadius: 0,
}, },
@@ -941,6 +948,22 @@ export const AssistantMessage = memo(function AssistantMessage({
</View> </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: ( link: (
node: any, node: any,
children: ReactNode[], children: ReactNode[],
@@ -1594,12 +1617,23 @@ const ExpandableBadge = memo(function ExpandableBadge({
[isExpanded, isInteractive] [isExpanded, isInteractive]
); );
const isActive = isHovered || isExpanded;
const labelStyle = useMemo( const labelStyle = useMemo(
() => [ () => [
expandableBadgeStylesheet.label, expandableBadgeStylesheet.label,
isActive && expandableBadgeStylesheet.labelActive,
isLoading && expandableBadgeStylesheet.labelLoading, isLoading && expandableBadgeStylesheet.labelLoading,
], ],
[isLoading] [isActive, isLoading]
);
const secondaryLabelStyle = useMemo(
() => [
expandableBadgeStylesheet.secondaryLabel,
isActive && expandableBadgeStylesheet.secondaryLabelActive,
],
[isActive]
); );
const shimmerLabelTextStyle = useMemo( const shimmerLabelTextStyle = useMemo(
@@ -1670,7 +1704,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
const IconComponent = icon; const IconComponent = icon;
const iconColor = isError const iconColor = isError
? theme.colors.destructive ? theme.colors.destructive
: theme.colors.mutedForeground; : isActive
? theme.colors.foreground
: theme.colors.mutedForeground;
let iconNode: ReactNode = null; let iconNode: ReactNode = null;
if (isError) { if (isError) {
@@ -1717,7 +1753,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</Text> </Text>
{secondaryLabel ? ( {secondaryLabel ? (
<Text <Text
style={expandableBadgeStylesheet.secondaryLabel} style={secondaryLabelStyle}
numberOfLines={1} numberOfLines={1}
onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined} onLayout={shouldMeasureWebShimmer ? handleSecondaryLayout : undefined}
> >
@@ -1812,7 +1848,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
{isInteractive && isHovered ? ( {isInteractive && isHovered ? (
<ChevronRight <ChevronRight
size={14} size={14}
color={theme.colors.foregroundMuted} color={theme.colors.foreground}
style={chevronStyle} style={chevronStyle}
/> />
) : null} ) : 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 { import {
DndContext, DndContext,
DragOverlay, DragOverlay,
@@ -15,7 +15,7 @@ import {
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
import { import {
@@ -63,6 +63,7 @@ interface SplitContainerProps {
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void; onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
onNewTerminalTab: () => void;
newTabAgentOptionId?: "__new_tab_agent__"; newTabAgentOptionId?: "__new_tab_agent__";
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
@@ -74,6 +75,10 @@ interface SplitContainerProps {
targetPaneId: string; targetPaneId: string;
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
}) => void; }) => void;
onSplitPaneEmpty: (input: {
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void; onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void; onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
@@ -110,7 +115,6 @@ interface SplitPaneViewProps
| "activeDragTabId" | "activeDragTabId"
| "showDropZones" | "showDropZones"
| "dropPreview" | "dropPreview"
| "onSplitPane"
| "onMoveTabToPane" | "onMoveTabToPane"
| "onResizeSplit" | "onResizeSplit"
> { > {
@@ -161,10 +165,12 @@ export function SplitContainer({
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onSelectNewTabOption, onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId = "__new_tab_agent__", newTabAgentOptionId = "__new_tab_agent__",
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane, onMoveTabToPane,
onResizeSplit, onResizeSplit,
onReorderTabsInPane, onReorderTabsInPane,
@@ -360,10 +366,12 @@ export function SplitContainer({
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption} onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId} newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onMoveTabToPane={onMoveTabToPane} onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit} onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane} onReorderTabsInPane={onReorderTabsInPane}
@@ -479,10 +487,12 @@ function SplitNodeView({
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onSelectNewTabOption, onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId, newTabAgentOptionId,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane, onMoveTabToPane,
onResizeSplit, onResizeSplit,
onReorderTabsInPane, onReorderTabsInPane,
@@ -513,9 +523,12 @@ function SplitNodeView({
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption} onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId} newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onReorderTabsInPane={onReorderTabsInPane} onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState} renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
@@ -560,10 +573,12 @@ function SplitNodeView({
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption} onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId} newTabAgentOptionId={newTabAgentOptionId}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onMoveTabToPane={onMoveTabToPane} onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit} onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane} onReorderTabsInPane={onReorderTabsInPane}
@@ -608,9 +623,12 @@ function SplitPaneView({
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onSelectNewTabOption, onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId, newTabAgentOptionId,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onReorderTabsInPane, onReorderTabsInPane,
renderPaneEmptyState, renderPaneEmptyState,
activeDragTabId, activeDragTabId,
@@ -618,6 +636,7 @@ function SplitPaneView({
dropPreview, dropPreview,
}: SplitPaneViewProps) { }: SplitPaneViewProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const paneState = useMemo( const paneState = useMemo(
() => () =>
deriveWorkspacePaneState({ 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 ( return (
<View <View
ref={paneRef}
collapsable={false}
style={styles.pane} style={styles.pane}
onFocus={() => { onFocus={() => {
onFocusPane(pane.id); onFocusPane(pane.id);
@@ -704,10 +752,13 @@ function SplitPaneView({
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onSelectNewTabOption={onSelectNewTabOption} onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"} newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onReorderTabs={(nextTabs) => { onReorderTabs={(nextTabs) => {
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId)); onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
}} }}
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
externalDndContext externalDndContext
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
/> />
@@ -1,6 +1,6 @@
import { useCallback, 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 { 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list"; import { SortableInlineList } from "@/components/sortable-inline-list";
import { import {
@@ -57,6 +57,9 @@ type WorkspaceDesktopTabsRowProps = {
onSelectNewTabOption: (selection: NewTabSelection) => void; onSelectNewTabOption: (selection: NewTabSelection) => void;
newTabAgentOptionId: NewTabOptionId; newTabAgentOptionId: NewTabOptionId;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
onNewTerminalTab: () => void;
onSplitRight: () => void;
onSplitDown: () => void;
externalDndContext?: boolean; externalDndContext?: boolean;
activeDragTabId?: string | null; activeDragTabId?: string | null;
}; };
@@ -270,6 +273,9 @@ export function WorkspaceDesktopTabsRow({
onSelectNewTabOption, onSelectNewTabOption,
newTabAgentOptionId, newTabAgentOptionId,
onReorderTabs, onReorderTabs,
onNewTerminalTab,
onSplitRight,
onSplitDown,
externalDndContext = false, externalDndContext = false,
activeDragTabId = null, activeDragTabId = null,
}: WorkspaceDesktopTabsRowProps) { }: WorkspaceDesktopTabsRowProps) {
@@ -410,6 +416,54 @@ export function WorkspaceDesktopTabsRow({
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </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>
</View> </View>
); );
@@ -514,11 +568,13 @@ function ResolvedDesktopTabChip({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
tabsContainer: { tabsContainer: {
minWidth: 0, minWidth: 0,
height: theme.spacing[2] * 2 + theme.iconSize.sm + theme.spacing[1],
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
overflow: "visible",
}, },
tabsScroll: { tabsScroll: {
minWidth: 0, minWidth: 0,
@@ -536,7 +592,6 @@ const styles = StyleSheet.create((theme) => ({
tabsActions: { tabsActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
}, },
tab: { tab: {
@@ -613,8 +668,8 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface3,
}, },
newTabActionButton: { newTabActionButton: {
width: 28, width: 22,
height: 28, height: 22,
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -781,6 +781,7 @@ function WorkspaceScreenContent({
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
@@ -2017,9 +2018,13 @@ function WorkspaceScreenContent({
onFocusPane={(paneId) => { onFocusPane={(paneId) => {
focusWorkspacePane(persistenceKey, paneId); focusWorkspacePane(persistenceKey, paneId);
}} }}
onNewTerminalTab={handleCreateTerminal}
onSplitPane={(input) => { onSplitPane={(input) => {
splitWorkspacePane(persistenceKey, input); splitWorkspacePane(persistenceKey, input);
}} }}
onSplitPaneEmpty={(input) => {
splitWorkspacePaneEmpty(persistenceKey, input);
}}
onMoveTabToPane={(tabId, toPaneId) => { onMoveTabToPane={(tabId, toPaneId) => {
moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId); moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId);
}} }}
@@ -145,6 +145,14 @@ interface SplitPaneInLayoutResult {
paneId: string; paneId: string;
} }
interface SplitPaneEmptyInLayoutInput {
layout: WorkspaceLayout;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: "pane" | "group") => string;
maxTreeDepth: number;
}
interface MoveTabToPaneInLayoutInput { interface MoveTabToPaneInLayoutInput {
layout: WorkspaceLayout; layout: WorkspaceLayout;
tabId: string; 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 { export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null {
const layout = asInternalLayout(input.layout); const layout = asInternalLayout(input.layout);
const sourcePane = findPaneContainingTab(layout.root, input.tabId); const sourcePane = findPaneContainingTab(layout.root, input.tabId);
@@ -26,6 +26,7 @@ import {
reorderFocusedPaneTabsInLayout, reorderFocusedPaneTabsInLayout,
reorderPaneTabsInLayout, reorderPaneTabsInLayout,
retargetTabInLayout, retargetTabInLayout,
splitPaneEmptyInLayout,
splitPaneInLayout, splitPaneInLayout,
type SplitGroup, type SplitGroup,
type SplitNode, type SplitNode,
@@ -66,6 +67,13 @@ interface WorkspaceLayoutStore {
position: "left" | "right" | "top" | "bottom"; position: "left" | "right" | "top" | "bottom";
} }
) => string | null; ) => string | null;
splitPaneEmpty: (
workspaceKey: string,
input: {
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}
) => string | null;
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void; moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
focusPane: (workspaceKey: string, paneId: string) => void; focusPane: (workspaceKey: string, paneId: string) => void;
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void; resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
@@ -252,6 +260,39 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
return result.paneId; 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) => { moveTabToPane: (workspaceKey, tabId, toPaneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId); const normalizedTabId = trimNonEmpty(tabId);