Return to parent tabs on close
This commit is contained in:
@@ -1502,6 +1502,9 @@ function WorkspaceScreenContent({
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const openWorkspaceChildTabFocused = useWorkspaceLayoutStore(
|
||||
(state) => state.openChildTabFocused,
|
||||
);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
@@ -2039,7 +2042,7 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
const handleOpenFileFromChat = useCallback(
|
||||
(location: WorkspaceFileLocation) => {
|
||||
(location: WorkspaceFileLocation, options?: { parentTabId?: string | null }) => {
|
||||
const normalizedLocation = normalizeWorkspaceFileLocation(location);
|
||||
if (!normalizedLocation) {
|
||||
return;
|
||||
@@ -2050,25 +2053,36 @@ function WorkspaceScreenContent({
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const tabId = openWorkspaceTabFocused(
|
||||
persistenceKey,
|
||||
createWorkspaceFileTabTarget(normalizedLocation),
|
||||
);
|
||||
const target = createWorkspaceFileTabTarget(normalizedLocation);
|
||||
const tabId = options?.parentTabId
|
||||
? openWorkspaceChildTabFocused(persistenceKey, target, options.parentTabId)
|
||||
: openWorkspaceTabFocused(persistenceKey, target);
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent],
|
||||
[
|
||||
isMobile,
|
||||
navigateToTabId,
|
||||
openWorkspaceChildTabFocused,
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
showMobileAgent,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenFileFromChatInSidePane = useCallback(
|
||||
(input: { location: WorkspaceFileLocation; sourcePaneId?: string }) => {
|
||||
(input: {
|
||||
location: WorkspaceFileLocation;
|
||||
sourcePaneId?: string;
|
||||
parentTabId?: string | null;
|
||||
}) => {
|
||||
const location = normalizeWorkspaceFileLocation(input.location);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (!persistenceKey || isMobile || !input.sourcePaneId) {
|
||||
handleOpenFileFromChat(location);
|
||||
handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
|
||||
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) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
@@ -2098,6 +2114,7 @@ function WorkspaceScreenContent({
|
||||
isMobile,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
openWorkspaceChildTabFocused,
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
splitWorkspacePaneEmpty,
|
||||
@@ -2756,7 +2773,7 @@ function WorkspaceScreenContent({
|
||||
if (input.focusPaneBeforeOpen && input.paneId) {
|
||||
focusWorkspacePane(persistenceKey, input.paneId);
|
||||
}
|
||||
const tabId = openWorkspaceTabFocused(persistenceKey, target);
|
||||
const tabId = openWorkspaceChildTabFocused(persistenceKey, target, input.tab.tabId);
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
@@ -2782,10 +2799,11 @@ function WorkspaceScreenContent({
|
||||
handleOpenFileFromChatInSidePane({
|
||||
location: request.location,
|
||||
sourcePaneId: input.paneId ?? undefined,
|
||||
parentTabId: input.tab.tabId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleOpenFileFromChat(request.location);
|
||||
handleOpenFileFromChat(request.location, { parentTabId: input.tab.tabId });
|
||||
},
|
||||
onOpenImportSheet: openImportSheet,
|
||||
}),
|
||||
@@ -2798,7 +2816,7 @@ function WorkspaceScreenContent({
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openImportSheet,
|
||||
openWorkspaceTabFocused,
|
||||
openWorkspaceChildTabFocused,
|
||||
persistenceKey,
|
||||
convertWorkspaceDraftToAgent,
|
||||
retargetWorkspaceTab,
|
||||
|
||||
@@ -26,6 +26,7 @@ export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; gro
|
||||
export interface WorkspaceLayout {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string | null;
|
||||
parentTabIdByTabId?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SplitPaneInternal extends SplitPane {
|
||||
@@ -903,10 +904,16 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout {
|
||||
collectAllPanes(root)[0]?.id ??
|
||||
DEFAULT_PANE_ID);
|
||||
|
||||
return {
|
||||
const normalizedLayout = {
|
||||
root,
|
||||
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 {
|
||||
@@ -1040,14 +1047,15 @@ function insertNewTabIntoFocusedPane(input: {
|
||||
|
||||
return {
|
||||
tabId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: insertTabIntoPane(layout.root, {
|
||||
paneId: focusedPane.id,
|
||||
tab: nextTab,
|
||||
focusTabId: input.focus ? tabId : preservedFocusTabId,
|
||||
}),
|
||||
focusedPaneId: input.focus ? focusedPane.id : layout.focusedPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1061,28 +1069,28 @@ function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabT
|
||||
}
|
||||
|
||||
function updateExistingTabTarget(
|
||||
layout: { root: SplitNodeInternal; focusedPaneId: string | null },
|
||||
layout: WorkspaceLayout,
|
||||
tab: WorkspaceTab,
|
||||
target: WorkspaceTabTarget,
|
||||
): { root: SplitNodeInternal; focusedPaneId: string | null } {
|
||||
): WorkspaceLayout {
|
||||
if (workspaceTabTargetsEqual(tab.target, target)) {
|
||||
return layout;
|
||||
}
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
...layout,
|
||||
root: replaceTabInTree(layout.root, {
|
||||
root: replaceTabInTree(asInternalNode(layout.root), {
|
||||
tabId: tab.tabId,
|
||||
nextTabId: tab.tabId,
|
||||
target,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function openTabInLayoutFocused(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = findExistingTabForTarget(layout.root, input.target);
|
||||
if (existingTab) {
|
||||
const nextLayout = updateExistingTabTarget(layout, existingTab, input.target);
|
||||
const nextLayout = updateExistingTabTarget(input.layout, existingTab, input.target);
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
@@ -1102,7 +1110,7 @@ export function openTabInLayoutBackground(input: OpenTabInLayoutInput): OpenTabI
|
||||
if (existingTab) {
|
||||
return {
|
||||
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;
|
||||
}
|
||||
|
||||
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 nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal;
|
||||
const nextFocusedPaneId =
|
||||
internalLayout.focusedPaneId === null
|
||||
? null
|
||||
: (findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ??
|
||||
(fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ??
|
||||
collectAllPanes(nextRoot)[0]?.id ??
|
||||
DEFAULT_PANE_ID);
|
||||
const parentTabIdByTabId = normalizeParentTabMap({
|
||||
raw: input.layout.parentTabIdByTabId,
|
||||
openTabIds: new Set(collectAllTabs(nextRoot).map((tab) => tab.tabId)),
|
||||
});
|
||||
const nextFocusedPaneId = getFocusedPaneIdAfterTabClose({
|
||||
root: nextRoot,
|
||||
focusedPaneId: internalLayout.focusedPaneId,
|
||||
fallbackPaneId,
|
||||
});
|
||||
|
||||
return {
|
||||
const nextLayout = {
|
||||
root: nextRoot,
|
||||
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 {
|
||||
@@ -1143,10 +1173,11 @@ export function focusTabInLayout(input: FocusTabInLayoutInput): WorkspaceLayout
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: focusTabInPane(layout.root, pane.id, input.tabId),
|
||||
focusedPaneId: pane.id,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function retargetTabInLayout(
|
||||
@@ -1197,14 +1228,15 @@ export function retargetTabInLayout(
|
||||
// target identity immediately so local tab state cannot masquerade as the
|
||||
// previous agent/terminal/file.
|
||||
tabId: nextTabId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId,
|
||||
target: input.target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1243,14 +1275,15 @@ export function convertDraftToAgentInLayout(
|
||||
|
||||
return {
|
||||
tabId: canonicalTabId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1262,13 +1295,14 @@ export function reorderFocusedPaneTabsInLayout(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: updatePaneInTree(layout.root, {
|
||||
paneId: layout.focusedPaneId,
|
||||
updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }),
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLayoutResult | null {
|
||||
@@ -1293,10 +1327,11 @@ export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLay
|
||||
|
||||
return {
|
||||
paneId: result.newPaneId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: result.root,
|
||||
focusedPaneId: result.newPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1350,10 +1385,11 @@ export function splitPaneEmptyInLayout(
|
||||
|
||||
return {
|
||||
paneId: newPaneId,
|
||||
layout: {
|
||||
layout: withNormalizedParentTabMap({
|
||||
root: nextRoot,
|
||||
focusedPaneId: newPaneId,
|
||||
},
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1372,14 +1408,15 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: insertTabIntoPane(detached.root, {
|
||||
paneId: input.toPaneId,
|
||||
tab: detached.tab,
|
||||
focusTabId: input.tabId,
|
||||
}),
|
||||
focusedPaneId: input.toPaneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayout | null {
|
||||
@@ -1389,21 +1426,23 @@ export function focusPaneInLayout(input: FocusPaneInLayoutInput): WorkspaceLayou
|
||||
if (input.layout.focusedPaneId === input.paneId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: input.layout.root,
|
||||
focusedPaneId: input.paneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resizeSplitInLayout(input: ResizeSplitInLayoutInput): WorkspaceLayout {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: updateGroupSizesInTree(layout.root, {
|
||||
groupId: input.groupId,
|
||||
sizes: input.sizes,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
export function reorderPaneTabsInLayout(
|
||||
@@ -1414,13 +1453,14 @@ export function reorderPaneTabsInLayout(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: updatePaneInTree(layout.root, {
|
||||
paneId: input.paneId,
|
||||
updater: (pane) => reorderTabsForPane({ pane, tabIds: input.tabIds }),
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: input.layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStringSet(values: Iterable<string>): Set<string> {
|
||||
@@ -1434,6 +1474,86 @@ function normalizeStringSet(values: Iterable<string>): Set<string> {
|
||||
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(
|
||||
target: WorkspaceTabTarget,
|
||||
): target is Extract<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
|
||||
@@ -1464,7 +1584,7 @@ function openEntityTabWithoutFocusing(
|
||||
invariant(focusedPane, "Workspace layout must always have a pane");
|
||||
|
||||
const tabId = buildDeterministicWorkspaceTabId(target);
|
||||
return {
|
||||
return withNormalizedParentTabMap({
|
||||
root: insertTabIntoPane(internalLayout.root, {
|
||||
paneId: focusedPane.id,
|
||||
tab: {
|
||||
@@ -1475,7 +1595,8 @@ function openEntityTabWithoutFocusing(
|
||||
focusTabId: focusedPane.focusedTabId ?? tabId,
|
||||
}),
|
||||
focusedPaneId: internalLayout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: layout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
|
||||
interface EntityTabGroup {
|
||||
@@ -1654,14 +1775,15 @@ export function reconcileWorkspaceTabs(
|
||||
reconciledFocusedTabId = canonicalTabId;
|
||||
}
|
||||
if (keeper.tabId !== canonicalTabId || !workspaceTabTargetsEqual(keeper.target, group.target)) {
|
||||
nextLayout = {
|
||||
nextLayout = withNormalizedParentTabMap({
|
||||
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
||||
tabId: keeper.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target: group.target,
|
||||
}),
|
||||
focusedPaneId: nextLayout.focusedPaneId,
|
||||
};
|
||||
parentTabIdByTabId: nextLayout.parentTabIdByTabId,
|
||||
});
|
||||
}
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.tabId === keeper.tabId) {
|
||||
|
||||
@@ -429,6 +429,90 @@ describe("workspace-layout-store actions", () => {
|
||||
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", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
@@ -76,6 +76,11 @@ interface WorkspaceLayoutStore {
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
openChildTabFocused: (
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
parentTabId: string,
|
||||
) => string | null;
|
||||
openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||
@@ -188,6 +193,31 @@ function withoutFocusRestoration(
|
||||
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(
|
||||
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
||||
) {
|
||||
@@ -230,6 +260,45 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
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) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
|
||||
Reference in New Issue
Block a user