From 0e673c206ae868236503606c4e49e7fea02c0c0e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:42:23 +0800 Subject: [PATCH] fix(editor): keep the saved layout when a session restore meets open tabs (#2325) A session restore that lands after tabs were already opened (a file launched into LightEdit while the window was still restoring, or a file opened from the CLI into a project window during startup) appended every restored tab to the current pane: saved splits collapsed into one pane, restored pinned tabs landed after unpinned ones, a pane could hold several preview tabs, and the tab limit was skipped. The restore now starts from the saved state and reopens the live tabs into it through the normal tab rules (pin order, one preview per pane, tab limit without evicting the live tabs), keeping their slots and line/column targets; the launched tab ends up active and focused in the saved active pane. An empty window still takes the saved layout as is. Refs #2229 --- src/editor/core/controller.ts | 68 +++++---- src/editor/core/lightedit-launch.test.ts | 179 ++++++++++++++++++++++- src/editor/core/model/tabs.ts | 66 ++++++--- 3 files changed, 266 insertions(+), 47 deletions(-) diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 3e0ac510e..cc4eb9512 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -338,8 +338,8 @@ export class EditorController implements SlotHost { .catch((e) => this.documentError(e)) .then(() => { if (generation !== this.projectGeneration) return; - if (root) this.restoreSession(root); - else if (s.lightEdit) this.restoreSession(LIGHT_EDIT_SESSION); + if (root) return this.restoreSession(root); + else if (s.lightEdit) return this.restoreSession(LIGHT_EDIT_SESSION); }); } else if (previous.opening && !s.opening && this.closeCommitted) { // A failed project open returns to the same project. @@ -354,7 +354,7 @@ export class EditorController implements SlotHost { this.upgradeWrappedImageTabs(wrappers); }), ); - if (prevRoot) this.restoreSession(prevRoot); + if (prevRoot) this.projectTransition = this.restoreSession(prevRoot); if (this.manager.backend.onFsChanged) this.disposers.push(this.manager.backend.onFsChanged((e) => this.onFilesRemoved(e.removed))); const onWindowBlur = () => { @@ -520,7 +520,7 @@ export class EditorController implements SlotHost { } /** Reopens the tabs, splits and carets saved for `root` (after the project opened). */ - restoreSession(root: string) { + async restoreSession(root: string) { this.sessionRoot = root; const session = loadSession(root); if (!session) return; @@ -528,17 +528,29 @@ export class EditorController implements SlotHost { const kinds = useContributions.getState().editorKinds; for (const pane of Object.values(state.panes)) pane.tabs = pane.tabs.map((tab) => (documentLifecycle(tab.input, kinds) ? { ...tab, preview: false } : tab)); - this.restoredViews = new Map(Object.entries(session.files)); + const current = useTabs.getState(); + const liveKeys = openKeys(current); + this.restoredViews = new Map(Object.entries(session.files).filter(([path]) => !liveKeys.has(fileKey(path)))); this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id))); this.restoring = true; try { // A restore landing after tabs were already opened (e.g. a launched file // admitted while the transition was still in flight) must not wipe them. - useTabs.setState((current) => mergeSessionTabs(current, state), true); + useTabs.setState(mergeSessionTabs(current, state), true); } finally { this.restoring = false; } - for (const pane of Object.values(state.panes)) if (pane.active) void this.ensureSlot(pane.active); + if (liveKeys.size) + for (const paneId of paneIds(useTabs.getState())) await this.enforcePaneTabLimit(paneId, undefined, liveKeys); + const next = useTabs.getState(); + for (const pane of Object.values(next.panes)) if (pane.active) void this.ensureSlot(pane.active); + if (liveKeys.size) { + const active = this.activeTabOf(next); + if (active) { + await this.ensureSlot(active.id); + if (this.activeTabOf()?.id === active.id) this.focus(); + } + } } private activeTabOf(s: TabsState = useTabs.getState()): Tab | null { @@ -1131,24 +1143,7 @@ export class EditorController implements SlotHost { this.admitting = false; } const canFocus = () => this.isCurrentOpening(generation) && this.activeTabOf()?.id === tabId; - const tabPrefs = editorPrefs().tabs; - await this.removeGuarded( - (current) => - current.panes[paneId] - ? enforceTabLimit( - current, - paneId, - tabPrefs.tabLimit, - tabPrefs.closingPolicy, - (tab) => - inputDirty(tab.input, useContributions.getState().editorKinds) ?? - !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty), - activate ? tabId : (current.panes[paneId]?.active ?? tabId), - (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true, - ) - : current, - { protect: (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true }, - ); + await this.enforcePaneTabLimit(paneId, activate ? tabId : (useTabs.getState().panes[paneId]?.active ?? tabId)); if (!isTextInput(input)) { if (o.focus !== false) requestAnimationFrame(() => { @@ -1184,6 +1179,29 @@ export class EditorController implements SlotHost { } } + private enforcePaneTabLimit(paneId: string, keep?: string, liveKeys?: ReadonlySet) { + const prefs = editorPrefs().tabs; + const protect = (tab: Tab) => + !!liveKeys?.has(tab.input.key) || inputDirty(tab.input, useContributions.getState().editorKinds) === true; + return this.removeGuarded( + (current) => + current.panes[paneId] + ? enforceTabLimit( + current, + paneId, + prefs.tabLimit, + prefs.closingPolicy, + (tab) => + inputDirty(tab.input, useContributions.getState().editorKinds) ?? + !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty), + keep ?? current.panes[paneId]?.active ?? undefined, + protect, + ) + : current, + { protect }, + ); + } + activate(paneId: string, tabId: string, focus = true) { const restored = restoreMaximizedToolWindow(); ++this.activationGeneration; diff --git a/src/editor/core/lightedit-launch.test.ts b/src/editor/core/lightedit-launch.test.ts index d0af5ed79..c513cccf1 100644 --- a/src/editor/core/lightedit-launch.test.ts +++ b/src/editor/core/lightedit-launch.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { EditorController, LIGHT_EDIT_SESSION, openFilePaths, useTabs } from "./controller"; import { createMemoryBackend } from "./backend"; -import { initialTabsState } from "./model/tabs"; -import { storeSession } from "./model/session"; +import { initialTabsState, openTab, paneIds, togglePin } from "./model/tabs"; +import { restoreTabsState, storeSession, type EditorSession } from "./model/session"; import { useProject } from "@/platform/project"; let ctrl: EditorController | null = null; @@ -15,6 +15,7 @@ afterEach(() => { useProject.setState({ project: null, lightEdit: false, settings: initialSettings } as never); try { localStorage.removeItem(`openidea.editorSession.v1:${LIGHT_EDIT_SESSION}`); + storeSession("/p", null); } catch { /* ignore */ } @@ -55,9 +56,181 @@ describe("lightedit launch (#2229)", () => { useProject.setState({ project: null, lightEdit: true }); await ctrl.openFile("/p/b.ts", { focus: true }); // A stale/second transition restore landing after the admit (#2229). - ctrl.restoreSession(LIGHT_EDIT_SESSION); + await ctrl.restoreSession(LIGHT_EDIT_SESSION); await new Promise((r) => setTimeout(r, 50)); expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); expect(activePath()).toBe("/p/b.ts"); }); }); + +function savedSplit(dir: "row" | "column" = "row"): EditorSession { + return { + version: 1, + layout: { + kind: "split", + dir, + children: [ + { + kind: "pane", + tabs: [ + { path: "/p/a.ts", pinned: true }, + { path: "/p/shared.ts", preview: true }, + ], + active: 1, + }, + { kind: "pane", tabs: [{ path: "/p/c.ts", preview: true }, { path: "/p/shared.ts" }], active: 1 }, + ], + }, + activePane: 1, + files: {}, + }; +} + +function setupRestore() { + ctrl = new EditorController( + createMemoryBackend({ + "/p/a.ts": "a", + "/p/b.ts": "b\nsecond", + "/p/c.ts": "c", + "/p/shared.ts": "shared", + }), + ); + ctrl.start(); + return ctrl; +} + +describe.each([LIGHT_EDIT_SESSION, "/p"])("late session restore: %s", (root) => { + it.each(["row", "column"] as const)( + "keeps the saved %s split and intentional copies, with the CLI file active in the saved active pane", + async (dir) => { + const controller = setupRestore(); + await controller.openFile("/p/b.ts", { selection: { line: 1, col: 2 } }); + const live = controller.activeSlot!; + storeSession(root, savedSplit(dir)); + await controller.restoreSession(root); + const state = useTabs.getState(); + expect(state.layout).toMatchObject({ kind: "split", dir }); + expect(paneIds(state)).toHaveLength(2); + expect(state.activePane).toBe(paneIds(state)[1]); + expect( + Object.values(state.panes) + .flatMap((p) => p.tabs) + .filter((t) => t.input.path === "/p/shared.ts"), + ).toHaveLength(2); + expect(activePath()).toBe("/p/b.ts"); + expect(controller.activeSlot).toBe(live); + expect(live.caret()).toMatchObject({ line: 1, col: 2 }); + expect(live.wantsFocus).toBe(true); + }, + ); + + it("keeps pins before regular tabs, including after a subsequent pin and open", async () => { + const controller = setupRestore(); + await controller.openFile("/p/b.ts", { focus: false }); + storeSession(root, { + version: 1, + layout: { kind: "pane", tabs: [{ path: "/p/a.ts", pinned: true }], active: 0 }, + activePane: 0, + files: {}, + }); + await controller.restoreSession(root); + let state = useTabs.getState(); + expect(state.panes[state.activePane]!.tabs.map((t) => t.pinned)).toEqual([true, false]); + const pinned = togglePin(state, state.activePane, state.panes[state.activePane]!.active!); + state = openTab(pinned, pinned.activePane, { kind: "file", key: "file:/p/c.ts", path: "/p/c.ts" }).state; + expect(state.panes[state.activePane]!.tabs.map((t) => t.pinned)).toEqual([true, true, false]); + }); + + it("keeps at most one preview per pane when the live tab and both saved panes have previews", async () => { + const controller = setupRestore(); + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + storeSession(root, savedSplit()); + await controller.restoreSession(root); + for (const pane of Object.values(useTabs.getState().panes)) + expect(pane.tabs.filter((t) => t.preview).length).toBeLessThanOrEqual(1); + expect(openFilePaths(useTabs.getState())).toContain("/p/b.ts"); + }); + + it("enforces the tab limit without evicting the launched tab", async () => { + useProject.setState({ settings: { editor: { tabs: { tabLimit: 1, closingPolicy: "lru" } } } as never }); + const controller = setupRestore(); + await controller.openFile("/p/b.ts", { focus: false }); + storeSession(root, { + version: 1, + layout: { kind: "pane", tabs: [{ path: "/p/a.ts" }], active: 0 }, + activePane: 0, + files: {}, + }); + await controller.restoreSession(root); + expect(openFilePaths(useTabs.getState())).toEqual(["/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps the live same-key slot and launch target instead of the saved caret", async () => { + const controller = setupRestore(); + await controller.openFile("/p/b.ts", { selection: { line: 1, col: 2 } }); + const slot = controller.activeSlot!; + storeSession(root, { + version: 1, + layout: { kind: "pane", tabs: [{ path: "/p/b.ts", pinned: true }], active: 0 }, + activePane: 0, + files: { "/p/b.ts": { anchor: 0, head: 0 } }, + }); + await controller.restoreSession(root); + expect(openFilePaths(useTabs.getState())).toEqual(["/p/b.ts"]); + expect(controller.activeSlot).toBe(slot); + expect(slot.caret()).toMatchObject({ line: 1, col: 2 }); + expect(useTabs.getState().panes[useTabs.getState().activePane]!.tabs[0]!.pinned).toBe(true); + }); + + it("moves a saved same-key launch target into the saved active pane without losing the split", async () => { + const controller = setupRestore(); + await controller.openFile("/p/b.ts", { selection: { line: 1, col: 2 } }); + const slot = controller.activeSlot!; + const session = savedSplit(); + if (session.layout.kind !== "split") throw new Error("expected split"); + session.layout.children[0] = { kind: "pane", tabs: [{ path: "/p/b.ts", pinned: true }], active: 0 }; + storeSession(root, session); + await controller.restoreSession(root); + const state = useTabs.getState(); + expect(state.layout).toMatchObject({ kind: "split", dir: "row" }); + expect(state.activePane).toBe(paneIds(state)[1]); + expect( + Object.values(state.panes) + .flatMap((p) => p.tabs) + .filter((t) => t.input.path === "/p/b.ts"), + ).toHaveLength(1); + expect(controller.activeSlot).toBe(slot); + expect(slot.caret()).toMatchObject({ line: 1, col: 2 }); + expect(state.panes[state.activePane]!.tabs[0]!.pinned).toBe(true); + }); + + it("protects every live tab when they already exceed the configured limit", async () => { + const controller = setupRestore(); + await controller.openFile("/p/b.ts", { focus: false }); + await controller.openFile("/p/c.ts", { preview: true, focus: false }); + useProject.setState({ settings: { editor: { tabs: { tabLimit: 1 } } } as never }); + storeSession(root, { + version: 1, + layout: { kind: "pane", tabs: [{ path: "/p/a.ts" }], active: 0 }, + activePane: 0, + files: {}, + }); + await controller.restoreSession(root); + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/b.ts", "/p/c.ts"]); + expect(activePath()).toBe("/p/c.ts"); + }); + + it("takes the saved state unchanged when the window is empty", async () => { + const controller = setupRestore(); + storeSession(root, savedSplit()); + await controller.restoreSession(root); + const saved = restoreTabsState(savedSplit(), (p) => `file:${p}`); + const state = useTabs.getState(); + expect(paneIds(state)).toHaveLength(2); + expect(Object.values(state.panes).map((p) => p.tabs.map((t) => [t.input.path, t.pinned, t.preview]))).toEqual( + Object.values(saved.panes).map((p) => p.tabs.map((t) => [t.input.path, t.pinned, t.preview])), + ); + expect(activePath()).toBe("/p/shared.ts"); + }); +}); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index ae3099c85..d2e4cb289 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -93,27 +93,55 @@ export function openKeys(s: TabsState): Set { return keys; } -/** - * Folds restored tabs into the current tabs without losing anything open: tabs - * already open stay (with their active tab), restored tabs missing by key are - * appended to the active pane. An empty window takes the restored state as-is, - * keeping its saved layout and active tab. - */ +/** Replays live tabs into the saved layout, retaining their slots and the launch target. */ export function mergeSessionTabs(current: TabsState, restored: TabsState): TabsState { - const target = current.panes[current.activePane]; - if (!target || !Object.values(current.panes).some((p) => p.tabs.length)) return restored; - const seen = openKeys(current); - const additions: Tab[] = []; - for (const pane of Object.values(restored.panes)) - for (const tab of pane.tabs) { - if (seen.has(tab.input.key)) continue; - seen.add(tab.input.key); - additions.push(tab); + const live = new Map(); + for (const pane of Object.values(current.panes)) for (const tab of pane.tabs) live.set(tab.input.key, tab); + if (!live.size) return restored; + const active = current.panes[current.activePane]?.tabs.find( + (t) => t.id === current.panes[current.activePane]?.active, + ); + if (active) live.set(active.input.key, active); + let next = restored; + for (const tab of live.values()) { + let found = findTab(next, tab.input.key, restored.activePane); + let opening = tab; + // Put the launch target in the saved active pane without collapsing its saved source pane. + if (tab === active && found && found.paneId !== restored.activePane) { + const source = paneOf(next, found.paneId); + const savedId = found.tab.id; + const tabs = source.tabs.filter((t) => t.id !== savedId); + const mru = source.mru.filter((id) => id !== savedId); + next = withPane(next, { + ...source, + tabs, + mru, + active: source.active === savedId ? (mru[0] ?? tabs[0]?.id ?? null) : source.active, + }); + opening = { ...found.tab, input: tab.input }; + found = null; } - if (!additions.length) return current; - const tabs = [...target.tabs, ...additions]; - const mru = [...target.mru, ...additions.map((t) => t.id).filter((id) => !target.mru.includes(id))]; - return withPane(current, { ...target, tabs, mru }); + if (!found) { + const pane = paneOf(next, restored.activePane); + const opened = openTab(next, pane.id, tab.input, { + pinned: opening.pinned, + preview: opening.preview && !pane.tabs.some((t) => t.preview), + preservePreview: true, + activate: false, + }); + next = opened.state; + found = { paneId: pane.id, tab: paneOf(next, pane.id).tabs.find((t) => t.id === opened.tabId)! }; + } + const pane = paneOf(next, found.paneId); + const savedId = found.tab.id; + next = withPane(next, { + ...pane, + tabs: pane.tabs.map((t) => (t.id === savedId ? { ...t, id: tab.id, input: tab.input } : t)), + active: pane.active === savedId ? tab.id : pane.active, + mru: pane.mru.map((id) => (id === savedId ? tab.id : id)), + }); + } + return active ? activateTab(next, restored.activePane, active.id) : next; } function withPane(s: TabsState, pane: Pane): TabsState {