diff --git a/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts b/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts index f00c5215a37..6cb1a844323 100644 --- a/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts +++ b/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts @@ -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 }) + ]) + }) +}) diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts index 7abd8913c03..a6e57904d41 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -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) => 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 = { diff --git a/src/main/runtime/runtime-layout-apply.test.ts b/src/main/runtime/runtime-layout-apply.test.ts index d9bd93036a9..4eba2b292c3 100644 --- a/src/main/runtime/runtime-layout-apply.test.ts +++ b/src/main/runtime/runtime-layout-apply.test.ts @@ -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() + }) }) diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 0505fda1c2e..d9a15fd60e9 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -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 { + 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 { + // 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, diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index 58583a8d956..26f6feea967 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -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[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 { diff --git a/src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts b/src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts index 5340be511ec..e7fcc4a0787 100644 --- a/src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts +++ b/src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts @@ -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 } } diff --git a/src/renderer/src/store/slices/editor/tabs/editor-tab-layout-owner.ts b/src/renderer/src/store/slices/editor/tabs/editor-tab-layout-owner.ts index 7ef815c7eee..f9d1c2f02a5 100644 --- a/src/renderer/src/store/slices/editor/tabs/editor-tab-layout-owner.ts +++ b/src/renderer/src/store/slices/editor/tabs/editor-tab-layout-owner.ts @@ -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[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 { diff --git a/src/renderer/src/store/slices/restored-editor-owner-layout-owner.test.ts b/src/renderer/src/store/slices/restored-editor-owner-layout-owner.test.ts new file mode 100644 index 00000000000..28ee7236be8 --- /dev/null +++ b/src/renderer/src/store/slices/restored-editor-owner-layout-owner.test.ts @@ -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 + ) +} + +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([]) + }) +}) diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index daffb5e487e..4ad90a302a8 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -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 ]) diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 64fac3b8a0a..99dc786557a 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -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 diff --git a/src/shared/workspace-layout/workspace-layout-commands.ts b/src/shared/workspace-layout/workspace-layout-commands.ts index cd10e641c94..4d386a1b8b7 100644 --- a/src/shared/workspace-layout/workspace-layout-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-commands.ts @@ -35,6 +35,7 @@ import { setChatPane, setTabProps } from './workspace-layout-tab-commands' +import { moveTabToWorkspace } from './workspace-layout-workspace-move' const CREATES_WORKSPACE = new Set([ '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': diff --git a/src/shared/workspace-layout/workspace-layout-workspace-move.test.ts b/src/shared/workspace-layout/workspace-layout-workspace-move.test.ts new file mode 100644 index 00000000000..2cfbdd004f8 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-workspace-move.test.ts @@ -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'> => ({ + 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' }) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-workspace-move.ts b/src/shared/workspace-layout/workspace-layout-workspace-move.ts new file mode 100644 index 00000000000..80c925f9405 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-workspace-move.ts @@ -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([ + '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['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] } + } +}