From cb4bd66ff2cfd67ce122890a81796f880ba810e9 Mon Sep 17 00:00:00 2001 From: Kyle Kincer Date: Tue, 8 Sep 2026 15:19:43 -0400 Subject: [PATCH] fix(web): open remote files correctly in VS Code editors FilePreviewPanel passes a selected file to the shared picker. Preserve file or folder intent and add the line suffix VS Code and its forks require for remote file URLs. --- .../src/electron/ElectronShell.test.ts | 17 +++--- apps/web/src/components/chat/ChatHeader.tsx | 3 +- apps/web/src/components/chat/OpenInPicker.tsx | 22 ++++---- .../src/components/files/FilePreviewPanel.tsx | 3 +- apps/web/src/remoteOpen.test.ts | 53 ++++++++++++++++--- packages/contracts/src/editor.ts | 5 +- 6 files changed, 77 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index caaa39d88c0d..43a9f59531d9 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -77,14 +77,19 @@ describe("ElectronShell", () => { openExternalMock.mockResolvedValue(undefined); const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal( + const urls = [ "vscode://vscode-remote/ssh-remote+example.com/home/user/project", + "vscode://vscode-remote/ssh-remote+example.com/home/user/my%20file%20%231.json:1", + ]; + for (const url of urls) { + const result = yield* electronShell.openExternal(url); + + assert.equal(result, true); + } + assert.deepEqual( + openExternalMock.mock.calls, + urls.map((url) => [url]), ); - - assert.equal(result, true); - assert.deepEqual(openExternalMock.mock.calls, [ - ["vscode://vscode-remote/ssh-remote+example.com/home/user/project"], - ]); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index fbebc323a950..dce3a155c862 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -426,7 +426,8 @@ export const ChatHeader = memo(function ChatHeader({ environmentId={activeThreadEnvironmentId} keybindings={keybindings} availableEditors={availableEditors} - openInCwd={openInCwd} + openInPath={openInCwd} + pathKind="folder" /> )} {activeProjectName && ( diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 9f8e81bdba67..3997677f1746 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -177,14 +177,16 @@ export const OpenInPicker = memo(function OpenInPicker({ environmentId, keybindings, availableEditors, - openInCwd, + openInPath, + pathKind, compact = false, enableShortcut = true, }: { environmentId: EnvironmentId; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; - openInCwd: string | null; + openInPath: string | null; + pathKind: "file" | "folder"; compact?: boolean; enableShortcut?: boolean; }) { @@ -205,7 +207,7 @@ export const OpenInPicker = memo(function OpenInPicker({ const openInEditor = useCallback( (editorId: EditorId | null) => { - if (!openInCwd) return; + if (!openInPath) return; const editor = editorId ?? preferredEditor; if (!editor) return; if (remote.mode === "remote-unavailable") return; @@ -213,7 +215,8 @@ export const OpenInPicker = memo(function OpenInPicker({ const url = buildRemoteOpenUrl({ editor, host: remote.host.host, - absolutePath: openInCwd, + absolutePath: openInPath, + pathKind, }); if (url === undefined) return; // Only record hint-seen/preferred when the shell actually accepted @@ -228,7 +231,7 @@ export const OpenInPicker = memo(function OpenInPicker({ const result = openInEditorMutation({ environmentId, input: { - cwd: openInCwd, + cwd: openInPath, editor, }, }); @@ -238,8 +241,9 @@ export const OpenInPicker = memo(function OpenInPicker({ [ environmentId, markRemoteHintSeen, - openInCwd, + openInPath, openInEditorMutation, + pathKind, preferredEditor, remote, setPreferredEditor, @@ -255,7 +259,7 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!enableShortcut) return; const handler = (e: globalThis.KeyboardEvent) => { if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!openInCwd) return; + if (!openInPath) return; if (!preferredEditor) return; e.preventDefault(); @@ -263,7 +267,7 @@ export const OpenInPicker = memo(function OpenInPicker({ }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); + }, [enableShortcut, keybindings, openInPath, openInEditor, preferredEditor]); return ( @@ -272,7 +276,7 @@ export const OpenInPicker = memo(function OpenInPicker({ className="ps-[8.5px]" size="xs" variant="outline" - disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"} + disabled={!preferredEditor || !openInPath || remote.mode === "remote-unavailable"} onClick={() => openInEditor(preferredEditor)} > {primaryOption?.Icon && ( diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index b739d120da63..1bb64224e4ef 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1140,7 +1140,8 @@ export default function FilePreviewPanel({ environmentId={environmentId} keybindings={keybindings} availableEditors={availableEditors} - openInCwd={absolutePath} + openInPath={absolutePath} + pathKind="file" compact enableShortcut={false} /> diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ff78967aa3dc..069158ae0e91 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -119,31 +119,68 @@ describe("resolveRemoteOpenState", () => { }); describe("buildRemoteOpenUrl", () => { + it.each([ + ["/home/user/.local/share/app/settings.json", "/home/user/.local/share/app/settings.json"], + ["/tmp/README", "/tmp/README"], + ["/tmp/my file #1?.json", "/tmp/my%20file%20%231%3F.json"], + ["C:\\Users\\user\\settings.json", "/C%3A/Users/user/settings.json"], + ["/tmp/project.code-workspace", "/tmp/project.code-workspace"], + ])("opens %s as a remote file", (absolutePath, encodedPath) => { + expect( + buildRemoteOpenUrl({ + editor: "vscode", + host: "sol", + absolutePath, + pathKind: "file", + }), + ).toBe(`vscode://vscode-remote/ssh-remote+sol${encodedPath}:1`); + }); + it("builds a vscode-remote deep link", () => { expect( buildRemoteOpenUrl({ editor: "vscode", host: "sol.tail1234.ts.net", absolutePath: "/home/theo/code/my repo", + pathKind: "folder", }), ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo"); }); - it("uses the fork's scheme", () => { - expect(buildRemoteOpenUrl({ editor: "cursor", host: "sol", absolutePath: "/tmp/x" })).toBe( - "cursor://vscode-remote/ssh-remote+sol/tmp/x", - ); + it.each(["cursor", "vscode-insiders", "vscodium"] as const)("uses %s's scheme", (editor) => { + expect( + buildRemoteOpenUrl({ editor, host: "sol", absolutePath: "/tmp/x", pathKind: "file" }), + ).toBe(`${editor}://vscode-remote/ssh-remote+sol/tmp/x:1`); + expect( + buildRemoteOpenUrl({ editor, host: "sol", absolutePath: "/tmp/x", pathKind: "folder" }), + ).toBe(`${editor}://vscode-remote/ssh-remote+sol/tmp/x`); + }); + + it("keeps folders with file extensions as folders", () => { + expect( + buildRemoteOpenUrl({ + editor: "vscode", + host: "sol", + absolutePath: "/tmp/project.json", + pathKind: "folder", + }), + ).toBe("vscode://vscode-remote/ssh-remote+sol/tmp/project.json"); }); it("roots Windows paths", () => { expect( - buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }), + buildRemoteOpenUrl({ + editor: "vscode", + host: "sol", + absolutePath: "C:\\Users\\theo", + pathKind: "folder", + }), ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); }); it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( - undefined, - ); + expect( + buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x", pathKind: "file" }), + ).toBe(undefined); }); }); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 72efd84a14d6..b59e0e64904f 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -103,6 +103,7 @@ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; readonly host: string; readonly absolutePath: string; + readonly pathKind: "file" | "folder"; }): string | undefined => { const scheme = remoteSchemeForEditor(input.editor); if (scheme === undefined) { @@ -112,7 +113,9 @@ export const buildRemoteOpenUrl = (input: { const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; + // A :line suffix makes VS Code's remote URL handler open a file instead of a folder. + const position = input.pathKind === "file" ? ":1" : ""; + return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}${position}`; }; /**