mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
Close, rename and equalize panes through main (dark, build switch off by default) (#29187)
* feat(layout): add the dark layout.apply channel and route pane splits through main behind ORCA_LAYOUT_OWNER A build made with ORCA_LAYOUT_OWNER=main registers layout.apply: main applies the window's layout command to the stored partition through the shared applyLayoutCommand, minting new ids. A user split then waits for main's answer and mounts the pane id main minted. Every other build substitutes 'window', so neither branch is compiled in. * fix(layout): write only the records a layout.apply command touched A load-then-save rewrite also normalizes records the command never touched. The command's effect is now the diff between the save before and after it, written onto the stored partition record by record. A split whose main reply says a paired server holds the tab splits as today. * fix(layout): write a load-supplied record whole and check that patched partitions reload to the command's result * feat(layout): close, rename and equalize panes through main behind ORCA_LAYOUT_OWNER In a layout-owner build the window's pane close, title change and equalize go through layout.apply and the window shows main's answer. A window pane close keeps the legacy close's rules: the tab's last pane is refused, a pane restarted since main read the close is left alone, every partition holding the tab closes it, and the tab's remote session id follows the remaining pane. The rules sit behind a command field only layout.apply sets. * fix(layout): close the tab when a racing pane close leaves the window one pane * fix(layout): leave an unmounted tab alone when main refuses its last pane * fix(layout): close a user's pane even if its terminal restarted, as today Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * test(layout): pin the restarted-pane close directly, not through a made-up profile state Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125 * fix(layout): leave a split to today's path only when the workspace's row names a paired server The split switch point asked which host to route to with a helper that falls back to the focused server, so a focused server could claim a workspace whose row names no owner. Use the same owner test the tab mirror uses. * test(layout): type the split owner mock without a cast * test(layout): type the layout-owner command mocks with the real signature * fix(layout): finish the main merge with this branch's versions of the layout-owner files
This commit is contained in:
@@ -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<LayoutApplyOutcome> {
|
||||
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
|
||||
applyLayout(resolveHostId(hostId), command, this.topology())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function writePtyBinding(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LayoutApplyOutcome> {
|
||||
const context = this[layoutCommandPersistenceContext]
|
||||
const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()]
|
||||
return context.runtime.runDurableMutation(
|
||||
applyLayout(resolved, command, topologyCommitContext(context))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof diffStoredSession>[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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutApplyOutcome> {
|
||||
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<LayoutApplyOutcome> {
|
||||
const applied = applyLayoutCommand(loaded.layout, command, {
|
||||
mintId: randomUUID,
|
||||
|
||||
@@ -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<LayoutApplyOutcome> {
|
||||
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<LayoutApplyOutcome> {
|
||||
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<T>(
|
||||
hostId: ExecutionHostId,
|
||||
change: PaneLayoutChange<T>,
|
||||
|
||||
@@ -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<LayoutApplyReply> {
|
||||
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<LayoutApplyReply> {
|
||||
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`). */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutApplyReply> {
|
||||
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')
|
||||
)
|
||||
}
|
||||
@@ -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<typeof sendPaneCommandToLayoutOwner>()
|
||||
}))
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -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<ReturnType<typeof resolveTerminalKeyboardShortcutAction>>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<typeof sendPaneCommandToLayoutOwner>()
|
||||
}))
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
+150
@@ -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<typeof vi.fn>
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutApplyReply>({ 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()
|
||||
}
|
||||
-80
@@ -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<typeof vi.fn>; 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()
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutApplyReply>({ 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
|
||||
}
|
||||
+19
-12
@@ -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<typeof vi.fn>): 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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<PaneManager | null>
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user