diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index cd913ba58..3e0ac510e 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -56,6 +56,7 @@ import { enforceTabLimit, findTab, initialTabsState, + mergeSessionTabs, moveTab, moveTabToNewSplit, neighbourPane, @@ -531,7 +532,9 @@ export class EditorController implements SlotHost { this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id))); this.restoring = true; try { - useTabs.setState(state, true); + // 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); } finally { this.restoring = false; } diff --git a/src/editor/core/lightedit-launch.test.ts b/src/editor/core/lightedit-launch.test.ts new file mode 100644 index 000000000..d0af5ed79 --- /dev/null +++ b/src/editor/core/lightedit-launch.test.ts @@ -0,0 +1,63 @@ +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 { useProject } from "@/platform/project"; + +let ctrl: EditorController | null = null; +const initialSettings = useProject.getState().settings; + +afterEach(() => { + ctrl?.stop(); + ctrl = null; + useTabs.setState(initialTabsState(), true); + useProject.setState({ project: null, lightEdit: false, settings: initialSettings } as never); + try { + localStorage.removeItem(`openidea.editorSession.v1:${LIGHT_EDIT_SESSION}`); + } catch { + /* ignore */ + } +}); + +function activePath() { + const state = useTabs.getState(); + const pane = state.panes[state.activePane]!; + return pane.tabs.find((tab) => tab.id === pane.active)?.input.path; +} + +function seedLightEditSession() { + storeSession(LIGHT_EDIT_SESSION, { + version: 1, + layout: { kind: "pane", tabs: [{ path: "/p/a.ts" }], active: 0 }, + activePane: 0, + files: {}, + }); +} + +describe("lightedit launch (#2229)", () => { + it("admits the launched file after the saved session restores", async () => { + seedLightEditSession(); + ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "a", "/p/b.ts": "b" })); + ctrl.start(); + // openFilesHere: window has neither project nor LightEdit -> openLightEdit(b) then openInEditor([b]). + useProject.setState({ project: null, lightEdit: true }); + await ctrl.openFile("/p/b.ts", { focus: true }); + 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"); + }); + + it("merges a late saved-session restore with the launched tab instead of replacing it", async () => { + seedLightEditSession(); + ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "a", "/p/b.ts": "b" })); + ctrl.start(); + 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 new Promise((r) => setTimeout(r, 50)); + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + }); +}); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index b582a9285..ae3099c85 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -93,6 +93,29 @@ 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. + */ +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); + } + 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 }); +} + function withPane(s: TabsState, pane: Pane): TabsState { return { ...s, panes: { ...s.panes, [pane.id]: pane } }; }