diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 17d07f5eca..74336a3202 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -77,11 +77,16 @@ function useWorkspaceLayoutIds(...values: string[]) { workspaceLayoutIds.useValues(values); } -function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab { +function createTab( + tabId: string, + target?: WorkspaceTab["target"], + state?: WorkspaceTab["state"], +): WorkspaceTab { return { tabId, target: target ?? { kind: "draft", draftId: tabId }, createdAt: 1, + ...(state === undefined ? {} : { state }), }; } @@ -91,8 +96,11 @@ function createPane(input: { focusedTabId?: string | null; hidden?: boolean; targetsByTabId?: Record; + stateByTabId?: Record; }): SplitNode { - const tabs = input.tabIds.map((tabId) => createTab(tabId, input.targetsByTabId?.[tabId])); + const tabs = input.tabIds.map((tabId) => + createTab(tabId, input.targetsByTabId?.[tabId], input.stateByTabId?.[tabId]), + ); return { kind: "pane", pane: { @@ -276,6 +284,231 @@ describe("workspace-layout-store helpers", () => { }); }); +describe("workspace-layout-store version 2 migration", () => { + const workspaceKey = "workspace"; + const preservedFileState = { treeVisible: true, treeWidth: 333 }; + + function createVersionOneLayout(includeExplorerDefaults: boolean) { + const explorerTabIds = [ + ...(includeExplorerDefaults ? ["files", "changes_tree"] : []), + "legacy-file", + "legacy-agent", + ]; + return { + root: { + kind: "group" as const, + group: { + id: "workspace-root", + direction: "horizontal" as const, + sizes: [0.61, 0.39], + children: [ + createPane({ + id: "main", + tabIds: ["main-draft"], + targetsByTabId: { + "main-draft": { kind: "draft", draftId: "main-draft" }, + }, + }), + createPane({ + id: "explorer", + tabIds: explorerTabIds, + focusedTabId: "legacy-file", + hidden: true, + targetsByTabId: { + files: { kind: "files" }, + changes_tree: { kind: "changes_tree" }, + "legacy-file": { kind: "file", path: "/repo/preserved.ts" }, + "legacy-agent": { kind: "agent", agentId: "preserved-agent" }, + }, + stateByTabId: { "legacy-file": preservedFileState }, + }), + ], + }, + }, + focusedPaneId: "main", + }; + } + + async function persistVersionOneLayout( + source: "v0.5" | "v0.6", + options: { withSidePane?: boolean } = {}, + ) { + const layout = createVersionOneLayout(source === "v0.6"); + const persistedLayout = options.withSidePane + ? { + ...layout, + root: { + kind: "group" as const, + group: { + id: "side-root", + direction: "horizontal" as const, + sizes: [0.7, 0.3], + children: [ + layout.root, + createPane({ + id: "existing-side", + tabIds: ["existing-side-file"], + targetsByTabId: { + "existing-side-file": { kind: "file", path: "/repo/already-beside.ts" }, + }, + }), + ], + }, + }, + } + : layout; + await AsyncStorage.removeItem("workspace-layout-state"); + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + state: { + layoutByWorkspace: { + [workspaceKey]: persistedLayout, + }, + splitSizesByWorkspace: {}, + explorerPaneIdByWorkspace: { [workspaceKey]: "explorer" }, + ...(source === "v0.6" + ? { + explorerSidebarWidthByWorkspace: {}, + sidePaneIdByWorkspace: options.withSidePane + ? { [workspaceKey]: "existing-side" } + : {}, + } + : {}), + }, + version: 1, + }), + ); + } + + function expectMigratedLayout( + store: { + getState: () => { + layoutByWorkspace: Record; + explorerSidebarPaneIdByWorkspace: Record; + sidePaneIdByWorkspace: Record; + }; + }, + expected: { + sidePaneId?: string; + sideTabIds?: string[]; + } = {}, + ) { + const state = store.getState(); + const layout = state.layoutByWorkspace[workspaceKey]; + const explorerPaneId = state.explorerSidebarPaneIdByWorkspace[workspaceKey]; + const sidePaneId = state.sidePaneIdByWorkspace[workspaceKey]; + expect(explorerPaneId).toBe("explorer"); + expect(sidePaneId).toBeTruthy(); + expect(sidePaneId).not.toBe(explorerPaneId); + + const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab])); + const explorerPane = findPaneById(layout.root, explorerPaneId); + expect(explorerPane?.tabIds.map((tabId) => tabsById.get(tabId)?.target)).toEqual([ + { kind: "files" }, + { kind: "changes_tree" }, + ]); + + const sidePane = findPaneById(layout.root, sidePaneId); + expect(sidePane?.hidden).not.toBe(true); + expect(sidePane?.tabIds).toEqual(expected.sideTabIds ?? ["legacy-file", "legacy-agent"]); + expect(tabsById.get("legacy-file")).toMatchObject({ + target: { kind: "file", path: "/repo/preserved.ts" }, + state: preservedFileState, + }); + expect(sidePaneId).toBe(expected.sidePaneId ?? sidePaneId); + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main", sidePaneId]); + } + + it.each(["v0.5", "v0.6"] as const)( + "replaces Explorer once and preserves persisted side content when upgrading from %s", + async (source) => { + await persistVersionOneLayout(source); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + + await restored.persist.rehydrate(); + + expectMigratedLayout(restored); + await vi.waitFor(async () => { + const persisted = JSON.parse( + (await AsyncStorage.getItem("workspace-layout-state")) ?? "{}", + ); + expect(persisted.version).toBe(2); + }); + }, + ); + + it("reuses an ordinary side pane already created by v0.6", async () => { + await persistVersionOneLayout("v0.6", { withSidePane: true }); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + + await restored.persist.rehydrate(); + + expectMigratedLayout(restored, { + sidePaneId: "existing-side", + sideTabIds: ["existing-side-file", "legacy-file", "legacy-agent"], + }); + }); + + it("writes version 2 with the persisted Explorer key accepted by released v0.6", async () => { + await persistVersionOneLayout("v0.6"); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + + await restored.persist.rehydrate(); + + await vi.waitFor(async () => { + const persisted = JSON.parse((await AsyncStorage.getItem("workspace-layout-state")) ?? "{}"); + expect(persisted.version).toBe(2); + expect(Object.keys(persisted.state).sort()).toEqual([ + "explorerPaneIdByWorkspace", + "explorerSidebarWidthByWorkspace", + "layoutByWorkspace", + "sidePaneIdByWorkspace", + "splitSizesByWorkspace", + ]); + expect(persisted.state.explorerPaneIdByWorkspace).toEqual({ + [workspaceKey]: "explorer", + }); + }); + }); + + it("does not replace Explorer again when the migrated version 2 layout reloads", async () => { + await persistVersionOneLayout("v0.6"); + const migrated = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await migrated.persist.rehydrate(); + expectMigratedLayout(migrated); + const explorerPaneId = migrated.getState().explorerSidebarPaneIdByWorkspace[workspaceKey]; + if (!explorerPaneId) { + throw new Error("Migrated Explorer pane is missing"); + } + const addedTabId = migrated.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "added-after-migration" }, + intent: "new", + placement: { mode: "pane", paneId: explorerPaneId }, + }); + expect(addedTabId).toBeTruthy(); + + await vi.waitFor(async () => { + const persisted = JSON.parse((await AsyncStorage.getItem("workspace-layout-state")) ?? "{}"); + expect(persisted.version).toBe(2); + }); + const reloaded = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await reloaded.persist.rehydrate(); + + const reloadedState = reloaded.getState(); + const reloadedLayout = reloadedState.layoutByWorkspace[workspaceKey]; + const reloadedExplorer = findPaneById( + reloadedLayout.root, + reloadedState.explorerSidebarPaneIdByWorkspace[workspaceKey], + ); + expect(reloadedExplorer?.tabIds).toContain(addedTabId); + expect(findPaneContainingTab(reloadedLayout.root, addedTabId as string)?.id).toBe( + reloadedExplorer?.id, + ); + }); +}); + describe("workspace-layout-store tree transforms", () => { beforeEach(() => { workspaceLayoutIds.reset(); @@ -2712,7 +2945,6 @@ describe("workspace-layout-store actions", () => { layoutByWorkspace: { [workspaceKey]: layout }, splitSizesByWorkspace: currentState.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace, - // The persisted key keeps its pre-rename spelling for older clients. explorerPaneIdByWorkspace: {}, sidePaneIdByWorkspace: currentState.sidePaneIdByWorkspace, }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 4ef564f328..8076dd019f 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -279,6 +279,7 @@ const WorkspaceLayoutPersistedStateSchema = z.strictObject({ // The persisted keys keep their pre-rename spelling: the schema is strict, so a // rename here would fail every existing blob and wipe the layout it describes. explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), + explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), // COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted // and ignored so upgrading does not discard the layout. Remove after 2027-08-20. @@ -286,6 +287,7 @@ const WorkspaceLayoutPersistedStateSchema = z.strictObject({ }); const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440; +const WORKSPACE_LAYOUT_PERSIST_VERSION = 2; function convertLegacyExplorerSidebarRatios( ratiosByWorkspace: Record, @@ -298,6 +300,178 @@ function convertLegacyExplorerSidebarRatios( ); } +function replacePaneInNode(node: SplitNode, paneId: string, replacement: SplitPane): SplitNode { + if (node.kind === "pane") { + return node.pane.id === paneId ? { kind: "pane", pane: replacement } : node; + } + return { + kind: "group", + group: { + ...node.group, + children: node.group.children.map((child) => replacePaneInNode(child, paneId, replacement)), + }, + }; +} + +function visiblePane(pane: TPane): Omit { + const { hidden: _hidden, ...visible } = pane; + return visible; +} + +function createExplorerSidebarNode(): SplitNode { + const layout = createWorkspaceLayoutWithExplorerSidebar(); + const pane = findPaneById(layout.root, EXPLORER_SIDEBAR_PANE_ID); + if (!pane) { + throw new Error("Default Explorer pane is missing"); + } + return { kind: "pane", pane }; +} + +function preserveVersionOneSideTabs(input: { + layout: WorkspaceLayout; + legacyExplorerPane: SplitPane; + rememberedSidePane: SplitPane | null; + tabs: WorkspaceTab[]; + ids: WorkspaceLayoutIdSource; +}): { root: SplitNode; sidePaneId: string | null } { + const rootWithoutExplorer = removePaneFromTree(input.layout.root, input.legacyExplorerPane.id); + if (input.tabs.length === 0) { + return { + root: findPaneById(rootWithoutExplorer, input.legacyExplorerPane.id) + ? createDefaultLayout().root + : rootWithoutExplorer, + sidePaneId: input.rememberedSidePane?.id ?? null, + }; + } + + if (input.rememberedSidePane) { + const rememberedTabs = collectAllTabs(input.layout.root).filter((tab) => + input.rememberedSidePane?.tabIds.includes(tab.tabId), + ); + const nextSidePane = visiblePane({ + ...input.rememberedSidePane, + tabIds: [...input.rememberedSidePane.tabIds, ...input.tabs.map((tab) => tab.tabId)], + tabs: [...rememberedTabs, ...input.tabs], + focusedTabId: input.rememberedSidePane.focusedTabId ?? input.tabs[0]?.tabId ?? null, + }); + return { + root: replacePaneInNode(rootWithoutExplorer, input.rememberedSidePane.id, nextSidePane), + sidePaneId: input.rememberedSidePane.id, + }; + } + + const sidePaneId = input.ids.createNodeId("pane"); + const sidePane = visiblePane({ + ...input.legacyExplorerPane, + id: sidePaneId, + tabIds: input.tabs.map((tab) => tab.tabId), + tabs: input.tabs, + focusedTabId: + input.tabs.find((tab) => tab.tabId === input.legacyExplorerPane.focusedTabId)?.tabId ?? + input.tabs[0]?.tabId ?? + null, + }); + return { + root: replacePaneInNode(input.layout.root, input.legacyExplorerPane.id, sidePane), + sidePaneId, + }; +} + +function migrateVersionOneWorkspaceLayout(input: { + layout: WorkspaceLayout; + legacyExplorerPaneId: string | null | undefined; + rememberedSidePaneId: string | null | undefined; + ids: WorkspaceLayoutIdSource; +}): { layout: WorkspaceLayout; explorerPaneId: string; sidePaneId: string | null } { + const strippedLayout = stripEphemeralTabsFromLayout(input.layout); + const legacyExplorerPaneId = resolveExplorerSidebarPaneId( + strippedLayout, + input.legacyExplorerPaneId, + ); + const legacyExplorerPane = findPaneById(strippedLayout.root, legacyExplorerPaneId); + if (!legacyExplorerPane) { + const ensured = ensurePersistedExplorerSidebarPane({ + layout: strippedLayout, + registeredPaneId: null, + ids: input.ids, + }); + return { + layout: ensured?.layout ?? strippedLayout, + explorerPaneId: ensured?.paneId ?? EXPLORER_SIDEBAR_PANE_ID, + sidePaneId: input.rememberedSidePaneId ?? null, + }; + } + + const preservedTabs = collectAllTabs(strippedLayout.root).filter( + (tab) => + legacyExplorerPane.tabIds.includes(tab.tabId) && + tab.target.kind !== "files" && + tab.target.kind !== "changes_tree", + ); + const preservedSide = preserveVersionOneSideTabs({ + layout: strippedLayout, + legacyExplorerPane, + rememberedSidePane: findPaneById(strippedLayout.root, input.rememberedSidePaneId ?? null), + tabs: preservedTabs, + ids: input.ids, + }); + const explorerNode = createExplorerSidebarNode(); + const focusedPaneId = + strippedLayout.focusedPaneId === legacyExplorerPane.id + ? (preservedSide.sidePaneId ?? collectAllPanes(preservedSide.root)[0]?.id ?? DEFAULT_PANE_ID) + : strippedLayout.focusedPaneId; + return { + layout: normalizeLayout({ + root: { + kind: "group", + group: { + id: input.ids.createNodeId("group"), + direction: "horizontal", + children: [preservedSide.root, explorerNode], + sizes: [0.78, 0.22], + }, + }, + focusedPaneId, + parentTabIdByTabId: strippedLayout.parentTabIdByTabId, + }), + explorerPaneId: EXPLORER_SIDEBAR_PANE_ID, + sidePaneId: preservedSide.sidePaneId, + }; +} + +function migrateWorkspaceLayoutPersistedState( + persistedState: unknown, + version: number, + ids: WorkspaceLayoutIdSource, +): z.infer { + const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState); + if (!result.success || version >= WORKSPACE_LAYOUT_PERSIST_VERSION) { + return result.success ? result.data : { layoutByWorkspace: {} }; + } + + const layoutByWorkspace: Record = {}; + const explorerPaneIdByWorkspace: Record = {}; + const sidePaneIdByWorkspace = { ...result.data.sidePaneIdByWorkspace }; + for (const [workspaceKey, layout] of Object.entries(result.data.layoutByWorkspace)) { + const migrated = migrateVersionOneWorkspaceLayout({ + layout, + legacyExplorerPaneId: result.data.explorerPaneIdByWorkspace?.[workspaceKey], + rememberedSidePaneId: result.data.sidePaneIdByWorkspace?.[workspaceKey], + ids, + }); + layoutByWorkspace[workspaceKey] = migrated.layout; + explorerPaneIdByWorkspace[workspaceKey] = migrated.explorerPaneId; + sidePaneIdByWorkspace[workspaceKey] = migrated.sidePaneId; + } + + return { + ...result.data, + layoutByWorkspace, + explorerPaneIdByWorkspace, + sidePaneIdByWorkspace, + }; +} + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -424,21 +598,15 @@ export function resolveExplorerSidebarPaneId( } function ensurePersistedExplorerSidebarPane(input: { - workspaceKey: string; layout: WorkspaceLayout; registeredPaneId: string | null | undefined; ids: WorkspaceLayoutIdSource; }): { layout: WorkspaceLayout; paneId: string } | null { const existingPaneId = resolveExplorerSidebarPaneId(input.layout, input.registeredPaneId); if (existingPaneId) { - const migratedLayout = migrateExplorerSidebarTabs( - input.workspaceKey, - input.layout, - existingPaneId, - ); return { layout: keepWorkspaceFocusOutOfExplorerSidebar( - migratedLayout, + input.layout, existingPaneId, input.layout.focusedPaneId, ), @@ -471,83 +639,11 @@ function ensurePersistedExplorerSidebarPane(input: { split.paneId, ); return { - layout: migrateExplorerSidebarTabs(input.workspaceKey, seededLayout, split.paneId), + layout: seededLayout, paneId: split.paneId, }; } -/** Keeps hydration compatible while enforcing the Explorer's navigation-only contract. */ -function migrateExplorerSidebarTabs( - workspaceKey: string, - layout: WorkspaceLayout, - paneId: string, -): WorkspaceLayout { - const changesMigrated = migrateLegacyExplorerSidebarChanges(layout, paneId); - const pane = findPaneById(changesMigrated.root, paneId); - if (!pane) return changesMigrated; - const targetPane = - findPaneById(changesMigrated.root, DEFAULT_PANE_ID) ?? - collectAllPanes(changesMigrated.root).find((candidate) => candidate.id !== paneId); - if (!targetPane) return changesMigrated; - const tabsById = new Map(collectAllTabs(changesMigrated.root).map((tab) => [tab.tabId, tab])); - let nextLayout = changesMigrated; - for (const tabId of pane.tabIds) { - const tab = tabsById.get(tabId); - if ( - !tab || - tab.target.kind === "new_tab" || - panelTargetSupportsHostForWorkspaceKey(workspaceKey, tab.target, "explorer") - ) { - continue; - } - nextLayout = - moveTabToPaneInLayout({ layout: nextLayout, tabId, toPaneId: targetPane.id }) ?? nextLayout; - } - - return nextLayout; -} - -function migrateLegacyExplorerSidebarChanges( - layout: WorkspaceLayout, - paneId: string, -): WorkspaceLayout { - const pane = findPaneById(layout.root, paneId); - if (!pane) return layout; - - const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab])); - const paneTabs = pane.tabIds.flatMap((tabId) => { - const tab = tabsById.get(tabId); - return tab ? [tab] : []; - }); - const legacyTabs = paneTabs.filter((tab) => tab.target.kind === "working_diff"); - if (legacyTabs.length === 0) return layout; - - let nextLayout = layout; - let hasChangesTree = paneTabs.some((tab) => tab.target.kind === "changes_tree"); - for (const legacyTab of legacyTabs) { - if (hasChangesTree) { - nextLayout = - closeTabInLayout({ - layout: nextLayout, - tabId: legacyTab.tabId, - preserveEmptyPaneId: paneId, - }) ?? nextLayout; - continue; - } - const replacement = replaceTabTargetInLayout({ - layout: nextLayout, - tabId: legacyTab.tabId, - target: { kind: "changes_tree" }, - createTabId: createWorkspaceTabInstanceId, - }); - if (replacement) { - nextLayout = replacement.layout; - hasChangesTree = true; - } - } - return nextLayout; -} - function getOpenTabPlacement( state: WorkspaceLayoutStore, workspaceKey: string, @@ -1722,8 +1818,10 @@ export function createWorkspaceLayoutStore( }), { name: "workspace-layout-state", - version: 1, + version: WORKSPACE_LAYOUT_PERSIST_VERSION, storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema), + migrate: (persistedState, version) => + migrateWorkspaceLayoutPersistedState(persistedState, version, ids), partialize: (state) => { const layoutByWorkspace: Record = {}; for (const key in state.layoutByWorkspace) { @@ -1748,14 +1846,14 @@ export function createWorkspaceLayoutStore( } const layoutByWorkspace: Record = {}; const explorerSidebarPaneIdByWorkspace: Record = { - ...result.data.explorerPaneIdByWorkspace, + ...(result.data.explorerSidebarPaneIdByWorkspace ?? + result.data.explorerPaneIdByWorkspace), }; for (const [workspaceKey, persistedLayout] of Object.entries( result.data.layoutByWorkspace, )) { const strippedLayout = stripEphemeralTabsFromLayout(persistedLayout); const explorerSidebar = ensurePersistedExplorerSidebarPane({ - workspaceKey, layout: strippedLayout, registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey], ids,