mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user