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 {