From ce9026f26470b5ff4c3e1f3e418ce437dda53def Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 18 Aug 2026 11:07:52 +0200 Subject: [PATCH] fix(app): retain workspace explorer pane Desktop workspaces need a stable hidden explorer pane so setup and pull-request tabs have a consistent destination without changing the active pane. Mobile keeps pull requests in its explorer sidebar. --- .../screens/workspace/workspace-screen.tsx | 166 ++++++++++------ .../src/stores/workspace-layout-actions.ts | 32 ++- .../src/stores/workspace-layout-store.test.ts | 95 +++++---- .../app/src/stores/workspace-layout-store.ts | 184 +++++++++++++++++- 4 files changed, 363 insertions(+), 114 deletions(-) diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 2725e16cd..ab5278904 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -53,9 +53,10 @@ import { traceInstant } from "@/performance/native-trace"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { collectAllTabs, - createDefaultLayout, + createWorkspaceLayoutWithExplorer, findPaneById, getFocusedBrowserId, + resolveExplorerPaneId, type WorkspaceLayout, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, @@ -1360,8 +1361,12 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): return `${serverId}:${workspaceId}`; } -function canObservePullRequest(isRouteFocused: boolean, isGitCheckout: boolean): boolean { - return isRouteFocused && isGitCheckout; +function canObservePullRequest( + isRouteFocused: boolean, + isGitCheckout: boolean, + isCompact: boolean, +): boolean { + return isRouteFocused && isGitCheckout && !isCompact && supportsDesktopPaneSplits(); } interface ToggleWorkspaceExplorerPaneInput { @@ -1383,15 +1388,28 @@ function toggleWorkspaceExplorerPane(input: ToggleWorkspaceExplorerPaneInput): v } const store = useWorkspaceLayoutStore.getState(); - const layout = store.layoutByWorkspace[input.persistenceKey] ?? createDefaultLayout(); + const layout = + store.layoutByWorkspace[input.persistenceKey] ?? createWorkspaceLayoutWithExplorer(); const parentTabId = findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null; - const explorerPaneId = store.explorerPaneIdByWorkspace[input.persistenceKey] ?? null; + const explorerPaneId = resolveExplorerPaneId( + layout, + store.explorerPaneIdByWorkspace[input.persistenceKey], + ); const explorerPane = findPaneById(layout.root, explorerPaneId); if (explorerPane) { if (explorerPane.hidden === true) { store.showPane(input.persistenceKey, explorerPane.id); } else { store.hidePane(input.persistenceKey, explorerPane.id); + return; + } + if (explorerPane.tabIds.length === 0) { + const tabId = parentTabId + ? store.openChildTabFocused(input.persistenceKey, { kind: "working_diff" }, parentTabId) + : store.openTabFocused(input.persistenceKey, { kind: "working_diff" }); + if (tabId) { + store.moveTabToPane(input.persistenceKey, tabId, explorerPane.id); + } } return; } @@ -1415,8 +1433,10 @@ function isWorkspaceExplorerPaneOpen( if (!persistenceKey) { return false; } - const paneId = state.explorerPaneIdByWorkspace[persistenceKey]; const layout = state.layoutByWorkspace[persistenceKey]; + const paneId = layout + ? resolveExplorerPaneId(layout, state.explorerPaneIdByWorkspace[persistenceKey]) + : null; const pane = paneId && layout ? findPaneById(layout.root, paneId) : null; return Boolean(pane && pane.hidden !== true); } @@ -1706,7 +1726,7 @@ function WorkspaceScreenContent({ workspaceKey: persistenceKey, serverId: normalizedServerId, cwd: workspaceDirectory, - enabled: canObservePullRequest(isRouteFocused, isGitCheckout), + enabled: canObservePullRequest(isRouteFocused, isGitCheckout, isMobile), }); const isCompactExplorerOpen = usePanelStore((state) => @@ -1785,6 +1805,12 @@ function WorkspaceScreenContent({ const openWorkspaceTabInBackground = useWorkspaceLayoutStore( (state) => state.openTabInBackground, ); + const openWorkspaceTabInExplorerPaneBackground = useWorkspaceLayoutStore( + (state) => state.openTabInExplorerPaneBackground, + ); + const openWorkspaceTabInExplorerPaneFocused = useWorkspaceLayoutStore( + (state) => state.openTabInExplorerPaneFocused, + ); const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); @@ -1994,7 +2020,6 @@ function WorkspaceScreenContent({ ), [normalizedWorkspaceId, uiTabs], ); - const navigateToTabId = useCallback( function navigateToTabId(tabId: string) { if (!tabId || !persistenceKey) { @@ -2020,6 +2045,64 @@ function WorkspaceScreenContent({ const emptyWorkspaceSeedRef = useRef(null); const autoOpenedSetupTabWorkspaceRef = useRef(null); + useEffect(() => { + if (!isRouteFocused) { + return; + } + if (!persistenceKey) { + return; + } + if (!workspaceSetupSnapshot || !showWorkspaceSetup) { + if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) { + autoOpenedSetupTabWorkspaceRef.current = null; + } + return; + } + + const snapshotAge = Date.now() - workspaceSetupSnapshot.updatedAt; + const shouldAutoOpen = + workspaceSetupSnapshot.status === "running" || + snapshotAge <= WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS; + if (!shouldAutoOpen) { + return; + } + if (hasSetupTab) { + autoOpenedSetupTabWorkspaceRef.current = persistenceKey; + return; + } + if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) { + return; + } + + const target = normalizeWorkspaceTabTarget({ + kind: "setup", + workspaceId: normalizedWorkspaceId, + }); + if (!target) { + return; + } + + const tabId = + isMobile || !supportsDesktopPaneSplits() + ? openWorkspaceTabInBackground(persistenceKey, target) + : openWorkspaceTabInExplorerPaneBackground(persistenceKey, target); + if (!tabId) { + return; + } + + autoOpenedSetupTabWorkspaceRef.current = persistenceKey; + }, [ + hasSetupTab, + isMobile, + isRouteFocused, + normalizedWorkspaceId, + openWorkspaceTabInBackground, + openWorkspaceTabInExplorerPaneBackground, + persistenceKey, + showWorkspaceSetup, + workspaceSetupSnapshot, + ]); + useEffect(() => { if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) { return; @@ -2075,59 +2158,6 @@ function WorkspaceScreenContent({ workspaceAgentVisibility.activeAgentIds.size, ]); - useEffect(() => { - if (!isRouteFocused) { - return; - } - if (!persistenceKey) { - return; - } - if (!workspaceSetupSnapshot || !showWorkspaceSetup) { - if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) { - autoOpenedSetupTabWorkspaceRef.current = null; - } - return; - } - - const snapshotAge = Date.now() - workspaceSetupSnapshot.updatedAt; - const shouldAutoOpen = - workspaceSetupSnapshot.status === "running" || - snapshotAge <= WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS; - if (!shouldAutoOpen) { - return; - } - if (hasSetupTab) { - autoOpenedSetupTabWorkspaceRef.current = persistenceKey; - return; - } - if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) { - return; - } - - const target = normalizeWorkspaceTabTarget({ - kind: "setup", - workspaceId: normalizedWorkspaceId, - }); - if (!target) { - return; - } - - const tabId = openWorkspaceTabInBackground(persistenceKey, target); - if (!tabId) { - return; - } - - autoOpenedSetupTabWorkspaceRef.current = persistenceKey; - }, [ - hasSetupTab, - isRouteFocused, - normalizedWorkspaceId, - openWorkspaceTabInBackground, - persistenceKey, - showWorkspaceSetup, - workspaceSetupSnapshot, - ]); - const handleOpenFileFromExplorer = useCallback( function handleOpenFileFromExplorer(filePath: string) { if (!persistenceKey) { @@ -2704,8 +2734,18 @@ function WorkspaceScreenContent({ if (!target) { return; } - openWorkspaceTabFocused(persistenceKey, target); - }, [normalizedWorkspaceId, openWorkspaceTabFocused, persistenceKey]); + if (isMobile || !supportsDesktopPaneSplits()) { + openWorkspaceTabFocused(persistenceKey, target); + return; + } + openWorkspaceTabInExplorerPaneFocused(persistenceKey, { target }); + }, [ + isMobile, + normalizedWorkspaceId, + openWorkspaceTabFocused, + openWorkspaceTabInExplorerPaneFocused, + persistenceKey, + ]); const handleBulkCloseTabs = useCallback( async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => { diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index d68b2e8d9..e4724f2f3 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -140,6 +140,7 @@ interface ReorderFocusedPaneTabsInLayoutInput { interface CloseTabInLayoutInput { layout: WorkspaceLayout; tabId: string; + preserveEmptyPaneId?: string | null; } interface SplitPaneInLayoutInput { @@ -211,6 +212,8 @@ export interface WorkspaceTabSnapshot { } const DEFAULT_PANE_ID = "main"; +export const DEFAULT_EXPLORER_PANE_ID = "explorer"; +const DEFAULT_LAYOUT_GROUP_ID = "workspace-root"; function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { @@ -1051,6 +1054,22 @@ export function createDefaultLayout(): WorkspaceLayout { }; } +/** The desktop companion pane exists before it is first shown. */ +export function createWorkspaceLayoutWithExplorer(): WorkspaceLayout { + return { + root: createGroupNode({ + id: DEFAULT_LAYOUT_GROUP_ID, + direction: "horizontal", + children: [ + createPaneNode({ id: DEFAULT_PANE_ID }), + createPaneNode({ id: DEFAULT_EXPLORER_PANE_ID, hidden: true }), + ], + sizes: [0.5, 0.5], + }), + focusedPaneId: DEFAULT_PANE_ID, + }; +} + export function insertSplit( root: SplitNode, targetPaneId: string, @@ -1079,7 +1098,10 @@ export function removePaneFromTree(root: SplitNode, paneId: string): SplitNode { } export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode { - return detachTabFromTree(asInternalNode(root), { tabId }).root; + return detachTabFromTree(asInternalNode(root), { + tabId, + preserveEmptyPaneId: DEFAULT_PANE_ID, + }).root; } function insertNewTabIntoFocusedPane(input: { @@ -1182,6 +1204,9 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout if (!pane) { return null; } + const preserveEmptyPaneId = + input.preserveEmptyPaneId ?? + (pane.id === DEFAULT_PANE_ID || pane.id === DEFAULT_EXPLORER_PANE_ID ? pane.id : null); const closeSuccessorTabId = getCloseSuccessorTabId({ pane, @@ -1190,7 +1215,10 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout parentTabIdByTabId: input.layout.parentTabIdByTabId, }); const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id); - const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal; + const nextRoot = detachTabFromTree(internalLayout.root, { + tabId: input.tabId, + preserveEmptyPaneId, + }).root; const parentTabIdByTabId = normalizeParentTabMap({ raw: input.layout.parentTabIdByTabId, openTabIds: new Set(collectAllTabs(nextRoot).map((tab) => tab.tabId)), diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index d9c917765..71d3e7d95 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -22,6 +22,7 @@ import { collectAllTabs, createWorkspaceLayoutStore, createDefaultLayout, + createWorkspaceLayoutWithExplorer, findPaneById, findPaneContainingTab, getFocusedBrowserId, @@ -329,7 +330,7 @@ describe("workspace-layout-store actions", () => { }); }); - it("keeps layouts written before split sizes were persisted", async () => { + it("migrates legacy layouts to include the hidden registered explorer pane", async () => { const legacyLayout = createDefaultLayout(); await AsyncStorage.setItem( "workspace-layout-state", @@ -342,7 +343,12 @@ describe("workspace-layout-store actions", () => { await restored.persist.rehydrate(); - expect(restored.getState().layoutByWorkspace.legacy).toEqual(legacyLayout); + const state = restored.getState(); + const layout = state.layoutByWorkspace.legacy; + const explorerPaneId = state.explorerPaneIdByWorkspace.legacy; + expect(findPaneById(layout.root, "main")).toBeTruthy(); + expect(explorerPaneId).toBeTruthy(); + expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBe(true); expect(restored.getState().splitSizesByWorkspace).toEqual({}); await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull(); }); @@ -407,6 +413,40 @@ describe("workspace-layout-store actions", () => { ]); }); + it("reveals the registered explorer pane for a first detected pull request", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + + store.observePullRequest(workspaceKey, "url:https://example.test/pulls/1"); + + const state = workspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[workspaceKey]; + const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey]; + expect(explorerPaneId).toBe("explorer"); + expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBeUndefined(); + expect(findPaneContainingTab(layout.root, "pull_request")?.id).toBe(explorerPaneId); + }); + + it("creates setup in the hidden explorer pane without changing focus", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + + const tabId = store.openTabInExplorerPaneBackground(workspaceKey, { + kind: "setup", + workspaceId: WORKSPACE_ID, + }); + + const state = workspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[workspaceKey]; + const explorerPaneId = state.explorerPaneIdByWorkspace[workspaceKey]; + expect(tabId).toBe("setup_ws-main"); + expect(layout.focusedPaneId).toBe("main"); + expect(findPaneById(layout.root, explorerPaneId)?.hidden).toBe(true); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(agentTabId); + expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe(explorerPaneId); + }); + it("places an auto-added pull request in the registered explorer pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -445,8 +485,6 @@ describe("workspace-layout-store actions", () => { it("opens assistant files in an ensured explorer pane and reveals it on the next open", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - useWorkspaceLayoutIds("explorer"); - const tabId = store.openTabInExplorerPaneFocused(workspaceKey, { target: { kind: "file", path: "/repo/worktree/a.ts" }, parentTabId: "agent_agent-a", @@ -454,7 +492,7 @@ describe("workspace-layout-store actions", () => { const createdState = workspaceLayoutStore.getState(); const explorerPaneId = createdState.explorerPaneIdByWorkspace[workspaceKey]; - expect(explorerPaneId).toBe("pane_explorer"); + expect(explorerPaneId).toBe("explorer"); expect( findPaneContainingTab(createdState.layoutByWorkspace[workspaceKey].root, tabId!)?.id, ).toBe(explorerPaneId); @@ -1276,7 +1314,7 @@ describe("workspace-layout-store actions", () => { expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); }); - it("splitPane enforces the maximum depth of four", () => { + it("splitPane preserves four user-created levels beneath the explorer split", () => { useWorkspaceLayoutIds( "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222", @@ -1323,7 +1361,7 @@ describe("workspace-layout-store actions", () => { expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333"); expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555"); expect(pane4).toBeNull(); - expect(getTreeDepth(layout.root)).toBe(4); + expect(getTreeDepth(layout.root)).toBe(5); }); it("moveTabToPane collapses the source pane when its last tab moves out", () => { @@ -1388,38 +1426,13 @@ describe("workspace-layout-store actions", () => { store.closeTab(workspaceKey, secondTabId!); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - const rootGroup = expectGroup(layout.root); expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878"); expect(paneCId).toBe("pane_9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a"); expect(layout.focusedPaneId).toBe(paneCId); - expect(rootGroup.group.direction).toBe("horizontal"); - 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]); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]); + expect(findPaneById(layout.root, paneCId)?.tabIds).toEqual(["file_/repo/worktree/c.ts"]); + expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); }); it("openTab focuses the existing tab instead of creating a duplicate entry", () => { @@ -1546,17 +1559,21 @@ describe("workspace-layout-store actions", () => { const farRightPaneId = store.splitPane(workspaceKey, { tabId: c!, targetPaneId: rightPaneId!, - position: "right", + position: "bottom", }); const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const splitGroup = expectGroup(splitRoot); - const nestedGroup = expectGroup(splitGroup.group.children[1]); + const nestedGroup = expectGroup( + splitGroup.group.children.find((child) => child.kind === "group")!, + ); store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]); const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root; const resizedGroup = expectGroup(resizedRoot); - const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]); + const resizedNestedGroup = expectGroup( + resizedGroup.group.children.find((child) => child.kind === "group")!, + ); const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0); expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); @@ -1574,7 +1591,7 @@ describe("workspace-layout-store actions", () => { store.closeTab(workspaceKey, tabId!); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(layout).toEqual(createDefaultLayout()); + expect(layout).toEqual(createWorkspaceLayoutWithExplorer()); }); it("keeps pinned archived agents in memory per workspace without persisting them", () => { diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index fdadc5ac1..282670d62 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -15,6 +15,8 @@ import { collectAllTabs, convertDraftToAgentInLayout, createDefaultLayout, + createWorkspaceLayoutWithExplorer, + DEFAULT_EXPLORER_PANE_ID, findPaneById, findPaneContainingTab, focusPaneInLayout, @@ -50,6 +52,7 @@ export { collectAllPanes, collectAllTabs, createDefaultLayout, + createWorkspaceLayoutWithExplorer, findPaneById, findPaneContainingTab, getFocusedBrowserId, @@ -85,6 +88,10 @@ interface WorkspaceLayoutStore { parentTabId: string, ) => string | null; openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; + openTabInExplorerPaneBackground: ( + workspaceKey: string, + target: WorkspaceTabTarget, + ) => string | null; ensureExplorerPane: (workspaceKey: string) => EnsureExplorerPaneResult | null; openTabInExplorerPaneFocused: ( workspaceKey: string, @@ -140,7 +147,9 @@ interface WorkspaceFocusRestorationState { tokens: string[]; } -const MAX_TREE_DEPTH = 4; +// The root companion split is always present for the hidden explorer pane. +// Preserve four user-created split levels beneath it. +const MAX_TREE_DEPTH = 5; const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ provider: z.string(), @@ -293,7 +302,52 @@ function getWorkspaceLayout( state: Record, workspaceKey: string, ): WorkspaceLayout { - return normalizeLayout(state[workspaceKey] ?? createDefaultLayout()); + return normalizeLayout(state[workspaceKey] ?? createWorkspaceLayoutWithExplorer()); +} + +export function resolveExplorerPaneId( + layout: WorkspaceLayout, + registeredPaneId: string | null | undefined, +): string | null { + const registeredPane = findPaneById(layout.root, registeredPaneId ?? null); + if (registeredPane) { + return registeredPane.id; + } + const defaultPane = findPaneById(layout.root, DEFAULT_EXPLORER_PANE_ID); + return defaultPane?.id ?? null; +} + +function ensurePersistedExplorerPane(input: { + layout: WorkspaceLayout; + registeredPaneId: string | null | undefined; + ids: WorkspaceLayoutIdSource; +}): { layout: WorkspaceLayout; paneId: string } | null { + const existingPaneId = resolveExplorerPaneId(input.layout, input.registeredPaneId); + if (existingPaneId) { + return { layout: input.layout, paneId: existingPaneId }; + } + const targetPaneId = + findPaneById(input.layout.root, input.layout.focusedPaneId)?.id ?? + collectAllPanes(input.layout.root)[0]?.id; + if (!targetPaneId) { + return null; + } + const split = splitPaneEmptyInLayout({ + layout: input.layout, + targetPaneId, + position: "right", + createNodeId: input.ids.createNodeId, + maxTreeDepth: MAX_TREE_DEPTH, + }); + if (!split) { + return null; + } + const hiddenLayout = setPaneHiddenInLayout({ + layout: split.layout, + paneId: split.paneId, + hidden: true, + }); + return { layout: hiddenLayout ?? split.layout, paneId: split.paneId }; } function withoutFocusRestoration( @@ -447,6 +501,71 @@ export function createWorkspaceLayoutStore( return result.tabId; }, + openTabInExplorerPaneBackground: (workspaceKey, target) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTarget = normalizeWorkspaceTabTarget(target); + if (!normalizedWorkspaceKey || !normalizedTarget) { + return null; + } + + let tabId: string | null = null; + set((state) => { + const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + const explorerPaneId = resolveExplorerPaneId( + layout, + state.explorerPaneIdByWorkspace[normalizedWorkspaceKey], + ); + const explorerPane = findPaneById(layout.root, explorerPaneId); + if (!explorerPane) { + return state; + } + + const originalFocusedPaneId = layout.focusedPaneId; + const revealedLayout = focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout; + const result = openTabInLayoutBackground({ + layout: revealedLayout, + target: normalizedTarget, + now: Date.now(), + }); + const currentPane = findPaneContainingTab(result.layout.root, result.tabId); + const placedLayout = + currentPane?.id === explorerPane.id + ? result.layout + : (moveTabToPaneInLayout({ + layout: result.layout, + tabId: result.tabId, + toPaneId: explorerPane.id, + }) ?? result.layout); + const restoredVisibility = explorerPane.hidden + ? (setPaneHiddenInLayout({ + layout: placedLayout, + paneId: explorerPane.id, + hidden: true, + }) ?? placedLayout) + : placedLayout; + const restoredLayout = + originalFocusedPaneId && originalFocusedPaneId !== explorerPane.id + ? (focusPaneInLayout({ + layout: restoredVisibility, + paneId: originalFocusedPaneId, + }) ?? restoredVisibility) + : restoredVisibility; + + tabId = result.tabId; + return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + explorerPaneIdByWorkspace: { + ...state.explorerPaneIdByWorkspace, + [normalizedWorkspaceKey]: explorerPane.id, + }, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: restoredLayout, + }, + }; + }); + return tabId; + }, ensureExplorerPane: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -455,9 +574,15 @@ export function createWorkspaceLayoutStore( const store = get(); const layout = getWorkspaceLayout(store.layoutByWorkspace, normalizedWorkspaceKey); - const explorerPaneId = store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null; + const explorerPaneId = resolveExplorerPaneId( + layout, + store.explorerPaneIdByWorkspace[normalizedWorkspaceKey], + ); const explorerPane = findPaneById(layout.root, explorerPaneId); if (explorerPane) { + if (store.explorerPaneIdByWorkspace[normalizedWorkspaceKey] !== explorerPane.id) { + store.setExplorerPaneId(normalizedWorkspaceKey, explorerPane.id); + } store.focusPane(normalizedWorkspaceKey, explorerPane.id); return { paneId: explorerPane.id, created: false }; } @@ -521,11 +646,16 @@ export function createWorkspaceLayoutStore( } const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); - const explorerPaneId = state.explorerPaneIdByWorkspace[normalizedWorkspaceKey] ?? null; + const explorerPaneId = resolveExplorerPaneId( + layout, + state.explorerPaneIdByWorkspace[normalizedWorkspaceKey], + ); const explorerPane = findPaneById(layout.root, explorerPaneId); - const placementLayout = explorerPane - ? (focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout) - : layout; + if (!explorerPane) { + return state; + } + const placementLayout = + focusPaneInLayout({ layout, paneId: explorerPane.id }) ?? layout; const result = openTabInLayoutFocused({ layout: placementLayout, target: { kind: "pull_request" }, @@ -538,6 +668,10 @@ export function createWorkspaceLayoutStore( ...state.acknowledgedPullRequestByWorkspace, [normalizedWorkspaceKey]: normalizedIdentity, }, + explorerPaneIdByWorkspace: { + ...state.explorerPaneIdByWorkspace, + [normalizedWorkspaceKey]: explorerPane.id, + }, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: result.layout, @@ -553,9 +687,20 @@ export function createWorkspaceLayoutStore( } set((state) => { + const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + const explorerPaneId = resolveExplorerPaneId( + layout, + state.explorerPaneIdByWorkspace[normalizedWorkspaceKey], + ); + const closingPane = findPaneContainingTab(layout.root, normalizedTabId); + const preserveEmptyPaneId = + closingPane?.id === "main" || closingPane?.id === explorerPaneId + ? closingPane.id + : null; const nextLayout = closeTabInLayout({ - layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), + layout, tabId: normalizedTabId, + preserveEmptyPaneId, }); if (!nextLayout) { return state; @@ -1252,11 +1397,30 @@ export function createWorkspaceLayoutStore( if (!result.success) { return currentState; } + const layoutByWorkspace: Record = {}; + const explorerPaneIdByWorkspace: Record = { + ...result.data.explorerPaneIdByWorkspace, + }; + for (const [workspaceKey, persistedLayout] of Object.entries( + result.data.layoutByWorkspace, + )) { + const explorer = ensurePersistedExplorerPane({ + layout: normalizeLayout(persistedLayout), + registeredPaneId: explorerPaneIdByWorkspace[workspaceKey], + ids, + }); + if (!explorer) { + layoutByWorkspace[workspaceKey] = normalizeLayout(persistedLayout); + continue; + } + layoutByWorkspace[workspaceKey] = explorer.layout; + explorerPaneIdByWorkspace[workspaceKey] = explorer.paneId; + } return { ...currentState, - layoutByWorkspace: result.data.layoutByWorkspace, + layoutByWorkspace, splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, - explorerPaneIdByWorkspace: result.data.explorerPaneIdByWorkspace ?? {}, + explorerPaneIdByWorkspace, acknowledgedPullRequestByWorkspace: result.data.acknowledgedPullRequestByWorkspace ?? {}, };