Fix WSL terminal settings parity (#2446)
This commit is contained in:
parent
288d96be03
commit
6abf031d66
|
|
@ -0,0 +1,203 @@
|
|||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { WslCliInstaller, _internals } from './wsl-cli-installer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function makeHostStatus(launcherPath = 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd') {
|
||||
return {
|
||||
platform: 'win32',
|
||||
commandName: 'orca',
|
||||
commandPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
|
||||
pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin',
|
||||
pathConfigured: true,
|
||||
launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: 'installed',
|
||||
currentTarget: launcherPath,
|
||||
unsupportedReason: null,
|
||||
detail: null
|
||||
} satisfies CliInstallStatus
|
||||
}
|
||||
|
||||
function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) {
|
||||
const commandPath = '/home/alice/.local/bin/orca'
|
||||
const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
|
||||
const files = new Map<string, string>()
|
||||
if (initialFile !== null) {
|
||||
files.set(commandPath, initialFile)
|
||||
files.set(bridgePath, _internals.buildWslBridgeScript())
|
||||
}
|
||||
const calls: string[] = []
|
||||
const runner = vi.fn(async (_distro: string, command: string) => {
|
||||
calls.push(command)
|
||||
if (command.includes('printf %s "$HOME"')) {
|
||||
return '/home/alice'
|
||||
}
|
||||
if (command.includes('command -v powershell.exe')) {
|
||||
return 'yes'
|
||||
}
|
||||
if (command.includes('case ":$PATH:"')) {
|
||||
return pathIncludesLocalBin ? 'yes' : 'no'
|
||||
}
|
||||
if (command.includes('cat > "$command_tmp"')) {
|
||||
const launcher =
|
||||
command.match(/cat > "\$command_tmp" <<'ORCA_WSL_CLI'\n([\s\S]*)\nORCA_WSL_CLI/)?.[1] ?? ''
|
||||
const bridge =
|
||||
command.match(
|
||||
/cat > "\$bridge_tmp" <<'ORCA_WSL_BRIDGE'\n([\s\S]*)\nORCA_WSL_BRIDGE/
|
||||
)?.[1] ?? ''
|
||||
files.set(commandPath, launcher)
|
||||
files.set(bridgePath, bridge)
|
||||
return ''
|
||||
}
|
||||
if (command.includes('rm -f')) {
|
||||
if (
|
||||
files.has(bridgePath) &&
|
||||
!files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
|
||||
) {
|
||||
throw new Error('__ORCA_CONFLICT__')
|
||||
}
|
||||
files.delete(commandPath)
|
||||
files.delete(bridgePath)
|
||||
return ''
|
||||
}
|
||||
if (command.includes('cat ')) {
|
||||
if (command.includes(commandPath)) {
|
||||
return files.get(commandPath) ?? '__ORCA_MISSING__'
|
||||
}
|
||||
if (command.includes(bridgePath)) {
|
||||
return files.get(bridgePath) ?? '__ORCA_MISSING__'
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected WSL command: ${command}`)
|
||||
})
|
||||
return {
|
||||
runner,
|
||||
calls,
|
||||
getBridge: () => files.get(bridgePath) ?? null,
|
||||
getFile: () => files.get(commandPath) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
describe('WslCliInstaller', () => {
|
||||
it('installs a WSL launcher that forwards to the Windows Orca launcher', async () => {
|
||||
const wsl = createWslRunner()
|
||||
const installer = new WslCliInstaller({
|
||||
platform: 'win32',
|
||||
distro: 'Ubuntu',
|
||||
hostInstaller: { getStatus: async () => makeHostStatus() },
|
||||
wslRunner: wsl.runner
|
||||
})
|
||||
|
||||
await expect(installer.getStatus()).resolves.toMatchObject({
|
||||
state: 'not_installed',
|
||||
commandPath: '/home/alice/.local/bin/orca'
|
||||
})
|
||||
|
||||
const installed = await installer.install()
|
||||
|
||||
expect(installed).toMatchObject({
|
||||
state: 'installed',
|
||||
pathConfigured: true,
|
||||
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd'
|
||||
})
|
||||
expect(wsl.getFile()).toBe(
|
||||
_internals.buildWslLauncher(
|
||||
'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd',
|
||||
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
|
||||
)
|
||||
)
|
||||
expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript())
|
||||
})
|
||||
|
||||
it('reports installed WSL launchers whose bin directory is missing from PATH', async () => {
|
||||
const launcher = _internals.buildWslLauncher(
|
||||
'C:\\Orca\\orca.cmd',
|
||||
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
|
||||
)
|
||||
const wsl = createWslRunner(launcher, false)
|
||||
const installer = new WslCliInstaller({
|
||||
platform: 'win32',
|
||||
distro: 'Ubuntu',
|
||||
hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') },
|
||||
wslRunner: wsl.runner
|
||||
})
|
||||
|
||||
await expect(installer.getStatus()).resolves.toMatchObject({
|
||||
state: 'installed',
|
||||
pathConfigured: false,
|
||||
detail: expect.stringContaining('not on PATH')
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to replace an unmanaged WSL command', async () => {
|
||||
const wsl = createWslRunner('#!/usr/bin/env bash\necho elsewhere\n')
|
||||
const installer = new WslCliInstaller({
|
||||
platform: 'win32',
|
||||
distro: 'Ubuntu',
|
||||
hostInstaller: { getStatus: async () => makeHostStatus() },
|
||||
wslRunner: wsl.runner
|
||||
})
|
||||
|
||||
await expect(installer.getStatus()).resolves.toMatchObject({ state: 'conflict' })
|
||||
await expect(installer.install()).rejects.toThrow('Refusing to replace')
|
||||
})
|
||||
|
||||
it('removes a managed WSL launcher', async () => {
|
||||
const wsl = createWslRunner(
|
||||
_internals.buildWslLauncher(
|
||||
'C:\\Orca\\orca.cmd',
|
||||
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
|
||||
)
|
||||
)
|
||||
const installer = new WslCliInstaller({
|
||||
platform: 'win32',
|
||||
distro: 'Ubuntu',
|
||||
hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') },
|
||||
wslRunner: wsl.runner
|
||||
})
|
||||
|
||||
await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' })
|
||||
expect(wsl.getFile()).toBeNull()
|
||||
})
|
||||
|
||||
it('generates a launcher that forwards arguments through a PowerShell file bridge', () => {
|
||||
const launcher = _internals.buildWslLauncher(
|
||||
'C:\\Program Files\\Orca\\orca.cmd',
|
||||
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
|
||||
)
|
||||
const bridge = _internals.buildWslBridgeScript()
|
||||
|
||||
expect(launcher).toContain('powershell.exe -NoProfile -ExecutionPolicy Bypass -File')
|
||||
expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"')
|
||||
expect(launcher).not.toContain('-Command')
|
||||
expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]')
|
||||
expect(bridge).toContain('& $OrcaLauncher @ForwardArgs')
|
||||
expect(bridge).toContain('catch')
|
||||
expect(bridge).toContain('exit 1')
|
||||
})
|
||||
|
||||
it('refuses to remove an old managed launcher when the bridge path is user-owned', async () => {
|
||||
const oldLauncher = _internals.buildWslLauncher(
|
||||
'C:\\Old\\orca.cmd',
|
||||
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
|
||||
)
|
||||
const wsl = createWslRunner(oldLauncher)
|
||||
const installer = new WslCliInstaller({
|
||||
platform: 'win32',
|
||||
distro: 'Ubuntu',
|
||||
hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') },
|
||||
wslRunner: async (distro, command) => {
|
||||
if (command.includes('cat /home/alice/.local/share/orca/orca-wsl-bridge.ps1')) {
|
||||
return 'user bridge'
|
||||
}
|
||||
if (command.includes('rm -f')) {
|
||||
throw new Error('__ORCA_CONFLICT__')
|
||||
}
|
||||
return wsl.runner(distro, command)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(installer.remove()).rejects.toThrow('__ORCA_CONFLICT__')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
/* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine;
|
||||
splitting the installer would separate conflict checks from the operations they guard. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { getDefaultWslDistro } from '../wsl'
|
||||
import { CliInstaller } from './cli-installer'
|
||||
import {
|
||||
buildSafeRemoveCommand,
|
||||
buildSafeReplaceGuard,
|
||||
buildWslBridgeScript,
|
||||
buildWslLauncher,
|
||||
getBridgePathFromCommandPath,
|
||||
getPosixDirname,
|
||||
getWslBridgeMarker,
|
||||
getWslLauncherMarker,
|
||||
parseManagedLauncherTarget,
|
||||
quoteShell
|
||||
} from './wsl-cli-scripts'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const MANAGED_MARKER = getWslLauncherMarker()
|
||||
const BRIDGE_MANAGED_MARKER = getWslBridgeMarker()
|
||||
|
||||
type WslCliInstallerOptions = {
|
||||
platform?: NodeJS.Platform
|
||||
distro?: string | null
|
||||
hostInstaller?: Pick<CliInstaller, 'getStatus'>
|
||||
wslRunner?: (distro: string, command: string) => Promise<string>
|
||||
}
|
||||
|
||||
export class WslCliInstaller {
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly distro: string | null
|
||||
private readonly hostInstaller: Pick<CliInstaller, 'getStatus'>
|
||||
private readonly wslRunner: (distro: string, command: string) => Promise<string>
|
||||
|
||||
constructor(options: WslCliInstallerOptions = {}) {
|
||||
this.platform = options.platform ?? process.platform
|
||||
this.distro = options.distro === undefined ? getDefaultWslDistro() : options.distro
|
||||
this.hostInstaller = options.hostInstaller ?? new CliInstaller()
|
||||
this.wslRunner = options.wslRunner ?? runWslCommand
|
||||
}
|
||||
|
||||
async getStatus(): Promise<CliInstallStatus> {
|
||||
const ready = await this.resolveReadyState()
|
||||
if ('status' in ready) {
|
||||
return ready.status
|
||||
}
|
||||
|
||||
const content = await this.readCommandFile(ready.distro, ready.commandPath)
|
||||
if (content === null) {
|
||||
return this.buildStatus({
|
||||
distro: ready.distro,
|
||||
commandPath: ready.commandPath,
|
||||
launcherPath: ready.launcherPath,
|
||||
state: 'not_installed',
|
||||
currentTarget: null,
|
||||
pathConfigured: ready.pathConfigured,
|
||||
detail: `Register ${ready.commandPath} to use Orca from WSL.`
|
||||
})
|
||||
}
|
||||
|
||||
if (content === 'not_file') {
|
||||
return this.buildStatus({
|
||||
distro: ready.distro,
|
||||
commandPath: ready.commandPath,
|
||||
launcherPath: ready.launcherPath,
|
||||
state: 'conflict',
|
||||
currentTarget: null,
|
||||
pathConfigured: ready.pathConfigured,
|
||||
detail: `${ready.commandPath} exists but is not an Orca launcher script.`
|
||||
})
|
||||
}
|
||||
|
||||
const expected = buildWslLauncher(ready.launcherPath, ready.bridgePath)
|
||||
const managed = content.includes(MANAGED_MARKER)
|
||||
const currentTarget = managed ? parseManagedLauncherTarget(content) : null
|
||||
if (content === expected) {
|
||||
const bridgeContent = await this.readCommandFile(ready.distro, ready.bridgePath)
|
||||
const expectedBridge = buildWslBridgeScript()
|
||||
if (bridgeContent === expectedBridge) {
|
||||
return this.buildStatus({
|
||||
distro: ready.distro,
|
||||
commandPath: ready.commandPath,
|
||||
launcherPath: ready.launcherPath,
|
||||
state: 'installed',
|
||||
currentTarget,
|
||||
pathConfigured: ready.pathConfigured,
|
||||
detail: `Registered in ${ready.distro} at ${ready.commandPath}.`
|
||||
})
|
||||
}
|
||||
|
||||
const bridgeManaged =
|
||||
typeof bridgeContent === 'string' && bridgeContent.includes(BRIDGE_MANAGED_MARKER)
|
||||
return this.buildStatus({
|
||||
distro: ready.distro,
|
||||
commandPath: ready.commandPath,
|
||||
launcherPath: ready.launcherPath,
|
||||
state: bridgeContent === null || bridgeManaged ? 'stale' : 'conflict',
|
||||
currentTarget,
|
||||
pathConfigured: ready.pathConfigured,
|
||||
detail:
|
||||
bridgeContent === null || bridgeManaged
|
||||
? `${ready.commandPath} is missing its PowerShell bridge.`
|
||||
: `${ready.bridgePath} exists but is not managed by Orca.`
|
||||
})
|
||||
}
|
||||
|
||||
return this.buildStatus({
|
||||
distro: ready.distro,
|
||||
commandPath: ready.commandPath,
|
||||
launcherPath: ready.launcherPath,
|
||||
state: managed ? 'stale' : 'conflict',
|
||||
currentTarget,
|
||||
pathConfigured: ready.pathConfigured,
|
||||
detail: managed
|
||||
? `${ready.commandPath} points to a different Orca launcher.`
|
||||
: `${ready.commandPath} exists but is not managed by Orca.`
|
||||
})
|
||||
}
|
||||
|
||||
async install(): Promise<CliInstallStatus> {
|
||||
const status = await this.getStatus()
|
||||
if (!status.supported || !status.commandPath || !status.launcherPath) {
|
||||
throw new Error(status.detail ?? 'WSL CLI registration is unavailable.')
|
||||
}
|
||||
if (status.state === 'conflict') {
|
||||
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
|
||||
}
|
||||
|
||||
await this.run(
|
||||
this.distro as string,
|
||||
[
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
|
||||
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
|
||||
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
|
||||
`bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
|
||||
'bridge_tmp="${bridge_path}.tmp.$$"',
|
||||
'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }',
|
||||
'trap cleanup EXIT',
|
||||
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
|
||||
buildSafeReplaceGuard(
|
||||
getBridgePathFromCommandPath(status.commandPath),
|
||||
BRIDGE_MANAGED_MARKER
|
||||
),
|
||||
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
|
||||
buildWslLauncher(status.launcherPath, getBridgePathFromCommandPath(status.commandPath)),
|
||||
'ORCA_WSL_CLI',
|
||||
`cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`,
|
||||
buildWslBridgeScript(),
|
||||
'ORCA_WSL_BRIDGE',
|
||||
'chmod 755 "$command_tmp"',
|
||||
'chmod 644 "$bridge_tmp"',
|
||||
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
|
||||
buildSafeReplaceGuard(
|
||||
getBridgePathFromCommandPath(status.commandPath),
|
||||
BRIDGE_MANAGED_MARKER
|
||||
),
|
||||
`mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
|
||||
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
|
||||
'trap - EXIT'
|
||||
].join('\n')
|
||||
)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async remove(): Promise<CliInstallStatus> {
|
||||
const status = await this.getStatus()
|
||||
if (!status.supported || !status.commandPath) {
|
||||
return status
|
||||
}
|
||||
if (status.state === 'not_installed') {
|
||||
return status
|
||||
}
|
||||
if (status.state === 'conflict') {
|
||||
throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`)
|
||||
}
|
||||
|
||||
await this.run(this.distro as string, buildSafeRemoveCommand(status.commandPath))
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
private async resolveReadyState(): Promise<
|
||||
| { status: CliInstallStatus }
|
||||
| {
|
||||
distro: string
|
||||
commandPath: string
|
||||
bridgePath: string
|
||||
launcherPath: string
|
||||
pathConfigured: boolean
|
||||
}
|
||||
> {
|
||||
if (this.platform !== 'win32') {
|
||||
return {
|
||||
status: this.unsupported(
|
||||
'platform_not_supported',
|
||||
'WSL CLI registration is only available on Windows.'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!this.distro) {
|
||||
return {
|
||||
status: this.unsupported('platform_not_supported', 'No WSL distribution is available.')
|
||||
}
|
||||
}
|
||||
|
||||
const hostStatus = await this.hostInstaller.getStatus()
|
||||
if (!hostStatus.launcherPath) {
|
||||
return {
|
||||
status: this.unsupported(
|
||||
hostStatus.unsupportedReason ?? 'launcher_missing',
|
||||
hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const home = (await this.run(this.distro, 'printf %s "$HOME"')).trim()
|
||||
if (!home.startsWith('/')) {
|
||||
return {
|
||||
status: this.unsupported('launcher_missing', 'Unable to resolve the WSL home directory.')
|
||||
}
|
||||
}
|
||||
|
||||
const interopReady =
|
||||
(
|
||||
await this.run(
|
||||
this.distro,
|
||||
'command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
|
||||
)
|
||||
).trim() === 'yes'
|
||||
if (!interopReady) {
|
||||
return {
|
||||
status: this.unsupported(
|
||||
'launcher_missing',
|
||||
'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pathDirectory = `${home}/.local/bin`
|
||||
const commandPath = `${pathDirectory}/orca`
|
||||
const pathConfigured =
|
||||
(
|
||||
await this.run(
|
||||
this.distro,
|
||||
`case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`
|
||||
)
|
||||
).trim() === 'yes'
|
||||
|
||||
return {
|
||||
distro: this.distro,
|
||||
commandPath,
|
||||
bridgePath: getBridgePathFromCommandPath(commandPath),
|
||||
launcherPath: hostStatus.launcherPath,
|
||||
pathConfigured
|
||||
}
|
||||
}
|
||||
|
||||
private async readCommandFile(
|
||||
distro: string,
|
||||
commandPath: string
|
||||
): Promise<string | 'not_file' | null> {
|
||||
const output = await this.run(
|
||||
distro,
|
||||
[
|
||||
`if [ -L ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_NOT_FILE__',
|
||||
`elif [ ! -e ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_MISSING__',
|
||||
`elif [ ! -f ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_NOT_FILE__',
|
||||
'else',
|
||||
` cat ${quoteShell(commandPath)}`,
|
||||
'fi'
|
||||
].join('\n')
|
||||
)
|
||||
if (output === '__ORCA_MISSING__') {
|
||||
return null
|
||||
}
|
||||
if (output === '__ORCA_NOT_FILE__') {
|
||||
return 'not_file'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private buildStatus(args: {
|
||||
distro: string
|
||||
commandPath: string
|
||||
launcherPath: string
|
||||
state: CliInstallStatus['state']
|
||||
currentTarget: string | null
|
||||
pathConfigured: boolean
|
||||
detail: string
|
||||
}): CliInstallStatus {
|
||||
return {
|
||||
platform: 'linux',
|
||||
commandName: 'orca',
|
||||
commandPath: args.commandPath,
|
||||
pathDirectory: args.commandPath.replace(/\/orca$/, ''),
|
||||
pathConfigured: args.pathConfigured,
|
||||
launcherPath: args.launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: args.state,
|
||||
currentTarget: args.currentTarget,
|
||||
unsupportedReason: null,
|
||||
detail:
|
||||
args.state === 'installed' && !args.pathConfigured
|
||||
? `${args.commandPath} is registered, but ${args.commandPath.replace(/\/orca$/, '')} is not on PATH in ${args.distro}.`
|
||||
: args.detail
|
||||
}
|
||||
}
|
||||
|
||||
private unsupported(
|
||||
unsupportedReason: NonNullable<CliInstallStatus['unsupportedReason']>,
|
||||
detail: string
|
||||
): CliInstallStatus {
|
||||
return {
|
||||
platform: 'linux',
|
||||
commandName: 'orca',
|
||||
commandPath: null,
|
||||
pathDirectory: null,
|
||||
pathConfigured: false,
|
||||
launcherPath: null,
|
||||
installMethod: null,
|
||||
supported: false,
|
||||
state: 'unsupported',
|
||||
currentTarget: null,
|
||||
unsupportedReason,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
private async run(distro: string, command: string): Promise<string> {
|
||||
return this.wslRunner(distro, command)
|
||||
}
|
||||
}
|
||||
|
||||
async function runWslCommand(distro: string, command: string): Promise<string> {
|
||||
const { stdout } = (await execFileAsync('wsl.exe', ['-d', distro, '--', 'bash', '-lc', command], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000
|
||||
})) as { stdout: string }
|
||||
return stdout
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
buildWslBridgeScript,
|
||||
buildWslLauncher
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
const MANAGED_MARKER = '# Orca managed WSL CLI launcher'
|
||||
const BRIDGE_MANAGED_MARKER = '# Orca managed WSL CLI PowerShell bridge'
|
||||
|
||||
export function buildWslLauncher(
|
||||
windowsLauncherPath: string,
|
||||
bridgePath = '${XDG_DATA_HOME:-$HOME/.local/share}/orca/orca-wsl-bridge.ps1'
|
||||
): string {
|
||||
const encodedTarget = Buffer.from(windowsLauncherPath, 'utf8').toString('base64')
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
${MANAGED_MARKER}
|
||||
# ORCA_WIN_LAUNCHER_B64=${encodedTarget}
|
||||
ORCA_WIN_LAUNCHER=${quoteShell(windowsLauncherPath)}
|
||||
ORCA_BRIDGE_PS1=${quoteShell(bridgePath)}
|
||||
ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1")
|
||||
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@"
|
||||
`
|
||||
}
|
||||
|
||||
export function buildWslBridgeScript(): string {
|
||||
return `${BRIDGE_MANAGED_MARKER}
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OrcaLauncher,
|
||||
|
||||
[Parameter(ValueFromRemainingArguments=$true)]
|
||||
[string[]]$ForwardArgs
|
||||
)
|
||||
|
||||
try {
|
||||
& $OrcaLauncher @ForwardArgs
|
||||
if (-not $?) {
|
||||
exit 1
|
||||
}
|
||||
if ($null -eq $LASTEXITCODE) {
|
||||
exit 0
|
||||
}
|
||||
exit $LASTEXITCODE
|
||||
} catch {
|
||||
Write-Error $_
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export function getBridgePathFromCommandPath(commandPath: string): string {
|
||||
return `${commandPath.replace(/\/\.local\/bin\/orca$/, '/.local/share/orca')}/orca-wsl-bridge.ps1`
|
||||
}
|
||||
|
||||
export function buildSafeReplaceGuard(path: string, managedMarker: string): string {
|
||||
const quotedPath = quoteShell(path)
|
||||
const quotedMarker = quoteShell(managedMarker)
|
||||
return [
|
||||
`if [ -L ${quotedPath} ]; then`,
|
||||
' echo "__ORCA_CONFLICT__"',
|
||||
' exit 23',
|
||||
`elif [ -e ${quotedPath} ] && { [ ! -f ${quotedPath} ] || ! grep -Fq ${quotedMarker} ${quotedPath}; }; then`,
|
||||
' echo "__ORCA_CONFLICT__"',
|
||||
' exit 23',
|
||||
'fi'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildSafeRemoveCommand(commandPath: string): string {
|
||||
const bridgePath = getBridgePathFromCommandPath(commandPath)
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
buildSafeReplaceGuard(commandPath, MANAGED_MARKER),
|
||||
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
|
||||
`rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function parseManagedLauncherTarget(content: string): string | null {
|
||||
const encoded = content.match(/^# ORCA_WIN_LAUNCHER_B64=([A-Za-z0-9+/=]+)$/m)?.[1]
|
||||
if (encoded) {
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64').toString('utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const legacyTarget = content.match(/^ORCA_WIN_LAUNCHER='((?:[^']|'"'"')*)'$/m)?.[1]
|
||||
return legacyTarget ? legacyTarget.replaceAll(`'"'"'`, "'") : null
|
||||
}
|
||||
|
||||
export function getPosixDirname(path: string): string {
|
||||
return path.slice(0, path.lastIndexOf('/')) || '/'
|
||||
}
|
||||
|
||||
export function getWslLauncherMarker(): string {
|
||||
return MANAGED_MARKER
|
||||
}
|
||||
|
||||
export function getWslBridgeMarker(): string {
|
||||
return BRIDGE_MANAGED_MARKER
|
||||
}
|
||||
|
||||
export function quoteShell(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`
|
||||
}
|
||||
|
|
@ -3,10 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type * as LocalPtyUtils from '../providers/local-pty-utils'
|
||||
|
||||
const { spawnMock, isPwshAvailableMock } = vi.hoisted(() => ({
|
||||
const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn()
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
|
||||
if (cwd.includes('definitely-missing')) {
|
||||
throw new Error(
|
||||
`Working directory "${cwd}" does not exist. It may have been deleted or is on an unmounted volume.`
|
||||
)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
|
|
@ -17,6 +25,14 @@ vi.mock('../pwsh', () => ({
|
|||
isPwshAvailable: isPwshAvailableMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/local-pty-utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LocalPtyUtils>()
|
||||
return {
|
||||
...actual,
|
||||
validateWorkingDirectory: validateWorkingDirectoryMock
|
||||
}
|
||||
})
|
||||
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
|
||||
const ORCA_SHELL_WRAPPER_ENV = [
|
||||
|
|
@ -57,6 +73,7 @@ describe('createPtySubprocess', () => {
|
|||
beforeEach(() => {
|
||||
spawnMock.mockReset()
|
||||
isPwshAvailableMock.mockReset()
|
||||
validateWorkingDirectoryMock.mockClear()
|
||||
isPwshAvailableMock.mockReturnValue(false)
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-'))
|
||||
|
|
@ -741,6 +758,65 @@ describe('createPtySubprocess', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('launches WSL for WSL worktree cwd even when a stale Windows shell override is present', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
try {
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps daemon WSL split panes in their distro when cwd is already POSIX', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
try {
|
||||
createPtySubprocess({
|
||||
sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo@@deadbeef',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/jin/repo/subdir',
|
||||
shellOverride: 'wsl.exe'
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(validateWorkingDirectoryMock).toHaveBeenCalledWith(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\subdir'
|
||||
)
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo/subdir' && exec bash -l"],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
// Why: node-pty's UnixTerminal.destroy() registers _socket.once('close', () =>
|
||||
// this.kill('SIGHUP')), and the socket 'close' event can fire concurrently
|
||||
// with onExit. If kill is not neutralized by the time close fires, SIGHUP
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
/* eslint-disable max-lines -- Why: daemon PTY spawning centralizes platform launch setup,
|
||||
preflight validation, and lifecycle guards that must stay in one execution path. */
|
||||
import * as pty from 'node-pty'
|
||||
import { statSync } from 'fs'
|
||||
import { win32 as pathWin32 } from 'path'
|
||||
|
|
@ -18,6 +20,8 @@ import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
|
|||
import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershell'
|
||||
import { isPwshAvailable } from '../pwsh'
|
||||
import { removeInheritedNoColor } from '../pty/terminal-color-env'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { getWslContextFromSessionId } from './wsl-session-context'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
|
||||
|
||||
|
|
@ -185,7 +189,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
|||
// setting, relayed by main) takes priority over env.COMSPEC — otherwise
|
||||
// Windows always resolves to cmd.exe (COMSPEC) or PowerShell by fallback,
|
||||
// no matter which shell the user actually picked.
|
||||
let shellPath = opts.shellOverride || resolvePtyShellPath(env)
|
||||
const cwdWslInfo = process.platform === 'win32' ? parseWslPath(opts.cwd ?? '') : null
|
||||
const sessionWslContext =
|
||||
process.platform === 'win32' ? getWslContextFromSessionId(opts.sessionId) : undefined
|
||||
// Why: WSL worktree cwd is the repo's execution environment. Older persisted
|
||||
// tabs can carry a PowerShell/cmd shellOverride; ignore it so reconnects and
|
||||
// daemon-backed terminals enter the WSL distro just like LocalPtyProvider.
|
||||
let shellPath =
|
||||
cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env)
|
||||
let shellArgs: string[]
|
||||
let spawnCwd = opts.cwd || getDefaultCwd()
|
||||
let validationCwd = spawnCwd
|
||||
|
|
@ -217,7 +228,12 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
|||
// Reuse the same shared launch-args helper after resolving the effective
|
||||
// PowerShell executable so daemon-backed terminals preserve parity with the
|
||||
// in-process PTY path.
|
||||
const resolved = resolveWindowsShellLaunchArgs(shellPath, spawnCwd, getDefaultCwd())
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
shellPath,
|
||||
spawnCwd,
|
||||
getDefaultCwd(),
|
||||
sessionWslContext
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
spawnCwd = resolved.effectiveCwd
|
||||
validationCwd = resolved.validationCwd
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
|
||||
export type WslSessionContext = {
|
||||
distro: string
|
||||
}
|
||||
|
||||
export function getWslContextFromSessionId(sessionId: string): WslSessionContext | undefined {
|
||||
const worktreeId = parsePtySessionId(sessionId).worktreeId
|
||||
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
|
||||
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
|
||||
return wslInfo ? { distro: wslInfo.distro } : undefined
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { CliInstaller } from '../cli/cli-installer'
|
||||
import { WslCliInstaller } from '../cli/wsl-cli-installer'
|
||||
|
||||
export function registerCliHandlers(): void {
|
||||
ipcMain.handle('cli:getInstallStatus', async (): Promise<CliInstallStatus> => {
|
||||
|
|
@ -14,4 +15,16 @@ export function registerCliHandlers(): void {
|
|||
ipcMain.handle('cli:remove', async (): Promise<CliInstallStatus> => {
|
||||
return new CliInstaller().remove()
|
||||
})
|
||||
|
||||
ipcMain.handle('cli:getWslInstallStatus', async (): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller().getStatus()
|
||||
})
|
||||
|
||||
ipcMain.handle('cli:installWsl', async (): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller().install()
|
||||
})
|
||||
|
||||
ipcMain.handle('cli:removeWsl', async (): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller().remove()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,20 @@ vi.mock('node-pty', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
parseWslPath: () => null
|
||||
parseWslPath: (path: string) => {
|
||||
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
distro: match[1],
|
||||
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
|
||||
}
|
||||
},
|
||||
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
|
||||
toWindowsWslPath: (path: string, distro: string) =>
|
||||
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`,
|
||||
isWslAvailable: () => true
|
||||
}))
|
||||
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
|
|
@ -56,8 +69,11 @@ describe('LocalPtyProvider', () => {
|
|||
}
|
||||
let exitCb: ((info: { exitCode: number }) => void) | undefined
|
||||
let origShell: string | undefined
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
origShell = process.env.SHELL
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
|
||||
|
|
@ -87,6 +103,9 @@ describe('LocalPtyProvider', () => {
|
|||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (origPlatform) {
|
||||
Object.defineProperty(process, 'platform', origPlatform)
|
||||
}
|
||||
if (origShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
|
|
@ -244,6 +263,31 @@ describe('LocalPtyProvider', () => {
|
|||
expect.objectContaining({ cwd: 'D:\\Users\\orca' })
|
||||
)
|
||||
})
|
||||
|
||||
it('launches POSIX cwd split panes through WSL when worktree context is WSL', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/jin/repo/subdir',
|
||||
shellOverride: 'powershell.exe',
|
||||
worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo/subdir' && exec bash -l"],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('write', () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { resolveProcessCwd } from './process-cwd'
|
|||
import { existsSync } from 'fs'
|
||||
import * as pty from 'node-pty'
|
||||
import { parseWslPath, isWslAvailable } from '../wsl'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import {
|
||||
injectHistoryEnv,
|
||||
updateHistFileForFallback,
|
||||
|
|
@ -89,6 +90,14 @@ function disposePtyListeners(id: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function getWslContextFromWorktreeId(
|
||||
worktreeId: string | undefined
|
||||
): { distro: string } | undefined {
|
||||
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
|
||||
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
|
||||
return wslInfo ? { distro: wslInfo.distro } : undefined
|
||||
}
|
||||
|
||||
function clearPtyState(id: string): void {
|
||||
disposePtyListeners(id)
|
||||
ptyProcesses.delete(id)
|
||||
|
|
@ -163,6 +172,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
|||
const defaultCwd = getDefaultCwd()
|
||||
const cwd = args.cwd || defaultCwd
|
||||
const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null
|
||||
const worktreeWslContext =
|
||||
process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined
|
||||
|
||||
let shellPath: string
|
||||
let shellArgs: string[]
|
||||
|
|
@ -182,11 +193,12 @@ export class LocalPtyProvider implements IPtyProvider {
|
|||
// Why: shellOverride lets a single tab open in a different shell than the
|
||||
// persisted default (e.g. "New WSL terminal" from the "+" submenu) without
|
||||
// changing the user's setting. It takes priority over the setting.
|
||||
const shellFamily =
|
||||
const requestedShellFamily =
|
||||
args.shellOverride ||
|
||||
this.opts.getWindowsShell?.() ||
|
||||
process.env.COMSPEC ||
|
||||
'powershell.exe'
|
||||
const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily
|
||||
const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase()
|
||||
// Why: shell selection can arrive either as a canonical setting value
|
||||
// ('powershell.exe') or as a concrete PowerShell executable path from a
|
||||
|
|
@ -215,7 +227,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
|||
// same shellArgs for the same (shell, cwd) pair. The helper keeps CJK
|
||||
// UTF-8 setup (chcp 65001), PowerShell $PROFILE dot-sourcing, and the
|
||||
// wsl.exe /mnt/<drive> cwd translation in one place.
|
||||
const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd)
|
||||
const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, worktreeWslContext)
|
||||
shellArgs = resolved.shellArgs
|
||||
effectiveCwd = resolved.effectiveCwd
|
||||
validationCwd = resolved.validationCwd
|
||||
|
|
|
|||
|
|
@ -119,6 +119,26 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('keeps POSIX cwd inside the worktree distro when WSL context is provided', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'/home/alice/repo/subdir',
|
||||
'C:\\Users\\alice',
|
||||
{ distro: 'Ubuntu' }
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
"cd '/home/alice/repo/subdir' && exec bash -l"
|
||||
])
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir')
|
||||
})
|
||||
|
||||
it('falls back to empty args + same cwd for unknown shells', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\tools\\fish.exe',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { win32 as pathWin32 } from 'path'
|
||||
import { parseWslPath, toLinuxPath } from '../wsl'
|
||||
import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl'
|
||||
import {
|
||||
encodePowerShellCommand,
|
||||
getPowerShellOsc133Bootstrap
|
||||
|
|
@ -25,6 +25,16 @@ export type WindowsShellLaunchArgs = {
|
|||
validationCwd: string
|
||||
}
|
||||
|
||||
export type WindowsShellWslContext = {
|
||||
distro: string
|
||||
}
|
||||
|
||||
function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
|
||||
const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''")
|
||||
const shellArgs = ['--', 'bash', '-c', `cd '${escapedLinuxCwd}' && exec bash -l`]
|
||||
return distro ? ['-d', distro, ...shellArgs] : shellArgs
|
||||
}
|
||||
|
||||
/** Build the argv + effective cwd for a Windows shell launch.
|
||||
*
|
||||
* - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly.
|
||||
|
|
@ -37,7 +47,8 @@ export type WindowsShellLaunchArgs = {
|
|||
export function resolveWindowsShellLaunchArgs(
|
||||
shellPath: string,
|
||||
cwd: string,
|
||||
defaultCwd: string
|
||||
defaultCwd: string,
|
||||
wslContext?: WindowsShellWslContext
|
||||
): WindowsShellLaunchArgs {
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
|
||||
|
|
@ -67,25 +78,23 @@ export function resolveWindowsShellLaunchArgs(
|
|||
if (shellBasename === 'wsl.exe') {
|
||||
const wslInfo = parseWslPath(cwd)
|
||||
if (wslInfo) {
|
||||
const escapedLinuxCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
|
||||
return {
|
||||
shellArgs: [
|
||||
'-d',
|
||||
wslInfo.distro,
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
`cd '${escapedLinuxCwd}' && exec bash -l`
|
||||
],
|
||||
shellArgs: buildWslShellArgs(wslInfo.linuxPath, wslInfo.distro),
|
||||
effectiveCwd: defaultCwd,
|
||||
validationCwd: cwd
|
||||
}
|
||||
}
|
||||
if (wslContext && cwd.startsWith('/')) {
|
||||
return {
|
||||
shellArgs: buildWslShellArgs(cwd, wslContext.distro),
|
||||
effectiveCwd: defaultCwd,
|
||||
validationCwd: toWindowsWslPath(cwd, wslContext.distro)
|
||||
}
|
||||
}
|
||||
const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/)
|
||||
const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c'
|
||||
const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''")
|
||||
return {
|
||||
shellArgs: ['--', 'bash', '-c', `cd '${escapedLinuxCwd}' && exec bash -l`],
|
||||
shellArgs: buildWslShellArgs(linuxCwd),
|
||||
effectiveCwd: defaultCwd,
|
||||
validationCwd: cwd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { execFileSync } from 'child_process'
|
||||
import { parseWslUncPath } from '../shared/wsl-paths'
|
||||
|
||||
export type WslPathInfo = {
|
||||
distro: string
|
||||
|
|
@ -19,19 +20,7 @@ export function parseWslPath(windowsPath: string): WslPathInfo | null {
|
|||
return null
|
||||
}
|
||||
|
||||
// Normalize backslashes to forward slashes for uniform matching
|
||||
const normalized = windowsPath.replace(/\\/g, '/')
|
||||
|
||||
// Match //wsl.localhost/Distro/... or //wsl$/Distro/...
|
||||
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
distro: match[2],
|
||||
linuxPath: match[3] || '/'
|
||||
}
|
||||
return parseWslUncPath(windowsPath)
|
||||
}
|
||||
|
||||
export function isWslPath(path: string): boolean {
|
||||
|
|
|
|||
|
|
@ -1209,6 +1209,9 @@ export type PreloadApi = {
|
|||
getInstallStatus: () => Promise<CliInstallStatus>
|
||||
install: () => Promise<CliInstallStatus>
|
||||
remove: () => Promise<CliInstallStatus>
|
||||
getWslInstallStatus: () => Promise<CliInstallStatus>
|
||||
installWsl: () => Promise<CliInstallStatus>
|
||||
removeWsl: () => Promise<CliInstallStatus>
|
||||
}
|
||||
agentHooks: {
|
||||
claudeStatus: () => Promise<AgentHookInstallStatus>
|
||||
|
|
|
|||
|
|
@ -1245,7 +1245,11 @@ const api = {
|
|||
cli: {
|
||||
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
|
||||
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),
|
||||
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove')
|
||||
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove'),
|
||||
getWslInstallStatus: (): Promise<CliInstallStatus> =>
|
||||
ipcRenderer.invoke('cli:getWslInstallStatus'),
|
||||
installWsl: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:installWsl'),
|
||||
removeWsl: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:removeWsl')
|
||||
},
|
||||
|
||||
agentHooks: {
|
||||
|
|
|
|||
|
|
@ -176,14 +176,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
|
||||
: ''
|
||||
|
||||
const [wslAvailable, setWslAvailable] = useState(false)
|
||||
useEffect(() => {
|
||||
// Why: wsl:isAvailable is synchronous on the main-process side but we
|
||||
// call it asynchronously so the renderer doesn't block on startup. The
|
||||
// result only gates UI options, so a brief false→true transition is fine.
|
||||
void window.api.wsl.isAvailable().then(setWslAvailable)
|
||||
}, [])
|
||||
|
||||
// Save confirmation dialog state
|
||||
const [saveDialogFileId, setSaveDialogFileId] = useState<string | null>(null)
|
||||
const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null
|
||||
|
|
@ -1338,7 +1330,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
onNewTerminalWithShell={handleNewTab}
|
||||
onNewBrowserTab={handleNewBrowserTab}
|
||||
onNewFileTab={handleNewFile}
|
||||
wslAvailable={wslAvailable}
|
||||
onSetCustomTitle={setTabCustomTitle}
|
||||
onSetTabColor={setTabColor}
|
||||
expandedPaneByTabId={expandedPaneByTabId}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '../ui/dialog'
|
||||
import { Label } from '../ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { WslCliRegistration } from './WslCliRegistration'
|
||||
|
||||
type CliSectionProps = {
|
||||
currentPlatform: string
|
||||
|
|
@ -246,6 +247,8 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<WslCliRegistration currentPlatform={currentPlatform} />
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldCommitOpenInApplicationsDraft } from './GeneralPane'
|
||||
import { getDesktopPlatformFromUserAgent, shouldCommitOpenInApplicationsDraft } from './GeneralPane'
|
||||
|
||||
describe('GeneralPane open-in application drafts', () => {
|
||||
it('does not commit rows until both label and command are present', () => {
|
||||
|
|
@ -30,3 +30,17 @@ describe('GeneralPane open-in application drafts', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GeneralPane desktop platform detection', () => {
|
||||
it('keeps Windows available for Windows-only CLI settings', () => {
|
||||
expect(
|
||||
getDesktopPlatformFromUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
)
|
||||
).toBe('win32')
|
||||
expect(getDesktopPlatformFromUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe(
|
||||
'darwin'
|
||||
)
|
||||
expect(getDesktopPlatformFromUserAgent('Mozilla/5.0 (X11; Linux x86_64)')).toBe('other')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ export function shouldCommitOpenInApplicationsDraft(applications: OpenInApplicat
|
|||
})
|
||||
}
|
||||
|
||||
export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | 'win32' | 'other' {
|
||||
if (userAgent.includes('Mac')) {
|
||||
return 'darwin'
|
||||
}
|
||||
if (userAgent.includes('Windows')) {
|
||||
return 'win32'
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export { GENERAL_PANE_SEARCH_ENTRIES }
|
||||
|
||||
type GeneralPaneProps = {
|
||||
|
|
@ -652,7 +662,7 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
|||
matchesSettingsSearch(searchQuery, GENERAL_CLI_SEARCH_ENTRIES) ? (
|
||||
<CliSection
|
||||
key="cli"
|
||||
currentPlatform={navigator.userAgent.includes('Mac') ? 'darwin' : 'other'}
|
||||
currentPlatform={getDesktopPlatformFromUserAgent(navigator.userAgent)}
|
||||
/>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, GENERAL_CACHE_TIMER_SEARCH_ENTRIES) ? (
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import { SettingsSidebar } from './SettingsSidebar'
|
|||
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
|
||||
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
|
||||
import {
|
||||
deriveNeededRepoIds,
|
||||
deriveNeededSectionIds,
|
||||
|
|
@ -244,8 +245,6 @@ function Settings(): React.JSX.Element {
|
|||
// the import trigger as a headerAction. The modal itself still lives inside
|
||||
// TerminalPane, driven by this shared state.
|
||||
const ghostty = useGhosttyImport(updateSettings, settings)
|
||||
const [wslAvailable, setWslAvailable] = useState(false)
|
||||
const [pwshAvailable, setPwshAvailable] = useState(false)
|
||||
const [fontSuggestions, setFontSuggestions] = useState<string[]>(
|
||||
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
|
||||
)
|
||||
|
|
@ -265,7 +264,6 @@ function Settings(): React.JSX.Element {
|
|||
const contentScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const terminalFontsLoadedRef = useRef(false)
|
||||
const terminalCapabilitiesLoadedRef = useRef(false)
|
||||
const pendingNavSectionRef = useRef<string | null>(null)
|
||||
const pendingScrollTargetRef = useRef<string | null>(null)
|
||||
const repoHooksRequestSeqRef = useRef(0)
|
||||
|
|
@ -678,6 +676,9 @@ function Settings(): React.JSX.Element {
|
|||
}),
|
||||
[activeSectionId, mountedSectionIds, navSections, settingsSearchQuery, visibleSectionIds]
|
||||
)
|
||||
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
|
||||
isWindows && neededSectionIds.has('terminal')
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setMountedSectionIds((previous) => {
|
||||
|
|
@ -722,34 +723,6 @@ function Settings(): React.JSX.Element {
|
|||
}
|
||||
}, [neededSectionIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWindows) {
|
||||
setWslAvailable(false)
|
||||
setPwshAvailable(false)
|
||||
terminalCapabilitiesLoadedRef.current = true
|
||||
return
|
||||
}
|
||||
if (!neededSectionIds.has('terminal') || terminalCapabilitiesLoadedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
let stale = false
|
||||
terminalCapabilitiesLoadedRef.current = true
|
||||
void window.api.wsl.isAvailable().then((available) => {
|
||||
if (!stale) {
|
||||
setWslAvailable(available)
|
||||
}
|
||||
})
|
||||
void window.api.pwsh.isAvailable().then((available) => {
|
||||
if (!stale) {
|
||||
setPwshAvailable(available)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [isWindows, neededSectionIds])
|
||||
|
||||
const neededRepoIds = useMemo(
|
||||
() => deriveNeededRepoIds(repos, neededSectionIds),
|
||||
[neededSectionIds, repos]
|
||||
|
|
@ -1088,8 +1061,8 @@ function Settings(): React.JSX.Element {
|
|||
scrollbackMode={scrollbackMode}
|
||||
setScrollbackMode={setScrollbackMode}
|
||||
ghostty={ghostty}
|
||||
wslAvailable={wslAvailable}
|
||||
pwshAvailable={pwshAvailable}
|
||||
wslAvailable={windowsTerminalCapabilities.wslAvailable}
|
||||
pwshAvailable={windowsTerminalCapabilities.pwshAvailable}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
|
|
|||
|
|
@ -211,4 +211,46 @@ describe('TerminalPane PowerShell version setting', () => {
|
|||
expect(link).not.toBeNull()
|
||||
expect(link?.props.href).toBe('https://github.com/PowerShell/PowerShell/releases/latest')
|
||||
})
|
||||
|
||||
it('shows WSL as a Windows default shell option when available', () => {
|
||||
const element = TerminalPane({
|
||||
settings: {
|
||||
terminalScrollbackBytes: 10_000_000,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'auto',
|
||||
terminalWordSeparator: ''
|
||||
} as never,
|
||||
updateSettings: () => {},
|
||||
systemPrefersDark: true,
|
||||
terminalFontSuggestions: [],
|
||||
scrollbackMode: 'preset',
|
||||
setScrollbackMode: () => {},
|
||||
ghostty: ghosttyMock,
|
||||
wslAvailable: true,
|
||||
pwshAvailable: false
|
||||
})
|
||||
|
||||
expect(collectText(element)).toContain('WSL')
|
||||
})
|
||||
|
||||
it('hides WSL as a Windows default shell option when unavailable', () => {
|
||||
const element = TerminalPane({
|
||||
settings: {
|
||||
terminalScrollbackBytes: 10_000_000,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'auto',
|
||||
terminalWordSeparator: ''
|
||||
} as never,
|
||||
updateSettings: () => {},
|
||||
systemPrefersDark: true,
|
||||
terminalFontSuggestions: [],
|
||||
scrollbackMode: 'preset',
|
||||
setScrollbackMode: () => {},
|
||||
ghostty: ghosttyMock,
|
||||
wslAvailable: false,
|
||||
pwshAvailable: false
|
||||
})
|
||||
|
||||
expect(collectText(element)).not.toContain('WSL')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { Label } from '../ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
|
||||
type WslCliRegistrationProps = {
|
||||
currentPlatform: string
|
||||
}
|
||||
|
||||
export function WslCliRegistration({
|
||||
currentPlatform
|
||||
}: WslCliRegistrationProps): React.JSX.Element | null {
|
||||
const [status, setStatus] = useState<CliInstallStatus | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null)
|
||||
const { wslAvailable } = useWindowsTerminalCapabilities(currentPlatform === 'win32')
|
||||
const showWslCli = currentPlatform === 'win32' && wslAvailable
|
||||
|
||||
const refreshStatus = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setStatus(await window.api.cli.getWslInstallStatus())
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to load WSL CLI status.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showWslCli) {
|
||||
void refreshStatus()
|
||||
}
|
||||
}, [showWslCli])
|
||||
|
||||
if (!showWslCli) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isEnabled = status?.state === 'installed'
|
||||
const isSupported = status?.supported ?? false
|
||||
|
||||
const handleInstall = async (): Promise<void> => {
|
||||
setBusyAction('install')
|
||||
try {
|
||||
const next = await window.api.cli.installWsl()
|
||||
setStatus(next)
|
||||
setDialogOpen(false)
|
||||
toast.success('Registered `orca` in WSL.')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to register `orca` in WSL.')
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (): Promise<void> => {
|
||||
setBusyAction('remove')
|
||||
try {
|
||||
const next = await window.api.cli.removeWsl()
|
||||
setStatus(next)
|
||||
setDialogOpen(false)
|
||||
toast.success('Removed `orca` from WSL.')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to remove `orca` from WSL.')
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>WSL shell command</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading
|
||||
? 'Checking WSL CLI registration...'
|
||||
: (status?.detail ?? 'Register `orca` in ~/.local/bin inside WSL.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={loading || busyAction !== null}
|
||||
aria-label="Refresh WSL CLI status"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Refresh
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
disabled={loading || !isSupported || busyAction !== null}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
|
||||
isEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
} ${loading || !isSupported || busyAction !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
isEnabled ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.commandPath ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command path:{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status?.state === 'stale' && status.currentTarget ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Existing launcher target: <code>{status.currentTarget}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEnabled ? 'Remove `orca` from WSL?' : 'Register `orca` in WSL?'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEnabled
|
||||
? 'This removes the WSL shell command. Orca itself remains installed on Windows.'
|
||||
: `Orca will register ${status?.commandPath ?? '`orca`'} so the command works from WSL terminals.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{status?.commandPath ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Target path:{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={busyAction !== null}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void (isEnabled ? handleRemove() : handleInstall())}
|
||||
disabled={busyAction !== null || !isSupported}
|
||||
>
|
||||
{busyAction === 'remove'
|
||||
? 'Removing...'
|
||||
: busyAction === 'install'
|
||||
? 'Registering...'
|
||||
: isEnabled
|
||||
? 'Remove'
|
||||
: 'Register'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -181,7 +181,6 @@ async function renderTabBar(props: Record<string, unknown>): Promise<unknown> {
|
|||
onSetCustomTitle: () => {},
|
||||
onSetTabColor: () => {},
|
||||
onTogglePaneExpand: () => {},
|
||||
wslAvailable: false,
|
||||
...props
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
|
|||
import { ShellIcon } from './shell-icons'
|
||||
import { resolveWindowsShellLaunchTarget } from './windows-shell-launch'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -58,9 +59,6 @@ type TabBarProps = {
|
|||
terminalOnly?: boolean
|
||||
showAgentLaunchItems?: boolean
|
||||
onNewFileTab?: () => void
|
||||
/** Whether WSL is installed on this Windows machine. When true, the "+"
|
||||
* dropdown shows a WSL option under the terminal submenu. */
|
||||
wslAvailable?: boolean
|
||||
onSetCustomTitle: (tabId: string, title: string | null) => void
|
||||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
onTogglePaneExpand: (tabId: string) => void
|
||||
|
|
@ -142,8 +140,7 @@ function TabBarInner({
|
|||
onPinFile,
|
||||
tabBarOrder,
|
||||
onCreateSplitGroup,
|
||||
hoveredTabInsertion,
|
||||
wslAvailable
|
||||
hoveredTabInsertion
|
||||
}: TabBarProps): React.JSX.Element {
|
||||
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
|
||||
const defaultWindowsShell = useAppStore(
|
||||
|
|
@ -152,15 +149,7 @@ function TabBarInner({
|
|||
const defaultWindowsPowerShellImplementation = useAppStore(
|
||||
(s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto'
|
||||
)
|
||||
const [pwshAvailable, setPwshAvailable] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!isWindows) {
|
||||
setPwshAvailable(false)
|
||||
return
|
||||
}
|
||||
|
||||
void window.api.pwsh.isAvailable().then(setPwshAvailable)
|
||||
}, [])
|
||||
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(isWindows)
|
||||
const resolvedGroupId = groupId ?? worktreeId
|
||||
|
||||
const statusByRelativePath = useMemo(
|
||||
|
|
@ -497,7 +486,9 @@ function TabBarInner({
|
|||
}[] = [
|
||||
{ label: 'PowerShell', shell: 'powershell.exe' },
|
||||
{ label: 'CMD Prompt', shell: 'cmd.exe' },
|
||||
...(wslAvailable ? ([{ label: 'WSL', shell: 'wsl.exe' }] as const) : [])
|
||||
...(windowsTerminalCapabilities.wslAvailable
|
||||
? ([{ label: 'WSL', shell: 'wsl.exe' }] as const)
|
||||
: [])
|
||||
]
|
||||
const defaultEntry =
|
||||
allShells.find((s) => s.shell === defaultWindowsShell) ?? allShells[0]
|
||||
|
|
@ -520,7 +511,7 @@ function TabBarInner({
|
|||
resolveWindowsShellLaunchTarget(
|
||||
entry.shell,
|
||||
defaultWindowsPowerShellImplementation,
|
||||
pwshAvailable
|
||||
windowsTerminalCapabilities.pwshAvailable
|
||||
)
|
||||
)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ vi.mock('react', async () => {
|
|||
useLayoutEffect: () => {},
|
||||
useMemo: <T>(factory: () => T) => factory(),
|
||||
useRef: <T>(current: T) => ({ current }),
|
||||
useState: <T>(initial: T) => [initial, vi.fn()] as const
|
||||
useState: <T>(initial: T | (() => T)) =>
|
||||
[typeof initial === 'function' ? (initial as () => T)() : initial, vi.fn()] as const
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -205,6 +206,15 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
})
|
||||
|
||||
it('passes pwsh.exe when the PowerShell menu item uses the PowerShell 7+ implementation', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: { isAvailable: vi.fn().mockResolvedValue(false) },
|
||||
pwsh: { isAvailable: vi.fn().mockResolvedValue(true) }
|
||||
}
|
||||
})
|
||||
const capabilities = await import('@/lib/windows-terminal-capabilities')
|
||||
await capabilities.loadWindowsTerminalCapabilities()
|
||||
|
||||
const tabBarModule = await import('./TabBar')
|
||||
const candidate = tabBarModule.default ?? tabBarModule
|
||||
const TabBar =
|
||||
|
|
@ -230,8 +240,7 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
onNewBrowserTab: () => {},
|
||||
onSetCustomTitle: () => {},
|
||||
onSetTabColor: () => {},
|
||||
onTogglePaneExpand: () => {},
|
||||
wslAvailable: false
|
||||
onTogglePaneExpand: () => {}
|
||||
})
|
||||
|
||||
const item = findDropdownMenuItemByText(expandNode(element), 'New Terminal: PowerShell')
|
||||
|
|
@ -242,4 +251,44 @@ describe('TabBar PowerShell launch wiring', () => {
|
|||
|
||||
expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe')
|
||||
})
|
||||
|
||||
it('shows the WSL terminal row when shared Windows capabilities report WSL', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: { isAvailable: vi.fn().mockResolvedValue(true) },
|
||||
pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }
|
||||
}
|
||||
})
|
||||
const capabilities = await import('@/lib/windows-terminal-capabilities')
|
||||
await capabilities.loadWindowsTerminalCapabilities()
|
||||
|
||||
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 element = TabBar!({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
worktreeId: 'wt-1',
|
||||
expandedPaneByTabId: {},
|
||||
onActivate: () => {},
|
||||
onClose: () => {},
|
||||
onCloseOthers: () => {},
|
||||
onCloseToRight: () => {},
|
||||
onNewTerminalTab: () => {},
|
||||
onNewTerminalWithShell: () => {},
|
||||
onNewBrowserTab: () => {},
|
||||
onSetCustomTitle: () => {},
|
||||
onSetTabColor: () => {},
|
||||
onTogglePaneExpand: () => {}
|
||||
})
|
||||
|
||||
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { lazy, Suspense, useMemo } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
|
|
@ -50,11 +50,6 @@ export default function TabGroupPanel({
|
|||
const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen)
|
||||
const sidebarOpen = useAppStore((state) => state.sidebarOpen)
|
||||
|
||||
const [wslAvailable, setWslAvailable] = useState(false)
|
||||
useEffect(() => {
|
||||
void window.api.wsl.isAvailable().then(setWslAvailable)
|
||||
}, [])
|
||||
|
||||
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
|
||||
const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model
|
||||
const { setNodeRef: setBodyDropRef } = useDroppable({
|
||||
|
|
@ -116,7 +111,6 @@ export default function TabGroupPanel({
|
|||
}}
|
||||
onNewTerminalTab={commands.newTerminalTab}
|
||||
onNewTerminalWithShell={commands.newTerminalWithShell}
|
||||
wslAvailable={wslAvailable}
|
||||
onNewBrowserTab={commands.newBrowserTab}
|
||||
onNewFileTab={commands.newFileTab}
|
||||
onSetCustomTitle={commands.setTabCustomTitle}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ type TerminalShellProps = {
|
|||
onNewTerminalWithShell?: (shell: string) => void
|
||||
onNewBrowserTab: () => void
|
||||
onNewFileTab?: () => void
|
||||
wslAvailable?: boolean
|
||||
onSetCustomTitle: (tabId: string, title: string | null) => void
|
||||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
onTogglePaneExpand: (tabId: string) => void
|
||||
|
|
@ -70,7 +69,6 @@ export function TerminalShell({
|
|||
onNewTerminalWithShell,
|
||||
onNewBrowserTab,
|
||||
onNewFileTab,
|
||||
wslAvailable,
|
||||
onSetCustomTitle,
|
||||
onSetTabColor,
|
||||
onTogglePaneExpand,
|
||||
|
|
@ -110,7 +108,6 @@ export function TerminalShell({
|
|||
onNewTerminalWithShell={onNewTerminalWithShell}
|
||||
onNewBrowserTab={onNewBrowserTab}
|
||||
onNewFileTab={onNewFileTab}
|
||||
wslAvailable={wslAvailable}
|
||||
onSetCustomTitle={onSetCustomTitle}
|
||||
onSetTabColor={onSetTabColor}
|
||||
expandedPaneByTabId={expandedPaneByTabId}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { AppState } from '@/store/types'
|
||||
import { parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
|
||||
export type LocalPreflightContext = { wslDistro?: string | null } | undefined
|
||||
|
||||
export function getWslDistroFromPath(path?: string | null): string | null {
|
||||
return path ? (parseWslUncPath(path)?.distro ?? null) : null
|
||||
}
|
||||
|
||||
export function getLocalPreflightContext(state: AppState): LocalPreflightContext {
|
||||
const activeWorktree = state.activeWorktreeId
|
||||
? Object.values(state.worktreesByRepo ?? {})
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === state.activeWorktreeId)
|
||||
: null
|
||||
const activePath =
|
||||
activeWorktree?.path ?? (state.repos ?? []).find((repo) => repo.id === state.activeRepoId)?.path
|
||||
const wslDistro = getWslDistroFromPath(activePath)
|
||||
return wslDistro ? { wslDistro } : undefined
|
||||
}
|
||||
|
||||
export function localPreflightContextKey(context: LocalPreflightContext): string {
|
||||
return context?.wslDistro ? `wsl:${context.wslDistro}` : 'host'
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getCachedWindowsTerminalCapabilities,
|
||||
loadWindowsTerminalCapabilities,
|
||||
refreshWindowsTerminalCapabilities,
|
||||
resetWindowsTerminalCapabilitiesForTests
|
||||
} from './windows-terminal-capabilities'
|
||||
|
||||
function stubTerminalCapabilityApi(args: { wslAvailable: boolean; pwshAvailable: boolean }): {
|
||||
wslIsAvailable: ReturnType<typeof vi.fn>
|
||||
pwshIsAvailable: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const wslIsAvailable = vi.fn().mockResolvedValue(args.wslAvailable)
|
||||
const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable)
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: { isAvailable: wslIsAvailable },
|
||||
pwsh: { isAvailable: pwshIsAvailable }
|
||||
}
|
||||
})
|
||||
|
||||
return { wslIsAvailable, pwshIsAvailable }
|
||||
}
|
||||
|
||||
describe('windows terminal capabilities', () => {
|
||||
afterEach(() => {
|
||||
resetWindowsTerminalCapabilitiesForTests()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('shares WSL and PowerShell availability between terminal UI consumers', async () => {
|
||||
const { wslIsAvailable, pwshIsAvailable } = stubTerminalCapabilityApi({
|
||||
wslAvailable: true,
|
||||
pwshAvailable: true
|
||||
})
|
||||
|
||||
expect(getCachedWindowsTerminalCapabilities()).toEqual({
|
||||
wslAvailable: false,
|
||||
pwshAvailable: false
|
||||
})
|
||||
|
||||
await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
pwshAvailable: true
|
||||
})
|
||||
expect(getCachedWindowsTerminalCapabilities()).toEqual({
|
||||
wslAvailable: true,
|
||||
pwshAvailable: true
|
||||
})
|
||||
|
||||
await loadWindowsTerminalCapabilities()
|
||||
expect(wslIsAvailable).toHaveBeenCalledTimes(1)
|
||||
expect(pwshIsAvailable).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps WSL available when the PowerShell version probe fails', async () => {
|
||||
const wslIsAvailable = vi.fn().mockResolvedValue(true)
|
||||
const pwshIsAvailable = vi.fn().mockRejectedValue(new Error('pwsh probe failed'))
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: { isAvailable: wslIsAvailable },
|
||||
pwsh: { isAvailable: pwshIsAvailable }
|
||||
}
|
||||
})
|
||||
|
||||
await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({
|
||||
wslAvailable: true,
|
||||
pwshAvailable: false
|
||||
})
|
||||
})
|
||||
|
||||
it('can refresh cached capabilities when WSL availability changes', async () => {
|
||||
const wslIsAvailable = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
||||
const pwshIsAvailable = vi.fn().mockResolvedValue(false)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: { isAvailable: wslIsAvailable },
|
||||
pwsh: { isAvailable: pwshIsAvailable }
|
||||
}
|
||||
})
|
||||
|
||||
await expect(loadWindowsTerminalCapabilities()).resolves.toMatchObject({
|
||||
wslAvailable: false
|
||||
})
|
||||
await expect(loadWindowsTerminalCapabilities()).resolves.toMatchObject({
|
||||
wslAvailable: false
|
||||
})
|
||||
await expect(refreshWindowsTerminalCapabilities()).resolves.toMatchObject({
|
||||
wslAvailable: true
|
||||
})
|
||||
|
||||
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('re-probes when the capability cache expires', async () => {
|
||||
const wslIsAvailable = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||||
const pwshIsAvailable = vi.fn().mockResolvedValue(false)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
wsl: { isAvailable: wslIsAvailable },
|
||||
pwsh: { isAvailable: pwshIsAvailable }
|
||||
}
|
||||
})
|
||||
|
||||
await expect(loadWindowsTerminalCapabilities({ now: 1_000 })).resolves.toMatchObject({
|
||||
wslAvailable: true
|
||||
})
|
||||
await expect(loadWindowsTerminalCapabilities({ now: 20_000 })).resolves.toMatchObject({
|
||||
wslAvailable: true
|
||||
})
|
||||
await expect(loadWindowsTerminalCapabilities({ now: 32_000 })).resolves.toMatchObject({
|
||||
wslAvailable: false
|
||||
})
|
||||
|
||||
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
export type WindowsTerminalCapabilities = {
|
||||
wslAvailable: boolean
|
||||
pwshAvailable: boolean
|
||||
}
|
||||
|
||||
const UNAVAILABLE_CAPABILITIES: WindowsTerminalCapabilities = {
|
||||
wslAvailable: false,
|
||||
pwshAvailable: false
|
||||
}
|
||||
|
||||
const CAPABILITY_CACHE_TTL_MS = 30_000
|
||||
let cachedCapabilities: WindowsTerminalCapabilities | null = null
|
||||
let cachedCapabilitiesLoadedAt = 0
|
||||
let pendingCapabilities: Promise<WindowsTerminalCapabilities> | null = null
|
||||
let latestCapabilityRequestId = 0
|
||||
const subscribers = new Set<(capabilities: WindowsTerminalCapabilities) => void>()
|
||||
|
||||
function publish(capabilities: WindowsTerminalCapabilities, loadedAt = Date.now()): void {
|
||||
cachedCapabilities = capabilities
|
||||
cachedCapabilitiesLoadedAt = loadedAt
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedWindowsTerminalCapabilities(): WindowsTerminalCapabilities {
|
||||
return cachedCapabilities ?? UNAVAILABLE_CAPABILITIES
|
||||
}
|
||||
|
||||
export function loadWindowsTerminalCapabilities(
|
||||
options: {
|
||||
force?: boolean
|
||||
now?: number
|
||||
} = {}
|
||||
): Promise<WindowsTerminalCapabilities> {
|
||||
const now = options.now ?? Date.now()
|
||||
if (
|
||||
cachedCapabilities &&
|
||||
!options.force &&
|
||||
now - cachedCapabilitiesLoadedAt < CAPABILITY_CACHE_TTL_MS
|
||||
) {
|
||||
return Promise.resolve(cachedCapabilities)
|
||||
}
|
||||
if (pendingCapabilities && !options.force) {
|
||||
return pendingCapabilities
|
||||
}
|
||||
|
||||
// Why: Settings and the tab bar need one shared answer. Separate probes can
|
||||
// leave Settings rendering without WSL while the "+" menu already shows it.
|
||||
const requestId = ++latestCapabilityRequestId
|
||||
pendingCapabilities = Promise.all([
|
||||
window.api.wsl.isAvailable().catch(() => false),
|
||||
window.api.pwsh.isAvailable().catch(() => false)
|
||||
])
|
||||
.then(([wslAvailable, pwshAvailable]) => {
|
||||
const capabilities = { wslAvailable, pwshAvailable }
|
||||
if (requestId === latestCapabilityRequestId) {
|
||||
pendingCapabilities = null
|
||||
publish(capabilities, now)
|
||||
return capabilities
|
||||
}
|
||||
return getCachedWindowsTerminalCapabilities()
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId === latestCapabilityRequestId) {
|
||||
pendingCapabilities = null
|
||||
publish(UNAVAILABLE_CAPABILITIES, now)
|
||||
return UNAVAILABLE_CAPABILITIES
|
||||
}
|
||||
return getCachedWindowsTerminalCapabilities()
|
||||
})
|
||||
|
||||
return pendingCapabilities
|
||||
}
|
||||
|
||||
export function refreshWindowsTerminalCapabilities(): Promise<WindowsTerminalCapabilities> {
|
||||
return loadWindowsTerminalCapabilities({ force: true })
|
||||
}
|
||||
|
||||
export function useWindowsTerminalCapabilities(enabled: boolean): WindowsTerminalCapabilities {
|
||||
const [capabilities, setCapabilities] = useState(getCachedWindowsTerminalCapabilities)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setCapabilities(UNAVAILABLE_CAPABILITIES)
|
||||
return
|
||||
}
|
||||
|
||||
setCapabilities(getCachedWindowsTerminalCapabilities())
|
||||
subscribers.add(setCapabilities)
|
||||
void loadWindowsTerminalCapabilities().then(setCapabilities)
|
||||
|
||||
return () => {
|
||||
subscribers.delete(setCapabilities)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return enabled ? capabilities : UNAVAILABLE_CAPABILITIES
|
||||
}
|
||||
|
||||
export function resetWindowsTerminalCapabilitiesForTests(): void {
|
||||
cachedCapabilities = null
|
||||
cachedCapabilitiesLoadedAt = 0
|
||||
pendingCapabilities = null
|
||||
latestCapabilityRequestId = 0
|
||||
subscribers.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { createDetectedAgentsSlice } from './detected-agents'
|
||||
|
||||
const detectAgents = vi.fn()
|
||||
const refreshAgents = vi.fn()
|
||||
|
||||
globalThis.window = {
|
||||
api: {
|
||||
preflight: {
|
||||
detectAgents,
|
||||
refreshAgents,
|
||||
detectRemoteAgents: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
} as unknown as Window['api']
|
||||
} as Window & typeof globalThis
|
||||
|
||||
function createTestStore(initial?: Partial<AppState>) {
|
||||
const store = create<AppState>()(
|
||||
(...a) =>
|
||||
({
|
||||
...createDetectedAgentsSlice(...a)
|
||||
}) as AppState
|
||||
)
|
||||
store.setState({
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
...initial
|
||||
} as Partial<AppState>)
|
||||
return store
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> & { id: string; path: string }): Repo {
|
||||
return {
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(
|
||||
overrides: Partial<Worktree> & { id: string; repoId: string; path: string }
|
||||
): Worktree {
|
||||
return {
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'main',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('createDetectedAgentsSlice WSL context', () => {
|
||||
beforeEach(() => {
|
||||
detectAgents.mockReset().mockResolvedValue(['claude'])
|
||||
refreshAgents.mockReset().mockResolvedValue({
|
||||
agents: ['codex'],
|
||||
addedPathSegments: [],
|
||||
shellHydrationOk: true,
|
||||
pathSource: 'shell_hydrate',
|
||||
pathFailureReason: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('detects local agents inside the active WSL worktree distro', async () => {
|
||||
const store = createTestStore({
|
||||
repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
makeWorktree({
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo'
|
||||
})
|
||||
]
|
||||
},
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1'
|
||||
})
|
||||
|
||||
await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude'])
|
||||
|
||||
expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('refreshes local agents inside the active WSL repo distro when no worktree is selected', async () => {
|
||||
const store = createTestStore({
|
||||
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl$\\Debian\\home\\alice\\repo' })],
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: null
|
||||
})
|
||||
|
||||
await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['codex'])
|
||||
|
||||
expect(refreshAgents).toHaveBeenCalledWith({ wslDistro: 'Debian' })
|
||||
})
|
||||
|
||||
it('does not keep previous context agents when detection fails after a context switch', async () => {
|
||||
detectAgents
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(['claude'])
|
||||
.mockRejectedValueOnce(new Error('probe failed'))
|
||||
const store = createTestStore({
|
||||
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' })],
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: null
|
||||
})
|
||||
|
||||
await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude'])
|
||||
expect(store.getState().detectedAgentIds).toEqual(['claude'])
|
||||
|
||||
store.setState({
|
||||
repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })],
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: null
|
||||
} as Partial<AppState>)
|
||||
const detected = store.getState().ensureDetectedAgents()
|
||||
|
||||
expect(store.getState().detectedAgentIds).toBeNull()
|
||||
await expect(detected).resolves.toEqual([])
|
||||
expect(store.getState().detectedAgentIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,33 +1,7 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types'
|
||||
|
||||
type LocalPreflightContext = { wslDistro?: string | null } | undefined
|
||||
|
||||
function getWslDistroFromPath(path?: string | null): string | null {
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const match = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(?:\/|$)/i)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function getLocalPreflightContext(state: AppState): LocalPreflightContext {
|
||||
const activeWorktree = state.activeWorktreeId
|
||||
? Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === state.activeWorktreeId)
|
||||
: null
|
||||
const activePath =
|
||||
activeWorktree?.path ?? state.repos.find((repo) => repo.id === state.activeRepoId)?.path
|
||||
const wslDistro = getWslDistroFromPath(activePath)
|
||||
return wslDistro ? { wslDistro } : undefined
|
||||
}
|
||||
|
||||
function localPreflightContextKey(context: LocalPreflightContext): string {
|
||||
return context?.wslDistro ? `wsl:${context.wslDistro}` : 'host'
|
||||
}
|
||||
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
|
||||
|
||||
export type DetectedAgentsSlice = {
|
||||
detectedAgentIds: TuiAgent[] | null
|
||||
|
|
@ -83,7 +57,11 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
|
|||
if (detectPromise?.key === contextKey) {
|
||||
return detectPromise.promise
|
||||
}
|
||||
set({ isDetectingAgents: true })
|
||||
const contextChanged = detectedContextKey !== contextKey
|
||||
set({
|
||||
detectedAgentIds: contextChanged ? null : get().detectedAgentIds,
|
||||
isDetectingAgents: true
|
||||
})
|
||||
const pending = window.api.preflight
|
||||
.detectAgents(context)
|
||||
.then((ids) => {
|
||||
|
|
@ -94,9 +72,12 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
|
|||
})
|
||||
.catch(() => {
|
||||
// Why: allow a retry on the next call if detection blew up (IPC timeout
|
||||
// during cold start). Do not cache the failure.
|
||||
// during cold start). Do not cache the failure or show stale context.
|
||||
detectPromise = null
|
||||
set({ isDetectingAgents: false })
|
||||
set({
|
||||
detectedAgentIds: contextChanged ? [] : get().detectedAgentIds,
|
||||
isDetectingAgents: false
|
||||
})
|
||||
return [] as TuiAgent[]
|
||||
})
|
||||
detectPromise = { key: contextKey, promise: pending }
|
||||
|
|
@ -109,7 +90,11 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
|
|||
if (refreshPromise?.key === contextKey) {
|
||||
return refreshPromise.promise
|
||||
}
|
||||
set({ isRefreshingAgents: true })
|
||||
const contextChanged = detectedContextKey !== contextKey
|
||||
set({
|
||||
detectedAgentIds: contextChanged ? null : get().detectedAgentIds,
|
||||
isRefreshingAgents: true
|
||||
})
|
||||
const pending = window.api.preflight
|
||||
.refreshAgents(context)
|
||||
.then((result) => {
|
||||
|
|
@ -127,8 +112,12 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
|
|||
return typed
|
||||
})
|
||||
.catch(() => {
|
||||
set({ isRefreshingAgents: false })
|
||||
return get().detectedAgentIds ?? []
|
||||
const fallback = contextChanged ? [] : (get().detectedAgentIds ?? [])
|
||||
set({
|
||||
detectedAgentIds: fallback,
|
||||
isRefreshingAgents: false
|
||||
})
|
||||
return fallback
|
||||
})
|
||||
.finally(() => {
|
||||
if (refreshPromise?.promise === pending) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { PreflightStatus } from '../../../../preload/api-types'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import type { AppState } from '../types'
|
||||
import { createPreflightSlice } from './preflight'
|
||||
|
||||
|
|
@ -40,6 +41,39 @@ function makeStatus(glabInstalled: boolean): PreflightStatus {
|
|||
}
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> & { id: string; path: string }): Repo {
|
||||
return {
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(
|
||||
overrides: Partial<Worktree> & { id: string; repoId: string; path: string }
|
||||
): Worktree {
|
||||
return {
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'main',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
|
|
@ -105,4 +139,88 @@ describe('createPreflightSlice', () => {
|
|||
|
||||
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
|
||||
})
|
||||
|
||||
it('checks integrations inside the active WSL worktree distro', async () => {
|
||||
preflightCheck.mockReset()
|
||||
preflightCheck.mockResolvedValueOnce(makeStatus(true))
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'repo-1',
|
||||
path: 'C:\\repo'
|
||||
})
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
makeWorktree({
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo'
|
||||
})
|
||||
]
|
||||
},
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1'
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().refreshPreflightStatus()
|
||||
|
||||
expect(preflightCheck).toHaveBeenCalledWith({ wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('keeps preflight request dedupe scoped by WSL distro context', async () => {
|
||||
preflightCheck.mockReset()
|
||||
const ubuntu = deferred<PreflightStatus>()
|
||||
const debian = deferred<PreflightStatus>()
|
||||
preflightCheck.mockReturnValueOnce(ubuntu.promise).mockReturnValueOnce(debian.promise)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' })],
|
||||
worktreesByRepo: {},
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: null
|
||||
} as Partial<AppState>)
|
||||
|
||||
const first = store.getState().refreshPreflightStatus()
|
||||
store.setState({
|
||||
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Debian\\home\\alice\\repo' })]
|
||||
} as Partial<AppState>)
|
||||
const second = store.getState().refreshPreflightStatus()
|
||||
|
||||
expect(preflightCheck).toHaveBeenNthCalledWith(1, { wslDistro: 'Ubuntu' })
|
||||
expect(preflightCheck).toHaveBeenNthCalledWith(2, { wslDistro: 'Debian' })
|
||||
ubuntu.resolve(makeStatus(false))
|
||||
debian.resolve(makeStatus(true))
|
||||
await Promise.all([first, second])
|
||||
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
|
||||
})
|
||||
|
||||
it('clears checked status immediately when refreshing a different local context', async () => {
|
||||
preflightCheck.mockReset()
|
||||
const host = deferred<PreflightStatus>()
|
||||
const wsl = deferred<PreflightStatus>()
|
||||
preflightCheck.mockReturnValueOnce(host.promise).mockReturnValueOnce(wsl.promise)
|
||||
const store = createTestStore()
|
||||
|
||||
const first = store.getState().refreshPreflightStatus()
|
||||
host.resolve(makeStatus(true))
|
||||
await first
|
||||
expect(store.getState().preflightStatusChecked).toBe(true)
|
||||
|
||||
store.setState({
|
||||
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' })],
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: null
|
||||
} as Partial<AppState>)
|
||||
const second = store.getState().refreshPreflightStatus()
|
||||
|
||||
expect(store.getState().preflightStatus).toBeNull()
|
||||
expect(store.getState().preflightStatusChecked).toBe(false)
|
||||
expect(store.getState().preflightStatusLoading).toBe(true)
|
||||
|
||||
wsl.resolve(makeStatus(false))
|
||||
await second
|
||||
expect(store.getState().preflightStatus?.glab?.installed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,44 +1,75 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { PreflightStatus } from '../../../../preload/api-types'
|
||||
import type { AppState } from '../types'
|
||||
import {
|
||||
getLocalPreflightContext,
|
||||
localPreflightContextKey,
|
||||
type LocalPreflightContext
|
||||
} from '@/lib/local-preflight-context'
|
||||
|
||||
export type PreflightSlice = {
|
||||
preflightStatus: PreflightStatus | null
|
||||
preflightStatusChecked: boolean
|
||||
preflightStatusContextKey: string | null
|
||||
preflightStatusLoading: boolean
|
||||
preflightStatusError: string | null
|
||||
|
||||
refreshPreflightStatus: (options?: { force?: boolean }) => Promise<void>
|
||||
}
|
||||
|
||||
let nonForcedPreflightRequest: Promise<void> | null = null
|
||||
let forcedPreflightRequest: Promise<void> | null = null
|
||||
let nonForcedPreflightRequest: { key: string; promise: Promise<void> } | null = null
|
||||
let forcedPreflightRequest: { key: string; promise: Promise<void> } | null = null
|
||||
let latestPreflightRequestId = 0
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Failed to check integrations.'
|
||||
}
|
||||
|
||||
export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice> = (set) => ({
|
||||
function buildPreflightArgs(
|
||||
force: boolean,
|
||||
context: LocalPreflightContext
|
||||
): { force?: boolean; wslDistro?: string | null } | undefined {
|
||||
if (!force && !context) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...(force ? { force: true } : {}),
|
||||
...context
|
||||
}
|
||||
}
|
||||
|
||||
export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice> = (set, get) => ({
|
||||
preflightStatus: null,
|
||||
preflightStatusChecked: false,
|
||||
preflightStatusContextKey: null,
|
||||
preflightStatusLoading: false,
|
||||
preflightStatusError: null,
|
||||
|
||||
refreshPreflightStatus: async (options) => {
|
||||
const force = options?.force === true
|
||||
if (!force && forcedPreflightRequest) {
|
||||
return forcedPreflightRequest
|
||||
const context = getLocalPreflightContext(get())
|
||||
const contextKey = localPreflightContextKey(context)
|
||||
if (!force && forcedPreflightRequest?.key === contextKey) {
|
||||
return forcedPreflightRequest.promise
|
||||
}
|
||||
if (!force && nonForcedPreflightRequest) {
|
||||
return nonForcedPreflightRequest
|
||||
if (!force && nonForcedPreflightRequest?.key === contextKey) {
|
||||
return nonForcedPreflightRequest.promise
|
||||
}
|
||||
if (force && forcedPreflightRequest?.key === contextKey) {
|
||||
return forcedPreflightRequest.promise
|
||||
}
|
||||
|
||||
const requestId = ++latestPreflightRequestId
|
||||
set({ preflightStatusLoading: true, preflightStatusError: null })
|
||||
const contextChanged = get().preflightStatusContextKey !== contextKey
|
||||
set({
|
||||
preflightStatus: contextChanged ? null : get().preflightStatus,
|
||||
preflightStatusChecked: contextChanged ? false : get().preflightStatusChecked,
|
||||
preflightStatusLoading: true,
|
||||
preflightStatusError: null
|
||||
})
|
||||
|
||||
const request = window.api.preflight
|
||||
.check(force ? { force: true } : undefined)
|
||||
.check(buildPreflightArgs(force, context))
|
||||
.then((status) => {
|
||||
if (requestId !== latestPreflightRequestId) {
|
||||
return
|
||||
|
|
@ -46,6 +77,7 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
|
|||
set({
|
||||
preflightStatus: status,
|
||||
preflightStatusChecked: true,
|
||||
preflightStatusContextKey: contextKey,
|
||||
preflightStatusLoading: false,
|
||||
preflightStatusError: null
|
||||
})
|
||||
|
|
@ -56,23 +88,24 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
|
|||
}
|
||||
set({
|
||||
preflightStatusChecked: true,
|
||||
preflightStatusContextKey: contextKey,
|
||||
preflightStatusLoading: false,
|
||||
preflightStatusError: getErrorMessage(error)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!force && nonForcedPreflightRequest === request) {
|
||||
if (!force && nonForcedPreflightRequest?.promise === request) {
|
||||
nonForcedPreflightRequest = null
|
||||
}
|
||||
if (force && forcedPreflightRequest === request) {
|
||||
if (force && forcedPreflightRequest?.promise === request) {
|
||||
forcedPreflightRequest = null
|
||||
}
|
||||
})
|
||||
|
||||
if (!force) {
|
||||
nonForcedPreflightRequest = request
|
||||
nonForcedPreflightRequest = { key: contextKey, promise: request }
|
||||
} else {
|
||||
forcedPreflightRequest = request
|
||||
forcedPreflightRequest = { key: contextKey, promise: request }
|
||||
}
|
||||
|
||||
return request
|
||||
|
|
|
|||
|
|
@ -718,6 +718,39 @@ describe('setActiveWorktree', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('uses WSL as the default shell for WSL worktree terminals on Windows', () => {
|
||||
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 = 'repo1::/wsl/path'
|
||||
|
||||
seedStore(store, {
|
||||
settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'powershell.exe' },
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: wt,
|
||||
repoId: 'repo1',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const terminal = store.getState().createTab(wt)
|
||||
expect(terminal.shellOverride).toBe('wsl.exe')
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('does not stamp local Windows shell icons onto SSH terminal tabs', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
|
||||
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id'
|
||||
import { isWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import type { AgentStartedTelemetry } from '../../lib/worktree-activation'
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
|
||||
|
|
@ -73,7 +74,8 @@ function isWindowsRendererRuntime(): boolean {
|
|||
function resolveCreatedTabShellOverride(
|
||||
explicitShellOverride: string | undefined,
|
||||
defaultWindowsShell: string | undefined,
|
||||
isRemoteWorktree: boolean
|
||||
isRemoteWorktree: boolean,
|
||||
isWslWorktree: boolean
|
||||
): string | undefined {
|
||||
if (isRemoteWorktree) {
|
||||
return undefined
|
||||
|
|
@ -82,11 +84,24 @@ function resolveCreatedTabShellOverride(
|
|||
return explicitShellOverride
|
||||
}
|
||||
if (isWindowsRendererRuntime()) {
|
||||
if (isWslWorktree) {
|
||||
return 'wsl.exe'
|
||||
}
|
||||
return defaultWindowsShell
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function worktreeUsesWslPath(
|
||||
state: Pick<AppState, 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
return worktree ? isWslUncPath(worktree.path) : false
|
||||
}
|
||||
|
||||
function worktreeUsesRemoteConnection(
|
||||
state: Pick<AppState, 'repos' | 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
|
|
@ -435,7 +450,11 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
s.settings?.terminalWindowsShell,
|
||||
// Why: SSH PTYs ignore local Windows shell selection; persisting a
|
||||
// local shell icon would mislabel a remote terminal.
|
||||
worktreeUsesRemoteConnection(s, worktreeId)
|
||||
worktreeUsesRemoteConnection(s, worktreeId),
|
||||
// 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.
|
||||
worktreeUsesWslPath(s, worktreeId)
|
||||
)
|
||||
tab = {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -951,7 +951,10 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
|
|||
return {
|
||||
getInstallStatus: () => Promise.resolve(status),
|
||||
install: () => Promise.resolve(status),
|
||||
remove: () => Promise.resolve(status)
|
||||
remove: () => Promise.resolve(status),
|
||||
getWslInstallStatus: () => Promise.resolve(status),
|
||||
installWsl: () => Promise.resolve(status),
|
||||
removeWsl: () => Promise.resolve(status)
|
||||
} as NonNullable<Partial<PreloadApi>['cli']>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { isWslUncPath, parseWslUncPath } from './wsl-paths'
|
||||
|
||||
describe('wsl path helpers', () => {
|
||||
it('parses modern and legacy WSL UNC paths without platform checks', () => {
|
||||
expect(parseWslUncPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toEqual({
|
||||
distro: 'Ubuntu',
|
||||
linuxPath: '/home/jin/repo'
|
||||
})
|
||||
expect(parseWslUncPath('\\\\wsl$\\Debian\\home\\jin')).toEqual({
|
||||
distro: 'Debian',
|
||||
linuxPath: '/home/jin'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects ordinary Windows and POSIX paths', () => {
|
||||
expect(isWslUncPath('C:\\Users\\jin\\repo')).toBe(false)
|
||||
expect(isWslUncPath('/home/jin/repo')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
export type WslUncPathInfo = {
|
||||
distro: string
|
||||
linuxPath: string
|
||||
}
|
||||
|
||||
export function parseWslUncPath(path: string): WslUncPathInfo | null {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
distro: match[2],
|
||||
linuxPath: match[3] || '/'
|
||||
}
|
||||
}
|
||||
|
||||
export function isWslUncPath(path: string): boolean {
|
||||
return parseWslUncPath(path) !== null
|
||||
}
|
||||
Loading…
Reference in New Issue