mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(ssh): open a plain SSH terminal to a host whose move to its Orca server is blocked (#31716)
* feat(ssh): open a plain SSH terminal to a host whose move to its Orca server is blocked A refused or unserved host fails the whole connect, so the user had no way to reach their work there. The blocked-move banner and the SSH host row now offer an unmanaged, unpersisted ssh login in a non-modal window. Main builds the argv from the saved target (no -T, BatchMode or Orca multiplexing; the config alias stays bare so ssh_config applies) and spawns ssh with an argv array through the local PTY spawn primitive. Co-Authored-By: Claude <noreply@anthropic.com> * fix(ssh): claim a host before awaiting, wait for ssh on quit, and share the preview terminal's keys Co-Authored-By: Claude <noreply@anthropic.com> * fix(ssh): give the plain SSH terminal Orca's Unicode widths, kitty flags and spawn-helper repair Co-Authored-By: Claude <noreply@anthropic.com> * refactor(terminal): one standalone terminal for the agent preview and plain SSH Extracts the preview's xterm setup (Unicode widths, links, IME bridge, kitty flags, copy/paste and shortcut policy, user-input routing) into openStandaloneTerminal, so the plain SSH terminal reuses it instead of a second copy. Plain SSH output is now acked once rendered, and main pauses ssh while too much is unrendered. Co-Authored-By: Claude <noreply@anthropic.com> * fix(ssh): plain SSH answers terminal queries, keeps early exits, and ends a paused pty on Windows Co-Authored-By: Claude <noreply@anthropic.com> * fix(ssh): plain SSH applies theme changes live and never refits while minimized Co-Authored-By: Claude <noreply@anthropic.com> * fix(ssh): a native call on a just-exited plain SSH pty never crashes main Co-Authored-By: Claude <noreply@anthropic.com> * test(ssh): give the plain SSH test's ResizeObserver stub unobserve, which Tooltip needs Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/** IPC for plain SSH terminals; the renderer names a saved target id and never any argv. */
|
||||
import { homedir } from 'node:os'
|
||||
import { ipcMain, type WebContents } from 'electron'
|
||||
import {
|
||||
PLAIN_SSH_TERMINAL_CHANNELS,
|
||||
type PlainSshTerminalEvent
|
||||
} from '../../../shared/plain-ssh-terminal'
|
||||
import { isSshHostMoveBlocked } from '../../../shared/ssh-host-move-blocked'
|
||||
import { mayUserSshConfigClaimAlias } from '../../ssh/config/ssh-config-alias-claim'
|
||||
import { getSshHostServerStatus } from '../../ssh/ssh-host-server-status'
|
||||
import { getSshTargetRegistryStore } from '../../ssh/targets/ssh-target-registry'
|
||||
import { findSystemSsh } from '../../ssh/system-transport/system-ssh-binary'
|
||||
import { ensureNodePtySpawnHelperExecutable } from '../../providers/local-pty-utils'
|
||||
import {
|
||||
ackPlainSshTerminal,
|
||||
closePlainSshTerminal,
|
||||
closePlainSshTerminalsOwnedBy,
|
||||
openPlainSshTerminal,
|
||||
resizePlainSshTerminal,
|
||||
writePlainSshTerminal,
|
||||
type PlainSshTerminalDeps
|
||||
} from '../../ssh/plain-ssh-terminal/plain-ssh-terminal-sessions'
|
||||
|
||||
const deps: PlainSshTerminalDeps = {
|
||||
getTarget: (targetId) => getSshTargetRegistryStore()?.getTarget(targetId),
|
||||
isMoveBlocked: (targetId) => isSshHostMoveBlocked(getSshHostServerStatus(targetId)),
|
||||
findSsh: findSystemSsh,
|
||||
mayConfigClaimAlias: mayUserSshConfigClaimAlias,
|
||||
loadPtySpawn: async () => {
|
||||
// Why: a packaged macOS build can lose node-pty's spawn-helper exec bit; the pane paths repair it too.
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
return (await import('node-pty')).spawn
|
||||
},
|
||||
homeDir: homedir,
|
||||
platform: process.platform
|
||||
}
|
||||
|
||||
const watchedOwners = new Set<number>()
|
||||
|
||||
/** A reload, a crash or a closed window can't show its sessions again, so they end with it. */
|
||||
function endSessionsWithOwner(sender: WebContents): void {
|
||||
if (watchedOwners.has(sender.id)) {
|
||||
return
|
||||
}
|
||||
watchedOwners.add(sender.id)
|
||||
const owner = sender.id
|
||||
const end = (): void => closePlainSshTerminalsOwnedBy(owner)
|
||||
sender.on('did-start-navigation', (details) => {
|
||||
if (details.isMainFrame && !details.isSameDocument) {
|
||||
end()
|
||||
}
|
||||
})
|
||||
sender.on('render-process-gone', end)
|
||||
sender.once('destroyed', () => {
|
||||
watchedOwners.delete(owner)
|
||||
end()
|
||||
})
|
||||
}
|
||||
|
||||
export function registerPlainSshTerminalHandlers(): void {
|
||||
const { open, write, resize, close, ack, event } = PLAIN_SSH_TERMINAL_CHANNELS
|
||||
ipcMain.removeHandler(open)
|
||||
ipcMain.removeAllListeners(write)
|
||||
ipcMain.removeAllListeners(resize)
|
||||
ipcMain.removeAllListeners(close)
|
||||
ipcMain.removeAllListeners(ack)
|
||||
|
||||
ipcMain.handle(
|
||||
open,
|
||||
async (ipcEvent, args: { targetId?: unknown; cols?: unknown; rows?: unknown }) => {
|
||||
const sender = ipcEvent.sender
|
||||
endSessionsWithOwner(sender)
|
||||
return openPlainSshTerminal(
|
||||
deps,
|
||||
{ targetId: args?.targetId, cols: args?.cols, rows: args?.rows, owner: sender.id },
|
||||
(payload: PlainSshTerminalEvent) => {
|
||||
if (sender.isDestroyed()) {
|
||||
return false
|
||||
}
|
||||
sender.send(event, payload)
|
||||
return true
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
ipcMain.on(write, (ipcEvent, args: { sessionId?: unknown; data?: unknown }) => {
|
||||
writePlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.data)
|
||||
})
|
||||
ipcMain.on(resize, (ipcEvent, args: { sessionId?: unknown; cols?: unknown; rows?: unknown }) => {
|
||||
resizePlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.cols, args?.rows)
|
||||
})
|
||||
ipcMain.on(close, (ipcEvent, args: { sessionId?: unknown }) => {
|
||||
closePlainSshTerminal(args?.sessionId, ipcEvent.sender.id)
|
||||
})
|
||||
ipcMain.on(ack, (ipcEvent, args: { sessionId?: unknown; chars?: unknown }) => {
|
||||
ackPlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.chars)
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from './ssh-connect-attempt-registry'
|
||||
import { connectionManager, portForwardManager } from './ssh-ipc-context'
|
||||
import { shutdownOrcadManagedTunnels } from '../../managed-server/lifecycle/orcad-managed-tunnel'
|
||||
import { closeAllPlainSshTerminals } from '../../ssh/plain-ssh-terminal/plain-ssh-terminal-sessions'
|
||||
|
||||
// Why one budget for the whole sequence rather than one per phase: an invalidated connect only
|
||||
// observes its cancellation at the next checkpoint, and one blocked in the transport handshake can
|
||||
@@ -70,6 +71,11 @@ function sshShutdownTasks(): SshShutdownTask[] {
|
||||
// its local port, so the next launch cannot bind a managed tunnel's fixed port.
|
||||
targetId: '*forwards',
|
||||
promise: Promise.all([portForwardManager?.closeAll(), shutdownOrcadManagedTunnels()])
|
||||
},
|
||||
{
|
||||
// Why: nothing restores a plain SSH terminal, so none may outlive Orca.
|
||||
targetId: '*plain-ssh',
|
||||
promise: closeAllPlainSshTerminals()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createSshConnectionCallbacks } from './ssh-connection-state-callbacks'
|
||||
import { resetSshCredentialDeclinesForTests } from '../../ssh/ssh-credential-decline'
|
||||
import { resetSshUserDisconnectsForTests } from '../../ssh/ssh-user-disconnect'
|
||||
import { registerSshConnectionHandlers } from './ssh-connection-handlers'
|
||||
import { registerPlainSshTerminalHandlers } from './plain-ssh-terminal-handlers'
|
||||
import {
|
||||
registerPowerMonitorReconnect,
|
||||
unregisterPowerMonitorReconnect
|
||||
@@ -135,6 +136,7 @@ export function registerSshHandlers(
|
||||
registerSshTargetCrudHandlers()
|
||||
registerSshConnectionHandlers()
|
||||
registerSshPortForwardHandlers()
|
||||
registerPlainSshTerminalHandlers()
|
||||
// COMPAT(legacy-relay-bridge): terminals an older Orca's relay still runs.
|
||||
registerLegacyRelayBridgeIpc({
|
||||
store,
|
||||
|
||||
@@ -24,34 +24,7 @@ export function buildLocalPtySpawnEnvironment(args: {
|
||||
plan: LocalPtyLaunchPlan
|
||||
}): Record<string, string> | Promise<Record<string, string>> {
|
||||
const { id, spawn, getOptions, plan } = args
|
||||
const inheritedEnv = stripInheritedBuildModeEnv(process.env)
|
||||
restoreManagedDataAccountEnvironment(inheritedEnv)
|
||||
const spawnEnv: Record<string, string> = {
|
||||
...mergeGitConfigEnvProtocol(inheritedEnv, spawn.env),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'Orca',
|
||||
// Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working.
|
||||
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
|
||||
// Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them.
|
||||
FORCE_HYPERLINK: '1',
|
||||
[ORCA_IMAGE_PROTOCOL_ENV]: ORCA_IMAGE_PROTOCOL_VALUE
|
||||
} satisfies Record<string, string>
|
||||
removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env)
|
||||
stripPiProcessOwnerEnv(spawnEnv)
|
||||
removeAppImageRuntimeEnv(spawnEnv)
|
||||
removeChromiumDisabledSessionBus(spawnEnv)
|
||||
removeInheritedNoColor(spawnEnv)
|
||||
for (const key of spawn.envToDelete ?? []) {
|
||||
delete spawnEnv[key]
|
||||
}
|
||||
if (spawn.env?.TERM) {
|
||||
spawnEnv.TERM = spawn.env.TERM
|
||||
}
|
||||
// Why after the strips and deletes: an alias must never outlive the value it mirrors.
|
||||
applyScrubSafeAgentEnvAliases(spawnEnv)
|
||||
|
||||
spawnEnv.LANG ??= 'en_US.UTF-8'
|
||||
const spawnEnv = buildInheritedLocalPtyEnvironment(spawn.env, spawn.envToDelete)
|
||||
|
||||
// Why: on Windows LANG doesn't set the console code page; PYTHONUTF8=1 forces Python UTF-8 stdio to avoid garbled CJK.
|
||||
if (process.platform === 'win32') {
|
||||
@@ -77,6 +50,42 @@ export function buildLocalPtySpawnEnvironment(args: {
|
||||
})
|
||||
}
|
||||
|
||||
/** What any local PTY inherits from Orca: its terminal identity, minus build and pane leftovers. */
|
||||
export function buildInheritedLocalPtyEnvironment(
|
||||
explicitEnv: Record<string, string> | undefined,
|
||||
envToDelete: readonly string[] = []
|
||||
): Record<string, string> {
|
||||
const inheritedEnv = stripInheritedBuildModeEnv(process.env)
|
||||
restoreManagedDataAccountEnvironment(inheritedEnv)
|
||||
const spawnEnv: Record<string, string> = {
|
||||
...mergeGitConfigEnvProtocol(inheritedEnv, explicitEnv),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'Orca',
|
||||
// Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working.
|
||||
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
|
||||
// Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them.
|
||||
FORCE_HYPERLINK: '1',
|
||||
[ORCA_IMAGE_PROTOCOL_ENV]: ORCA_IMAGE_PROTOCOL_VALUE
|
||||
} satisfies Record<string, string>
|
||||
removeUnspecifiedPaneIdentityEnv(spawnEnv, explicitEnv)
|
||||
stripPiProcessOwnerEnv(spawnEnv)
|
||||
removeAppImageRuntimeEnv(spawnEnv)
|
||||
removeChromiumDisabledSessionBus(spawnEnv)
|
||||
removeInheritedNoColor(spawnEnv)
|
||||
for (const key of envToDelete) {
|
||||
delete spawnEnv[key]
|
||||
}
|
||||
if (explicitEnv?.TERM) {
|
||||
spawnEnv.TERM = explicitEnv.TERM
|
||||
}
|
||||
// Why after the strips and deletes: an alias must never outlive the value it mirrors.
|
||||
applyScrubSafeAgentEnvAliases(spawnEnv)
|
||||
|
||||
spawnEnv.LANG ??= 'en_US.UTF-8'
|
||||
return spawnEnv
|
||||
}
|
||||
|
||||
/** App-level env builders can reintroduce deleted keys; enforce isolation after they finish. */
|
||||
export function enforceLocalPtySpawnEnvironmentOverrides(
|
||||
spawn: PtySpawnOptions,
|
||||
|
||||
@@ -212,6 +212,24 @@ function spawnWindowsFallbackChain(
|
||||
return null
|
||||
}
|
||||
|
||||
export type PtyExecutableSpawnParams = Pick<
|
||||
ShellSpawnParams,
|
||||
'termName' | 'cols' | 'rows' | 'cwd' | 'env' | 'ptySpawn'
|
||||
> & { file: string; args: string[] }
|
||||
|
||||
/** One executable with an argv array, no shell and no fallback; throws when it can't start. */
|
||||
export function spawnPtyExecutable(params: PtyExecutableSpawnParams): pty.IPty {
|
||||
const { file, args, termName = 'xterm-256color', cols, rows, cwd, env, ptySpawn } = params
|
||||
return ptySpawn(file, args, {
|
||||
name: termName,
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env,
|
||||
...windowsConptyDllOptions()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to spawn a PTY shell. If the primary shell fails, try fallback shells
|
||||
* (Unix: zsh/bash/sh; Windows: the PowerShell -> cmd.exe chain) before giving up.
|
||||
@@ -239,13 +257,15 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
|
||||
try {
|
||||
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, env)
|
||||
return {
|
||||
process: ptySpawn(wrapped.file, wrapped.args, {
|
||||
name: termName,
|
||||
process: spawnPtyExecutable({
|
||||
file: wrapped.file,
|
||||
args: wrapped.args,
|
||||
termName,
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env,
|
||||
...windowsConptyDllOptions()
|
||||
ptySpawn
|
||||
}),
|
||||
shellPath,
|
||||
reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SshTarget } from '../../../shared/ssh-types'
|
||||
import { buildPlainSshTerminalArgs } from './plain-ssh-terminal-args'
|
||||
|
||||
const manual: SshTarget = {
|
||||
id: 't1',
|
||||
label: 'lambda',
|
||||
source: 'manual',
|
||||
host: '203.0.113.7',
|
||||
port: 2222,
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/me/.ssh/lambda key',
|
||||
jumpHost: 'bastion.example.com'
|
||||
}
|
||||
|
||||
describe('plain SSH terminal argv', () => {
|
||||
it('logs in interactively: no -T, BatchMode or Orca multiplexing', () => {
|
||||
const args = buildPlainSshTerminalArgs(manual)
|
||||
expect(args).not.toContain('-T')
|
||||
expect(args.join(' ')).not.toMatch(/BatchMode|ControlMaster|ControlPath|ControlPersist|-S/)
|
||||
expect(args.join(' ')).not.toMatch(/StrictHostKeyChecking|UserKnownHostsFile/)
|
||||
})
|
||||
|
||||
it('states a manual target’s endpoint and ends with -- user@host', () => {
|
||||
expect(buildPlainSshTerminalArgs(manual)).toEqual([
|
||||
'-p',
|
||||
'2222',
|
||||
'-i',
|
||||
'/home/me/.ssh/lambda key',
|
||||
'-J',
|
||||
'bastion.example.com',
|
||||
'--',
|
||||
'ubuntu@203.0.113.7'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a config alias bare, so Include, Match and ProxyJump in ssh_config apply', () => {
|
||||
const alias: SshTarget = {
|
||||
...manual,
|
||||
source: 'ssh-config',
|
||||
configHost: 'lambda',
|
||||
host: 'lambda'
|
||||
}
|
||||
expect(buildPlainSshTerminalArgs(alias)).toEqual(['--', 'lambda'])
|
||||
})
|
||||
|
||||
it('restores the stored endpoint only when no config block claims the alias', () => {
|
||||
const alias: SshTarget = {
|
||||
...manual,
|
||||
source: 'ssh-config',
|
||||
configHost: 'lambda',
|
||||
host: '203.0.113.7'
|
||||
}
|
||||
expect(buildPlainSshTerminalArgs(alias, { aliasClaimedByConfig: false })).toEqual([
|
||||
'-o',
|
||||
'Hostname=203.0.113.7',
|
||||
'-p',
|
||||
'2222',
|
||||
'-l',
|
||||
'ubuntu',
|
||||
'--',
|
||||
'lambda'
|
||||
])
|
||||
})
|
||||
|
||||
it('passes Windows paths and option-like hosts as single argv elements, after --', () => {
|
||||
const windows: SshTarget = {
|
||||
...manual,
|
||||
host: '-oProxyCommand=calc',
|
||||
username: '',
|
||||
identityFile: 'C:\\Users\\Me\\.ssh\\id ed25519',
|
||||
jumpHost: undefined,
|
||||
port: 22
|
||||
}
|
||||
const args = buildPlainSshTerminalArgs(windows)
|
||||
expect(args).toEqual(['-i', 'C:\\Users\\Me\\.ssh\\id ed25519', '--', '-oProxyCommand=calc'])
|
||||
expect(args.indexOf('--')).toBe(args.length - 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* argv for an interactive, unmanaged `ssh` login to a saved target. Only the target's identity is
|
||||
* shared with Orca's transport: no `-T`, no BatchMode and no Orca ControlMaster, so OpenSSH
|
||||
* allocates a TTY, prompts for passphrases, OTPs and host keys in the terminal, and applies the
|
||||
* user's own ssh_config (Include, Match, ProxyJump, ControlMaster) to the saved alias.
|
||||
*/
|
||||
import type { SshTarget } from '../../../shared/ssh-types'
|
||||
import {
|
||||
appendSshTargetDestinationArgs,
|
||||
type SystemSshBuildArgsOptions
|
||||
} from '../system-transport/system-ssh-args'
|
||||
|
||||
export function buildPlainSshTerminalArgs(
|
||||
target: SshTarget,
|
||||
options?: Pick<SystemSshBuildArgsOptions, 'aliasClaimedByConfig'>
|
||||
): string[] {
|
||||
const args: string[] = []
|
||||
appendSshTargetDestinationArgs(args, target, options)
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as pty from 'node-pty'
|
||||
import type { SshTarget } from '../../../shared/ssh-types'
|
||||
import type { PlainSshTerminalEvent } from '../../../shared/plain-ssh-terminal'
|
||||
import {
|
||||
ackPlainSshTerminal,
|
||||
closeAllPlainSshTerminals,
|
||||
closePlainSshTerminal,
|
||||
closePlainSshTerminalsOwnedBy,
|
||||
openPlainSshTerminal,
|
||||
resetPlainSshTerminalsForTests,
|
||||
resizePlainSshTerminal,
|
||||
writePlainSshTerminal,
|
||||
type PlainSshTerminalDeps
|
||||
} from './plain-ssh-terminal-sessions'
|
||||
|
||||
const lambda: SshTarget = {
|
||||
id: 'lambda',
|
||||
label: 'lambda',
|
||||
source: 'ssh-config',
|
||||
configHost: 'lambda',
|
||||
host: 'lambda',
|
||||
port: 22,
|
||||
username: ''
|
||||
}
|
||||
|
||||
type FakePty = {
|
||||
file: string
|
||||
args: string[]
|
||||
options: pty.IPtyForkOptions
|
||||
write: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
resume: ReturnType<typeof vi.fn>
|
||||
emitData: (data: string) => void
|
||||
emitExit: (exitCode: number, signal?: number) => void
|
||||
}
|
||||
|
||||
const spawned: FakePty[] = []
|
||||
|
||||
function fakeSpawn(file: string, args: string[] | string, options: pty.IPtyForkOptions): pty.IPty {
|
||||
let onData: ((data: string) => void) | null = null
|
||||
let onExit: ((event: { exitCode: number; signal?: number }) => void) | null = null
|
||||
const fake: FakePty = {
|
||||
file,
|
||||
args: Array.isArray(args) ? args : [args],
|
||||
options,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => fake.emitExit(129)),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
emitData: (data) => onData?.(data),
|
||||
emitExit: (exitCode, signal) => onExit?.({ exitCode, signal })
|
||||
}
|
||||
spawned.push(fake)
|
||||
const proc = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
write: fake.write,
|
||||
resize: fake.resize,
|
||||
kill: fake.kill,
|
||||
pause: fake.pause,
|
||||
resume: fake.resume,
|
||||
destroy: vi.fn(),
|
||||
onData: (listener: (data: string) => void) => {
|
||||
onData = listener
|
||||
return { dispose: () => {} }
|
||||
},
|
||||
onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
|
||||
onExit = listener
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sessions module only calls the IPty members this fake implements.
|
||||
return proc as unknown as pty.IPty
|
||||
}
|
||||
|
||||
function deps(overrides: Partial<PlainSshTerminalDeps> = {}): PlainSshTerminalDeps {
|
||||
return {
|
||||
getTarget: (id) => (id === lambda.id ? lambda : undefined),
|
||||
isMoveBlocked: () => true,
|
||||
findSsh: () => '/usr/bin/ssh',
|
||||
mayConfigClaimAlias: () => true,
|
||||
loadPtySpawn: async () => fakeSpawn,
|
||||
homeDir: () => '/home/me',
|
||||
platform: 'linux',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function open(
|
||||
d: PlainSshTerminalDeps,
|
||||
targetId: unknown = 'lambda',
|
||||
owner = 1,
|
||||
events: PlainSshTerminalEvent[] = []
|
||||
): ReturnType<typeof openPlainSshTerminal> {
|
||||
return openPlainSshTerminal(d, { targetId, cols: 100, rows: 30, owner }, (event) => {
|
||||
events.push(event)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await closeAllPlainSshTerminals()
|
||||
resetPlainSshTerminalsForTests()
|
||||
spawned.length = 0
|
||||
})
|
||||
|
||||
describe('plain SSH terminal sessions', () => {
|
||||
it('spawns ssh itself with an argv array in the home directory, never a shell', async () => {
|
||||
const result = await open(deps())
|
||||
expect(result).toMatchObject({ ok: true, label: 'lambda' })
|
||||
expect(spawned).toHaveLength(1)
|
||||
expect(spawned[0]).toMatchObject({ file: '/usr/bin/ssh', args: ['--', 'lambda'] })
|
||||
expect(spawned[0].options).toMatchObject({ cols: 100, rows: 30, cwd: '/home/me' })
|
||||
})
|
||||
|
||||
it('refuses an unknown, internal or non-string target without spawning', async () => {
|
||||
const internal: SshTarget = {
|
||||
...lambda,
|
||||
id: 'hop',
|
||||
owner: { type: 'on-demand-runtime', runtimeId: 'r1' }
|
||||
}
|
||||
const d = deps({ getTarget: (id) => (id === 'hop' ? internal : undefined) })
|
||||
expect(await open(d, 'nope')).toEqual({ ok: false, reason: 'unknown-target' })
|
||||
expect(await open(d, 'hop')).toEqual({ ok: false, reason: 'unknown-target' })
|
||||
expect(await open(d, { id: 'lambda' })).toEqual({ ok: false, reason: 'unknown-target' })
|
||||
expect(spawned).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a host whose move isn’t blocked: Orca’s own terminals serve it', async () => {
|
||||
expect(await open(deps({ isMoveBlocked: () => false }))).toEqual({
|
||||
ok: false,
|
||||
reason: 'not-blocked'
|
||||
})
|
||||
expect(spawned).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('names OpenSSH Client when Windows has no ssh.exe, with no shell workaround', async () => {
|
||||
const result = await open(deps({ findSsh: () => null, platform: 'win32' }))
|
||||
expect(result).toMatchObject({ ok: false, reason: 'ssh-missing' })
|
||||
expect(result.ok ? '' : 'message' in result ? result.message : '').toContain('OpenSSH Client')
|
||||
})
|
||||
|
||||
it('keeps one session per host across windows', async () => {
|
||||
await open(deps(), 'lambda', 1)
|
||||
expect(await open(deps(), 'lambda', 2)).toEqual({ ok: false, reason: 'already-open' })
|
||||
expect(spawned).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('claims the host before awaiting, so concurrent opens never spawn twice', async () => {
|
||||
let release: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const slow = deps({
|
||||
loadPtySpawn: async () => {
|
||||
await gate
|
||||
return fakeSpawn
|
||||
}
|
||||
})
|
||||
const first = open(slow, 'lambda', 1)
|
||||
const other = await open(slow, 'lambda', 2)
|
||||
// The same window replacing its own pending open (e.g. a remount) wins; the older one yields.
|
||||
const again = open(slow, 'lambda', 1)
|
||||
release()
|
||||
expect(other).toEqual({ ok: false, reason: 'already-open' })
|
||||
expect(await first).toEqual({ ok: false, reason: 'cancelled' })
|
||||
expect(await again).toMatchObject({ ok: true })
|
||||
expect(spawned).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('spawns nothing for an open whose window reloaded while it was pending', async () => {
|
||||
let release: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const pending = open(
|
||||
deps({
|
||||
loadPtySpawn: async () => {
|
||||
await gate
|
||||
return fakeSpawn
|
||||
}
|
||||
}),
|
||||
'lambda',
|
||||
3
|
||||
)
|
||||
closePlainSshTerminalsOwnedBy(3)
|
||||
release()
|
||||
expect(await pending).toEqual({ ok: false, reason: 'cancelled' })
|
||||
expect(spawned).toHaveLength(0)
|
||||
expect(await open(deps(), 'lambda', 4)).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('frees the host as soon as a close is asked, before ssh has exited', async () => {
|
||||
const first = await open(deps(), 'lambda', 1)
|
||||
spawned[0].kill.mockImplementation(() => {})
|
||||
closePlainSshTerminal(first.ok ? first.sessionId : '', 1)
|
||||
expect(await open(deps(), 'lambda', 2)).toMatchObject({ ok: true })
|
||||
spawned[0].emitExit(129)
|
||||
})
|
||||
|
||||
it('pauses ssh while the window has too much unrendered output, and resumes on ack', async () => {
|
||||
const result = await open(deps(), 'lambda', 1)
|
||||
const sessionId = result.ok ? result.sessionId : ''
|
||||
const chunk = 'x'.repeat(64 * 1024)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
spawned[0].emitData(chunk)
|
||||
}
|
||||
expect(spawned[0].pause).not.toHaveBeenCalled()
|
||||
spawned[0].emitData(chunk)
|
||||
expect(spawned[0].pause).toHaveBeenCalledTimes(1)
|
||||
// Another window's ack is ignored.
|
||||
ackPlainSshTerminal(sessionId, 2, 9 * chunk.length)
|
||||
expect(spawned[0].resume).not.toHaveBeenCalled()
|
||||
ackPlainSshTerminal(sessionId, 1, 6 * chunk.length)
|
||||
expect(spawned[0].resume).not.toHaveBeenCalled()
|
||||
ackPlainSshTerminal(sessionId, 1, 2 * chunk.length)
|
||||
expect(spawned[0].resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resumes a paused ssh before hanging up, or Windows would never report its exit', async () => {
|
||||
const result = await open(deps(), 'lambda', 1)
|
||||
const order: string[] = []
|
||||
spawned[0].resume.mockImplementation(() => order.push('resume'))
|
||||
spawned[0].kill.mockImplementation(() => order.push('kill'))
|
||||
spawned[0].emitData('x'.repeat(600 * 1024))
|
||||
expect(spawned[0].pause).toHaveBeenCalledTimes(1)
|
||||
closePlainSshTerminal(result.ok ? result.sessionId : '', 1)
|
||||
expect(order).toEqual(['resume', 'kill'])
|
||||
spawned[0].emitExit(129)
|
||||
})
|
||||
|
||||
it('swallows native calls on a pty that exited but hasn’t reported it, as Windows does', async () => {
|
||||
const result = await open(deps(), 'lambda', 1)
|
||||
const sessionId = result.ok ? result.sessionId : ''
|
||||
const exited = (): never => {
|
||||
throw new Error('Cannot resize a pty that has already exited')
|
||||
}
|
||||
spawned[0].resize.mockImplementation(exited)
|
||||
spawned[0].write.mockImplementation(exited)
|
||||
expect(() => resizePlainSshTerminal(sessionId, 1, 100, 30)).not.toThrow()
|
||||
expect(() => writePlainSshTerminal(sessionId, 1, 'ls\r')).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports a signal kill, which is not a clean logout', async () => {
|
||||
const events: PlainSshTerminalEvent[] = []
|
||||
const result = await open(deps(), 'lambda', 1, events)
|
||||
spawned[0].emitExit(0, 9)
|
||||
expect(events).toEqual([
|
||||
{ kind: 'exit', sessionId: result.ok ? result.sessionId : '', exitCode: 0, signal: 9 }
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards output and exit, and only its owner can write, resize or close it', async () => {
|
||||
const events: PlainSshTerminalEvent[] = []
|
||||
const result = await open(deps(), 'lambda', 7, events)
|
||||
const sessionId = result.ok ? result.sessionId : ''
|
||||
spawned[0].emitData('Welcome to lambda\r\n')
|
||||
writePlainSshTerminal(sessionId, 8, 'rm -rf /\r')
|
||||
resizePlainSshTerminal(sessionId, 8, 10, 10)
|
||||
closePlainSshTerminal(sessionId, 8)
|
||||
expect(spawned[0].write).not.toHaveBeenCalled()
|
||||
expect(spawned[0].resize).not.toHaveBeenCalled()
|
||||
expect(spawned[0].kill).not.toHaveBeenCalled()
|
||||
writePlainSshTerminal(sessionId, 7, 'ls\r')
|
||||
resizePlainSshTerminal(sessionId, 7, 120, 40)
|
||||
expect(spawned[0].write).toHaveBeenCalledWith('ls\r')
|
||||
expect(spawned[0].resize).toHaveBeenCalledWith(120, 40)
|
||||
closePlainSshTerminal(sessionId, 7)
|
||||
expect(events).toEqual([
|
||||
{ kind: 'data', sessionId, data: 'Welcome to lambda\r\n' },
|
||||
{ kind: 'exit', sessionId, exitCode: 129 }
|
||||
])
|
||||
// Ended: a new one may open.
|
||||
expect(await open(deps(), 'lambda', 7)).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('on quit, hangs up every session, waits for it to exit, and opens no more', async () => {
|
||||
await open(deps())
|
||||
spawned[0].kill.mockImplementation(() => {})
|
||||
let done = false
|
||||
const quit = closeAllPlainSshTerminals().then(() => {
|
||||
done = true
|
||||
})
|
||||
expect(spawned[0].kill).toHaveBeenCalledTimes(1)
|
||||
await Promise.resolve()
|
||||
expect(done).toBe(false)
|
||||
spawned[0].emitExit(129)
|
||||
await quit
|
||||
expect(await open(deps(), 'lambda', 9)).toEqual({ ok: false, reason: 'cancelled' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Plain SSH terminals: an unmanaged `ssh` login to a host whose move to its Orca server is blocked,
|
||||
* so the user can keep working there meanwhile. A session is not an Orca workspace or terminal:
|
||||
* nothing persists or restores it, no agent or status runs in it, and it never marks the host
|
||||
* connected. One per host; it ends when ssh exits, its window closes or reloads, or Orca quits.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type * as pty from 'node-pty'
|
||||
import type { SshTarget } from '../../../shared/ssh-types'
|
||||
import type {
|
||||
PlainSshTerminalEvent,
|
||||
PlainSshTerminalOpenResult
|
||||
} from '../../../shared/plain-ssh-terminal'
|
||||
import { buildInheritedLocalPtyEnvironment } from '../../providers/local-pty-spawn-environment'
|
||||
import { spawnPtyExecutable } from '../../providers/local-pty-utils'
|
||||
import { destroyPtyProcess } from '../../providers/local-pty-termination'
|
||||
import { buildPlainSshTerminalArgs } from './plain-ssh-terminal-args'
|
||||
|
||||
export type PlainSshTerminalDeps = {
|
||||
getTarget: (targetId: string) => SshTarget | undefined
|
||||
/** True only while the host's move to its Orca server is blocked. */
|
||||
isMoveBlocked: (targetId: string) => boolean
|
||||
findSsh: () => string | null
|
||||
mayConfigClaimAlias: (alias: string) => boolean
|
||||
loadPtySpawn: () => Promise<typeof pty.spawn>
|
||||
homeDir: () => string
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
|
||||
/** Where a session's output and exit go; false once that window is gone. */
|
||||
export type PlainSshTerminalSink = (event: PlainSshTerminalEvent) => boolean
|
||||
|
||||
type Session = {
|
||||
id: string
|
||||
owner: number
|
||||
proc: pty.IPty
|
||||
exited: Promise<void>
|
||||
hungUp: boolean
|
||||
/** Output sent but not yet rendered by the window, in UTF-16 units. */
|
||||
unacked: number
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
/** A host's one claim, taken before the first await so concurrent opens can't both spawn. */
|
||||
type Slot = { owner: number; token: symbol; session: Session | null }
|
||||
|
||||
const FORCE_KILL_AFTER_MS = 5_000
|
||||
const QUIT_FORCE_KILL_AFTER_MS = 1_000
|
||||
// Why: a flood (cat of a big file) must wait for the window to render, not pile up in IPC.
|
||||
const PAUSE_ABOVE_UNACKED = 512 * 1024
|
||||
const RESUME_BELOW_UNACKED = 128 * 1024
|
||||
const MIN_SIZE = 2
|
||||
const MAX_SIZE = 1_000
|
||||
|
||||
const slots = new Map<string, Slot>()
|
||||
/** Every process not yet exited, including ones already hung up. */
|
||||
const sessions = new Map<string, Session>()
|
||||
let quitting = false
|
||||
|
||||
function sshMissingMessage(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32'
|
||||
? 'Orca couldn’t find OpenSSH (ssh.exe) on this computer. Install “OpenSSH Client” in Windows Settings > System > Optional features, then try again.'
|
||||
: 'Orca couldn’t find ssh on this computer. Install the OpenSSH client, then try again.'
|
||||
}
|
||||
|
||||
function clampSize(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.floor(value)))
|
||||
: fallback
|
||||
}
|
||||
|
||||
/** Frees a host's claim; a live session in it is hung up. */
|
||||
function releaseSlot(targetId: string, slot: Slot): void {
|
||||
if (slots.get(targetId) === slot) {
|
||||
slots.delete(targetId)
|
||||
}
|
||||
if (slot.session) {
|
||||
hangUp(slot.session, FORCE_KILL_AFTER_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: on Windows node-pty records the exit before it emits one, and a native call in that gap
|
||||
* throws ("already exited"). From an IPC listener that would reach the fatal main-process guard.
|
||||
*/
|
||||
function onLivePty(session: Session, call: (proc: pty.IPty) => void): void {
|
||||
try {
|
||||
call(session.proc)
|
||||
} catch {
|
||||
// Exited; its exit event follows.
|
||||
}
|
||||
}
|
||||
|
||||
function hangUp(session: Session, forceAfterMs: number): void {
|
||||
if (!session.hungUp) {
|
||||
session.hungUp = true
|
||||
if (session.paused) {
|
||||
// Why: a paused Windows pty never reads its socket's end, so it would never report exit.
|
||||
session.paused = false
|
||||
onLivePty(session, (proc) => proc.resume())
|
||||
}
|
||||
try {
|
||||
session.proc.kill()
|
||||
} catch {
|
||||
// Already gone; its exit cleans up.
|
||||
}
|
||||
}
|
||||
// Why armed again when already hung up: quit's shorter deadline must apply to it too.
|
||||
const forceKill = setTimeout(() => {
|
||||
if (sessions.has(session.id)) {
|
||||
try {
|
||||
// Why: Windows rejects a signal name; its kill() above already terminated the console.
|
||||
session.proc.kill('SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
}, forceAfterMs)
|
||||
forceKill.unref?.()
|
||||
void session.exited.then(() => clearTimeout(forceKill))
|
||||
}
|
||||
|
||||
export async function openPlainSshTerminal(
|
||||
deps: PlainSshTerminalDeps,
|
||||
request: { targetId: unknown; cols: unknown; rows: unknown; owner: number },
|
||||
sink: PlainSshTerminalSink
|
||||
): Promise<PlainSshTerminalOpenResult> {
|
||||
const targetId = typeof request.targetId === 'string' ? request.targetId : ''
|
||||
const target = targetId ? deps.getTarget(targetId) : undefined
|
||||
// Why `owner`: internal targets (a server's own SSH hops) are never the user's host.
|
||||
if (!target || target.owner) {
|
||||
return { ok: false, reason: 'unknown-target' }
|
||||
}
|
||||
if (!deps.isMoveBlocked(target.id)) {
|
||||
return { ok: false, reason: 'not-blocked' }
|
||||
}
|
||||
if (quitting) {
|
||||
return { ok: false, reason: 'cancelled' }
|
||||
}
|
||||
const held = slots.get(target.id)
|
||||
if (held && held.owner !== request.owner) {
|
||||
return { ok: false, reason: 'already-open' }
|
||||
}
|
||||
// Why the same window may replace its own: it shows one per host, so an older one is stale.
|
||||
if (held) {
|
||||
releaseSlot(target.id, held)
|
||||
}
|
||||
const slot: Slot = { owner: request.owner, token: Symbol(target.id), session: null }
|
||||
slots.set(target.id, slot)
|
||||
const stillClaimed = (): boolean => !quitting && slots.get(target.id)?.token === slot.token
|
||||
|
||||
const sshPath = deps.findSsh()
|
||||
if (!sshPath) {
|
||||
releaseSlot(target.id, slot)
|
||||
return { ok: false, reason: 'ssh-missing', message: sshMissingMessage(deps.platform) }
|
||||
}
|
||||
const args = buildPlainSshTerminalArgs(
|
||||
target,
|
||||
target.configHost && !deps.mayConfigClaimAlias(target.configHost)
|
||||
? { aliasClaimedByConfig: false }
|
||||
: undefined
|
||||
)
|
||||
let proc: pty.IPty
|
||||
try {
|
||||
const ptySpawn = await deps.loadPtySpawn()
|
||||
// A reload, a newer open or quit during the await took the claim; spawn nothing.
|
||||
if (!stillClaimed()) {
|
||||
return { ok: false, reason: 'cancelled' }
|
||||
}
|
||||
proc = spawnPtyExecutable({
|
||||
file: sshPath,
|
||||
args,
|
||||
cols: clampSize(request.cols, 80),
|
||||
rows: clampSize(request.rows, 24),
|
||||
cwd: deps.homeDir(),
|
||||
env: buildInheritedLocalPtyEnvironment(undefined),
|
||||
ptySpawn
|
||||
})
|
||||
} catch (error) {
|
||||
releaseSlot(target.id, slot)
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'spawn-failed',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
let markExited: () => void = () => {}
|
||||
const session: Session = {
|
||||
id: randomUUID(),
|
||||
owner: request.owner,
|
||||
proc,
|
||||
exited: new Promise((resolve) => {
|
||||
markExited = resolve
|
||||
}),
|
||||
hungUp: false,
|
||||
unacked: 0,
|
||||
paused: false
|
||||
}
|
||||
sessions.set(session.id, session)
|
||||
slot.session = session
|
||||
proc.onData((data) => {
|
||||
if (session.hungUp) {
|
||||
return
|
||||
}
|
||||
if (!sink({ kind: 'data', sessionId: session.id, data })) {
|
||||
releaseSlot(target.id, slot)
|
||||
return
|
||||
}
|
||||
session.unacked += data.length
|
||||
if (!session.paused && session.unacked > PAUSE_ABOVE_UNACKED) {
|
||||
session.paused = true
|
||||
onLivePty(session, (live) => live.pause())
|
||||
}
|
||||
})
|
||||
proc.onExit(({ exitCode, signal }) => {
|
||||
sessions.delete(session.id)
|
||||
if (slots.get(target.id) === slot) {
|
||||
slots.delete(target.id)
|
||||
}
|
||||
destroyPtyProcess(proc, { alreadyKilled: true })
|
||||
markExited()
|
||||
sink({
|
||||
kind: 'exit',
|
||||
sessionId: session.id,
|
||||
exitCode,
|
||||
...(signal ? { signal } : {})
|
||||
})
|
||||
})
|
||||
return { ok: true, sessionId: session.id, label: target.label }
|
||||
}
|
||||
|
||||
function ownedSession(sessionId: unknown, owner: number): Session | null {
|
||||
const session = typeof sessionId === 'string' ? sessions.get(sessionId) : undefined
|
||||
return session && session.owner === owner && !session.hungUp ? session : null
|
||||
}
|
||||
|
||||
export function writePlainSshTerminal(sessionId: unknown, owner: number, data: unknown): void {
|
||||
const session = ownedSession(sessionId, owner)
|
||||
if (session && typeof data === 'string') {
|
||||
onLivePty(session, (proc) => proc.write(data))
|
||||
}
|
||||
}
|
||||
|
||||
export function resizePlainSshTerminal(
|
||||
sessionId: unknown,
|
||||
owner: number,
|
||||
cols: unknown,
|
||||
rows: unknown
|
||||
): void {
|
||||
const session = ownedSession(sessionId, owner)
|
||||
if (session) {
|
||||
onLivePty(session, (proc) =>
|
||||
proc.resize(clampSize(cols, proc.cols), clampSize(rows, proc.rows))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function ackPlainSshTerminal(sessionId: unknown, owner: number, chars: unknown): void {
|
||||
const session = ownedSession(sessionId, owner)
|
||||
if (!session || typeof chars !== 'number' || !Number.isFinite(chars) || chars <= 0) {
|
||||
return
|
||||
}
|
||||
session.unacked = Math.max(0, session.unacked - chars)
|
||||
if (session.paused && session.unacked < RESUME_BELOW_UNACKED) {
|
||||
session.paused = false
|
||||
onLivePty(session, (proc) => proc.resume())
|
||||
}
|
||||
}
|
||||
|
||||
/** Hangs up ssh and frees its host at once; a stuck process is killed outright later. */
|
||||
export function closePlainSshTerminal(sessionId: unknown, owner: number): void {
|
||||
const session = ownedSession(sessionId, owner)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
for (const [targetId, slot] of slots) {
|
||||
if (slot.session === session) {
|
||||
releaseSlot(targetId, slot)
|
||||
return
|
||||
}
|
||||
}
|
||||
hangUp(session, FORCE_KILL_AFTER_MS)
|
||||
}
|
||||
|
||||
/** Every session a window owns, opened or still opening, e.g. when it closes or reloads. */
|
||||
export function closePlainSshTerminalsOwnedBy(owner: number): void {
|
||||
for (const [targetId, slot] of slots) {
|
||||
if (slot.owner === owner) {
|
||||
releaseSlot(targetId, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** On quit: nothing restores these, so none may outlive Orca. Resolves once all have exited. */
|
||||
export async function closeAllPlainSshTerminals(): Promise<void> {
|
||||
quitting = true
|
||||
slots.clear()
|
||||
const pending = [...sessions.values()]
|
||||
for (const session of pending) {
|
||||
hangUp(session, QUIT_FORCE_KILL_AFTER_MS)
|
||||
}
|
||||
await Promise.all(pending.map((session) => session.exited))
|
||||
}
|
||||
|
||||
/** Test seam: quit is one-way in a real app. */
|
||||
export function resetPlainSshTerminalsForTests(): void {
|
||||
quitting = false
|
||||
slots.clear()
|
||||
sessions.clear()
|
||||
}
|
||||
@@ -63,6 +63,19 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
|
||||
args.push('-o', 'ServerAliveCountMax=3')
|
||||
}
|
||||
|
||||
appendSshTargetDestinationArgs(args, target, options)
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Who to log in as and how to reach the host, ending `-- destination`: the part of a dial that
|
||||
* belongs to the saved target rather than to Orca's transport (multiplexing, batch mode, `-T`).
|
||||
*/
|
||||
export function appendSshTargetDestinationArgs(
|
||||
args: string[],
|
||||
target: SshTarget,
|
||||
options?: Pick<SystemSshBuildArgsOptions, 'aliasClaimedByConfig' | 'gssapiOnly'>
|
||||
): void {
|
||||
const useConfigHost = shouldUseOpenSshConfigHost(target)
|
||||
|
||||
// Why: a wildcard `Host *` block supplies ProxyCommand/ProxyJump for every alias, so an alias
|
||||
@@ -109,8 +122,6 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
|
||||
// must not override a fresh wildcard, Include, or Match result.
|
||||
const userHost = useConfigHost ? host : target.username ? `${target.username}@${host}` : host
|
||||
args.push('--', userHost)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,6 +58,7 @@ import type { KeybindingsApi, SettingsApi } from './api/settings-api'
|
||||
import type { ShellApi } from './api/shell-api'
|
||||
import type { SpeechApi } from './api/speech-api'
|
||||
import type { SshApi } from './api/ssh-api'
|
||||
import type { PlainSshTerminalApi } from './api/plain-ssh-terminal-bridge'
|
||||
import type { LegacyRelayBridgeApi } from './api/legacy-relay-bridge-api'
|
||||
import type { DiagnosticsApi, MemoryApi, StatsApi, TelemetryApi } from './api/telemetry-api'
|
||||
import type { UiCommandEventApi } from './api/ui-command-event-api'
|
||||
@@ -153,6 +154,8 @@ export type PreloadApi = {
|
||||
grokAccounts: GrokAccountsApi
|
||||
cursorAccounts: CursorAccountsApi
|
||||
ssh: SshApi
|
||||
/** Desktop only: an unmanaged ssh login to a host whose move is blocked. */
|
||||
plainSsh?: PlainSshTerminalApi
|
||||
/** COMPAT(legacy-relay-bridge): desktop-only, revisit 2027-01-15. */
|
||||
legacyRelayBridge?: LegacyRelayBridgeApi
|
||||
automations: AutomationsApi
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import {
|
||||
PLAIN_SSH_TERMINAL_CHANNELS,
|
||||
type PlainSshTerminalEvent,
|
||||
type PlainSshTerminalOpenResult
|
||||
} from '../../shared/plain-ssh-terminal'
|
||||
|
||||
export type PlainSshTerminalApi = {
|
||||
open: (args: {
|
||||
targetId: string
|
||||
cols: number
|
||||
rows: number
|
||||
}) => Promise<PlainSshTerminalOpenResult>
|
||||
write: (args: { sessionId: string; data: string }) => void
|
||||
resize: (args: { sessionId: string; cols: number; rows: number }) => void
|
||||
close: (args: { sessionId: string }) => void
|
||||
ack: (args: { sessionId: string; chars: number }) => void
|
||||
onEvent: (callback: (event: PlainSshTerminalEvent) => void) => () => void
|
||||
}
|
||||
|
||||
export const plainSshTerminalApi: PlainSshTerminalApi = {
|
||||
open: (args) => ipcRenderer.invoke(PLAIN_SSH_TERMINAL_CHANNELS.open, args),
|
||||
write: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.write, args),
|
||||
resize: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.resize, args),
|
||||
close: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.close, args),
|
||||
ack: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.ack, args),
|
||||
onEvent: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: PlainSshTerminalEvent): void =>
|
||||
callback(payload)
|
||||
ipcRenderer.on(PLAIN_SSH_TERMINAL_CHANNELS.event, listener)
|
||||
return () => ipcRenderer.removeListener(PLAIN_SSH_TERMINAL_CHANNELS.event, listener)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge'
|
||||
import { grokAccountsApi } from './api/grok-accounts-bridge'
|
||||
import { cursorAccountsApi } from './api/cursor-accounts-bridge'
|
||||
import { sshApi } from './api/ssh-bridge'
|
||||
import { plainSshTerminalApi } from './api/plain-ssh-terminal-bridge'
|
||||
import { legacyRelayBridgeApi } from './api/legacy-relay-bridge-api'
|
||||
import { automationsApi } from './api/automations-bridge'
|
||||
import { e2eApi } from './api/e2e-bridge'
|
||||
@@ -179,6 +180,7 @@ const api = {
|
||||
grokAccounts: grokAccountsApi,
|
||||
cursorAccounts: cursorAccountsApi,
|
||||
ssh: sshApi,
|
||||
plainSsh: plainSshTerminalApi,
|
||||
legacyRelayBridge: legacyRelayBridgeApi,
|
||||
automations: automationsApi,
|
||||
e2e: e2eApi,
|
||||
|
||||
@@ -35,6 +35,7 @@ import { usePersistedUIWriter } from './app-shell/use-persisted-ui-writer'
|
||||
import { useRuntimeGraphSync } from './app-shell/use-runtime-graph-sync'
|
||||
import { useWindowVisibilityEffects } from './app-shell/use-window-visibility-effects'
|
||||
import { SpaceWindowStatePublisher } from './components/workspace-space/SpaceWindowStatePublisher'
|
||||
import { PlainSshTerminalPanels } from './components/plain-ssh-terminal/PlainSshTerminalPanels'
|
||||
import { installActiveWorktreeRemovalSuccessor } from './lib/active-worktree-removal-successor'
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
@@ -110,6 +111,7 @@ function App(): React.JSX.Element {
|
||||
</LinkRoutingPreferenceDialogProvider>
|
||||
</ConfirmationDialogProvider>
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
<PlainSshTerminalPanels />
|
||||
</TooltipProvider>
|
||||
<SkillFreshnessNudge />
|
||||
<WorktreeBaseFallbackDialog />
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Terminal, type ITheme } from '@xterm/xterm'
|
||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal'
|
||||
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
|
||||
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
@@ -15,21 +12,17 @@ import {
|
||||
previewAdvertisesKittyKeyboard
|
||||
} from './preview-terminal-options'
|
||||
import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
|
||||
import { installPreviewTerminalCompatibility } from './preview-terminal-compatibility'
|
||||
import { createPreviewClipboardPaster } from './preview-terminal-paste'
|
||||
import { installPreviewImeBridge, type PreviewImeBridge } from './preview-terminal-ime-bridge'
|
||||
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
|
||||
import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message'
|
||||
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { installPreviewTerminalKeyHandler } from './preview-terminal-key-handler'
|
||||
import { createPreviewGridClaim } from './preview-grid-claim'
|
||||
import { createPreviewBoxFit } from './preview-terminal-box-fit'
|
||||
import { installPreviewTerminalAppMenuClipboard } from './preview-terminal-app-menu-clipboard'
|
||||
import { installPreviewTerminalRightClickPaste } from './preview-terminal-right-click-paste'
|
||||
import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/terminal-native-copy-gutter'
|
||||
import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
|
||||
import {
|
||||
openStandaloneTerminal,
|
||||
type StandaloneTerminal
|
||||
} from '../standalone-terminal/open-standalone-terminal'
|
||||
import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview'
|
||||
import { terminalPreviewApiFor } from './terminal-preview-api'
|
||||
|
||||
@@ -110,13 +103,9 @@ export function AgentTerminalPreview({
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
let standalone: StandaloneTerminal | null = null
|
||||
let terminal: Terminal | null = null
|
||||
let offData: (() => void) | null = null
|
||||
let userInputDisposable: { dispose: () => void } | null = null
|
||||
let imeBridge: PreviewImeBridge | null = null
|
||||
let disposeKeyHandler: (() => void) | null = null
|
||||
let disposeNativeCopyGutterTrim: (() => void) | null = null
|
||||
let disposeTerminalCompatibility: (() => void) | null = null
|
||||
const previewApi = terminalPreviewApiFor(ptyId)
|
||||
// Why one read: the xterm's advertisement and its mirror must never disagree.
|
||||
const mountTerminalInput = terminalInputRef.current
|
||||
@@ -150,20 +139,13 @@ export function AgentTerminalPreview({
|
||||
}
|
||||
boxResizeObserver?.observe(container)
|
||||
|
||||
let replayDepth = 0
|
||||
const writeReplayed = (chunk: string, onDone?: () => void, live = false): void => {
|
||||
// Why: a redelivered snapshot repeats the TUI's one-time kitty push, so
|
||||
// replayed bytes must apply as idempotent sets (see the tracker's docs).
|
||||
if (live) {
|
||||
kittyKeyboardModes.scan(chunk)
|
||||
} else {
|
||||
kittyKeyboardModes.scanReplay(chunk)
|
||||
}
|
||||
replayDepth++
|
||||
terminal?.write(chunk, () => {
|
||||
replayDepth--
|
||||
boxFit.schedule()
|
||||
onDone?.()
|
||||
standalone?.write(chunk, {
|
||||
live,
|
||||
onDone: () => {
|
||||
boxFit.schedule()
|
||||
onDone?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,72 +165,6 @@ export function AgentTerminalPreview({
|
||||
)
|
||||
}
|
||||
|
||||
const pasteClipboardText = createPreviewClipboardPaster({
|
||||
ptyId,
|
||||
container,
|
||||
getTerminal: () => terminal,
|
||||
getTerminalInput: () => terminalInputRef.current,
|
||||
isDisposed: () => disposed
|
||||
})
|
||||
|
||||
const disposeImeNativeTextBridge = (): void => {
|
||||
imeBridge?.dispose()
|
||||
imeBridge = null
|
||||
}
|
||||
|
||||
const installImeNativeTextBridge = (): void => {
|
||||
if (terminal) {
|
||||
// Why a live getter: kitty state can change between keydown and commit,
|
||||
// and the tracker outlives every reconnect inside this effect.
|
||||
imeBridge = installPreviewImeBridge(terminal, {
|
||||
getKittyKeyboardFlags: () => kittyKeyboardModes.flags
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const installKeyHandler = (): void => {
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
disposeKeyHandler = installPreviewTerminalKeyHandler({
|
||||
terminal,
|
||||
claimImeKeyEvent: (event) => imeBridge?.claimKeyEvent(event) ?? false,
|
||||
pasteClipboardText: (activeElement, source) =>
|
||||
void pasteClipboardText(activeElement, source),
|
||||
// Why: route through terminal.input so the chord's bytes carry core's user-input signal, like typed keys.
|
||||
sendInput: (data) => terminal?.input(data),
|
||||
getShortcutContext: () => ({
|
||||
clientPlatform: getShortcutPlatform(),
|
||||
macOptionAsAlt: macOptionAsAltRef.current,
|
||||
keybindings: useAppStore.getState().keybindings,
|
||||
terminalInput: terminalInputRef.current,
|
||||
getKittyKeyboardFlags: () => kittyKeyboardModes.flags,
|
||||
terminalShortcutPolicy: settingsRef.current?.terminalShortcutPolicy
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const installInputRouting = (): void => {
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
let pendingUserInputSignals = 0
|
||||
userInputDisposable = subscribeToTerminalUserInput(terminal, () => {
|
||||
pendingUserInputSignals = Math.min(32, pendingUserInputSignals + 1)
|
||||
})
|
||||
terminal.onData((data) => {
|
||||
const signaledUserInput = pendingUserInputSignals > 0
|
||||
if (signaledUserInput) {
|
||||
pendingUserInputSignals--
|
||||
}
|
||||
// Why: core's signal distinguishes real input from parser replies, so typing survives live replay without forwarding synthetic CPR/DA bytes.
|
||||
if (userInputDisposable ? !signaledUserInput : replayDepth > 0) {
|
||||
return
|
||||
}
|
||||
void previewApi.input(ptyId, data)
|
||||
})
|
||||
}
|
||||
|
||||
const replayConnection = (
|
||||
connection: Awaited<ReturnType<typeof previewApi.connect>>,
|
||||
replaceExisting: boolean,
|
||||
@@ -256,8 +172,9 @@ export function AgentTerminalPreview({
|
||||
): void => {
|
||||
const snap = connection.snapshot!
|
||||
if (!terminal) {
|
||||
terminal = new Terminal(
|
||||
buildPreviewTerminalOptions({
|
||||
standalone = openStandaloneTerminal({
|
||||
container,
|
||||
options: buildPreviewTerminalOptions({
|
||||
settings: settingsRef.current,
|
||||
terminalInput: mountTerminalInput,
|
||||
macOptionIsMeta: macOptionAsAltRef.current === 'true',
|
||||
@@ -266,23 +183,20 @@ export function AgentTerminalPreview({
|
||||
cols: clamp(snap.cols ?? FALLBACK_COLS, 2, 500),
|
||||
rows: clamp(snap.rows ?? FALLBACK_ROWS, 2, 200),
|
||||
scrollback: PREVIEW_SCROLLBACK_BUFFER_ROWS
|
||||
})
|
||||
)
|
||||
try {
|
||||
terminal.open(container)
|
||||
} catch {
|
||||
terminal.dispose()
|
||||
terminal = null
|
||||
}),
|
||||
kittyKeyboardModes,
|
||||
sessionKey: ptyId,
|
||||
sendInput: (data) => previewApi.input(ptyId, data),
|
||||
answersQueries: false,
|
||||
getSettings: () => settingsRef.current,
|
||||
getMacOptionAsAlt: () => macOptionAsAltRef.current,
|
||||
getTerminalInput: () => terminalInputRef.current
|
||||
})
|
||||
if (!standalone) {
|
||||
return
|
||||
}
|
||||
terminal = standalone.terminal
|
||||
terminalRef.current = terminal
|
||||
disposeTerminalCompatibility = installPreviewTerminalCompatibility(terminal, {
|
||||
getSettings: () => settingsRef.current
|
||||
})
|
||||
disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose
|
||||
installInputRouting()
|
||||
installImeNativeTextBridge()
|
||||
installKeyHandler()
|
||||
} else if (replaceExisting) {
|
||||
// Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives.
|
||||
terminal.resize(
|
||||
@@ -325,16 +239,8 @@ export function AgentTerminalPreview({
|
||||
const releaseTerminal = (): void => {
|
||||
offData?.()
|
||||
offData = null
|
||||
userInputDisposable?.dispose()
|
||||
userInputDisposable = null
|
||||
disposeImeNativeTextBridge()
|
||||
disposeTerminalCompatibility?.()
|
||||
disposeTerminalCompatibility = null
|
||||
disposeKeyHandler?.()
|
||||
disposeKeyHandler = null
|
||||
disposeNativeCopyGutterTrim?.()
|
||||
disposeNativeCopyGutterTrim = null
|
||||
terminal?.dispose()
|
||||
standalone?.dispose()
|
||||
standalone = null
|
||||
terminal = null
|
||||
terminalRef.current = null
|
||||
void previewApi.unsubscribe(ptyId)
|
||||
@@ -367,20 +273,6 @@ export function AgentTerminalPreview({
|
||||
replayConnection(connection, replaceExisting, () => void setup(true))
|
||||
}
|
||||
|
||||
const disposeAppMenuClipboard = installPreviewTerminalAppMenuClipboard({
|
||||
container,
|
||||
getTerminal: () => terminal,
|
||||
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source)
|
||||
})
|
||||
const disposeRightClickPaste = installPreviewTerminalRightClickPaste({
|
||||
container,
|
||||
getTerminal: () => terminal,
|
||||
// Same default as the pane: Windows users expect terminal-style right-click.
|
||||
isRightClickToPasteEnabled: () =>
|
||||
settingsRef.current?.terminalRightClickToPaste ?? isWindowsUserAgent(),
|
||||
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source)
|
||||
})
|
||||
|
||||
offData = previewApi.onData((payload) => {
|
||||
if (payload.ptyId !== ptyId) {
|
||||
return
|
||||
@@ -402,8 +294,6 @@ export function AgentTerminalPreview({
|
||||
}
|
||||
gridClaim.dispose()
|
||||
boxResizeObserver?.disconnect()
|
||||
disposeAppMenuClipboard()
|
||||
disposeRightClickPaste()
|
||||
releaseTerminal()
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -6,17 +6,19 @@ import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal
|
||||
import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits'
|
||||
import { pasteTerminalText } from '@/components/terminal-pane/terminal-bracketed-paste'
|
||||
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
|
||||
import { terminalPreviewApiFor } from './terminal-preview-api'
|
||||
|
||||
export type PreviewTerminalPasteSource = 'keyboard' | 'app-menu' | 'right-click'
|
||||
|
||||
/**
|
||||
* Clipboard paste for the preview terminal, on the pane's coordinator: large
|
||||
* Clipboard paste for a standalone terminal, on the pane's coordinator: large
|
||||
* pastes stream as bounded IPC payloads, and the plan re-checks that the same
|
||||
* terminal still owns focus between chunks.
|
||||
*/
|
||||
export function createPreviewClipboardPaster(deps: {
|
||||
/** Names the session for paste planning; a remote-runtime pty id routes as remote. */
|
||||
ptyId: string
|
||||
/** Where pasted bytes go when they bypass xterm, e.g. a bracketed paste's closing marker. */
|
||||
writeInput: (data: string) => boolean | Promise<boolean>
|
||||
container: HTMLElement
|
||||
getTerminal: () => Terminal | null
|
||||
getTerminalInput: () => DashboardCardTerminalInput | null
|
||||
@@ -61,7 +63,7 @@ export function createPreviewClipboardPaster(deps: {
|
||||
await executeTerminalPastePlan(plan, {
|
||||
// Why: stream large pastes so the renderer never emits one huge IPC payload.
|
||||
pasteText: (text, options) => pasteTerminalText(pasteTerminal, text, options),
|
||||
writePty: (data) => terminalPreviewApiFor(deps.ptyId).input(deps.ptyId, data),
|
||||
writePty: (data) => deps.writeInput(data),
|
||||
isTargetCurrent: targetIsCurrent,
|
||||
// Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY.
|
||||
canContinue: () => true
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
|
||||
import { isSshHostMoveBlocked } from '../../../../shared/ssh-host-move-blocked'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { showPlainSshPanel } from '@/ssh/plain-ssh-terminal-panels'
|
||||
|
||||
/**
|
||||
* The way to keep working on a host whose move is blocked. Only offered then, and only for a host
|
||||
* this desktop dials itself: a remote Orca server's host is that server's to reach.
|
||||
*/
|
||||
export function OpenPlainSshTerminalButton({
|
||||
targetId,
|
||||
targetLabel,
|
||||
status,
|
||||
ownerEnvironmentId = null
|
||||
}: {
|
||||
targetId: string
|
||||
targetLabel: string
|
||||
status: SshManagedServerStatus | undefined
|
||||
ownerEnvironmentId?: string | null
|
||||
}): React.JSX.Element | null {
|
||||
if (ownerEnvironmentId || !isSshHostMoveBlocked(status) || !window.api.plainSsh) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => showPlainSshPanel(targetId, targetLabel)}
|
||||
>
|
||||
{translate('auto.components.plainSshTerminal.open', 'Open plain SSH terminal')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
|
||||
import type {
|
||||
PlainSshTerminalEvent,
|
||||
PlainSshTerminalOpenResult
|
||||
} from '../../../../shared/plain-ssh-terminal'
|
||||
import { OpenPlainSshTerminalButton } from './OpenPlainSshTerminalButton'
|
||||
import { PlainSshTerminalPanels } from './PlainSshTerminalPanels'
|
||||
import { removePlainSshPanel } from '@/ssh/plain-ssh-terminal-panels'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
|
||||
const written: string[] = []
|
||||
|
||||
// The standalone terminal is the preview's, covered by its tests; this stands in for xterm.
|
||||
vi.mock('@/components/standalone-terminal/open-standalone-terminal', () => ({
|
||||
openStandaloneTerminal: () => ({
|
||||
terminal: {
|
||||
options: {},
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
loadAddon: () => {},
|
||||
focus: () => {},
|
||||
onResize: () => ({ dispose: () => {} })
|
||||
},
|
||||
write: (chunk: string, options: { onDone?: () => void }) => {
|
||||
written.push(chunk)
|
||||
options.onDone?.()
|
||||
},
|
||||
dispose: () => {}
|
||||
})
|
||||
}))
|
||||
const fit = vi.fn()
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: class {
|
||||
fit(): void {
|
||||
fit()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const blocked: SshManagedServerStatus = {
|
||||
kind: 'relay',
|
||||
reason: 'refused',
|
||||
detail: 'This SSH host cannot move yet: automations (2).'
|
||||
}
|
||||
|
||||
let emit: (event: PlainSshTerminalEvent) => void = () => {}
|
||||
const open = vi.fn<(args: unknown) => Promise<PlainSshTerminalOpenResult>>()
|
||||
const close = vi.fn()
|
||||
const ack = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
written.length = 0
|
||||
open.mockReset()
|
||||
close.mockReset()
|
||||
ack.mockReset()
|
||||
Object.defineProperty(window, 'ResizeObserver', {
|
||||
configurable: true,
|
||||
// Full surface: a hovered header button's Tooltip positions itself with unobserve too.
|
||||
value: class {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
plainSsh: {
|
||||
open,
|
||||
close,
|
||||
ack,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
onEvent: (callback: (event: PlainSshTerminalEvent) => void) => {
|
||||
emit = callback
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => removePlainSshPanel('lambda'))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
/** The open reply arrives when the test says, so nothing races it. */
|
||||
function deferOpen(): (result: PlainSshTerminalOpenResult) => void {
|
||||
let resolve: (result: PlainSshTerminalOpenResult) => void = () => {}
|
||||
open.mockImplementation(
|
||||
() =>
|
||||
new Promise((done) => {
|
||||
resolve = done
|
||||
})
|
||||
)
|
||||
return (result) => resolve(result)
|
||||
}
|
||||
|
||||
function Harness({ status }: { status: SshManagedServerStatus | undefined }): React.JSX.Element {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<OpenPlainSshTerminalButton targetId="lambda" targetLabel="lambda" status={status} />
|
||||
<PlainSshTerminalPanels />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('plain SSH terminal', () => {
|
||||
it('is offered only while the host’s move is blocked, and only for this desktop’s host', () => {
|
||||
const { rerender } = render(<Harness status={undefined} />)
|
||||
expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull()
|
||||
rerender(<Harness status={{ kind: 'managed', environmentId: 'e' }} />)
|
||||
expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull()
|
||||
rerender(<Harness status={{ ...blocked, bridged: true }} />)
|
||||
expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull()
|
||||
rerender(
|
||||
<OpenPlainSshTerminalButton
|
||||
targetId="lambda"
|
||||
targetLabel="lambda"
|
||||
status={blocked}
|
||||
ownerEnvironmentId="env-1"
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a labelled unmanaged window, shows early output, and closes when ssh exits cleanly', async () => {
|
||||
let resolveOpen: (result: PlainSshTerminalOpenResult) => void = () => {}
|
||||
open.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveOpen = resolve
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(<Harness status={blocked} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
|
||||
|
||||
expect(screen.getByText('Plain SSH · lambda')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Plain SSH — unmanaged\. Not an Orca workspace/)).toBeInTheDocument()
|
||||
expect(open).toHaveBeenCalledWith({ targetId: 'lambda', cols: 80, rows: 24 })
|
||||
|
||||
// ssh can print before the open reply arrives.
|
||||
act(() => emit({ kind: 'data', sessionId: 's1', data: 'Enter passphrase: ' }))
|
||||
await act(async () => resolveOpen({ ok: true, sessionId: 's1', label: 'lambda' }))
|
||||
expect(written).toEqual(['Enter passphrase: '])
|
||||
// Rendered output is acked, so main resumes a paused ssh.
|
||||
expect(ack).toHaveBeenCalledWith({ sessionId: 's1', chars: 'Enter passphrase: '.length })
|
||||
|
||||
act(() => emit({ kind: 'exit', sessionId: 's1', exitCode: 0 }))
|
||||
await waitFor(() => expect(screen.queryByText('Plain SSH · lambda')).toBeNull())
|
||||
})
|
||||
|
||||
it('keeps output when ssh is killed by a signal, even with exit code 0', async () => {
|
||||
const resolveOpen = deferOpen()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness status={blocked} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
|
||||
await waitFor(() => expect(open).toHaveBeenCalled())
|
||||
await act(async () => resolveOpen({ ok: true, sessionId: 's4', label: 'lambda' }))
|
||||
act(() => emit({ kind: 'exit', sessionId: 's4', exitCode: 0, signal: 9 }))
|
||||
expect(await screen.findByText(/ssh was stopped \(signal 9\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still ends a session whose exit arrives before its open reply', async () => {
|
||||
let resolveOpen: (result: PlainSshTerminalOpenResult) => void = () => {}
|
||||
open.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveOpen = resolve
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(<Harness status={blocked} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
|
||||
act(() => {
|
||||
// A flood from another window's session must not crowd out this session's exit.
|
||||
emit({ kind: 'data', sessionId: 'other', data: 'y'.repeat(300 * 1024) })
|
||||
emit({ kind: 'data', sessionId: 's5', data: 'Permission denied (publickey).\r\n' })
|
||||
emit({ kind: 'exit', sessionId: 's5', exitCode: 255 })
|
||||
})
|
||||
await act(async () => resolveOpen({ ok: true, sessionId: 's5', label: 'lambda' }))
|
||||
expect(await screen.findByText(/ssh exited with code 255/)).toBeInTheDocument()
|
||||
expect(written).toEqual(['Permission denied (publickey).\r\n'])
|
||||
})
|
||||
|
||||
it('keeps a failed login readable, with no shell to fall back to', async () => {
|
||||
const resolveOpen = deferOpen()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness status={blocked} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
|
||||
await waitFor(() => expect(open).toHaveBeenCalled())
|
||||
await act(async () => resolveOpen({ ok: true, sessionId: 's2', label: 'lambda' }))
|
||||
act(() => emit({ kind: 'exit', sessionId: 's2', exitCode: 255 }))
|
||||
expect(await screen.findByText(/ssh exited with code 255/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close and end session' }))
|
||||
expect(screen.queryByText('Plain SSH · lambda')).toBeNull()
|
||||
})
|
||||
|
||||
it('never refits a minimized window, which would shrink the remote terminal to 2x1', async () => {
|
||||
const width = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600)
|
||||
const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(400)
|
||||
try {
|
||||
const resolveOpen = deferOpen()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness status={blocked} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
|
||||
await act(async () => resolveOpen({ ok: true, sessionId: 's6', label: 'lambda' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Minimize' }))
|
||||
fit.mockClear()
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
settings: { ...getDefaultSettings('/home/me'), terminalFontSize: 18 }
|
||||
})
|
||||
)
|
||||
expect(fit).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
width.mockRestore()
|
||||
height.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows why it can’t open, e.g. no OpenSSH on Windows', async () => {
|
||||
open.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'ssh-missing',
|
||||
message: 'Orca couldn’t find OpenSSH (ssh.exe) on this computer.'
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<Harness status={blocked} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
|
||||
expect(await screen.findByText(/couldn’t find OpenSSH/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hangs up when its window closes', async () => {
|
||||
const resolveOpen = deferOpen()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness status={blocked} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
|
||||
await waitFor(() => expect(open).toHaveBeenCalled())
|
||||
await act(async () => resolveOpen({ ok: true, sessionId: 's3', label: 'lambda' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Close and end session' }))
|
||||
expect(close).toHaveBeenCalledWith({ sessionId: 's3' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import { Minus, SquareTerminal, X } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
minimizePlainSshPanel,
|
||||
removePlainSshPanel,
|
||||
showPlainSshPanel,
|
||||
usePlainSshPanels,
|
||||
type PlainSshPanel
|
||||
} from '@/ssh/plain-ssh-terminal-panels'
|
||||
import { PlainSshTerminalView, type PlainSshTerminalPhase } from './PlainSshTerminalView'
|
||||
|
||||
export function plainSshUnmanagedNotice(): string {
|
||||
return translate(
|
||||
'auto.components.plainSshTerminal.unmanaged',
|
||||
'Plain SSH — unmanaged. Not an Orca workspace: agents and tabs here aren’t tracked, and this terminal isn’t restored. Closing it ends the SSH session.'
|
||||
)
|
||||
}
|
||||
|
||||
function phaseText(phase: PlainSshTerminalPhase): string | null {
|
||||
switch (phase.kind) {
|
||||
case 'opening':
|
||||
case 'open':
|
||||
return null
|
||||
case 'failed':
|
||||
return phase.message
|
||||
case 'ended':
|
||||
return phase.signal
|
||||
? translate(
|
||||
'auto.components.plainSshTerminal.endedBySignal',
|
||||
'ssh was stopped (signal {{signal}}). Close this window when you’re done reading.',
|
||||
{ signal: phase.signal }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.plainSshTerminal.ended',
|
||||
'ssh exited with code {{code}}. Close this window when you’re done reading.',
|
||||
{ code: phase.exitCode }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function HeaderIconButton({
|
||||
label,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" size="icon-xs" variant="ghost" aria-label={label} onClick={onClick}>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** A non-modal window over Orca; minimized windows wait in a strip at the bottom right. */
|
||||
function PlainSshTerminalCard({ panel }: { panel: PlainSshPanel }): React.JSX.Element {
|
||||
const [phase, setPhase] = useState<PlainSshTerminalPhase>({ kind: 'opening' })
|
||||
const message = phaseText(phase)
|
||||
return (
|
||||
<section
|
||||
aria-label={translate('auto.components.plainSshTerminal.title', 'Plain SSH · {{host}}', {
|
||||
host: panel.label
|
||||
})}
|
||||
data-plain-ssh-terminal={panel.targetId}
|
||||
// Why hidden, not unmounted: minimizing must keep the SSH session and its scrollback.
|
||||
className={cn(
|
||||
'pointer-events-auto flex h-[26rem] w-[min(44rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-floating',
|
||||
panel.minimized && 'hidden'
|
||||
)}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<SquareTerminal className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{translate('auto.components.plainSshTerminal.title', 'Plain SSH · {{host}}', {
|
||||
host: panel.label
|
||||
})}
|
||||
</div>
|
||||
<HeaderIconButton
|
||||
label={translate('auto.components.plainSshTerminal.minimize', 'Minimize')}
|
||||
onClick={() => minimizePlainSshPanel(panel.targetId)}
|
||||
>
|
||||
<Minus />
|
||||
</HeaderIconButton>
|
||||
<HeaderIconButton
|
||||
label={translate('auto.components.plainSshTerminal.close', 'Close and end session')}
|
||||
onClick={() => removePlainSshPanel(panel.targetId)}
|
||||
>
|
||||
<X />
|
||||
</HeaderIconButton>
|
||||
</header>
|
||||
<p className="border-b border-border bg-muted px-3 py-1.5 text-xs text-status-warning">
|
||||
{plainSshUnmanagedNotice()}
|
||||
</p>
|
||||
{message ? (
|
||||
<p className="px-3 py-1.5 text-xs text-destructive [overflow-wrap:anywhere]">{message}</p>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1">
|
||||
<PlainSshTerminalView
|
||||
targetId={panel.targetId}
|
||||
hidden={panel.minimized}
|
||||
onPhase={setPhase}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function PlainSshTerminalPanels(): React.JSX.Element | null {
|
||||
const panels = usePlainSshPanels()
|
||||
if (panels.length === 0) {
|
||||
return null
|
||||
}
|
||||
const minimized = panels.filter((panel) => panel.minimized)
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 bottom-4 z-50 flex flex-col items-end gap-2">
|
||||
{panels.map((panel) => (
|
||||
<PlainSshTerminalCard key={panel.targetId} panel={panel} />
|
||||
))}
|
||||
{minimized.length > 0 ? (
|
||||
<div className="pointer-events-auto flex flex-wrap justify-end gap-2">
|
||||
{minimized.map((panel) => (
|
||||
<Button
|
||||
key={panel.targetId}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => showPlainSshPanel(panel.targetId, panel.label)}
|
||||
>
|
||||
<SquareTerminal />
|
||||
{translate('auto.components.plainSshTerminal.title', 'Plain SSH · {{host}}', {
|
||||
host: panel.label
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type {
|
||||
PlainSshTerminalEvent,
|
||||
PlainSshTerminalOpenResult
|
||||
} from '../../../../shared/plain-ssh-terminal'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
|
||||
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
|
||||
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
|
||||
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
|
||||
import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
|
||||
import { syncPreviewTerminalLigatures } from '@/components/dashboard-popout/preview-terminal-ligatures'
|
||||
import {
|
||||
buildPreviewAppearanceOptions,
|
||||
buildPreviewTerminalOptions
|
||||
} from '@/components/dashboard-popout/preview-terminal-options'
|
||||
import { openStandaloneTerminal } from '@/components/standalone-terminal/open-standalone-terminal'
|
||||
import { removePlainSshPanel } from '@/ssh/plain-ssh-terminal-panels'
|
||||
|
||||
export type PlainSshTerminalPhase =
|
||||
| { kind: 'opening' }
|
||||
| { kind: 'open' }
|
||||
| { kind: 'failed'; message: string }
|
||||
| { kind: 'ended'; exitCode: number; signal?: number }
|
||||
|
||||
/** Output held while the open reply is in flight; a login banner or prompt is far smaller. */
|
||||
const EARLY_DATA_CHAR_LIMIT = 256 * 1024
|
||||
const SCROLLBACK_ROWS = 5_000
|
||||
|
||||
export function plainSshOpenFailureText(
|
||||
result: Extract<PlainSshTerminalOpenResult, { ok: false }>
|
||||
): string {
|
||||
switch (result.reason) {
|
||||
case 'unknown-target':
|
||||
return translate(
|
||||
'auto.components.plainSshTerminal.unknownTarget',
|
||||
'This SSH host is no longer saved in Orca.'
|
||||
)
|
||||
case 'not-blocked':
|
||||
return translate(
|
||||
'auto.components.plainSshTerminal.notBlocked',
|
||||
'Orca can reach this host again, so use its terminals instead. Plain SSH is only for while Orca can’t set up on the host.'
|
||||
)
|
||||
case 'already-open':
|
||||
return translate(
|
||||
'auto.components.plainSshTerminal.alreadyOpen',
|
||||
'Another Orca window already has a plain SSH terminal open to this host.'
|
||||
)
|
||||
case 'cancelled':
|
||||
return translate(
|
||||
'auto.components.plainSshTerminal.cancelled',
|
||||
'This plain SSH terminal was closed before it started.'
|
||||
)
|
||||
case 'ssh-missing':
|
||||
case 'spawn-failed':
|
||||
return result.message
|
||||
}
|
||||
}
|
||||
|
||||
function resolveThemeColors(
|
||||
settings: GlobalSettings | null,
|
||||
systemPrefersDark: boolean
|
||||
): { theme: ITheme | null; mode: 'dark' | 'light' } {
|
||||
if (!settings) {
|
||||
return { theme: null, mode: 'dark' }
|
||||
}
|
||||
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
|
||||
return {
|
||||
theme: composeActiveTerminalTheme(
|
||||
appearance.theme ?? getBuiltinTheme(appearance.themeName),
|
||||
settings
|
||||
),
|
||||
mode: appearance.mode
|
||||
}
|
||||
}
|
||||
|
||||
/** One plain SSH session on Orca's standalone terminal; it starts on mount and hangs up on unmount. */
|
||||
export function PlainSshTerminalView({
|
||||
targetId,
|
||||
hidden,
|
||||
onPhase
|
||||
}: {
|
||||
targetId: string
|
||||
hidden: boolean
|
||||
onPhase: (phase: PlainSshTerminalPhase) => void
|
||||
}): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitRef = useRef<FitAddon | null>(null)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const systemPrefersDark = useSystemPrefersDark()
|
||||
const macOptionAsAlt = useEffectiveMacOptionAsAlt(settings?.terminalMacOptionAsAlt)
|
||||
// Why refs: keys and appearance read live values; a remount would hang up the SSH session.
|
||||
const settingsRef = useRef(settings)
|
||||
const systemPrefersDarkRef = useRef(systemPrefersDark)
|
||||
const macOptionAsAltRef = useRef(macOptionAsAlt)
|
||||
const onPhaseRef = useRef(onPhase)
|
||||
useLayoutEffect(() => {
|
||||
settingsRef.current = settings
|
||||
systemPrefersDarkRef.current = systemPrefersDark
|
||||
macOptionAsAltRef.current = macOptionAsAlt
|
||||
onPhaseRef.current = onPhase
|
||||
}, [settings, systemPrefersDark, macOptionAsAlt, onPhase])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const api = window.api.plainSsh
|
||||
if (!container || !api) {
|
||||
return
|
||||
}
|
||||
const mountSettings = settingsRef.current
|
||||
const { theme, mode } = resolveThemeColors(mountSettings, systemPrefersDarkRef.current)
|
||||
const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker({ kittyKeyboard: true })
|
||||
let sessionId: string | null = null
|
||||
const standalone = openStandaloneTerminal({
|
||||
container,
|
||||
options: buildPreviewTerminalOptions({
|
||||
settings: mountSettings,
|
||||
terminalInput: null,
|
||||
macOptionIsMeta: macOptionAsAltRef.current === 'true',
|
||||
theme,
|
||||
themeMode: mode,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
scrollback: SCROLLBACK_ROWS
|
||||
}),
|
||||
kittyKeyboardModes,
|
||||
sessionKey: `plain-ssh:${targetId}`,
|
||||
sendInput: (data) => {
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
api.write({ sessionId, data })
|
||||
return true
|
||||
},
|
||||
// Why: this xterm is the SSH session's only emulator, so remote programs wait on its replies.
|
||||
answersQueries: true,
|
||||
getSettings: () => settingsRef.current,
|
||||
getMacOptionAsAlt: () => macOptionAsAltRef.current,
|
||||
getTerminalInput: () => null
|
||||
})
|
||||
if (!standalone) {
|
||||
onPhaseRef.current({
|
||||
kind: 'failed',
|
||||
message: translate(
|
||||
'auto.components.plainSshTerminal.terminalUnavailable',
|
||||
'Orca couldn’t open a terminal here.'
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
const { terminal } = standalone
|
||||
const fit = new FitAddon()
|
||||
terminal.loadAddon(fit)
|
||||
terminalRef.current = terminal
|
||||
fitRef.current = fit
|
||||
fit.fit()
|
||||
|
||||
let phase: 'opening' | 'open' | 'done' = 'opening'
|
||||
let disposed = false
|
||||
const early: PlainSshTerminalEvent[] = []
|
||||
let earlyDataChars = 0
|
||||
// Why: output dropped past the early cap was never rendered, but main still counts it unacked.
|
||||
const droppedEarlyChars = new Map<string, number>()
|
||||
let unsubscribe: () => void = () => {}
|
||||
const finish = (next: PlainSshTerminalPhase | null): void => {
|
||||
phase = 'done'
|
||||
sessionId = null
|
||||
early.length = 0
|
||||
earlyDataChars = 0
|
||||
droppedEarlyChars.clear()
|
||||
unsubscribe()
|
||||
if (next) {
|
||||
onPhaseRef.current(next)
|
||||
}
|
||||
}
|
||||
const handle = (event: PlainSshTerminalEvent, id: string): void => {
|
||||
if (event.kind === 'data') {
|
||||
standalone.write(event.data, {
|
||||
live: true,
|
||||
onDone: () => {
|
||||
if (!disposed) {
|
||||
api.ack({ sessionId: id, chars: event.data.length })
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
// A clean logout closes the window; anything else keeps its output, with no shell after it.
|
||||
const clean = event.exitCode === 0 && !event.signal
|
||||
finish(clean ? null : { kind: 'ended', exitCode: event.exitCode, signal: event.signal })
|
||||
if (clean) {
|
||||
removePlainSshPanel(targetId)
|
||||
}
|
||||
}
|
||||
unsubscribe = api.onEvent((event) => {
|
||||
if (phase === 'opening') {
|
||||
// Exits are always kept: a session that ended before its id arrived must still end here.
|
||||
if (event.kind === 'exit') {
|
||||
early.push(event)
|
||||
} else if (
|
||||
// Why: once a session drops output, later chunks would leave a hole mid escape sequence.
|
||||
!droppedEarlyChars.has(event.sessionId) &&
|
||||
earlyDataChars + event.data.length <= EARLY_DATA_CHAR_LIMIT
|
||||
) {
|
||||
earlyDataChars += event.data.length
|
||||
early.push(event)
|
||||
} else {
|
||||
droppedEarlyChars.set(
|
||||
event.sessionId,
|
||||
(droppedEarlyChars.get(event.sessionId) ?? 0) + event.data.length
|
||||
)
|
||||
}
|
||||
} else if (phase === 'open' && sessionId && event.sessionId === sessionId) {
|
||||
handle(event, sessionId)
|
||||
}
|
||||
})
|
||||
void api
|
||||
.open({ targetId, cols: terminal.cols, rows: terminal.rows })
|
||||
.then((result) => {
|
||||
if (disposed) {
|
||||
if (result.ok) {
|
||||
api.close({ sessionId: result.sessionId })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
finish({ kind: 'failed', message: plainSshOpenFailureText(result) })
|
||||
return
|
||||
}
|
||||
const id = result.sessionId
|
||||
sessionId = id
|
||||
phase = 'open'
|
||||
onPhaseRef.current({ kind: 'open' })
|
||||
const dropped = droppedEarlyChars.get(id)
|
||||
droppedEarlyChars.clear()
|
||||
if (dropped) {
|
||||
api.ack({ sessionId: id, chars: dropped })
|
||||
}
|
||||
earlyDataChars = 0
|
||||
for (const event of early.splice(0)) {
|
||||
if (phase === 'open' && event.sessionId === id) {
|
||||
handle(event, id)
|
||||
}
|
||||
}
|
||||
terminal.focus()
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!disposed) {
|
||||
finish({
|
||||
kind: 'failed',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
const resizeListener = terminal.onResize(({ cols, rows }) => {
|
||||
if (sessionId) {
|
||||
api.resize({ sessionId, cols, rows })
|
||||
}
|
||||
})
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (container.offsetWidth > 0 && container.offsetHeight > 0) {
|
||||
fit.fit()
|
||||
}
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
disposed = true
|
||||
observer.disconnect()
|
||||
resizeListener.dispose()
|
||||
unsubscribe()
|
||||
if (sessionId) {
|
||||
api.close({ sessionId })
|
||||
}
|
||||
standalone.dispose()
|
||||
terminalRef.current = null
|
||||
fitRef.current = null
|
||||
}
|
||||
}, [targetId])
|
||||
|
||||
// Why: appearance and Option-as-Alt changes must land live; reopening would hang up ssh.
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
const { theme, mode } = resolveThemeColors(settings, systemPrefersDark)
|
||||
Object.assign(terminal.options, {
|
||||
...buildPreviewAppearanceOptions(settings, macOptionAsAlt === 'true'),
|
||||
theme: theme ?? undefined,
|
||||
minimumContrastRatio: resolveTerminalMinimumContrastRatio(
|
||||
theme?.background,
|
||||
mode,
|
||||
settings?.terminalMinimumContrastRatio
|
||||
)
|
||||
})
|
||||
syncPreviewTerminalLigatures(terminal, settings)
|
||||
// Why the guard: a minimized panel measures 0x0, and fitting would shrink the remote pty to 2x1.
|
||||
const container = containerRef.current
|
||||
if (!hidden && container && container.offsetWidth > 0 && container.offsetHeight > 0) {
|
||||
fitRef.current?.fit()
|
||||
}
|
||||
}, [settings, systemPrefersDark, macOptionAsAlt, hidden])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hidden) {
|
||||
fitRef.current?.fit()
|
||||
terminalRef.current?.focus()
|
||||
}
|
||||
}, [hidden])
|
||||
|
||||
return <div ref={containerRef} className="size-full min-h-0 overflow-hidden px-2 py-1" />
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './SshHostKeepServerVersionDialog'
|
||||
import { SshHostDeltaMoveDialog } from './SshHostDeltaMoveDialog'
|
||||
import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction'
|
||||
import { OpenPlainSshTerminalButton } from '../plain-ssh-terminal/OpenPlainSshTerminalButton'
|
||||
import { canMoveSshHostAnyway, SshHostMoveAnywayDialog } from './SshHostMoveAnywayAction'
|
||||
import { canRestartSshHostServer, SshHostRestartServerDialog } from './SshHostRestartServerAction'
|
||||
import { getManagedOrcadApi } from './use-managed-server-catalog'
|
||||
@@ -48,6 +49,11 @@ export function SshTargetServerStatusLine({
|
||||
{line.hint ? <p className="px-1 text-xs text-muted-foreground">{line.hint}</p> : null}
|
||||
{/* Why inline, not in the menu: it is the way out of this failure. */}
|
||||
<SshHostNodeMirrorAction targetId={target.id} status={state?.managedServer} />
|
||||
<OpenPlainSshTerminalButton
|
||||
targetId={target.id}
|
||||
targetLabel={target.label}
|
||||
status={state?.managedServer}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { openStandaloneTerminal } from './open-standalone-terminal'
|
||||
|
||||
let emitData: (data: string) => void = () => {}
|
||||
let signalUserInput: () => void = () => {}
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: class {
|
||||
open(): void {}
|
||||
dispose(): void {}
|
||||
write(_data: string, done?: () => void): void {
|
||||
done?.()
|
||||
}
|
||||
onData(listener: (data: string) => void): { dispose: () => void } {
|
||||
emitData = listener
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
|
||||
vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({
|
||||
subscribeToTerminalUserInput: (_terminal: unknown, listener: () => void) => {
|
||||
signalUserInput = listener
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/terminal-native-copy-gutter', () => ({
|
||||
installTerminalNativeCopyGutterTrim: () => ({ dispose: () => {} })
|
||||
}))
|
||||
vi.mock('../dashboard-popout/preview-terminal-compatibility', () => ({
|
||||
installPreviewTerminalCompatibility: () => () => {}
|
||||
}))
|
||||
vi.mock('../dashboard-popout/preview-terminal-ime-bridge', () => ({
|
||||
installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: () => {} })
|
||||
}))
|
||||
vi.mock('../dashboard-popout/preview-terminal-key-handler', () => ({
|
||||
installPreviewTerminalKeyHandler: () => () => {}
|
||||
}))
|
||||
vi.mock('../dashboard-popout/preview-terminal-app-menu-clipboard', () => ({
|
||||
installPreviewTerminalAppMenuClipboard: () => () => {}
|
||||
}))
|
||||
vi.mock('../dashboard-popout/preview-terminal-right-click-paste', () => ({
|
||||
installPreviewTerminalRightClickPaste: () => () => {}
|
||||
}))
|
||||
|
||||
function open(answersQueries: boolean): ReturnType<typeof vi.fn> {
|
||||
const sendInput = vi.fn(() => true)
|
||||
openStandaloneTerminal({
|
||||
container: document.createElement('div'),
|
||||
options: {},
|
||||
kittyKeyboardModes: new TerminalKittyKeyboardModeTracker(),
|
||||
sessionKey: 's',
|
||||
sendInput,
|
||||
answersQueries,
|
||||
getSettings: () => null,
|
||||
getMacOptionAsAlt: () => 'false',
|
||||
getTerminalInput: () => null
|
||||
})
|
||||
return sendInput
|
||||
}
|
||||
|
||||
const CURSOR_POSITION_REPLY = '\u001b[5;1R'
|
||||
|
||||
describe('standalone terminal input', () => {
|
||||
it('forwards the user’s typing either way', () => {
|
||||
const sendInput = open(false)
|
||||
signalUserInput()
|
||||
emitData('ls\r')
|
||||
expect(sendInput).toHaveBeenCalledWith('ls\r')
|
||||
})
|
||||
|
||||
it('leaves query replies to the real pane when it only mirrors one', () => {
|
||||
const sendInput = open(false)
|
||||
emitData(CURSOR_POSITION_REPLY)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers queries when it is the session’s only emulator', () => {
|
||||
const sendInput = open(true)
|
||||
emitData(CURSOR_POSITION_REPLY)
|
||||
expect(sendInput).toHaveBeenCalledWith(CURSOR_POSITION_REPLY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* One xterm outside the pane system, with everything a typed-into terminal needs: Orca's Unicode
|
||||
* widths, links and IME handling, the pane's copy/paste and shortcut policy, kitty keyboard flags,
|
||||
* and input that never forwards xterm's own replies to replayed output. Callers own the transport
|
||||
* (where output comes from, where input goes, and the backpressure ack in `write`'s callback).
|
||||
* Used by the agent dashboard preview and the plain SSH terminal.
|
||||
*/
|
||||
import { Terminal, type ITerminalInitOnlyOptions, type ITerminalOptions } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
|
||||
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { MacOptionAsAlt } from '@/components/terminal-pane/terminal-shortcut-policy'
|
||||
import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal'
|
||||
import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/terminal-native-copy-gutter'
|
||||
import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
|
||||
import { installPreviewTerminalCompatibility } from '../dashboard-popout/preview-terminal-compatibility'
|
||||
import { createPreviewClipboardPaster } from '../dashboard-popout/preview-terminal-paste'
|
||||
import { installPreviewImeBridge } from '../dashboard-popout/preview-terminal-ime-bridge'
|
||||
import { installPreviewTerminalKeyHandler } from '../dashboard-popout/preview-terminal-key-handler'
|
||||
import { installPreviewTerminalAppMenuClipboard } from '../dashboard-popout/preview-terminal-app-menu-clipboard'
|
||||
import { installPreviewTerminalRightClickPaste } from '../dashboard-popout/preview-terminal-right-click-paste'
|
||||
|
||||
export type StandaloneTerminalArgs = {
|
||||
container: HTMLElement
|
||||
options: ITerminalOptions & ITerminalInitOnlyOptions
|
||||
/** Owned by the caller so it can outlive a terminal the caller recreates. */
|
||||
kittyKeyboardModes: TerminalKittyKeyboardModeTracker
|
||||
/** Names the session for paste planning; a remote-runtime pty id routes as remote. */
|
||||
sessionKey: string
|
||||
/** The user's keystrokes and pastes, plus xterm's replies to output when `answersQueries`. */
|
||||
sendInput: (data: string) => boolean | Promise<boolean>
|
||||
/**
|
||||
* True when this xterm is the session's only emulator, so programs wait on its replies to
|
||||
* cursor, device and keyboard queries. A mirror (the preview) leaves them to the real pane.
|
||||
*/
|
||||
answersQueries: boolean
|
||||
getSettings: () => GlobalSettings | null
|
||||
getMacOptionAsAlt: () => MacOptionAsAlt
|
||||
getTerminalInput: () => DashboardCardTerminalInput | null
|
||||
}
|
||||
|
||||
export type StandaloneTerminal = {
|
||||
terminal: Terminal
|
||||
/**
|
||||
* `live`: new output, scanned for kitty pushes as they happen; otherwise replayed bytes, applied
|
||||
* idempotently. `onDone` runs once xterm has parsed the chunk, which is when to ack backpressure.
|
||||
*/
|
||||
write: (chunk: string, options: { live: boolean; onDone?: () => void }) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/** Null when xterm can't open in the container. */
|
||||
export function openStandaloneTerminal(args: StandaloneTerminalArgs): StandaloneTerminal | null {
|
||||
const { container, kittyKeyboardModes } = args
|
||||
const terminal = new Terminal(args.options)
|
||||
try {
|
||||
terminal.open(container)
|
||||
} catch {
|
||||
terminal.dispose()
|
||||
return null
|
||||
}
|
||||
let disposed = false
|
||||
let replayDepth = 0
|
||||
|
||||
const disposeCompatibility = installPreviewTerminalCompatibility(terminal, {
|
||||
getSettings: args.getSettings
|
||||
})
|
||||
const disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose
|
||||
|
||||
let pendingUserInputSignals = 0
|
||||
const userInputDisposable = subscribeToTerminalUserInput(terminal, () => {
|
||||
pendingUserInputSignals = Math.min(32, pendingUserInputSignals + 1)
|
||||
})
|
||||
terminal.onData((data) => {
|
||||
const signaledUserInput = pendingUserInputSignals > 0
|
||||
if (signaledUserInput) {
|
||||
pendingUserInputSignals--
|
||||
}
|
||||
// Why: core's signal distinguishes real input from parser replies, so typing survives live replay without forwarding synthetic CPR/DA bytes.
|
||||
if (!args.answersQueries && (userInputDisposable ? !signaledUserInput : replayDepth > 0)) {
|
||||
return
|
||||
}
|
||||
void args.sendInput(data)
|
||||
})
|
||||
|
||||
// Why a live getter: kitty state can change between keydown and commit.
|
||||
const imeBridge = installPreviewImeBridge(terminal, {
|
||||
getKittyKeyboardFlags: () => kittyKeyboardModes.flags
|
||||
})
|
||||
const pasteClipboardText = createPreviewClipboardPaster({
|
||||
ptyId: args.sessionKey,
|
||||
writeInput: args.sendInput,
|
||||
container,
|
||||
getTerminal: () => (disposed ? null : terminal),
|
||||
getTerminalInput: args.getTerminalInput,
|
||||
isDisposed: () => disposed
|
||||
})
|
||||
const disposeKeyHandler = installPreviewTerminalKeyHandler({
|
||||
terminal,
|
||||
claimImeKeyEvent: (event) => imeBridge?.claimKeyEvent(event) ?? false,
|
||||
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source),
|
||||
// Why: route through terminal.input so the chord's bytes carry core's user-input signal, like typed keys.
|
||||
sendInput: (data) => terminal.input(data),
|
||||
getShortcutContext: () => ({
|
||||
clientPlatform: getShortcutPlatform(),
|
||||
macOptionAsAlt: args.getMacOptionAsAlt(),
|
||||
keybindings: useAppStore.getState().keybindings,
|
||||
terminalInput: args.getTerminalInput(),
|
||||
getKittyKeyboardFlags: () => kittyKeyboardModes.flags,
|
||||
terminalShortcutPolicy: args.getSettings()?.terminalShortcutPolicy
|
||||
})
|
||||
})
|
||||
const disposeAppMenuClipboard = installPreviewTerminalAppMenuClipboard({
|
||||
container,
|
||||
getTerminal: () => (disposed ? null : terminal),
|
||||
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source)
|
||||
})
|
||||
const disposeRightClickPaste = installPreviewTerminalRightClickPaste({
|
||||
container,
|
||||
getTerminal: () => (disposed ? null : terminal),
|
||||
// Same default as the pane: Windows users expect terminal-style right-click.
|
||||
isRightClickToPasteEnabled: () =>
|
||||
args.getSettings()?.terminalRightClickToPaste ?? isWindowsUserAgent(),
|
||||
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source)
|
||||
})
|
||||
|
||||
return {
|
||||
terminal,
|
||||
write: (chunk, { live, onDone }) => {
|
||||
// Why: a redelivered snapshot repeats the TUI's one-time kitty push, so
|
||||
// replayed bytes must apply as idempotent sets (see the tracker's docs).
|
||||
if (live) {
|
||||
kittyKeyboardModes.scan(chunk)
|
||||
} else {
|
||||
kittyKeyboardModes.scanReplay(chunk)
|
||||
}
|
||||
replayDepth++
|
||||
terminal.write(chunk, () => {
|
||||
replayDepth--
|
||||
onDone?.()
|
||||
})
|
||||
},
|
||||
dispose: () => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
userInputDisposable?.dispose()
|
||||
imeBridge?.dispose()
|
||||
disposeCompatibility()
|
||||
disposeKeyHandler()
|
||||
disposeNativeCopyGutterTrim()
|
||||
disposeAppMenuClipboard()
|
||||
disposeRightClickPaste()
|
||||
terminal.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SshHostNodeMirrorAction } from '@/components/settings/SshHostNodeMirrorAction'
|
||||
import { SshHostMoveAnywayButton } from '@/components/settings/SshHostMoveAnywayAction'
|
||||
import { OpenPlainSshTerminalButton } from '@/components/plain-ssh-terminal/OpenPlainSshTerminalButton'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
@@ -220,6 +221,12 @@ export function TerminalSshReconnectOverlay({
|
||||
disabled={isConnecting}
|
||||
/>
|
||||
)}
|
||||
<OpenPlainSshTerminalButton
|
||||
targetId={targetId}
|
||||
targetLabel={targetLabel}
|
||||
status={managedServer}
|
||||
ownerEnvironmentId={sshOwnerEnvironmentId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -18585,6 +18585,20 @@
|
||||
"copyFailed": "Could not copy the link.",
|
||||
"signingIn": "Signing in…",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"plainSshTerminal": {
|
||||
"open": "Open plain SSH terminal",
|
||||
"unmanaged": "Plain SSH — unmanaged. Not an Orca workspace: agents and tabs here aren’t tracked, and this terminal isn’t restored. Closing it ends the SSH session.",
|
||||
"ended": "ssh exited with code {{code}}. Close this window when you’re done reading.",
|
||||
"title": "Plain SSH · {{host}}",
|
||||
"minimize": "Minimize",
|
||||
"close": "Close and end session",
|
||||
"unknownTarget": "This SSH host is no longer saved in Orca.",
|
||||
"notBlocked": "Orca can reach this host again, so use its terminals instead. Plain SSH is only for while Orca can’t set up on the host.",
|
||||
"alreadyOpen": "Another Orca window already has a plain SSH terminal open to this host.",
|
||||
"endedBySignal": "ssh was stopped (signal {{signal}}). Close this window when you’re done reading.",
|
||||
"cancelled": "This plain SSH terminal was closed before it started.",
|
||||
"terminalUnavailable": "Orca couldn’t open a terminal here."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Which plain SSH terminal windows are open, one per host. Deliberately outside the app store and
|
||||
* the workspace session: these windows are never persisted, restored or synced.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
export type PlainSshPanel = { targetId: string; label: string; minimized: boolean }
|
||||
|
||||
let panels: readonly PlainSshPanel[] = []
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function publish(next: readonly PlainSshPanel[]): void {
|
||||
panels = next
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the host's window, opening it if needed; only one is expanded at a time. */
|
||||
export function showPlainSshPanel(targetId: string, label: string): void {
|
||||
const rest = panels
|
||||
.filter((panel) => panel.targetId !== targetId)
|
||||
.map((panel) => (panel.minimized ? panel : { ...panel, minimized: true }))
|
||||
const current = panels.find((panel) => panel.targetId === targetId)
|
||||
publish([...rest, { targetId, label: current?.label ?? label, minimized: false }])
|
||||
}
|
||||
|
||||
export function minimizePlainSshPanel(targetId: string): void {
|
||||
publish(
|
||||
panels.map((panel) => (panel.targetId === targetId ? { ...panel, minimized: true } : panel))
|
||||
)
|
||||
}
|
||||
|
||||
export function removePlainSshPanel(targetId: string): void {
|
||||
publish(panels.filter((panel) => panel.targetId !== targetId))
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function usePlainSshPanels(): readonly PlainSshPanel[] {
|
||||
return useSyncExternalStore(subscribe, () => panels)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** The plain SSH terminal's IPC contract: open by saved target id, then talk by session id. */
|
||||
export const PLAIN_SSH_TERMINAL_CHANNELS = {
|
||||
open: 'plainSsh:open',
|
||||
write: 'plainSsh:write',
|
||||
resize: 'plainSsh:resize',
|
||||
close: 'plainSsh:close',
|
||||
/** Output the window has rendered, in UTF-16 units of `data`; ssh is paused while too much is unrendered. */
|
||||
ack: 'plainSsh:ack',
|
||||
event: 'plainSsh:event'
|
||||
} as const
|
||||
|
||||
export type PlainSshTerminalOpenResult =
|
||||
| { ok: true; sessionId: string; label: string }
|
||||
/** `already-open`: another window holds this host's one session. */
|
||||
| { ok: false; reason: 'unknown-target' | 'not-blocked' | 'already-open' }
|
||||
/** A newer open from the same window, a reload or quit took this one's place. */
|
||||
| { ok: false; reason: 'cancelled' }
|
||||
| { ok: false; reason: 'ssh-missing' | 'spawn-failed'; message: string }
|
||||
|
||||
export type PlainSshTerminalEvent =
|
||||
| { kind: 'data'; sessionId: string; data: string }
|
||||
/** `signal`: set when ssh was killed, so a zero `exitCode` is not a clean logout. */
|
||||
| { kind: 'exit'; sessionId: string; exitCode: number; signal?: number }
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SshManagedServerStatus } from './ssh-types'
|
||||
|
||||
/**
|
||||
* A host its Orca server doesn't serve, so this desktop can't open Orca terminals there. A bridged
|
||||
* host stays connected for its old terminals, so it isn't blocked.
|
||||
*/
|
||||
export function isSshHostMoveBlocked(status: SshManagedServerStatus | undefined): boolean {
|
||||
return status?.kind === 'relay' && !status.bridged
|
||||
}
|
||||
Reference in New Issue
Block a user