refactor: extract workspace layout actions and pane state logic
This commit is contained in:
@@ -16,12 +16,21 @@ import { View } 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 { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
|
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
|
||||||
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
|
import {
|
||||||
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
deriveWorkspacePaneState,
|
||||||
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
getWorkspacePaneDescriptors,
|
||||||
|
} from "@/screens/workspace/workspace-pane-state";
|
||||||
|
import {
|
||||||
|
WorkspacePaneContent,
|
||||||
|
type WorkspacePaneContentModel,
|
||||||
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
|
import {
|
||||||
|
WorkspaceDesktopTabsRow,
|
||||||
|
type WorkspaceDesktopTabRowItem,
|
||||||
|
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store";
|
import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
interface SplitContainerProps {
|
interface SplitContainerProps {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
@@ -44,9 +53,10 @@ interface SplitContainerProps {
|
|||||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onSelectNewTabOption: (optionId: "__new_tab_agent__") => void;
|
onSelectNewTabOption: (optionId: "__new_tab_agent__") => void;
|
||||||
newTabAgentOptionId?: "__new_tab_agent__";
|
newTabAgentOptionId?: "__new_tab_agent__";
|
||||||
onOpenPaneTab: (input: { paneId: string; target: WorkspaceTabTarget }) => void;
|
buildPaneContentModel: (input: {
|
||||||
onRetargetTab: (tabId: string, target: WorkspaceTabTarget) => void;
|
paneId: string;
|
||||||
onOpenWorkspaceFile: (input: { paneId: string; filePath: string }) => void;
|
tab: WorkspaceTabDescriptor;
|
||||||
|
}) => WorkspacePaneContentModel;
|
||||||
onFocusPane: (paneId: string) => void;
|
onFocusPane: (paneId: string) => void;
|
||||||
onSplitPane: (input: {
|
onSplitPane: (input: {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -71,9 +81,9 @@ interface SplitPaneDropData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SplitNodeViewProps
|
interface SplitNodeViewProps
|
||||||
extends Omit<SplitContainerProps, "layout" | "workspaceKey" | "uiTabs"> {
|
extends Omit<SplitContainerProps, "layout" | "workspaceKey"> {
|
||||||
node: SplitNode;
|
node: SplitNode;
|
||||||
tabsById: Map<string, WorkspaceTab>;
|
uiTabs: WorkspaceTab[];
|
||||||
focusedPaneId: string;
|
focusedPaneId: string;
|
||||||
activeDragTabId: string | null;
|
activeDragTabId: string | null;
|
||||||
showDropZones: boolean;
|
showDropZones: boolean;
|
||||||
@@ -85,7 +95,6 @@ interface SplitPaneViewProps
|
|||||||
extends Omit<
|
extends Omit<
|
||||||
SplitNodeViewProps,
|
SplitNodeViewProps,
|
||||||
| "node"
|
| "node"
|
||||||
| "tabsById"
|
|
||||||
| "focusedPaneId"
|
| "focusedPaneId"
|
||||||
| "activeDragTabId"
|
| "activeDragTabId"
|
||||||
| "showDropZones"
|
| "showDropZones"
|
||||||
@@ -96,7 +105,7 @@ interface SplitPaneViewProps
|
|||||||
| "onResizeSplit"
|
| "onResizeSplit"
|
||||||
> {
|
> {
|
||||||
pane: SplitPane;
|
pane: SplitPane;
|
||||||
tabsById: Map<string, WorkspaceTab>;
|
uiTabs: WorkspaceTab[];
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
activeDragTabId: string | null;
|
activeDragTabId: string | null;
|
||||||
showDropZones: boolean;
|
showDropZones: boolean;
|
||||||
@@ -143,9 +152,7 @@ export function SplitContainer({
|
|||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onSelectNewTabOption,
|
||||||
newTabAgentOptionId = "__new_tab_agent__",
|
newTabAgentOptionId = "__new_tab_agent__",
|
||||||
onOpenPaneTab,
|
buildPaneContentModel,
|
||||||
onRetargetTab,
|
|
||||||
onOpenWorkspaceFile,
|
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
onMoveTabToPane,
|
onMoveTabToPane,
|
||||||
@@ -167,14 +174,6 @@ export function SplitContainer({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
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 panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
@@ -215,8 +214,8 @@ export function SplitContainer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById);
|
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||||
const targetTabs = getPaneTabDescriptors(targetPane, tabsById);
|
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||||
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
|
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
|
||||||
if (sourceIndex < 0 || targetIndex < 0) {
|
if (sourceIndex < 0 || targetIndex < 0) {
|
||||||
@@ -259,7 +258,7 @@ export function SplitContainer({
|
|||||||
|
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
},
|
},
|
||||||
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById]
|
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, uiTabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -272,7 +271,7 @@ export function SplitContainer({
|
|||||||
>
|
>
|
||||||
<SplitNodeView
|
<SplitNodeView
|
||||||
node={layout.root}
|
node={layout.root}
|
||||||
tabsById={tabsById}
|
uiTabs={uiTabs}
|
||||||
focusedPaneId={layout.focusedPaneId}
|
focusedPaneId={layout.focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
@@ -291,9 +290,7 @@ export function SplitContainer({
|
|||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onSelectNewTabOption={onSelectNewTabOption}
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
newTabAgentOptionId={newTabAgentOptionId}
|
||||||
onOpenPaneTab={onOpenPaneTab}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onRetargetTab={onRetargetTab}
|
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
onMoveTabToPane={onMoveTabToPane}
|
onMoveTabToPane={onMoveTabToPane}
|
||||||
@@ -311,7 +308,7 @@ export function SplitContainer({
|
|||||||
|
|
||||||
function SplitNodeView({
|
function SplitNodeView({
|
||||||
node,
|
node,
|
||||||
tabsById,
|
uiTabs,
|
||||||
focusedPaneId,
|
focusedPaneId,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
@@ -330,9 +327,7 @@ function SplitNodeView({
|
|||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onSelectNewTabOption,
|
||||||
newTabAgentOptionId,
|
newTabAgentOptionId,
|
||||||
onOpenPaneTab,
|
buildPaneContentModel,
|
||||||
onRetargetTab,
|
|
||||||
onOpenWorkspaceFile,
|
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
onMoveTabToPane,
|
onMoveTabToPane,
|
||||||
@@ -348,7 +343,7 @@ function SplitNodeView({
|
|||||||
return (
|
return (
|
||||||
<SplitPaneView
|
<SplitPaneView
|
||||||
pane={node.pane}
|
pane={node.pane}
|
||||||
tabsById={tabsById}
|
uiTabs={uiTabs}
|
||||||
focused={node.pane.id === focusedPaneId}
|
focused={node.pane.id === focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
@@ -367,9 +362,7 @@ function SplitNodeView({
|
|||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onSelectNewTabOption={onSelectNewTabOption}
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
newTabAgentOptionId={newTabAgentOptionId}
|
||||||
onOpenPaneTab={onOpenPaneTab}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onRetargetTab={onRetargetTab}
|
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onReorderTabsInPane={onReorderTabsInPane}
|
onReorderTabsInPane={onReorderTabsInPane}
|
||||||
renderPaneEmptyState={renderPaneEmptyState}
|
renderPaneEmptyState={renderPaneEmptyState}
|
||||||
@@ -393,7 +386,7 @@ function SplitNodeView({
|
|||||||
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}>
|
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}>
|
||||||
<SplitNodeView
|
<SplitNodeView
|
||||||
node={child}
|
node={child}
|
||||||
tabsById={tabsById}
|
uiTabs={uiTabs}
|
||||||
focusedPaneId={focusedPaneId}
|
focusedPaneId={focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
@@ -412,9 +405,7 @@ function SplitNodeView({
|
|||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onSelectNewTabOption={onSelectNewTabOption}
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
newTabAgentOptionId={newTabAgentOptionId}
|
||||||
onOpenPaneTab={onOpenPaneTab}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onRetargetTab={onRetargetTab}
|
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
onMoveTabToPane={onMoveTabToPane}
|
onMoveTabToPane={onMoveTabToPane}
|
||||||
@@ -444,7 +435,7 @@ function SplitNodeView({
|
|||||||
|
|
||||||
function SplitPaneView({
|
function SplitPaneView({
|
||||||
pane,
|
pane,
|
||||||
tabsById,
|
uiTabs,
|
||||||
focused,
|
focused,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
@@ -463,9 +454,7 @@ function SplitPaneView({
|
|||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onSelectNewTabOption,
|
||||||
newTabAgentOptionId,
|
newTabAgentOptionId,
|
||||||
onOpenPaneTab,
|
buildPaneContentModel,
|
||||||
onRetargetTab,
|
|
||||||
onOpenWorkspaceFile,
|
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onReorderTabsInPane,
|
onReorderTabsInPane,
|
||||||
renderPaneEmptyState,
|
renderPaneEmptyState,
|
||||||
@@ -475,22 +464,64 @@ function SplitPaneView({
|
|||||||
onDropPreviewChange,
|
onDropPreviewChange,
|
||||||
}: SplitPaneViewProps) {
|
}: SplitPaneViewProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const paneTabs = useMemo(() => getPaneTabDescriptors(pane, tabsById), [pane, tabsById]);
|
const paneState = useMemo(
|
||||||
const paneModel = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
deriveWorkspaceTabModel({
|
deriveWorkspacePaneState({
|
||||||
tabs: paneTabs.map((tab) => ({
|
pane,
|
||||||
tabId: tab.tabId,
|
tabs: uiTabs,
|
||||||
target: tab.target,
|
|
||||||
createdAt: 0,
|
|
||||||
})),
|
|
||||||
focusedTabId: pane.focusedTabId,
|
|
||||||
}),
|
}),
|
||||||
[pane.focusedTabId, paneTabs]
|
[pane, uiTabs]
|
||||||
|
);
|
||||||
|
const paneTabs = useMemo(
|
||||||
|
() => paneState.tabs.map((tab) => tab.descriptor),
|
||||||
|
[paneState.tabs]
|
||||||
|
);
|
||||||
|
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||||
|
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
|
||||||
|
() =>
|
||||||
|
paneTabs.map((tab) => {
|
||||||
|
const isClosingAgent =
|
||||||
|
tab.target.kind === "agent" &&
|
||||||
|
isArchivingAgent({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
agentId: tab.target.agentId,
|
||||||
|
});
|
||||||
|
const isClosingTerminal =
|
||||||
|
tab.target.kind === "terminal" &&
|
||||||
|
killTerminalPending &&
|
||||||
|
killTerminalId === tab.target.terminalId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tab,
|
||||||
|
isActive: tab.key === activeTabDescriptor?.key,
|
||||||
|
isCloseHovered: hoveredCloseTabKey === tab.key,
|
||||||
|
isClosingTab: isClosingAgent || isClosingTerminal,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeTabDescriptor?.key,
|
||||||
|
hoveredCloseTabKey,
|
||||||
|
isArchivingAgent,
|
||||||
|
killTerminalId,
|
||||||
|
killTerminalPending,
|
||||||
|
normalizedServerId,
|
||||||
|
paneTabs,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const paneContent = useMemo(
|
||||||
|
() =>
|
||||||
|
activeTabDescriptor
|
||||||
|
? buildPaneContentModel({
|
||||||
|
paneId: pane.id,
|
||||||
|
tab: activeTabDescriptor,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
[
|
||||||
|
activeTabDescriptor,
|
||||||
|
buildPaneContentModel,
|
||||||
|
pane.id,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const activeTabDescriptor = paneModel.activeTab
|
|
||||||
? paneTabs.find((tab) => tab.tabId === paneModel.activeTab?.descriptor.tabId) ?? null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -516,16 +547,11 @@ function SplitPaneView({
|
|||||||
>
|
>
|
||||||
<WorkspaceDesktopTabsRow
|
<WorkspaceDesktopTabsRow
|
||||||
paneId={pane.id}
|
paneId={pane.id}
|
||||||
tabs={paneTabs}
|
tabs={desktopTabRowItems}
|
||||||
activeTabKey={activeTabDescriptor?.key ?? ""}
|
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
isArchivingAgent={isArchivingAgent}
|
|
||||||
killTerminalPending={killTerminalPending}
|
|
||||||
killTerminalId={killTerminalId}
|
|
||||||
onNavigateTab={onNavigateTab}
|
onNavigateTab={onNavigateTab}
|
||||||
onCloseTab={onCloseTab}
|
onCloseTab={onCloseTab}
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
@@ -544,30 +570,8 @@ function SplitPaneView({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.paneContent}>
|
<View style={styles.paneContent}>
|
||||||
{activeTabDescriptor ? (
|
{paneContent ? (
|
||||||
<WorkspacePaneContent
|
<WorkspacePaneContent content={paneContent} />
|
||||||
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
|
renderPaneEmptyState?.() ?? null
|
||||||
)}
|
)}
|
||||||
@@ -598,26 +602,6 @@ function collectPanesById(node: SplitNode): Map<string, SplitPane> {
|
|||||||
return next;
|
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 {
|
function getNodeKey(node: SplitNode): string {
|
||||||
if (node.kind === "pane") {
|
if (node.kind === "pane") {
|
||||||
return node.pane.id;
|
return node.pane.id;
|
||||||
|
|||||||
@@ -18,26 +18,30 @@ import {
|
|||||||
WorkspaceTabIcon,
|
WorkspaceTabIcon,
|
||||||
type WorkspaceTabPresentation,
|
type WorkspaceTabPresentation,
|
||||||
} from "@/screens/workspace/workspace-tab-presentation";
|
} from "@/screens/workspace/workspace-tab-presentation";
|
||||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
import {
|
||||||
|
buildWorkspaceDesktopTabActions,
|
||||||
|
type WorkspaceDesktopTabActions,
|
||||||
|
} from "@/screens/workspace/workspace-tab-menu";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
|
||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||||
type NewTabOptionId = "__new_tab_agent__";
|
type NewTabOptionId = "__new_tab_agent__";
|
||||||
|
|
||||||
|
export interface WorkspaceDesktopTabRowItem {
|
||||||
|
tab: WorkspaceTabDescriptor;
|
||||||
|
isActive: boolean;
|
||||||
|
isCloseHovered: boolean;
|
||||||
|
isClosingTab: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceDesktopTabsRowProps = {
|
type WorkspaceDesktopTabsRowProps = {
|
||||||
paneId?: string;
|
paneId?: string;
|
||||||
tabs: WorkspaceTabDescriptor[];
|
tabs: WorkspaceDesktopTabRowItem[];
|
||||||
activeTabKey: string;
|
|
||||||
normalizedServerId: string;
|
normalizedServerId: string;
|
||||||
normalizedWorkspaceId: string;
|
normalizedWorkspaceId: string;
|
||||||
hoveredCloseTabKey: string | null;
|
|
||||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
setHoveredCloseTabKey: 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;
|
onNavigateTab: (tabId: string) => void;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
@@ -65,87 +69,41 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
|||||||
return "Agent";
|
return "Agent";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
|
||||||
if (tab.target.kind === "agent") {
|
|
||||||
return `workspace-agent-close-${tab.target.agentId}`;
|
|
||||||
}
|
|
||||||
if (tab.target.kind === "terminal") {
|
|
||||||
return `workspace-terminal-close-${tab.target.terminalId}`;
|
|
||||||
}
|
|
||||||
if (tab.target.kind === "draft") {
|
|
||||||
return `workspace-draft-close-${tab.target.draftId}`;
|
|
||||||
}
|
|
||||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabChip({
|
function TabChip({
|
||||||
tab,
|
tab,
|
||||||
index,
|
|
||||||
tabCount,
|
|
||||||
isActive,
|
isActive,
|
||||||
resolvedTabWidth,
|
resolvedTabWidth,
|
||||||
showLabel,
|
showLabel,
|
||||||
showCloseButton,
|
showCloseButton,
|
||||||
isCloseHovered,
|
isCloseHovered,
|
||||||
isClosingTab,
|
isClosingTab,
|
||||||
normalizedServerId,
|
presentation,
|
||||||
normalizedWorkspaceId,
|
tooltipLabel,
|
||||||
|
resolvedTab,
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
setHoveredCloseTabKey,
|
setHoveredCloseTabKey,
|
||||||
onNavigateTab,
|
onNavigateTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCopyResumeCommand,
|
|
||||||
onCopyAgentId,
|
|
||||||
onCloseTabsToLeft,
|
|
||||||
onCloseTabsToRight,
|
|
||||||
onCloseOtherTabs,
|
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: {
|
}: {
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
index: number;
|
|
||||||
tabCount: number;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
resolvedTabWidth: number;
|
resolvedTabWidth: number;
|
||||||
showLabel: boolean;
|
showLabel: boolean;
|
||||||
showCloseButton: boolean;
|
showCloseButton: boolean;
|
||||||
isCloseHovered: boolean;
|
isCloseHovered: boolean;
|
||||||
isClosingTab: boolean;
|
isClosingTab: boolean;
|
||||||
normalizedServerId: string;
|
presentation: WorkspaceTabPresentation;
|
||||||
normalizedWorkspaceId: string;
|
tooltipLabel: string;
|
||||||
|
resolvedTab: WorkspaceDesktopTabActions;
|
||||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
onNavigateTab: (tabId: string) => void;
|
onNavigateTab: (tabId: string) => void;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
|
||||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
|
||||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
|
||||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
|
||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
|
||||||
dragHandleProps: any;
|
dragHandleProps: any;
|
||||||
}) {
|
}) {
|
||||||
const presentation = useWorkspaceTabPresentation({
|
|
||||||
tab,
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
});
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
||||||
const tooltipLabel =
|
|
||||||
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
|
|
||||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
|
||||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
|
||||||
surface: "desktop",
|
|
||||||
tab,
|
|
||||||
index,
|
|
||||||
tabCount,
|
|
||||||
menuTestIDBase: contextMenuTestId,
|
|
||||||
onCopyResumeCommand,
|
|
||||||
onCopyAgentId,
|
|
||||||
onCloseTab,
|
|
||||||
onCloseTabsBefore: onCloseTabsToLeft,
|
|
||||||
onCloseTabsAfter: onCloseTabsToRight,
|
|
||||||
onCloseOtherTabs,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={tab.key}>
|
<ContextMenu key={tab.key}>
|
||||||
@@ -214,7 +172,7 @@ function TabChip({
|
|||||||
|
|
||||||
{showCloseButton ? (
|
{showCloseButton ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={getCloseButtonTestId(tab)}
|
testID={closeButtonTestId}
|
||||||
disabled={isClosingTab}
|
disabled={isClosingTab}
|
||||||
onHoverIn={() => {
|
onHoverIn={() => {
|
||||||
setHoveredTabKey(tab.key);
|
setHoveredTabKey(tab.key);
|
||||||
@@ -280,15 +238,10 @@ function TabChip({
|
|||||||
export function WorkspaceDesktopTabsRow({
|
export function WorkspaceDesktopTabsRow({
|
||||||
paneId,
|
paneId,
|
||||||
tabs,
|
tabs,
|
||||||
activeTabKey,
|
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
hoveredCloseTabKey,
|
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
setHoveredCloseTabKey,
|
setHoveredCloseTabKey,
|
||||||
isArchivingAgent,
|
|
||||||
killTerminalPending,
|
|
||||||
killTerminalId,
|
|
||||||
onNavigateTab,
|
onNavigateTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
@@ -334,7 +287,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
const tabLabelLengths = useMemo(
|
const tabLabelLengths = useMemo(
|
||||||
() =>
|
() =>
|
||||||
tabs.map((tab) => {
|
tabs.map((tab) => {
|
||||||
const label = getFallbackTabLabel(tab);
|
const label = getFallbackTabLabel(tab.tab);
|
||||||
return label.length;
|
return label.length;
|
||||||
}),
|
}),
|
||||||
[tabs]
|
[tabs]
|
||||||
@@ -365,10 +318,10 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
>
|
>
|
||||||
<SortableInlineList
|
<SortableInlineList
|
||||||
data={tabs}
|
data={tabs}
|
||||||
keyExtractor={(tab) => tab.key}
|
keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
disabled={tabs.length < 2}
|
disabled={tabs.length < 2}
|
||||||
onDragEnd={onReorderTabs}
|
onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
|
||||||
externalDndContext={externalDndContext}
|
externalDndContext={externalDndContext}
|
||||||
activeId={activeDragTabId}
|
activeId={activeDragTabId}
|
||||||
getItemData={
|
getItemData={
|
||||||
@@ -376,52 +329,36 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
? (tab) => ({
|
? (tab) => ({
|
||||||
kind: "workspace-tab",
|
kind: "workspace-tab",
|
||||||
paneId,
|
paneId,
|
||||||
tabId: tab.tabId,
|
tabId: tab.tab.tabId,
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
renderItem={({ item: tab, index, dragHandleProps }) => {
|
renderItem={({ item, index, dragHandleProps }) => {
|
||||||
const isActive = tab.key === activeTabKey;
|
|
||||||
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
|
||||||
const isClosingAgent =
|
|
||||||
tab.target.kind === "agent" &&
|
|
||||||
isArchivingAgent({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
agentId: tab.target.agentId,
|
|
||||||
});
|
|
||||||
const isClosingTerminal =
|
|
||||||
tab.target.kind === "terminal" &&
|
|
||||||
killTerminalPending &&
|
|
||||||
killTerminalId === tab.target.terminalId;
|
|
||||||
const isClosingTab = isClosingAgent || isClosingTerminal;
|
|
||||||
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
|
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
|
||||||
const layoutItem = layout.items[index] ?? null;
|
const layoutItem = layout.items[index] ?? null;
|
||||||
const resolvedTabWidth = layoutItem?.width ?? 150;
|
const resolvedTabWidth = layoutItem?.width ?? 150;
|
||||||
const showLabel = layoutItem?.showLabel ?? true;
|
const showLabel = layoutItem?.showLabel ?? true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabChip
|
<ResolvedDesktopTabChip
|
||||||
key={tab.key}
|
key={`${item.tab.key}:${item.tab.kind}`}
|
||||||
tab={tab}
|
item={item}
|
||||||
index={index}
|
index={index}
|
||||||
tabCount={tabs.length}
|
tabCount={tabs.length}
|
||||||
isActive={isActive}
|
|
||||||
resolvedTabWidth={resolvedTabWidth}
|
|
||||||
showLabel={showLabel}
|
|
||||||
showCloseButton={shouldShowCloseButton}
|
|
||||||
isCloseHovered={isCloseHovered}
|
|
||||||
isClosingTab={isClosingTab}
|
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
|
||||||
onNavigateTab={onNavigateTab}
|
|
||||||
onCloseTab={onCloseTab}
|
|
||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
|
resolvedTabWidth={resolvedTabWidth}
|
||||||
|
showLabel={showLabel}
|
||||||
|
showCloseButton={shouldShowCloseButton}
|
||||||
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
|
onNavigateTab={onNavigateTab}
|
||||||
|
onCloseTab={onCloseTab}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -454,6 +391,99 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResolvedDesktopTabChip({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
tabCount,
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
|
onCopyResumeCommand,
|
||||||
|
onCopyAgentId,
|
||||||
|
onCloseTabsToLeft,
|
||||||
|
onCloseTabsToRight,
|
||||||
|
onCloseOtherTabs,
|
||||||
|
resolvedTabWidth,
|
||||||
|
showLabel,
|
||||||
|
showCloseButton,
|
||||||
|
setHoveredTabKey,
|
||||||
|
setHoveredCloseTabKey,
|
||||||
|
onNavigateTab,
|
||||||
|
onCloseTab,
|
||||||
|
dragHandleProps,
|
||||||
|
}: {
|
||||||
|
item: WorkspaceDesktopTabRowItem;
|
||||||
|
index: number;
|
||||||
|
tabCount: number;
|
||||||
|
normalizedServerId: string;
|
||||||
|
normalizedWorkspaceId: string;
|
||||||
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
|
resolvedTabWidth: number;
|
||||||
|
showLabel: boolean;
|
||||||
|
showCloseButton: boolean;
|
||||||
|
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
|
onNavigateTab: (tabId: string) => void;
|
||||||
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
|
dragHandleProps: any;
|
||||||
|
}) {
|
||||||
|
const presentation = useWorkspaceTabPresentation({
|
||||||
|
tab: item.tab,
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
});
|
||||||
|
const tooltipLabel =
|
||||||
|
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
|
||||||
|
const resolvedTab = useMemo(
|
||||||
|
() =>
|
||||||
|
buildWorkspaceDesktopTabActions({
|
||||||
|
tab: item.tab,
|
||||||
|
index,
|
||||||
|
tabCount,
|
||||||
|
onCopyResumeCommand,
|
||||||
|
onCopyAgentId,
|
||||||
|
onCloseTab,
|
||||||
|
onCloseTabsToLeft,
|
||||||
|
onCloseTabsToRight,
|
||||||
|
onCloseOtherTabs,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
index,
|
||||||
|
item.tab,
|
||||||
|
onCloseOtherTabs,
|
||||||
|
onCloseTab,
|
||||||
|
onCloseTabsToLeft,
|
||||||
|
onCloseTabsToRight,
|
||||||
|
onCopyAgentId,
|
||||||
|
onCopyResumeCommand,
|
||||||
|
tabCount,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabChip
|
||||||
|
tab={item.tab}
|
||||||
|
isActive={item.isActive}
|
||||||
|
resolvedTabWidth={resolvedTabWidth}
|
||||||
|
showLabel={showLabel}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
isCloseHovered={item.isCloseHovered}
|
||||||
|
isClosingTab={item.isClosingTab}
|
||||||
|
presentation={presentation}
|
||||||
|
tooltipLabel={tooltipLabel}
|
||||||
|
resolvedTab={resolvedTab}
|
||||||
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
|
onNavigateTab={onNavigateTab}
|
||||||
|
onCloseTab={onCloseTab}
|
||||||
|
dragHandleProps={dragHandleProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
tabsContainer: {
|
tabsContainer: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { PaneProvider } from "@/panels/pane-context";
|
import { PaneProvider, type PaneContextValue } from "@/panels/pane-context";
|
||||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
|
||||||
export interface WorkspacePaneContentProps {
|
export interface WorkspacePaneContentModel {
|
||||||
|
key: string;
|
||||||
|
Component: ComponentType;
|
||||||
|
paneContextValue: PaneContextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildWorkspacePaneContentModelInput {
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
normalizedServerId: string;
|
normalizedServerId: string;
|
||||||
normalizedWorkspaceId: string;
|
normalizedWorkspaceId: string;
|
||||||
@@ -14,7 +21,7 @@ export interface WorkspacePaneContentProps {
|
|||||||
onOpenWorkspaceFile: (filePath: string) => void;
|
onOpenWorkspaceFile: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspacePaneContent({
|
export function buildWorkspacePaneContentModel({
|
||||||
tab,
|
tab,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
@@ -22,26 +29,36 @@ export function WorkspacePaneContent({
|
|||||||
onCloseCurrentTab,
|
onCloseCurrentTab,
|
||||||
onRetargetCurrentTab,
|
onRetargetCurrentTab,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: WorkspacePaneContentProps) {
|
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
||||||
ensurePanelsRegistered();
|
ensurePanelsRegistered();
|
||||||
const registration = getPanelRegistration(tab.kind);
|
const registration = getPanelRegistration(tab.kind);
|
||||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||||
const Component = registration.component;
|
return {
|
||||||
|
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
|
||||||
|
Component: registration.component,
|
||||||
|
paneContextValue: {
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabId: tab.tabId,
|
||||||
|
target: tab.target,
|
||||||
|
openTab: onOpenTab,
|
||||||
|
closeCurrentTab: onCloseCurrentTab,
|
||||||
|
retargetCurrentTab: onRetargetCurrentTab,
|
||||||
|
openFileInWorkspace: onOpenWorkspaceFile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacePaneContentProps {
|
||||||
|
content: WorkspacePaneContentModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) {
|
||||||
|
const { Component, key, paneContextValue } = content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaneProvider
|
<PaneProvider value={paneContextValue}>
|
||||||
value={{
|
<Component key={key} />
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
tabId: tab.tabId,
|
|
||||||
target: tab.target,
|
|
||||||
openTab: onOpenTab,
|
|
||||||
closeCurrentTab: onCloseCurrentTab,
|
|
||||||
retargetCurrentTab: onRetargetCurrentTab,
|
|
||||||
openFileInWorkspace: onOpenWorkspaceFile,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Component key={`${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`} />
|
|
||||||
</PaneProvider>
|
</PaneProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { deriveWorkspacePaneState, getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state";
|
||||||
|
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||||
|
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
|
function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab {
|
||||||
|
return {
|
||||||
|
tabId,
|
||||||
|
target,
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workspace-pane-state", () => {
|
||||||
|
it("selects the focused pane and keeps its tab order", () => {
|
||||||
|
const tabs: WorkspaceTab[] = [
|
||||||
|
createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" }),
|
||||||
|
createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" }),
|
||||||
|
createTab("terminal_term-1", { kind: "terminal", terminalId: "term-1" }),
|
||||||
|
];
|
||||||
|
const layout: WorkspaceLayout = {
|
||||||
|
root: {
|
||||||
|
kind: "group",
|
||||||
|
group: {
|
||||||
|
id: "group-root",
|
||||||
|
direction: "horizontal",
|
||||||
|
sizes: [0.5, 0.5],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
kind: "pane",
|
||||||
|
pane: {
|
||||||
|
id: "left",
|
||||||
|
tabIds: ["file_/repo/README.md", "agent_agent-a"],
|
||||||
|
focusedTabId: "agent_agent-a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "pane",
|
||||||
|
pane: {
|
||||||
|
id: "right",
|
||||||
|
tabIds: ["terminal_term-1"],
|
||||||
|
focusedTabId: "terminal_term-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focusedPaneId: "left",
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = deriveWorkspacePaneState({ layout, tabs });
|
||||||
|
|
||||||
|
expect(state.pane?.id).toBe("left");
|
||||||
|
expect(state.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||||
|
"file_/repo/README.md",
|
||||||
|
"agent_agent-a",
|
||||||
|
]);
|
||||||
|
expect(state.activeTabId).toBe("agent_agent-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first ordered pane tab when focusedTabId is empty", () => {
|
||||||
|
const pane = {
|
||||||
|
id: "main",
|
||||||
|
tabIds: ["draft_1", "draft_2"],
|
||||||
|
focusedTabId: " ",
|
||||||
|
};
|
||||||
|
const tabs: WorkspaceTab[] = [
|
||||||
|
createTab("draft_2", { kind: "draft", draftId: "draft_2" }),
|
||||||
|
createTab("draft_1", { kind: "draft", draftId: "draft_1" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = deriveWorkspacePaneState({ pane, tabs });
|
||||||
|
|
||||||
|
expect(state.activeTabId).toBe("draft_1");
|
||||||
|
expect(getWorkspacePaneDescriptors({ pane, tabs }).map((tab) => tab.tabId)).toEqual([
|
||||||
|
"draft_1",
|
||||||
|
"draft_2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a matching target over stale focused tab state", () => {
|
||||||
|
const pane = {
|
||||||
|
id: "main",
|
||||||
|
tabIds: ["agent_agent-a", "file_/repo/README.md"],
|
||||||
|
focusedTabId: "agent_agent-a",
|
||||||
|
};
|
||||||
|
const tabs: WorkspaceTab[] = [
|
||||||
|
createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" }),
|
||||||
|
createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = deriveWorkspacePaneState({
|
||||||
|
pane,
|
||||||
|
tabs,
|
||||||
|
preferredTarget: { kind: "file", path: "\\repo\\README.md" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.activeTabId).toBe("file_/repo/README.md");
|
||||||
|
expect(state.activeTab?.descriptor.target).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
path: "/repo/README.md",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
findPaneById,
|
||||||
|
type SplitPane,
|
||||||
|
type WorkspaceLayout,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
import {
|
||||||
|
buildDeterministicWorkspaceTabId,
|
||||||
|
normalizeWorkspaceTabTarget,
|
||||||
|
workspaceTabTargetsEqual,
|
||||||
|
} from "@/utils/workspace-tab-identity";
|
||||||
|
|
||||||
|
export interface WorkspaceDerivedTab {
|
||||||
|
descriptor: WorkspaceTabDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacePaneState {
|
||||||
|
pane: SplitPane | null;
|
||||||
|
tabs: WorkspaceDerivedTab[];
|
||||||
|
focusedTabId: string | null;
|
||||||
|
activeTabId: string | null;
|
||||||
|
activeTab: WorkspaceDerivedTab | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalizeWorkspacePaneTabsResult {
|
||||||
|
tabs: WorkspaceDerivedTab[];
|
||||||
|
openTabIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
|
||||||
|
if (!tab || typeof tab !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tabId = trimNonEmpty(tab.tabId);
|
||||||
|
if (!tabId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const target = normalizeWorkspaceTabTarget(tab.target);
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabId,
|
||||||
|
target,
|
||||||
|
createdAt: tab.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderPaneTabs(input: { pane: SplitPane | null; tabs: WorkspaceTab[] }): WorkspaceTab[] {
|
||||||
|
if (!input.pane) {
|
||||||
|
return input.tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsById = new Map<string, WorkspaceTab>();
|
||||||
|
for (const tab of input.tabs) {
|
||||||
|
tabsById.set(tab.tabId, tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedTabs: WorkspaceTab[] = [];
|
||||||
|
for (const tabId of input.pane.tabIds) {
|
||||||
|
const tab = tabsById.get(tabId);
|
||||||
|
if (tab) {
|
||||||
|
orderedTabs.push(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orderedTabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkspacePaneTabs(tabs: WorkspaceTab[]): NormalizeWorkspacePaneTabsResult {
|
||||||
|
const nextTabs: WorkspaceDerivedTab[] = [];
|
||||||
|
const openTabIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const normalizedTab = normalizeWorkspaceTab(tab);
|
||||||
|
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
openTabIds.add(normalizedTab.tabId);
|
||||||
|
nextTabs.push({
|
||||||
|
descriptor: {
|
||||||
|
key: normalizedTab.tabId,
|
||||||
|
tabId: normalizedTab.tabId,
|
||||||
|
kind: normalizedTab.target.kind,
|
||||||
|
target: normalizedTab.target,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs: nextTabs,
|
||||||
|
openTabIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveTabId(input: {
|
||||||
|
tabs: WorkspaceDerivedTab[];
|
||||||
|
openTabIds: Set<string>;
|
||||||
|
focusedTabId?: string | null;
|
||||||
|
preferredTarget?: WorkspaceTabTarget | null;
|
||||||
|
}): string | null {
|
||||||
|
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||||
|
const preferredTarget = normalizeWorkspaceTabTarget(input.preferredTarget ?? null);
|
||||||
|
const preferredTabId = (() => {
|
||||||
|
if (!preferredTarget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const matchingTab =
|
||||||
|
input.tabs.find((tab) => workspaceTabTargetsEqual(tab.descriptor.target, preferredTarget)) ??
|
||||||
|
null;
|
||||||
|
return matchingTab?.descriptor.tabId ?? buildDeterministicWorkspaceTabId(preferredTarget);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (preferredTabId && input.openTabIds.has(preferredTabId)) {
|
||||||
|
return preferredTabId;
|
||||||
|
}
|
||||||
|
if (focusedTabId && input.openTabIds.has(focusedTabId)) {
|
||||||
|
return focusedTabId;
|
||||||
|
}
|
||||||
|
return input.tabs[0]?.descriptor.tabId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPane(input: {
|
||||||
|
layout: WorkspaceLayout | null;
|
||||||
|
pane?: SplitPane | null;
|
||||||
|
paneId?: string | null;
|
||||||
|
}): SplitPane | null {
|
||||||
|
if (input.pane) {
|
||||||
|
return input.pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = input.layout;
|
||||||
|
if (!layout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPaneId = trimNonEmpty(input.paneId) ?? layout.focusedPaneId;
|
||||||
|
if (!resolvedPaneId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findPaneById(layout.root, resolvedPaneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveWorkspacePaneState(input: {
|
||||||
|
layout?: WorkspaceLayout | null;
|
||||||
|
pane?: SplitPane | null;
|
||||||
|
paneId?: string | null;
|
||||||
|
tabs: WorkspaceTab[];
|
||||||
|
focusedTabId?: string | null;
|
||||||
|
preferredTarget?: WorkspaceTabTarget | null;
|
||||||
|
}): WorkspacePaneState {
|
||||||
|
const pane = getPane({
|
||||||
|
layout: input.layout ?? null,
|
||||||
|
pane: input.pane ?? null,
|
||||||
|
paneId: input.paneId,
|
||||||
|
});
|
||||||
|
const orderedTabs = orderPaneTabs({
|
||||||
|
pane,
|
||||||
|
tabs: input.tabs,
|
||||||
|
});
|
||||||
|
const normalizedTabs = normalizeWorkspacePaneTabs(orderedTabs);
|
||||||
|
const focusedTabId = pane?.focusedTabId ?? trimNonEmpty(input.focusedTabId) ?? null;
|
||||||
|
const activeTabId = getActiveTabId({
|
||||||
|
tabs: normalizedTabs.tabs,
|
||||||
|
openTabIds: normalizedTabs.openTabIds,
|
||||||
|
focusedTabId,
|
||||||
|
preferredTarget: input.preferredTarget,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
pane,
|
||||||
|
tabs: normalizedTabs.tabs,
|
||||||
|
focusedTabId,
|
||||||
|
activeTabId,
|
||||||
|
activeTab:
|
||||||
|
activeTabId
|
||||||
|
? normalizedTabs.tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspacePaneDescriptors(input: {
|
||||||
|
layout?: WorkspaceLayout | null;
|
||||||
|
pane?: SplitPane | null;
|
||||||
|
paneId?: string | null;
|
||||||
|
tabs: WorkspaceTab[];
|
||||||
|
}): WorkspaceTabDescriptor[] {
|
||||||
|
return deriveWorkspacePaneState(input).tabs.map((tab) => tab.descriptor);
|
||||||
|
}
|
||||||
@@ -54,10 +54,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
findPaneById,
|
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
type SplitPane,
|
|
||||||
type WorkspaceLayout,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
@@ -69,6 +66,10 @@ import {
|
|||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||||
|
import {
|
||||||
|
normalizeWorkspaceTabTarget,
|
||||||
|
workspaceTabTargetsEqual,
|
||||||
|
} from "@/utils/workspace-tab-identity";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||||
import {
|
import {
|
||||||
@@ -95,10 +96,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
deriveWorkspaceAgentVisibility,
|
deriveWorkspaceAgentVisibility,
|
||||||
} from "@/screens/workspace/workspace-agent-visibility";
|
} from "@/screens/workspace/workspace-agent-visibility";
|
||||||
|
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||||
import {
|
import {
|
||||||
deriveWorkspaceTabModel,
|
buildWorkspacePaneContentModel,
|
||||||
} from "@/screens/workspace/workspace-tab-model";
|
WorkspacePaneContent,
|
||||||
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
import {
|
import {
|
||||||
buildBulkCloseConfirmationMessage,
|
buildBulkCloseConfirmationMessage,
|
||||||
classifyBulkClosableTabs,
|
classifyBulkClosableTabs,
|
||||||
@@ -109,11 +111,6 @@ const TERMINALS_QUERY_STALE_TIME = 5_000;
|
|||||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||||
|
|
||||||
interface FocusedPaneTabState {
|
|
||||||
tabs: WorkspaceTab[];
|
|
||||||
focusedTabId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceScreenProps = {
|
type WorkspaceScreenProps = {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -177,82 +174,6 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
|||||||
return tab.target.path;
|
return tab.target.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspaceTabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
|
||||||
if (left.kind !== right.kind) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (left.kind === "draft" && right.kind === "draft") {
|
|
||||||
return left.draftId === right.draftId;
|
|
||||||
}
|
|
||||||
if (left.kind === "agent" && right.kind === "agent") {
|
|
||||||
return left.agentId === right.agentId;
|
|
||||||
}
|
|
||||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
|
||||||
return left.terminalId === right.terminalId;
|
|
||||||
}
|
|
||||||
if (left.kind === "file" && right.kind === "file") {
|
|
||||||
return left.path === right.path;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFocusedPaneTabState(input: {
|
|
||||||
layout: WorkspaceLayout | null;
|
|
||||||
tabs: WorkspaceTab[];
|
|
||||||
}): FocusedPaneTabState {
|
|
||||||
const { layout, tabs } = input;
|
|
||||||
if (!layout) {
|
|
||||||
return {
|
|
||||||
tabs,
|
|
||||||
focusedTabId: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
|
|
||||||
if (!focusedPane) {
|
|
||||||
return {
|
|
||||||
tabs,
|
|
||||||
focusedTabId: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabsById = new Map<string, WorkspaceTab>();
|
|
||||||
for (const tab of tabs) {
|
|
||||||
tabsById.set(tab.tabId, tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderedTabs: WorkspaceTab[] = [];
|
|
||||||
for (const tabId of focusedPane.tabIds) {
|
|
||||||
const tab = tabsById.get(tabId);
|
|
||||||
if (tab) {
|
|
||||||
orderedTabs.push(tab);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tabs: orderedTabs,
|
|
||||||
focusedTabId: focusedPane.focusedTabId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEffectiveFocusedTabId(input: FocusedPaneTabState): string | null {
|
|
||||||
return trimNonEmpty(input.focusedTabId) ?? input.tabs[0]?.tabId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFocusedPane(layout: WorkspaceLayout | null): SplitPane | null {
|
|
||||||
if (!layout) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return findPaneById(layout.root, layout.focusedPaneId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFocusedPaneActiveTabId(pane: SplitPane | null): string | null {
|
|
||||||
if (!pane) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return trimNonEmpty(pane.focusedTabId) ?? pane.tabIds[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MobileWorkspaceTabSwitcherProps = {
|
type MobileWorkspaceTabSwitcherProps = {
|
||||||
tabs: WorkspaceTabDescriptor[];
|
tabs: WorkspaceTabDescriptor[];
|
||||||
activeTabKey: string;
|
activeTabKey: string;
|
||||||
@@ -873,7 +794,7 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const focusedPaneTabState = useMemo(
|
const focusedPaneTabState = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getFocusedPaneTabState({
|
deriveWorkspacePaneState({
|
||||||
layout: workspaceLayout,
|
layout: workspaceLayout,
|
||||||
tabs: uiTabs,
|
tabs: uiTabs,
|
||||||
}),
|
}),
|
||||||
@@ -886,14 +807,19 @@ function WorkspaceScreenContent({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
|
if (!normalizedTarget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const existingTab =
|
const existingTab =
|
||||||
uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, target)) ?? null;
|
uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
return existingTab.tabId;
|
return existingTab.tabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousFocusedTabId = getEffectiveFocusedTabId(focusedPaneTabState);
|
const previousFocusedTabId = focusedPaneTabState.activeTabId;
|
||||||
const tabId = openWorkspaceTab(persistenceKey, target);
|
const tabId = openWorkspaceTab(persistenceKey, normalizedTarget);
|
||||||
if (tabId && previousFocusedTabId && previousFocusedTabId !== tabId) {
|
if (tabId && previousFocusedTabId && previousFocusedTabId !== tabId) {
|
||||||
focusWorkspaceTab(persistenceKey, previousFocusedTabId);
|
focusWorkspaceTab(persistenceKey, previousFocusedTabId);
|
||||||
}
|
}
|
||||||
@@ -908,10 +834,11 @@ function WorkspaceScreenContent({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target: WorkspaceTabTarget = {
|
const target = normalizeWorkspaceTabTarget({
|
||||||
kind: "draft",
|
kind: "draft",
|
||||||
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
|
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
|
||||||
};
|
});
|
||||||
|
invariant(target?.kind === "draft", "Draft tab target must be valid");
|
||||||
if (input?.focus === false) {
|
if (input?.focus === false) {
|
||||||
return ensureWorkspaceTab(target);
|
return ensureWorkspaceTab(target);
|
||||||
}
|
}
|
||||||
@@ -989,6 +916,24 @@ function WorkspaceScreenContent({
|
|||||||
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
|
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
|
||||||
? openIntent
|
? openIntent
|
||||||
: null;
|
: null;
|
||||||
|
const resolvedPaneTabState = useMemo(
|
||||||
|
() =>
|
||||||
|
deriveWorkspacePaneState({
|
||||||
|
layout: workspaceLayout,
|
||||||
|
tabs: uiTabs,
|
||||||
|
preferredTarget:
|
||||||
|
unresolvedOpenIntent?.kind === "agent"
|
||||||
|
? { kind: "agent", agentId: unresolvedOpenIntent.agentId }
|
||||||
|
: unresolvedOpenIntent?.kind === "terminal"
|
||||||
|
? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId }
|
||||||
|
: unresolvedOpenIntent?.kind === "draft"
|
||||||
|
? { kind: "draft", draftId: unresolvedOpenIntent.draftId }
|
||||||
|
: unresolvedOpenIntent?.kind === "file"
|
||||||
|
? { kind: "file", path: unresolvedOpenIntent.path }
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
[uiTabs, unresolvedOpenIntent, workspaceLayout]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
|
if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
|
||||||
@@ -1051,25 +996,7 @@ function WorkspaceScreenContent({
|
|||||||
workspaceAgents,
|
workspaceAgents,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tabModel = useMemo(
|
const activeTabId = resolvedPaneTabState.activeTabId;
|
||||||
() =>
|
|
||||||
deriveWorkspaceTabModel({
|
|
||||||
tabs: focusedPaneTabState.tabs,
|
|
||||||
focusedTabId: focusedPaneTabState.focusedTabId,
|
|
||||||
preferredTarget:
|
|
||||||
unresolvedOpenIntent?.kind === "agent"
|
|
||||||
? { kind: "agent", agentId: unresolvedOpenIntent.agentId }
|
|
||||||
: unresolvedOpenIntent?.kind === "terminal"
|
|
||||||
? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId }
|
|
||||||
: unresolvedOpenIntent?.kind === "draft"
|
|
||||||
? { kind: "draft", draftId: unresolvedOpenIntent.draftId }
|
|
||||||
: unresolvedOpenIntent?.kind === "file"
|
|
||||||
? { kind: "file", path: unresolvedOpenIntent.path }
|
|
||||||
: null,
|
|
||||||
}),
|
|
||||||
[focusedPaneTabState, unresolvedOpenIntent]
|
|
||||||
);
|
|
||||||
const activeTabId = tabModel.activeTabId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTabId || !persistenceKey) {
|
if (!activeTabId || !persistenceKey) {
|
||||||
@@ -1078,11 +1005,11 @@ function WorkspaceScreenContent({
|
|||||||
focusWorkspaceTab(persistenceKey, activeTabId);
|
focusWorkspaceTab(persistenceKey, activeTabId);
|
||||||
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
||||||
|
|
||||||
const activeTab = tabModel.activeTab;
|
const activeTab = resolvedPaneTabState.activeTab;
|
||||||
|
|
||||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||||
() => tabModel.tabs.map((tab) => tab.descriptor),
|
() => resolvedPaneTabState.tabs.map((tab) => tab.descriptor),
|
||||||
[tabModel.tabs]
|
[resolvedPaneTabState.tabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigateToTabId = useCallback(
|
const navigateToTabId = useCallback(
|
||||||
@@ -1625,13 +1552,13 @@ function WorkspaceScreenContent({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusedPane = getFocusedPane(workspaceLayout);
|
const focusedPane = focusedPaneTabState.pane;
|
||||||
if (!focusedPane) {
|
if (!focusedPane) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.split.right") {
|
if (action.id === "workspace.pane.split.right") {
|
||||||
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
|
const activePaneTabId = focusedPaneTabState.activeTabId;
|
||||||
if (activePaneTabId) {
|
if (activePaneTabId) {
|
||||||
splitWorkspacePane(persistenceKey, {
|
splitWorkspacePane(persistenceKey, {
|
||||||
tabId: activePaneTabId,
|
tabId: activePaneTabId,
|
||||||
@@ -1643,7 +1570,7 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.split.down") {
|
if (action.id === "workspace.pane.split.down") {
|
||||||
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
|
const activePaneTabId = focusedPaneTabState.activeTabId;
|
||||||
if (activePaneTabId) {
|
if (activePaneTabId) {
|
||||||
splitWorkspacePane(persistenceKey, {
|
splitWorkspacePane(persistenceKey, {
|
||||||
tabId: activePaneTabId,
|
tabId: activePaneTabId,
|
||||||
@@ -1688,7 +1615,7 @@ function WorkspaceScreenContent({
|
|||||||
direction === "up" ||
|
direction === "up" ||
|
||||||
direction === "down"
|
direction === "down"
|
||||||
) {
|
) {
|
||||||
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
|
const activePaneTabId = focusedPaneTabState.activeTabId;
|
||||||
const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction);
|
const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction);
|
||||||
if (activePaneTabId && adjacentPaneId) {
|
if (activePaneTabId && adjacentPaneId) {
|
||||||
moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId);
|
moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId);
|
||||||
@@ -1712,6 +1639,8 @@ function WorkspaceScreenContent({
|
|||||||
moveWorkspaceTabToPane,
|
moveWorkspaceTabToPane,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
splitWorkspacePane,
|
splitWorkspacePane,
|
||||||
|
focusedPaneTabState.activeTabId,
|
||||||
|
focusedPaneTabState.pane,
|
||||||
workspaceLayout,
|
workspaceLayout,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1752,6 +1681,67 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeTabDescriptor = activeTab?.descriptor ?? null;
|
const activeTabDescriptor = activeTab?.descriptor ?? null;
|
||||||
|
const buildPaneContentModel = useCallback(
|
||||||
|
(input: {
|
||||||
|
tab: WorkspaceTabDescriptor;
|
||||||
|
paneId?: string | null;
|
||||||
|
focusPaneBeforeOpen?: boolean;
|
||||||
|
}) =>
|
||||||
|
buildWorkspacePaneContentModel({
|
||||||
|
tab: input.tab,
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
|
onOpenTab: (target) => {
|
||||||
|
if (!persistenceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.focusPaneBeforeOpen && input.paneId) {
|
||||||
|
focusWorkspacePane(persistenceKey, input.paneId);
|
||||||
|
}
|
||||||
|
const tabId = openWorkspaceTab(persistenceKey, target);
|
||||||
|
if (tabId) {
|
||||||
|
navigateToTabId(tabId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCloseCurrentTab: () => {
|
||||||
|
void handleCloseTabById(input.tab.tabId);
|
||||||
|
},
|
||||||
|
onRetargetCurrentTab: (target) => {
|
||||||
|
if (!persistenceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
||||||
|
},
|
||||||
|
onOpenWorkspaceFile: (filePath) => {
|
||||||
|
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
|
||||||
|
focusWorkspacePane(persistenceKey, input.paneId);
|
||||||
|
}
|
||||||
|
handleOpenFileFromChat({ filePath });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
handleCloseTabById,
|
||||||
|
handleOpenFileFromChat,
|
||||||
|
focusWorkspacePane,
|
||||||
|
navigateToTabId,
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
|
openWorkspaceTab,
|
||||||
|
persistenceKey,
|
||||||
|
retargetWorkspaceTab,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const activePaneContent = useMemo(
|
||||||
|
() =>
|
||||||
|
activeTabDescriptor
|
||||||
|
? buildPaneContentModel({
|
||||||
|
tab: activeTabDescriptor,
|
||||||
|
paneId: focusedPaneTabState.pane?.id ?? null,
|
||||||
|
focusPaneBeforeOpen: false,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
[activeTabDescriptor, buildPaneContentModel, focusedPaneTabState.pane?.id]
|
||||||
|
);
|
||||||
const content = shouldRenderMissingWorkspaceDescriptor({
|
const content = shouldRenderMissingWorkspaceDescriptor({
|
||||||
workspace: workspaceDescriptor,
|
workspace: workspaceDescriptor,
|
||||||
hasHydratedWorkspaces,
|
hasHydratedWorkspaces,
|
||||||
@@ -1772,34 +1762,7 @@ function WorkspaceScreenContent({
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<WorkspacePaneContent
|
<WorkspacePaneContent content={activePaneContent!} />
|
||||||
tab={activeTabDescriptor}
|
|
||||||
normalizedServerId={normalizedServerId}
|
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
|
||||||
onOpenTab={(target) => {
|
|
||||||
if (!persistenceKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tabId = openWorkspaceTab(persistenceKey, target);
|
|
||||||
if (tabId) {
|
|
||||||
navigateToTabId(tabId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCloseCurrentTab={() => {
|
|
||||||
if (activeTabDescriptor.tabId) {
|
|
||||||
void handleCloseTabById(activeTabDescriptor.tabId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onRetargetCurrentTab={(target) => {
|
|
||||||
if (!persistenceKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
retargetWorkspaceTab(persistenceKey, activeTabDescriptor.tabId, target);
|
|
||||||
}}
|
|
||||||
onOpenWorkspaceFile={(filePath) => {
|
|
||||||
handleOpenFileFromChat({ filePath });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2034,23 +1997,13 @@ function WorkspaceScreenContent({
|
|||||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||||
onSelectNewTabOption={handleSelectNewTabOption}
|
onSelectNewTabOption={handleSelectNewTabOption}
|
||||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||||
onOpenPaneTab={({ paneId, target }) => {
|
buildPaneContentModel={({ paneId, tab }) =>
|
||||||
focusWorkspacePane(persistenceKey, paneId);
|
buildPaneContentModel({
|
||||||
const tabId = openWorkspaceTab(persistenceKey, target);
|
tab,
|
||||||
if (tabId) {
|
paneId,
|
||||||
navigateToTabId(tabId);
|
focusPaneBeforeOpen: true,
|
||||||
}
|
})
|
||||||
}}
|
}
|
||||||
onRetargetTab={(tabId, target) => {
|
|
||||||
retargetWorkspaceTab(persistenceKey, tabId, target);
|
|
||||||
}}
|
|
||||||
onOpenWorkspaceFile={({ paneId, filePath }) => {
|
|
||||||
focusWorkspacePane(persistenceKey, paneId);
|
|
||||||
const tabId = openWorkspaceTab(persistenceKey, { kind: "file", path: filePath });
|
|
||||||
if (tabId) {
|
|
||||||
navigateToTabId(tabId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocusPane={(paneId) => {
|
onFocusPane={(paneId) => {
|
||||||
focusWorkspacePane(persistenceKey, paneId);
|
focusWorkspacePane(persistenceKey, paneId);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||||
|
|
||||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||||
|
|
||||||
@@ -31,6 +32,24 @@ interface BuildWorkspaceTabMenuEntriesInput {
|
|||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BuildWorkspaceDesktopTabActionsInput {
|
||||||
|
tab: WorkspaceTabDescriptor;
|
||||||
|
index: number;
|
||||||
|
tabCount: number;
|
||||||
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceDesktopTabActions {
|
||||||
|
contextMenuTestId: string;
|
||||||
|
menuEntries: WorkspaceTabMenuEntry[];
|
||||||
|
closeButtonTestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
|
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
|
||||||
return surface === "mobile" ? "Close tabs above" : "Close to the left";
|
return surface === "mobile" ? "Close tabs above" : "Close to the left";
|
||||||
}
|
}
|
||||||
@@ -47,6 +66,19 @@ function buildCloseAfterTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
|||||||
return surface === "mobile" ? "close-below" : "close-right";
|
return surface === "mobile" ? "close-below" : "close-right";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||||
|
if (tab.target.kind === "agent") {
|
||||||
|
return `workspace-agent-close-${tab.target.agentId}`;
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "terminal") {
|
||||||
|
return `workspace-terminal-close-${tab.target.terminalId}`;
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "draft") {
|
||||||
|
return `workspace-draft-close-${tab.target.draftId}`;
|
||||||
|
}
|
||||||
|
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildWorkspaceTabMenuEntries(
|
export function buildWorkspaceTabMenuEntries(
|
||||||
input: BuildWorkspaceTabMenuEntriesInput
|
input: BuildWorkspaceTabMenuEntriesInput
|
||||||
): WorkspaceTabMenuEntry[] {
|
): WorkspaceTabMenuEntry[] {
|
||||||
@@ -136,3 +168,26 @@ export function buildWorkspaceTabMenuEntries(
|
|||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceDesktopTabActions(
|
||||||
|
input: BuildWorkspaceDesktopTabActionsInput
|
||||||
|
): WorkspaceDesktopTabActions {
|
||||||
|
const contextMenuTestId = `workspace-tab-context-${input.tab.key}`;
|
||||||
|
return {
|
||||||
|
contextMenuTestId,
|
||||||
|
menuEntries: buildWorkspaceTabMenuEntries({
|
||||||
|
surface: "desktop",
|
||||||
|
tab: input.tab,
|
||||||
|
index: input.index,
|
||||||
|
tabCount: input.tabCount,
|
||||||
|
menuTestIDBase: contextMenuTestId,
|
||||||
|
onCopyResumeCommand: input.onCopyResumeCommand,
|
||||||
|
onCopyAgentId: input.onCopyAgentId,
|
||||||
|
onCloseTab: input.onCloseTab,
|
||||||
|
onCloseTabsBefore: input.onCloseTabsToLeft,
|
||||||
|
onCloseTabsAfter: input.onCloseTabsToRight,
|
||||||
|
onCloseOtherTabs: input.onCloseOtherTabs,
|
||||||
|
}),
|
||||||
|
closeButtonTestId: getCloseButtonTestId(input.tab),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,31 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-b" });
|
expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-b" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes preferredTarget before overriding focused tab selection", () => {
|
||||||
|
const model = deriveWorkspaceTabModel({
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
tabId: "file_/repo/worktree/README.md",
|
||||||
|
target: { kind: "file", path: "/repo/worktree/README.md" },
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tabId: "agent_agent-a",
|
||||||
|
target: { kind: "agent", agentId: "agent-a" },
|
||||||
|
createdAt: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
focusedTabId: "agent_agent-a",
|
||||||
|
preferredTarget: { kind: "file", path: "\\repo\\worktree\\README.md" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.activeTabId).toBe("file_/repo/worktree/README.md");
|
||||||
|
expect(model.activeTab?.descriptor.target).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
path: "/repo/worktree/README.md",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps retargeted tab ids stable while matching upgraded targets", () => {
|
it("keeps retargeted tab ids stable while matching upgraded targets", () => {
|
||||||
const model = deriveWorkspaceTabModel({
|
const model = deriveWorkspaceTabModel({
|
||||||
tabs: [
|
tabs: [
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import {
|
||||||
|
deriveWorkspacePaneState,
|
||||||
export interface WorkspaceDerivedTab {
|
type WorkspaceDerivedTab,
|
||||||
descriptor: WorkspaceTabDescriptor;
|
} from "@/screens/workspace/workspace-pane-state";
|
||||||
}
|
import {
|
||||||
|
buildDeterministicWorkspaceTabId,
|
||||||
|
} from "@/utils/workspace-tab-identity";
|
||||||
|
|
||||||
export interface WorkspaceTabModel {
|
export interface WorkspaceTabModel {
|
||||||
tabs: WorkspaceDerivedTab[];
|
tabs: WorkspaceDerivedTab[];
|
||||||
@@ -11,102 +13,8 @@ export interface WorkspaceTabModel {
|
|||||||
activeTab: WorkspaceDerivedTab | null;
|
activeTab: WorkspaceDerivedTab | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
|
||||||
if (left.kind !== right.kind) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (left.kind === "draft" && right.kind === "draft") {
|
|
||||||
return left.draftId === right.draftId;
|
|
||||||
}
|
|
||||||
if (left.kind === "agent" && right.kind === "agent") {
|
|
||||||
return left.agentId === right.agentId;
|
|
||||||
}
|
|
||||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
|
||||||
return left.terminalId === right.terminalId;
|
|
||||||
}
|
|
||||||
if (left.kind === "file" && right.kind === "file") {
|
|
||||||
return left.path === right.path;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
|
|
||||||
if (!tab || typeof tab !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const tabId = trimNonEmpty(tab.tabId);
|
|
||||||
if (!tabId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!tab.target || typeof tab.target !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (tab.target.kind === "draft") {
|
|
||||||
const draftId = trimNonEmpty(tab.target.draftId);
|
|
||||||
if (!draftId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tabId,
|
|
||||||
target: { kind: "draft", draftId },
|
|
||||||
createdAt: tab.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (tab.target.kind === "agent") {
|
|
||||||
const agentId = trimNonEmpty(tab.target.agentId);
|
|
||||||
if (!agentId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tabId,
|
|
||||||
target: { kind: "agent", agentId },
|
|
||||||
createdAt: tab.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (tab.target.kind === "terminal") {
|
|
||||||
const terminalId = trimNonEmpty(tab.target.terminalId);
|
|
||||||
if (!terminalId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tabId,
|
|
||||||
target: { kind: "terminal", terminalId },
|
|
||||||
createdAt: tab.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (tab.target.kind === "file") {
|
|
||||||
const path = trimNonEmpty(tab.target.path);
|
|
||||||
if (!path) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tabId,
|
|
||||||
target: { kind: "file", path: path.replace(/\\/g, "/") },
|
|
||||||
createdAt: tab.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
|
export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
|
||||||
if (target.kind === "draft") {
|
return buildDeterministicWorkspaceTabId(target);
|
||||||
return target.draftId;
|
|
||||||
}
|
|
||||||
if (target.kind === "agent") {
|
|
||||||
return `agent_${target.agentId}`;
|
|
||||||
}
|
|
||||||
if (target.kind === "terminal") {
|
|
||||||
return `terminal_${target.terminalId}`;
|
|
||||||
}
|
|
||||||
return `file_${target.path}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveWorkspaceTabModel(input: {
|
export function deriveWorkspaceTabModel(input: {
|
||||||
@@ -114,51 +22,14 @@ export function deriveWorkspaceTabModel(input: {
|
|||||||
focusedTabId?: string | null;
|
focusedTabId?: string | null;
|
||||||
preferredTarget?: WorkspaceTabTarget | null;
|
preferredTarget?: WorkspaceTabTarget | null;
|
||||||
}): WorkspaceTabModel {
|
}): WorkspaceTabModel {
|
||||||
const tabs: WorkspaceDerivedTab[] = [];
|
const paneState = deriveWorkspacePaneState({
|
||||||
const openTabIds = new Set<string>();
|
tabs: input.tabs,
|
||||||
|
focusedTabId: input.focusedTabId,
|
||||||
for (const tab of input.tabs) {
|
preferredTarget: input.preferredTarget,
|
||||||
const normalizedTab = normalizeWorkspaceTab(tab);
|
});
|
||||||
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
openTabIds.add(normalizedTab.tabId);
|
|
||||||
tabs.push({
|
|
||||||
descriptor: {
|
|
||||||
key: normalizedTab.tabId,
|
|
||||||
tabId: normalizedTab.tabId,
|
|
||||||
kind: normalizedTab.target.kind,
|
|
||||||
target: normalizedTab.target,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
|
||||||
const preferredTarget = input.preferredTarget ?? null;
|
|
||||||
const preferredTabId = (() => {
|
|
||||||
if (!preferredTarget) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const matchingTab =
|
|
||||||
tabs.find((tab) => tabTargetsEqual(tab.descriptor.target, preferredTarget)) ?? null;
|
|
||||||
return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const activeTabId =
|
|
||||||
preferredTabId && openTabIds.has(preferredTabId)
|
|
||||||
? preferredTabId
|
|
||||||
: focusedTabId && openTabIds.has(focusedTabId)
|
|
||||||
? focusedTabId
|
|
||||||
: tabs[0]?.descriptor.tabId ?? null;
|
|
||||||
|
|
||||||
const activeTab = activeTabId
|
|
||||||
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs: paneState.tabs,
|
||||||
activeTabId,
|
activeTabId: paneState.activeTabId,
|
||||||
activeTab,
|
activeTab: paneState.activeTab,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -121,7 +121,7 @@ describe("workspace-layout-store tree transforms", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("insertSplit reuses a matching parent direction instead of nesting", () => {
|
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
|
||||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||||
@@ -141,10 +141,14 @@ describe("workspace-layout-store tree transforms", () => {
|
|||||||
|
|
||||||
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
||||||
const nextGroup = expectGroup(nextRoot);
|
const nextGroup = expectGroup(nextRoot);
|
||||||
|
const nestedGroup = expectGroup(nextGroup.group.children[1]!);
|
||||||
|
|
||||||
expect(nextGroup.group.direction).toBe("horizontal");
|
expect(nextGroup.group.direction).toBe("horizontal");
|
||||||
expect(nextGroup.group.children).toHaveLength(3);
|
expect(nextGroup.group.children).toHaveLength(2);
|
||||||
expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]);
|
expect(nextGroup.group.sizes).toEqual([0.25, 0.75]);
|
||||||
|
expect(nestedGroup.group.id).toBe("group_22222222-2222-2222-2222-222222222222");
|
||||||
|
expect(nestedGroup.group.direction).toBe("horizontal");
|
||||||
|
expect(nestedGroup.group.sizes).toEqual([0.5, 0.5]);
|
||||||
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([
|
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([
|
||||||
"left",
|
"left",
|
||||||
"right",
|
"right",
|
||||||
@@ -519,12 +523,33 @@ describe("workspace-layout-store actions", () => {
|
|||||||
const rootGroup = expectGroup(layout.root);
|
const rootGroup = expectGroup(layout.root);
|
||||||
|
|
||||||
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
||||||
expect(paneCId).toBe("pane_89898989-8989-8989-8989-898989898989");
|
expect(paneCId).toBe("pane_9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
|
||||||
expect(layout.focusedPaneId).toBe(paneCId);
|
expect(layout.focusedPaneId).toBe(paneCId);
|
||||||
expect(rootGroup.group.direction).toBe("horizontal");
|
expect(rootGroup.group.direction).toBe("horizontal");
|
||||||
expect(rootGroup.group.children).toEqual([
|
expect(rootGroup.group.children).toHaveLength(2);
|
||||||
createPane({ id: "main", tabIds: ["file_/repo/worktree/a.ts"] }),
|
expect(
|
||||||
createPane({ id: paneCId!, tabIds: ["file_/repo/worktree/c.ts"] }),
|
rootGroup.group.children.map((child) => {
|
||||||
|
expect(child.kind).toBe("pane");
|
||||||
|
if (child.kind !== "pane") {
|
||||||
|
throw new Error("Expected pane child");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: child.pane.id,
|
||||||
|
tabIds: child.pane.tabIds,
|
||||||
|
focusedTabId: child.pane.focusedTabId,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
tabIds: ["file_/repo/worktree/a.ts"],
|
||||||
|
focusedTabId: "file_/repo/worktree/a.ts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: paneCId!,
|
||||||
|
tabIds: ["file_/repo/worktree/c.ts"],
|
||||||
|
focusedTabId: "file_/repo/worktree/c.ts",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(rootGroup.group.sizes).toEqual([0.5, 0.5]);
|
expect(rootGroup.group.sizes).toEqual([0.5, 0.5]);
|
||||||
});
|
});
|
||||||
@@ -560,7 +585,8 @@ describe("workspace-layout-store actions", () => {
|
|||||||
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
||||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||||
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
|
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
|
||||||
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
|
||||||
|
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
|
||||||
|
|
||||||
const workspaceKey = createWorkspaceKey();
|
const workspaceKey = createWorkspaceKey();
|
||||||
const store = useWorkspaceLayoutStore.getState();
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
@@ -583,17 +609,18 @@ describe("workspace-layout-store actions", () => {
|
|||||||
|
|
||||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
||||||
const splitGroup = expectGroup(splitRoot);
|
const splitGroup = expectGroup(splitRoot);
|
||||||
store.resizeSplit(workspaceKey, splitGroup.group.id, [0.01, 0.01, 0.98]);
|
const nestedGroup = expectGroup(splitGroup.group.children[1]!);
|
||||||
|
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
|
||||||
|
|
||||||
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
||||||
const resizedGroup = expectGroup(resizedRoot);
|
const resizedGroup = expectGroup(resizedRoot);
|
||||||
const total = resizedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]!);
|
||||||
|
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
||||||
|
|
||||||
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
||||||
expect(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff");
|
expect(farRightPaneId).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||||
expect(resizedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
|
expect(resizedNestedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
|
||||||
expect(resizedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
|
expect(resizedNestedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
|
||||||
expect(resizedGroup.group.sizes[2]).toBeGreaterThanOrEqual(0.1);
|
|
||||||
expect(total).toBeCloseTo(1, 10);
|
expect(total).toBeCloseTo(1, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
|
export function normalizeWorkspaceTabTarget(
|
||||||
|
value: WorkspaceTabTarget | null | undefined
|
||||||
|
): WorkspaceTabTarget | null {
|
||||||
|
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.kind === "draft") {
|
||||||
|
const draftId = trimNonEmpty(value.draftId);
|
||||||
|
return draftId ? { kind: "draft", draftId } : null;
|
||||||
|
}
|
||||||
|
if (value.kind === "agent") {
|
||||||
|
const agentId = trimNonEmpty(value.agentId);
|
||||||
|
return agentId ? { kind: "agent", agentId } : null;
|
||||||
|
}
|
||||||
|
if (value.kind === "terminal") {
|
||||||
|
const terminalId = trimNonEmpty(value.terminalId);
|
||||||
|
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||||
|
}
|
||||||
|
if (value.kind === "file") {
|
||||||
|
const path = trimNonEmpty(value.path);
|
||||||
|
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceTabTargetsEqual(
|
||||||
|
left: WorkspaceTabTarget,
|
||||||
|
right: WorkspaceTabTarget
|
||||||
|
): boolean {
|
||||||
|
if (left.kind !== right.kind) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (left.kind === "draft" && right.kind === "draft") {
|
||||||
|
return left.draftId === right.draftId;
|
||||||
|
}
|
||||||
|
if (left.kind === "agent" && right.kind === "agent") {
|
||||||
|
return left.agentId === right.agentId;
|
||||||
|
}
|
||||||
|
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||||
|
return left.terminalId === right.terminalId;
|
||||||
|
}
|
||||||
|
if (left.kind === "file" && right.kind === "file") {
|
||||||
|
return left.path === right.path;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string {
|
||||||
|
if (target.kind === "draft") {
|
||||||
|
return target.draftId;
|
||||||
|
}
|
||||||
|
if (target.kind === "agent") {
|
||||||
|
return `agent_${target.agentId}`;
|
||||||
|
}
|
||||||
|
if (target.kind === "terminal") {
|
||||||
|
return `terminal_${target.terminalId}`;
|
||||||
|
}
|
||||||
|
return `file_${target.path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { defineConfig, configDefaults } from "vitest/config";
|
import { defineConfig, configDefaults } from "vitest/config";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
const appNodeModules = path.resolve(__dirname, "node_modules");
|
const appNodeModules = path.resolve(__dirname, "node_modules");
|
||||||
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
|
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
|
||||||
|
const resolvePackageEntry = (packageName: string) => {
|
||||||
|
const appPackagePath = path.resolve(appNodeModules, packageName);
|
||||||
|
return fs.existsSync(appPackagePath)
|
||||||
|
? appPackagePath
|
||||||
|
: path.resolve(rootNodeModules, packageName);
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -45,10 +52,10 @@ export default defineConfig({
|
|||||||
find: "react-native",
|
find: "react-native",
|
||||||
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
|
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
|
||||||
},
|
},
|
||||||
{ find: "react", replacement: path.resolve(appNodeModules, "react") },
|
{ find: "react", replacement: resolvePackageEntry("react") },
|
||||||
{
|
{
|
||||||
find: "react-dom",
|
find: "react-dom",
|
||||||
replacement: path.resolve(appNodeModules, "react-dom"),
|
replacement: resolvePackageEntry("react-dom"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user