From ed2ccc829e181d19d46d41d8ea2aef303cb6cb7c Mon Sep 17 00:00:00 2001 From: Kunping Wang <146500293+wang-kaopu@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:32:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E5=9C=A8=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=89=93=E5=BC=80=20SSH=20=E8=BF=9C=E7=A8=8B=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/api.ts | 55 +++++++++++++++-- tests/open-external-client.spec.ts | 98 ++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 tests/open-external-client.spec.ts diff --git a/src/client/api.ts b/src/client/api.ts index 8958f63..206fafc 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -216,6 +216,51 @@ function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Re return scopePayload(scope, { ...(worktree !== undefined && worktree !== '' ? { worktree } : {}), ...extra }) } +/** One external-open request from the file tree. */ +type OpenExternalPayload = + | { action: 'reveal'; path: string } + | { action: 'url'; url: string } + +/** The host route's success shape. */ +type OpenExternalResult = { started: boolean } + +/** + * Remote VSCode-family URLs must be consumed on the browser/client machine: + * the DSH host can be a headless remote server with no editor or DISPLAY. + * Local editor URLs and reveal actions still belong to the host opener. + */ +function shouldOpenExternalOnClient(payload: OpenExternalPayload): payload is { action: 'url'; url: string } { + if (payload.action !== 'url') return false + let parsed: URL + try { + parsed = new URL(payload.url) + } catch { + return false + } + return parsed.protocol !== 'http:' + && parsed.protocol !== 'https:' + && parsed.hostname === 'vscode-remote' + && parsed.pathname.startsWith('/ssh-remote+') +} + +/** + * Dispatch an external-open request to the correct machine. SSH remote-editor + * URLs stay in the synchronous user-click chain and navigate the client so + * its registered vscode:// / cursor:// handler can launch. Everything else + * keeps using the DSH host route. + */ +function openExternal(payload: OpenExternalPayload): Promise { + if (!shouldOpenExternalOnClient(payload)) { + return call('open.external', payload) + } + try { + window.location.assign(payload.url) + return Promise.resolve({ started: true }) + } catch (error) { + return Promise.reject(error) + } +} + /** The sidebar API surface (session scope threaded through every call). */ export const api = { sessionCwd: (scope: SessionScope, signal?: AbortSignal) => @@ -350,12 +395,10 @@ export const api = { * check; see the host's browser.probe route). */ browserProbe: (url: string, signal?: AbortSignal) => call('browser.probe', { url }, signal), - /** External open for the file tree's "open with" menu: reveal a path in - * the OS file manager, or hand a custom-scheme URL (vscode://, cursor://, - * zed://, custom editors) to its registered handler. The host launches - * the platform opener (argv, no shell). */ - openExternal: (payload: { action: 'reveal'; path: string } | { action: 'url'; url: string }) => - call<{ started: boolean }>('open.external', payload), + /** External open for the file tree's "open with" menu. Remote SSH editor + * URLs are launched on the browser/client machine; reveal and local URLs + * keep using the host's platform opener. */ + openExternal, } /** Absolute URL of the media route for one path (images only). */ diff --git a/tests/open-external-client.spec.ts b/tests/open-external-client.spec.ts new file mode 100644 index 0000000..714233f --- /dev/null +++ b/tests/open-external-client.spec.ts @@ -0,0 +1,98 @@ +/** + * Client/host routing for the file tree's external-open action. Remote + * VSCode-family SSH URLs must launch on the browser machine, while local + * editor URLs and reveal actions keep using the DSH host opener. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { api } from '../src/client/api.ts' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function hostOk(): ReturnType { + return vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true, value: { started: true } }), + })) +} + +describe('api.openExternal', () => { + it('launches an SSH remote-editor URL on the client without calling the host', async () => { + const assign = vi.fn() + const fetchMock = hostOk() + vi.stubGlobal('window', { location: { assign } }) + vi.stubGlobal('fetch', fetchMock) + const url = 'vscode://vscode-remote/ssh-remote+dev/home/u/f.ts' + + await expect(api.openExternal({ action: 'url', url })).resolves.toEqual({ started: true }) + expect(assign).toHaveBeenCalledOnce() + expect(assign).toHaveBeenCalledWith(url) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('launches Cursor/custom VSCode-family SSH URLs on the client too', async () => { + const assign = vi.fn() + const fetchMock = hostOk() + vi.stubGlobal('window', { location: { assign } }) + vi.stubGlobal('fetch', fetchMock) + + await api.openExternal({ + action: 'url', + url: 'cursor://vscode-remote/ssh-remote+dev/home/u/f.ts', + }) + await api.openExternal({ + action: 'url', + url: 'myfork://vscode-remote/ssh-remote+dev/home/u/f.ts', + }) + + expect(assign).toHaveBeenCalledTimes(2) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('keeps local editor URLs on the host opener', async () => { + const assign = vi.fn() + const fetchMock = hostOk() + vi.stubGlobal('window', { location: { assign } }) + vi.stubGlobal('fetch', fetchMock) + + await expect(api.openExternal({ + action: 'url', + url: 'vscode://file//tmp/a.ts', + })).resolves.toEqual({ started: true }) + + expect(assign).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls[0]?.[0]).toBe('/sidebar/api/open.external') + }) + + it('keeps reveal actions and http(s) lookalikes on the host opener', async () => { + const assign = vi.fn() + const fetchMock = hostOk() + vi.stubGlobal('window', { location: { assign } }) + vi.stubGlobal('fetch', fetchMock) + + await api.openExternal({ action: 'reveal', path: '/tmp/a.ts' }) + await api.openExternal({ + action: 'url', + url: 'https://vscode-remote/ssh-remote+dev/home/u/f.ts', + }) + + expect(assign).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('returns a rejected promise when client navigation throws', async () => { + const error = new Error('navigation failed') + const fetchMock = hostOk() + vi.stubGlobal('window', { location: { assign: vi.fn(() => { throw error }) } }) + vi.stubGlobal('fetch', fetchMock) + + await expect(api.openExternal({ + action: 'url', + url: 'vscode://vscode-remote/ssh-remote+dev/home/u/f.ts', + })).rejects.toBe(error) + expect(fetchMock).not.toHaveBeenCalled() + }) +})