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.
This commit is contained in:
Jinwoo-H
2026-10-18 03:21:10 -04:00
parent 2c513b2d10
commit ee27a0bb07
6 changed files with 232 additions and 6 deletions
@@ -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]
)
)
}
}
}
@@ -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<string, ExecutionHostId> = {
[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)
})
})
@@ -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<LayoutApplyOutcome> {
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.
+14 -2
View File
@@ -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<LayoutApplyOutcome> => ({
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
+6 -3
View File
@@ -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')
@@ -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']