From ee27a0bb0749010af5b748e776fe681e7dc73114 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 18 Oct 2026 03:21:10 -0400 Subject: [PATCH] fix(layout): main moves a tab between a local and an SSH workspace itself (dark, layout-owner build only) moveTabToWorkspace across two partitions was refused as other_partition and left to the window's save. Main now reads both partitions, moves the tabs as within one, and writes both in one durable mutation. --- .../cross-partition-tab-move.ts | 115 ++++++++++++++++++ .../editor-tab-layout-apply.test.ts | 58 +++++++++ .../layout-command-persistence.ts | 39 +++++- src/main/runtime/runtime-layout-apply.test.ts | 16 ++- src/main/runtime/runtime-layout-apply.ts | 9 +- src/main/runtime/runtime-store-contract.ts | 1 + 6 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 src/main/persistence/terminal-topology/cross-partition-tab-move.ts diff --git a/src/main/persistence/terminal-topology/cross-partition-tab-move.ts b/src/main/persistence/terminal-topology/cross-partition-tab-move.ts new file mode 100644 index 00000000000..a98f6a9830d --- /dev/null +++ b/src/main/persistence/terminal-topology/cross-partition-tab-move.ts @@ -0,0 +1,115 @@ +// Moving editor tabs between workspaces held in two partitions (a local and an SSH workspace): +// main reads both, moves the tabs as it would within one, and writes back only what each +// partition's records changed. A window save no longer carries the move. + +import { randomUUID } from 'node:crypto' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { + CommandOf, + LayoutApplyOutcome +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import { updateLegacyPersistence } from '../../../shared/workspace-layout/workspace-layout-legacy-persistence' +import { loadWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load' +import type { LoadedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load-types' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import type { + WorkspaceLayout, + WorkspaceLayoutModel +} from '../../../shared/workspace-layout/workspace-layout-model' +import { patchStoredSession } from '../../../shared/workspace-layout/workspace-layout-record-patch' +import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' +import { moveTabToWorkspace } from '../../../shared/workspace-layout/workspace-layout-workspace-move' + +type PartitionSession = { hostId: ExecutionHostId; session: WorkspaceSessionState } + +export type CrossPartitionTabMove = { + value: LayoutApplyOutcome + /** Each partition's session after the move; null when nothing changes. */ + sessions: { from: WorkspaceSessionState; to: WorkspaceSessionState } | null +} + +function withWorkspace( + model: WorkspaceLayoutModel, + key: string, + workspace: WorkspaceLayout | undefined +): WorkspaceLayoutModel { + const workspaces = { ...model.workspaces } + if (workspace) { + workspaces[key] = workspace + } else { + delete workspaces[key] + } + return { ...model, workspaces } +} + +/** `stored` with only the records the move changed in its loaded partition rewritten. */ +function written( + stored: WorkspaceSessionState, + loaded: LoadedWorkspaceLayout, + after: WorkspaceLayoutModel +): WorkspaceSessionState { + const layout = updateLegacyPersistence(loaded.layout, after) + return patchStoredSession( + stored, + saveWorkspaceLayout(loaded), + saveWorkspaceLayout({ ...loaded, layout }) + ) +} + +/** The move of `command` from its workspace in `from` to its target workspace in `to`. */ +export function moveTabAcrossPartitions( + from: PartitionSession, + to: PartitionSession, + command: CommandOf<'moveTabToWorkspace'> +): CrossPartitionTabMove { + const loadedFrom = loadWorkspaceLayout(from.hostId, from.session, nameBasedLoadContext()) + const loadedTo = loadWorkspaceLayout(to.hostId, to.session, nameBasedLoadContext()) + if (!loadedFrom.layout.workspaces[command.workspace]) { + return { value: { ok: false, code: 'workspace_not_found' }, sessions: null } + } + // Both partitions' workspaces, so a new tab id either one holds is refused. + const joined = withWorkspace( + { + ...loadedFrom.layout, + workspaces: { ...loadedTo.layout.workspaces, ...loadedFrom.layout.workspaces } + }, + command.toWorkspace, + loadedTo.layout.workspaces[command.toWorkspace] + ) + const moved = moveTabToWorkspace(joined, command, { + mintId: randomUUID, + mintLeafId: randomUUID, + now: Date.now + }) + if (!moved.ok) { + return { value: { ok: false, code: moved.code }, sessions: null } + } + const value: LayoutApplyOutcome = { ok: true, result: moved.result } + if (moved.model === joined) { + return { value, sessions: null } + } + return { + value, + sessions: { + from: written( + from.session, + loadedFrom, + withWorkspace( + loadedFrom.layout, + command.workspace, + moved.model.workspaces[command.workspace] + ) + ), + to: written( + to.session, + loadedTo, + withWorkspace( + loadedTo.layout, + command.toWorkspace, + moved.model.workspaces[command.toWorkspace] + ) + ) + } + } +} diff --git a/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts b/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts index b9ed0fb26a1..8252c343d18 100644 --- a/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts +++ b/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts @@ -4,6 +4,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../../shared/constants' import type { Store } from '../loading-store/store' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { applyWindowLayoutCommand } from '../../runtime/runtime-layout-apply' import { WindowSession } from './terminal-topology-window-session-fixture' import { emptyTerminalSessionProfile, @@ -149,3 +151,59 @@ describe('Store.applyLayoutCommand moveTabToWorkspace', () => { ]) }) }) + +describe('a window moveTabToWorkspace between a local and an SSH workspace', () => { + const SSH_HOST = 'ssh:target-1' + const SSH_WORKTREE = 'repo-ssh::/home/dev/api' + const routes: Record = { + [FIXTURE_GIT_WORKTREE_ID]: 'local', + [SSH_WORKTREE]: SSH_HOST + } + const hostIdsFor = (worktreeId: string) => (routes[worktreeId] ? [routes[worktreeId]] : []) + + it.each([ + ['local to SSH', FIXTURE_GIT_WORKTREE_ID, SSH_WORKTREE], + ['SSH to local', SSH_WORKTREE, FIXTURE_GIT_WORKTREE_ID] + ])('moves a restored editor tab %s in one durable write', async (_name, from, to) => { + const { store, directory } = await storeWithTerminal(FIXTURE_GIT_WORKTREE_ID) + const path = '/fixture/other/readme.md' + const open = { ...editorOpen(from, 'e1', path), preview: undefined } + await store.applyLayoutCommand({ ...open, file: { ...open.file, relativePath: path } }, [ + routes[from]! + ]) + const newId = `editor:${to}:${path}` + + await expect( + applyWindowLayoutCommand(store, hostIdsFor, { + type: 'moveTabToWorkspace', + workspace: from, + toWorkspace: to, + tabs: [{ tabId: 'e1', newTabId: 'e1', newEntityId: newId }], + groupId: `group-${to}`, + file: { filePath: path, relativePath: 'readme.md', language: 'markdown' } + }) + ).resolves.toEqual({ ok: true, result: { tabId: 'e1', groupId: `group-${to}` } }) + + // The move resolves once durable; a reload would drop the SSH workspace, whose repo the fixture + // catalog lacks. + const source = store.getWorkspaceSession(routes[from]) + const target = store.getWorkspaceSession(routes[to]) + expect(source.unifiedTabs?.[from]?.some((tab) => tab.id === 'e1') ?? false).toBe(false) + expect(source.openFilesByWorktree?.[from] ?? []).toEqual([]) + expect(target.unifiedTabs?.[to]?.find((tab) => tab.id === 'e1')).toMatchObject({ + entityId: newId + }) + expect(target.openFilesByWorktree?.[to]).toEqual([ + expect.objectContaining({ filePath: path, relativePath: 'readme.md', worktreeId: to }) + ]) + const reopened = await reopenTopologyStore(store, directory) + expect(reopened.getWorkspaceSession('local').tabsByWorktree[FIXTURE_GIT_WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: TAB }) + ]) + expect( + reopened + .getWorkspaceSession('local') + .unifiedTabs?.[FIXTURE_GIT_WORKTREE_ID]?.some((tab) => tab.id === 'e1') + ).toBe(to === FIXTURE_GIT_WORKTREE_ID) + }) +}) diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts index 1b188797de9..001a7dc79f0 100644 --- a/src/main/persistence/terminal-topology/layout-command-persistence.ts +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -1,4 +1,5 @@ import type { + CommandOf, LayoutApplyOutcome, LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' @@ -7,7 +8,10 @@ import { type SessionHostPartitionOperations } from '../loading-store/session-host-partitions' import type { StoreRuntimeState } from '../loading-store/store-runtime-state' -import { applyLayout, topologyCommitContext } from './terminal-topology-commit' +import { applyLayout, keepFrozen, topologyCommitContext } from './terminal-topology-commit' +import { moveTabAcrossPartitions } from './cross-partition-tab-move' +import { writeRestorable } from './restorable-write' +import { assignWorkspaceSessionPartition } from './terminal-topology-membership' import { repairDuplicatePtyPanesMutation, type DuplicatePtyPaneTransfer @@ -44,6 +48,39 @@ export class LayoutCommandPersistence { ) } + /** Moves editor tabs between workspaces of two partitions in one durable write of both. */ + moveTabToWorkspaceAcrossPartitions( + command: CommandOf<'moveTabToWorkspace'>, + fromHostId: string, + toHostId: string + ): Promise { + const commit = topologyCommitContext(this[layoutCommandPersistenceContext]) + const [from, to] = [resolveHostId(fromHostId), resolveHostId(toHostId)] + return this[layoutCommandPersistenceContext].runtime.runDurableMutation(() => { + const moved = moveTabAcrossPartitions( + { hostId: from, session: commit.getSession(from) }, + { hostId: to, session: commit.getSession(to) }, + command + ) + if (!moved.sessions) { + return { value: moved.value, persist: false } + } + const restores = ( + [ + [from, moved.sessions.from], + [to, moved.sessions.to] + ] as const + ).map(([hostId, session]) => + writeRestorable( + () => commit.getSession(hostId), + (value) => commit.markDirty(assignWorkspaceSessionPartition(commit.state, hostId, value)), + keepFrozen(hostId, session, commit) + ) + ) + return { value: moved.value, rollback: () => restores.forEach((restore) => restore()) } + }) + } + /** * Layout-owner builds, once a runtime starts: one pane per terminal on disk, written only after * `transfer` moved each dropped pane's owners. Resolves the number of partitions repaired. diff --git a/src/main/runtime/runtime-layout-apply.test.ts b/src/main/runtime/runtime-layout-apply.test.ts index c6a6b5f5a5c..667a6ad4fc7 100644 --- a/src/main/runtime/runtime-layout-apply.test.ts +++ b/src/main/runtime/runtime-layout-apply.test.ts @@ -237,8 +237,13 @@ describe('applyWindowLayoutCommand', () => { expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled() }) - it('moves editor tabs between workspaces of one partition, and refuses two partitions', async () => { + it('moves editor tabs within one partition or across two, and refuses a paired server', async () => { const { store, applyLayoutCommand } = storeWith() + const moveTabToWorkspaceAcrossPartitions = vi.fn(async (): Promise => ({ + ok: true, + result: {} + })) + store.moveTabToWorkspaceAcrossPartitions = moveTabToWorkspaceAcrossPartitions const move: WindowLayoutCommand = { type: 'moveTabToWorkspace', workspace: 'wt-1', @@ -258,8 +263,15 @@ describe('applyWindowLayoutCommand', () => { expect(applyLayoutCommand).toHaveBeenCalledWith(move, ['ssh:target-1']) applyLayoutCommand.mockClear() + await applyWindowLayoutCommand( + store, + (worktreeId) => [worktreeId === 'wt-1' ? 'local' : 'ssh:target-1'], + move + ) + expect(moveTabToWorkspaceAcrossPartitions).toHaveBeenCalledWith(move, 'local', 'ssh:target-1') + expect(applyLayoutCommand).not.toHaveBeenCalled() + const partitions = { - 'two partitions': [['local'], ['ssh:target-1'], 'other_partition'], 'a paired server': [['local'], ['runtime:env-1'], 'host_not_owned'], 'an unknown target': [['local'], [], 'host_not_owned'] } as const diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index e16430736a0..15f4e9157b1 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -124,8 +124,8 @@ export async function applyWindowLayoutCommand( } /** - * Applied in the one partition holding both workspaces. Workspaces in two partitions are refused: - * the window's save carries that move, as today. + * Applied in the one partition holding both workspaces, or across the two that do (a local and an + * SSH workspace) in one durable write: a window save no longer carries the move. */ async function moveTabToWorkspace( store: RuntimeStore, @@ -143,7 +143,10 @@ async function moveTabToWorkspace( return { ok: false, code: 'host_not_owned' } } if (from !== to) { - return { ok: false, code: 'other_partition' } + if (!store.moveTabToWorkspaceAcrossPartitions) { + throw new Error('layout_store_unavailable') + } + return store.moveTabToWorkspaceAcrossPartitions(command, from, to) } if (!store.applyLayoutCommand) { throw new Error('layout_store_unavailable') diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 26306f4246c..8cdeaee3d60 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -73,6 +73,7 @@ export type RuntimeStore = { admitTerminalPane?: Store['admitTerminalPane'] withdrawTerminalPane?: Store['withdrawTerminalPane'] applyLayoutCommand?: Store['applyLayoutCommand'] + moveTabToWorkspaceAcrossPartitions?: Store['moveTabToWorkspaceAcrossPartitions'] getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] getUI?: Store['getUI'] onUIChanged?: Store['onUIChanged']