fix(app): migrate persisted Explorer layouts once (#3861)

The first-class Explorer reused the old Side panel persistence identity, so upgrades hydrated ordinary persisted tabs into the dock. Move those tabs into an ordinary side pane during the versioned migration while preserving main layouts, and keep the persisted key spelling stable at the storage boundary.
This commit is contained in:
Mohamed Boudra
2026-08-25 20:50:26 +02:00
committed by GitHub
parent f73f65cd78
commit c4dc701411
2 changed files with 416 additions and 86 deletions
@@ -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<string, WorkspaceTab["target"]>;
stateByTabId?: Record<string, WorkspaceTab["state"]>;
}): 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<string, { root: SplitNode }>;
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
sidePaneIdByWorkspace: Record<string, string | null>;
};
},
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,
});
+181 -83
View File
@@ -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<string, number>,
@@ -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<TPane extends SplitPane>(pane: TPane): Omit<TPane, "hidden"> {
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<typeof WorkspaceLayoutPersistedStateSchema> {
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
if (!result.success || version >= WORKSPACE_LAYOUT_PERSIST_VERSION) {
return result.success ? result.data : { layoutByWorkspace: {} };
}
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
const explorerPaneIdByWorkspace: Record<string, string | null> = {};
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<string, WorkspaceLayout> = {};
for (const key in state.layoutByWorkspace) {
@@ -1748,14 +1846,14 @@ export function createWorkspaceLayoutStore(
}
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
const explorerSidebarPaneIdByWorkspace: Record<string, string | null> = {
...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,