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 40b549126fd..39b300de69d 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 @@ -6,13 +6,24 @@ import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-in import { createDeferred } from './pty-connection-test-async' import type { splitPaneInMain, splitServerPane } from './terminal-pane-split-in-main' -const mocks = vi.hoisted(() => ({ - layoutOwnerIsMain: false, - recordCreatedTerminalPaneSplit: vi.fn(), - resolveSplitCwd: vi.fn(), - splitPaneInMain: vi.fn(), - splitServerPane: vi.fn(), - splitWebRuntimeTerminal: vi.fn() +const mocks = vi.hoisted(() => { + return { + layoutOwnerIsMain: false, + recordCreatedTerminalPaneSplit: vi.fn(), + resolveSplitCwd: vi.fn(), + splitPaneInMain: vi.fn(), + splitServerPane: vi.fn(), + splitWebRuntimeTerminal: vi.fn(), + readHostPublishedSplitSourcePtyId: vi.fn<() => string | null>(), + isHostTabPane: vi.fn<() => boolean>(), + notifyHostTabSplitRefused: vi.fn() + } +}) + +vi.mock('@/runtime/web-runtime-split-source-pty', () => ({ + readHostPublishedSplitSourcePtyId: mocks.readHostPublishedSplitSourcePtyId, + isHostTabPane: mocks.isHostTabPane, + notifyHostTabSplitRefused: mocks.notifyHostTabSplitRefused })) vi.mock('../../../../shared/layout-owner-switch', () => ({ @@ -67,6 +78,64 @@ describe('splitTerminalPaneWithInheritedCwd', () => { mocks.resolveSplitCwd.mockReset() mocks.splitWebRuntimeTerminal.mockReset() mocks.splitWebRuntimeTerminal.mockReturnValue(false) + mocks.readHostPublishedSplitSourcePtyId.mockReset().mockReturnValue(null) + mocks.isHostTabPane.mockReset().mockReturnValue(false) + mocks.notifyHostTabSplitRefused.mockReset() + }) + + describe('a pane of a paired server tab whose own attach has not landed', () => { + const HOST_TAB = 'web-terminal-host-tab-1' + const HOST_PTY = 'remote:env-1@@terminal-1' + const SOURCE = { worktreeId: 'worktree-1', tabId: HOST_TAB, leafId: 'leaf-1' } + + function splitHostTabPane(splitPane: ReturnType, serverTakesSplit = true): void { + mocks.isHostTabPane.mockReturnValue(true) + mocks.splitWebRuntimeTerminal.mockImplementation( + (ptyId: string | null) => serverTakesSplit && Boolean(ptyId) + ) + splitTerminalPaneWithInheritedCwd({ + worktreeId: 'worktree-1', + tabId: HOST_TAB, + manager: makeManager(splitPane), + // No transport yet: the pane's attach has not landed. + paneTransports: new Map(), + paneCwdMap: new Map(), + fallbackCwd: '/fallback', + pane: LAYOUT_OWNER_SOURCE_PANE, + direction: 'vertical', + source: 'keyboard' + }) + } + + it('splits on the server from the terminal the server published for it', () => { + mocks.readHostPublishedSplitSourcePtyId.mockReturnValue(HOST_PTY) + const splitPane = vi.fn() + + splitHostTabPane(splitPane) + + expect(mocks.splitWebRuntimeTerminal).toHaveBeenCalledWith( + HOST_PTY, + 'vertical', + 'keyboard', + SOURCE + ) + expect(splitPane).not.toHaveBeenCalled() + }) + + // A pane split here has no server terminal, and nothing would ever start one. + it.each([ + ['no terminal yet', null], + // e.g. the session to that server is not active. + ['a terminal the server cannot take a split for', HOST_PTY] + ])('refuses rather than splitting here with %s', (_case, published) => { + mocks.readHostPublishedSplitSourcePtyId.mockReturnValue(published) + const splitPane = vi.fn(() => ({ id: 2 })) + + splitHostTabPane(splitPane, false) + + expect(splitPane).not.toHaveBeenCalled() + expect(mocks.notifyHostTabSplitRefused).toHaveBeenCalledOnce() + }) }) it.each(['keyboard', 'context_menu'] as const)( 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 381465436d8..d3d78fad9a7 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 @@ -1,6 +1,11 @@ import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-terminal-actions' +import { + isHostTabPane, + notifyHostTabSplitRefused, + readHostPublishedSplitSourcePtyId +} from '@/runtime/web-runtime-split-source-pty' import type { PtyTransport } from './pty-transport-types' import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' @@ -26,14 +31,12 @@ type SplitArgs = { } export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void { - const ptyId = args.paneTransports.get(args.pane.id)?.getPtyId() ?? null - if ( - splitWebRuntimeTerminal(ptyId, args.direction, args.source, { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.pane.leafId - }) - ) { + const sourcePane = { worktreeId: args.worktreeId, tabId: args.tabId, leafId: args.pane.leafId } + // Why the host's binding: a pane whose attach is still in flight has no transport id yet. + const ptyId = + args.paneTransports.get(args.pane.id)?.getPtyId() ?? + readHostPublishedSplitSourcePtyId(sourcePane) + if (splitWebRuntimeTerminal(ptyId, args.direction, args.source, sourcePane)) { return } // Switch point: main writes the split and starts its terminal; main's frame draws the pane. A @@ -54,6 +57,10 @@ export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void { return } } + if (isHostTabPane(sourcePane)) { + notifyHostTabSplitRefused(sourcePane) + return + } splitLocally(args, () => splitCwd(args, ptyId)) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a79e1bef9e3..66fc5676179 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18792,6 +18792,7 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host.", + "splitSourceUnpublished": "The pane didn’t split: its terminal hasn’t started on the host yet.", "terminalCreateUnconfirmed": "Orca couldn’t confirm that the new terminal opened on the host." }, "gitlabJobTraceClient": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3afd4c23074..055f5bb9ec0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18511,6 +18511,7 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "El espacio de trabajo no está conectado a un host Orca remoto.", + "splitSourceUnpublished": "El panel no se dividió: su terminal aún no se ha iniciado en el host.", "terminalCreateUnconfirmed": "Orca no pudo confirmar que la nueva terminal se abrió en el host." }, "gitlabJobTraceClient": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9c78c89d314..ded65607d61 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18651,6 +18651,7 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "L'espace de travail n'est pas connecté à un hôte Orca distant.", + "splitSourceUnpublished": "Le volet n'a pas été divisé : son terminal n'a pas encore démarré sur l'hôte.", "terminalCreateUnconfirmed": "Orca n'a pas pu confirmer que le nouveau terminal s'est ouvert sur l'hôte." }, "gitlabJobTraceClient": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4f35ee4a440..eecdc39af6d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18510,6 +18510,7 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "このワークスペースは、リモートの Orca ホストに接続されていません。", + "splitSourceUnpublished": "ペインを分割できませんでした: ホスト上でターミナルがまだ起動していません。", "terminalCreateUnconfirmed": "Orca は、ホスト上で新規ターミナルが開いたことを確認できませんでした。" }, "gitlabJobTraceClient": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 33b04edc432..dc332e230d8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18510,6 +18510,7 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "워크스페이스가 원격 Orca 호스트에 연결되어 있지 않습니다.", + "splitSourceUnpublished": "창을 분할하지 못했습니다: 호스트에서 터미널이 아직 시작되지 않았습니다.", "terminalCreateUnconfirmed": "Orca가 호스트에서 새 터미널이 열렸는지 확인할 수 없습니다." }, "gitlabJobTraceClient": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 2a20accbb9c..2f316a217f7 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -1038,6 +1038,7 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "O espaço de trabalho não está conectado a um host Orca remoto.", + "splitSourceUnpublished": "O painel não foi dividido: o terminal ainda não foi iniciado no host.", "terminalCreateUnconfirmed": "O Orca não conseguiu confirmar que o novo terminal foi aberto no host." }, "gitlabJobTraceClient": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3af40d5080e..60f4a3b54d1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18510,6 +18510,7 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "工作区未连接到远程 Orca 主机。", + "splitSourceUnpublished": "窗格未拆分:其终端尚未在主机上启动。", "terminalCreateUnconfirmed": "Orca 无法确认新终端是否在主机上打开。" }, "gitlabJobTraceClient": { diff --git a/src/renderer/src/runtime/web-runtime-split-source-pty.test.ts b/src/renderer/src/runtime/web-runtime-split-source-pty.test.ts new file mode 100644 index 00000000000..b8d15eb1612 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-split-source-pty.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + notifyHostTabSplitRefused, + readHostPublishedSplitSourcePtyId +} from './web-runtime-split-source-pty' + +const TAB = 'web-terminal-host-tab-1' +const SOURCE = { worktreeId: 'worktree-1', tabId: TAB, leafId: 'leaf-1' } + +const mocks = vi.hoisted(() => { + const owner: { environmentId: string | null; transport: string } = { + environmentId: 'env-1', + transport: 'ready' + } + return { owner, toastError: vi.fn() } +}) + +vi.mock('../store', () => ({ + useAppStore: { + getState: () => ({ + terminalLayoutsByTabId: { + 'web-terminal-host-tab-1': { ptyIdsByLeafId: { 'leaf-1': 'remote:env-1@@terminal-1' } } + }, + runtimeStatusByEnvironmentId: new Map([ + ['env-1', { snapshot: { transport: mocks.owner.transport } }] + ]) + }) + } +})) +vi.mock('../lib/resolve-owner', () => ({ + runtimeEnvironmentIdUnlessTwin: () => mocks.owner.environmentId +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +describe('host-tab split source', () => { + beforeEach(() => { + mocks.owner.environmentId = 'env-1' + mocks.owner.transport = 'ready' + mocks.toastError.mockReset() + }) + + it('reads the terminal the workspace’s server published for the pane', () => { + expect(readHostPublishedSplitSourcePtyId(SOURCE)).toBe('remote:env-1@@terminal-1') + }) + + // A split sent from it would run on a server the workspace no longer belongs to. + it('ignores a binding naming another server', () => { + mocks.owner.environmentId = 'env-2' + expect(readHostPublishedSplitSourcePtyId(SOURCE)).toBeNull() + }) + + it.each([ + ['still starting', 'ready', 'its terminal hasn’t started'], + ['disconnected', 'disconnected', 'not connected to a remote Orca host'] + ])('tells the user why the split was refused while the server is %s', (_c, transport, text) => { + mocks.owner.transport = transport + notifyHostTabSplitRefused(SOURCE) + expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(text), expect.anything()) + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-split-source-pty.ts b/src/renderer/src/runtime/web-runtime-split-source-pty.ts new file mode 100644 index 00000000000..afcc4a70a3d --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-split-source-pty.ts @@ -0,0 +1,56 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { runtimeEnvironmentIdUnlessTwin } from '../lib/resolve-owner' +import { useAppStore } from '../store' +import type { WebRuntimeSplitSource } from './web-runtime-split-focus' + +/** + * The terminal the workspace's server published for a pane of one of its tabs, before this pane's + * own attach lands. A binding naming any other server is not this pane's terminal. + */ +export function readHostPublishedSplitSourcePtyId(source: WebRuntimeSplitSource): string | null { + if (!isWebTerminalSurfaceTabId(source.tabId)) { + return null + } + const state = useAppStore.getState() + const ptyId = state.terminalLayoutsByTabId[source.tabId]?.ptyIdsByLeafId?.[source.leafId] + const environmentId = runtimeEnvironmentIdUnlessTwin(state, source.worktreeId) + return ptyId && environmentId && parseRemoteRuntimePtyId(ptyId)?.environmentId === environmentId + ? ptyId + : null +} + +/** A pane of a paired server's tab: only that server starts terminals in it. */ +export function isHostTabPane(source: WebRuntimeSplitSource): boolean { + return ( + isWebTerminalSurfaceTabId(source.tabId) && + runtimeEnvironmentIdUnlessTwin(useAppStore.getState(), source.worktreeId) !== null + ) +} + +/** + * Why a server-tab pane did not split. Only the server starts terminals in its tabs, so splitting + * here instead would leave a blank pane. + */ +export function notifyHostTabSplitRefused(source: WebRuntimeSplitSource): void { + const state = useAppStore.getState() + const environmentId = runtimeEnvironmentIdUnlessTwin(state, source.worktreeId) + const disconnected = + !environmentId || + state.runtimeStatusByEnvironmentId.get(environmentId)?.snapshot?.transport === 'disconnected' + toast.error( + disconnected + ? translate( + 'auto.runtime.webRuntimeSession.remoteHostDisconnected', + 'The workspace is not connected to a remote Orca host.' + ) + : translate( + 'auto.runtime.webRuntimeSession.splitSourceUnpublished', + 'The pane didn’t split: its terminal hasn’t started on the host yet.' + ), + // Why an id: repeated presses must not stack toasts. + { id: 'web-runtime-host-tab-split-refused' } + ) +} diff --git a/tests/e2e/paired-seeded-terminal-immediate-split.spec.ts b/tests/e2e/paired-seeded-terminal-immediate-split.spec.ts new file mode 100644 index 00000000000..7366192ac92 --- /dev/null +++ b/tests/e2e/paired-seeded-terminal-immediate-split.spec.ts @@ -0,0 +1,67 @@ +import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { launchPairedElectronClient } from './helpers/paired-electron-client' +import { waitForBoundPanes } from './helpers/terminal-layout-journeys' +import { splitActiveTerminalPane } from './helpers/terminal' + +const PANE = '.pane[data-leaf-id]:visible' + +// A split issued while the desktop's seeded first tab is still settling must still give the new +// pane a live server terminal, not a blank pane. +test('a split right after the first terminal of an empty paired-server workspace binds both panes', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(180_000) + const host = await launchHeadlessPairedRuntimeHost() + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + const desktop = await launchPairedElectronClient(host.offer, testInfo, 'seeded split') + try { + const page = desktop.page + let worktreeId = '' + await expect + .poll( + async () => + (worktreeId = await page.evaluate( + (repo) => + window.__store + ?.getState() + .allWorktrees() + .find((worktree) => worktree.path === repo)?.id ?? '', + testRepoPath + )), + { timeout: 60_000 } + ) + .not.toBe('') + await page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + // Split the moment the seeded pane mounts, before its terminal has attached. + await expect(page.locator(PANE)).toHaveCount(1, { timeout: 60_000 }) + await splitActiveTerminalPane(page, 'vertical') + await waitForBoundPanes(page, 2) + + const leafIds = await page + .locator(PANE) + .evaluateAll((panes) => panes.map((pane) => pane.getAttribute('data-leaf-id') ?? '')) + await expect + .poll( + async () => { + const { terminals } = ( + await host.client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + ).result + return leafIds.every((leafId) => + terminals.some((terminal) => terminal.leafId === leafId) + ) + }, + { timeout: 30_000, message: 'a split pane has no server terminal' } + ) + .toBe(true) + } finally { + await desktop.dispose() + } + } finally { + await host.dispose() + } +})