diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index edf621508a3..c274a883acc 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -24,7 +24,6 @@ import type { } from '../../../shared/terminal-leaf-move' import { admitPane, - applyLayout, moveLeaf, topologyCommitContext, withdrawPane, @@ -36,8 +35,6 @@ import type { } from '../terminal-topology/terminal-pane-admission' import type { CommandOf, - LayoutApplyOutcome, - LayoutCommand, LayoutRefusalCode } from '../../../shared/workspace-layout/workspace-layout-command-types' import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants' @@ -259,13 +256,6 @@ export class PtyBindingPersistenceOperations { private topology(): TerminalTopologyCommitContext { return topologyCommitContext(this[ptyBindingPersistenceOperationsContext]) } - - /** A client's layout command (`layout.apply`); the commit module owns the write. */ - applyLayoutCommand(command: LayoutCommand, hostId?: string | null): Promise { - return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( - applyLayout(resolveHostId(hostId), command, this.topology()) - ) - } } function writePtyBinding( diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index fbb1bc36dce..d07b26f15e3 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -19,6 +19,7 @@ import { MobileTabSelectionPersistence } from './mobile-tab-selection-persistenc import { RemoteLayoutSnapshotPersistence } from '../../remote-layout-snapshot' import { SparsePresetPersistence } from './sparse-preset-persistence' import { PtyBindingPersistenceOperations } from './pty-binding-persistence' +import { LayoutCommandPersistence } from '../terminal-topology/layout-command-persistence' import { SshProfileOperations } from './ssh-profile-operations' import { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' import { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' @@ -39,6 +40,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & SessionHostPartitionOperations & SessionSnapshotOperations & PtyBindingPersistenceOperations & + LayoutCommandPersistence & SshProfileOperations & RetiredWorktreeNamePersistence & SshLeaseRecoveryOperations & @@ -67,6 +69,7 @@ export type StoreDomains = { remoteLayoutSnapshots: RemoteLayoutSnapshotPersistence sparsePresets: SparsePresetPersistence ptyBindings: PtyBindingPersistenceOperations + layoutCommands: LayoutCommandPersistence sshProfiles: SshProfileOperations retiredWorktreeNames: RetiredWorktreeNamePersistence sshLeases: SshLeaseRecoveryOperations @@ -89,6 +92,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ SessionHostPartitionOperations, SessionSnapshotOperations, PtyBindingPersistenceOperations, + LayoutCommandPersistence, SshProfileOperations, RetiredWorktreeNamePersistence, SshLeaseRecoveryOperations, @@ -162,6 +166,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { remoteLayoutSnapshots, sparsePresets, ptyBindings, + layoutCommands: new LayoutCommandPersistence(runtime, sessions), sshProfiles, retiredWorktreeNames, sshLeases, diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts new file mode 100644 index 00000000000..ff5648fd187 --- /dev/null +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -0,0 +1,42 @@ +import type { + LayoutApplyOutcome, + LayoutCommand +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import { + resolveHostId, + type SessionHostPartitionOperations +} from '../loading-store/session-host-partitions' +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import { applyLayout, topologyCommitContext } from './terminal-topology-commit' + +type LayoutCommandRuntime = Pick< + StoreRuntimeState, + 'runDurableMutation' | 'state' | 'dirtyProfileStateDomains' +> + +const layoutCommandPersistenceContext = Symbol('LayoutCommandPersistence') +type LayoutCommandPersistenceContext = { + runtime: LayoutCommandRuntime + sessions: SessionHostPartitionOperations +} + +/** The Store's half of `layout.apply`; the commit module owns the write. */ +export class LayoutCommandPersistence { + readonly [layoutCommandPersistenceContext]: LayoutCommandPersistenceContext + + constructor(runtime: LayoutCommandRuntime, sessions: SessionHostPartitionOperations) { + this[layoutCommandPersistenceContext] = { runtime, sessions } + } + + /** Applies to each partition holding the command's tab, routed partition first (local if none). */ + applyLayoutCommand( + command: LayoutCommand, + hostIds: readonly string[] = [] + ): Promise { + const context = this[layoutCommandPersistenceContext] + const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()] + return context.runtime.runDurableMutation( + applyLayout(resolved, command, topologyCommitContext(context)) + ) + } +} diff --git a/src/main/persistence/terminal-topology/pane-close-remote-session.ts b/src/main/persistence/terminal-topology/pane-close-remote-session.ts new file mode 100644 index 00000000000..f0af92bba02 --- /dev/null +++ b/src/main/persistence/terminal-topology/pane-close-remote-session.ts @@ -0,0 +1,33 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' + +/** + * A tab's remote session id is carried data the layout model does not hold, so a pane close moves + * it here: one naming the closed pane's terminal follows the tab's remaining pane, as the legacy + * pane close (`retireTerminalSurfaceFromPersistence`) does. + */ +export function followRemoteSessionAfterPaneClose( + command: LayoutCommand, + stored: WorkspaceSessionState, + written: WorkspaceSessionState +): WorkspaceSessionState { + if (command.type !== 'closePane') { + return written + } + const closedPtyId = stored.terminalLayoutsByTabId[command.tabId]?.ptyIdsByLeafId?.[command.leafId] + const remoteSessionIds = written.remoteSessionIdsByTabId + if (!closedPtyId || remoteSessionIds?.[command.tabId] !== closedPtyId) { + return written + } + const layout = written.terminalLayoutsByTabId[command.tabId] + const next = { ...remoteSessionIds } + const remaining = + layout?.ptyIdsByLeafId?.[layout.activeLeafId ?? ''] ?? + Object.values(layout?.ptyIdsByLeafId ?? {})[0] + if (remaining) { + next[command.tabId] = remaining + } else { + delete next[command.tabId] + } + return { ...written, remoteSessionIdsByTabId: next } +} diff --git a/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts b/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts index a2ee53f8407..07d9b5bcd3a 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts @@ -126,7 +126,7 @@ describe('Store.applyLayoutCommand (layout.apply)', () => { const { store } = await storeWithOneTab('ssh:target-1') const local = store.getWorkspaceSession() - const reply = await store.applyLayoutCommand(split, 'ssh:target-1') + const reply = await store.applyLayoutCommand(split, ['ssh:target-1']) expect(reply.ok).toBe(true) const root = store.getWorkspaceSession('ssh:target-1').terminalLayoutsByTabId[TAB]?.root diff --git a/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts b/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts new file mode 100644 index 00000000000..b38d48c4ff1 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts @@ -0,0 +1,204 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { diffStoredSession } from '../../../shared/workspace-layout/workspace-layout-load-report' +import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture' +import { WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture' +import { + closeTerminalSurfaceInWorkspaceSession, + collectTerminalLayoutLeafIds +} from '../../runtime/terminal-surface-close' +import { applyCommandToSession } from './terminal-pane-admission' +import { leafIds, WindowSession } from './terminal-topology-window-session-fixture' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const WORKTREE = FIXTURE_GIT_WORKTREE_ID +const TAB = 'tab-1' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** What only the legacy close writes, which no reader distinguishes from its absence. */ +function legacyOnly(change: ReturnType[number]): boolean { + if (change.table !== 'session') { + return false + } + // The legacy close re-derives the active tab's type; layout.apply leaves selection alone. + if (change.field === 'activeTabTypeByWorktree') { + return true + } + // An empty tombstone map where none was stored. + return ( + change.field === 'terminalSurfaceTombstonesByPaneKey' && + change.after === undefined && + JSON.stringify(change.before) === '{}' + ) +} + +/** + * Known gap, the same one the record-patch test pins: the load folds this legacy row into the + * tab-bar tab whose terminal it repeats, so the stored row keeps its terminal id after the close. + */ +const FOLDED_LEGACY_ROW_CLOSES = new Map([ + [ + 'tab-legacy', + ['row/repo-1::/Users/dev/orca|tab-legacy/ptyId', 'layout/tab-legacy/ptyIdsByLeafId'] + ] +]) + +describe('layout.apply closePane against the legacy pane close', () => { + const profiles = [...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES] + + it.each(profiles.map((profile) => [profile.name, profile] as const))( + '%s: writes what the legacy close writes, and refuses a tab’s last pane', + (_name, profile) => { + const stored = profile.build() + for (const [worktreeId, tabs] of Object.entries(stored.tabsByWorktree)) { + for (const tab of tabs) { + const panes = collectTerminalLayoutLeafIds(stored.terminalLayoutsByTabId[tab.id]?.root) + for (const leafId of panes) { + const target = { kind: 'pane', tabId: tab.id, leafId } as const + const legacy = closeTerminalSurfaceInWorkspaceSession(stored, worktreeId, target, { + reason: 'user' + }) + const dark = applyCommandToSession(profile.hostId, stored, { + type: 'closePane', + workspace: worktreeId, + tabId: tab.id, + leafId, + userPaneClose: true + }) + const label = `${tab.id}:${leafId}` + if (legacy.resolution !== 'pane') { + // The legacy close changes nothing here; nor may layout.apply (a refusal or no-op). + expect(dark.session, label).toBeNull() + continue + } + expect(dark.value.ok, label).toBe(true) + const differences = diffStoredSession(legacy.session, dark.session ?? stored) + .filter((change) => !legacyOnly(change)) + .map(({ table, record, field }) => `${table}/${record}/${field}`) + expect(differences, label).toEqual(FOLDED_LEGACY_ROW_CLOSES.get(tab.id) ?? []) + } + } + } + } + ) + + // The legacy persisted close refuses a pane restarted after the request, but the window has + // already removed it and saves its layout without it; main closes it outright. + it('closes a pane whose terminal restarted after the window asked', () => { + const window = new WindowSession(getDefaultWorkspaceSession()) + window.addTab(WORKTREE, TAB, LEAF_A) + window.setLayout( + TAB, + { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b-restarted' } + ) + window.session.terminalPtyIncarnationsByPaneKey = { [`${TAB}:${LEAF_B}`]: 'incarnation-2' } + + const closed = applyCommandToSession('local', window.snapshot(), { + type: 'closePane', + workspace: WORKTREE, + tabId: TAB, + leafId: LEAF_B, + userPaneClose: true + }) + + expect(closed.value.ok).toBe(true) + expect(leafIds(closed.session?.terminalLayoutsByTabId[TAB]?.root)).toEqual([LEAF_A]) + expect(closed.session?.terminalPtyIncarnationsByPaneKey?.[`${TAB}:${LEAF_B}`]).toBeUndefined() + }) +}) + +describe('Store.applyLayoutCommand pane operations', () => { + async function storeWithSplitTab(hostIds: string[]) { + const directory = mkdtempSync(join(tmpdir(), 'orca-layout-pane-ops-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = new WindowSession(getDefaultWorkspaceSession()) + window.addTab(WORKTREE, TAB, LEAF_A) + window.setLayout( + TAB, + { + type: 'split', + direction: 'vertical', + ratio: 0.3, + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } + ) + for (const hostId of hostIds) { + store.setWorkspaceSession(window.snapshot(), hostId) + } + return store + } + + it('closes the pane in every partition holding the tab', async () => { + const store = await storeWithSplitTab(['local', 'ssh:target-1']) + + const reply = await store.applyLayoutCommand( + { type: 'closePane', workspace: WORKTREE, tabId: TAB, leafId: LEAF_B, userPaneClose: true }, + ['local', 'ssh:target-1'] + ) + + expect(reply.ok).toBe(true) + for (const hostId of ['local', 'ssh:target-1']) { + expect(leafIds(store.getWorkspaceSession(hostId).terminalLayoutsByTabId[TAB]?.root)).toEqual([ + LEAF_A + ]) + } + }) + + it('saves a pane title as the window saves it, and clears it on null', async () => { + const store = await storeWithSplitTab(['local']) + const rename = { type: 'renamePane', workspace: WORKTREE, tabId: TAB, leafId: LEAF_B } as const + + await store.applyLayoutCommand({ ...rename, title: 'Server' }) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.titlesByLeafId).toEqual({ + [LEAF_B]: 'Server' + }) + + await store.applyLayoutCommand({ ...rename, title: null }) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.titlesByLeafId ?? {}).toEqual( + {} + ) + }) + + it('equalizes as the window saves an even split: no ratio', async () => { + const store = await storeWithSplitTab(['local']) + + await store.applyLayoutCommand({ type: 'equalizePanes', workspace: WORKTREE, tabId: TAB }) + + expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.root).toEqual({ + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts index 02bf656cd9d..fc4f46213ee 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-admission.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -18,6 +18,7 @@ import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace import { paneKeyOf } from '../../../shared/workspace-layout/workspace-layout-model' import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' import { patchStoredSession } from '../../../shared/workspace-layout/workspace-layout-record-patch' +import { followRemoteSessionAfterPaneClose } from './pane-close-remote-session' /** A new tab, or a split of an existing pane, with the ids its terminal will run under. */ export type TerminalPaneAdmission = @@ -64,15 +65,16 @@ export function applyCommandToSession( command: LayoutCommand ): PaneLayoutChange { const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) - return applyToSession(loaded, command, (after) => - patchStoredSession(session, saveWorkspaceLayout(loaded), after) - ) + return applyToSession(loaded, command, (after) => { + const patched = patchStoredSession(session, saveWorkspaceLayout(loaded), after) + return patched === session ? null : followRemoteSessionAfterPaneClose(command, session, patched) + }) } function applyToSession( loaded: LoadedWorkspaceLayout, command: LayoutCommand, - write: (after: WorkspaceSessionState) => WorkspaceSessionState = (after) => after + write: (after: WorkspaceSessionState) => WorkspaceSessionState | null = (after) => after ): PaneLayoutChange { const applied = applyLayoutCommand(loaded.layout, command, { mintId: randomUUID, diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 30cf447302d..af0388fb6c7 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -107,20 +107,18 @@ export function withdrawPane( ) } -/** A client's layout command (`layout.apply`), applied to the stored partition. */ +/** + * A client's layout command (`layout.apply`), applied to each partition holding its tab, routed + * partition first: that partition's outcome is the reply, and its refusal writes nothing. + */ export function applyLayout( - hostId: ExecutionHostId, + hostIds: readonly ExecutionHostId[], command: LayoutCommand, context: TerminalTopologyCommitContext ): () => DurableProfileStateMutation { return traced( 'apply_layout', - () => - commitPaneChange( - hostId, - applyCommandToSession(hostId, context.getSession(hostId), command), - context - ), + () => commitLayoutCommand(hostIds, command, context), (outcome) => (outcome.ok ? undefined : outcome.code) ) } @@ -260,6 +258,35 @@ function commitLeafMove( } } +function commitLayoutCommand( + hostIds: readonly ExecutionHostId[], + command: LayoutCommand, + context: TerminalTopologyCommitContext +): DurableProfileStateMutation { + const changes = hostIds.map((hostId) => ({ + hostId, + change: applyCommandToSession(hostId, context.getSession(hostId), command) + })) + const routed = changes[0]?.change.value + if (!routed) { + return { value: { ok: false, code: 'workspace_not_found' }, persist: false } + } + const writes = routed.ok + ? changes.filter(({ change }) => change.value.ok && change.session !== null) + : [] + if (writes.length === 0) { + return { value: routed, persist: false } + } + const restores = writes.map(({ hostId, change }) => + writeRestorable( + () => context.getSession(hostId), + (value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)), + keepFrozen(hostId, change.session!, context) + ) + ) + return { value: routed, rollback: () => restores.forEach((restore) => restore()) } +} + function commitPaneChange( hostId: ExecutionHostId, change: PaneLayoutChange, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3a2709da850..7639d6ca3d8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -26,11 +26,9 @@ import { import { findWorkspaceReferences } from './runtime-reference-find' import { createReferenceAgentIndex, referenceConnectionHosts } from './runtime-reference-agents' import type { RuntimeReferenceFindParams } from '../../shared/runtime-reference-contracts' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' -import type { - LayoutApplyReply, - LayoutCommand -} from '../../shared/workspace-layout/workspace-layout-command-types' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types' +import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply' import type { TerminalCreateOptions } from './runtime-terminal-contracts' // oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- SAFETY: The existing Linear installer supplies the canonical prototype surface. @@ -171,18 +169,13 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { return this.workspaceLayoutStream.subscribe(listener) } - /** A window's layout command (`layout.apply`), applied to the partition that holds the tab. */ - async applyLayoutCommand(command: LayoutCommand & { tabId: string }): Promise { - const hostId = this.getWorkspaceSessionHostIdForTab(command.workspace, command.tabId) - // Why: a paired server's workspaces keep today's mirrored layout until that server owns it. - if (parseExecutionHostId(hostId)?.kind === 'runtime') { - return { ok: false, code: 'host_not_owned' } - } - const store = this.requireStore() - if (!store.applyLayoutCommand) { - throw new Error('layout_store_unavailable') - } - return store.applyLayoutCommand(command, hostId) + /** A window's layout command (`layout.apply`), applied to the partitions that hold its tab. */ + applyLayoutCommand(command: WindowLayoutCommand): Promise { + return applyWindowLayoutCommand( + this.requireStore(), + (worktreeId, tabId) => this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId), + command + ) } /** This desktop's display-only copy of each paired server's layout (`layout.getRemoteSnapshots`). */ 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 2e5272f18c6..d46b40dac65 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -89,6 +89,23 @@ describe('layout.apply', () => { expect(applyLayoutCommand).toHaveBeenCalledWith(SPLIT) }) + it('takes no close semantics or blank title from the window', async () => { + const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply') + const pane = { workspace: WORKTREE, tabId: 'tab-1', leafId: 'leaf-1' } + + expect( + apply?.params?.parse({ + command: { type: 'closePane', ...pane, userPaneClose: true } + }) + ).toEqual({ command: { type: 'closePane', ...pane } }) + expect( + apply?.params?.safeParse({ command: { type: 'renamePane', ...pane, title: ' ' } }).success + ).toBe(false) + expect( + apply?.params?.parse({ command: { type: 'renamePane', ...pane, title: ' Build ' } }) + ).toEqual({ command: { type: 'renamePane', ...pane, title: 'Build' } }) + }) + it('applies to the partition that holds the tab, such as an SSH host’s', async () => { const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1') @@ -96,7 +113,7 @@ describe('layout.apply', () => { ok: true, result: { leafId: 'minted' } }) - expect(applyLayoutCommand).toHaveBeenCalledWith(SPLIT, 'ssh:target-1') + expect(applyLayoutCommand).toHaveBeenCalledWith(SPLIT, ['ssh:target-1']) }) it('refuses a paired server’s workspace, which keeps its mirrored layout', async () => { diff --git a/src/main/runtime/runtime-layout-apply.test.ts b/src/main/runtime/runtime-layout-apply.test.ts new file mode 100644 index 00000000000..7e649a137fd --- /dev/null +++ b/src/main/runtime/runtime-layout-apply.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { applyWindowLayoutCommand } from './runtime-layout-apply' +import type { RuntimeStore } from './runtime-store-contract' + +const mocks = vi.hoisted(() => ({ markAgentLaunchesClosedByUser: vi.fn() })) +vi.mock('../agent-launch/agent-launch-pane-attachment', () => ({ + markAgentLaunchesClosedByUser: mocks.markAgentLaunchesClosedByUser +})) + +const CLOSE = { type: 'closePane', workspace: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1' } as const + +function storeWith() { + const applyLayoutCommand = vi.fn(async () => ({ ok: true as const, result: {} })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the apply path reads only applyLayoutCommand. + const store = { applyLayoutCommand } as unknown as RuntimeStore + return { store, applyLayoutCommand } +} + +describe('applyWindowLayoutCommand', () => { + beforeEach(() => mocks.markAgentLaunchesClosedByUser.mockReset()) + + it('closes a pane as the user’s close, in every local or SSH holder', async () => { + const { store, applyLayoutCommand } = storeWith() + + await applyWindowLayoutCommand(store, () => ['local', 'runtime:env-1', 'ssh:target-1'], CLOSE) + + expect(applyLayoutCommand).toHaveBeenCalledWith({ ...CLOSE, userPaneClose: true }, [ + 'local', + 'ssh:target-1' + ]) + expect(mocks.markAgentLaunchesClosedByUser).toHaveBeenCalledWith('wt-1', { + kind: 'pane', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + }) + + it('applies other commands to the routed partition only', async () => { + const { store, applyLayoutCommand } = storeWith() + const rename = { ...CLOSE, type: 'renamePane', title: 'Pane' } as const + + await applyWindowLayoutCommand(store, () => ['local', 'ssh:target-1'], rename) + + expect(applyLayoutCommand).toHaveBeenCalledWith(rename, ['local']) + expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled() + }) + + it('refuses a tab a paired server routes, touching nothing', async () => { + const { store, applyLayoutCommand } = storeWith() + + await expect( + applyWindowLayoutCommand(store, () => ['runtime:env-1', 'local'], CLOSE) + ).resolves.toEqual({ ok: false, code: 'host_not_owned' }) + expect(applyLayoutCommand).not.toHaveBeenCalled() + expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts new file mode 100644 index 00000000000..8cb7db232a0 --- /dev/null +++ b/src/main/runtime/runtime-layout-apply.ts @@ -0,0 +1,40 @@ +import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import type { + LayoutApplyReply, + LayoutCommand +} from '../../shared/workspace-layout/workspace-layout-command-types' +import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' +import type { RuntimeStore } from './runtime-store-contract' + +/** A window's layout command: one on a tab, which names the partitions it applies to. */ +export type WindowLayoutCommand = LayoutCommand & { tabId: string } + +/** + * `layout.apply`: the window's command on the partitions holding its tab, routed partition first. + * A pane close is the user's: main marks it so, and the window cannot. + */ +export async function applyWindowLayoutCommand( + store: RuntimeStore, + hostIdsForTab: (worktreeId: string, tabId: string) => ExecutionHostId[], + command: WindowLayoutCommand +): Promise { + const hostIds = hostIdsForTab(command.workspace, command.tabId) + // Why: a paired server's workspaces keep today's mirrored layout until that server owns it. + if (!hostIds[0] || parseExecutionHostId(hostIds[0])?.kind === 'runtime') { + return { ok: false, code: 'host_not_owned' } + } + if (!store.applyLayoutCommand) { + throw new Error('layout_store_unavailable') + } + if (command.type !== 'closePane') { + return store.applyLayoutCommand(command, [hostIds[0]]) + } + const { workspace, tabId, leafId } = command + // A launch still starting in the pane the user closed stops, and says so (as the legacy close). + markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId }) + // Why every holder: a legacy relay reattach can leave one tab in two partitions. + return store.applyLayoutCommand( + { ...command, userPaneClose: true }, + hostIds.filter((hostId) => parseExecutionHostId(hostId)?.kind !== 'runtime') + ) +} diff --git a/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts b/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts new file mode 100644 index 00000000000..06626042c93 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { preparePendingSplitClose } from './pending-split-close-test-fixture' +import type { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' + +const mocks = vi.hoisted(() => ({ + layoutOwnerIsMain: false, + commitTerminalSurfaceClose: vi.fn(), + sendPaneCommandToLayoutOwner: vi.fn() +})) + +vi.mock('../../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return mocks.layoutOwnerIsMain + } +})) +vi.mock('./terminal-pane-layout-owner-commands', () => ({ + sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner +})) +vi.mock('@/store/terminals/terminal-surface-close-intent', () => ({ + commitTerminalSurfaceClose: mocks.commitTerminalSurfaceClose +})) + +const LEAF = '11111111-1111-4111-8111-111111111111' + +beforeEach(() => { + mocks.layoutOwnerIsMain = false + mocks.commitTerminalSurfaceClose.mockReset() + mocks.sendPaneCommandToLayoutOwner.mockReset() + mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true) +}) + +it('a window-owned build closes the pane at once and reports it to main', async () => { + const p = await preparePendingSplitClose() + + p.actions.executeClosePane(1) + + expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled() + expect(mocks.commitTerminalSurfaceClose).toHaveBeenCalledWith('workspace', { + kind: 'pane', + tabId: 'tab-parent', + leafId: LEAF + }) + expect(p.transports.has(1)).toBe(false) +}) + +it('a layout-owner build removes the pane only once main has closed it', async () => { + mocks.layoutOwnerIsMain = true + const p = await preparePendingSplitClose() + + p.actions.executeClosePane(1) + + expect(mocks.sendPaneCommandToLayoutOwner).toHaveBeenCalledWith( + 'workspace', + { type: 'closePane', tabId: 'tab-parent', leafId: LEAF }, + { apply: expect.any(Function), asToday: expect.any(Function), refused: expect.any(Function) } + ) + expect(p.transports.has(1)).toBe(true) + + mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].apply({}) + + expect(p.transports.has(1)).toBe(false) + expect(mocks.commitTerminalSurfaceClose).not.toHaveBeenCalled() +}) + +it('a layout-owner build closes as today when a paired server holds the tab', async () => { + mocks.layoutOwnerIsMain = true + const p = await preparePendingSplitClose() + + p.actions.executeClosePane(1) + mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].asToday() + + expect(mocks.commitTerminalSurfaceClose).toHaveBeenCalledOnce() + expect(p.transports.has(1)).toBe(false) +}) + +it('a layout-owner build closes the tab when main refuses a second close as the last pane', async () => { + mocks.layoutOwnerIsMain = true + const p = await preparePendingSplitClose() + p.actions.executeClosePane(1) + p.actions.executeClosePane(2) + const [first, second] = mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, , on]) => on) + + first?.apply({}) + second?.refused?.('last_pane') + + expect(p.controller.onCloseTab).toHaveBeenCalledOnce() +}) + +it('a layout-owner build closes the tab when main’s answer finds the pane alone', async () => { + mocks.layoutOwnerIsMain = true + const p = await preparePendingSplitClose() + p.actions.executeClosePane(1) + p.actions.executeClosePane(2) + const [first, second] = mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, , on]) => on) + + first?.apply({}) + second?.apply({}) + + expect(p.controller.onCloseTab).toHaveBeenCalledOnce() + expect(p.controller.managerRef.current?.getPanes()).toHaveLength(1) +}) + +it('a layout-owner build leaves an unmounted tab alone when main refuses its last pane', async () => { + mocks.layoutOwnerIsMain = true + const p = await preparePendingSplitClose() + p.actions.executeClosePane(1) + p.controller.managerRef.current = null + + mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].refused?.('last_pane') + + expect(p.controller.onCloseTab).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts index b6d1244210f..8981d60f1d3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts @@ -9,6 +9,7 @@ import { syncTerminalScrollIntentFromViewport } from '@/lib/pane-manager/terminal-scroll-intent' import type { resolveTerminalKeyboardShortcutAction } from './terminal-keyboard-shortcut-matching' +import { equalizeTerminalPanes } from './terminal-pane-layout-owner-commands' type TerminalShortcutAction = NonNullable> @@ -164,8 +165,9 @@ export function dispatchTerminalShortcutAction( if (expandedPaneIdRef.current !== null) { return } - manager.equalizePaneSizes() - ;(manager.getActivePane() ?? manager.getPanes()[0])?.terminal.focus() + equalizeTerminalPanes({ worktreeId, tabId, getManager: () => managerRef.current }, (current) => + (current.getActivePane() ?? current.getPanes()[0])?.terminal.focus() + ) return } if (action.type === 'toggleExpandActivePane') { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx index 10f1c3ec71f..6940f1297e6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx @@ -2,10 +2,24 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useTerminalPaneLayoutPersistence } from './use-terminal-pane-layout-persistence' +import type { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' const LEFT = '11111111-1111-4111-8111-111111111111' const RIGHT = '22222222-2222-4222-8222-222222222222' +const mocks = vi.hoisted(() => ({ + layoutOwnerIsMain: false, + sendPaneCommandToLayoutOwner: vi.fn() +})) + +vi.mock('../../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return mocks.layoutOwnerIsMain + } +})) +vi.mock('./terminal-pane-layout-owner-commands', () => ({ + sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner +})) vi.mock('../../store', () => ({ useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) } })) @@ -69,7 +83,11 @@ function renderPersistence() { return { hook, push, setTabLayout, pushedIntents, panes } } -afterEach(cleanup) +afterEach(() => { + cleanup() + mocks.layoutOwnerIsMain = false + mocks.sendPaneCommandToLayoutOwner.mockReset() +}) describe('terminal layout gesture persist', () => { it('saves the same layout for a gesture and an automatic persist and marks only the push', () => { @@ -109,4 +127,35 @@ describe('terminal layout gesture persist', () => { expect(pushedIntents()).toEqual(['gesture', 'gesture']) }) + + it('a window-owned build saves a renamed pane at once, without asking main', () => { + const { hook, pushedIntents, push } = renderPersistence() + push.mockClear() + + hook.result.current.changePaneTitle(2, 'server') + + expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled() + expect(pushedIntents()).toEqual(['gesture']) + }) + + it('a layout-owner build shows a title change only once main has saved it', () => { + mocks.layoutOwnerIsMain = true + mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true) + const { hook, pushedIntents, push } = renderPersistence() + push.mockClear() + + hook.result.current.changePaneTitle(2, 'server') + hook.result.current.removePaneTitle(1) + + expect(mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, command]) => command)).toEqual([ + { type: 'renamePane', tabId: 'tab', leafId: RIGHT, title: 'server' }, + { type: 'renamePane', tabId: 'tab', leafId: LEFT, title: null } + ]) + expect(pushedIntents()).toEqual([]) + + for (const [, , handlers] of mocks.sendPaneCommandToLayoutOwner.mock.calls) { + handlers.apply({}) + } + expect(pushedIntents()).toEqual(['gesture', 'gesture']) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts new file mode 100644 index 00000000000..b3f1d98cc12 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + equalizeTerminalPanes, + sendPaneCommandToLayoutOwner +} from './terminal-pane-layout-owner-commands' + +const mocks = vi.hoisted( + (): { + callRuntimeRpc: ReturnType + ownerEnvironmentId: string | null + layoutOwnerIsMain: boolean + } => ({ + callRuntimeRpc: vi.fn(), + ownerEnvironmentId: null, + layoutOwnerIsMain: false + }) +) + +vi.mock('../../../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return mocks.layoutOwnerIsMain + } +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExplicitRuntimeEnvironmentIdForWorktree: () => mocks.ownerEnvironmentId +})) + +const SPLIT = { + type: 'splitPane', + tabId: 'tab-1', + leafId: 'leaf-1', + direction: 'vertical' +} as const + +function handlers() { + return { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn() } +} + +describe('sendPaneCommandToLayoutOwner', () => { + beforeEach(() => { + mocks.callRuntimeRpc.mockReset() + mocks.ownerEnvironmentId = null + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('asks local main first, then applies main’s result', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } }) + const on = handlers() + + expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', { + command: { ...SPLIT, workspace: 'worktree-1' } + }) + expect(on.apply).not.toHaveBeenCalled() + await vi.waitFor(() => expect(on.apply).toHaveBeenCalledWith({ leafId: 'leaf-minted' })) + expect(on.asToday).not.toHaveBeenCalled() + }) + + it.each([ + ['a refusal', () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'last_pane' })], + ['a failed call', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))] + ])('changes nothing after %s', async (_case, arrange) => { + arrange() + const on = handlers() + + expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true) + + await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) + expect(on.apply).not.toHaveBeenCalled() + expect(on.asToday).not.toHaveBeenCalled() + }) + + it('hands any other refusal to the caller', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'last_pane' }) + const on = handlers() + + sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on) + + await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('last_pane')) + }) + + it('acts as today when main says a paired server holds the tab', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' }) + const on = handlers() + + expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true) + + await vi.waitFor(() => expect(on.asToday).toHaveBeenCalled()) + expect(on.apply).not.toHaveBeenCalled() + }) + + it('leaves a paired server’s workspace to today’s path', () => { + mocks.ownerEnvironmentId = 'env-1' + + expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, handlers())).toBe(false) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) +}) + +describe('equalizeTerminalPanes', () => { + beforeEach(() => { + mocks.callRuntimeRpc.mockReset() + mocks.ownerEnvironmentId = null + mocks.layoutOwnerIsMain = false + }) + + function manager() { + const equalizePaneSizes = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: equalize calls only equalizePaneSizes. + return { equalizePaneSizes } as unknown as PaneManager & { + equalizePaneSizes: typeof equalizePaneSizes + } + } + + it('a window-owned build equalizes at once, without asking main', () => { + const current = manager() + const then = vi.fn() + + equalizeTerminalPanes( + { worktreeId: 'worktree-1', tabId: 'tab-1', getManager: () => current }, + then + ) + + expect(current.equalizePaneSizes).toHaveBeenCalledOnce() + expect(then).toHaveBeenCalledWith(current) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('a layout-owner build equalizes once main has', async () => { + mocks.layoutOwnerIsMain = true + mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: {} }) + const current = manager() + + equalizeTerminalPanes( + { worktreeId: 'worktree-1', tabId: 'tab-1', getManager: () => current }, + vi.fn() + ) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', { + command: { type: 'equalizePanes', tabId: 'tab-1', workspace: 'worktree-1' } + }) + expect(current.equalizePaneSizes).not.toHaveBeenCalled() + await vi.waitFor(() => expect(current.equalizePaneSizes).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts new file mode 100644 index 00000000000..bc732834e9a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts @@ -0,0 +1,82 @@ +import { useAppStore } from '@/store' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type { + LayoutApplyReply, + LayoutCommandResult, + LayoutRefusalCode +} from '../../../../shared/workspace-layout/workspace-layout-command-types' + +/** The pane commands a window sends `layout.apply`; main mints any new id. */ +export type PaneLayoutCommand = + | { type: 'splitPane'; tabId: string; leafId: string; direction: 'vertical' | 'horizontal' } + | { type: 'closePane'; tabId: string; leafId: string } + | { type: 'renamePane'; tabId: string; leafId: string; title: string | null } + | { type: 'equalizePanes'; tabId: string } + +/** + * A pane command in a layout-owner build: main applies it first, then `apply` shows main's result. + * `asToday` runs instead when main says a paired server holds the tab (#18202); `refused` on any + * other refusal. False when a paired server owns the workspace's layout; the caller acts as today. + */ +export function sendPaneCommandToLayoutOwner( + worktreeId: string, + command: PaneLayoutCommand, + handlers: { + apply: (result: LayoutCommandResult) => void + asToday: () => void + refused?: (code: LayoutRefusalCode) => void + } +): boolean { + // Why: the same owner test that mirrors a workspace's tabs from a paired server; a focused + // server must not claim a workspace whose row names no owner. + if (getExplicitRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)) { + return false + } + void callRuntimeRpc({ kind: 'local' }, 'layout.apply', { + command: { ...command, workspace: worktreeId } + }) + .then((reply) => { + if (reply.ok) { + handlers.apply(reply.result) + } else if (reply.code === 'host_not_owned') { + handlers.asToday() + } else { + console.warn(`[layout-owner] ${command.type} refused:`, reply.code) + handlers.refused?.(reply.code) + } + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : error + console.warn(`[layout-owner] ${command.type} failed:`, message) + }) + return true +} + +/** Equalizes a tab's panes, then `then` runs (focus); in a layout-owner build main equalizes first. */ +export function equalizeTerminalPanes( + pane: { worktreeId: string; tabId: string; getManager: () => PaneManager | null }, + then: (manager: PaneManager) => void +): void { + const equalize = (): void => { + const manager = pane.getManager() + if (manager) { + manager.equalizePaneSizes() + then(manager) + } + } + // Switch point: main equalizes the tab first; the window then redraws it. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + sendPaneCommandToLayoutOwner( + pane.worktreeId, + { type: 'equalizePanes', tabId: pane.tabId }, + { apply: equalize, asToday: equalize } + ) + ) { + return + } + equalize() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts deleted file mode 100644 index f56dca214ed..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { splitThroughLayoutOwner } from './terminal-pane-split-through-layout-owner' - -const mocks = vi.hoisted( - (): { callRuntimeRpc: ReturnType; ownerEnvironmentId: string | null } => ({ - callRuntimeRpc: vi.fn(), - ownerEnvironmentId: null - }) -) - -vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) -vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) -vi.mock('@/lib/worktree-runtime-owner', () => ({ - getExplicitRuntimeEnvironmentIdForWorktree: () => mocks.ownerEnvironmentId -})) - -const SPLIT = { - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: 'leaf-1', - direction: 'vertical' -} as const - -describe('splitThroughLayoutOwner', () => { - beforeEach(() => { - mocks.callRuntimeRpc.mockReset() - mocks.ownerEnvironmentId = null - vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - it('asks local main for the split and mounts the pane id it minted', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } }) - const mount = vi.fn() - - expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true) - - expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', { - command: { - type: 'splitPane', - workspace: 'worktree-1', - tabId: 'tab-1', - leafId: 'leaf-1', - direction: 'vertical' - } - }) - await vi.waitFor(() => expect(mount).toHaveBeenCalledWith('leaf-minted')) - }) - - it.each([ - [ - 'a refusal', - () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' }) - ], - ['a failed call', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))] - ])('mounts nothing after %s', async (_case, arrange) => { - arrange() - const mount = vi.fn() - - expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true) - - await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) - expect(mount).not.toHaveBeenCalled() - }) - - it('splits as today when main says a paired server holds the tab', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' }) - const mount = vi.fn() - - expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true) - - await vi.waitFor(() => expect(mount).toHaveBeenCalledWith(undefined)) - }) - - it('leaves a paired server’s workspace to today’s split', () => { - mocks.ownerEnvironmentId = 'env-1' - - expect(splitThroughLayoutOwner(SPLIT, vi.fn())).toBe(false) - expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts deleted file mode 100644 index 41e9c8ea502..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useAppStore } from '@/store' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types' - -/** - * A split in a layout-owner build: main writes it and mints the new pane's id, then `mount` shows - * that pane (no id: split as today). False when a paired server owns the workspace's layout; the - * caller splits as today. - */ -export function splitThroughLayoutOwner( - split: { - worktreeId: string - tabId: string - leafId: string - direction: 'vertical' | 'horizontal' - }, - mount: (leafId: string | undefined) => void -): boolean { - // Why: the same owner test that mirrors a workspace's tabs from a paired server; a focused - // server must not claim a workspace whose row names no owner. - if (getExplicitRuntimeEnvironmentIdForWorktree(useAppStore.getState(), split.worktreeId)) { - return false - } - const { worktreeId: workspace, ...pane } = split - void callRuntimeRpc({ kind: 'local' }, 'layout.apply', { - command: { type: 'splitPane', workspace, ...pane } - }) - .then((reply) => { - if (reply.ok && reply.result.leafId) { - mount(reply.result.leafId) - } else if (!reply.ok && reply.code === 'host_not_owned') { - // A tab a paired server holds inside a local workspace (#18202): split it as today. - mount(undefined) - } else { - console.warn('[layout-owner] split refused:', reply.ok ? 'no_leaf' : reply.code) - } - }) - .catch((error: unknown) => { - console.warn('[layout-owner] split failed:', error instanceof Error ? error.message : error) - }) - return true -} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts index 9b179450563..a2a0b21b876 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts @@ -9,8 +9,14 @@ const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: false, recordCreatedTerminalPaneSplit: vi.fn(), resolveSplitCwd: vi.fn(), - splitThroughLayoutOwner: - vi.fn<(split: unknown, mount: (leafId: string | undefined) => void) => boolean>(), + sendPaneCommandToLayoutOwner: + vi.fn< + ( + worktreeId: string, + command: unknown, + handlers: { apply: (result: { leafId?: string }) => void; asToday: () => void } + ) => boolean + >(), splitWebRuntimeTerminal: vi.fn() })) @@ -20,8 +26,8 @@ vi.mock('../../../../shared/layout-owner-switch', () => ({ } })) -vi.mock('./terminal-pane-split-through-layout-owner', () => ({ - splitThroughLayoutOwner: mocks.splitThroughLayoutOwner +vi.mock('./terminal-pane-layout-owner-commands', () => ({ + sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner })) vi.mock('@/runtime/web-runtime-terminal-actions', () => ({ @@ -43,7 +49,7 @@ function makeManager(splitPane: ReturnType): PaneManager { describe('splitTerminalPaneWithInheritedCwd', () => { beforeEach(() => { mocks.layoutOwnerIsMain = false - mocks.splitThroughLayoutOwner.mockReset() + mocks.sendPaneCommandToLayoutOwner.mockReset() mocks.recordCreatedTerminalPaneSplit.mockReset() mocks.resolveSplitCwd.mockReset() mocks.splitWebRuntimeTerminal.mockReset() @@ -240,13 +246,13 @@ describe('splitTerminalPaneWithInheritedCwd', () => { source: 'keyboard' }) - expect(mocks.splitThroughLayoutOwner).not.toHaveBeenCalled() + expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled() expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached' }) }) it('in a layout-owner build, mounts the pane main minted only once main answers', () => { mocks.layoutOwnerIsMain = true - mocks.splitThroughLayoutOwner.mockReturnValue(true) + mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true) const createdPane = { id: 2 } const splitPane = vi.fn(() => createdPane) @@ -262,13 +268,14 @@ describe('splitTerminalPaneWithInheritedCwd', () => { source: 'keyboard' }) - expect(mocks.splitThroughLayoutOwner).toHaveBeenCalledWith( - { worktreeId: 'worktree-1', tabId: 'tab-1', leafId: 'leaf-1', direction: 'vertical' }, - expect.any(Function) + expect(mocks.sendPaneCommandToLayoutOwner).toHaveBeenCalledWith( + 'worktree-1', + { type: 'splitPane', tabId: 'tab-1', leafId: 'leaf-1', direction: 'vertical' }, + { apply: expect.any(Function), asToday: expect.any(Function) } ) expect(splitPane).not.toHaveBeenCalled() - mocks.splitThroughLayoutOwner.mock.calls[0]?.[1]('leaf-minted') + mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].apply({ leafId: 'leaf-minted' }) expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { leafId: 'leaf-minted', @@ -282,7 +289,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => { it('in a layout-owner build, splits as today where a paired server owns the layout', () => { mocks.layoutOwnerIsMain = true - mocks.splitThroughLayoutOwner.mockReturnValue(false) + mocks.sendPaneCommandToLayoutOwner.mockReturnValue(false) const splitPane = vi.fn(() => ({ id: 2 })) splitTerminalPaneWithInheritedCwd({ diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts index 16c179c91ca..198b115dc66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts @@ -5,7 +5,7 @@ import type { PtyTransport } from './pty-transport-types' import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { splitThroughLayoutOwner } from './terminal-pane-split-through-layout-owner' +import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' type SplitArgs = { worktreeId: string @@ -34,14 +34,13 @@ export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void { // Switch point: main writes the split and mints the new pane's id; the window then mounts it. if ( ORCA_LAYOUT_OWNER_IS_MAIN && - splitThroughLayoutOwner( + sendPaneCommandToLayoutOwner( + args.worktreeId, + { type: 'splitPane', tabId: args.tabId, leafId: args.pane.leafId, direction: args.direction }, { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.pane.leafId, - direction: args.direction - }, - (leafId) => splitLocally(args, ptyId, leafId) + apply: ({ leafId }) => leafId && splitLocally(args, ptyId, leafId), + asToday: () => splitLocally(args, ptyId) + } ) ) { return diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index b3acc4ec6e3..bc0a493a1e9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -19,6 +19,8 @@ import { retireUnboundIpcTerminalPane } from './retire-unbound-ipc-terminal-pane import { capturePendingTerminalPaneClose } from './terminal-pane-close-admission' import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent' import { noteAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' export function useTerminalPaneCloseActions(controller: TerminalPaneBindingController) { const confirmedCloseRef = useRef<(() => void) | null>(null) @@ -38,53 +40,58 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr updateSettings, worktreeId } = controller - const executeClosePane = useCallback( - (paneId: number) => { + // The window's half of a split pane's close: its transport, status and pane go. + const closePaneLocally = useCallback( + (paneId: number, committedLeafId?: string) => { const manager = managerRef.current - if (!manager) { + // Why the pane check: main's answer can land after the pane already went. + if (!manager || (committedLeafId && manager.getLeafId(paneId) !== committedLeafId)) { return } - if (manager.getPanes().length <= 1) { + // A sibling went while main answered: this pane is now the tab's last, so the tab closes. + if (committedLeafId && manager.getPanes().length <= 1) { onCloseTab() - } else { - const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null - closeWebRuntimeTerminal(ptyId) - clearSessionRestoredBannerForPane(paneId) - const leafId = manager.getLeafId(paneId) - if (leafId) { - const launchPane = useAppStore - .getState() - .tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.agentLaunchPane - if (launchPane?.leafId === leafId && !launchPane.outcome) { - // The user closed a launch pane whose agent is still starting; main stops that launch. - noteAgentLaunchPaneClosedByUser(tabId, leafId) - } - commitTerminalSurfaceClose(worktreeId, { kind: 'pane', tabId, leafId }) - retireUnboundIpcTerminalPane({ - getState: useAppStore.getState, - tabId, - leafId, - transport: paneTransportsRef.current.get(paneId), - getTransports: () => paneTransportsRef.current - }) - useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) - useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true }) - } - setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId)) - if (leafId) { - retireUnboundRuntimeTerminalPane({ - getState: useAppStore.getState, - tabId, - leafId, - transport: paneTransportsRef.current.get(paneId), - getTransports: () => paneTransportsRef.current - }) - syncPanePtyLayoutBindingForLeaf?.(leafId, null, paneId) - } else { - syncPanePtyLayoutBinding(paneId, null) - } - manager.closePane(paneId) + return } + const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null + closeWebRuntimeTerminal(ptyId) + clearSessionRestoredBannerForPane(paneId) + const leafId = manager.getLeafId(paneId) + if (leafId) { + const launchPane = useAppStore + .getState() + .tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.agentLaunchPane + if (launchPane?.leafId === leafId && !launchPane.outcome) { + // The user closed a launch pane whose agent is still starting; main stops that launch. + noteAgentLaunchPaneClosedByUser(tabId, leafId) + } + if (!committedLeafId) { + commitTerminalSurfaceClose(worktreeId, { kind: 'pane', tabId, leafId }) + } + retireUnboundIpcTerminalPane({ + getState: useAppStore.getState, + tabId, + leafId, + transport: paneTransportsRef.current.get(paneId), + getTransports: () => paneTransportsRef.current + }) + useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) + useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true }) + } + setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId)) + if (leafId) { + retireUnboundRuntimeTerminalPane({ + getState: useAppStore.getState, + tabId, + leafId, + transport: paneTransportsRef.current.get(paneId), + getTransports: () => paneTransportsRef.current + }) + syncPanePtyLayoutBindingForLeaf?.(leafId, null, paneId) + } else { + syncPanePtyLayoutBinding(paneId, null) + } + manager.closePane(paneId) }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. [ @@ -96,6 +103,40 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr worktreeId ] ) + const executeClosePane = useCallback( + (paneId: number) => { + const manager = managerRef.current + if (!manager) { + return + } + if (manager.getPanes().length <= 1) { + onCloseTab() + return + } + const leafId = manager.getLeafId(paneId) + // Switch point: main closes the pane first; the window then removes it. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + leafId && + sendPaneCommandToLayoutOwner( + worktreeId, + { type: 'closePane', tabId, leafId }, + { + apply: () => closePaneLocally(paneId, leafId), + asToday: () => closePaneLocally(paneId), + // An earlier close in flight took the sibling: once it lands here too, the tab closes. + refused: (code) => + code === 'last_pane' && managerRef.current?.getPanes().length === 1 && onCloseTab() + } + ) + ) { + return + } + closePaneLocally(paneId) + }, + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. + [closePaneLocally, onCloseTab, tabId, worktreeId] + ) const getCloseDialogCopyKind = useCallback( (paneId: number) => resolveLeafCloseCopyKind(tabId, managerRef.current?.getLeafId(paneId)), // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts index b800c4c3b80..b70e0f0ec75 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts @@ -23,12 +23,11 @@ export function useTerminalPaneContextActions(controller: TerminalPaneCloseContr paneCwdRef, paneTitlesRef, paneTransportsRef, - persistLayoutSnapshot, quickCommandGroupId, quickCommandRepoId, projectHostSetupProjection, removePaneTitle, - removedTitleLeafIdsRef, + changePaneTitle, renameBlurCommitEnabledRef, renameEnableBlurFrameRef, renameFocusFrameRef, @@ -42,7 +41,6 @@ export function useTerminalPaneContextActions(controller: TerminalPaneCloseContr rightClickToPaste, setAgentSessionContinuation, setAgentSessionFork, - setPaneTitles, setRenamingPaneId, setTerminalError, tabId, @@ -64,17 +62,11 @@ export function useTerminalPaneContextActions(controller: TerminalPaneCloseContr setRenamingPaneId(null) return } - setPaneTitles((previous) => ({ ...previous, [renamingPaneId]: trimmed })) - paneTitlesRef.current = { ...paneTitlesRef.current, [renamingPaneId]: trimmed } - const leafId = managerRef.current?.getPanes().find((pane) => pane.id === renamingPaneId)?.leafId - if (leafId) { - removedTitleLeafIdsRef.current.delete(leafId) - } closeRenameSession() setRenamingPaneId(null) - persistLayoutSnapshot('gesture') + changePaneTitle(renamingPaneId, trimmed) // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - }, [closeRenameSession, renamingPaneId, renameValue, removePaneTitle, persistLayoutSnapshot]) + }, [closeRenameSession, renamingPaneId, renameValue, removePaneTitle, changePaneTitle]) const handleRenameCancel = useCallback(() => { renameSubmittedRef.current = true closeRenameSession() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 7d086992e21..3289186e9b8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -27,6 +27,7 @@ import { useAppStore } from '@/store' import { makePaneKey } from '../../../../shared/stable-pane-id' import { resolvePaneAgentSessionId } from './pane-agent-session-id' import { resetTerminalInputModes } from './terminal-input-mode-reset' +import { equalizeTerminalPanes } from './terminal-pane-layout-owner-commands' type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject @@ -196,8 +197,9 @@ export function useTerminalPaneContextMenu({ if (!pane || !manager) { return } - manager.equalizePaneSizes() - pane.terminal.focus() + equalizeTerminalPanes({ worktreeId, tabId, getManager: () => managerRef.current }, () => + pane.terminal.focus() + ) } const onClosePane = (): void => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index cf9182128fa..982bfe66e70 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -13,6 +13,8 @@ import { } from '@/lib/terminal-pane-title-sanitization' import type { TerminalPaneStartupController } from './use-terminal-pane-startup-actions' import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands' export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartupController) { const { @@ -157,7 +159,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup [paneTransportsRef, persistLayoutSnapshot] ) - const removePaneTitle = useCallback( + const removePaneTitleLocally = useCallback( (paneId: number) => { setPaneTitles((previous) => { if (!(paneId in previous)) { @@ -182,6 +184,46 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. [persistLayoutSnapshot] ) + const setPaneTitleLocally = useCallback( + (paneId: number, title: string) => { + setPaneTitles((previous) => ({ ...previous, [paneId]: title })) + paneTitlesRef.current = { ...paneTitlesRef.current, [paneId]: title } + const leafId = managerRef.current?.getPanes().find((pane) => pane.id === paneId)?.leafId + if (leafId) { + removedTitleLeafIdsRef.current.delete(leafId) + } + persistLayoutSnapshot('gesture') + }, + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Refs are stable. + [persistLayoutSnapshot] + ) + /** A user's title for a pane, trimmed and non-empty; null clears it. */ + const changePaneTitle = useCallback( + (paneId: number, title: string | null) => { + const showTitle = (): void => + title === null ? removePaneTitleLocally(paneId) : setPaneTitleLocally(paneId, title) + const leafId = managerRef.current?.getPanes().find((pane) => pane.id === paneId)?.leafId + // Switch point: main renames the pane first; the window then shows the title. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + leafId && + sendPaneCommandToLayoutOwner( + worktreeId, + { type: 'renamePane', tabId, leafId, title }, + { apply: showTitle, asToday: showTitle } + ) + ) { + return + } + showTitle() + }, + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Refs are stable. + [removePaneTitleLocally, setPaneTitleLocally, tabId, worktreeId] + ) + const removePaneTitle = useCallback( + (paneId: number) => changePaneTitle(paneId, null), + [changePaneTitle] + ) const handleClearPaneTitleShortcut = useCallback( (paneId: number) => { if (!paneTitlesRef.current[paneId]) { @@ -238,6 +280,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup return { persistLayoutSnapshot, clearPaneScrollback, + changePaneTitle, removePaneTitle, handleClearPaneTitleShortcut } diff --git a/src/renderer/src/lib/pane-manager/pane-split-ratio.ts b/src/renderer/src/lib/pane-manager/pane-split-ratio.ts index fb8bf9b6cf6..8fe032316b3 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-ratio.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-ratio.ts @@ -1,5 +1,5 @@ // Why: serialization drops ratios this close to 0.5, so an apply within it must not write either. -export const SPLIT_RATIO_TOLERANCE = 0.005 +export { SPLIT_RATIO_TOLERANCE } from '../../../../shared/workspace-layout/terminal-pane-tree' /** First child's share of a split, read from the two children's flex-grow. */ export function readSplitRatio(first: HTMLElement, second: HTMLElement): number { diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 947554e6e0a..28a87083bc2 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -11,18 +11,43 @@ export const LayoutUnsubscribeParams = z.object({ subscriptionId: z.string().min(1, 'Missing subscriptionId') }) -const SplitPaneCommand = z.object({ - type: z.literal('splitPane'), +const OnPane = { workspace: z.string().min(1), tabId: z.string().min(1), - leafId: z.string().min(1), + leafId: z.string().min(1) +} + +const SplitPaneCommand = z.object({ + type: z.literal('splitPane'), + ...OnPane, direction: z.enum(['horizontal', 'vertical']) }) +/** Main marks a close as the user's; the window cannot send that mark. */ +const ClosePaneCommand = z.object({ type: z.literal('closePane'), ...OnPane }) + +/** A title is trimmed and non-empty; null clears it. */ +const RenamePaneCommand = z.object({ + type: z.literal('renamePane'), + ...OnPane, + title: z.union([z.string().trim().min(1), z.null()]) +}) + +const EqualizePanesCommand = z.object({ + type: z.literal('equalizePanes'), + workspace: OnPane.workspace, + tabId: OnPane.tabId +}) + /** * A window's layout command (`layout.apply`, registered only in a layout-owner build). The window * names what it acts on; main mints every new id, so no new id is accepted here. */ export const LayoutApplyParams = z.object({ - command: z.discriminatedUnion('type', [SplitPaneCommand]) + command: z.discriminatedUnion('type', [ + SplitPaneCommand, + ClosePaneCommand, + RenamePaneCommand, + EqualizePanesCommand + ]) }) diff --git a/src/shared/workspace-layout/terminal-pane-tree.ts b/src/shared/workspace-layout/terminal-pane-tree.ts index 98deb286a9f..dc42eb0154e 100644 --- a/src/shared/workspace-layout/terminal-pane-tree.ts +++ b/src/shared/workspace-layout/terminal-pane-tree.ts @@ -120,6 +120,9 @@ function equalizeWeight( : 1 } +/** A ratio this close to an even split is saved as none, as the desktop window saves it. */ +export const SPLIT_RATIO_TOLERANCE = 0.005 + /** Same-axis panes get equal shares, so three side by side become thirds, as the window does. */ export function equalizeLayout(node: TerminalPaneLayoutNode): TerminalPaneLayoutNode { if (node.type === 'leaf') { @@ -127,9 +130,13 @@ export function equalizeLayout(node: TerminalPaneLayoutNode): TerminalPaneLayout } const first = equalizeWeight(node.first, node.direction) const second = equalizeWeight(node.second, node.direction) + const { ratio: _ratio, ...split } = node + const ratio = first / (first + second) return { - ...node, - ratio: first / (first + second), + ...split, + ...(Math.abs(ratio - 0.5) > SPLIT_RATIO_TOLERANCE + ? { ratio: Math.round(ratio * 1000) / 1000 } + : {}), first: equalizeLayout(node.first), second: equalizeLayout(node.second) } diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 4498f319315..a9461bc09d4 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -40,7 +40,16 @@ export type LayoutCommand = /** The new pane's id, when the runtime minted it before applying. */ newLeafId?: string }) - | (On & { type: 'closePane'; tabId: string; leafId: string }) + | (On & { + type: 'closePane' + tabId: string + leafId: string + /** + * A user's close of one pane of a split, as the desktop's pane close: the tab's last pane is + * refused (closing it is a tab close). + */ + userPaneClose?: true + }) | (On & { type: 'closeTabs' tabIds: string[] diff --git a/src/shared/workspace-layout/workspace-layout-pane-commands.ts b/src/shared/workspace-layout/workspace-layout-pane-commands.ts index 1f7002cca71..434bfd29b06 100644 --- a/src/shared/workspace-layout/workspace-layout-pane-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-pane-commands.ts @@ -63,7 +63,11 @@ export function closePane(model: WorkspaceLayoutModel, command: CommandOf<'close if (!tab || !layoutContainsLeafId(tab.panes.root, command.leafId)) { return applied(model, { alreadyClosed: true }) } - const ptyId = model.workspaces[command.workspace]!.leaves?.[command.leafId]?.ptyId + const leaf = model.workspaces[command.workspace]!.leaves?.[command.leafId] + if (command.userPaneClose && tab.panes.root?.type === 'leaf') { + return refuse('last_pane') + } + const ptyId = leaf?.ptyId const retired = retireTerminalPane( model, { workspaceKey: command.workspace, tab }, diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.test.ts b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts index 036776531ac..4467c3dd5bb 100644 --- a/src/shared/workspace-layout/workspace-layout-record-patch.test.ts +++ b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts @@ -55,8 +55,24 @@ function commandsFor(model: WorkspaceLayoutModel): LayoutCommand[] { direction: 'vertical' }) } + if (first) { + commands.push({ + type: 'renamePane', + workspace, + tabId: tab.id, + leafId: first, + title: 'Pane' + }) + } if (second) { commands.push({ type: 'closePane', workspace, tabId: tab.id, leafId: second }) + commands.push({ + type: 'closePane', + workspace, + tabId: tab.id, + leafId: second, + userPaneClose: true + }) commands.push({ type: 'equalizePanes', workspace, tabId: tab.id }) } } diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.ts b/src/shared/workspace-layout/workspace-layout-record-patch.ts index c9afdce83ca..61934a7f50d 100644 --- a/src/shared/workspace-layout/workspace-layout-record-patch.ts +++ b/src/shared/workspace-layout/workspace-layout-record-patch.ts @@ -23,7 +23,7 @@ const MAP_RECORD_DEPTH = 2 /** * `stored` with the records the command touched (those differing between `before` and `after`) * written as `after` has them. A touched record the load supplied is written whole; every - * untouched record stays as stored. + * untouched record stays as stored. `stored` itself when the command changed nothing. */ export function patchStoredSession( stored: WorkspaceSessionState, @@ -33,8 +33,12 @@ export function patchStoredSession( const storedFields = toJson(stored) const beforeFields = toJson(before) const afterFields = toJson(after) + const changed = changedKeys(beforeFields, afterFields) + if (changed.length === 0) { + return stored + } const patched: Json = { ...storedFields } - for (const field of changedKeys(beforeFields, afterFields)) { + for (const field of changed) { const idOf = LIST_FIELDS[field] // The command dropped the field: stored records the load never read go with it. if (afterFields[field] === undefined) {