Record a restored editor tab's move to its owning workspace through main (dark, build switch off by default) (#29653)

* Record a restored editor tab's move to its owning workspace through main (dark, build switch off by default)

* Refuse non-editor and duplicate moves; route the move by workspace

* Type the move routing test's command
This commit is contained in:
Jinwoo Hong
2026-10-16 05:17:45 -07:00
committed by GitHub
parent 93ca977ca9
commit ebb9e3243a
13 changed files with 840 additions and 6 deletions
@@ -85,3 +85,36 @@ describe.each([
expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([TAB])
})
})
describe('Store.applyLayoutCommand moveTabToWorkspace', () => {
it.each([
['a git worktree to a folder workspace', FIXTURE_GIT_WORKTREE_ID, FIXTURE_FOLDER_WORKTREE_ID],
['a folder workspace to a git worktree', FIXTURE_FOLDER_WORKTREE_ID, FIXTURE_GIT_WORKTREE_ID]
])('moves a restored editor tab from %s durably', async (_name, from, to) => {
const { store, directory } = await storeWithTerminal(from)
const path = '/fixture/other/readme.md'
const open = { ...editorOpen(from, 'e1', path), preview: undefined }
await store.applyLayoutCommand({ ...open, file: { ...open.file, relativePath: path } })
const newId = `editor:${to}:${path}`
await expect(
store.applyLayoutCommand({
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}` } })
const session = (await reopenTopologyStore(store, directory)).getWorkspaceSession()
expect(session.tabGroups?.[from]?.[0]?.tabOrder).toEqual([TAB])
expect(session.tabGroups?.[to]?.[0]).toMatchObject({ id: `group-${to}`, tabOrder: ['e1'] })
expect(session.unifiedTabs?.[to]?.[0]).toMatchObject({ id: 'e1', entityId: newId })
expect(session.openFilesByWorktree?.[from] ?? []).toEqual([])
expect(session.openFilesByWorktree?.[to]).toEqual([
expect.objectContaining({ filePath: path, relativePath: 'readme.md', worktreeId: to })
])
})
})
@@ -270,6 +270,25 @@ describe('layout.apply', () => {
).toEqual({ command: { type: 'closeTabs', workspace: WORKTREE, tabIds: ['e1'] } })
})
it('accepts an editor tab move to the workspace the window resolved, naming its new ids', async () => {
const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply')
const parse = (command: Record<string, unknown>) => apply?.params?.safeParse({ command })
const move = {
type: 'moveTabToWorkspace',
workspace: WORKTREE,
toWorkspace: 'repo-1::/work/two',
tabs: [{ tabId: 'e1', newTabId: 'e1', newEntityId: 'editor:two:/work/two/a.ts' }],
groupId: 'g2',
file: { filePath: '/work/two/a.ts', relativePath: 'a.ts', language: 'typescript' }
}
expect(parse(move)?.success).toBe(true)
expect(parse({ ...move, file: undefined })?.success).toBe(true)
expect(parse({ ...move, tabs: [] })?.success).toBe(false)
expect(parse({ ...move, groupId: undefined })?.success).toBe(false)
expect(parse({ ...move, toWorkspace: '' })?.success).toBe(false)
})
it('opens an editor tab in the workspace’s routed partition', async () => {
const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1')
const open = {
@@ -203,4 +203,42 @@ describe('applyWindowLayoutCommand', () => {
expect(applyLayoutCommand).not.toHaveBeenCalled()
expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled()
})
it('moves editor tabs between workspaces of one partition, and refuses two partitions', async () => {
const { store, applyLayoutCommand } = storeWith()
const move: WindowLayoutCommand = {
type: 'moveTabToWorkspace',
workspace: 'wt-1',
toWorkspace: 'wt-2',
tabs: [{ tabId: 'tab-1', newTabId: 'tab-1', newEntityId: 'file-2' }],
groupId: 'g2'
}
const hostIdsFor = vi.fn((_worktreeId: string, _tabId: string | null) => [
'ssh:target-1' as const
])
await applyWindowLayoutCommand(store, hostIdsFor, move)
expect(hostIdsFor.mock.calls).toEqual([
['wt-1', null],
['wt-2', null]
])
expect(applyLayoutCommand).toHaveBeenCalledWith(move, ['ssh:target-1'])
applyLayoutCommand.mockClear()
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
for (const [from, to, code] of Object.values(partitions)) {
await expect(
applyWindowLayoutCommand(
store,
(worktreeId) => [...(worktreeId === 'wt-1' ? from : to)],
move
)
).resolves.toEqual({ ok: false, code })
}
expect(applyLayoutCommand).not.toHaveBeenCalled()
})
})
+33
View File
@@ -1,5 +1,6 @@
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import type {
CommandOf,
LayoutApplyReply,
LayoutCommand
} from '../../shared/workspace-layout/workspace-layout-command-types'
@@ -18,6 +19,7 @@ export type WindowLayoutCommand = LayoutCommand &
| { type: 'closeTabs'; tabIds: [string] }
// A browser tab's record lives in its workspace's partition, so it routes by workspace.
| { browserTabId: string }
| { type: 'moveTabToWorkspace' }
)
/**
@@ -32,6 +34,9 @@ export async function applyWindowLayoutCommand(
/** Agent status and orchestration records follow a pane moved into a new tab. */
onPaneMoved: (move: TerminalLeafMoveRequest) => void = () => {}
): Promise<LayoutApplyReply> {
if (command.type === 'moveTabToWorkspace') {
return moveTabToWorkspace(store, hostIdsFor, command)
}
const routedTabId =
'tabId' in command
? (command.tabId ?? null)
@@ -98,6 +103,34 @@ export async function applyWindowLayoutCommand(
return store.applyLayoutCommand({ ...command, userPaneClose: true }, holders)
}
/**
* Applied in the one partition holding both workspaces. Workspaces in two partitions are refused:
* the window's save carries that move, as today.
*/
async function moveTabToWorkspace(
store: RuntimeStore,
hostIdsFor: (worktreeId: string, tabId: string | null) => ExecutionHostId[],
command: CommandOf<'moveTabToWorkspace'>
): Promise<LayoutApplyReply> {
// An editor tab lives in its workspace's routed partition.
const [from] = hostIdsFor(command.workspace, null)
const [to] = hostIdsFor(command.toWorkspace, null)
if (
!from ||
!to ||
[from, to].some((hostId) => parseExecutionHostId(hostId)?.kind === 'runtime')
) {
return { ok: false, code: 'host_not_owned' }
}
if (from !== to) {
return { ok: false, code: 'other_partition' }
}
if (!store.applyLayoutCommand) {
throw new Error('layout_store_unavailable')
}
return store.applyLayoutCommand(command, [from])
}
/** A tab-bar id's terminal id, which a legacy entry's can differ from. */
function terminalIdOf(
store: RuntimeStore,
+15 -4
View File
@@ -65,6 +65,13 @@ export type WindowLayoutCommand =
preview?: true
}
| { type: 'promotePreviewTab'; tabId: string }
| {
type: 'moveTabToWorkspace'
toWorkspace: string
tabs: { tabId: string; newTabId: string; newEntityId: string }[]
groupId: string
file?: LayoutEditorFile
}
| { type: 'sleep'; tabId: string; paneKeys: string[]; records: SleepingAgentSessionRecord[] }
| { type: 'wake'; tabId: string; paneKeys: [string] }
@@ -77,9 +84,9 @@ export type LayoutApplyHandlers = {
/**
* A layout command in a layout-owner build: main applies it first, then `apply` shows main's
* result. `asToday` runs instead when main cannot act on it: a paired server holds the tab
* (#18202), main's copy lacks the workspace, tab, pane or group, or the call threw. `refused` on
* any other refusal. False when a paired server owns the workspace's layout; the caller acts as
* today.
* (#18202), a move's workspaces are in two partitions, main's copy lacks the workspace, tab, pane
* or group, or the call threw. `refused` on any other refusal. False when a paired server owns the
* workspace's layout; the caller acts as today.
*/
export function sendLayoutApply(
state: Parameters<typeof getExplicitRuntimeEnvironmentIdForWorktree>[0],
@@ -99,7 +106,11 @@ export function sendLayoutApply(
(reply) => {
if (reply.ok) {
handlers.apply(reply.result)
} else if (reply.code === 'host_not_owned' || mainLacksTarget(reply.code)) {
} else if (
reply.code === 'host_not_owned' ||
reply.code === 'other_partition' ||
mainLacksTarget(reply.code)
) {
// Why lacking: main's copy lags the window's here (the window still saves its own).
handlers.asToday()
} else {
@@ -2,6 +2,8 @@ import type { EditorGet, EditorSet } from '../types/editor-set-get'
import type { EditorSlice } from '../types/editor-slice'
import type { RestoredEditorOwnerResult } from '../types/restored-editor-owner'
import { buildRestoredEditorOwnerTransition } from './restored-editor-owner-transition'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../../shared/layout-owner-switch'
import { recordRestoredEditorMoveInLayoutOwner } from '../tabs/editor-tab-layout-owner'
export function createRestoredEditorOwner(
set: EditorSet,
@@ -27,6 +29,7 @@ export function createRestoredEditorOwner(
},
reparentRestoredEditorFileOwner: (args) => {
const before = ORCA_LAYOUT_OWNER_IS_MAIN ? get() : null
let result: RestoredEditorOwnerResult = { ok: false, reason: 'stale' }
const projectReparent = buildRestoredEditorOwnerTransition(args, (next) => {
result = next
@@ -44,6 +47,9 @@ export function createRestoredEditorOwner(
} else {
set((state) => projectReparent(state).patch)
}
if (ORCA_LAYOUT_OWNER_IS_MAIN && before && initialSource) {
recordRestoredEditorMoveInLayoutOwner(before, get(), initialSource.worktreeId, result)
}
return result
}
}
@@ -8,6 +8,7 @@ import type { LayoutEditorFile } from '../../../../../../shared/workspace-layout
import { recordLayoutApply } from '@/runtime/layout-apply-send'
import type { AppState } from '../../../types'
import type { OpenFile } from '../types/open-file'
import type { RestoredEditorOwnerResult } from '../types/restored-editor-owner'
import { isEditorTabContentType } from './editor-tab-content-type'
type EditorOwnerState = Parameters<typeof recordLayoutApply>[0] &
@@ -97,6 +98,54 @@ export function recordReopenedEditorTabPositionInLayoutOwner(
}
}
/**
* Restored editor tabs the window just moved to the workspace that owns their file (F42), read
* off the store before and after the move. A host change within one workspace sends nothing: only
* a paired server's tabs do that, and main does not own their layout.
*/
export function recordRestoredEditorMoveInLayoutOwner(
before: EditorOwnerState,
after: EditorOwnerState,
sourceWorktreeId: string,
result: RestoredEditorOwnerResult
): void {
const file = result.ok
? after.openFiles.find((candidate) => candidate.id === result.fileId)
: undefined
const targetWorktreeId = file?.worktreeId
if (!file || !targetWorktreeId || targetWorktreeId === sourceWorktreeId) {
return
}
const editorTabs = (state: EditorOwnerState, worktreeId: string): Tab[] =>
(state.unifiedTabsByWorktree[worktreeId] ?? []).filter((tab) =>
isEditorTabContentType(tab.contentType)
)
const stayed = new Set(editorTabs(after, sourceWorktreeId).map((tab) => tab.id))
const left = editorTabs(before, sourceWorktreeId).filter((tab) => !stayed.has(tab.id))
const had = new Set(
editorTabs(before, targetWorktreeId).map((tab) => `${tab.id}\0${tab.entityId}`)
)
// The window appends the moved tabs in their source order.
const joined = editorTabs(after, targetWorktreeId).filter(
(tab) => !had.has(`${tab.id}\0${tab.entityId}`)
)
const groupId = joined.at(-1)?.groupId
if (!groupId || left.length !== joined.length) {
return
}
recordLayoutApply(after, sourceWorktreeId, {
type: 'moveTabToWorkspace',
toWorkspace: targetWorktreeId,
tabs: left.map((tab, index) => ({
tabId: tab.id,
newTabId: joined[index]!.id,
newEntityId: joined[index]!.entityId
})),
groupId,
...(file.mode === 'edit' ? { file: restorableEditorFile(file) } : {})
})
}
/** The fields the window's session save keeps for an edit-mode file. */
export function restorableEditorFile(file: OpenFile): LayoutEditorFile {
return {
@@ -0,0 +1,245 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { captureEditorFileOperationProvenance } from '@/lib/editor-file-operation-owner'
import type { AppState } from '@/store/types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { applyLayoutCommand } from '../../../../shared/workspace-layout/workspace-layout-commands'
import type { LayoutCommand } from '../../../../shared/workspace-layout/workspace-layout-command-types'
import type { WorkspaceLayoutModel } from '../../../../shared/workspace-layout/workspace-layout-model'
const mocks = vi.hoisted(() => ({
layoutOwnerIsMain: true,
callRuntimeRpc: vi.fn(
async (_target: unknown, _method: string, _params: { command: LayoutCommand }) => ({
ok: true,
result: {}
})
)
}))
vi.mock('../../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return mocks.layoutOwnerIsMain
}
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
const SOURCE = 'repo-a::/repo-a'
const TARGET = 'repo-b::/repo-b'
const FILE_PATH = '/repo-b/docs/readme.md'
let main: WorkspaceLayoutModel
function installWorkspaceState(): void {
useAppStore.setState(useAppStore.getInitialState(), true)
useAppStore.setState(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial catalog with only the fields the owner route reads, as restored-editor-owner-reparent.test.ts uses.
{
activeWorktreeId: SOURCE,
settings: { editorAutoSave: false },
repos: [
{ id: 'repo-a', path: '/repo-a', kind: 'git', executionHostId: 'local' },
{ id: 'repo-b', path: '/repo-b', kind: 'git', executionHostId: 'local' }
],
worktreesByRepo: {
'repo-a': [{ id: SOURCE, repoId: 'repo-a', path: '/repo-a', hostId: 'local', branch: '' }],
'repo-b': [{ id: TARGET, repoId: 'repo-b', path: '/repo-b', hostId: 'local', branch: '' }]
},
detectedWorktreesByRepo: {},
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true,
removedRuntimeEnvironmentIds: new Set(),
sshConnectionStates: new Map(),
sshStateByEnvironment: new Map(),
folderWorkspaces: [],
projectGroups: [],
gitStatusHugeByWorktree: {}
} as unknown as Partial<AppState>
)
}
beforeEach(() => {
mocks.layoutOwnerIsMain = true
mocks.callRuntimeRpc.mockClear()
installWorkspaceState()
main = {
hostId: LOCAL_EXECUTION_HOST_ID,
workspaces: {},
records: {},
legacy: { terminalRowOwners: {} }
}
})
function sentCommands(): LayoutCommand[] {
const commands = mocks.callRuntimeRpc.mock.calls.map((call) => call[2].command)
mocks.callRuntimeRpc.mockClear()
return commands
}
/** Applies what the window sent to main's copy, as `layout.apply` does. */
function applySent(): LayoutCommand[] {
const commands = sentCommands()
let id = 0
for (const command of commands) {
const applied = applyLayoutCommand(main, command, {
mintId: () => `main-${++id}`,
mintLeafId: () => `leaf-${++id}`,
now: () => 1
})
if (!applied.ok) {
throw new Error(`${command.type} refused: ${applied.code}`)
}
main = applied.model
}
return commands
}
/** Non-empty groups only: an emptied group is never saved. */
function windowView(worktreeId: string) {
const state = useAppStore.getState()
const tabs = state.unifiedTabsByWorktree[worktreeId] ?? []
return (state.groupsByWorktree[worktreeId] ?? [])
.filter((group) => group.tabOrder.length > 0)
.map((group) => ({
group: group.id,
tabs: group.tabOrder.map((tabId) => {
const tab = tabs.find((entry) => entry.id === tabId)!
return { id: tab.id, entity: tab.entityId }
})
}))
}
function mainView(worktreeId: string) {
const workspace = main.workspaces[worktreeId]
return (workspace?.groups ?? []).map((group) => ({
group: group.id,
tabs: group.tabOrder.map((tabId) => {
const tab = workspace!.tabs.find((entry) => entry.id === tabId)!
return { id: tab.id, entity: tab.entityId }
})
}))
}
function openRestoredSource(): string {
return useAppStore.getState().openFile(
{
filePath: FILE_PATH,
relativePath: FILE_PATH,
worktreeId: SOURCE,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
}
/** The window's move when the restored tab's file loads under a sibling workspace (F42). */
function reparent(fileId: string) {
const state = useAppStore.getState()
state.setRestoredEditorOwnerMigrationPending(fileId, true)
return state.reparentRestoredEditorFileOwner({
fileId,
targetWorktreeId: TARGET,
targetRelativePath: 'docs/readme.md',
targetExecutionHostId: 'local',
targetRuntimeEnvironmentId: null,
targetOperationProvenance: captureEditorFileOperationProvenance(state, TARGET, null, true)
})
}
function expectMainMatchesWindow(): void {
for (const worktreeId of [SOURCE, TARGET]) {
expect(mainView(worktreeId)).toEqual(windowView(worktreeId))
}
}
describe('restored editor tab moved to its owning workspace, layout-owner build', () => {
it('records the move under the ids and group the window chose', () => {
const oldId = openRestoredSource()
applySent()
expectMainMatchesWindow()
const result = reparent(oldId)
expect(result.ok).toBe(true)
const [command] = applySent()
expect(command).toMatchObject({
type: 'moveTabToWorkspace',
workspace: SOURCE,
toWorkspace: TARGET,
file: { filePath: FILE_PATH, relativePath: 'docs/readme.md' }
})
expect(windowView(TARGET)[0]!.tabs).toEqual([
expect.objectContaining({ entity: result.ok ? result.fileId : '' })
])
expectMainMatchesWindow()
expect(main.workspaces[TARGET]!.editorFiles).toEqual([
expect.objectContaining({ filePath: FILE_PATH, relativePath: 'docs/readme.md' })
])
expect(main.workspaces[SOURCE]!.editorFiles).toEqual([])
})
it("appends to the target's active group after its own tabs, with a dependent preview", () => {
const oldId = openRestoredSource()
useAppStore.getState().openMarkdownPreview(
{
filePath: FILE_PATH,
relativePath: FILE_PATH,
worktreeId: SOURCE,
runtimeEnvironmentId: null,
language: 'markdown'
},
{ sourceFileId: oldId }
)
useAppStore.getState().openFile(
{
filePath: '/repo-b/a.ts',
relativePath: 'a.ts',
worktreeId: TARGET,
runtimeEnvironmentId: null,
language: 'typescript',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
applySent()
expectMainMatchesWindow()
expect(windowView(SOURCE)[0]!.tabs).toHaveLength(2)
expect(reparent(oldId).ok).toBe(true)
const [command] = applySent()
expect(command).toMatchObject({ type: 'moveTabToWorkspace' })
expect(command!.type === 'moveTabToWorkspace' && command.tabs).toHaveLength(2)
expect(windowView(TARGET)[0]!.tabs).toHaveLength(3)
expectMainMatchesWindow()
})
it('sends nothing when the move fails or the switch is off', () => {
const oldId = openRestoredSource()
applySent()
const collidingId = useAppStore.getState().openFile(
{
filePath: FILE_PATH,
relativePath: 'docs/readme.md',
worktreeId: TARGET,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
applySent()
expect(reparent(oldId)).toEqual({ ok: false, reason: 'collision' })
expect(sentCommands()).toEqual([])
useAppStore.getState().closeFile(collidingId)
sentCommands()
mocks.layoutOwnerIsMain = false
expect(reparent(oldId).ok).toBe(true)
expect(sentCommands()).toEqual([])
})
})
@@ -218,6 +218,27 @@ const SetBrowserTabProfileCommand = z.object({
const PromotePreviewTabCommand = z.object({ type: z.literal('promotePreviewTab'), ...OnTab })
/**
* Restored editor tabs the window moved to the workspace that owns their file (F42): the window
* resolved that owner. Main refuses it when the two workspaces are in different partitions.
*/
const MoveTabToWorkspaceCommand = z.object({
type: z.literal('moveTabToWorkspace'),
workspace: OnPane.workspace,
toWorkspace: z.string().min(1),
tabs: z
.array(
z.object({
tabId: z.string().min(1),
newTabId: z.string().min(1),
newEntityId: z.string().min(1)
})
)
.min(1),
groupId: z.string().min(1),
file: EditorFileRecord.optional()
})
/** Pane keys of the command's own tab only, so a sleep or wake never reaches past it. */
function paneKeysNameTab(command: { tabId: string; paneKeys: string[] }): boolean {
return command.paneKeys.every((paneKey) => parsePaneKey(paneKey)?.tabId === command.tabId)
@@ -273,6 +294,7 @@ export const LayoutApplyParams = z.object({
SetBrowserTabProfileCommand,
OpenEditorTabCommand,
PromotePreviewTabCommand,
MoveTabToWorkspaceCommand,
SleepCommand,
WakeCommand
])
@@ -116,6 +116,16 @@ export type LayoutCommand =
dirtyTabIds?: string[]
})
| (On & { type: 'promotePreviewTab'; tabId: string })
| (On & {
type: 'moveTabToWorkspace'
/** The workspace that owns the tabs' file, as the window resolved it; same partition. */
toWorkspace: string
/** In the order they join the group's end; ids change when they name the old owner. */
tabs: { tabId: string; newTabId: string; newEntityId: string }[]
groupId: string
/** The moved edit-mode file's record in its new workspace. */
file?: LayoutEditorFile
})
| (On & {
type: 'openBrowserTab'
profileId?: string | null
@@ -213,8 +223,13 @@ export type LayoutApplyOutcome =
| { ok: true; result: LayoutCommandResult }
| { ok: false; code: LayoutRefusalCode }
/** `layout.apply`'s reply. `host_not_owned`: a paired server owns that workspace's layout. */
export type LayoutApplyReply = LayoutApplyOutcome | { ok: false; code: 'host_not_owned' }
/**
* `layout.apply`'s reply. `host_not_owned`: a paired server owns that workspace's layout.
* `other_partition`: a move's two workspaces are in different host partitions.
*/
export type LayoutApplyReply =
| LayoutApplyOutcome
| { ok: false; code: 'host_not_owned' | 'other_partition' }
export type LayoutContext = {
mintId: () => string
@@ -35,6 +35,7 @@ import {
setChatPane,
setTabProps
} from './workspace-layout-tab-commands'
import { moveTabToWorkspace } from './workspace-layout-workspace-move'
const CREATES_WORKSPACE = new Set<LayoutCommand['type']>([
'createTerminalTab',
@@ -101,6 +102,8 @@ function applyCommand(
return openEditorTab(model, command, context)
case 'promotePreviewTab':
return promotePreviewTab(model, command)
case 'moveTabToWorkspace':
return moveTabToWorkspace(model, command, context)
case 'openBrowserTab':
return openBrowserTab(model, command, context)
case 'openBrowserPage':
@@ -0,0 +1,247 @@
import { describe, expect, it } from 'vitest'
import { applyLayoutCommand } from './workspace-layout-commands'
import type { CommandOf } from './workspace-layout-command-types'
import { build, testContext, WS } from './workspace-layout-command.test-fixture'
import type { WorkspaceLayoutModel } from './workspace-layout-model'
const TO = 'wt-target'
const PATH = '/target/docs/readme.md'
const NEW_ID = `editor:${encodeURIComponent(TO)}:local:${encodeURIComponent(PATH)}`
const FILE = { filePath: PATH, relativePath: 'docs/readme.md', language: 'markdown' }
/** The source holds a terminal and the restored editor tab; the target one editor tab. */
function twoWorkspaces() {
const ctx = testContext()
const source = build(ctx, [
{ type: 'createTerminalTab', workspace: WS },
{
type: 'openEditorTab',
workspace: WS,
tabId: PATH,
fileId: PATH,
contentType: 'editor',
file: { ...FILE, relativePath: PATH }
}
])
const target = build(
ctx,
[
{
type: 'openEditorTab',
workspace: TO,
tabId: 'target-tab',
fileId: '/target/a.ts',
contentType: 'editor',
groupId: 'target-group'
}
],
source.model
)
return { ctx, model: target.model, terminal: source.results[0]!.tabId! }
}
const move = (
fields: Partial<CommandOf<'moveTabToWorkspace'>> = {}
): CommandOf<'moveTabToWorkspace'> => ({
type: 'moveTabToWorkspace',
workspace: WS,
toWorkspace: TO,
tabs: [{ tabId: PATH, newTabId: NEW_ID, newEntityId: NEW_ID }],
groupId: 'target-group',
file: FILE,
...fields
})
function tabsOf(model: WorkspaceLayoutModel, key: string) {
const workspace = model.workspaces[key]!
return workspace.groups.map((group) => ({
group: group.id,
tabs: group.tabOrder.map((id) => {
const tab = workspace.tabs.find((entry) => entry.id === id)!
return `${tab.id}=${tab.entityId}`
})
}))
}
describe('moveTabToWorkspace', () => {
it("appends the tab to the window's group under its new ids, and moves the file's record", () => {
const { ctx, model, terminal } = twoWorkspaces()
// `build` also checks every structural rule on the result.
const moved = build(ctx, [move()], model)
expect(moved.results[0]).toEqual({ tabId: NEW_ID, groupId: 'target-group' })
expect(tabsOf(moved.model, TO)).toEqual([
{ group: 'target-group', tabs: ['target-tab=/target/a.ts', `${NEW_ID}=${NEW_ID}`] }
])
expect(tabsOf(moved.model, WS)).toEqual([
{ group: expect.any(String), tabs: [`${terminal}=${terminal}`] }
])
expect(moved.model.workspaces[TO]!.editorFiles).toEqual([FILE])
expect(moved.model.workspaces[WS]!.editorFiles).toEqual([])
})
it('keeps the tab id that does not name the file, and carries pin and title', () => {
const { ctx, model } = twoWorkspaces()
const pinned = applyLayoutCommand(
model,
{ type: 'setTabProps', workspace: WS, tabId: PATH, isPinned: true, color: 'red' },
ctx
)
if (!pinned.ok) {
throw new Error(pinned.code)
}
const moved = applyLayoutCommand(
pinned.model,
move({ tabs: [{ tabId: PATH, newTabId: PATH, newEntityId: NEW_ID }] }),
ctx
)
if (!moved.ok) {
throw new Error(moved.code)
}
expect(moved.model.workspaces[TO]!.tabs.find((tab) => tab.id === PATH)).toMatchObject({
entityId: NEW_ID,
isPinned: true,
color: 'red'
})
// Appended as the window appends it, even pinned.
expect(tabsOf(moved.model, TO)[0]!.tabs.at(-1)).toBe(`${PATH}=${NEW_ID}`)
})
it('moves a dependent preview tab after its source, in the order sent', () => {
const { ctx, model } = twoWorkspaces()
const withPreview = applyLayoutCommand(
model,
{
type: 'openEditorTab',
workspace: WS,
tabId: 'preview-tab',
fileId: `markdown-preview::${PATH}`,
contentType: 'editor'
},
ctx
)
if (!withPreview.ok) {
throw new Error(withPreview.code)
}
const moved = applyLayoutCommand(
withPreview.model,
move({
tabs: [
{ tabId: PATH, newTabId: NEW_ID, newEntityId: NEW_ID },
{
tabId: 'preview-tab',
newTabId: 'preview-tab',
newEntityId: `markdown-preview::${NEW_ID}`
}
]
}),
ctx
)
if (!moved.ok) {
throw new Error(moved.code)
}
expect(tabsOf(moved.model, TO)[0]!.tabs).toEqual([
'target-tab=/target/a.ts',
`${NEW_ID}=${NEW_ID}`,
`preview-tab=markdown-preview::${NEW_ID}`
])
expect(moved.result.tabId).toBe('preview-tab')
})
it("closes the source's emptied group and gives a target with no group the window's", () => {
const ctx = testContext()
const source = build(ctx, [
{ type: 'openEditorTab', workspace: WS, tabId: PATH, fileId: PATH, contentType: 'editor' }
])
const moved = applyLayoutCommand(source.model, move({ groupId: 'window-group' }), ctx)
if (!moved.ok) {
throw new Error(moved.code)
}
expect(moved.model.workspaces[WS]!.groups).toEqual([])
expect(tabsOf(moved.model, TO)).toEqual([
{ group: 'window-group', tabs: [`${NEW_ID}=${NEW_ID}`] }
])
})
it('carries what the tab shows beyond its file', () => {
const { ctx, model } = twoWorkspaces()
const view = { mode: 'diff' } as const
const withView: WorkspaceLayoutModel = {
...model,
workspaces: {
...model.workspaces,
[WS]: { ...model.workspaces[WS]!, editorViews: { [PATH]: view } }
}
}
const moved = applyLayoutCommand(withView, move(), ctx)
if (!moved.ok) {
throw new Error(moved.code)
}
expect(moved.model.workspaces[WS]!.editorViews).toEqual({})
expect(moved.model.workspaces[TO]!.editorViews).toEqual({ [NEW_ID]: view })
})
it('changes nothing for a move already saved, and refuses what main cannot match', () => {
const { ctx, model } = twoWorkspaces()
const moved = applyLayoutCommand(model, move(), ctx)
if (!moved.ok) {
throw new Error(moved.code)
}
const again = applyLayoutCommand(moved.model, move(), ctx)
expect(again.ok && again.model).toEqual(moved.model)
expect(applyLayoutCommand(model, move({ groupId: 'other-group' }), ctx)).toEqual({
ok: false,
code: 'group_not_found'
})
expect(
applyLayoutCommand(
model,
move({ tabs: [{ tabId: 'missing', newTabId: 'x', newEntityId: 'x' }] }),
ctx
)
).toEqual({ ok: false, code: 'tab_not_found' })
expect(
applyLayoutCommand(
model,
move({ tabs: [{ tabId: PATH, newTabId: 'target-tab', newEntityId: NEW_ID }] }),
ctx
)
).toEqual({ ok: false, code: 'tab_exists' })
const twice = { tabId: PATH, newTabId: NEW_ID, newEntityId: NEW_ID }
expect(applyLayoutCommand(model, move({ tabs: [twice, twice] }), ctx)).toEqual({
ok: false,
code: 'invalid_params'
})
expect(applyLayoutCommand(model, move({ toWorkspace: WS }), ctx)).toEqual({
ok: false,
code: 'invalid_params'
})
expect(applyLayoutCommand(model, move({ workspace: 'unknown' }), ctx)).toEqual({
ok: false,
code: 'workspace_not_found'
})
})
it('refuses a terminal tab, whose panes would not follow it', () => {
const { ctx, model, terminal } = twoWorkspaces()
expect(
applyLayoutCommand(
model,
move({ tabs: [{ tabId: terminal, newTabId: terminal, newEntityId: terminal }] }),
ctx
)
).toEqual({ ok: false, code: 'invalid_params' })
})
})
@@ -0,0 +1,113 @@
// Moving editor tabs to another workspace of the same partition: a restored tab whose file the
// window found under a sibling workspace (F42). The window resolves that owner; main moves the tabs.
import {
applied,
findTab,
placeNewTab,
refuse,
workspaceOrEmpty,
type Applied
} from './workspace-layout-command-steps'
import type { CommandOf, LayoutContext } from './workspace-layout-command-types'
import type { LayoutTab, WorkspaceLayout, WorkspaceLayoutModel } from './workspace-layout-model'
import { removeTabFromWorkspace, withWorkspace } from './workspace-layout-removal'
import { withoutKey } from './stored-record-fields'
const EDITOR_KINDS = new Set<LayoutTab['kind']>([
'editor',
'diff',
'conflict-review',
'check-details'
])
/**
* Each tab leaves its workspace and is appended to `groupId` under its new ids, as the window
* moves it; the file's record moves with it. A tab already moved (the window's save got there
* first) is skipped. Only editor tabs move: no other kind's records follow it.
*/
export function moveTabToWorkspace(
model: WorkspaceLayoutModel,
command: CommandOf<'moveTabToWorkspace'>,
context: LayoutContext
): Applied {
const { workspace: fromKey, toWorkspace: toKey } = command
const distinct = (ids: string[]) => new Set(ids).size === ids.length
if (
fromKey === toKey ||
!distinct(command.tabs.map((move) => move.tabId)) ||
!distinct(command.tabs.map((move) => move.newTabId))
) {
return refuse('invalid_params')
}
let source = model.workspaces[fromKey]!
let target = workspaceOrEmpty(model, toKey)
const moves: { tab: LayoutTab; newTabId: string; newEntityId: string }[] = []
for (const move of command.tabs) {
const tab = findTab(source, move.tabId)
if (!tab) {
const moved = findTab(target, move.newTabId)?.entityId === move.newEntityId
if (moved) {
continue
}
return refuse('tab_not_found')
}
if (!EDITOR_KINDS.has(tab.kind)) {
return refuse('invalid_params')
}
const held = Object.values(model.workspaces).some((workspace) =>
workspace.tabs.some((entry) => entry.id === move.newTabId && entry !== tab)
)
if (held) {
return refuse('tab_exists')
}
moves.push({ tab, ...move })
}
if (moves.length === 0) {
return applied(model, { groupId: command.groupId })
}
if (!target.groups.some((group) => group.id === command.groupId)) {
if (target.groups.length > 0) {
return refuse('group_not_found')
}
// A workspace with no group takes the window's, as openEditorTab does.
target = { ...target, groups: [{ id: command.groupId, tabOrder: [] }] }
}
for (const { tab, newTabId, newEntityId } of moves) {
const view = source.editorViews?.[tab.id]
source = removeTabFromWorkspace(source, tab.id)
if (source.editorViews) {
source = { ...source, editorViews: withoutKey(source.editorViews, tab.id) }
}
const end = target.groups.find((group) => group.id === command.groupId)!.tabOrder.length
target = placeNewTab(
target,
{ ...tab, id: newTabId, entityId: newEntityId },
{ groupId: command.groupId, index: end },
context
)
if (view) {
target = { ...target, editorViews: { ...target.editorViews, [newTabId]: view } }
}
}
if (command.file) {
;({ source, target } = moveEditorFile(source, target, command.file))
}
const next = withWorkspace(withWorkspace(model, fromKey, source), toKey, target)
return applied(next, { tabId: moves.at(-1)!.newTabId, groupId: command.groupId })
}
/** The file's record moves with its tabs. */
function moveEditorFile(
source: WorkspaceLayout,
target: WorkspaceLayout,
file: NonNullable<CommandOf<'moveTabToWorkspace'>['file']>
): { source: WorkspaceLayout; target: WorkspaceLayout } {
const others = (files: WorkspaceLayout['editorFiles']) =>
files?.filter((entry) => entry.filePath !== file.filePath)
const sourceFiles = others(source.editorFiles)
return {
source: sourceFiles ? { ...source, editorFiles: sourceFiles } : source,
target: { ...target, editorFiles: [...(others(target.editorFiles) ?? []), file] }
}
}