From 4fda57bb111e1b319662b319ab0eacb91bb726e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:28:29 -0700 Subject: [PATCH] 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 * 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 --- docs/site/content/docs/ssh.mdx | 14 ++-- docs/site/content/docs/troubleshooting.mdx | 4 +- ...xternal-editor-launch-process-host.test.ts | 21 +++--- src/main/external-editor-launch.test.ts | 75 +++++++++++++++---- src/main/external-editor-launch.ts | 31 ++------ src/main/ipc/shell.test.ts | 29 ++++++- src/main/ipc/shell.ts | 21 ++---- src/main/remote-ssh-editor-launch.ts | 47 ++++++++++++ src/main/ssh/config/vscode-ssh-authority.ts | 2 +- src/main/ssh/config/zed-ssh-url.test.ts | 71 ++++++++++++++++++ src/main/ssh/config/zed-ssh-url.ts | 65 ++++++++++++++++ .../sidebar/WorktreeOpenInMenu.test.tsx | 62 ++++++++------- .../components/sidebar/WorktreeOpenInMenu.tsx | 2 +- .../sidebar/worktree-open-failure-toast.ts | 8 +- src/renderer/src/i18n/locales/en.json | 10 +-- src/renderer/src/i18n/locales/es.json | 10 +-- src/renderer/src/i18n/locales/fr.json | 10 +-- src/renderer/src/i18n/locales/ja.json | 10 +-- src/renderer/src/i18n/locales/ko.json | 10 +-- src/renderer/src/i18n/locales/pt.json | 10 +-- src/renderer/src/i18n/locales/zh.json | 10 +-- .../external-editor-open-capability.test.ts | 13 +++- .../lib/external-editor-open-capability.ts | 6 +- src/shared/remote-ssh-editor-launcher.test.ts | 37 +++++++++ src/shared/remote-ssh-editor-launcher.ts | 44 +++++++++++ src/shared/vscode-remote-ssh-launcher.test.ts | 29 ------- src/shared/vscode-remote-ssh-launcher.ts | 30 -------- 27 files changed, 475 insertions(+), 206 deletions(-) create mode 100644 src/main/remote-ssh-editor-launch.ts create mode 100644 src/main/ssh/config/zed-ssh-url.test.ts create mode 100644 src/main/ssh/config/zed-ssh-url.ts create mode 100644 src/shared/remote-ssh-editor-launcher.test.ts create mode 100644 src/shared/remote-ssh-editor-launcher.ts delete mode 100644 src/shared/vscode-remote-ssh-launcher.test.ts delete mode 100644 src/shared/vscode-remote-ssh-launcher.ts diff --git a/docs/site/content/docs/ssh.mdx b/docs/site/content/docs/ssh.mdx index a75d61c8717..9efaacf26fc 100644 --- a/docs/site/content/docs/ssh.mdx +++ b/docs/site/content/docs/ssh.mdx @@ -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+ `). 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+ `. 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]/`, 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 diff --git a/docs/site/content/docs/troubleshooting.mdx b/docs/site/content/docs/troubleshooting.mdx index 5fb96c33fd9..d1b2c0167ba 100644 --- a/docs/site/content/docs/troubleshooting.mdx +++ b/docs/site/content/docs/troubleshooting.mdx @@ -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 diff --git a/src/main/external-editor-launch-process-host.test.ts b/src/main/external-editor-launch-process-host.test.ts index d0cbf2430da..fac021e22e8 100644 --- a/src/main/external-editor-launch-process-host.test.ts +++ b/src/main/external-editor-launch-process-host.test.ts @@ -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 } diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index df1cc80b1bf..c541ad5bef6 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -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[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' }) } ) }) diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index 5859587cbef..e4212f05224 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -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 { diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index d54fc9768ac..31ed3074247 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -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()) diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 5620184553b..1e3126ed2d6 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -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' } diff --git a/src/main/remote-ssh-editor-launch.ts b/src/main/remote-ssh-editor-launch.ts new file mode 100644 index 00000000000..fd1a3324b0b --- /dev/null +++ b/src/main/remote-ssh-editor-launch.ts @@ -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 { + 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 } + } +} diff --git a/src/main/ssh/config/vscode-ssh-authority.ts b/src/main/ssh/config/vscode-ssh-authority.ts index 85bc7c03fab..fcdc5a58a0c 100644 --- a/src/main/ssh/config/vscode-ssh-authority.ts +++ b/src/main/ssh/config/vscode-ssh-authority.ts @@ -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) => { diff --git a/src/main/ssh/config/zed-ssh-url.test.ts b/src/main/ssh/config/zed-ssh-url.test.ts new file mode 100644 index 00000000000..4be131c5727 --- /dev/null +++ b/src/main/ssh/config/zed-ssh-url.test.ts @@ -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 { + 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' + }) + }) +}) diff --git a/src/main/ssh/config/zed-ssh-url.ts b/src/main/ssh/config/zed-ssh-url.ts new file mode 100644 index 00000000000..006d0fff8c5 --- /dev/null +++ b/src/main/ssh/config/zed-ssh-url.ts @@ -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 + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx index 2269471abde..987da899521 100644 --- a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx @@ -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 () => { diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx index 75b13a579e1..a2f0beec172 100644 --- a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx @@ -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', diff --git a/src/renderer/src/components/sidebar/worktree-open-failure-toast.ts b/src/renderer/src/components/sidebar/worktree-open-failure-toast.ts index 0b1881530c8..ac65d54195b 100644 --- a/src/renderer/src/components/sidebar/worktree-open-failure-toast.ts +++ b/src/renderer/src/components/sidebar/worktree-open-failure-toast.ts @@ -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.' ) } ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fb83955a361..0773f1d6fbd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cc3717cdb4a..907a665a76b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ad2263329ce..9842721f5f8 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1c43074d0e9..566e9ab85b2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 37c8a2e6434..d9f111cb59e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "읽지 않음:", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index efceda084f8..98ce14fcf30 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 55d7e9e295f..b5cc35fce64 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/external-editor-open-capability.test.ts b/src/renderer/src/lib/external-editor-open-capability.test.ts index c3600bdd213..c758169594a 100644 --- a/src/renderer/src/lib/external-editor-open-capability.test.ts +++ b/src/renderer/src/lib/external-editor-open-capability.test.ts @@ -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 }) diff --git a/src/renderer/src/lib/external-editor-open-capability.ts b/src/renderer/src/lib/external-editor-open-capability.ts index 2522ca924da..158cb4bf6e4 100644 --- a/src/renderer/src/lib/external-editor-open-capability.ts +++ b/src/renderer/src/lib/external-editor-open-capability.ts @@ -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' } } diff --git a/src/shared/remote-ssh-editor-launcher.test.ts b/src/shared/remote-ssh-editor-launcher.test.ts new file mode 100644 index 00000000000..19da2068778 --- /dev/null +++ b/src/shared/remote-ssh-editor-launcher.test.ts @@ -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() + }) +}) diff --git a/src/shared/remote-ssh-editor-launcher.ts b/src/shared/remote-ssh-editor-launcher.ts new file mode 100644 index 00000000000..6850872e715 --- /dev/null +++ b/src/shared/remote-ssh-editor-launcher.ts @@ -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+`; 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 +} diff --git a/src/shared/vscode-remote-ssh-launcher.test.ts b/src/shared/vscode-remote-ssh-launcher.test.ts deleted file mode 100644 index 1a57bfa1c70..00000000000 --- a/src/shared/vscode-remote-ssh-launcher.test.ts +++ /dev/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) - }) -}) diff --git a/src/shared/vscode-remote-ssh-launcher.ts b/src/shared/vscode-remote-ssh-launcher.ts deleted file mode 100644 index c4269e60c3a..00000000000 --- a/src/shared/vscode-remote-ssh-launcher.ts +++ /dev/null @@ -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) -}