diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts new file mode 100644 index 00000000000..4b807ba8afb --- /dev/null +++ b/src/main/ipc/preflight-command-exec.ts @@ -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(command: string, commandPromise: Promise): Promise { + let timeout: ReturnType | null = null + try { + return await Promise.race([ + commandPromise, + new Promise((_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 { + const env = buildLocalPreflightEnv() + const commandPromise = execFileAsync(command, args, { + encoding: 'utf-8', + timeout: PREFLIGHT_COMMAND_TIMEOUT_MS, + ...(env ? { env } : {}) + }) as Promise + + return withPreflightTimeout(command, commandPromise) +} + +export async function execCommandInWsl( + target: WslPreflightTarget, + command: string +): Promise { + const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS) + return withPreflightTimeout('wsl.exe', commandPromise) +} + +export async function isCommandAvailable( + command: string, + wslTarget?: WslPreflightTarget +): Promise { + 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 { + 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 + } +} diff --git a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts new file mode 100644 index 00000000000..f28886cdaec --- /dev/null +++ b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts @@ -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 { + 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 +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index d3ffef854ef..944ee1b4418 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -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, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index fbe00db4605..cc14ca490b6 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -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(command: string, commandPromise: Promise): Promise { - let timeout: ReturnType | null = null - try { - return await Promise.race([ - commandPromise, - new Promise((_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 { - const env = buildLocalPreflightEnv() - const commandPromise = execFileAsync(command, args, { - encoding: 'utf-8', - timeout: PREFLIGHT_COMMAND_TIMEOUT_MS, - ...(env ? { env } : {}) - }) as Promise - - 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 { - 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 { - 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 => { + return detectRemoteWindowsTerminalCapabilities(args) + } + ) } diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 7a6af3da39a..1523f38f02f 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -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' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index c33910bad95..7284f2353ba 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -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 } diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index d28b97b8e53..e8f1ef82930 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -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' + } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 4e0f06201e9..aafbb96319c 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7a485016fe6..96429261e41 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -548,6 +548,13 @@ export type PreflightApi = { detectAgents: (args?: PreflightRuntimeContext) => Promise refreshAgents: (args?: PreflightRuntimeContext) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise + 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index d29a1b701c7..be4ba360c2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1771,7 +1771,16 @@ const api = { refreshAgents: (args?: PreflightRuntimeContext): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => - 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: { diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 6d2f9339b9e..668354b03db 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -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) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + 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 + }) + } + }) +}) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index a0382d77275..cdfc42c2b21 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -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. diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 3c9b0597030..321458c65da 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -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' }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 8ac4259d3ee..5f4fff86610 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -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 | 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 }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index d2ee25e2d41..935f48d46d4 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -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, 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') { diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index f61ff4aff33..18a0bb907bf 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -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, diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 9c1073ac246..1fa6e5809a8 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -20,6 +20,7 @@ const appStoreSnapshot: { sourceRepoIds?: string[] }[] repos: { id: string; connectionId?: string | null }[] + sshConnectionStates: Map 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 projects: typeof appStoreSnapshot.projects repos: { id: string; connectionId?: string | null }[] + sshConnectionStates: Map worktreesByRepo: typeof appStoreSnapshot.worktreesByRepo unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record @@ -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) => 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() }) diff --git a/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts b/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts index fefc1ca44a4..e196f888138 100644 --- a/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts +++ b/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts @@ -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) }) }) diff --git a/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts b/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts index 48af2956238..26a4bad10d2 100644 --- a/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts +++ b/src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts @@ -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 } diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 11ca5d3c2f3..242a56f62cf 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -898,7 +898,10 @@ function WorkspaceCleanupFilterToolbar({ 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" /> @@ -941,7 +944,10 @@ function WorkspaceCleanupFilterToolbar({ )} - 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)} /> - 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)} /> - 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)} /> - label="Context" + label={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7644af86b2', + 'Context' + )} value={filters.context} options={[ ['all', 'Any context'], @@ -993,7 +1008,10 @@ function WorkspaceCleanupFilterToolbar({ )} - 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} /> - label="Direction" + label={translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.27acbc2efe', + 'Direction' + )} value={sortDirection} options={[ ['asc', 'Ascending'], diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts index a9f6b5362da..3dd1fb6bc4d 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts @@ -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 = {}): 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', diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index ad860e1f7e2..1dbbeb6e343 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -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 } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 14b4834a441..dc987fa1ea7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5247ced7988..12eb041c162 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7c12bb98c9d..515a4873bc2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2304381ddcf..d0ca9068824 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 98467e2c391..cf8639fed03 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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" } } }, diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 55af965a537..18f00b5b44c 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -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 = { + '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, diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 6af6037e0f8..ba8da74c86a 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -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 { 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 { - 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, - Promise, - Promise, - Promise, - Promise -] { - 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(target, 'host.wsl.isAvailable', undefined, { timeoutMs: 15_000 }).catch( - () => false - ), - callRuntimeRpc(target, 'host.wsl.listDistros', undefined, { - timeoutMs: 15_000 - }).catch(() => []), - callRuntimeRpc(target, 'host.pwsh.isAvailable', undefined, { - timeoutMs: 15_000 - }).catch(() => false), - callRuntimeRpc(target, 'host.gitBash.isAvailable', undefined, { - timeoutMs: 15_000 - }).catch(() => false), - callRuntimeRpc(target, 'status.get', undefined, { timeoutMs: 15_000 }) - .then((status) => status.hostPlatform ?? null) - .catch(() => null) - ] + return selectWindowsTerminalCapabilitiesForOwner(state, enabled, resolvedOwnerKey) } export function resetWindowsTerminalCapabilitiesForTests(): void { diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts new file mode 100644 index 00000000000..e73336b424f --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -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 { + if (sshConnectionId) { + const remoteCapabilityPromise = + target.kind === 'environment' + ? callRuntimeRpc>( + 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(target, 'host.wsl.isAvailable', undefined, { + timeoutMs: 15_000 + }).catch(() => false), + callRuntimeRpc(target, 'host.wsl.listDistros', undefined, { + timeoutMs: 15_000 + }).catch(() => []), + callRuntimeRpc(target, 'host.pwsh.isAvailable', undefined, { + timeoutMs: 15_000 + }).catch(() => false), + callRuntimeRpc(target, 'host.gitBash.isAvailable', undefined, { + timeoutMs: 15_000 + }).catch(() => false), + callRuntimeRpc(target, 'status.get', undefined, { timeoutMs: 15_000 }) + .then((status) => status.hostPlatform ?? null) + .catch(() => null) + ]) + return { + wslAvailable, + wslDistros, + pwshAvailable, + gitBashAvailable, + hostPlatform, + isLoading: false + } +} diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 4f2d06848e0..9e70b9136c0 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -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', { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index b99b248a503..4911236964e 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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, + 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, worktreeId: string @@ -686,7 +722,8 @@ export const createTerminalSlice: StateCreator 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 // 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. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index eee02f7627c..aff6f472e12 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2179,6 +2179,20 @@ function createPreflightApi(): NonNullable['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['preflight']> { detectRemoteAgents: async (args) => requireActiveEnvironmentOrNull() ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) - : [] + : [], + detectRemoteWindowsTerminalCapabilities: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult( + 'preflight.detectRemoteWindowsTerminalCapabilities', + args + ).catch(() => fallbackWindowsTerminalCapabilities) + : Promise.resolve(fallbackWindowsTerminalCapabilities) } }