authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-10 23:39:41-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:39:41-03:00
log20ef25037f455a570426ac2a93fcf499d1f91167
treebfb6606e8eb27f6b6bb77aa7a3f0fc21c4421a0a
parent5735693d4acb949c2b0daf1219b4f62845115435
signature Signed by PGP key B5690EEEBB952194

fix(editors): accept root paths and Windows servers in Zed remote links (#11044)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 31 insertions(+), 9 deletions(-)

apps/desktop/src/electron/ElectronShell.test.ts+9-3
...@@ -93,10 +93,16 @@ describe("ElectronShell", () => {...@@ -93,10 +93,16 @@ describe("ElectronShell", () => {
93 openExternalMock.mockResolvedValue(undefined);93 openExternalMock.mockResolvedValue(undefined);
9494
95 const electronShell = yield* ElectronShell.ElectronShell;95 const electronShell = yield* ElectronShell.ElectronShell;
96 const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project");96 const results = yield* Effect.all([
97 electronShell.openExternal("zed://ssh/example.com/home/user/project"),
98 electronShell.openExternal("zed://ssh/example.com/"),
99 ]);
97100
98 assert.equal(result, true);101 assert.deepEqual(results, [true, true]);
99 assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]);102 assert.deepEqual(openExternalMock.mock.calls, [
103 ["zed://ssh/example.com/home/user/project"],
104 ["zed://ssh/example.com/"],
105 ]);
100 }).pipe(Effect.provide(ElectronShell.layer)),106 }).pipe(Effect.provide(ElectronShell.layer)),
101 );107 );
102108
apps/desktop/src/electron/ElectronShell.ts+1-1
...@@ -36,7 +36,7 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(...@@ -36,7 +36,7 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
36);36);
3737
38// Zed's host sits in the first path segment, so it needs its own userinfo ban.38// Zed's host sits in the first path segment, so it needs its own userinfo ban.
39const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/;39const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/;
4040
41const isRemoteEditorUrl = (url: URL) =>41const isRemoteEditorUrl = (url: URL) =>
42 REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&42 REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
apps/web/src/remoteOpen.test.ts+9
...@@ -151,6 +151,15 @@ describe("buildRemoteOpenUrl", () => {...@@ -151,6 +151,15 @@ describe("buildRemoteOpenUrl", () => {
151 ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo");151 ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo");
152 });152 });
153153
154 it("drops the Windows drive letter for Zed", () => {
155 expect(
156 buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "C:\\Users\\theo" }),
157 ).toBe("zed://ssh/sol/Users/theo");
158 expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/C:/project" })).toBe(
159 "zed://ssh/sol/C%3A/project",
160 );
161 });
162
154 it("returns undefined for editors without remote support", () => {163 it("returns undefined for editors without remote support", () => {
155 expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe(164 expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe(
156 undefined,165 undefined,
packages/contracts/src/editor.ts+12-5
...@@ -91,7 +91,7 @@ export type LaunchEditorInput = typeof LaunchEditorInput.Type;...@@ -91,7 +91,7 @@ export type LaunchEditorInput = typeof LaunchEditorInput.Type;
9191
92const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme;92const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme;
9393
94/** Editors that can open a remote workspace via `vscode-remote` deep links. */94/** Editors that can open a remote workspace via an SSH deep link. */
95export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) =>95export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) =>
96 remoteSchemeOf(editor) !== undefined ? [editor.id] : [],96 remoteSchemeOf(editor) !== undefined ? [editor.id] : [],
97);97);
...@@ -119,11 +119,18 @@ export const buildRemoteOpenUrl = (input: {...@@ -119,11 +119,18 @@ export const buildRemoteOpenUrl = (input: {
119 // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs.119 // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs.
120 const posixPath = input.absolutePath.replaceAll("\\", "/");120 const posixPath = input.absolutePath.replaceAll("\\", "/");
121 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;121 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
122 const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/");
123 const encodedHost = encodeURIComponent(input.host);122 const encodedHost = encodeURIComponent(input.host);
124 return input.editor === "zed"123 if (input.editor === "zed") {
125 ? `${scheme}://ssh/${encodedHost}${encodedPath}`124 // Zed's remote server resolves a rooted path on the system drive, so a
126 : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`;125 // Windows `C:\Users\x` must become `/Users/x` (verified in #8938). Other
126 // drives are untested and kept as is rather than silently remapped, and a
127 // POSIX path that happens to start with `/C:` is left alone.
128 const zedPath = /^[Cc]:[\\/]/.test(input.absolutePath) ? rootedPath.slice(3) : rootedPath;
129 const encodedZedPath = zedPath.split("/").map(encodeURIComponent).join("/");
130 return `${scheme}://ssh/${encodedHost}${encodedZedPath}`;
131 }
132 const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/");
133 return `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`;
127};134};
128135
129/**136/**