diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index d9c76bda563..809b62631fe 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -68,7 +68,8 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void { () => store.getWorkspaceSession(hostId), hostId, stage, - 'shutdown-checkpoint' + 'shutdown-checkpoint', + state.terminalLayoutsByTabId ) } else { stage() diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 89f7c25b165..a9e83b28106 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -33,13 +33,19 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic // Switch point: main keeps its own pane trees over the window's, and reports any layout the // save still changes. - const saveFromWindow = (hostId: string | null | undefined, write: () => void, source: string) => + const saveFromWindow = ( + hostId: string | null | undefined, + write: () => void, + source: string, + save: WorkspaceSessionState | WorkspaceSessionPatch + ) => ORCA_LAYOUT_OWNER_IS_MAIN ? writeWindowSaveWatchingLayout( () => store.getWorkspaceSession(hostId), hostId, write, - source + source, + save.terminalLayoutsByTabId ) : write() const forMain = ( @@ -55,7 +61,8 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic saveFromWindow( hostId, () => store.setWorkspaceSession(forMain(args, hostId), hostId), - 'session:set' + 'session:set', + args ) } }) @@ -65,7 +72,8 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic saveFromWindow( hostId, () => store.patchWorkspaceSession(forMain(args, hostId), hostId), - 'session:patch' + 'session:patch', + args ) } }) diff --git a/src/main/persistence/window-save-divergence.test.ts b/src/main/persistence/window-save-divergence.test.ts index 50116dce4dc..5b8d58b7fa4 100644 --- a/src/main/persistence/window-save-divergence.test.ts +++ b/src/main/persistence/window-save-divergence.test.ts @@ -10,6 +10,7 @@ import { leaf } from '../../shared/workspace-layout/workspace-layout-session.test-fixture' import { writeWindowSaveWatchingLayout } from './window-save-divergence' +import { windowSaveForMain } from './window-save-pane-trees' function session(tabs: { id: string; title?: string }[]): WorkspaceSessionState { return addWorkspace(emptySession(), GIT_KEY, [ @@ -185,16 +186,16 @@ describe('writeWindowSaveWatchingLayout', () => { ) }) - it('reports a default-tab record of a workspace with no tabs, and a terminal row owner', () => { + it('reports a default-tab record of a workspace with no tabs', () => { const stored = emptySession() const next = { ...emptySession(), - defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/Users/dev/notes': true as const }, - tabsByWorktree: { 'repo-1::/Users/dev/empty': [] } + defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/Users/dev/notes': true as const } } save(stored, next) + expect(warn).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith( '[layout-owner] window save changed main layout:', 'session:set', @@ -203,16 +204,209 @@ describe('writeWindowSaveWatchingLayout', () => { paths: ['defaultTabsAppliedByWorkspace changed'] }) ) + }) + + it('stays quiet for a save that only names a workspace with nothing in it', () => { + const key = 'repo-1::/Users/dev/empty' + const next = { ...emptySession(), unifiedTabs: { [key]: [] }, tabGroups: { [key]: [] } } + + save(emptySession(), next) + save(next, emptySession()) + + expect(warn).not.toHaveBeenCalled() + }) + + it('reports an empty terminal row a save adds or drops: a relaunch reads it there', () => { + const key = 'repo-1::/Users/dev/emptied' + const emptied = { ...emptySession(), tabsByWorktree: { [key]: [] } } + + save(emptySession(), emptied) + save(emptied, emptySession()) + + expect(warn).toHaveBeenCalledTimes(2) + expect(warn).toHaveBeenCalledWith( + '[layout-owner] window save changed main layout:', + 'session:set', + expect.objectContaining({ workspace: key, paths: ['terminal row owner changed'] }) + ) + }) + + it('reports a closed tab record only when the save adds or drops it, not when a key places it', () => { + const key = 'repo-1::/Users/dev/empty' + const closed = { + closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } } + } + const stored = { ...emptySession(), ...closed } + const named = { ...stored, unifiedTabs: { [key]: [] } } + + save(stored, named) + expect(warn).not.toHaveBeenCalled() + + save(named, { ...named, closedTerminalTabTombstonesByTabId: {} }) + save({ ...emptySession(), unifiedTabs: {} }, named) + for (const path of ['closed tab tab-x removed', 'closed tab tab-x added']) { + expect(warn).toHaveBeenCalledWith( + '[layout-owner] window save changed main layout:', + 'session:set', + expect.objectContaining({ workspace: key, paths: [path] }) + ) + } + expect(warn).toHaveBeenCalledTimes(2) + }) + + it('reports a closed tab record the save moves into a workspace only it names', () => { + const from = 'repo-1::/Users/dev/old' + const to = 'repo-1::/Users/dev/new' + const closedIn = (key: string) => ({ + ...emptySession(), + closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } } + }) + + save(closedIn(from), { ...closedIn(to), unifiedTabs: { [to]: [] } }) + + expect(warn).toHaveBeenCalledWith( + '[layout-owner] window save changed main layout:', + 'session:set', + expect.objectContaining({ workspace: to, paths: ['closed tab tab-x added'] }) + ) + }) + + it('still reports a workspace with tabs that the save adds or drops', () => { + save(emptySession(), session([{ id: 'tab-a' }])) + save(session([{ id: 'tab-a' }]), emptySession()) + + for (const path of ['workspace added', 'workspace removed']) { + expect(warn).toHaveBeenCalledWith( + '[layout-owner] window save changed main layout:', + 'session:set', + expect.objectContaining({ workspace: GIT_KEY, paths: expect.arrayContaining([path]) }) + ) + } + }) + + it('reports a save that drops a terminal tab’s tab-bar entry but keeps its row', () => { + const stored = session([{ id: 'tab-a' }, { id: 'tab-b' }]) + const next = structuredClone(stored) + next.unifiedTabs![GIT_KEY] = next.unifiedTabs![GIT_KEY]!.filter((tab) => tab.id !== 'tab-b') + + save(stored, next) + expect(warn).toHaveBeenCalledWith( '[layout-owner] window save changed main layout:', 'session:set', expect.objectContaining({ - workspace: 'repo-1::/Users/dev/empty', - paths: expect.arrayContaining(['terminal row owner changed']) + workspace: GIT_KEY, + paths: expect.arrayContaining(['terminal tab-b tab-bar entry removed']) }) ) }) + it('stays quiet for a save that carries no tab bar for the workspace yet', () => { + const stored = session([{ id: 'tab-a' }]) + const next = structuredClone(stored) + delete next.unifiedTabs![GIT_KEY] + + save(stored, next) + + expect(warn).not.toHaveBeenCalled() + }) + + it('reports a pane tree main keeps over the save’s, and still writes the save', () => { + const stored = session([{ id: 'tab-a' }]) + const incoming = structuredClone(stored) + incoming.terminalLayoutsByTabId['tab-a'] = { + ...incoming.terminalLayoutsByTabId['tab-a']!, + root: { + type: 'split', + direction: 'vertical', + ratio: 0.5, + first: { type: 'leaf', leafId: leaf(1) }, + second: { type: 'leaf', leafId: leaf(2) } + } + } + let current = stored + writeWindowSaveWatchingLayout( + () => current, + 'local', + () => { + current = windowSaveForMain(current, incoming, 'local') + }, + 'session:set', + incoming.terminalLayoutsByTabId + ) + + expect(current.terminalLayoutsByTabId['tab-a']?.root).toEqual( + stored.terminalLayoutsByTabId['tab-a']?.root + ) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith( + '[layout-owner] window save changed main layout:', + 'session:set', + expect.objectContaining({ + workspace: GIT_KEY, + paths: ['pane tree tab-a kept over the save’s'] + }) + ) + }) + + it('reports a pane tree main keeps for a tab no workspace places', () => { + const stored = session([{ id: 'tab-a' }]) + const layout = stored.terminalLayoutsByTabId['tab-a']! + stored.terminalLayoutsByTabId.orphan = { ...layout, root: { type: 'leaf', leafId: leaf(7) } } + const saved = { orphan: { ...layout, root: { type: 'leaf' as const, leafId: leaf(8) } } } + writeWindowSaveWatchingLayout( + () => stored, + 'local', + () => {}, + 'session:set', + saved + ) + + expect(warn).toHaveBeenCalledWith( + '[layout-owner] window save changed main layout:', + 'session:set', + expect.objectContaining({ + workspace: '(no workspace)', + paths: ['pane tree orphan kept over the save’s'] + }) + ) + }) + + it('reports a save that sends no pane tree for a tab main keeps one for', () => { + const stored = session([{ id: 'tab-a' }]) + const saved = { 'tab-a': { ...stored.terminalLayoutsByTabId['tab-a']!, root: null } } + writeWindowSaveWatchingLayout( + () => stored, + 'local', + () => {}, + 'session:set', + saved + ) + + expect(warn).toHaveBeenCalledWith( + '[layout-owner] window save changed main layout:', + 'session:set', + expect.objectContaining({ + workspace: GIT_KEY, + paths: ['pane tree tab-a kept over the save’s'] + }) + ) + }) + + it('stays quiet for a save whose pane trees match main’s', () => { + const stored = session([{ id: 'tab-a' }]) + const incoming = structuredClone(stored) + writeWindowSaveWatchingLayout( + () => stored, + 'local', + () => {}, + 'session:set', + incoming.terminalLayoutsByTabId + ) + + expect(warn).not.toHaveBeenCalled() + }) + it('leaves a paired server’s partition alone: its layout is the server’s', () => { const next = session([{ id: 'tab-c' }]) expect(save(session([{ id: 'tab-a' }]), next, 'runtime:env-1')).toBe(next) diff --git a/src/main/persistence/window-save-divergence.ts b/src/main/persistence/window-save-divergence.ts index 87aca53936f..02be9977893 100644 --- a/src/main/persistence/window-save-divergence.ts +++ b/src/main/persistence/window-save-divergence.ts @@ -18,6 +18,13 @@ import type { } from '../../shared/workspace-layout/workspace-layout-model' import type { TabGroupLayoutNode } from '../../shared/tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + closedTabsOfOneSidedWorkspaces, + droppedTabBarEntries, + overriddenPaneTrees, + terminalTabBarEntries, + type SavedPaneTrees +} from './window-save-raw-session-divergence' /** Set by evidence runs: each divergence is appended there as one JSON line. */ const LOG_PATH_ENV = 'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG' @@ -117,6 +124,30 @@ function reboundLeaves( return { rebound, was, now } } +/** + * Whether a workspace holds anything. The Loader makes one for every key a save names and places a + * stored closed-tab record by worktree id into it, so neither makes one by itself. + */ +function hasContent(workspace: WorkspaceLayout | undefined): workspace is WorkspaceLayout { + return Boolean( + workspace && + (workspace.tabs.length > 0 || + workspace.groups.length > 0 || + workspace.groupLayout || + [workspace.editorFiles, workspace.browserTabs, workspace.editorViews, workspace.leaves].some( + (items) => items && Object.keys(items).length > 0 + )) + ) +} + +function diffClosedTabs(before: WorkspaceLayout, after: WorkspaceLayout): string[] { + return diffKeyed( + 'closed tab', + recordById(before.closedTerminalTabs), + recordById(after.closedTerminalTabs) + ) +} + /** A lone group with no stored tree is that group's leaf, as main's own save writes it. */ function groupTreeOf(workspace: WorkspaceLayout): TabGroupLayoutNode | null { const [only, ...rest] = workspace.groups @@ -130,11 +161,12 @@ export function diffWorkspaceLayouts( before: WorkspaceLayout | undefined, after: WorkspaceLayout | undefined ): WorkspaceDiff { - if (!before || !after) { - return { - paths: before === after ? [] : [after ? 'workspace added' : 'workspace removed'], - rebound: 0 + if (!hasContent(before) || !hasContent(after)) { + if (hasContent(before) || hasContent(after)) { + return { paths: [hasContent(after) ? 'workspace added' : 'workspace removed'], rebound: 0 } } + // Both hold no tab: only a closed-tab record of a workspace both copies name can differ. + return { paths: before && after ? diffClosedTabs(before, after) : [], rebound: 0 } } const leaves = reboundLeaves(before.leaves, after.leaves) const paths = [ @@ -159,11 +191,7 @@ export function diffWorkspaceLayouts( listById(before.browserTabs, (b) => b.id), listById(after.browserTabs, (b) => b.id) ), - ...diffKeyed( - 'closed tab', - recordById(before.closedTerminalTabs), - recordById(after.closedTerminalTabs) - ), + ...diffClosedTabs(before, after), ...diffKeyed('leaf', leaves.was, leaves.now) ] if (stableJson(groupTreeOf(before)) !== stableJson(groupTreeOf(after))) { @@ -172,29 +200,33 @@ export function diffWorkspaceLayouts( return { paths, rebound: leaves.rebound.size } } -/** Each workspace whose part of main's model differs between two copies of one partition. */ +/** + * Each workspace whose part of main's model differs between two copies of one partition, plus + * `rawPaths`: what only the stored session itself shows, by workspace. + */ export function windowSaveDivergences( was: WorkspaceLayoutModel | null, - now: WorkspaceLayoutModel | null + now: WorkspaceLayoutModel | null, + rawPaths: ReadonlyMap = new Map() ): WindowSaveDivergence[] { // Records count for a workspace with no tabs too. - const workspaces = new Set( - [was, now].flatMap((model) => + const workspaces = new Set([ + ...[was, now].flatMap((model) => model ? [ ...Object.keys(model.workspaces), - ...RECORDS.flatMap((record) => Object.keys(model.records[record] ?? {})), - ...Object.keys(model.legacy.terminalRowOwners) + ...RECORDS.flatMap((record) => Object.keys(model.records[record] ?? {})) ] : [] - ) - ) + ), + ...rawPaths.keys() + ]) const result: WindowSaveDivergence[] = [] for (const workspace of workspaces) { - const { paths, rebound } = diffWorkspaceLayouts( - was?.workspaces[workspace], - now?.workspaces[workspace] - ) + const before = was?.workspaces[workspace] + const after = now?.workspaces[workspace] + const { paths: modelPaths, rebound } = diffWorkspaceLayouts(before, after) + const paths = [...modelPaths, ...(rawPaths.get(workspace) ?? [])] for (const record of RECORDS) { if ( stableJson(was?.records[record]?.[workspace] ?? null) !== @@ -203,6 +235,7 @@ export function windowSaveDivergences( paths.push(`${record} changed`) } } + // Counted for an empty workspace too: a relaunch reads it there to skip the first terminal. if ( Boolean(was?.legacy.terminalRowOwners[workspace]) !== Boolean(now?.legacy.terminalRowOwners[workspace]) @@ -238,15 +271,17 @@ function report(source: string, partition: ExecutionHostId, divergence: WindowSa } /** - * Runs the window's save `write` and reports what it changed in main's layout model. A paired - * server's partition is skipped: its layout is the server's, mirrored by the window. Nothing here - * can fail the save: the write's own errors propagate, the check's are only logged. + * Runs the window's save `write` and reports what it changed in main's layout model, and which of + * its `savedPaneTrees` (as the window sent them) main keeps its own tree over. A paired server's + * partition is skipped: its layout is the server's, mirrored by the window. Nothing here can fail + * the save: the write's own errors propagate, the check's are only logged. */ export function writeWindowSaveWatchingLayout( read: () => WorkspaceSessionState | null | undefined, hostId: string | null | undefined, write: () => void, - source: string + source: string, + savedPaneTrees?: SavedPaneTrees ): void { const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID if (parseExecutionHostId(partition)?.kind === 'runtime') { @@ -254,17 +289,35 @@ export function writeWindowSaveWatchingLayout( return } let was: WorkspaceLayoutModel | null | undefined + let wasEntries: Map> | undefined + let wasClosed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId'] + const rawPaths = new Map() try { - was = layoutModelOf(read(), partition) + const stored = read() + was = layoutModelOf(stored, partition) + wasEntries = stored ? terminalTabBarEntries(stored) : new Map() + wasClosed = stored?.closedTerminalTabTombstonesByTabId + if (savedPaneTrees) { + overriddenPaneTrees(stored, was, savedPaneTrees, rawPaths) + } } catch (error) { + was = undefined console.warn('[layout-owner] save divergence check failed:', error) } write() - if (was === undefined) { + if (was === undefined || wasEntries === undefined) { return } try { - for (const divergence of windowSaveDivergences(was, layoutModelOf(read(), partition))) { + const now = read() + const nowModel = layoutModelOf(now, partition) + droppedTabBarEntries(wasEntries, now, rawPaths) + closedTabsOfOneSidedWorkspaces( + { model: was, closed: wasClosed }, + { model: nowModel, closed: now?.closedTerminalTabTombstonesByTabId }, + rawPaths + ) + for (const divergence of windowSaveDivergences(was, nowModel, rawPaths)) { report(source, partition, divergence) } } catch (error) { diff --git a/src/main/persistence/window-save-raw-session-divergence.ts b/src/main/persistence/window-save-raw-session-divergence.ts new file mode 100644 index 00000000000..61f3ed08e04 --- /dev/null +++ b/src/main/persistence/window-save-raw-session-divergence.ts @@ -0,0 +1,110 @@ +// What a window's save changes that main's layout model cannot show, read from the stored session +// itself: the Loader rebuilds a dropped terminal tab-bar entry from its row, places closed-tab +// records into whatever workspace a key names, and main keeps its own pane trees over the save's. +// Each finding is added to `paths` under its workspace. + +import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report' +import type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model' +import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +/** The report key of a pane tree whose tab no workspace names. */ +const UNPLACED = '(no workspace)' + +export type SavedPaneTrees = Readonly> + +/** The terminal tabs (by entity id) each workspace's stored tab bar names. */ +export function terminalTabBarEntries(session: WorkspaceSessionState): Map> { + return new Map( + Object.entries(session.unifiedTabs ?? {}).map(([workspace, entries]) => [ + workspace, + new Set( + entries.flatMap((entry) => (entry.contentType === 'terminal' ? [entry.entityId] : [])) + ) + ]) + ) +} + +function addPath(paths: Map, workspace: string, path: string): void { + paths.set(workspace, [...(paths.get(workspace) ?? []), path]) +} + +/** + * A tab-bar entry the save dropped from a tab bar it still writes, while the row stays: the Loader + * rebuilds that entry from the row, so main's model shows no change. + */ +export function droppedTabBarEntries( + wasEntries: ReadonlyMap>, + now: WorkspaceSessionState | null | undefined, + paths: Map +): void { + const nowEntries = now ? terminalTabBarEntries(now) : new Map>() + for (const [workspace, entities] of wasEntries) { + const kept = nowEntries.get(workspace) + // No tab bar at all: a save that predates main's first frame for the workspace. + if (!kept) { + continue + } + const rows = new Set((now?.tabsByWorktree?.[workspace] ?? []).map((row) => row.id)) + for (const entityId of entities) { + if (!kept.has(entityId) && rows.has(entityId)) { + addPath(paths, workspace, `terminal ${entityId} tab-bar entry removed`) + } + } + } +} + +/** + * The save's pane trees that differ from main's: main keeps its own for every tab it has one for + * (windowSaveForMain), so each is reported under the workspace whose tab or row names it. + */ +export function overriddenPaneTrees( + stored: WorkspaceSessionState | null | undefined, + model: WorkspaceLayoutModel | null, + saved: SavedPaneTrees, + paths: Map +): void { + const homes = new Map() + for (const [workspace, rows] of Object.entries(stored?.tabsByWorktree ?? {})) { + rows.forEach((row) => homes.set(row.id, workspace)) + } + for (const [workspace, layout] of Object.entries(model?.workspaces ?? {})) { + layout.tabs.forEach((tab) => homes.set(tab.entityId, workspace)) + } + for (const [tabId, layout] of Object.entries(saved)) { + const mains = stored?.terminalLayoutsByTabId?.[tabId]?.root + if (mains && stableJson(mains) !== stableJson(layout.root ?? null)) { + addPath(paths, homes.get(tabId) ?? UNPLACED, `pane tree ${tabId} kept over the save’s`) + } + } +} + +type PartitionCopy = { + model: WorkspaceLayoutModel | null + closed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId'] +} + +/** + * Closed-tab records of a workspace only one copy has: the model diff cannot tell a record the + * Loader just placed there (already stored, unplaced) from one the save added or dropped. + */ +export function closedTabsOfOneSidedWorkspaces( + was: PartitionCopy, + now: PartitionCopy, + paths: Map +): void { + const oneSided = (from: PartitionCopy, other: PartitionCopy, change: string) => { + for (const [workspace, layout] of Object.entries(from.model?.workspaces ?? {})) { + if (other.model?.workspaces[workspace]) { + continue + } + for (const tabId of Object.keys(layout.closedTerminalTabs ?? {})) { + if (other.closed?.[tabId]?.worktreeId !== layout.worktreeId) { + addPath(paths, workspace, `closed tab ${tabId} ${change}`) + } + } + } + } + oneSided(now, was, 'added') + oneSided(was, now, 'removed') +}