fix(terminal): support Windows shell selection for SSH hosts (#6466)
* fix(terminal): support Windows shell selection for SSH hosts * fix(runtime): register SSH Windows capability preflight RPC * fix(terminal): honor remote Git Bash and split preflight IPC seams * fix(terminal): keep SSH Windows shell state tied to the selected host * fix(terminal): preserve Windows SSH shell selection --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
This commit is contained in:
parent
60baa449d4
commit
5e3d8a12bf
|
|
@ -0,0 +1,93 @@
|
|||
import { execFile } from 'child_process'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { buildLocalPreflightEnv } from './preflight-local-env'
|
||||
import { runPreflightCommandInWsl } from './preflight-wsl-command'
|
||||
import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
|
||||
|
||||
export type PreflightCommandResult = { stdout: string; stderr: string }
|
||||
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
async function withPreflightTimeout<T>(command: string, commandPromise: Promise<T>): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
commandPromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = Object.assign(new Error(`Timed out running ${command}`), {
|
||||
code: 'ETIMEDOUT'
|
||||
})
|
||||
reject(error)
|
||||
}, PREFLIGHT_COMMAND_TIMEOUT_MS)
|
||||
if (typeof timeout.unref === 'function') {
|
||||
timeout.unref()
|
||||
}
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execLocalPreflightCommand(
|
||||
command: string,
|
||||
args: string[]
|
||||
): Promise<PreflightCommandResult> {
|
||||
const env = buildLocalPreflightEnv()
|
||||
const commandPromise = execFileAsync(command, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
|
||||
...(env ? { env } : {})
|
||||
}) as Promise<PreflightCommandResult>
|
||||
|
||||
return withPreflightTimeout(command, commandPromise)
|
||||
}
|
||||
|
||||
export async function execCommandInWsl(
|
||||
target: WslPreflightTarget,
|
||||
command: string
|
||||
): Promise<PreflightCommandResult> {
|
||||
const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
|
||||
return withPreflightTimeout('wsl.exe', commandPromise)
|
||||
}
|
||||
|
||||
export async function isCommandAvailable(
|
||||
command: string,
|
||||
wslTarget?: WslPreflightTarget
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await (wslTarget
|
||||
? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`)
|
||||
: execLocalPreflightCommand(command, ['--version']))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCommandOnPath(
|
||||
command: string,
|
||||
wslTarget?: WslPreflightTarget
|
||||
): Promise<boolean> {
|
||||
const finder = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
const { stdout } = wslTarget
|
||||
? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`)
|
||||
: await execLocalPreflightCommand(finder, [command])
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.some((line) => path.isAbsolute(line))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { getActiveMultiplexer } from './ssh'
|
||||
|
||||
export type RemoteWindowsTerminalCapabilities = {
|
||||
wslAvailable: boolean
|
||||
wslDistros: string[]
|
||||
pwshAvailable: boolean
|
||||
gitBashAvailable: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
const EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES: RemoteWindowsTerminalCapabilities = {
|
||||
wslAvailable: false,
|
||||
wslDistros: [],
|
||||
pwshAvailable: false,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: null
|
||||
}
|
||||
|
||||
export async function detectRemoteWindowsTerminalCapabilities(args: {
|
||||
connectionId: string
|
||||
}): Promise<RemoteWindowsTerminalCapabilities> {
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (!mux || mux.isDisposed()) {
|
||||
return EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES
|
||||
}
|
||||
const result = (await mux.request('preflight.detectWindowsTerminalCapabilities', {})) as
|
||||
| RemoteWindowsTerminalCapabilities
|
||||
| undefined
|
||||
return result ?? EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES
|
||||
}
|
||||
|
|
@ -708,6 +708,35 @@ describe('preflight', () => {
|
|||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends remote Windows shell capability probes through the SSH preflight path', async () => {
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
getActiveMultiplexerMock.mockReturnValue({
|
||||
isDisposed: () => false,
|
||||
request
|
||||
})
|
||||
|
||||
registerPreflightHandlers()
|
||||
|
||||
await expect(
|
||||
handlers['preflight:detectRemoteWindowsTerminalCapabilities'](undefined, {
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
expect(request).toHaveBeenCalledWith('preflight.detectWindowsTerminalCapabilities', {})
|
||||
})
|
||||
|
||||
it('detects agents from the selected WSL distro for a WSL workspace', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import type { PathSource, ShellHydrationFailureReason } from '../../shared/types'
|
||||
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
|
||||
|
|
@ -11,13 +8,20 @@ import { getGiteaAuthStatus } from '../gitea/client'
|
|||
import { _resetKnownHostsCache } from '../gitlab/gl-utils'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection'
|
||||
import { runPreflightCommandInWsl } from './preflight-wsl-command'
|
||||
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
|
||||
import { buildLocalPreflightEnv } from './preflight-local-env'
|
||||
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
|
||||
import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path'
|
||||
const execFileAsync = promisify(execFile)
|
||||
const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
|
||||
import {
|
||||
execCommandInWsl,
|
||||
execLocalPreflightCommand,
|
||||
isCommandAvailable,
|
||||
isCommandOnPath,
|
||||
shellQuote
|
||||
} from './preflight-command-exec'
|
||||
import {
|
||||
detectRemoteWindowsTerminalCapabilities,
|
||||
type RemoteWindowsTerminalCapabilities
|
||||
} from './preflight-remote-windows-terminal-capabilities'
|
||||
|
||||
export type PreflightStatus = {
|
||||
git: { installed: boolean }
|
||||
|
|
@ -44,6 +48,9 @@ export type PreflightStatus = {
|
|||
}
|
||||
}
|
||||
|
||||
export { detectRemoteWindowsTerminalCapabilities }
|
||||
export type { RemoteWindowsTerminalCapabilities }
|
||||
|
||||
// Why: cache the result so repeated Landing mounts don't re-spawn processes.
|
||||
// The check only runs once per app session — relaunch to re-check.
|
||||
let cached: PreflightStatus | null = null
|
||||
|
|
@ -53,92 +60,6 @@ export function _resetPreflightCache(): void {
|
|||
cached = null
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
type PreflightCommandResult = { stdout: string; stderr: string }
|
||||
|
||||
// Why: a broken PATH shim or auth helper should not keep startup/settings
|
||||
// preflight IPC pending forever; WSL probes already use the same deadline.
|
||||
async function withPreflightTimeout<T>(command: string, commandPromise: Promise<T>): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
commandPromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = Object.assign(new Error(`Timed out running ${command}`), {
|
||||
code: 'ETIMEDOUT'
|
||||
})
|
||||
reject(error)
|
||||
}, PREFLIGHT_COMMAND_TIMEOUT_MS)
|
||||
if (typeof timeout.unref === 'function') {
|
||||
timeout.unref()
|
||||
}
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function execLocalPreflightCommand(
|
||||
command: string,
|
||||
args: string[]
|
||||
): Promise<PreflightCommandResult> {
|
||||
const env = buildLocalPreflightEnv()
|
||||
const commandPromise = execFileAsync(command, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
|
||||
...(env ? { env } : {})
|
||||
}) as Promise<PreflightCommandResult>
|
||||
|
||||
return withPreflightTimeout(command, commandPromise)
|
||||
}
|
||||
|
||||
async function execCommandInWsl(
|
||||
target: WslPreflightTarget,
|
||||
command: string
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
|
||||
return withPreflightTimeout('wsl.exe', commandPromise)
|
||||
}
|
||||
|
||||
async function isCommandAvailable(
|
||||
command: string,
|
||||
wslTarget?: WslPreflightTarget
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await (wslTarget
|
||||
? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`)
|
||||
: execLocalPreflightCommand(command, ['--version']))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Why: `which`/`where` is faster than spawning the agent binary itself and avoids
|
||||
// triggering any agent-specific startup side-effects. This gives a reliable
|
||||
// PATH-based check without requiring `--version` support from each agent.
|
||||
async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise<boolean> {
|
||||
const finder = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
const { stdout } = wslTarget
|
||||
? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`)
|
||||
: await execLocalPreflightCommand(finder, [command])
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.some((line) => path.isAbsolute(line))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) =>
|
||||
getTuiAgentDetectCommands(config).map((cmd) => ({
|
||||
id,
|
||||
|
|
@ -378,4 +299,11 @@ export function registerPreflightHandlers(): void {
|
|||
return detectRemoteAgents(args)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'preflight:detectRemoteWindowsTerminalCapabilities',
|
||||
async (_event, args: { connectionId: string }): Promise<RemoteWindowsTerminalCapabilities> => {
|
||||
return detectRemoteWindowsTerminalCapabilities(args)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,26 @@ describe('SshPtyProvider', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-2' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
shellOverride: 'powershell.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: undefined,
|
||||
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
|
||||
shellOverride: 'powershell.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves an explicit remote Powerlevel10k wizard env value', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-2' })
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ export class SshPtyProvider implements IPtyProvider {
|
|||
// Pi-compatible agent is being launched, while commandDelivery tells it
|
||||
// whether to submit the command itself for runtime-owned background PTYs.
|
||||
...(opts.command ? { command: opts.command } : {}),
|
||||
...(opts.shellOverride !== undefined ? { shellOverride: opts.shellOverride } : {}),
|
||||
...(opts.terminalWindowsWslDistro !== undefined
|
||||
? { terminalWindowsWslDistro: opts.terminalWindowsWslDistro }
|
||||
: {}),
|
||||
...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}),
|
||||
...(opts.startupCommandDelivery
|
||||
? { startupCommandDelivery: opts.startupCommandDelivery }
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ import { PREFLIGHT_METHODS } from './preflight'
|
|||
const {
|
||||
detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgentsMock,
|
||||
detectRemoteWindowsTerminalCapabilitiesMock,
|
||||
refreshShellPathAndDetectAgentsMock,
|
||||
runPreflightCheckMock
|
||||
} = vi.hoisted(() => ({
|
||||
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
|
||||
detectRemoteAgentsMock: vi.fn(),
|
||||
detectRemoteWindowsTerminalCapabilitiesMock: vi.fn(),
|
||||
refreshShellPathAndDetectAgentsMock: vi.fn(),
|
||||
runPreflightCheckMock: vi.fn()
|
||||
}))
|
||||
|
|
@ -19,6 +21,7 @@ const {
|
|||
vi.mock('../../../ipc/preflight', () => ({
|
||||
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgents: detectRemoteAgentsMock,
|
||||
detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock,
|
||||
refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock,
|
||||
runPreflightCheck: runPreflightCheckMock
|
||||
}))
|
||||
|
|
@ -81,4 +84,36 @@ describe('preflight RPC methods', () => {
|
|||
expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
|
||||
expect(response).toMatchObject({ ok: true, result: ['claude'] })
|
||||
})
|
||||
|
||||
it('detects remote Windows terminal capabilities through runtime RPC', async () => {
|
||||
detectRemoteWindowsTerminalCapabilitiesMock.mockResolvedValueOnce({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('preflight.detectRemoteWindowsTerminalCapabilities', {
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(detectRemoteWindowsTerminalCapabilitiesMock).toHaveBeenCalledWith({
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z } from 'zod'
|
|||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import {
|
||||
detectRemoteAgents,
|
||||
detectRemoteWindowsTerminalCapabilities,
|
||||
detectInstalledAgentsWithShellPathHydration,
|
||||
refreshShellPathAndDetectAgents,
|
||||
runPreflightCheck
|
||||
|
|
@ -13,6 +14,9 @@ const PreflightCheck = z.object({
|
|||
const PreflightDetectRemoteAgents = z.object({
|
||||
connectionId: z.string().min(1)
|
||||
})
|
||||
const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({
|
||||
connectionId: z.string().min(1)
|
||||
})
|
||||
|
||||
export const PREFLIGHT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
|
|
@ -30,6 +34,11 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [
|
|||
params: PreflightDetectRemoteAgents,
|
||||
handler: async (params) => detectRemoteAgents(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'preflight.detectRemoteWindowsTerminalCapabilities',
|
||||
params: PreflightDetectRemoteWindowsTerminalCapabilities,
|
||||
handler: async (params) => detectRemoteWindowsTerminalCapabilities(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'preflight.refreshAgents',
|
||||
params: null,
|
||||
|
|
|
|||
|
|
@ -548,6 +548,13 @@ export type PreflightApi = {
|
|||
detectAgents: (args?: PreflightRuntimeContext) => Promise<string[]>
|
||||
refreshAgents: (args?: PreflightRuntimeContext) => Promise<RefreshAgentsResult>
|
||||
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
|
||||
detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{
|
||||
wslAvailable: boolean
|
||||
wslDistros: string[]
|
||||
pwshAvailable: boolean
|
||||
gitBashAvailable: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}>
|
||||
}
|
||||
|
||||
// Why: renderer-facing mirror of the daemon's `SessionInfo` + protocolVersion
|
||||
|
|
|
|||
|
|
@ -1771,7 +1771,16 @@ const api = {
|
|||
refreshAgents: (args?: PreflightRuntimeContext): Promise<RefreshAgentsResult> =>
|
||||
ipcRenderer.invoke('preflight:refreshAgents', args),
|
||||
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
|
||||
ipcRenderer.invoke('preflight:detectRemoteAgents', args)
|
||||
ipcRenderer.invoke('preflight:detectRemoteAgents', args),
|
||||
detectRemoteWindowsTerminalCapabilities: (args: {
|
||||
connectionId: string
|
||||
}): Promise<{
|
||||
wslAvailable: boolean
|
||||
wslDistros: string[]
|
||||
pwshAvailable: boolean
|
||||
gitBashAvailable: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args)
|
||||
},
|
||||
|
||||
notifications: {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ const { execFileAsyncMock } = vi.hoisted(() => ({
|
|||
execFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
const { isPwshAvailableMock, isWslAvailableMock, listWslDistrosMock, isGitBashAvailableMock } =
|
||||
vi.hoisted(() => ({
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
isWslAvailableMock: vi.fn(),
|
||||
listWslDistrosMock: vi.fn(),
|
||||
isGitBashAvailableMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => {
|
||||
const execFileWithPromisify = Object.assign(vi.fn(), {
|
||||
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
|
||||
|
|
@ -11,11 +19,19 @@ vi.mock('child_process', () => {
|
|||
return { execFile: execFileWithPromisify }
|
||||
})
|
||||
|
||||
vi.mock('../main/pwsh', () => ({ isPwshAvailable: isPwshAvailableMock }))
|
||||
vi.mock('../main/wsl', () => ({
|
||||
isWslAvailable: isWslAvailableMock,
|
||||
listWslDistros: listWslDistrosMock
|
||||
}))
|
||||
vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock }))
|
||||
|
||||
import {
|
||||
buildCommandLookupSpec,
|
||||
buildCommandLookupSpecs,
|
||||
hasAbsoluteCommandPath,
|
||||
isCommandOnPathForRelay
|
||||
isCommandOnPathForRelay,
|
||||
PreflightHandler
|
||||
} from './preflight-handler'
|
||||
|
||||
function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] {
|
||||
|
|
@ -43,6 +59,10 @@ function fishLookupArgs(command: string): string[] {
|
|||
|
||||
beforeEach(() => {
|
||||
execFileAsyncMock.mockReset()
|
||||
isPwshAvailableMock.mockReset()
|
||||
isWslAvailableMock.mockReset()
|
||||
listWslDistrosMock.mockReset()
|
||||
isGitBashAvailableMock.mockReset()
|
||||
})
|
||||
|
||||
describe('buildCommandLookupSpec', () => {
|
||||
|
|
@ -214,3 +234,45 @@ describe('hasAbsoluteCommandPath', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PreflightHandler', () => {
|
||||
it('reports remote Windows shell capabilities through the SSH preflight path', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
isWslAvailableMock.mockReturnValue(true)
|
||||
listWslDistrosMock.mockReturnValue(['Ubuntu'])
|
||||
isPwshAvailableMock.mockReturnValue(true)
|
||||
isGitBashAvailableMock.mockReturnValue(true)
|
||||
|
||||
const requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
|
||||
const dispatcher = {
|
||||
onRequest: vi.fn(
|
||||
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
|
||||
requestHandlers.set(method, handler)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
new PreflightHandler(dispatcher as never)
|
||||
|
||||
try {
|
||||
const handler = requestHandlers.get('preflight.detectWindowsTerminalCapabilities')
|
||||
expect(handler).toBeDefined()
|
||||
await expect(handler!({})).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { promisify } from 'util'
|
|||
import path, { win32 } from 'path'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
import { buildRelayCommandEnv } from './relay-command-env'
|
||||
import { isPwshAvailable } from '../main/pwsh'
|
||||
import { isWslAvailable, listWslDistros } from '../main/wsl'
|
||||
import { isGitBashAvailable } from '../main/git-bash'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
|
|
@ -33,6 +36,9 @@ export class PreflightHandler {
|
|||
|
||||
private registerHandlers(): void {
|
||||
this.dispatcher.onRequest('preflight.detectAgents', (p) => this.detectAgents(p))
|
||||
this.dispatcher.onRequest('preflight.detectWindowsTerminalCapabilities', () =>
|
||||
this.detectWindowsTerminalCapabilities()
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the client sends the command list rather than importing TUI_AGENT_CONFIG
|
||||
|
|
@ -54,6 +60,28 @@ export class PreflightHandler {
|
|||
return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] }
|
||||
}
|
||||
|
||||
private async detectWindowsTerminalCapabilities(): Promise<{
|
||||
wslAvailable: boolean
|
||||
wslDistros: string[]
|
||||
pwshAvailable: boolean
|
||||
gitBashAvailable: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}> {
|
||||
const [wslAvailable, pwshAvailable, gitBashAvailable] = await Promise.all([
|
||||
Promise.resolve(isWslAvailable()).catch(() => false),
|
||||
Promise.resolve(isPwshAvailable()).catch(() => false),
|
||||
Promise.resolve(isGitBashAvailable()).catch(() => false)
|
||||
])
|
||||
const wslDistros = wslAvailable ? await Promise.resolve(listWslDistros()).catch(() => []) : []
|
||||
return {
|
||||
wslAvailable,
|
||||
wslDistros,
|
||||
pwshAvailable,
|
||||
gitBashAvailable,
|
||||
hostPlatform: process.platform
|
||||
}
|
||||
}
|
||||
|
||||
// Why: SSH exec channels give the relay a minimal environment without shell
|
||||
// startup files sourced. Ask the user's configured shell so agent dirs added
|
||||
// by zsh/bash/fish startup hooks match the remote terminal experience.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
|
|||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
|
||||
import * as gitBash from '../main/git-bash'
|
||||
import * as ptyShellUtils from './pty-shell-utils'
|
||||
import {
|
||||
resolveSetupAgentSequenceLaunchCommand,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
|
||||
|
|
@ -169,6 +171,155 @@ describe('PtyHandler', () => {
|
|||
expect(handler.activePtyCount).toBe(1)
|
||||
})
|
||||
|
||||
it('uses an explicit shell override and falls back to the default shell otherwise', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
const resolveDefaultShellSpy = vi
|
||||
.spyOn(ptyShellUtils, 'resolveDefaultShell')
|
||||
.mockReturnValue('/default-shell')
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
expect(mockPtySpawn).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
mockPtySpawn.mockClear()
|
||||
|
||||
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
|
||||
expect(mockPtySpawn).toHaveBeenCalledWith(
|
||||
'/default-shell',
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
} finally {
|
||||
resolveDefaultShellSpy.mockRestore()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores Windows shell overrides on non-Windows relay hosts', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux'
|
||||
})
|
||||
const resolveDefaultShellSpy = vi
|
||||
.spyOn(ptyShellUtils, 'resolveDefaultShell')
|
||||
.mockReturnValue('/default-shell')
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
|
||||
expect(mockPtySpawn).toHaveBeenCalledWith(
|
||||
'/default-shell',
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
} finally {
|
||||
resolveDefaultShellSpy.mockRestore()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unsupported shell overrides on Windows relay hosts', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
dispatcher.callRequest('pty.spawn', {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'notepad.exe'
|
||||
})
|
||||
).rejects.toThrow('Unsupported Windows shell override')
|
||||
expect(mockPtySpawn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
const resolveGitBashSpy = vi
|
||||
.spyOn(gitBash, 'resolveWindowsGitBashShellPath')
|
||||
.mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'git-bash'
|
||||
})
|
||||
|
||||
expect(resolveGitBashSpy).toHaveBeenCalledWith('git-bash')
|
||||
expect(mockPtySpawn).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
} finally {
|
||||
resolveGitBashSpy.mockRestore()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('passes the selected WSL distro to relay launches on Windows', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
expect(mockPtySpawn).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu-24.04'],
|
||||
expect.any(Object)
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => {
|
||||
await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' })
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import type { IPty } from 'node-pty'
|
||||
import type * as NodePty from 'node-pty'
|
||||
import { resolveWindowsGitBashShellPath } from '../main/git-bash'
|
||||
import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import {
|
||||
resolveDefaultShell,
|
||||
|
|
@ -128,6 +130,32 @@ const ALLOWED_SIGNALS = new Set([
|
|||
'SIGUSR2'
|
||||
])
|
||||
|
||||
const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([
|
||||
'powershell.exe',
|
||||
'powershell',
|
||||
'pwsh.exe',
|
||||
'pwsh',
|
||||
'cmd.exe',
|
||||
'cmd',
|
||||
'wsl.exe',
|
||||
'wsl',
|
||||
WINDOWS_GIT_BASH_SHELL
|
||||
])
|
||||
|
||||
function resolvePtyShellOverride(shellOverride: string): string {
|
||||
if (!shellOverride) {
|
||||
return ''
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
return ''
|
||||
}
|
||||
const normalized = shellOverride.toLowerCase()
|
||||
if (!ALLOWED_WINDOWS_SHELL_OVERRIDES.has(normalized)) {
|
||||
throw new Error(`Unsupported Windows shell override: ${shellOverride}`)
|
||||
}
|
||||
return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride
|
||||
}
|
||||
|
||||
type SerializedPtyEntry = {
|
||||
id: string
|
||||
pid: number
|
||||
|
|
@ -497,7 +525,10 @@ export class PtyHandler {
|
|||
const rows = (params.rows as number) || 24
|
||||
const cwd = (params.cwd as string) || resolveDefaultCwd()
|
||||
const env = params.env as Record<string, string> | undefined
|
||||
const shell = resolveDefaultShell()
|
||||
const shellOverride =
|
||||
typeof params.shellOverride === 'string' ? params.shellOverride.trim() : ''
|
||||
const resolvedShellOverride = resolvePtyShellOverride(shellOverride)
|
||||
const shell = resolvedShellOverride || resolveDefaultShell()
|
||||
const id = `pty-${this.nextId++}`
|
||||
|
||||
// Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay
|
||||
|
|
@ -509,6 +540,8 @@ export class PtyHandler {
|
|||
// because no renderer TerminalPane exists to type the command.
|
||||
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
|
||||
const command = typeof params.command === 'string' ? params.command : undefined
|
||||
const terminalWindowsWslDistro =
|
||||
typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null
|
||||
const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer'
|
||||
const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
|
||||
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command })
|
||||
|
|
@ -523,6 +556,7 @@ export class PtyHandler {
|
|||
// Why: renderer- and provider-delivered startup commands both use this
|
||||
// marker; the side responsible for delivery also strips it from output.
|
||||
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
|
||||
terminalWindowsWslDistro,
|
||||
emitReadyMarker: shouldEmitShellReadyMarker
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ function shellBasename(shellPath: string): string {
|
|||
return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
function windowsShellArgs(shellName: string): string[] | null {
|
||||
function windowsShellArgs(
|
||||
shellName: string,
|
||||
options: { terminalWindowsWslDistro?: string | null } = {}
|
||||
): string[] | null {
|
||||
if (shellName === 'powershell.exe' || shellName === 'powershell') {
|
||||
return ['-NoLogo']
|
||||
}
|
||||
|
|
@ -35,6 +38,10 @@ function windowsShellArgs(shellName: string): string[] | null {
|
|||
if (shellName === 'cmd.exe' || shellName === 'cmd') {
|
||||
return []
|
||||
}
|
||||
if (shellName === 'wsl.exe' || shellName === 'wsl') {
|
||||
const distro = options.terminalWindowsWslDistro?.trim()
|
||||
return distro ? ['-d', distro] : []
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -247,14 +254,20 @@ export function getRelayShellLaunchConfig(
|
|||
shellPath: string,
|
||||
env: Record<string, string>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: { emitReadyMarker?: boolean } = {}
|
||||
options: { emitReadyMarker?: boolean; terminalWindowsWslDistro?: string | null } = {}
|
||||
): RelayShellLaunchConfig {
|
||||
const shellName = shellBasename(shellPath)
|
||||
const emitReadyMarker = options.emitReadyMarker === true
|
||||
if (platform === 'win32') {
|
||||
// Why: pwsh also exists on POSIX remotes; Windows-specific shell args must
|
||||
// only apply when the relay itself is running on native Windows.
|
||||
return { args: windowsShellArgs(shellName) ?? [], env: {} }
|
||||
return {
|
||||
args:
|
||||
windowsShellArgs(shellName, {
|
||||
terminalWindowsWslDistro: options.terminalWindowsWslDistro
|
||||
}) ?? [],
|
||||
env: {}
|
||||
}
|
||||
}
|
||||
|
||||
if (shellName !== 'zsh' && shellName !== 'bash') {
|
||||
|
|
|
|||
|
|
@ -307,12 +307,18 @@ function TabBarInner({
|
|||
const activeRuntimeEnvironmentId = useAppStore(
|
||||
(s) => getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() || null
|
||||
)
|
||||
const worktreeHasRemoteConnection = useAppStore((s) => {
|
||||
const worktreeConnectionId = useAppStore((s) => {
|
||||
const worktree = Object.values(s.worktreesByRepo ?? {})
|
||||
.flat()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
const repo = worktree ? s.repos?.find((entry) => entry.id === worktree.repoId) : null
|
||||
return Boolean(repo?.connectionId)
|
||||
return repo?.connectionId?.trim() || null
|
||||
})
|
||||
const worktreeRemotePlatform = useAppStore((s) => {
|
||||
if (!worktreeConnectionId) {
|
||||
return null
|
||||
}
|
||||
return s.sshConnectionStates.get(worktreeConnectionId)?.remotePlatform ?? null
|
||||
})
|
||||
const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
|
||||
const agentCmdOverrides = useAppStore(
|
||||
|
|
@ -361,29 +367,36 @@ function TabBarInner({
|
|||
)
|
||||
const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true
|
||||
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
|
||||
activeRuntimeEnvironmentId
|
||||
activeRuntimeEnvironmentId,
|
||||
worktreeConnectionId
|
||||
)
|
||||
const runtimeTarget = useMemo(
|
||||
() => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }),
|
||||
[activeRuntimeEnvironmentId]
|
||||
)
|
||||
const shouldProbeWindowsShellCapabilities =
|
||||
(isWindows || Boolean(activeRuntimeEnvironmentId?.trim()) || isWebClient) &&
|
||||
!worktreeHasRemoteConnection
|
||||
isWindows ||
|
||||
Boolean(activeRuntimeEnvironmentId?.trim()) ||
|
||||
isWebClient ||
|
||||
Boolean(worktreeConnectionId)
|
||||
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
|
||||
shouldProbeWindowsShellCapabilities,
|
||||
false,
|
||||
windowsTerminalCapabilityOwnerKey,
|
||||
runtimeTarget
|
||||
runtimeTarget,
|
||||
worktreeConnectionId
|
||||
)
|
||||
const shellMenuHostPlatform = worktreeConnectionId
|
||||
? (worktreeRemotePlatform ?? windowsTerminalCapabilities.hostPlatform)
|
||||
: windowsTerminalCapabilities.hostPlatform
|
||||
const showWindowsShellMenu = shouldShowWindowsShellMenu({
|
||||
activeRuntimeEnvironmentId,
|
||||
hostPlatform: windowsTerminalCapabilities.hostPlatform,
|
||||
hostPlatform: shellMenuHostPlatform,
|
||||
isWindowsClient: isWindows,
|
||||
worktreeHasRemoteConnection
|
||||
worktreeHasRemoteConnection: Boolean(worktreeConnectionId)
|
||||
})
|
||||
const localProjectRuntime = useMemo(() => {
|
||||
if (!showWindowsShellMenu || activeRuntimeEnvironmentId?.trim()) {
|
||||
if (!showWindowsShellMenu || activeRuntimeEnvironmentId?.trim() || worktreeConnectionId) {
|
||||
return undefined
|
||||
}
|
||||
return getLocalProjectExecutionRuntimeContext(
|
||||
|
|
@ -414,6 +427,7 @@ function TabBarInner({
|
|||
repos,
|
||||
settings,
|
||||
showWindowsShellMenu,
|
||||
worktreeConnectionId,
|
||||
windowsTerminalCapabilities.isLoading,
|
||||
windowsTerminalCapabilities.wslAvailable,
|
||||
windowsTerminalCapabilities.wslDistros,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const appStoreSnapshot: {
|
|||
sourceRepoIds?: string[]
|
||||
}[]
|
||||
repos: { id: string; connectionId?: string | null }[]
|
||||
sshConnectionStates: Map<string, { remotePlatform?: NodeJS.Platform }>
|
||||
worktreesByRepo: Record<
|
||||
string,
|
||||
{ id: string; repoId: string; path?: string; projectId?: string }[]
|
||||
|
|
@ -38,6 +39,7 @@ const appStoreSnapshot: {
|
|||
activeWorktreeId: null,
|
||||
projects: [],
|
||||
repos: [],
|
||||
sshConnectionStates: new Map(),
|
||||
worktreesByRepo: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
|
|
@ -59,6 +61,7 @@ const useAppStoreMock = vi.fn(
|
|||
gitStatusByWorktree: Record<string, never[]>
|
||||
projects: typeof appStoreSnapshot.projects
|
||||
repos: { id: string; connectionId?: string | null }[]
|
||||
sshConnectionStates: Map<string, { remotePlatform?: NodeJS.Platform }>
|
||||
worktreesByRepo: typeof appStoreSnapshot.worktreesByRepo
|
||||
unifiedTabsByWorktree: Record<string, unknown[]>
|
||||
activeGroupIdByWorktree: Record<string, string>
|
||||
|
|
@ -84,6 +87,7 @@ const useAppStoreMock = vi.fn(
|
|||
gitStatusByWorktree: {},
|
||||
projects: appStoreSnapshot.projects,
|
||||
repos: appStoreSnapshot.repos,
|
||||
sshConnectionStates: appStoreSnapshot.sshConnectionStates,
|
||||
worktreesByRepo: appStoreSnapshot.worktreesByRepo,
|
||||
unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree,
|
||||
activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree,
|
||||
|
|
@ -159,6 +163,7 @@ useAppStoreExport.getState = vi.fn(() => ({
|
|||
gitStatusByWorktree: {},
|
||||
projects: appStoreSnapshot.projects,
|
||||
repos: appStoreSnapshot.repos,
|
||||
sshConnectionStates: appStoreSnapshot.sshConnectionStates,
|
||||
worktreesByRepo: appStoreSnapshot.worktreesByRepo,
|
||||
unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree,
|
||||
activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree,
|
||||
|
|
@ -339,6 +344,7 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
appStoreSnapshot.activeWorktreeId = null
|
||||
appStoreSnapshot.projects = []
|
||||
appStoreSnapshot.repos = []
|
||||
appStoreSnapshot.sshConnectionStates = new Map()
|
||||
appStoreSnapshot.worktreesByRepo = {}
|
||||
appStoreSnapshot.unifiedTabsByWorktree = {}
|
||||
appStoreSnapshot.activeGroupIdByWorktree = {}
|
||||
|
|
@ -701,24 +707,101 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
expect(onNewTerminalWithShell).toHaveBeenCalledWith('git-bash')
|
||||
})
|
||||
|
||||
it('hides local Windows shell rows for SSH worktrees', async () => {
|
||||
it('shows the Windows shell rows for an SSH Windows host', async () => {
|
||||
appStoreSnapshot.repos = [{ id: 'repo-1', connectionId: 'ssh-1' }]
|
||||
appStoreSnapshot.sshConnectionStates = new Map([['ssh-1', { remotePlatform: 'win32' }]])
|
||||
appStoreSnapshot.worktreesByRepo = {
|
||||
'repo-1': [{ id: 'wt-ssh', repoId: 'repo-1' }]
|
||||
}
|
||||
const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: {
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
listDistros: vi.fn().mockResolvedValue(['Ubuntu'])
|
||||
},
|
||||
pwsh: { isAvailable: vi.fn().mockResolvedValue(true) },
|
||||
gitBash: { isAvailable: vi.fn().mockResolvedValue(true) },
|
||||
runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) }
|
||||
preflight: {
|
||||
detectRemoteWindowsTerminalCapabilities
|
||||
}
|
||||
}
|
||||
})
|
||||
const capabilities = await import('@/lib/windows-terminal-capabilities')
|
||||
await capabilities.loadWindowsTerminalCapabilities()
|
||||
await capabilities.loadWindowsTerminalCapabilities({
|
||||
ownerKey: 'ssh:ssh-1',
|
||||
sshConnectionId: 'ssh-1'
|
||||
})
|
||||
|
||||
const tabBarModule = await import('./TabBar')
|
||||
const candidate = tabBarModule.default ?? tabBarModule
|
||||
const TabBar =
|
||||
typeof candidate === 'function'
|
||||
? candidate
|
||||
: typeof (candidate as { type?: unknown }).type === 'function'
|
||||
? (candidate as { type: (props: Record<string, unknown>) => unknown }).type
|
||||
: null
|
||||
expect(TabBar).not.toBeNull()
|
||||
|
||||
const onNewTerminalWithShell = vi.fn()
|
||||
const element = TabBar!({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
worktreeId: 'wt-ssh',
|
||||
expandedPaneByTabId: {},
|
||||
onActivate: () => {},
|
||||
onClose: () => {},
|
||||
onCloseOthers: () => {},
|
||||
onCloseToRight: () => {},
|
||||
onNewTerminalTab: () => {},
|
||||
onNewTerminalWithShell,
|
||||
onNewBrowserTab: () => {},
|
||||
onSetCustomTitle: () => {},
|
||||
onSetTabColor: () => {},
|
||||
onTogglePaneExpand: () => {}
|
||||
})
|
||||
|
||||
const powerShellItem = findDropdownMenuItemByText(
|
||||
expandNode(element),
|
||||
'New Terminal: PowerShell'
|
||||
)
|
||||
expect(powerShellItem).not.toBeNull()
|
||||
expect(
|
||||
findDropdownMenuItemByText(expandNode(element), 'New Terminal: CMD Prompt')
|
||||
).not.toBeNull()
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).not.toBeNull()
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull()
|
||||
|
||||
const onSelect = powerShellItem?.props.onSelect as (() => void) | undefined
|
||||
onSelect?.()
|
||||
expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe')
|
||||
})
|
||||
|
||||
it('keeps SSH Linux hosts on the generic new-terminal entry', async () => {
|
||||
appStoreSnapshot.repos = [{ id: 'repo-1', connectionId: 'ssh-1' }]
|
||||
appStoreSnapshot.sshConnectionStates = new Map([['ssh-1', { remotePlatform: 'linux' }]])
|
||||
appStoreSnapshot.worktreesByRepo = {
|
||||
'repo-1': [{ id: 'wt-ssh', repoId: 'repo-1' }]
|
||||
}
|
||||
const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({
|
||||
wslAvailable: false,
|
||||
wslDistros: [],
|
||||
pwshAvailable: false,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: 'linux'
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
preflight: {
|
||||
detectRemoteWindowsTerminalCapabilities
|
||||
}
|
||||
}
|
||||
})
|
||||
const capabilities = await import('@/lib/windows-terminal-capabilities')
|
||||
await capabilities.loadWindowsTerminalCapabilities({
|
||||
ownerKey: 'ssh:ssh-1',
|
||||
sshConnectionId: 'ssh-1'
|
||||
})
|
||||
|
||||
const tabBarModule = await import('./TabBar')
|
||||
const candidate = tabBarModule.default ?? tabBarModule
|
||||
|
|
@ -747,8 +830,10 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
onTogglePaneExpand: () => {}
|
||||
})
|
||||
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).toBeNull()
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: PowerShell')).toBeNull()
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: CMD Prompt')).toBeNull()
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).toBeNull()
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).toBeNull()
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal')).not.toBeNull()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ describe('shouldShowWindowsShellMenu', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides local Windows shells for SSH worktrees', () => {
|
||||
it('shows Windows shells for SSH worktrees when the remote host is Windows', () => {
|
||||
expect(
|
||||
shouldShowWindowsShellMenu({
|
||||
activeRuntimeEnvironmentId: null,
|
||||
|
|
@ -54,6 +54,17 @@ describe('shouldShowWindowsShellMenu', () => {
|
|||
isWindowsClient: true,
|
||||
worktreeHasRemoteConnection: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides Windows shells for SSH worktrees when the remote host is Linux', () => {
|
||||
expect(
|
||||
shouldShowWindowsShellMenu({
|
||||
activeRuntimeEnvironmentId: null,
|
||||
hostPlatform: 'linux',
|
||||
isWindowsClient: true,
|
||||
worktreeHasRemoteConnection: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ export function shouldShowWindowsShellMenu(args: {
|
|||
// to be Windows, local Windows shell choices would advertise the wrong target.
|
||||
const runtimeHostIsNotKnownWindows =
|
||||
Boolean(args.activeRuntimeEnvironmentId?.trim()) && args.hostPlatform !== 'win32'
|
||||
return (
|
||||
(args.isWindowsClient || args.hostPlatform === 'win32') &&
|
||||
!args.worktreeHasRemoteConnection &&
|
||||
!runtimeHostIsNotKnownWindows
|
||||
)
|
||||
if (args.worktreeHasRemoteConnection) {
|
||||
return args.hostPlatform === 'win32'
|
||||
}
|
||||
return (args.isWindowsClient || args.hostPlatform === 'win32') && !runtimeHostIsNotKnownWindows
|
||||
}
|
||||
|
|
|
|||
|
|
@ -898,7 +898,10 @@ function WorkspaceCleanupFilterToolbar({
|
|||
<Input
|
||||
value={filters.query}
|
||||
onChange={(event) => updateFilter('query', event.target.value)}
|
||||
placeholder="Search workspaces"
|
||||
placeholder={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4361f1534c',
|
||||
'Search workspaces'
|
||||
)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -941,7 +944,10 @@ function WorkspaceCleanupFilterToolbar({
|
|||
)}
|
||||
</DropdownMenuLabel>
|
||||
<WorkspaceCleanupMenuSub<WorkspaceCleanupTimeFilter>
|
||||
label="Age"
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.6cd8439929',
|
||||
'Age'
|
||||
)}
|
||||
value={filters.time}
|
||||
options={[
|
||||
['all', 'Any age'],
|
||||
|
|
@ -952,7 +958,10 @@ function WorkspaceCleanupFilterToolbar({
|
|||
onChange={(value) => updateFilter('time', value)}
|
||||
/>
|
||||
<WorkspaceCleanupMenuSub<WorkspaceCleanupReviewFilter>
|
||||
label="Review"
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38422e7b8d',
|
||||
'Review'
|
||||
)}
|
||||
value={filters.review}
|
||||
options={[
|
||||
['all', 'Any review'],
|
||||
|
|
@ -964,7 +973,10 @@ function WorkspaceCleanupFilterToolbar({
|
|||
onChange={(value) => updateFilter('review', value)}
|
||||
/>
|
||||
<WorkspaceCleanupMenuSub<WorkspaceCleanupGitFilter>
|
||||
label="Git"
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.6359dc079a',
|
||||
'Git'
|
||||
)}
|
||||
value={filters.git}
|
||||
options={[
|
||||
['all', 'Any git'],
|
||||
|
|
@ -976,7 +988,10 @@ function WorkspaceCleanupFilterToolbar({
|
|||
onChange={(value) => updateFilter('git', value)}
|
||||
/>
|
||||
<WorkspaceCleanupMenuSub<WorkspaceCleanupContextFilter>
|
||||
label="Context"
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7644af86b2',
|
||||
'Context'
|
||||
)}
|
||||
value={filters.context}
|
||||
options={[
|
||||
['all', 'Any context'],
|
||||
|
|
@ -993,7 +1008,10 @@ function WorkspaceCleanupFilterToolbar({
|
|||
)}
|
||||
</DropdownMenuLabel>
|
||||
<WorkspaceCleanupMenuSub<WorkspaceCleanupSortKey>
|
||||
label="Sort by"
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e343b9e9ce',
|
||||
'Sort by'
|
||||
)}
|
||||
value={sortKey}
|
||||
options={[
|
||||
['activity', 'Activity'],
|
||||
|
|
@ -1005,7 +1023,10 @@ function WorkspaceCleanupFilterToolbar({
|
|||
onChange={onSortKeyChange}
|
||||
/>
|
||||
<WorkspaceCleanupMenuSub<WorkspaceCleanupSortDirection>
|
||||
label="Direction"
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.27acbc2efe',
|
||||
'Direction'
|
||||
)}
|
||||
value={sortDirection}
|
||||
options={[
|
||||
['asc', 'Ascending'],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { AppState } from '@/store/types'
|
|||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupFilters } from './workspace-cleanup-presentation'
|
||||
|
||||
export const NOW = 1_700_000_000_000
|
||||
|
|
@ -84,7 +85,10 @@ export function makeReview(overrides: Partial<HostedReviewInfo> = {}): HostedRev
|
|||
return {
|
||||
provider: 'github',
|
||||
number: 42,
|
||||
title: 'Review alpha cleanup',
|
||||
title: translate(
|
||||
'auto.components.workspace.cleanup.presentationFixtures.5ed71d83ef',
|
||||
'Review alpha cleanup'
|
||||
),
|
||||
state: 'open',
|
||||
url: 'https://example.test/review/42',
|
||||
status: 'neutral',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { HostedReviewInfo, HostedReviewProvider } from '../../../../shared/hosted-review'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
|
|
@ -110,10 +111,22 @@ function getLinkedReviewFallback(worktree: Worktree | null): {
|
|||
return null
|
||||
}
|
||||
if (worktree.linkedGitLabMR != null) {
|
||||
return { label: `MR #${worktree.linkedGitLabMR}`, provider: 'gitlab' }
|
||||
return {
|
||||
label: `${translate(
|
||||
'auto.components.workspace.cleanup.presentation.0bb8d1aa02',
|
||||
'MR #'
|
||||
)}${worktree.linkedGitLabMR}`,
|
||||
provider: 'gitlab'
|
||||
}
|
||||
}
|
||||
if (worktree.linkedPR != null) {
|
||||
return { label: `PR #${worktree.linkedPR}`, provider: 'github' }
|
||||
return {
|
||||
label: `${translate(
|
||||
'auto.components.workspace.cleanup.presentation.b1f1a02943',
|
||||
'PR #'
|
||||
)}${worktree.linkedPR}`,
|
||||
provider: 'github'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2237,7 +2237,32 @@
|
|||
"2ddbd6fe8a": "checked",
|
||||
"ee81adfcef": "View",
|
||||
"4d0b72481c": "Ignore",
|
||||
"9cc26c019d": "Remove"
|
||||
"9cc26c019d": "Remove",
|
||||
"0e2d235c63": "Inactive workspace scan ready",
|
||||
"4a35c08764": "Review",
|
||||
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
|
||||
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
|
||||
"3d957ff117": "No workspaces match these filters.",
|
||||
"e94b1f8bb4": "Clear filters",
|
||||
"efb3843e75": "Filter and sort workspaces",
|
||||
"93b7381d50": "Filters",
|
||||
"a615e24679": "Sort",
|
||||
"4cc5b73efe": "Finding inactive workspaces...",
|
||||
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
|
||||
"4361f1534c": "Search workspaces",
|
||||
"6cd8439929": "Age",
|
||||
"38422e7b8d": "Review",
|
||||
"6359dc079a": "Git",
|
||||
"7644af86b2": "Context",
|
||||
"e343b9e9ce": "Sort by",
|
||||
"27acbc2efe": "Direction"
|
||||
},
|
||||
"presentation": {
|
||||
"0bb8d1aa02": "MR #",
|
||||
"b1f1a02943": "PR #"
|
||||
},
|
||||
"presentationFixtures": {
|
||||
"5ed71d83ef": "Review alpha cleanup"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2237,7 +2237,32 @@
|
|||
"2ddbd6fe8a": "comprobado",
|
||||
"ee81adfcef": "Vista",
|
||||
"4d0b72481c": "Ignorar",
|
||||
"9cc26c019d": "Eliminar"
|
||||
"9cc26c019d": "Eliminar",
|
||||
"0e2d235c63": "Inactive workspace scan ready",
|
||||
"4a35c08764": "Review",
|
||||
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
|
||||
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
|
||||
"3d957ff117": "No workspaces match these filters.",
|
||||
"e94b1f8bb4": "Clear filters",
|
||||
"efb3843e75": "Filter and sort workspaces",
|
||||
"93b7381d50": "Filters",
|
||||
"a615e24679": "Sort",
|
||||
"4cc5b73efe": "Finding inactive workspaces...",
|
||||
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
|
||||
"4361f1534c": "Search workspaces",
|
||||
"6cd8439929": "Age",
|
||||
"38422e7b8d": "Review",
|
||||
"6359dc079a": "Git",
|
||||
"7644af86b2": "Context",
|
||||
"e343b9e9ce": "Sort by",
|
||||
"27acbc2efe": "Direction"
|
||||
},
|
||||
"presentation": {
|
||||
"0bb8d1aa02": "MR #",
|
||||
"b1f1a02943": "PR #"
|
||||
},
|
||||
"presentationFixtures": {
|
||||
"5ed71d83ef": "Review alpha cleanup"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2237,7 +2237,32 @@
|
|||
"2ddbd6fe8a": "チェック済み",
|
||||
"ee81adfcef": "表示",
|
||||
"4d0b72481c": "無視",
|
||||
"9cc26c019d": "削除"
|
||||
"9cc26c019d": "削除",
|
||||
"0e2d235c63": "Inactive workspace scan ready",
|
||||
"4a35c08764": "Review",
|
||||
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
|
||||
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
|
||||
"3d957ff117": "No workspaces match these filters.",
|
||||
"e94b1f8bb4": "Clear filters",
|
||||
"efb3843e75": "Filter and sort workspaces",
|
||||
"93b7381d50": "Filters",
|
||||
"a615e24679": "Sort",
|
||||
"4cc5b73efe": "Finding inactive workspaces...",
|
||||
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
|
||||
"4361f1534c": "Search workspaces",
|
||||
"6cd8439929": "Age",
|
||||
"38422e7b8d": "Review",
|
||||
"6359dc079a": "Git",
|
||||
"7644af86b2": "Context",
|
||||
"e343b9e9ce": "Sort by",
|
||||
"27acbc2efe": "Direction"
|
||||
},
|
||||
"presentation": {
|
||||
"0bb8d1aa02": "MR #",
|
||||
"b1f1a02943": "PR #"
|
||||
},
|
||||
"presentationFixtures": {
|
||||
"5ed71d83ef": "Review alpha cleanup"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2237,7 +2237,32 @@
|
|||
"2ddbd6fe8a": "확인됨",
|
||||
"ee81adfcef": "보기",
|
||||
"4d0b72481c": "무시",
|
||||
"9cc26c019d": "제거"
|
||||
"9cc26c019d": "제거",
|
||||
"0e2d235c63": "Inactive workspace scan ready",
|
||||
"4a35c08764": "Review",
|
||||
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
|
||||
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
|
||||
"3d957ff117": "No workspaces match these filters.",
|
||||
"e94b1f8bb4": "Clear filters",
|
||||
"efb3843e75": "Filter and sort workspaces",
|
||||
"93b7381d50": "Filters",
|
||||
"a615e24679": "Sort",
|
||||
"4cc5b73efe": "Finding inactive workspaces...",
|
||||
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
|
||||
"4361f1534c": "Search workspaces",
|
||||
"6cd8439929": "Age",
|
||||
"38422e7b8d": "Review",
|
||||
"6359dc079a": "Git",
|
||||
"7644af86b2": "Context",
|
||||
"e343b9e9ce": "Sort by",
|
||||
"27acbc2efe": "Direction"
|
||||
},
|
||||
"presentation": {
|
||||
"0bb8d1aa02": "MR #",
|
||||
"b1f1a02943": "PR #"
|
||||
},
|
||||
"presentationFixtures": {
|
||||
"5ed71d83ef": "Review alpha cleanup"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2237,7 +2237,32 @@
|
|||
"2ddbd6fe8a": "检查过",
|
||||
"ee81adfcef": "查看",
|
||||
"4d0b72481c": "忽略",
|
||||
"9cc26c019d": "移除"
|
||||
"9cc26c019d": "移除",
|
||||
"0e2d235c63": "Inactive workspace scan ready",
|
||||
"4a35c08764": "Review",
|
||||
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
|
||||
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
|
||||
"3d957ff117": "No workspaces match these filters.",
|
||||
"e94b1f8bb4": "Clear filters",
|
||||
"efb3843e75": "Filter and sort workspaces",
|
||||
"93b7381d50": "Filters",
|
||||
"a615e24679": "Sort",
|
||||
"4cc5b73efe": "Finding inactive workspaces...",
|
||||
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
|
||||
"4361f1534c": "Search workspaces",
|
||||
"6cd8439929": "Age",
|
||||
"38422e7b8d": "Review",
|
||||
"6359dc079a": "Git",
|
||||
"7644af86b2": "Context",
|
||||
"e343b9e9ce": "Sort by",
|
||||
"27acbc2efe": "Direction"
|
||||
},
|
||||
"presentation": {
|
||||
"0bb8d1aa02": "MR #",
|
||||
"b1f1a02943": "PR #"
|
||||
},
|
||||
"presentationFixtures": {
|
||||
"5ed71d83ef": "Review alpha cleanup"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getCachedWindowsTerminalCapabilities,
|
||||
getWindowsTerminalCapabilityOwnerKey,
|
||||
hasCachedWindowsTerminalCapabilities,
|
||||
loadWindowsTerminalCapabilities,
|
||||
refreshWindowsTerminalCapabilities,
|
||||
resetWindowsTerminalCapabilitiesForTests,
|
||||
selectWindowsTerminalCapabilitiesForOwner
|
||||
selectWindowsTerminalCapabilitiesForOwner,
|
||||
useWindowsTerminalCapabilities
|
||||
} from './windows-terminal-capabilities'
|
||||
|
||||
function stubTerminalCapabilityApi(args: {
|
||||
|
|
@ -42,7 +48,12 @@ function stubTerminalCapabilityApi(args: {
|
|||
}
|
||||
|
||||
describe('windows terminal capabilities', () => {
|
||||
const hookRoots: Root[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of hookRoots.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
}
|
||||
resetWindowsTerminalCapabilitiesForTests()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
|
@ -258,6 +269,246 @@ describe('windows terminal capabilities', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('loads SSH Windows host capabilities through the SSH preflight bridge', async () => {
|
||||
const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
preflight: {
|
||||
detectRemoteWindowsTerminalCapabilities
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadWindowsTerminalCapabilities({
|
||||
ownerKey: 'ssh:ssh-1',
|
||||
sshConnectionId: 'ssh-1'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32',
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledWith({
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
expect(getCachedWindowsTerminalCapabilities('ssh:ssh-1')).toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32',
|
||||
isLoading: false
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the SSH owner cache key when callers omit ownerKey', async () => {
|
||||
const detectRemoteWindowsTerminalCapabilities = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu', 'Debian'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
preflight: {
|
||||
detectRemoteWindowsTerminalCapabilities
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const sshOwnerKey = getWindowsTerminalCapabilityOwnerKey(null, 'ssh-1')
|
||||
await expect(loadWindowsTerminalCapabilities({ sshConnectionId: 'ssh-1' })).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32',
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
expect(getCachedWindowsTerminalCapabilities(sshOwnerKey)).toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32',
|
||||
isLoading: false
|
||||
})
|
||||
expect(getCachedWindowsTerminalCapabilities()).toEqual({
|
||||
wslAvailable: false,
|
||||
wslDistros: [],
|
||||
pwshAvailable: false,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: null,
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
await expect(
|
||||
refreshWindowsTerminalCapabilities(undefined, { kind: 'local' }, 'ssh-1')
|
||||
).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu', 'Debian'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: 'win32',
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
expect(getCachedWindowsTerminalCapabilities(sshOwnerKey)).toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu', 'Debian'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: 'win32',
|
||||
isLoading: false
|
||||
})
|
||||
})
|
||||
|
||||
it('loads runtime-owned SSH capabilities through runtime RPC with a scoped cache key', async () => {
|
||||
const detectRemoteWindowsTerminalCapabilities = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn(async (args: { selector: string; method: string }) => {
|
||||
const resultByMethod: Record<string, unknown> = {
|
||||
'status.get': {
|
||||
hostPlatform: 'linux',
|
||||
runtimeProtocolVersion: 3,
|
||||
minCompatibleRuntimeClientVersion: 2
|
||||
},
|
||||
'preflight.detectRemoteWindowsTerminalCapabilities': {
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: 'win32'
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: args.method,
|
||||
ok: true,
|
||||
result: resultByMethod[args.method]
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
preflight: {
|
||||
detectRemoteWindowsTerminalCapabilities
|
||||
},
|
||||
runtimeEnvironments: {
|
||||
call: runtimeEnvironmentCall
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ownerKey = getWindowsTerminalCapabilityOwnerKey('env-1', 'ssh-1')
|
||||
expect(ownerKey).toBe('runtime:env-1:ssh:ssh-1')
|
||||
|
||||
await expect(
|
||||
loadWindowsTerminalCapabilities({
|
||||
target: { kind: 'environment', environmentId: 'env-1' },
|
||||
sshConnectionId: 'ssh-1'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: 'win32',
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
expect(detectRemoteWindowsTerminalCapabilities).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'env-1',
|
||||
method: 'preflight.detectRemoteWindowsTerminalCapabilities',
|
||||
params: { connectionId: 'ssh-1' }
|
||||
})
|
||||
)
|
||||
expect(getCachedWindowsTerminalCapabilities(ownerKey)).toEqual({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: 'win32',
|
||||
isLoading: false
|
||||
})
|
||||
expect(getCachedWindowsTerminalCapabilities('ssh:ssh-1')).toEqual({
|
||||
wslAvailable: false,
|
||||
wslDistros: [],
|
||||
pwshAvailable: false,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: null,
|
||||
isLoading: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not re-probe on parent rerenders with the same capability target', async () => {
|
||||
const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu'],
|
||||
pwshAvailable: true,
|
||||
gitBashAvailable: true,
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
preflight: {
|
||||
detectRemoteWindowsTerminalCapabilities
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function HookProbe(): null {
|
||||
useWindowsTerminalCapabilities(true, false, undefined, { kind: 'local' }, 'ssh-1')
|
||||
return null
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
hookRoots.push(root)
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe))
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe))
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('prunes expired runtime owner capability caches', async () => {
|
||||
stubTerminalCapabilityApi({
|
||||
wslAvailable: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
readWindowsTerminalCapabilities,
|
||||
type WindowsTerminalCapabilityLoadTarget
|
||||
} from './windows-terminal-capability-read'
|
||||
|
||||
export type WindowsTerminalCapabilities = {
|
||||
wslAvailable: boolean
|
||||
|
|
@ -39,14 +41,33 @@ type WindowsTerminalCapabilityHookState = {
|
|||
capabilities: WindowsTerminalCapabilities
|
||||
}
|
||||
|
||||
type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget
|
||||
function resolveWindowsTerminalCapabilityCacheKey(args: {
|
||||
ownerKey?: string
|
||||
target?: WindowsTerminalCapabilityLoadTarget
|
||||
sshConnectionId?: string | null
|
||||
}): string {
|
||||
const explicitOwnerKey = args.ownerKey?.trim()
|
||||
if (explicitOwnerKey) {
|
||||
return explicitOwnerKey
|
||||
}
|
||||
const environmentId = args.target?.kind === 'environment' ? args.target.environmentId : null
|
||||
return getWindowsTerminalCapabilityOwnerKey(environmentId, args.sshConnectionId)
|
||||
}
|
||||
|
||||
export function getWindowsTerminalCapabilityOwnerKey(
|
||||
activeRuntimeEnvironmentId?: string | null
|
||||
activeRuntimeEnvironmentId?: string | null,
|
||||
sshConnectionId?: string | null
|
||||
): string {
|
||||
// Why: remote desktop and paired web clients can switch hosts; Git Bash/WSL availability is
|
||||
// host-owned, so a previous runtime's answer must not bleed into the next.
|
||||
const connectionId = sshConnectionId?.trim()
|
||||
const environmentId = activeRuntimeEnvironmentId?.trim()
|
||||
if (connectionId && environmentId) {
|
||||
return `runtime:${environmentId}:ssh:${connectionId}`
|
||||
}
|
||||
if (connectionId) {
|
||||
return `ssh:${connectionId}`
|
||||
}
|
||||
return environmentId ? `runtime:${environmentId}` : 'local'
|
||||
}
|
||||
|
||||
|
|
@ -105,11 +126,17 @@ export function loadWindowsTerminalCapabilities(
|
|||
now?: number
|
||||
ownerKey?: string
|
||||
target?: WindowsTerminalCapabilityLoadTarget
|
||||
sshConnectionId?: string | null
|
||||
} = {}
|
||||
): Promise<WindowsTerminalCapabilities> {
|
||||
const now = options.now ?? Date.now()
|
||||
const ownerKey = options.ownerKey ?? 'local'
|
||||
const sshConnectionId = options.sshConnectionId?.trim() || null
|
||||
const target = options.target ?? { kind: 'local' }
|
||||
const ownerKey = resolveWindowsTerminalCapabilityCacheKey({
|
||||
ownerKey: options.ownerKey,
|
||||
target,
|
||||
sshConnectionId
|
||||
})
|
||||
pruneExpiredCapabilityOwners(now)
|
||||
const cached = cachedCapabilitiesByOwnerKey.get(ownerKey)
|
||||
if (cached && !options.force && now - cached.loadedAt < CAPABILITY_CACHE_TTL_MS) {
|
||||
|
|
@ -124,16 +151,8 @@ export function loadWindowsTerminalCapabilities(
|
|||
// Separate probes can leave one surface showing stale Windows shell choices.
|
||||
const requestId = ++nextCapabilityRequestId
|
||||
latestCapabilityRequestIdByOwnerKey.set(ownerKey, requestId)
|
||||
const nextPendingCapabilities = Promise.all(readWindowsTerminalCapabilityPromises(target))
|
||||
.then(([wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform]) => {
|
||||
const capabilities = {
|
||||
wslAvailable,
|
||||
wslDistros,
|
||||
pwshAvailable,
|
||||
gitBashAvailable,
|
||||
hostPlatform,
|
||||
isLoading: false
|
||||
}
|
||||
const nextPendingCapabilities = readWindowsTerminalCapabilities(target, sshConnectionId)
|
||||
.then((capabilities) => {
|
||||
if (requestId === latestCapabilityRequestIdByOwnerKey.get(ownerKey)) {
|
||||
pendingCapabilitiesByOwnerKey.delete(ownerKey)
|
||||
publish(capabilities, ownerKey, now)
|
||||
|
|
@ -155,10 +174,11 @@ export function loadWindowsTerminalCapabilities(
|
|||
}
|
||||
|
||||
export function refreshWindowsTerminalCapabilities(
|
||||
ownerKey = 'local',
|
||||
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }
|
||||
ownerKey: string | undefined = undefined,
|
||||
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' },
|
||||
sshConnectionId?: string | null
|
||||
): Promise<WindowsTerminalCapabilities> {
|
||||
return loadWindowsTerminalCapabilities({ force: true, ownerKey, target })
|
||||
return loadWindowsTerminalCapabilities({ force: true, ownerKey, target, sshConnectionId })
|
||||
}
|
||||
|
||||
export function selectWindowsTerminalCapabilitiesForOwner(
|
||||
|
|
@ -171,107 +191,76 @@ export function selectWindowsTerminalCapabilitiesForOwner(
|
|||
}
|
||||
return state.ownerKey === ownerKey
|
||||
? state.capabilities
|
||||
: (cachedCapabilitiesByOwnerKey.get(ownerKey)?.capabilities ?? UNAVAILABLE_CAPABILITIES)
|
||||
: getCachedWindowsTerminalCapabilities(ownerKey)
|
||||
}
|
||||
|
||||
export function useWindowsTerminalCapabilities(
|
||||
enabled: boolean,
|
||||
forceRefreshOnMount = false,
|
||||
ownerKey = 'local',
|
||||
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }
|
||||
ownerKey: string | undefined = undefined,
|
||||
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' },
|
||||
sshConnectionId?: string | null
|
||||
): WindowsTerminalCapabilities {
|
||||
const targetKind = target.kind
|
||||
const targetEnvironmentId = target.kind === 'environment' ? target.environmentId : null
|
||||
const [state, setState] = useState(() => ({
|
||||
const sshConnectionIdKey = sshConnectionId?.trim() || null
|
||||
const resolvedTarget: WindowsTerminalCapabilityLoadTarget = useMemo(
|
||||
() =>
|
||||
targetKind === 'environment' && targetEnvironmentId
|
||||
? { kind: 'environment', environmentId: targetEnvironmentId }
|
||||
: { kind: 'local' },
|
||||
[targetKind, targetEnvironmentId]
|
||||
)
|
||||
const resolvedOwnerKey = resolveWindowsTerminalCapabilityCacheKey({
|
||||
ownerKey,
|
||||
capabilities: getCachedWindowsTerminalCapabilities(ownerKey)
|
||||
target: resolvedTarget,
|
||||
sshConnectionId: sshConnectionIdKey
|
||||
})
|
||||
const [state, setState] = useState(() => ({
|
||||
ownerKey: resolvedOwnerKey,
|
||||
capabilities: getCachedWindowsTerminalCapabilities(resolvedOwnerKey)
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState({ ownerKey, capabilities: UNAVAILABLE_CAPABILITIES })
|
||||
setState({ ownerKey: resolvedOwnerKey, capabilities: UNAVAILABLE_CAPABILITIES })
|
||||
return
|
||||
}
|
||||
const loadTarget: WindowsTerminalCapabilityLoadTarget =
|
||||
targetKind === 'environment' && targetEnvironmentId
|
||||
? { kind: 'environment', environmentId: targetEnvironmentId }
|
||||
: { kind: 'local' }
|
||||
|
||||
let cancelled = false
|
||||
const cached = getCachedWindowsTerminalCapabilities(ownerKey)
|
||||
const hasOwnerCache = cachedCapabilitiesByOwnerKey.has(ownerKey)
|
||||
const cached = getCachedWindowsTerminalCapabilities(resolvedOwnerKey)
|
||||
const hasOwnerCache = cachedCapabilitiesByOwnerKey.has(resolvedOwnerKey)
|
||||
setState({
|
||||
ownerKey,
|
||||
ownerKey: resolvedOwnerKey,
|
||||
capabilities: hasOwnerCache ? cached : { ...cached, isLoading: true }
|
||||
})
|
||||
const setCapabilities = (capabilities: WindowsTerminalCapabilities): void => {
|
||||
setState({ ownerKey, capabilities })
|
||||
setState({ ownerKey: resolvedOwnerKey, capabilities })
|
||||
}
|
||||
const subscribers = subscribersByOwnerKey.get(ownerKey) ?? new Set()
|
||||
const subscribers = subscribersByOwnerKey.get(resolvedOwnerKey) ?? new Set()
|
||||
subscribers.add(setCapabilities)
|
||||
subscribersByOwnerKey.set(ownerKey, subscribers)
|
||||
subscribersByOwnerKey.set(resolvedOwnerKey, subscribers)
|
||||
void loadWindowsTerminalCapabilities({
|
||||
force: forceRefreshOnMount,
|
||||
ownerKey,
|
||||
target: loadTarget
|
||||
ownerKey: resolvedOwnerKey,
|
||||
target: resolvedTarget,
|
||||
sshConnectionId: sshConnectionIdKey
|
||||
}).then((nextCapabilities) => {
|
||||
if (!cancelled) {
|
||||
setState({ ownerKey, capabilities: nextCapabilities })
|
||||
setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities })
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
const currentSubscribers = subscribersByOwnerKey.get(ownerKey)
|
||||
const currentSubscribers = subscribersByOwnerKey.get(resolvedOwnerKey)
|
||||
currentSubscribers?.delete(setCapabilities)
|
||||
if (currentSubscribers?.size === 0) {
|
||||
subscribersByOwnerKey.delete(ownerKey)
|
||||
subscribersByOwnerKey.delete(resolvedOwnerKey)
|
||||
}
|
||||
}
|
||||
}, [enabled, forceRefreshOnMount, ownerKey, targetKind, targetEnvironmentId])
|
||||
}, [enabled, forceRefreshOnMount, resolvedOwnerKey, resolvedTarget, sshConnectionIdKey])
|
||||
|
||||
return selectWindowsTerminalCapabilitiesForOwner(state, enabled, ownerKey)
|
||||
}
|
||||
|
||||
function readWindowsTerminalCapabilityPromises(
|
||||
target: WindowsTerminalCapabilityLoadTarget
|
||||
): [
|
||||
Promise<boolean>,
|
||||
Promise<string[]>,
|
||||
Promise<boolean>,
|
||||
Promise<boolean>,
|
||||
Promise<NodeJS.Platform | null>
|
||||
] {
|
||||
if (target.kind === 'local') {
|
||||
return [
|
||||
window.api.wsl.isAvailable().catch(() => false),
|
||||
window.api.wsl.listDistros().catch(() => []),
|
||||
window.api.pwsh.isAvailable().catch(() => false),
|
||||
window.api.gitBash.isAvailable().catch(() => false),
|
||||
window.api.runtime
|
||||
.getStatus()
|
||||
.then((status) => status.hostPlatform ?? null)
|
||||
.catch(() => null)
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
callRuntimeRpc<boolean>(target, 'host.wsl.isAvailable', undefined, { timeoutMs: 15_000 }).catch(
|
||||
() => false
|
||||
),
|
||||
callRuntimeRpc<string[]>(target, 'host.wsl.listDistros', undefined, {
|
||||
timeoutMs: 15_000
|
||||
}).catch(() => []),
|
||||
callRuntimeRpc<boolean>(target, 'host.pwsh.isAvailable', undefined, {
|
||||
timeoutMs: 15_000
|
||||
}).catch(() => false),
|
||||
callRuntimeRpc<boolean>(target, 'host.gitBash.isAvailable', undefined, {
|
||||
timeoutMs: 15_000
|
||||
}).catch(() => false),
|
||||
callRuntimeRpc<RuntimeStatus>(target, 'status.get', undefined, { timeoutMs: 15_000 })
|
||||
.then((status) => status.hostPlatform ?? null)
|
||||
.catch(() => null)
|
||||
]
|
||||
return selectWindowsTerminalCapabilitiesForOwner(state, enabled, resolvedOwnerKey)
|
||||
}
|
||||
|
||||
export function resetWindowsTerminalCapabilitiesForTests(): void {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import type { WindowsTerminalCapabilities } from './windows-terminal-capabilities'
|
||||
|
||||
export type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget
|
||||
|
||||
export async function readWindowsTerminalCapabilities(
|
||||
target: WindowsTerminalCapabilityLoadTarget,
|
||||
sshConnectionId?: string | null
|
||||
): Promise<WindowsTerminalCapabilities> {
|
||||
if (sshConnectionId) {
|
||||
const remoteCapabilityPromise =
|
||||
target.kind === 'environment'
|
||||
? callRuntimeRpc<Omit<WindowsTerminalCapabilities, 'isLoading'>>(
|
||||
target,
|
||||
'preflight.detectRemoteWindowsTerminalCapabilities',
|
||||
{ connectionId: sshConnectionId },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
: window.api.preflight.detectRemoteWindowsTerminalCapabilities({
|
||||
connectionId: sshConnectionId
|
||||
})
|
||||
return remoteCapabilityPromise
|
||||
.then((capabilities) => ({
|
||||
...capabilities,
|
||||
wslDistros: capabilities.wslDistros ?? [],
|
||||
isLoading: false
|
||||
}))
|
||||
.catch(() => ({
|
||||
wslAvailable: false,
|
||||
wslDistros: [],
|
||||
pwshAvailable: false,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: null,
|
||||
isLoading: false
|
||||
}))
|
||||
}
|
||||
|
||||
if (target.kind === 'local') {
|
||||
const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] =
|
||||
await Promise.all([
|
||||
window.api.wsl.isAvailable().catch(() => false),
|
||||
window.api.wsl.listDistros().catch(() => []),
|
||||
window.api.pwsh.isAvailable().catch(() => false),
|
||||
window.api.gitBash.isAvailable().catch(() => false),
|
||||
window.api.runtime
|
||||
.getStatus()
|
||||
.then((status) => status.hostPlatform ?? null)
|
||||
.catch(() => null)
|
||||
])
|
||||
return {
|
||||
wslAvailable,
|
||||
wslDistros,
|
||||
pwshAvailable,
|
||||
gitBashAvailable,
|
||||
hostPlatform,
|
||||
isLoading: false
|
||||
}
|
||||
}
|
||||
|
||||
const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] =
|
||||
await Promise.all([
|
||||
callRuntimeRpc<boolean>(target, 'host.wsl.isAvailable', undefined, {
|
||||
timeoutMs: 15_000
|
||||
}).catch(() => false),
|
||||
callRuntimeRpc<string[]>(target, 'host.wsl.listDistros', undefined, {
|
||||
timeoutMs: 15_000
|
||||
}).catch(() => []),
|
||||
callRuntimeRpc<boolean>(target, 'host.pwsh.isAvailable', undefined, {
|
||||
timeoutMs: 15_000
|
||||
}).catch(() => false),
|
||||
callRuntimeRpc<boolean>(target, 'host.gitBash.isAvailable', undefined, {
|
||||
timeoutMs: 15_000
|
||||
}).catch(() => false),
|
||||
callRuntimeRpc<RuntimeStatus>(target, 'status.get', undefined, { timeoutMs: 15_000 })
|
||||
.then((status) => status.hostPlatform ?? null)
|
||||
.catch(() => null)
|
||||
])
|
||||
return {
|
||||
wslAvailable,
|
||||
wslDistros,
|
||||
pwshAvailable,
|
||||
gitBashAvailable,
|
||||
hostPlatform,
|
||||
isLoading: false
|
||||
}
|
||||
}
|
||||
|
|
@ -1342,6 +1342,104 @@ describe('setActiveWorktree', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('preserves explicit Windows shell selections for Windows SSH terminal tabs', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
|
||||
configurable: true
|
||||
})
|
||||
try {
|
||||
const store = createTestStore()
|
||||
const wt = 'remote-repo::/path/wt1'
|
||||
|
||||
seedStore(store, {
|
||||
repos: [
|
||||
{
|
||||
id: 'remote-repo',
|
||||
path: '/remote/repo',
|
||||
displayName: 'Remote Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'ssh-1',
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
remotePlatform: 'win32'
|
||||
}
|
||||
]
|
||||
]),
|
||||
settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'wsl.exe' },
|
||||
worktreesByRepo: {
|
||||
'remote-repo': [makeWorktree({ id: wt, repoId: 'remote-repo', path: '/path/wt1' })]
|
||||
}
|
||||
})
|
||||
|
||||
const terminal = store.getState().createTab(wt, undefined, 'cmd.exe')
|
||||
expect(terminal.shellOverride).toBe('cmd.exe')
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('drops explicit Windows shell selections for non-Windows SSH terminal tabs', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
|
||||
configurable: true
|
||||
})
|
||||
try {
|
||||
const store = createTestStore()
|
||||
const wt = 'remote-repo::/path/wt1'
|
||||
|
||||
seedStore(store, {
|
||||
repos: [
|
||||
{
|
||||
id: 'remote-repo',
|
||||
path: '/remote/repo',
|
||||
displayName: 'Remote Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'ssh-1',
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
remotePlatform: 'linux'
|
||||
}
|
||||
]
|
||||
]),
|
||||
settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'wsl.exe' },
|
||||
worktreesByRepo: {
|
||||
'remote-repo': [makeWorktree({ id: wt, repoId: 'remote-repo', path: '/path/wt1' })]
|
||||
}
|
||||
})
|
||||
|
||||
const terminal = store.getState().createTab(wt, undefined, 'cmd.exe')
|
||||
expect(terminal.shellOverride).toBeUndefined()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('does not offer Git Bash as a local shell override for SSH terminal tabs', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths'
|
|||
import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime'
|
||||
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
|
||||
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime'
|
||||
import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell'
|
||||
import type { AgentStartedTelemetry } from '../../lib/worktree-activation'
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
|
||||
|
|
@ -169,14 +170,28 @@ function isWindowsRendererRuntime(): boolean {
|
|||
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
|
||||
}
|
||||
|
||||
function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): boolean {
|
||||
return (
|
||||
shell === 'powershell.exe' ||
|
||||
shell === 'pwsh.exe' ||
|
||||
shell === 'cmd.exe' ||
|
||||
shell === 'wsl.exe' ||
|
||||
shell === WINDOWS_GIT_BASH_SHELL
|
||||
)
|
||||
}
|
||||
|
||||
function resolveCreatedTabShellOverride(
|
||||
explicitShellOverride: string | undefined,
|
||||
defaultWindowsShell: string | undefined,
|
||||
isRemoteWorktree: boolean,
|
||||
remotePlatform: NodeJS.Platform | null,
|
||||
isWslWorktree: boolean,
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined
|
||||
): string | undefined {
|
||||
if (isRemoteWorktree) {
|
||||
if (remotePlatform === 'win32' && isAllowedRemoteWindowsTerminalShell(explicitShellOverride)) {
|
||||
return explicitShellOverride
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (isWindowsRendererRuntime()) {
|
||||
|
|
@ -231,6 +246,27 @@ export function worktreeUsesRemoteConnection(
|
|||
return Boolean(repo?.connectionId)
|
||||
}
|
||||
|
||||
function getRemoteConnectionIdForWorktree(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
): string | null {
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null
|
||||
}
|
||||
const directRepoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const directRepo = state.repos.find((repo) => repo.id === directRepoId)
|
||||
if (directRepo) {
|
||||
return directRepo.connectionId?.trim() || null
|
||||
}
|
||||
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
|
||||
return repo?.connectionId?.trim() || null
|
||||
}
|
||||
|
||||
function resolveTerminalStopRuntimeEnvironmentId(
|
||||
state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
|
|
@ -686,7 +722,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
const nextOrdinal = getNextTerminalOrdinal(existing)
|
||||
const defaultTitle = `Terminal ${nextOrdinal}`
|
||||
const quickCommandLabel = options?.quickCommandLabel?.trim()
|
||||
const isRemoteWorktree = worktreeUsesRemoteConnection(s, worktreeId)
|
||||
const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId)
|
||||
const isRemoteWorktree = Boolean(remoteConnectionId)
|
||||
const isWslWorktree = worktreeUsesWslPath(s, worktreeId)
|
||||
const createdShellOverride = resolveCreatedTabShellOverride(
|
||||
shellOverride,
|
||||
|
|
@ -694,6 +731,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
// Why: SSH PTYs ignore local Windows shell selection; persisting a
|
||||
// local shell icon would mislabel a remote terminal.
|
||||
isRemoteWorktree,
|
||||
remoteConnectionId
|
||||
? ((s.sshConnectionStates.get(remoteConnectionId)
|
||||
?.remotePlatform as NodeJS.Platform | null) ?? null)
|
||||
: null,
|
||||
// Why: WSL UNC worktrees are repo-scoped WSL environments. New default
|
||||
// terminals should enter that distro even when the global Windows shell
|
||||
// preference is PowerShell or cmd.exe.
|
||||
|
|
|
|||
|
|
@ -2179,6 +2179,20 @@ function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
|
|||
pathSource: 'sync_seed_only',
|
||||
pathFailureReason: 'spawn_error'
|
||||
}
|
||||
type WindowsTerminalCapabilityBridgeResult = {
|
||||
wslAvailable: boolean
|
||||
wslDistros: string[]
|
||||
pwshAvailable: boolean
|
||||
gitBashAvailable: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}
|
||||
const fallbackWindowsTerminalCapabilities = {
|
||||
wslAvailable: false,
|
||||
wslDistros: [],
|
||||
pwshAvailable: false,
|
||||
gitBashAvailable: false,
|
||||
hostPlatform: null
|
||||
}
|
||||
return {
|
||||
check: async (args) => {
|
||||
if (!requireActiveEnvironmentOrNull()) {
|
||||
|
|
@ -2201,7 +2215,14 @@ function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
|
|||
detectRemoteAgents: async (args) =>
|
||||
requireActiveEnvironmentOrNull()
|
||||
? callRuntimeResult<string[]>('preflight.detectRemoteAgents', args).catch(() => [])
|
||||
: []
|
||||
: [],
|
||||
detectRemoteWindowsTerminalCapabilities: async (args) =>
|
||||
requireActiveEnvironmentOrNull()
|
||||
? callRuntimeResult<WindowsTerminalCapabilityBridgeResult>(
|
||||
'preflight.detectRemoteWindowsTerminalCapabilities',
|
||||
args
|
||||
).catch(() => fallbackWindowsTerminalCapabilities)
|
||||
: Promise.resolve(fallbackWindowsTerminalCapabilities)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue