Return to parent tabs on close
This commit is contained in:
@@ -1502,6 +1502,9 @@ function WorkspaceScreenContent({
|
|||||||
[normalizedServerId, normalizedWorkspaceId],
|
[normalizedServerId, normalizedWorkspaceId],
|
||||||
);
|
);
|
||||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||||
|
const openWorkspaceChildTabFocused = useWorkspaceLayoutStore(
|
||||||
|
(state) => state.openChildTabFocused,
|
||||||
|
);
|
||||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||||
const hasHydratedWorkspaces = useSessionStore(
|
const hasHydratedWorkspaces = useSessionStore(
|
||||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||||
@@ -2039,7 +2042,7 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenFileFromChat = useCallback(
|
const handleOpenFileFromChat = useCallback(
|
||||||
(location: WorkspaceFileLocation) => {
|
(location: WorkspaceFileLocation, options?: { parentTabId?: string | null }) => {
|
||||||
const normalizedLocation = normalizeWorkspaceFileLocation(location);
|
const normalizedLocation = normalizeWorkspaceFileLocation(location);
|
||||||
if (!normalizedLocation) {
|
if (!normalizedLocation) {
|
||||||
return;
|
return;
|
||||||
@@ -2050,25 +2053,36 @@ function WorkspaceScreenContent({
|
|||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabId = openWorkspaceTabFocused(
|
const target = createWorkspaceFileTabTarget(normalizedLocation);
|
||||||
persistenceKey,
|
const tabId = options?.parentTabId
|
||||||
createWorkspaceFileTabTarget(normalizedLocation),
|
? openWorkspaceChildTabFocused(persistenceKey, target, options.parentTabId)
|
||||||
);
|
: openWorkspaceTabFocused(persistenceKey, target);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
navigateToTabId(tabId);
|
navigateToTabId(tabId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent],
|
[
|
||||||
|
isMobile,
|
||||||
|
navigateToTabId,
|
||||||
|
openWorkspaceChildTabFocused,
|
||||||
|
openWorkspaceTabFocused,
|
||||||
|
persistenceKey,
|
||||||
|
showMobileAgent,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenFileFromChatInSidePane = useCallback(
|
const handleOpenFileFromChatInSidePane = useCallback(
|
||||||
(input: { location: WorkspaceFileLocation; sourcePaneId?: string }) => {
|
(input: {
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
sourcePaneId?: string;
|
||||||
|
parentTabId?: string | null;
|
||||||
|
}) => {
|
||||||
const location = normalizeWorkspaceFileLocation(input.location);
|
const location = normalizeWorkspaceFileLocation(input.location);
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!persistenceKey || isMobile || !input.sourcePaneId) {
|
if (!persistenceKey || isMobile || !input.sourcePaneId) {
|
||||||
handleOpenFileFromChat(location);
|
handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2088,7 +2102,9 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabId = openWorkspaceTabFocused(persistenceKey, target);
|
const tabId = input.parentTabId
|
||||||
|
? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId)
|
||||||
|
: openWorkspaceTabFocused(persistenceKey, target);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
navigateToTabId(tabId);
|
navigateToTabId(tabId);
|
||||||
}
|
}
|
||||||
@@ -2098,6 +2114,7 @@ function WorkspaceScreenContent({
|
|||||||
isMobile,
|
isMobile,
|
||||||
focusWorkspacePane,
|
focusWorkspacePane,
|
||||||
navigateToTabId,
|
navigateToTabId,
|
||||||
|
openWorkspaceChildTabFocused,
|
||||||
openWorkspaceTabFocused,
|
openWorkspaceTabFocused,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
splitWorkspacePaneEmpty,
|
splitWorkspacePaneEmpty,
|
||||||
@@ -2756,7 +2773,7 @@ function WorkspaceScreenContent({
|
|||||||
if (input.focusPaneBeforeOpen && input.paneId) {
|
if (input.focusPaneBeforeOpen && input.paneId) {
|
||||||
focusWorkspacePane(persistenceKey, input.paneId);
|
focusWorkspacePane(persistenceKey, input.paneId);
|
||||||
}
|
}
|
||||||
const tabId = openWorkspaceTabFocused(persistenceKey, target);
|
const tabId = openWorkspaceChildTabFocused(persistenceKey, target, input.tab.tabId);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
navigateToTabId(tabId);
|
navigateToTabId(tabId);
|
||||||
}
|
}
|
||||||
@@ -2782,10 +2799,11 @@ function WorkspaceScreenContent({
|
|||||||
handleOpenFileFromChatInSidePane({
|
handleOpenFileFromChatInSidePane({
|
||||||
location: request.location,
|
location: request.location,
|
||||||
sourcePaneId: input.paneId ?? undefined,
|
sourcePaneId: input.paneId ?? undefined,
|
||||||
|
parentTabId: input.tab.tabId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleOpenFileFromChat(request.location);
|
handleOpenFileFromChat(request.location, { parentTabId: input.tab.tabId });
|
||||||
},
|
},
|
||||||
onOpenImportSheet: openImportSheet,
|
onOpenImportSheet: openImportSheet,
|
||||||
}),
|
}),
|
||||||
@@ -2798,7 +2816,7 @@ function WorkspaceScreenContent({
|
|||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
openImportSheet,
|
openImportSheet,
|
||||||
openWorkspaceTabFocused,
|
openWorkspaceChildTabFocused,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
convertWorkspaceDraftToAgent,
|
convertWorkspaceDraftToAgent,
|
||||||
retargetWorkspaceTab,
|
retargetWorkspaceTab,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; gro
|
|||||||
export interface WorkspaceLayout {
|
export interface WorkspaceLayout {
|
||||||
root: SplitNode;
|
root: SplitNode;
|
||||||
focusedPaneId: string | null;
|
focusedPaneId: string | null;
|
||||||
|
parentTabIdByTabId?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SplitPaneInternal extends SplitPane {
|
interface SplitPaneInternal extends SplitPane {
|
||||||
@@ -903,10 +904,16 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout {
|
|||||||
collectAllPanes(root)[0]?.id ??
|
collectAllPanes(root)[0]?.id ??
|
||||||
DEFAULT_PANE_ID);
|
DEFAULT_PANE_ID);
|
||||||
|
|
||||||
return {
|
const normalizedLayout = {
|
||||||
root,
|
root,
|
||||||
focusedPaneId: resolvedFocusedPaneId,
|
focusedPaneId: resolvedFocusedPaneId,
|
||||||
};
|
};
|
||||||
|
const parentTabIdByTabId = normalizeParentTabMap({
|
||||||
|
raw: rawLayout.parentTabIdByTabId,
|
||||||
|
openTabIds: new Set(collectAllTabs(root).map((tab) => tab.tabId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return parentTabIdByTabId ? { ...normalizedLayout, parentTabIdByTabId } : normalizedLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findPaneById(root: SplitNode, paneId: string | null | undefined): SplitPane | null {
|
export function findPaneById(root: SplitNode, paneId: string | null | undefined): SplitPane | null {
|
||||||
@@ -1040,14 +1047,15 @@ function insertNewTabIntoFocusedPane(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
layout: {
|
layout: withNormalizedParentTabMap({
|
||||||
root: insertTabIntoPane(layout.root, {
|
root: insertTabIntoPane(layout.root, {
|
||||||
paneId: focusedPane.id,
|
paneId: focusedPane.id,
|
||||||
tab: nextTab,
|
tab: nextTab,
|
||||||
focusTabId: input.focus ? tabId : preservedFocusTabId,
|
focusTabId: input.focus ? tabId : preservedFocusTabId,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: input.focus ? focusedPane.id : layout.focusedPaneId,
|
focusedPaneId: input.focus ? focusedPane.id : layout.focusedPaneId,
|
||||||
},
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,28 +1069,28 @@ function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabT
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateExistingTabTarget(
|
function updateExistingTabTarget(
|
||||||
layout: { root: SplitNodeInternal; focusedPaneId: string | null },
|
layout: WorkspaceLayout,
|
||||||
tab: WorkspaceTab,
|
tab: WorkspaceTab,
|
||||||
target: WorkspaceTabTarget,
|
target: WorkspaceTabTarget,
|
||||||
): { root: SplitNodeInternal; focusedPaneId: string | null } {
|
): WorkspaceLayout {
|
||||||
if (workspaceTabTargetsEqual(tab.target, target)) {
|
if (workspaceTabTargetsEqual(tab.target, target)) {
|
||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
return {
|
return withNormalizedParentTabMap({
|
||||||
...layout,
|
...layout,
|
||||||
root: replaceTabInTree(layout.root, {
|
root: replaceTabInTree(asInternalNode(layout.root), {
|
||||||
tabId: tab.tabId,
|
tabId: tab.tabId,
|
||||||
nextTabId: tab.tabId,
|
nextTabId: tab.tabId,
|
||||||
target,
|
target,
|
||||||
}),
|
}),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||||
const layout = asInternalLayout(input.layout);
|
const layout = asInternalLayout(input.layout);
|
||||||
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
const nextLayout = updateExistingTabTarget(layout, existingTab, input.target);
|
const nextLayout = updateExistingTabTarget(input.layout, existingTab, input.target);
|
||||||
return {
|
return {
|
||||||
tabId: existingTab.tabId,
|
tabId: existingTab.tabId,
|
||||||
layout:
|
layout:
|
||||||
@@ -1102,7 +1110,7 @@ export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabI
|
|||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
return {
|
return {
|
||||||
tabId: existingTab.tabId,
|
tabId: existingTab.tabId,
|
||||||
layout: updateExistingTabTarget(layout, existingTab, input.target),
|
layout: updateExistingTabTarget(input.layout, existingTab, input.target),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,20 +1124,42 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeSuccessorTabId = getCloseSuccessorTabId({
|
||||||
|
pane,
|
||||||
|
tabId: input.tabId,
|
||||||
|
openTabIds: new Set(collectAllTabs(internalLayout.root).map((tab) => tab.tabId)),
|
||||||
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id);
|
const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id);
|
||||||
const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal;
|
const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal;
|
||||||
const nextFocusedPaneId =
|
const parentTabIdByTabId = normalizeParentTabMap({
|
||||||
internalLayout.focusedPaneId === null
|
raw: input.layout.parentTabIdByTabId,
|
||||||
? null
|
openTabIds: new Set(collectAllTabs(nextRoot).map((tab) => tab.tabId)),
|
||||||
: (findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
});
|
||||||
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
const nextFocusedPaneId = getFocusedPaneIdAfterTabClose({
|
||||||
collectAllPanes(nextRoot)[0]?.id ??
|
root: nextRoot,
|
||||||
DEFAULT_PANE_ID);
|
focusedPaneId: internalLayout.focusedPaneId,
|
||||||
|
fallbackPaneId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
const nextLayout = {
|
||||||
root: nextRoot,
|
root: nextRoot,
|
||||||
focusedPaneId: nextFocusedPaneId,
|
focusedPaneId: nextFocusedPaneId,
|
||||||
};
|
};
|
||||||
|
const nextLayoutWithParentMap = parentTabIdByTabId
|
||||||
|
? { ...nextLayout, parentTabIdByTabId }
|
||||||
|
: nextLayout;
|
||||||
|
|
||||||
|
if (closeSuccessorTabId && findPaneContainingTab(nextRoot, closeSuccessorTabId)) {
|
||||||
|
const focusedLayout =
|
||||||
|
focusTabInLayout({
|
||||||
|
layout: nextLayoutWithParentMap,
|
||||||
|
tabId: closeSuccessorTabId,
|
||||||
|
}) ?? nextLayoutWithParentMap;
|
||||||
|
return parentTabIdByTabId ? { ...focusedLayout, parentTabIdByTabId } : focusedLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextLayoutWithParentMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout | null {
|
export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout | null {
|
||||||
@@ -1143,10 +1173,11 @@ export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return withNormalizedParentTabMap({
|
||||||
root: focusTabInPane(layout.root, pane.id, input.tabId),
|
root: focusTabInPane(layout.root, pane.id, input.tabId),
|
||||||
focusedPaneId: pane.id,
|
focusedPaneId: pane.id,
|
||||||
};
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function retargetTabInLayout(
|
export function retargetTabInLayout(
|
||||||
@@ -1197,14 +1228,15 @@ export function retargetTabInLayout(
|
|||||||
// target identity immediately so local tab state cannot masquerade as the
|
// target identity immediately so local tab state cannot masquerade as the
|
||||||
// previous agent/terminal/file.
|
// previous agent/terminal/file.
|
||||||
tabId: nextTabId,
|
tabId: nextTabId,
|
||||||
layout: {
|
layout: withNormalizedParentTabMap({
|
||||||
root: replaceTabInTree(layout.root, {
|
root: replaceTabInTree(layout.root, {
|
||||||
tabId: input.tabId,
|
tabId: input.tabId,
|
||||||
nextTabId,
|
nextTabId,
|
||||||
target: input.target,
|
target: input.target,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: layout.focusedPaneId,
|
focusedPaneId: layout.focusedPaneId,
|
||||||
},
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1243,14 +1275,15 @@ export function convertDraftToAgentInLayout(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
tabId: canonicalTabId,
|
tabId: canonicalTabId,
|
||||||
layout: {
|
layout: withNormalizedParentTabMap({
|
||||||
root: replaceTabInTree(layout.root, {
|
root: replaceTabInTree(layout.root, {
|
||||||
tabId: input.tabId,
|
tabId: input.tabId,
|
||||||
nextTabId: canonicalTabId,
|
nextTabId: canonicalTabId,
|
||||||
target,
|
target,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: layout.focusedPaneId,
|
focusedPaneId: layout.focusedPaneId,
|
||||||
},
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1262,13 +1295,14 @@ export function reorderFocusedPaneTabsInLayout(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return withNormalizedParentTabMap({
|
||||||
root: updatePaneInTree(layout.root, {
|
root: updatePaneInTree(layout.root, {
|
||||||
paneId: layout.focusedPaneId,
|
paneId: layout.focusedPaneId,
|
||||||
updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }),
|
updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }),
|
||||||
}),
|
}),
|
||||||
focusedPaneId: layout.focusedPaneId,
|
focusedPaneId: layout.focusedPaneId,
|
||||||
};
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLayoutResult | null {
|
export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLayoutResult | null {
|
||||||
@@ -1293,10 +1327,11 @@ export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLay
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
paneId: result.newPaneId,
|
paneId: result.newPaneId,
|
||||||
layout: {
|
layout: withNormalizedParentTabMap({
|
||||||
root: result.root,
|
root: result.root,
|
||||||
focusedPaneId: result.newPaneId,
|
focusedPaneId: result.newPaneId,
|
||||||
},
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1350,10 +1385,11 @@ export function splitPaneEmptyInLayout(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
paneId: newPaneId,
|
paneId: newPaneId,
|
||||||
layout: {
|
layout: withNormalizedParentTabMap({
|
||||||
root: nextRoot,
|
root: nextRoot,
|
||||||
focusedPaneId: newPaneId,
|
focusedPaneId: newPaneId,
|
||||||
},
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,14 +1408,15 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return withNormalizedParentTabMap({
|
||||||
root: insertTabIntoPane(detached.root, {
|
root: insertTabIntoPane(detached.root, {
|
||||||
paneId: input.toPaneId,
|
paneId: input.toPaneId,
|
||||||
tab: detached.tab,
|
tab: detached.tab,
|
||||||
focusTabId: input.tabId,
|
focusTabId: input.tabId,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: input.toPaneId,
|
focusedPaneId: input.toPaneId,
|
||||||
};
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null {
|
export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null {
|
||||||
@@ -1389,21 +1426,23 @@ export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayou
|
|||||||
if (input.layout.focusedPaneId === input.paneId) {
|
if (input.layout.focusedPaneId === input.paneId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return withNormalizedParentTabMap({
|
||||||
root: input.layout.root,
|
root: input.layout.root,
|
||||||
focusedPaneId: input.paneId,
|
focusedPaneId: input.paneId,
|
||||||
};
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout {
|
export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout {
|
||||||
const layout = asInternalLayout(input.layout);
|
const layout = asInternalLayout(input.layout);
|
||||||
return {
|
return withNormalizedParentTabMap({
|
||||||
root: updateGroupSizesInTree(layout.root, {
|
root: updateGroupSizesInTree(layout.root, {
|
||||||
groupId: input.groupId,
|
groupId: input.groupId,
|
||||||
sizes: input.sizes,
|
sizes: input.sizes,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: layout.focusedPaneId,
|
focusedPaneId: layout.focusedPaneId,
|
||||||
};
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reorderPaneTabsInLayout(
|
export function reorderPaneTabsInLayout(
|
||||||
@@ -1414,13 +1453,14 @@ export function reorderPaneTabsInLayout(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return withNormalizedParentTabMap({
|
||||||
root: updatePaneInTree(layout.root, {
|
root: updatePaneInTree(layout.root, {
|
||||||
paneId: input.paneId,
|
paneId: input.paneId,
|
||||||
updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }),
|
updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }),
|
||||||
}),
|
}),
|
||||||
focusedPaneId: layout.focusedPaneId,
|
focusedPaneId: layout.focusedPaneId,
|
||||||
};
|
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStringSet(values: Iterable<string>): Set<string> {
|
function normalizeStringSet(values: Iterable<string>): Set<string> {
|
||||||
@@ -1434,6 +1474,86 @@ function normalizeStringSet(values: Iterable<string>): Set<string> {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeParentTabMap(input: {
|
||||||
|
raw: unknown;
|
||||||
|
openTabIds: ReadonlySet<string>;
|
||||||
|
}): Record<string, string> | undefined {
|
||||||
|
if (!input.raw || typeof input.raw !== "object" || Array.isArray(input.raw)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const [rawChildId, rawParentId] of Object.entries(input.raw)) {
|
||||||
|
const childId = trimNonEmpty(rawChildId);
|
||||||
|
const parentId = trimNonEmpty(typeof rawParentId === "string" ? rawParentId : null);
|
||||||
|
if (
|
||||||
|
!childId ||
|
||||||
|
!parentId ||
|
||||||
|
childId === parentId ||
|
||||||
|
!input.openTabIds.has(childId) ||
|
||||||
|
!input.openTabIds.has(parentId)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[childId] = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(next).length > 0 ? next : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLayoutParentTabMap(layout: WorkspaceLayout): Record<string, string> | undefined {
|
||||||
|
return normalizeParentTabMap({
|
||||||
|
raw: layout.parentTabIdByTabId,
|
||||||
|
openTabIds: new Set(collectAllTabs(layout.root).map((tab) => tab.tabId)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withNormalizedParentTabMap(layout: WorkspaceLayout): WorkspaceLayout {
|
||||||
|
const parentTabIdByTabId = normalizeLayoutParentTabMap(layout);
|
||||||
|
return parentTabIdByTabId
|
||||||
|
? { ...layout, parentTabIdByTabId }
|
||||||
|
: { root: layout.root, focusedPaneId: layout.focusedPaneId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCloseSuccessorTabId(input: {
|
||||||
|
pane: SplitPane;
|
||||||
|
tabId: string;
|
||||||
|
openTabIds: ReadonlySet<string>;
|
||||||
|
parentTabIdByTabId?: Record<string, string>;
|
||||||
|
}): string | null {
|
||||||
|
if (input.pane.focusedTabId !== input.tabId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabIndex = input.pane.tabIds.indexOf(input.tabId);
|
||||||
|
const parentTabId = input.parentTabIdByTabId?.[input.tabId] ?? null;
|
||||||
|
if (parentTabId && input.openTabIds.has(parentTabId)) {
|
||||||
|
return parentTabId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
input.pane.tabIds[tabIndex + 1] ??
|
||||||
|
(tabIndex > 0 ? input.pane.tabIds[tabIndex - 1] : null) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusedPaneIdAfterTabClose(input: {
|
||||||
|
root: SplitNode;
|
||||||
|
focusedPaneId: string | null;
|
||||||
|
fallbackPaneId: string | null;
|
||||||
|
}): string | null {
|
||||||
|
if (input.focusedPaneId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
findPaneById(input.root, input.focusedPaneId)?.id ??
|
||||||
|
(input.fallbackPaneId && findPaneById(input.root, input.fallbackPaneId)?.id) ??
|
||||||
|
collectAllPanes(input.root)[0]?.id ??
|
||||||
|
DEFAULT_PANE_ID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isEntityTarget(
|
function isEntityTarget(
|
||||||
target: WorkspaceTabTarget,
|
target: WorkspaceTabTarget,
|
||||||
): target is Extract<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
|
): target is Extract<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
|
||||||
@@ -1464,7 +1584,7 @@ function openEntityTabWithoutFocusing(
|
|||||||
invariant(focusedPane, "Workspace layout must always have a pane");
|
invariant(focusedPane, "Workspace layout must always have a pane");
|
||||||
|
|
||||||
const tabId = buildDeterministicWorkspaceTabId(target);
|
const tabId = buildDeterministicWorkspaceTabId(target);
|
||||||
return {
|
return withNormalizedParentTabMap({
|
||||||
root: insertTabIntoPane(internalLayout.root, {
|
root: insertTabIntoPane(internalLayout.root, {
|
||||||
paneId: focusedPane.id,
|
paneId: focusedPane.id,
|
||||||
tab: {
|
tab: {
|
||||||
@@ -1475,7 +1595,8 @@ function openEntityTabWithoutFocusing(
|
|||||||
focusTabId: focusedPane.focusedTabId ?? tabId,
|
focusTabId: focusedPane.focusedTabId ?? tabId,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: internalLayout.focusedPaneId,
|
focusedPaneId: internalLayout.focusedPaneId,
|
||||||
};
|
parentTabIdByTabId: layout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityTabGroup {
|
interface EntityTabGroup {
|
||||||
@@ -1654,14 +1775,15 @@ export function reconcileWorkspaceTabs(
|
|||||||
reconciledFocusedTabId = canonicalTabId;
|
reconciledFocusedTabId = canonicalTabId;
|
||||||
}
|
}
|
||||||
if (keeper.tabId !== canonicalTabId || !workspaceTabTargetsEqual(keeper.target, group.target)) {
|
if (keeper.tabId !== canonicalTabId || !workspaceTabTargetsEqual(keeper.target, group.target)) {
|
||||||
nextLayout = {
|
nextLayout = withNormalizedParentTabMap({
|
||||||
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
||||||
tabId: keeper.tabId,
|
tabId: keeper.tabId,
|
||||||
nextTabId: canonicalTabId,
|
nextTabId: canonicalTabId,
|
||||||
target: group.target,
|
target: group.target,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: nextLayout.focusedPaneId,
|
focusedPaneId: nextLayout.focusedPaneId,
|
||||||
};
|
parentTabIdByTabId: nextLayout.parentTabIdByTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const tab of group.tabs) {
|
for (const tab of group.tabs) {
|
||||||
if (tab.tabId === keeper.tabId) {
|
if (tab.tabId === keeper.tabId) {
|
||||||
|
|||||||
@@ -429,6 +429,90 @@ describe("workspace-layout-store actions", () => {
|
|||||||
expect(pane.focusedTabId).toBe(secondTabId);
|
expect(pane.focusedTabId).toBe(secondTabId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closing a focused middle tab selects the tab to its right", () => {
|
||||||
|
const workspaceKey = createWorkspaceKey();
|
||||||
|
const firstTabId = "draft-1";
|
||||||
|
const closedTabId = "draft-2";
|
||||||
|
const rightTabId = "draft-3";
|
||||||
|
|
||||||
|
workspaceLayoutStore.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
layoutByWorkspace: {
|
||||||
|
...state.layoutByWorkspace,
|
||||||
|
[workspaceKey]: {
|
||||||
|
root: createPane({
|
||||||
|
id: "main",
|
||||||
|
tabIds: [firstTabId, closedTabId, rightTabId],
|
||||||
|
focusedTabId: closedTabId,
|
||||||
|
}),
|
||||||
|
focusedPaneId: "main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
workspaceLayoutStore.getState().closeTab(workspaceKey, closedTabId);
|
||||||
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
const pane = findPaneById(layout.root, "main")!;
|
||||||
|
|
||||||
|
expect(pane.tabIds).toEqual([firstTabId, rightTabId]);
|
||||||
|
expect(pane.focusedTabId).toBe(rightTabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing a focused child tab returns to its parent before using tab-strip order", () => {
|
||||||
|
const workspaceKey = createWorkspaceKey();
|
||||||
|
const store = workspaceLayoutStore.getState();
|
||||||
|
|
||||||
|
const parentTabId = store.openTabFocused(workspaceKey, {
|
||||||
|
kind: "draft",
|
||||||
|
draftId: "draft-parent",
|
||||||
|
});
|
||||||
|
const childTabId = store.openChildTabFocused(
|
||||||
|
workspaceKey,
|
||||||
|
{ kind: "draft", draftId: "draft-child" },
|
||||||
|
parentTabId!,
|
||||||
|
);
|
||||||
|
const rightTabId = store.openTabFocused(workspaceKey, {
|
||||||
|
kind: "draft",
|
||||||
|
draftId: "draft-right",
|
||||||
|
});
|
||||||
|
store.focusTab(workspaceKey, childTabId!);
|
||||||
|
|
||||||
|
workspaceLayoutStore.getState().closeTab(workspaceKey, childTabId!);
|
||||||
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
const pane = findPaneById(layout.root, "main")!;
|
||||||
|
|
||||||
|
expect(pane.tabIds).toEqual([parentTabId, rightTabId]);
|
||||||
|
expect(pane.focusedTabId).toBe(parentTabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing a focused last tab selects the tab to its left", () => {
|
||||||
|
const workspaceKey = createWorkspaceKey();
|
||||||
|
const leftTabId = "draft-1";
|
||||||
|
const closedTabId = "draft-2";
|
||||||
|
|
||||||
|
workspaceLayoutStore.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
layoutByWorkspace: {
|
||||||
|
...state.layoutByWorkspace,
|
||||||
|
[workspaceKey]: {
|
||||||
|
root: createPane({
|
||||||
|
id: "main",
|
||||||
|
tabIds: [leftTabId, closedTabId],
|
||||||
|
focusedTabId: closedTabId,
|
||||||
|
}),
|
||||||
|
focusedPaneId: "main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
workspaceLayoutStore.getState().closeTab(workspaceKey, closedTabId);
|
||||||
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
const pane = findPaneById(layout.root, "main")!;
|
||||||
|
|
||||||
|
expect(pane.tabIds).toEqual([leftTabId]);
|
||||||
|
expect(pane.focusedTabId).toBe(leftTabId);
|
||||||
|
});
|
||||||
|
|
||||||
it("unfocuses and restores the previous focused pane", () => {
|
it("unfocuses and restores the previous focused pane", () => {
|
||||||
const workspaceKey = createWorkspaceKey();
|
const workspaceKey = createWorkspaceKey();
|
||||||
const store = workspaceLayoutStore.getState();
|
const store = workspaceLayoutStore.getState();
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ interface WorkspaceLayoutStore {
|
|||||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||||
|
openChildTabFocused: (
|
||||||
|
workspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
parentTabId: string,
|
||||||
|
) => string | null;
|
||||||
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||||
@@ -188,6 +193,31 @@ function withoutFocusRestoration(
|
|||||||
return { focusRestorationByWorkspace };
|
return { focusRestorationByWorkspace };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachParentTab(input: {
|
||||||
|
layout: WorkspaceLayout;
|
||||||
|
childTabId: string | null;
|
||||||
|
parentTabId: string | null;
|
||||||
|
}): WorkspaceLayout {
|
||||||
|
const childTabId = trimNonEmpty(input.childTabId);
|
||||||
|
const parentTabId = trimNonEmpty(input.parentTabId);
|
||||||
|
if (!childTabId || !parentTabId || childTabId === parentTabId) {
|
||||||
|
return normalizeLayout(input.layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTabIds = new Set(collectAllTabs(input.layout.root).map((tab) => tab.tabId));
|
||||||
|
if (!openTabIds.has(childTabId) || !openTabIds.has(parentTabId)) {
|
||||||
|
return normalizeLayout(input.layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeLayout({
|
||||||
|
...input.layout,
|
||||||
|
parentTabIdByTabId: {
|
||||||
|
...input.layout.parentTabIdByTabId,
|
||||||
|
[childTabId]: parentTabId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createWorkspaceLayoutStore(
|
export function createWorkspaceLayoutStore(
|
||||||
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
||||||
) {
|
) {
|
||||||
@@ -230,6 +260,45 @@ export function createWorkspaceLayoutStore(
|
|||||||
|
|
||||||
return result.tabId;
|
return result.tabId;
|
||||||
},
|
},
|
||||||
|
openChildTabFocused: (workspaceKey, target, parentTabId) => {
|
||||||
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
|
const normalizedParentTabId = trimNonEmpty(parentTabId);
|
||||||
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
|
if (!normalizedWorkspaceKey || !normalizedParentTabId || !normalizedTarget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = openTabInLayoutFocused({
|
||||||
|
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||||
|
target: normalizedTarget,
|
||||||
|
now: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const layout = attachParentTab({
|
||||||
|
layout: result.layout,
|
||||||
|
childTabId: result.tabId,
|
||||||
|
parentTabId: normalizedParentTabId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
|
hiddenAgentIdsByWorkspace:
|
||||||
|
normalizedTarget.kind !== "agent"
|
||||||
|
? state.hiddenAgentIdsByWorkspace
|
||||||
|
: removeAgentIdFromWorkspaceSet(
|
||||||
|
state.hiddenAgentIdsByWorkspace,
|
||||||
|
normalizedWorkspaceKey,
|
||||||
|
normalizedTarget.agentId,
|
||||||
|
),
|
||||||
|
layoutByWorkspace: {
|
||||||
|
...state.layoutByWorkspace,
|
||||||
|
[normalizedWorkspaceKey]: layout,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.tabId;
|
||||||
|
},
|
||||||
openTabInBackground: (workspaceKey, target) => {
|
openTabInBackground: (workspaceKey, target) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
|
|||||||
Reference in New Issue
Block a user