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 { ResizeHandle } from "@/components/resize-handle";
|
||||
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
|
||||
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
|
||||
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
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 { 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 {
|
||||
layout: WorkspaceLayout;
|
||||
@@ -44,9 +53,10 @@ interface SplitContainerProps {
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onSelectNewTabOption: (optionId: "__new_tab_agent__") => void;
|
||||
newTabAgentOptionId?: "__new_tab_agent__";
|
||||
onOpenPaneTab: (input: { paneId: string; target: WorkspaceTabTarget }) => void;
|
||||
onRetargetTab: (tabId: string, target: WorkspaceTabTarget) => void;
|
||||
onOpenWorkspaceFile: (input: { paneId: string; filePath: string }) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
}) => WorkspacePaneContentModel;
|
||||
onFocusPane: (paneId: string) => void;
|
||||
onSplitPane: (input: {
|
||||
tabId: string;
|
||||
@@ -71,9 +81,9 @@ interface SplitPaneDropData {
|
||||
}
|
||||
|
||||
interface SplitNodeViewProps
|
||||
extends Omit<SplitContainerProps, "layout" | "workspaceKey" | "uiTabs"> {
|
||||
extends Omit<SplitContainerProps, "layout" | "workspaceKey"> {
|
||||
node: SplitNode;
|
||||
tabsById: Map<string, WorkspaceTab>;
|
||||
uiTabs: WorkspaceTab[];
|
||||
focusedPaneId: string;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
@@ -85,7 +95,6 @@ interface SplitPaneViewProps
|
||||
extends Omit<
|
||||
SplitNodeViewProps,
|
||||
| "node"
|
||||
| "tabsById"
|
||||
| "focusedPaneId"
|
||||
| "activeDragTabId"
|
||||
| "showDropZones"
|
||||
@@ -96,7 +105,7 @@ interface SplitPaneViewProps
|
||||
| "onResizeSplit"
|
||||
> {
|
||||
pane: SplitPane;
|
||||
tabsById: Map<string, WorkspaceTab>;
|
||||
uiTabs: WorkspaceTab[];
|
||||
focused: boolean;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
@@ -143,9 +152,7 @@ export function SplitContainer({
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId = "__new_tab_agent__",
|
||||
onOpenPaneTab,
|
||||
onRetargetTab,
|
||||
onOpenWorkspaceFile,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
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 handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
@@ -215,8 +214,8 @@ export function SplitContainer({
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById);
|
||||
const targetTabs = getPaneTabDescriptors(targetPane, tabsById);
|
||||
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) {
|
||||
@@ -259,7 +258,7 @@ export function SplitContainer({
|
||||
|
||||
setDropPreview(null);
|
||||
},
|
||||
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById]
|
||||
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, uiTabs]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -272,7 +271,7 @@ export function SplitContainer({
|
||||
>
|
||||
<SplitNodeView
|
||||
node={layout.root}
|
||||
tabsById={tabsById}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
@@ -291,9 +290,7 @@ export function SplitContainer({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onOpenPaneTab={onOpenPaneTab}
|
||||
onRetargetTab={onRetargetTab}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
@@ -311,7 +308,7 @@ export function SplitContainer({
|
||||
|
||||
function SplitNodeView({
|
||||
node,
|
||||
tabsById,
|
||||
uiTabs,
|
||||
focusedPaneId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -330,9 +327,7 @@ function SplitNodeView({
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
onOpenPaneTab,
|
||||
onRetargetTab,
|
||||
onOpenWorkspaceFile,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onMoveTabToPane,
|
||||
@@ -348,7 +343,7 @@ function SplitNodeView({
|
||||
return (
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
tabsById={tabsById}
|
||||
uiTabs={uiTabs}
|
||||
focused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
@@ -367,9 +362,7 @@ function SplitNodeView({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onOpenPaneTab={onOpenPaneTab}
|
||||
onRetargetTab={onRetargetTab}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
@@ -393,7 +386,7 @@ function SplitNodeView({
|
||||
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}>
|
||||
<SplitNodeView
|
||||
node={child}
|
||||
tabsById={tabsById}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
@@ -412,9 +405,7 @@ function SplitNodeView({
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onOpenPaneTab={onOpenPaneTab}
|
||||
onRetargetTab={onRetargetTab}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
@@ -444,7 +435,7 @@ function SplitNodeView({
|
||||
|
||||
function SplitPaneView({
|
||||
pane,
|
||||
tabsById,
|
||||
uiTabs,
|
||||
focused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -463,9 +454,7 @@ function SplitPaneView({
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
onOpenPaneTab,
|
||||
onRetargetTab,
|
||||
onOpenWorkspaceFile,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
@@ -475,22 +464,64 @@ function SplitPaneView({
|
||||
onDropPreviewChange,
|
||||
}: SplitPaneViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const paneTabs = useMemo(() => getPaneTabDescriptors(pane, tabsById), [pane, tabsById]);
|
||||
const paneModel = useMemo(
|
||||
const paneState = useMemo(
|
||||
() =>
|
||||
deriveWorkspaceTabModel({
|
||||
tabs: paneTabs.map((tab) => ({
|
||||
tabId: tab.tabId,
|
||||
target: tab.target,
|
||||
createdAt: 0,
|
||||
})),
|
||||
focusedTabId: pane.focusedTabId,
|
||||
deriveWorkspacePaneState({
|
||||
pane,
|
||||
tabs: uiTabs,
|
||||
}),
|
||||
[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 (
|
||||
<View
|
||||
@@ -516,16 +547,11 @@ function SplitPaneView({
|
||||
>
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
tabs={paneTabs}
|
||||
activeTabKey={activeTabDescriptor?.key ?? ""}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
@@ -544,30 +570,8 @@ function SplitPaneView({
|
||||
</View>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{activeTabDescriptor ? (
|
||||
<WorkspacePaneContent
|
||||
tab={activeTabDescriptor}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onOpenTab={(target) => {
|
||||
onOpenPaneTab({
|
||||
paneId: pane.id,
|
||||
target,
|
||||
});
|
||||
}}
|
||||
onCloseCurrentTab={() => {
|
||||
void onCloseTab(activeTabDescriptor.tabId);
|
||||
}}
|
||||
onRetargetCurrentTab={(target) => {
|
||||
onRetargetTab(activeTabDescriptor.tabId, target);
|
||||
}}
|
||||
onOpenWorkspaceFile={(filePath) => {
|
||||
onOpenWorkspaceFile({
|
||||
paneId: pane.id,
|
||||
filePath,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{paneContent ? (
|
||||
<WorkspacePaneContent content={paneContent} />
|
||||
) : (
|
||||
renderPaneEmptyState?.() ?? null
|
||||
)}
|
||||
@@ -598,26 +602,6 @@ function collectPanesById(node: SplitNode): Map<string, SplitPane> {
|
||||
return next;
|
||||
}
|
||||
|
||||
function getPaneTabDescriptors(
|
||||
pane: SplitPane,
|
||||
tabsById: Map<string, WorkspaceTab>
|
||||
): WorkspaceTabDescriptor[] {
|
||||
const next: WorkspaceTabDescriptor[] = [];
|
||||
for (const tabId of pane.tabIds) {
|
||||
const tab = tabsById.get(tabId);
|
||||
if (!tab) {
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function getNodeKey(node: SplitNode): string {
|
||||
if (node.kind === "pane") {
|
||||
return node.pane.id;
|
||||
|
||||
@@ -18,26 +18,30 @@ import {
|
||||
WorkspaceTabIcon,
|
||||
type WorkspaceTabPresentation,
|
||||
} 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 { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
type NewTabOptionId = "__new_tab_agent__";
|
||||
|
||||
export interface WorkspaceDesktopTabRowItem {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
isActive: boolean;
|
||||
isCloseHovered: boolean;
|
||||
isClosingTab: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceDesktopTabsRowProps = {
|
||||
paneId?: string;
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
tabs: WorkspaceDesktopTabRowItem[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
hoveredCloseTabKey: string | null;
|
||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
|
||||
killTerminalPending: boolean;
|
||||
killTerminalId: string | null;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
@@ -65,87 +69,41 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
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({
|
||||
tab,
|
||||
index,
|
||||
tabCount,
|
||||
isActive,
|
||||
resolvedTabWidth,
|
||||
showLabel,
|
||||
showCloseButton,
|
||||
isCloseHovered,
|
||||
isClosingTab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
presentation,
|
||||
tooltipLabel,
|
||||
resolvedTab,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
index: number;
|
||||
tabCount: number;
|
||||
isActive: boolean;
|
||||
resolvedTabWidth: number;
|
||||
showLabel: boolean;
|
||||
showCloseButton: boolean;
|
||||
isCloseHovered: boolean;
|
||||
isClosingTab: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
presentation: WorkspaceTabPresentation;
|
||||
tooltipLabel: string;
|
||||
resolvedTab: WorkspaceDesktopTabActions;
|
||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
dragHandleProps: any;
|
||||
}) {
|
||||
const presentation = useWorkspaceTabPresentation({
|
||||
tab,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
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,
|
||||
});
|
||||
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
@@ -214,7 +172,7 @@ function TabChip({
|
||||
|
||||
{showCloseButton ? (
|
||||
<Pressable
|
||||
testID={getCloseButtonTestId(tab)}
|
||||
testID={closeButtonTestId}
|
||||
disabled={isClosingTab}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
@@ -280,15 +238,10 @@ function TabChip({
|
||||
export function WorkspaceDesktopTabsRow({
|
||||
paneId,
|
||||
tabs,
|
||||
activeTabKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
isArchivingAgent,
|
||||
killTerminalPending,
|
||||
killTerminalId,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
onCopyResumeCommand,
|
||||
@@ -334,7 +287,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
const tabLabelLengths = useMemo(
|
||||
() =>
|
||||
tabs.map((tab) => {
|
||||
const label = getFallbackTabLabel(tab);
|
||||
const label = getFallbackTabLabel(tab.tab);
|
||||
return label.length;
|
||||
}),
|
||||
[tabs]
|
||||
@@ -365,10 +318,10 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<SortableInlineList
|
||||
data={tabs}
|
||||
keyExtractor={(tab) => tab.key}
|
||||
keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
|
||||
useDragHandle
|
||||
disabled={tabs.length < 2}
|
||||
onDragEnd={onReorderTabs}
|
||||
onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
|
||||
externalDndContext={externalDndContext}
|
||||
activeId={activeDragTabId}
|
||||
getItemData={
|
||||
@@ -376,52 +329,36 @@ export function WorkspaceDesktopTabsRow({
|
||||
? (tab) => ({
|
||||
kind: "workspace-tab",
|
||||
paneId,
|
||||
tabId: tab.tabId,
|
||||
tabId: tab.tab.tabId,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
renderItem={({ item: tab, 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;
|
||||
renderItem={({ item, index, dragHandleProps }) => {
|
||||
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
|
||||
const layoutItem = layout.items[index] ?? null;
|
||||
const resolvedTabWidth = layoutItem?.width ?? 150;
|
||||
const showLabel = layoutItem?.showLabel ?? true;
|
||||
|
||||
return (
|
||||
<TabChip
|
||||
key={tab.key}
|
||||
tab={tab}
|
||||
<ResolvedDesktopTabChip
|
||||
key={`${item.tab.key}:${item.tab.kind}`}
|
||||
item={item}
|
||||
index={index}
|
||||
tabCount={tabs.length}
|
||||
isActive={isActive}
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={shouldShowCloseButton}
|
||||
isCloseHovered={isCloseHovered}
|
||||
isClosingTab={isClosingTab}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={shouldShowCloseButton}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
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) => ({
|
||||
tabsContainer: {
|
||||
borderBottomWidth: 1,
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { ComponentType } from "react";
|
||||
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 { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
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;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -14,7 +21,7 @@ export interface WorkspacePaneContentProps {
|
||||
onOpenWorkspaceFile: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function WorkspacePaneContent({
|
||||
export function buildWorkspacePaneContentModel({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -22,26 +29,36 @@ export function WorkspacePaneContent({
|
||||
onCloseCurrentTab,
|
||||
onRetargetCurrentTab,
|
||||
onOpenWorkspaceFile,
|
||||
}: WorkspacePaneContentProps) {
|
||||
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(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 (
|
||||
<PaneProvider
|
||||
value={{
|
||||
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 value={paneContextValue}>
|
||||
<Component key={key} />
|
||||
</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 {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitPane,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
@@ -69,6 +66,10 @@ import {
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
} from "@/utils/host-routes";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
import {
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import {
|
||||
@@ -95,10 +96,11 @@ import {
|
||||
import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
deriveWorkspaceTabModel,
|
||||
} from "@/screens/workspace/workspace-tab-model";
|
||||
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
|
||||
buildWorkspacePaneContentModel,
|
||||
WorkspacePaneContent,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import {
|
||||
buildBulkCloseConfirmationMessage,
|
||||
classifyBulkClosableTabs,
|
||||
@@ -109,11 +111,6 @@ const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
|
||||
interface FocusedPaneTabState {
|
||||
tabs: WorkspaceTab[];
|
||||
focusedTabId: string | null;
|
||||
}
|
||||
|
||||
type WorkspaceScreenProps = {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -177,82 +174,6 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
||||
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 = {
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
@@ -873,7 +794,7 @@ function WorkspaceScreenContent({
|
||||
|
||||
const focusedPaneTabState = useMemo(
|
||||
() =>
|
||||
getFocusedPaneTabState({
|
||||
deriveWorkspacePaneState({
|
||||
layout: workspaceLayout,
|
||||
tabs: uiTabs,
|
||||
}),
|
||||
@@ -886,14 +807,19 @@ function WorkspaceScreenContent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingTab =
|
||||
uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, target)) ?? null;
|
||||
uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
||||
if (existingTab) {
|
||||
return existingTab.tabId;
|
||||
}
|
||||
|
||||
const previousFocusedTabId = getEffectiveFocusedTabId(focusedPaneTabState);
|
||||
const tabId = openWorkspaceTab(persistenceKey, target);
|
||||
const previousFocusedTabId = focusedPaneTabState.activeTabId;
|
||||
const tabId = openWorkspaceTab(persistenceKey, normalizedTarget);
|
||||
if (tabId && previousFocusedTabId && previousFocusedTabId !== tabId) {
|
||||
focusWorkspaceTab(persistenceKey, previousFocusedTabId);
|
||||
}
|
||||
@@ -908,10 +834,11 @@ function WorkspaceScreenContent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = {
|
||||
const target = normalizeWorkspaceTabTarget({
|
||||
kind: "draft",
|
||||
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
|
||||
};
|
||||
});
|
||||
invariant(target?.kind === "draft", "Draft tab target must be valid");
|
||||
if (input?.focus === false) {
|
||||
return ensureWorkspaceTab(target);
|
||||
}
|
||||
@@ -989,6 +916,24 @@ function WorkspaceScreenContent({
|
||||
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
|
||||
? openIntent
|
||||
: 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(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
|
||||
@@ -1051,25 +996,7 @@ function WorkspaceScreenContent({
|
||||
workspaceAgents,
|
||||
]);
|
||||
|
||||
const tabModel = useMemo(
|
||||
() =>
|
||||
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;
|
||||
const activeTabId = resolvedPaneTabState.activeTabId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !persistenceKey) {
|
||||
@@ -1078,11 +1005,11 @@ function WorkspaceScreenContent({
|
||||
focusWorkspaceTab(persistenceKey, activeTabId);
|
||||
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
||||
|
||||
const activeTab = tabModel.activeTab;
|
||||
const activeTab = resolvedPaneTabState.activeTab;
|
||||
|
||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||
() => tabModel.tabs.map((tab) => tab.descriptor),
|
||||
[tabModel.tabs]
|
||||
() => resolvedPaneTabState.tabs.map((tab) => tab.descriptor),
|
||||
[resolvedPaneTabState.tabs]
|
||||
);
|
||||
|
||||
const navigateToTabId = useCallback(
|
||||
@@ -1625,13 +1552,13 @@ function WorkspaceScreenContent({
|
||||
return true;
|
||||
}
|
||||
|
||||
const focusedPane = getFocusedPane(workspaceLayout);
|
||||
const focusedPane = focusedPaneTabState.pane;
|
||||
if (!focusedPane) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.id === "workspace.pane.split.right") {
|
||||
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
|
||||
const activePaneTabId = focusedPaneTabState.activeTabId;
|
||||
if (activePaneTabId) {
|
||||
splitWorkspacePane(persistenceKey, {
|
||||
tabId: activePaneTabId,
|
||||
@@ -1643,7 +1570,7 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
if (action.id === "workspace.pane.split.down") {
|
||||
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
|
||||
const activePaneTabId = focusedPaneTabState.activeTabId;
|
||||
if (activePaneTabId) {
|
||||
splitWorkspacePane(persistenceKey, {
|
||||
tabId: activePaneTabId,
|
||||
@@ -1688,7 +1615,7 @@ function WorkspaceScreenContent({
|
||||
direction === "up" ||
|
||||
direction === "down"
|
||||
) {
|
||||
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
|
||||
const activePaneTabId = focusedPaneTabState.activeTabId;
|
||||
const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction);
|
||||
if (activePaneTabId && adjacentPaneId) {
|
||||
moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId);
|
||||
@@ -1712,6 +1639,8 @@ function WorkspaceScreenContent({
|
||||
moveWorkspaceTabToPane,
|
||||
persistenceKey,
|
||||
splitWorkspacePane,
|
||||
focusedPaneTabState.activeTabId,
|
||||
focusedPaneTabState.pane,
|
||||
workspaceLayout,
|
||||
]
|
||||
);
|
||||
@@ -1752,6 +1681,67 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
|
||||
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({
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
@@ -1772,34 +1762,7 @@ function WorkspaceScreenContent({
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<WorkspacePaneContent
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
<WorkspacePaneContent content={activePaneContent!} />
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -2034,23 +1997,13 @@ function WorkspaceScreenContent({
|
||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||
onOpenPaneTab={({ paneId, target }) => {
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
const tabId = openWorkspaceTab(persistenceKey, target);
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
}}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
buildPaneContentModel={({ paneId, tab }) =>
|
||||
buildPaneContentModel({
|
||||
tab,
|
||||
paneId,
|
||||
focusPaneBeforeOpen: true,
|
||||
})
|
||||
}
|
||||
onFocusPane={(paneId) => {
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
|
||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||
|
||||
@@ -31,6 +32,24 @@ interface BuildWorkspaceTabMenuEntriesInput {
|
||||
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 {
|
||||
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";
|
||||
}
|
||||
|
||||
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(
|
||||
input: BuildWorkspaceTabMenuEntriesInput
|
||||
): WorkspaceTabMenuEntry[] {
|
||||
@@ -136,3 +168,26 @@ export function buildWorkspaceTabMenuEntries(
|
||||
|
||||
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" });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
tabs: [
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export interface WorkspaceDerivedTab {
|
||||
descriptor: WorkspaceTabDescriptor;
|
||||
}
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
type WorkspaceDerivedTab,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
|
||||
export interface WorkspaceTabModel {
|
||||
tabs: WorkspaceDerivedTab[];
|
||||
@@ -11,102 +13,8 @@ export interface WorkspaceTabModel {
|
||||
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 {
|
||||
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}`;
|
||||
return buildDeterministicWorkspaceTabId(target);
|
||||
}
|
||||
|
||||
export function deriveWorkspaceTabModel(input: {
|
||||
@@ -114,51 +22,14 @@ export function deriveWorkspaceTabModel(input: {
|
||||
focusedTabId?: string | null;
|
||||
preferredTarget?: WorkspaceTabTarget | null;
|
||||
}): WorkspaceTabModel {
|
||||
const tabs: WorkspaceDerivedTab[] = [];
|
||||
const openTabIds = new Set<string>();
|
||||
|
||||
for (const tab of input.tabs) {
|
||||
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;
|
||||
|
||||
const paneState = deriveWorkspacePaneState({
|
||||
tabs: input.tabs,
|
||||
focusedTabId: input.focusedTabId,
|
||||
preferredTarget: input.preferredTarget,
|
||||
});
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
tabs: paneState.tabs,
|
||||
activeTabId: paneState.activeTabId,
|
||||
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();
|
||||
});
|
||||
|
||||
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")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.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 nextGroup = expectGroup(nextRoot);
|
||||
const nestedGroup = expectGroup(nextGroup.group.children[1]!);
|
||||
|
||||
expect(nextGroup.group.direction).toBe("horizontal");
|
||||
expect(nextGroup.group.children).toHaveLength(3);
|
||||
expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]);
|
||||
expect(nextGroup.group.children).toHaveLength(2);
|
||||
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([
|
||||
"left",
|
||||
"right",
|
||||
@@ -519,12 +523,33 @@ describe("workspace-layout-store actions", () => {
|
||||
const rootGroup = expectGroup(layout.root);
|
||||
|
||||
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(rootGroup.group.direction).toBe("horizontal");
|
||||
expect(rootGroup.group.children).toEqual([
|
||||
createPane({ id: "main", tabIds: ["file_/repo/worktree/a.ts"] }),
|
||||
createPane({ id: paneCId!, tabIds: ["file_/repo/worktree/c.ts"] }),
|
||||
expect(rootGroup.group.children).toHaveLength(2);
|
||||
expect(
|
||||
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]);
|
||||
});
|
||||
@@ -560,7 +585,8 @@ describe("workspace-layout-store actions", () => {
|
||||
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.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 store = useWorkspaceLayoutStore.getState();
|
||||
@@ -583,17 +609,18 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
|
||||
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 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(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
expect(resizedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
|
||||
expect(resizedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
|
||||
expect(resizedGroup.group.sizes[2]).toBeGreaterThanOrEqual(0.1);
|
||||
expect(farRightPaneId).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||
expect(resizedNestedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
|
||||
expect(resizedNestedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
|
||||
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 path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const appNodeModules = 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({
|
||||
test: {
|
||||
@@ -45,10 +52,10 @@ export default defineConfig({
|
||||
find: "react-native",
|
||||
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",
|
||||
replacement: path.resolve(appNodeModules, "react-dom"),
|
||||
replacement: resolvePackageEntry("react-dom"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user