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, diff --git a/src/browser/components/Settings/sections/GeneralSection.tsx b/src/browser/components/Settings/sections/GeneralSection.tsx index 3fc44659b3..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, @@ -23,12 +24,28 @@ 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 { 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 })); @@ -38,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 (
@@ -82,17 +108,43 @@ export function GeneralSection() {
{editorConfig.editor === "custom" && ( +
+
+
+
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" + /> +
+ {isBrowserMode && ( +
+ Custom editors are not supported in browser mode. Use VS Code or Cursor instead. +
+ )} +
+ )} + + {isBrowserMode && sshHostLoaded && (
-
Custom Command
-
Command to run (path will be appended)
+
SSH Host
+
+ SSH hostname for 'Open in Editor' deep links +
) => - handleCustomCommandChange(e.target.value) + handleSshHostChange(e.target.value) } - placeholder="e.g., nvim" + placeholder={window.location.hostname} className="border-border-medium bg-background-secondary h-9 w-40" />
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..85cfdf0ddc 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -440,6 +440,14 @@ export const server = { input: z.void(), output: z.string().nullable(), }, + getSshHost: { + input: z.void(), + output: z.string().nullable(), + }, + setSshHost: { + input: z.object({ sshHost: z.string().nullable() }), + output: z.void(), + }, }; // 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..b09501ee9c 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -49,6 +49,24 @@ 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; + }), + 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 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. *