mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "읽지 않음:",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user