mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user