feat(open-in): open SSH workspaces in Cursor and Zed (#33877)

* feat(open-in): open SSH workspaces in Cursor and Zed

Cursor shares VS Code's Remote-SSH argv and now opens SSH and SSH-reached
server workspaces the same way, in a new window like its local launch.
Zed opens them through its ssh://[user@]host[:port]/path URL, so manual
custom-port hosts work without a local SSH config alias.

Co-authored-by: zerone0x <soberzml2333@gmail.com>

* fix(open-in): keep manual Zed targets on their saved route

Always send the saved port for manual targets so a matching ssh config Port
cannot redirect them, ask for an ssh config alias when a manual host needs a
key, agent or proxy the URL cannot carry, reject option-like hosts, and accept
the zeditor CLI name.

* refactor(open-in): move the remote SSH editor launch into its own module

Keeps external-editor-launch.ts under the max-lines limit after the main merge,
and moves main's new macOS CLI fallback test onto the remote launch API.

---------

Co-authored-by: zerone0x <soberzml2333@gmail.com>
This commit is contained in:
Neil
2026-10-26 06:28:29 -07:00
committed by GitHub
co-authored by zerone0x
parent 0cf40b1687
commit 4fda57bb11
27 changed files with 475 additions and 206 deletions
+8 -6
View File
@@ -66,15 +66,17 @@ Orca uses the same SSH transfer path as the connection. Folder download appears
This is a desktop-only action — the web client doesn't expose Download because it relies on Electron's save dialog.
## Open a remote workspace in VS Code
## Open a remote workspace in VS Code, Cursor, or Zed
For SSH worktrees, the worktree menu's **Open in** list can hand the remote path to VS Code Remote-SSH when your configured app is VS Code or VS Code Insiders (`code` / `code-insiders`, or a direct path to those launchers).
For SSH worktrees, the worktree menu's **Open in** list can hand the remote path to an editor that opens SSH hosts itself, when your configured app is VS Code, VS Code Insiders, Cursor, or Zed (`code` / `code-insiders` / `cursor` / `zed`, or a direct path to those launchers).
1. Configure **Settings → General → Open in menu** so VS Code is listed (preset or custom command).
1. Right-click the SSH worktree (or use the worktree overflow menu) → **Open in** → **VS Code**.
1. Orca launches VS Code with Remote-SSH against that host and the worktree path (`--remote ssh-remote+<host> <path>`). The menu entry may show a **Remote SSH** badge.
1. Configure **Settings → General → Open in menu** so the editor is listed (preset or custom command).
1. Right-click the SSH worktree (or use the worktree overflow menu) → **Open in** → the editor.
1. Orca launches the editor against that host and the worktree path. The menu entry may show a **Remote SSH** badge.
- VS Code and Cursor use Remote-SSH: `--remote ssh-remote+<host> <path>`. A host on a port other than 22 needs an alias in your local SSH config.
- Zed uses its SSH URL: `zed ssh://[user@]host[:port]/<path>`, so custom ports work without an alias. A manual host with an identity file, jump host, or proxy command still needs an alias, because the URL can't carry those.
**Not supported for this remote path:** Cursor, Zed, compound shell commands, or opening via a **Remote Orca Server** active runtime (those stay **Local only**). Use the Finder/Explorer item only for local paths.
**Not supported for this remote path:** other editors, compound shell commands, or opening via a **Remote Orca Server** active runtime (those stay **Local only**). Use the Finder/Explorer item only for local paths.
## Kerberos / GSSAPI
+2 -2
View File
@@ -32,10 +32,10 @@ Register the CLI under [Settings → General → Orca CLI](/docs/settings). On m
Folder download needs recursive SFTP transfer. File download may still work on system-SSH-only connections. Use a terminal `tar`/`scp` as a fallback.
## “Open in VS Code” is disabled or Local only
## “Open in VS Code, Cursor, or Zed” is disabled or Local only
- Use an SSH worktree (not a Remote Orca Server active runtime).
- Set the Open-in command to VS Code / Insiders, not Cursor or a multi-arg shell command.
- Set the Open-in command to VS Code / Insiders, Cursor, or Zed, not a multi-arg shell command.
- Refresh SSH targets if the host was removed.
## Kerberos login fails
@@ -4,11 +4,8 @@ const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
vi.mock('node:child_process', () => ({ spawn: spawnMock }))
import {
launchExternalEditor,
resolveVsCodeRemoteSshLaunchSpec,
type ExternalEditorLaunchSpec
} from './external-editor-launch'
import { launchExternalEditor, type ExternalEditorLaunchSpec } from './external-editor-launch'
import { resolveRemoteSshEditorLaunchSpec } from './remote-ssh-editor-launch'
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -35,14 +32,16 @@ function remoteSpec(
path: string,
platform: NodeJS.Platform
): ExternalEditorLaunchSpec {
const spec = resolveVsCodeRemoteSshLaunchSpec(code, path, 'builder', {
platform,
fileExists: () => true
})
if (!spec) {
const result = resolveRemoteSshEditorLaunchSpec(
code,
path,
{ id: 'ssh-1', label: 'Builder', host: 'builder', port: 22, username: '', source: 'manual' },
{ platform, fileExists: () => true }
)
if (!result.ok) {
throw new Error('expected a VS Code launch spec')
}
return spec
return result.launchSpec
}
const HIDDEN_FIELDS = { detached: true, stdio: 'ignore', windowsHide: true, shell: false }
+61 -14
View File
@@ -8,11 +8,30 @@ vi.mock('./codex-cli/command', () => ({
resolveCliCommand: resolveCliCommandMock
}))
import type { SshTarget } from '../shared/ssh-types'
import { getCmdExePath } from './win32-utils'
import {
resolveExternalEditorLaunchSpec,
resolveVsCodeRemoteSshLaunchSpec
} from './external-editor-launch'
import { resolveExternalEditorLaunchSpec } from './external-editor-launch'
import { resolveRemoteSshEditorLaunchSpec } from './remote-ssh-editor-launch'
const BUILDER_ALIAS: SshTarget = {
id: 'ssh-1',
label: 'Builder',
host: 'builder.example.com',
port: 2222,
username: 'ada',
source: 'ssh-config',
configHost: 'builder'
}
function remoteLaunchSpec(
command: string,
pathValue: string,
options: Parameters<typeof resolveRemoteSshEditorLaunchSpec>[3],
target: SshTarget = BUILDER_ALIAS
) {
const result = resolveRemoteSshEditorLaunchSpec(command, pathValue, target, options)
return result.ok ? result.launchSpec : null
}
describe('resolveExternalEditorLaunchSpec', () => {
beforeEach(() => {
@@ -480,7 +499,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
describe('resolveVsCodeRemoteSshLaunchSpec', () => {
describe('resolveRemoteSshEditorLaunchSpec', () => {
beforeEach(() => {
resolveCliCommandMock.mockReset()
resolveCliCommandMock.mockImplementation((command: string) => command)
@@ -488,7 +507,7 @@ describe('resolveVsCodeRemoteSshLaunchSpec', () => {
it.each(['code', 'code-insiders'])('builds exact Remote-SSH arguments for %s', (command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/home/Ada Lovelace/project', 'builder', {
remoteLaunchSpec(command, '/home/Ada Lovelace/project', {
platform: 'linux'
})
).toEqual({
@@ -506,7 +525,7 @@ describe('resolveVsCodeRemoteSshLaunchSpec', () => {
'C:\\Tools\\code-insiders.bat'
])('supports the direct Windows launcher %s', (command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, 'C:\\Users\\Ada Lovelace\\project', 'builder', {
remoteLaunchSpec(command, 'C:\\Users\\Ada Lovelace\\project', {
platform: 'win32'
})?.spawnArgs
).toEqual(['--remote', 'ssh-remote+builder', 'C:\\Users\\Ada Lovelace\\project'])
@@ -515,7 +534,7 @@ describe('resolveVsCodeRemoteSshLaunchSpec', () => {
it('supports an existing direct POSIX launcher path containing spaces', () => {
const command = '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/srv/project', 'builder', {
remoteLaunchSpec(command, '/srv/project', {
platform: 'darwin',
fileExists: (candidate) => candidate === command
})
@@ -530,7 +549,7 @@ describe('resolveVsCodeRemoteSshLaunchSpec', () => {
const macCli = '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'
expect(
resolveVsCodeRemoteSshLaunchSpec('code', '/home/ada/project', 'user@host', {
remoteLaunchSpec('code', '/home/ada/project', {
platform: 'darwin',
fileExists: (candidate) => candidate === macCli
})
@@ -538,27 +557,55 @@ describe('resolveVsCodeRemoteSshLaunchSpec', () => {
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: macCli,
spawnArgs: ['--remote', 'ssh-remote+user@host', '/home/ada/project']
spawnArgs: ['--remote', 'ssh-remote+builder', '/home/ada/project']
})
})
it('recognizes a simple CLI name resolved to a Windows shim', () => {
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\Code.CMD')
expect(
resolveVsCodeRemoteSshLaunchSpec('code', '/srv/project', 'builder', {
remoteLaunchSpec('code', '/srv/project', {
platform: 'win32'
})
).toMatchObject({ spawnCmd: 'C:\\Tools\\Code.CMD' })
})
it.each(['cursor', 'zed', 'code --reuse-window', 'open -a "Visual Studio Code"'])(
it('opens Cursor through Remote-SSH in a new window', () => {
expect(remoteLaunchSpec('cursor', '/srv/project', { platform: 'linux' })?.spawnArgs).toEqual([
'--new-window',
'--remote',
'ssh-remote+builder',
'/srv/project'
])
})
it('opens Zed with an ssh:// URL that keeps a manual custom port', () => {
const manualTarget: SshTarget = { ...BUILDER_ALIAS, source: 'manual', configHost: undefined }
expect(
remoteLaunchSpec('zed', '/home/Ada Lovelace/project', { platform: 'linux' }, manualTarget)
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: 'zed',
spawnArgs: ['ssh://ada@builder.example.com:2222/home/Ada%20Lovelace/project']
})
})
it('still requires an SSH config alias for a VS Code custom port', () => {
const manualTarget: SshTarget = { ...BUILDER_ALIAS, source: 'manual', configHost: undefined }
expect(
resolveRemoteSshEditorLaunchSpec('code', '/srv/project', manualTarget, { platform: 'linux' })
).toEqual({ ok: false, reason: 'ssh-alias-required', host: 'builder.example.com', port: 2222 })
})
it.each(['subl', 'code --reuse-window', 'open -a "Visual Studio Code"'])(
'rejects unsupported and compound SSH commands: %s',
(command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/srv/project', 'builder', {
resolveRemoteSshEditorLaunchSpec(command, '/srv/project', BUILDER_ALIAS, {
platform: 'linux'
})
).toBeNull()
).toEqual({ ok: false, reason: 'remote-editor-unsupported' })
}
)
})
+8 -23
View File
@@ -4,7 +4,7 @@ import { spawnProcess } from '@orca/process-host'
import { existsSync } from 'node:fs'
import { posix, win32 } from 'node:path'
import { parseWslUncPath } from '../shared/wsl-paths'
import { isVsCodeLauncherExecutable } from '../shared/vscode-remote-ssh-launcher'
import { isVsCodeLauncherExecutable } from '../shared/remote-ssh-editor-launcher'
import { resolveCliCommand } from './codex-cli/command'
import { envWithoutAppImageRuntime } from './pty/appimage-terminal-env'
import {
@@ -217,35 +217,20 @@ export function resolveExternalEditorLaunchSpec(
)
}
export function resolveVsCodeRemoteSshLaunchSpec(
/** The executable a remote launch would spawn; null for compound shell commands, which carry no single launcher. */
export function resolveExternalEditorExecutable(
command: string | undefined,
pathValue: string,
authority: string,
options: { platform?: NodeJS.Platform; fileExists?: (path: string) => boolean } = {}
): ExternalEditorLaunchSpec | null {
): string | null {
const platform = options.platform ?? process.platform
const fileExists = options.fileExists ?? existsSync
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
let editorCommand: string
if (isDirectExecutablePath(trimmed, platform, fileExists)) {
editorCommand = stripMatchingQuotes(trimmed)
} else {
if (isCompoundShellCommand(trimmed)) {
return null
}
editorCommand = resolveEditorCliCommand(trimmed, platform, fileExists)
}
if (!isVsCodeLauncherExecutable(editorCommand)) {
return null
}
return {
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: editorCommand,
spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue]
return stripMatchingQuotes(trimmed)
}
return isCompoundShellCommand(trimmed)
? null
: resolveEditorCliCommand(trimmed, platform, fileExists)
}
function startExternalEditorProcess(launchSpec: ExternalEditorLaunchSpec): ChildProcess {
+28 -1
View File
@@ -688,6 +688,7 @@ describe('registerShellHandlers', () => {
port: 2222
})
)
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/code')
const handler = getHandler('shell:openInExternalEditor')
await expect(
@@ -704,7 +705,33 @@ describe('registerShellHandlers', () => {
expect(spawnMock).not.toHaveBeenCalled()
})
it.each(['cursor', 'zed', 'code --reuse-window'])(
it('opens a manual custom-port target in Zed without an SSH config alias', async () => {
sshTargets.set(
'ssh-1',
createSshTarget({
source: 'manual',
configHost: 'builder.example.com',
host: 'builder.example.com',
port: 2222
})
)
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/zed')
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler(
{},
{ path: '/srv/project', command: 'zed', connectionId: 'ssh-1', ownerHostId: 'ssh:ssh-1' }
)
).resolves.toEqual({ ok: true })
expect(spawnMock).toHaveBeenCalledWith(
'/usr/local/bin/zed',
['ssh://ada@builder.example.com:2222/srv/project'],
HIDDEN_EDITOR_LAUNCH_OPTIONS
)
})
it.each(['subl', 'code --reuse-window'])(
'rejects the unsupported SSH launcher %s',
async (command) => {
sshTargets.set('ssh-1', createSshTarget())
+6 -15
View File
@@ -13,10 +13,9 @@ import type { Store } from '../persistence'
import {
EXTERNAL_EDITOR_CLI_COMMAND,
launchExternalEditor,
resolveExternalEditorLaunchSpec,
resolveVsCodeRemoteSshLaunchSpec
resolveExternalEditorLaunchSpec
} from '../external-editor-launch'
import { resolveVsCodeSshAuthority } from '../ssh/config/vscode-ssh-authority'
import { resolveRemoteSshEditorLaunchSpec } from '../remote-ssh-editor-launch'
import { toSshExecutionHostId } from '../../shared/execution-host'
import {
isEditorSshHostForOwner,
@@ -74,20 +73,12 @@ async function openInExternalEditor(
if (!posix.isAbsolute(request.path) && !win32.isAbsolute(request.path)) {
return { ok: false, reason: 'not-absolute' }
}
const authority = resolveVsCodeSshAuthority(sshTarget)
if (!authority.ok) {
return authority
}
const launchSpec = resolveVsCodeRemoteSshLaunchSpec(
request.command,
request.path,
authority.authority
)
if (!launchSpec) {
return { ok: false, reason: 'remote-editor-unsupported' }
const remoteLaunch = resolveRemoteSshEditorLaunchSpec(request.command, request.path, sshTarget)
if (!remoteLaunch.ok) {
return remoteLaunch
}
try {
await launchExternalEditor(launchSpec)
await launchExternalEditor(remoteLaunch.launchSpec)
return { ok: true }
} catch {
return { ok: false, reason: 'launch-failed' }
+47
View File
@@ -0,0 +1,47 @@
import { getRemoteSshEditorKind } from '../shared/remote-ssh-editor-launcher'
import type { ShellOpenExternalEditorResult } from '../shared/shell-open-types'
import type { SshTarget } from '../shared/ssh-types'
import { getLauncherBaseName } from './editor-launcher-name'
import {
resolveExternalEditorExecutable,
type ExternalEditorLaunchSpec
} from './external-editor-launch'
import { resolveVsCodeSshAuthority } from './ssh/config/vscode-ssh-authority'
import { resolveZedSshUrl } from './ssh/config/zed-ssh-url'
export function resolveRemoteSshEditorLaunchSpec(
command: string | undefined,
pathValue: string,
target: SshTarget,
options: { platform?: NodeJS.Platform; fileExists?: (path: string) => boolean } = {}
):
| { ok: true; launchSpec: ExternalEditorLaunchSpec }
| Exclude<ShellOpenExternalEditorResult, { ok: true }> {
const editorCommand = resolveExternalEditorExecutable(command, options)
const editorKind = editorCommand ? getRemoteSshEditorKind(editorCommand) : null
if (!editorCommand || !editorKind) {
return { ok: false, reason: 'remote-editor-unsupported' }
}
let spawnArgs: string[]
if (editorKind === 'zed') {
const zedUrl = resolveZedSshUrl(target, pathValue)
if (!zedUrl.ok) {
return zedUrl
}
spawnArgs = [zedUrl.url]
} else {
const authority = resolveVsCodeSshAuthority(target)
if (!authority.ok) {
return authority
}
spawnArgs = ['--remote', `ssh-remote+${authority.authority}`, pathValue]
// Cursor may reuse the last workbench; a new window preserves this workspace target.
if (getLauncherBaseName(editorCommand) === 'cursor') {
spawnArgs.unshift('--new-window')
}
}
return {
ok: true,
launchSpec: { kind: 'executable', hideWindowsConsole: true, spawnCmd: editorCommand, spawnArgs }
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ type VsCodeSshAuthorityResult =
| { ok: false; reason: 'ssh-target-invalid' }
| { ok: false; reason: 'ssh-alias-required'; host: string; port: number }
function isValidAuthorityPart(value: string, allowEmpty = false): boolean {
export function isValidAuthorityPart(value: string, allowEmpty = false): boolean {
return (
(allowEmpty || value.trim().length > 0) &&
!Array.from(value).some((character) => {
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import type { SshTarget } from '../../../shared/ssh-types'
import { resolveZedSshUrl } from './zed-ssh-url'
function createTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'ssh-1',
label: 'Builder',
host: 'builder.example.com',
port: 22,
username: 'ada',
source: 'manual',
...overrides
}
}
describe('resolveZedSshUrl', () => {
it('keeps a custom port in the URL instead of requiring an SSH config alias', () => {
expect(resolveZedSshUrl(createTarget({ port: 2222 }), '/srv/project')).toEqual({
ok: true,
url: 'ssh://ada@builder.example.com:2222/srv/project'
})
})
it('uses only the alias for config-backed targets so local ssh config supplies user and port', () => {
expect(
resolveZedSshUrl(
createTarget({ source: 'ssh-config', configHost: ' devbox ', port: 2222 }),
'/srv/project'
)
).toEqual({ ok: true, url: 'ssh://devbox/srv/project' })
})
it('percent-encodes user and path segments that would otherwise end the URL early', () => {
expect(
resolveZedSshUrl(
createTarget({ username: 'ada@corp', host: '2001:db8::1' }),
'/home/Ada Lovelace/a#b?c%d'
)
).toEqual({
ok: true,
url: 'ssh://ada%40corp@[2001:db8::1]:22/home/Ada%20Lovelace/a%23b%3Fc%25d'
})
})
it('asks for an ssh config alias when a manual host needs settings the URL cannot carry', () => {
expect(
resolveZedSshUrl(createTarget({ identityFile: '~/.ssh/builder', port: 2222 }), '/srv')
).toEqual({ ok: false, reason: 'ssh-alias-required', host: 'builder.example.com', port: 2222 })
expect(resolveZedSshUrl(createTarget({ jumpHost: 'bastion' }), '/srv')).toMatchObject({
reason: 'ssh-alias-required'
})
})
it('refuses hosts and paths Zed cannot round-trip', () => {
expect(
resolveZedSshUrl(createTarget({ source: 'ssh-config', configHost: 'team devbox' }), '/srv')
).toEqual({ ok: false, reason: 'remote-editor-unsupported' })
expect(resolveZedSshUrl(createTarget({ host: '-oProxyCommand=x' }), '/srv')).toMatchObject({
reason: 'ssh-alias-required'
})
expect(resolveZedSshUrl(createTarget(), 'C:\\Users\\ada\\project')).toEqual({
ok: false,
reason: 'remote-editor-unsupported'
})
expect(resolveZedSshUrl(createTarget({ host: 'bad\nhost' }), '/srv')).toEqual({
ok: false,
reason: 'ssh-target-invalid'
})
})
})
+65
View File
@@ -0,0 +1,65 @@
import { posix } from 'node:path'
import type { SshTarget } from '../../../shared/ssh-types'
import { isOpenSshConfigBackedTarget } from '../system-transport/system-ssh-args'
import { isValidAuthorityPart } from './vscode-ssh-authority'
type ZedSshUrlResult =
| { ok: true; url: string }
| { ok: false; reason: 'ssh-target-invalid' | 'remote-editor-unsupported' }
| { ok: false; reason: 'ssh-alias-required'; host: string; port: number }
// A leading '-' would reach ssh as an option.
const URL_SAFE_HOST = /^[a-z0-9._~][a-z0-9._~-]*$/i
const IPV6_HOST = /^[0-9a-f:.]+$/i
function formatUrlHost(host: string): string | null {
if (URL_SAFE_HOST.test(host)) {
return host
}
// Zed passes the URL host to ssh undecoded, so only literal-safe hosts survive the round trip.
return host.includes(':') && IPV6_HOST.test(host) ? `[${host}]` : null
}
/** Builds `ssh://[user@]host[:port]/path`; Zed connects through the local ssh and its config. */
export function resolveZedSshUrl(target: SshTarget, remotePath: string): ZedSshUrlResult {
if (!posix.isAbsolute(remotePath)) {
return { ok: false, reason: 'remote-editor-unsupported' }
}
const configBacked = isOpenSshConfigBackedTarget(target)
const rawHost = configBacked ? (target.configHost ?? '') : target.host
if (
!isValidAuthorityPart(rawHost) ||
(!configBacked &&
(!isValidAuthorityPart(target.username, true) ||
!Number.isInteger(target.port) ||
target.port < 1 ||
target.port > 65_535))
) {
return { ok: false, reason: 'ssh-target-invalid' }
}
const host = formatUrlHost(rawHost.trim())
if (!host && configBacked) {
return { ok: false, reason: 'remote-editor-unsupported' }
}
// The URL carries only user, host and port; keys, agents and proxies need an alias in ssh config.
if (!host || (!configBacked && hasSettingsOutsideZedUrl(target))) {
return { ok: false, reason: 'ssh-alias-required', host: rawHost.trim(), port: target.port }
}
const username = configBacked ? '' : target.username.trim()
const userInfo = username ? `${encodeURIComponent(username)}@` : ''
// Keep :22 for manual targets so a matching ssh config Port cannot redirect them.
const port = configBacked ? '' : `:${target.port}`
const path = remotePath.split('/').map(encodeURIComponent).join('/')
return { ok: true, url: `ssh://${userInfo}${host}${port}${path}` }
}
function hasSettingsOutsideZedUrl(target: SshTarget): boolean {
return Boolean(
target.identityFile ||
target.identityAgent ||
target.identitiesOnly ||
target.gssapiAuthentication ||
target.jumpHost ||
target.proxyCommand
)
}
@@ -250,31 +250,32 @@ describe('WorktreeOpenInMenu', () => {
)
})
it('enables only VS Code-compatible launchers for SSH paths', () => {
it('enables only launchers that open SSH hosts themselves for SSH paths', () => {
const entries = getWorktreeOpenInEntries(
[
{ id: 'renamed', label: 'My Remote Editor', command: 'code-insiders' },
{ id: 'fake', label: 'VS Code', command: 'cursor' },
{ id: 'cursor', label: 'Cursor', command: 'cursor' },
{ id: 'zed', label: 'Zed', command: 'zed' },
{ id: 'fake', label: 'VS Code', command: 'subl' },
{ id: 'compound', label: 'VS Code Reuse', command: 'code --reuse-window' }
],
'Finder'
)
expect(getOpenInEntryAvailability(entries[0], 'ssh-1')).toEqual({
disabled: false,
metadata: 'Remote SSH'
})
expect(getOpenInEntryAvailability(entries[1], 'ssh-1')).toEqual({
disabled: true,
metadata: 'Local only',
disabledReason: 'Only VS Code can open folders on an SSH host.'
})
expect(getOpenInEntryAvailability(entries[2], 'ssh-1')).toEqual({
disabled: true,
metadata: 'Local only',
disabledReason: 'Only VS Code can open folders on an SSH host.'
})
expect(getOpenInEntryAvailability(entries[3], 'ssh-1')).toEqual({
for (const entry of entries.slice(0, 3)) {
expect(getOpenInEntryAvailability(entry, 'ssh-1')).toEqual({
disabled: false,
metadata: 'Remote SSH'
})
}
for (const entry of entries.slice(3, 5)) {
expect(getOpenInEntryAvailability(entry, 'ssh-1')).toEqual({
disabled: true,
metadata: 'Local only',
disabledReason: 'Only VS Code, Cursor, and Zed can open folders on an SSH host.'
})
}
expect(getOpenInEntryAvailability(entries[5], 'ssh-1')).toEqual({
disabled: true,
metadata: 'Local only'
})
@@ -301,12 +302,12 @@ describe('WorktreeOpenInMenu', () => {
target: 'external-editor',
worktreePath: '/home/ada/project',
connectionId: 'ssh-1',
command: 'cursor'
command: 'subl'
})
expect(openInExternalEditorMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledWith('This app cannot open SSH workspaces.', {
description: 'Choose VS Code or use the app locally.'
description: 'Choose VS Code, Cursor, or Zed, or use the app locally.'
})
})
@@ -326,7 +327,7 @@ describe('WorktreeOpenInMenu', () => {
})
expect(toastErrorMock).toHaveBeenCalledWith(
'VS Code needs an SSH config alias for this host.',
'This editor needs an SSH config alias for this host.',
{
description:
'Add a Host alias for builder.example.com:2222 to your local SSH config, reconnect the workspace, then try again.'
@@ -393,19 +394,24 @@ describe('WorktreeOpenInMenu', () => {
target: 'external-editor',
command: 'code'
} as const
const zed = { id: 'zed', label: 'Zed', target: 'external-editor', command: 'zed' } as const
const subl = {
id: 'subl',
label: 'Sublime',
target: 'external-editor',
command: 'subl'
} as const
it('enables VS Code over Remote SSH and explains why other apps stay off', () => {
expect(
getOpenInEntryAvailability(code, null, 'managed-owner', false, runtimeSshHost)
).toEqual({ disabled: false, metadata: 'Remote SSH' })
expect(getOpenInEntryAvailability(zed, null, 'managed-owner', false, runtimeSshHost)).toEqual(
{
disabled: true,
metadata: 'Local only',
disabledReason: 'Only VS Code can open folders on an SSH host.'
}
)
expect(
getOpenInEntryAvailability(subl, null, 'managed-owner', false, runtimeSshHost)
).toEqual({
disabled: true,
metadata: 'Local only',
disabledReason: 'Only VS Code, Cursor, and Zed can open folders on an SSH host.'
})
})
it('sends the SSH host with the server as the owner', async () => {
@@ -95,7 +95,7 @@ export function getOpenInEntryAvailability(
capability.reason === 'local-only-editor'
? translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteEditorHint',
'Only VS Code can open folders on an SSH host.'
'Only VS Code, Cursor, and Zed can open folders on an SSH host.'
)
: translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteRuntimeEditorHint',
@@ -55,7 +55,7 @@ export function showOpenFailureToast(
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.sshAliasRequired',
'VS Code needs an SSH config alias for this host.'
'This editor needs an SSH config alias for this host.'
),
{
description: translate(
@@ -76,7 +76,7 @@ export function showOpenFailureToast(
{
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteEditorUnsupportedDetail',
'Choose VS Code or use the app locally.'
'Choose VS Code, Cursor, or Zed, or use the app locally.'
)
}
)
@@ -123,12 +123,12 @@ export function showOpenFailureToast(
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteLaunchFailed',
'Could not open the path in VS Code.'
'Could not open the path in the editor.'
),
{
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteLaunchFailedDetail',
'Check the VS Code command configured on this machine.'
'Check the editor command configured on this machine.'
)
}
)
+5 -5
View File
@@ -6443,16 +6443,16 @@
"sshTargetNotFoundDetail": "Refresh workspaces or reconnect the host, then try again.",
"sshTargetInvalid": "SSH host configuration is incomplete.",
"sshTargetInvalidDetail": "Edit or reconnect the SSH host, then try again.",
"sshAliasRequired": "VS Code needs an SSH config alias for this host.",
"sshAliasRequired": "This editor needs an SSH config alias for this host.",
"sshAliasRequiredDetail": "Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.",
"remoteEditorUnsupported": "This app cannot open SSH workspaces.",
"remoteEditorUnsupportedDetail": "Choose VS Code or use the app locally.",
"remoteEditorHint": "Only VS Code can open folders on an SSH host.",
"remoteEditorUnsupportedDetail": "Choose VS Code, Cursor, or Zed, or use the app locally.",
"remoteEditorHint": "Only VS Code, Cursor, and Zed can open folders on an SSH host.",
"remoteRuntimeEditorHint": "Apps on this computer can open this server’s files only when Orca reaches it over SSH.",
"remotePathInvalid": "Path is not valid for the SSH host.",
"remotePathInvalidDetail": "Refresh the workspace before trying again.",
"remoteLaunchFailed": "Could not open the path in VS Code.",
"remoteLaunchFailedDetail": "Check the VS Code command configured on this machine.",
"remoteLaunchFailed": "Could not open the path in the editor.",
"remoteLaunchFailedDetail": "Check the editor command configured on this machine.",
"1417fd8380": "Customize apps...",
"8009ab69a6": "Open in",
"bd0e8159f8": "Check the editor command or file manager configuration on this machine.",
+5 -5
View File
@@ -6080,16 +6080,16 @@
"sshTargetNotFoundDetail": "Actualice los espacios de trabajo o vuelva a conectar el host y vuelva a intentarlo.",
"sshTargetInvalid": "La configuración del host SSH está incompleta.",
"sshTargetInvalidDetail": "Edite o vuelva a conectar el host SSH y vuelva a intentarlo.",
"sshAliasRequired": "VS Code necesita un alias de configuración SSH para este host.",
"sshAliasRequired": "Este editor necesita un alias de configuración SSH para este host.",
"sshAliasRequiredDetail": "Añade un alias Host para {{host}}:{{port}} a tu configuración SSH local, vuelve a conectar el espacio de trabajo e inténtalo de nuevo.",
"remoteEditorUnsupported": "Esta aplicación no puede abrir espacios de trabajo SSH.",
"remoteEditorUnsupportedDetail": "Elija VS Code o use la aplicación localmente.",
"remoteEditorUnsupportedDetail": "Elige VS Code, Cursor o Zed, o usa la aplicación localmente.",
"remotePathInvalid": "La ruta no es válida para el host SSH.",
"remotePathInvalidDetail": "Actualice el espacio de trabajo antes de volver a intentarlo.",
"remoteLaunchFailed": "No se pudo abrir la ruta en VS Code.",
"remoteLaunchFailedDetail": "Comprueba el comando de VS Code configurado en este equipo.",
"remoteLaunchFailed": "No se pudo abrir la ruta en el editor.",
"remoteLaunchFailedDetail": "Comprueba el comando del editor configurado en esta máquina.",
"wsl": "WSL",
"remoteEditorHint": "Sólo VS Code puede abrir carpetas en un host SSH.",
"remoteEditorHint": "Sólo VS Code, Cursor y Zed pueden abrir carpetas en un host SSH.",
"remoteRuntimeEditorHint": "Las aplicaciones de este equipo solo pueden abrir los archivos del servidor cuando Orca se conecta a él por SSH."
},
"WorktreeTitleInlineRename": {
+5 -5
View File
@@ -6243,14 +6243,14 @@
"sshTargetNotFoundDetail": "Actualisez les espaces de travail ou reconnectez l'hôte, puis réessayez.",
"sshTargetInvalid": "La configuration de l'hôte SSH est incomplète.",
"sshTargetInvalidDetail": "Modifiez l'hôte SSH ou reconnectez-le, puis réessayez.",
"sshAliasRequired": "VS Code a besoin d'un alias de config SSH pour cet hôte.",
"sshAliasRequired": "Cet éditeur a besoin d'un alias de config SSH pour cet hôte.",
"sshAliasRequiredDetail": "Ajoutez un alias Host pour {{host}}:{{port}} dans votre config SSH locale, reconnectez l'espace de travail, puis réessayez.",
"remoteEditorUnsupported": "Cette application ne peut pas ouvrir les espaces de travail SSH.",
"remoteEditorUnsupportedDetail": "Choisissez VS Code ou utilisez l'application localement.",
"remoteEditorUnsupportedDetail": "Choisissez VS Code, Cursor ou Zed, ou utilisez l'application en local.",
"remotePathInvalid": "Le chemin n'est pas valide pour l'hôte SSH.",
"remotePathInvalidDetail": "Actualisez l'espace de travail avant de réessayer.",
"remoteLaunchFailed": "Impossible d'ouvrir le chemin dans VS Code.",
"remoteLaunchFailedDetail": "Vérifiez la commande VS Code configurée sur cette machine.",
"remoteLaunchFailed": "Impossible d'ouvrir le chemin dans l'éditeur.",
"remoteLaunchFailedDetail": "Vérifiez la commande de l'éditeur configurée sur cette machine.",
"1417fd8380": "Personnaliser les applications...",
"8009ab69a6": "Ouvrir dans",
"bd0e8159f8": "Vérifiez la commande de l'éditeur ou la configuration du gestionnaire de fichiers sur cette machine.",
@@ -6260,7 +6260,7 @@
"f387af445b": "Le chemin de l'espace de travail n'est pas un chemin local valide.",
"3ec372b664": "file-manager",
"wsl": "WSL",
"remoteEditorHint": "Seul VS Code peut ouvrir des dossiers sur un hôte SSH.",
"remoteEditorHint": "Seuls VS Code, Cursor et Zed peuvent ouvrir des dossiers sur un hôte SSH.",
"remoteRuntimeEditorHint": "Les applications sur cet ordinateur ne peuvent ouvrir les fichiers de ce serveur que si Orca s’y connecte via SSH."
},
"WorktreeTitleInlineRename": {
+5 -5
View File
@@ -6046,16 +6046,16 @@
"sshTargetNotFoundDetail": "ワークスペースを更新するかホストに再接続してから、もう一度お試しください。",
"sshTargetInvalid": "SSH ホストの設定が不完全です。",
"sshTargetInvalidDetail": "SSH ホストを編集するか再接続してから、もう一度お試しください。",
"sshAliasRequired": "VS Code には、このホストの SSH 設定エイリアスが必要です。",
"sshAliasRequired": "このエディターには、このホストの SSH 設定エイリアスが必要です。",
"sshAliasRequiredDetail": "ローカルの SSH 設定に {{host}}:{{port}} の Host エイリアスを追加し、ワークスペースを再接続してから、もう一度お試しください。",
"remoteEditorUnsupported": "このアプリは SSH ワークスペースを開けません。",
"remoteEditorUnsupportedDetail": "VS Code を選択するか、このアプリをローカルで使用してください。",
"remoteEditorUnsupportedDetail": "VS Code、Cursor、Zed のいずれかを選択するか、アプリをローカルで使用してください。",
"remotePathInvalid": "このパスは SSH ホストでは無効です。",
"remotePathInvalidDetail": "ワークスペースを更新してから、もう一度お試しください。",
"remoteLaunchFailed": "VS Code でパスを開けませんでした。",
"remoteLaunchFailedDetail": "このマシンに設定されている VS Code のコマンドを確認してください。",
"remoteLaunchFailed": "エディターでパスを開けませんでした。",
"remoteLaunchFailedDetail": "このマシンで設定されているエディターのコマンドを確認してください。",
"wsl": "WSL",
"remoteEditorHint": "SSH ホスト上のフォルダーを開くことができるのは VS Code のみです。",
"remoteEditorHint": "SSH ホスト上のフォルダーを開くことができるのは VS Code、Cursor、Zed のみです。",
"remoteRuntimeEditorHint": "このコンピュータ上のアプリは、Orca が SSH を介してサーバーに到達した場合にのみ、このサーバーのファイルを開くことができます。"
},
"WorktreeTitleInlineRename": {
+5 -5
View File
@@ -6046,17 +6046,17 @@
"sshTargetNotFoundDetail": "워크스페이스의 새로고침을 실행하거나 호스트를 다시 연결한 후 다시 시도하세요.",
"sshTargetInvalid": "SSH 호스트 구성이 불완전합니다.",
"sshTargetInvalidDetail": "SSH 호스트를 편집하거나 다시 연결한 후 다시 시도하세요.",
"sshAliasRequired": "VS Code에는 이 호스트에 대한 SSH 구성 별칭이 필요합니다.",
"sshAliasRequired": "이 편집기에는 이 호스트에 대한 SSH 구성 별칭이 필요합니다.",
"sshAliasRequiredDetail": "로컬 SSH 설정에 {{host}}:{{port}}의 Host 별칭을 추가하고 워크스페이스를 다시 연결한 후 다시 시도하세요.",
"remoteEditorUnsupported": "이 앱은 SSH 워크스페이스를 열 수 없습니다.",
"remoteEditorUnsupportedDetail": "VS Code를 선택하거나 로컬에서 앱을 사용하세요.",
"remoteEditorUnsupportedDetail": "VS Code, Cursor 또는 Zed를 선택하거나 앱을 로컬에서 사용하세요.",
"remotePathInvalid": "SSH 호스트에 대한 경로가 유효하지 않습니다.",
"remotePathInvalidDetail": "워크스페이스의 새로고침을 실행한 후 다시 시도하세요.",
"remoteLaunchFailed": "VS Code에서 경로를 열 수 없습니다.",
"remoteLaunchFailedDetail": "이 컴퓨터에 구성된 VS Code 명령을 확인하세요.",
"remoteLaunchFailed": "편집기에서 경로를 열 수 없습니다.",
"remoteLaunchFailedDetail": "이 컴퓨터에 구성된 편집기 명령을 확인하세요.",
"wsl": "WSL",
"remoteRuntimeEditorHint": "Orca가 SSH로 이 서버에 연결된 경우에만 이 컴퓨터의 앱에서 서버 파일을 열 수 있습니다.",
"remoteEditorHint": "VS Code만 SSH 호스트의 폴더를 열 수 있습니다."
"remoteEditorHint": "VS Code, Cursor, Zed만 SSH 호스트의 폴더를 열 수 있습니다."
},
"WorktreeTitleInlineRename": {
"2f42ae024f": "읽지 않음:",
+5 -5
View File
@@ -10925,14 +10925,14 @@
"sshTargetNotFoundDetail": "Atualize os espaços de trabalho ou reconecte o host e tente novamente.",
"sshTargetInvalid": "A configuração do host SSH está incompleta.",
"sshTargetInvalidDetail": "Edite ou reconecte o host SSH e tente novamente.",
"sshAliasRequired": "O VS Code precisa de um alias de configuração SSH para este host.",
"sshAliasRequired": "Este editor precisa de um alias de configuração SSH para este host.",
"sshAliasRequiredDetail": "Adicione um alias Host para {{host}}:{{port}} à sua configuração SSH local, reconecte o espaço de trabalho e tente novamente.",
"remoteEditorUnsupported": "Este aplicativo não pode abrir espaços de trabalho SSH.",
"remoteEditorUnsupportedDetail": "Escolha o VS Code ou use o aplicativo localmente.",
"remoteEditorUnsupportedDetail": "Escolha VS Code, Cursor ou Zed, ou use o app localmente.",
"remotePathInvalid": "O caminho não é válido para o host SSH.",
"remotePathInvalidDetail": "Atualize o espaço de trabalho antes de tentar novamente.",
"remoteLaunchFailed": "Não foi possível abrir o caminho no VS Code.",
"remoteLaunchFailedDetail": "Verifique o comando do VS Code configurado nesta máquina.",
"remoteLaunchFailed": "Não foi possível abrir o caminho no editor.",
"remoteLaunchFailedDetail": "Verifique o comando do editor configurado nesta máquina.",
"1417fd8380": "Personalizar aplicativos...",
"8009ab69a6": "Abrir em",
"bd0e8159f8": "Verifique a configuração do comando do editor ou do gerenciador de arquivos nesta máquina.",
@@ -10941,7 +10941,7 @@
"3921d3d9a5": "A pasta do espaço de trabalho não foi encontrada.",
"f387af445b": "O caminho do espaço de trabalho não é um caminho local válido.",
"3ec372b664": "gerenciador-de-arquivos",
"remoteEditorHint": "Somente o VS Code pode abrir pastas em um host SSH.",
"remoteEditorHint": "Somente VS Code, Cursor e Zed podem abrir pastas em um host SSH.",
"remoteRuntimeEditorHint": "Os aplicativos deste computador só podem abrir arquivos deste servidor quando o Orca se conecta a ele por SSH.",
"wsl": "WSL"
},
+5 -5
View File
@@ -6046,16 +6046,16 @@
"sshTargetNotFoundDetail": "请刷新工作区或重新连接主机,然后重试。",
"sshTargetInvalid": "SSH 主机配置不完整。",
"sshTargetInvalidDetail": "请编辑或重新连接 SSH 主机,然后重试。",
"sshAliasRequired": "VS Code 需要此主机的 SSH 配置别名。",
"sshAliasRequired": "此编辑器需要此主机的 SSH 配置别名。",
"sshAliasRequiredDetail": "请在本地 SSH 配置中为 {{host}}:{{port}} 添加 Host 别名,重新连接工作区,然后重试。",
"remoteEditorUnsupported": "此应用无法打开 SSH 工作区。",
"remoteEditorUnsupportedDetail": "请选择 VS Code 或在本地使用该应用。",
"remoteEditorUnsupportedDetail": "请选择 VS Code、Cursor 或 Zed,或在本地使用该应用。",
"remotePathInvalid": "路径对此 SSH 主机无效。",
"remotePathInvalidDetail": "请先刷新工作区,再重试。",
"remoteLaunchFailed": "无法在 VS Code 中打开此路径。",
"remoteLaunchFailedDetail": "请检查此机器上配置的 VS Code 命令。",
"remoteLaunchFailed": "无法在编辑器中打开此路径。",
"remoteLaunchFailedDetail": "请检查此计算机上配置的编辑器命令。",
"wsl": "WSL",
"remoteEditorHint": "只有 VS Code 可以打开 SSH 主机上的文件夹。",
"remoteEditorHint": "只有 VS Code、Cursor 和 Zed 可以打开 SSH 主机上的文件夹。",
"remoteRuntimeEditorHint": "只有当 Orca 通过 SSH 连接到此服务器时,本机应用才能打开服务器上的文件。"
},
"WorktreeTitleInlineRename": {
@@ -14,8 +14,15 @@ describe('getExternalEditorOpenCapability', () => {
).toEqual({ allowed: true, remote: true })
})
it('rejects non-VS Code and compound commands for SSH paths', () => {
expect(getExternalEditorOpenCapability({ connectionId: 'ssh-1', command: 'cursor' })).toEqual({
it.each(['cursor', 'zed'])('allows %s for SSH paths', (command) => {
expect(getExternalEditorOpenCapability({ connectionId: 'ssh-1', command })).toEqual({
allowed: true,
remote: true
})
})
it('rejects other and compound commands for SSH paths', () => {
expect(getExternalEditorOpenCapability({ connectionId: 'ssh-1', command: 'subl' })).toEqual({
allowed: false,
reason: 'local-only-editor'
})
@@ -54,7 +61,7 @@ describe('getExternalEditorOpenCapability', () => {
).toEqual({ allowed: true, remote: true, sshTargetId: 'box' })
expect(
getExternalEditorOpenCapability({
command: 'zed',
command: 'subl',
runtimeEnvironmentId: 'managed',
runtimeSshHost
})
@@ -1,4 +1,4 @@
import { isVsCodeRemoteSshCommand } from '../../../shared/vscode-remote-ssh-launcher'
import { isRemoteSshEditorCommand } from '../../../shared/remote-ssh-editor-launcher'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { getWindowsPathAccess } from './windows-path-access'
import type { WorkspaceEditorSshHost } from './workspace-editor-ssh-target'
@@ -35,14 +35,14 @@ export function getExternalEditorOpenCapability(context: {
if (sshHost?.environmentId !== runtimeEnvironmentId) {
return { allowed: false, reason: 'remote-runtime' }
}
return isVsCodeRemoteSshCommand(context.command)
return isRemoteSshEditorCommand(context.command)
? { allowed: true, remote: true, sshTargetId: sshHost.sshTargetId }
: { allowed: false, reason: 'local-only-editor' }
}
if (!context.connectionId?.trim()) {
return { allowed: true, remote: false }
}
return isVsCodeRemoteSshCommand(context.command)
return isRemoteSshEditorCommand(context.command)
? { allowed: true, remote: true }
: { allowed: false, reason: 'local-only-editor' }
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { getRemoteSshEditorKind, isRemoteSshEditorCommand } from './remote-ssh-editor-launcher'
describe('remote SSH editor launcher capability', () => {
it.each([
'code',
'code-insiders',
'cursor',
'zed',
'/usr/local/bin/code',
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
'/usr/bin/zeditor',
'C:\\Program Files\\Microsoft VS Code\\Code.exe',
'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe',
'C:\\Tools\\CODE.CMD',
'C:\\Tools\\code-insiders.bat',
'C:\\Program Files\\cursor\\Cursor.exe'
])('recognizes a safe configured launcher: %s', (command) => {
expect(isRemoteSshEditorCommand(command)).toBe(true)
})
it.each(['subl', 'code --reuse-window', 'zed --new', 'open -a "Visual Studio Code"'])(
'rejects an unsupported or compound command: %s',
(command) => {
expect(isRemoteSshEditorCommand(command)).toBe(false)
}
)
it('routes Cursor through Remote-SSH arguments and Zed through its SSH URL', () => {
expect(getRemoteSshEditorKind('C:\\Tools\\Code - Insiders.exe')).toBe('vscode-remote')
expect(getRemoteSshEditorKind('C:\\Tools\\cursor.cmd')).toBe('vscode-remote')
expect(
getRemoteSshEditorKind('C:\\Users\\ada\\AppData\\Local\\Programs\\Zed\\bin\\zed.exe')
).toBe('zed')
expect(getRemoteSshEditorKind('/usr/bin/subl')).toBeNull()
})
})
+44
View File
@@ -0,0 +1,44 @@
const VSCODE_LAUNCHER_NAMES = new Set(['code', 'code-insiders', 'code - insiders'])
const WINDOWS_ABSOLUTE_PATH = /^(?:[a-z]:[\\/]|\\\\)/i
/** `vscode-remote` editors take `--remote ssh-remote+<authority>`; Zed takes an `ssh://` URL. */
export type RemoteSshEditorKind = 'vscode-remote' | 'zed'
function stripMatchingQuotes(value: string): string {
const trimmed = value.trim()
const quote = trimmed[0]
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
return trimmed.slice(1, -1)
}
return trimmed
}
function getLauncherName(command: string): string {
const segments = stripMatchingQuotes(command).split(/[\\/]/)
const fileName = segments.at(-1) ?? ''
return fileName.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
}
export function isVsCodeLauncherExecutable(command: string): boolean {
return VSCODE_LAUNCHER_NAMES.has(getLauncherName(command))
}
export function getRemoteSshEditorKind(executable: string): RemoteSshEditorKind | null {
const name = getLauncherName(executable)
if (VSCODE_LAUNCHER_NAMES.has(name) || name === 'cursor') {
return 'vscode-remote'
}
// Arch and Fedora package the Zed CLI as zeditor.
return name === 'zed' || name === 'zeditor' ? 'zed' : null
}
export function isRemoteSshEditorCommand(command: string | undefined): boolean {
const trimmed = command?.trim() || 'code'
const unquoted = stripMatchingQuotes(trimmed)
if (!/\s/.test(unquoted)) {
return getRemoteSshEditorKind(unquoted) !== null
}
const isAbsolutePath = unquoted.startsWith('/') || WINDOWS_ABSOLUTE_PATH.test(unquoted)
return isAbsolutePath && getRemoteSshEditorKind(unquoted) !== null
}
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isVsCodeLauncherExecutable, isVsCodeRemoteSshCommand } from './vscode-remote-ssh-launcher'
describe('VS Code Remote-SSH launcher capability', () => {
it.each([
'code',
'code-insiders',
'/usr/local/bin/code',
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
'C:\\Program Files\\Microsoft VS Code\\Code.exe',
'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe',
'C:\\Tools\\CODE.CMD',
'C:\\Tools\\code-insiders.bat'
])('recognizes a safe configured launcher: %s', (command) => {
expect(isVsCodeRemoteSshCommand(command)).toBe(true)
})
it.each(['cursor', 'zed', 'code --reuse-window', 'open -a "Visual Studio Code"'])(
'rejects an unsupported or compound command: %s',
(command) => {
expect(isVsCodeRemoteSshCommand(command)).toBe(false)
}
)
it('recognizes resolved Windows launchers by executable basename', () => {
expect(isVsCodeLauncherExecutable('C:\\Tools\\Code - Insiders.exe')).toBe(true)
expect(isVsCodeLauncherExecutable('C:\\Tools\\cursor.exe')).toBe(false)
})
})
-30
View File
@@ -1,30 +0,0 @@
const VSCODE_LAUNCHER_NAMES = new Set(['code', 'code-insiders', 'code - insiders'])
const WINDOWS_ABSOLUTE_PATH = /^(?:[a-z]:[\\/]|\\\\)/i
function stripMatchingQuotes(value: string): string {
const trimmed = value.trim()
const quote = trimmed[0]
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
return trimmed.slice(1, -1)
}
return trimmed
}
export function isVsCodeLauncherExecutable(command: string): boolean {
const unquoted = stripMatchingQuotes(command)
const segments = unquoted.split(/[\\/]/)
const fileName = segments.at(-1) ?? ''
const launcherName = fileName.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
return VSCODE_LAUNCHER_NAMES.has(launcherName)
}
export function isVsCodeRemoteSshCommand(command: string | undefined): boolean {
const trimmed = command?.trim() || 'code'
const unquoted = stripMatchingQuotes(trimmed)
if (!/\s/.test(unquoted)) {
return isVsCodeLauncherExecutable(unquoted)
}
const isAbsolutePath = unquoted.startsWith('/') || WINDOWS_ABSOLUTE_PATH.test(unquoted)
return isAbsolutePath && isVsCodeLauncherExecutable(unquoted)
}