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",
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user