From 297e0a2180401d8b873746b82b9ab8559896cba8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:52:57 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20editor=20deep?= =?UTF-8?q?=20links=20for=20browser=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running `mux server` and accessing via browser, 'Open in Editor' now uses deep link URLs (vscode://, cursor://) instead of trying to spawn an editor process on the server machine. Configuration options for SSH hostname (in order of precedence): - CLI: `mux server --ssh-host devbox` - Environment: `MUX_SSH_HOST=devbox mux server` - Config file: `serverSshHost` in ~/.mux/config.json Scenarios handled: - Local server (localhost): Uses local file deep links - Remote server with local workspace: Uses SSH remote deep links - Remote server with SSH workspace: Uses workspace's SSH host Also adds a warning in Settings when custom editor is selected in browser mode, since custom editors can't work via deep links. _Generated with `mux`_ --- .../Settings/sections/GeneralSection.tsx | 34 +++-- src/browser/hooks/useOpenInEditor.ts | 54 +++++++- src/browser/utils/editorDeepLinks.test.ts | 130 ++++++++++++++++++ src/browser/utils/editorDeepLinks.ts | 63 +++++++++ src/cli/server.ts | 7 + src/common/orpc/schemas/api.ts | 4 + src/common/types/project.ts | 2 + src/node/config.ts | 16 ++- src/node/orpc/router.ts | 6 + src/node/services/serverService.ts | 15 ++ 10 files changed, 316 insertions(+), 15 deletions(-) create mode 100644 src/browser/utils/editorDeepLinks.test.ts create mode 100644 src/browser/utils/editorDeepLinks.ts diff --git a/src/browser/components/Settings/sections/GeneralSection.tsx b/src/browser/components/Settings/sections/GeneralSection.tsx index 3fc44659b3..b56b02a5bf 100644 --- a/src/browser/components/Settings/sections/GeneralSection.tsx +++ b/src/browser/components/Settings/sections/GeneralSection.tsx @@ -23,6 +23,9 @@ const EDITOR_OPTIONS: Array<{ value: EditorType; label: string }> = [ { value: "custom", label: "Custom" }, ]; +// Browser mode: window.api is not set (only exists in Electron via preload) +const isBrowserMode = typeof window !== "undefined" && !window.api; + export function GeneralSection() { const { theme, setTheme } = useTheme(); const [editorConfig, setEditorConfig] = usePersistedState( @@ -82,19 +85,26 @@ export function GeneralSection() { {editorConfig.editor === "custom" && ( -
-
-
Custom Command
-
Command to run (path will be appended)
+
+
+
+
Custom Command
+
Command to run (path will be appended)
+
+ ) => + handleCustomCommandChange(e.target.value) + } + placeholder="e.g., nvim" + className="border-border-medium bg-background-secondary h-9 w-40" + />
- ) => - handleCustomCommandChange(e.target.value) - } - placeholder="e.g., nvim" - className="border-border-medium bg-background-secondary h-9 w-40" - /> + {isBrowserMode && ( +
+ Custom editors are not supported in browser mode. Use VS Code or Cursor instead. +
+ )}
)}
diff --git a/src/browser/hooks/useOpenInEditor.ts b/src/browser/hooks/useOpenInEditor.ts index 652a63b714..2896b71238 100644 --- a/src/browser/hooks/useOpenInEditor.ts +++ b/src/browser/hooks/useOpenInEditor.ts @@ -9,15 +9,27 @@ import { } from "@/common/constants/storage"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; +import { + getEditorDeepLink, + isLocalhost, + type DeepLinkEditor, +} from "@/browser/utils/editorDeepLinks"; export interface OpenInEditorResult { success: boolean; error?: string; } +// Browser mode: window.api is not set (only exists in Electron via preload) +const isBrowserMode = typeof window !== "undefined" && !window.api; + /** * Hook to open a path in the user's configured code editor. * + * In Electron mode: calls the backend API to spawn the editor process. + * In browser mode: generates deep link URLs (vscode://, cursor://) that open + * the user's locally installed editor. + * * If no editor is configured, opens Settings to the General section. * For SSH workspaces with unsupported editors (Zed, custom), returns an error. * @@ -66,7 +78,47 @@ export function useOpenInEditor() { } } - // Call the backend API + // Browser mode: use deep links instead of backend spawn + if (isBrowserMode) { + // Custom editor can't work via deep links + if (editorConfig.editor === "custom") { + return { + success: false, + error: "Custom editors are not supported in browser mode. Use VS Code or Cursor.", + }; + } + + // Determine SSH host for deep link + let sshHost: string | undefined; + if (isSSH && runtimeConfig?.type === "ssh") { + // SSH workspace: use the configured SSH host + sshHost = runtimeConfig.host; + } else if (!isLocalhost(window.location.hostname)) { + // Remote server + local workspace: need SSH to reach server's files + const serverSshHost = await api?.server.getSshHost(); + sshHost = serverSshHost ?? window.location.hostname; + } + // else: localhost access to local workspace → no SSH needed + + const deepLink = getEditorDeepLink({ + editor: editorConfig.editor as DeepLinkEditor, + path: targetPath, + sshHost, + }); + + if (!deepLink) { + return { + success: false, + error: `${editorConfig.editor} does not support SSH remote connections`, + }; + } + + // Open deep link (browser will handle protocol and launch editor) + window.open(deepLink, "_blank"); + return { success: true }; + } + + // Electron mode: call the backend API const result = await api?.general.openInEditor({ workspaceId, targetPath, diff --git a/src/browser/utils/editorDeepLinks.test.ts b/src/browser/utils/editorDeepLinks.test.ts new file mode 100644 index 0000000000..d2a9b168c4 --- /dev/null +++ b/src/browser/utils/editorDeepLinks.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { getEditorDeepLink, isLocalhost } from "./editorDeepLinks"; + +describe("getEditorDeepLink", () => { + describe("local paths", () => { + test("generates vscode:// URL for local path", () => { + const url = getEditorDeepLink({ + editor: "vscode", + path: "/home/user/project/file.ts", + }); + expect(url).toBe("vscode://file/home/user/project/file.ts"); + }); + + test("generates cursor:// URL for local path", () => { + const url = getEditorDeepLink({ + editor: "cursor", + path: "/home/user/project/file.ts", + }); + expect(url).toBe("cursor://file/home/user/project/file.ts"); + }); + + test("generates zed:// URL for local path", () => { + const url = getEditorDeepLink({ + editor: "zed", + path: "/home/user/project/file.ts", + }); + expect(url).toBe("zed://file/home/user/project/file.ts"); + }); + + test("includes line number in local path", () => { + const url = getEditorDeepLink({ + editor: "vscode", + path: "/home/user/project/file.ts", + line: 42, + }); + expect(url).toBe("vscode://file/home/user/project/file.ts:42"); + }); + + test("includes line and column in local path", () => { + const url = getEditorDeepLink({ + editor: "cursor", + path: "/home/user/project/file.ts", + line: 42, + column: 10, + }); + expect(url).toBe("cursor://file/home/user/project/file.ts:42:10"); + }); + }); + + describe("SSH remote paths", () => { + test("generates vscode-remote URL for SSH host", () => { + const url = getEditorDeepLink({ + editor: "vscode", + path: "/home/user/project/file.ts", + sshHost: "devbox", + }); + expect(url).toBe("vscode://vscode-remote/ssh-remote+devbox/home/user/project/file.ts"); + }); + + test("generates cursor-remote URL for SSH host", () => { + const url = getEditorDeepLink({ + editor: "cursor", + path: "/home/user/project/file.ts", + sshHost: "devbox", + }); + expect(url).toBe("cursor://vscode-remote/ssh-remote+devbox/home/user/project/file.ts"); + }); + + test("returns null for zed with SSH host (unsupported)", () => { + const url = getEditorDeepLink({ + editor: "zed", + path: "/home/user/project/file.ts", + sshHost: "devbox", + }); + expect(url).toBeNull(); + }); + + test("encodes SSH host with special characters", () => { + const url = getEditorDeepLink({ + editor: "vscode", + path: "/home/user/project/file.ts", + sshHost: "user@host.example.com", + }); + expect(url).toBe( + "vscode://vscode-remote/ssh-remote+user%40host.example.com/home/user/project/file.ts" + ); + }); + + test("includes line number in SSH remote path", () => { + const url = getEditorDeepLink({ + editor: "vscode", + path: "/home/user/project/file.ts", + sshHost: "devbox", + line: 42, + }); + expect(url).toBe("vscode://vscode-remote/ssh-remote+devbox/home/user/project/file.ts:42"); + }); + + test("includes line and column in SSH remote path", () => { + const url = getEditorDeepLink({ + editor: "cursor", + path: "/home/user/project/file.ts", + sshHost: "devbox", + line: 42, + column: 10, + }); + expect(url).toBe("cursor://vscode-remote/ssh-remote+devbox/home/user/project/file.ts:42:10"); + }); + }); +}); + +describe("isLocalhost", () => { + test("returns true for localhost", () => { + expect(isLocalhost("localhost")).toBe(true); + }); + + test("returns true for 127.0.0.1", () => { + expect(isLocalhost("127.0.0.1")).toBe(true); + }); + + test("returns true for ::1", () => { + expect(isLocalhost("::1")).toBe(true); + }); + + test("returns false for other hostnames", () => { + expect(isLocalhost("devbox")).toBe(false); + expect(isLocalhost("192.168.1.1")).toBe(false); + expect(isLocalhost("example.com")).toBe(false); + }); +}); diff --git a/src/browser/utils/editorDeepLinks.ts b/src/browser/utils/editorDeepLinks.ts new file mode 100644 index 0000000000..7559ddb6e7 --- /dev/null +++ b/src/browser/utils/editorDeepLinks.ts @@ -0,0 +1,63 @@ +/** + * Editor deep link URL generation for browser mode. + * + * When running `mux server` and accessing via browser, we can't spawn editor + * processes on the server. Instead, we generate deep link URLs that the browser + * opens, triggering the user's locally installed editor. + */ + +export type DeepLinkEditor = "vscode" | "cursor" | "zed"; + +export interface DeepLinkOptions { + editor: DeepLinkEditor; + path: string; + sshHost?: string; // For SSH/remote workspaces + line?: number; + column?: number; +} + +/** + * Generate an editor deep link URL. + * + * @returns Deep link URL, or null if the editor doesn't support the requested config + * (e.g., Zed doesn't support SSH remote) + */ +export function getEditorDeepLink(options: DeepLinkOptions): string | null { + const { editor, path, sshHost, line, column } = options; + + // Zed doesn't support Remote-SSH + if (sshHost && editor === "zed") { + return null; + } + + const scheme = editor; // vscode, cursor, zed all use their name as scheme + + if (sshHost) { + // Remote-SSH format: vscode://vscode-remote/ssh-remote+host/path + let url = `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(sshHost)}${path}`; + if (line != null) { + url += `:${line}`; + if (column != null) { + url += `:${column}`; + } + } + return url; + } + + // Local format: vscode://file/path + let url = `${scheme}://file${path}`; + if (line != null) { + url += `:${line}`; + if (column != null) { + url += `:${column}`; + } + } + return url; +} + +/** + * Check if a hostname represents localhost. + */ +export function isLocalhost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} diff --git a/src/cli/server.ts b/src/cli/server.ts index 01677bbd9a..90ac44a5a2 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -19,6 +19,7 @@ program .option("-h, --host ", "bind to specific host", "localhost") .option("-p, --port ", "bind to specific port", "3000") .option("--auth-token ", "optional bearer token for HTTP/WS auth") + .option("--ssh-host ", "SSH hostname/alias for editor deep links (e.g., devbox)") .option("--add-project ", "add and open project at the specified path (idempotent)") .parse(process.argv); @@ -28,6 +29,8 @@ const PORT = Number.parseInt(String(options.port), 10); const rawAuthToken = (options.authToken as string | undefined) ?? process.env.MUX_SERVER_AUTH_TOKEN; const AUTH_TOKEN = rawAuthToken?.trim() ? rawAuthToken.trim() : undefined; const ADD_PROJECT_PATH = options.addProject as string | undefined; +// SSH host for editor deep links (CLI flag > env var > config file, resolved later) +const CLI_SSH_HOST = options.sshHost as string | undefined; // Track the launch project path for initial navigation let launchProjectPath: string | null = null; @@ -66,6 +69,10 @@ const mockWindow: BrowserWindow = { // Set launch project path for clients serviceContainer.serverService.setLaunchProject(launchProjectPath); + // Set SSH host for editor deep links (CLI > env > config file) + const sshHost = CLI_SSH_HOST ?? process.env.MUX_SSH_HOST ?? config.getServerSshHost(); + serviceContainer.serverService.setSshHost(sshHost); + // Build oRPC context from services const context: ORPCContext = { config: serviceContainer.config, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 445dda6eb5..44d62a4a64 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -440,6 +440,10 @@ export const server = { input: z.void(), output: z.string().nullable(), }, + getSshHost: { + input: z.void(), + output: z.string().nullable(), + }, }; // Update diff --git a/src/common/types/project.ts b/src/common/types/project.ts index a38c63b335..32c89edd4a 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -12,4 +12,6 @@ export type ProjectConfig = z.infer; export interface ProjectsConfig { projects: Map; + /** SSH hostname/alias for this machine (used for editor deep links in browser mode) */ + serverSshHost?: string; } diff --git a/src/node/config.ts b/src/node/config.ts index 930a6352b4..1fe5743b9a 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -52,7 +52,7 @@ export class Config { try { if (fs.existsSync(this.configFile)) { const data = fs.readFileSync(this.configFile, "utf-8"); - const parsed = JSON.parse(data) as { projects?: unknown }; + const parsed = JSON.parse(data) as { projects?: unknown; serverSshHost?: string }; // Config is stored as array of [path, config] pairs if (parsed.projects && Array.isArray(parsed.projects)) { @@ -61,6 +61,7 @@ export class Config { ); return { projects: projectsMap, + serverSshHost: parsed.serverSshHost, }; } } @@ -80,9 +81,12 @@ export class Config { fs.mkdirSync(this.rootDir, { recursive: true }); } - const data = { + const data: { projects: Array<[string, ProjectConfig]>; serverSshHost?: string } = { projects: Array.from(config.projects.entries()), }; + if (config.serverSshHost) { + data.serverSshHost = config.serverSshHost; + } await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8"); } catch (error) { @@ -100,6 +104,14 @@ export class Config { await this.saveConfig(newConfig); } + /** + * Get the configured SSH hostname for this server (used for editor deep links in browser mode). + */ + getServerSshHost(): string | undefined { + const config = this.loadConfigOrDefault(); + return config.serverSshHost; + } + private getProjectName(projectPath: string): string { return PlatformPaths.getProjectName(projectPath); } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c07a506a7c..67c8561ea7 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -49,6 +49,12 @@ export const router = (authToken?: string) => { .handler(async ({ context }) => { return context.serverService.getLaunchProject(); }), + getSshHost: t + .input(schemas.server.getSshHost.input) + .output(schemas.server.getSshHost.output) + .handler(({ context }) => { + return context.serverService.getSshHost() ?? null; + }), }, providers: { list: t diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts index d44dbf3f6c..d340bafc8c 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -28,6 +28,7 @@ export class ServerService { private server: OrpcServer | null = null; private lockfile: ServerLockfile | null = null; private serverInfo: ServerInfo | null = null; + private sshHost: string | undefined = undefined; /** * Set the launch project path @@ -43,6 +44,20 @@ export class ServerService { return Promise.resolve(this.launchProjectPath); } + /** + * Set the SSH hostname for editor deep links (browser mode) + */ + setSshHost(host: string | undefined): void { + this.sshHost = host; + } + + /** + * Get the SSH hostname for editor deep links (browser mode) + */ + getSshHost(): string | undefined { + return this.sshHost; + } + /** * Start the HTTP/WS API server. * From 6eda37ee7da194debe2875b5d23306781ee1d7de Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:09:29 +0000 Subject: [PATCH 2/3] feat: add SSH Host setting in browser mode Settings UI Adds server.setSshHost() API endpoint and SSH Host input field in Settings that appears only in browser mode. Users can now configure the SSH hostname used for editor deep links directly from the Settings UI. --- .../Settings/sections/GeneralSection.tsx | 44 ++++++++++++++++++- src/common/orpc/schemas/api.ts | 4 ++ src/node/orpc/router.ts | 12 +++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/browser/components/Settings/sections/GeneralSection.tsx b/src/browser/components/Settings/sections/GeneralSection.tsx index b56b02a5bf..fcf6be82b4 100644 --- a/src/browser/components/Settings/sections/GeneralSection.tsx +++ b/src/browser/components/Settings/sections/GeneralSection.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { useTheme, THEME_OPTIONS, type ThemeMode } from "@/browser/contexts/ThemeContext"; import { Select, @@ -9,6 +9,7 @@ import { } from "@/browser/components/ui/select"; import { Input } from "@/browser/components/ui/input"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { useAPI } from "@/browser/contexts/API"; import { EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG, @@ -28,10 +29,23 @@ const isBrowserMode = typeof window !== "undefined" && !window.api; export function GeneralSection() { const { theme, setTheme } = useTheme(); + const { api } = useAPI(); const [editorConfig, setEditorConfig] = usePersistedState( EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG ); + const [sshHost, setSshHost] = useState(""); + const [sshHostLoaded, setSshHostLoaded] = useState(false); + + // Load SSH host from server on mount (browser mode only) + useEffect(() => { + if (isBrowserMode && api) { + void api.server.getSshHost().then((host) => { + setSshHost(host ?? ""); + setSshHostLoaded(true); + }); + } + }, [api]); const handleEditorChange = (editor: EditorType) => { setEditorConfig((prev) => ({ ...prev, editor })); @@ -41,6 +55,15 @@ export function GeneralSection() { setEditorConfig((prev) => ({ ...prev, customCommand })); }; + const handleSshHostChange = useCallback( + (value: string) => { + setSshHost(value); + // Save to server (debounced effect would be better, but keeping it simple) + void api?.server.setSshHost({ sshHost: value || null }); + }, + [api] + ); + return (
@@ -107,6 +130,25 @@ export function GeneralSection() { )}
)} + + {isBrowserMode && sshHostLoaded && ( +
+
+
SSH Host
+
+ SSH hostname for 'Open in Editor' deep links +
+
+ ) => + handleSshHostChange(e.target.value) + } + placeholder={window.location.hostname} + className="border-border-medium bg-background-secondary h-9 w-40" + /> +
+ )}
); } diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 44d62a4a64..85cfdf0ddc 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -444,6 +444,10 @@ export const server = { input: z.void(), output: z.string().nullable(), }, + setSshHost: { + input: z.object({ sshHost: z.string().nullable() }), + output: z.void(), + }, }; // Update diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 67c8561ea7..b09501ee9c 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -55,6 +55,18 @@ export const router = (authToken?: string) => { .handler(({ context }) => { return context.serverService.getSshHost() ?? null; }), + setSshHost: t + .input(schemas.server.setSshHost.input) + .output(schemas.server.setSshHost.output) + .handler(async ({ context, input }) => { + // Update in-memory value + context.serverService.setSshHost(input.sshHost ?? undefined); + // Persist to config file + await context.config.editConfig((config) => ({ + ...config, + serverSshHost: input.sshHost ?? undefined, + })); + }), }, providers: { list: t From 453be48546b8970ffff981a56f5e4ae3a4c8bb48 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:21:44 +0000 Subject: [PATCH 3/3] fix: add getSshHost/setSshHost mocks for storybook --- .storybook/mocks/orpc.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index a5184edd3f..d7f2eb0340 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -91,6 +91,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, server: { getLaunchProject: async () => null, + getSshHost: async () => null, + setSshHost: async () => undefined, }, providers: { list: async () => providersList,