Skip to content

Commit f6fda01

Browse files
authored
🤖 feat: add editor deep links for browser mode (#1078)
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 SSH hostname can be configured in order of precedence: | Method | Example | Best for | |--------|---------|----------| | CLI flag | `mux server --ssh-host devbox` | Ad-hoc usage | | Env var | `MUX_SSH_HOST=devbox mux server` | Containers, systemd | | Config file | `serverSshHost` in ~/.mux/config.json | Persistent default | | **Settings UI** | Settings → SSH Host (browser mode only) | Interactive configuration | ## Scenarios handled | Scenario | Deep Link Format | |----------|-----------------| | Local server (localhost) | `vscode://file/path/to/file` | | Remote server + local workspace | `vscode://vscode-remote/ssh-remote+host/path` | | Remote server + SSH workspace | Uses workspace's SSH host | ## UI Changes - **Settings now shows 'SSH Host' field in browser mode** - lets users configure the SSH hostname for deep links - Settings shows a warning when custom editor is selected in browser mode - Zed with SSH workspaces shows appropriate error (no Remote-SSH support) ## Files changed - New: `src/browser/utils/editorDeepLinks.ts` - deep link URL generator - `src/browser/hooks/useOpenInEditor.ts` - browser mode detection and deep link handling - `src/browser/components/Settings/sections/GeneralSection.tsx` - SSH Host field - `src/cli/server.ts` - added `--ssh-host` flag - `src/node/services/serverService.ts` - stores SSH host - `src/common/orpc/schemas/api.ts` + `router.ts` - exposes `server.getSshHost()` and `server.setSshHost()` - `src/common/types/project.ts` + `src/node/config.ts` - config schema update _Generated with `mux`_
1 parent 087ebb9 commit f6fda01

File tree

11 files changed

+370
-9
lines changed

11 files changed

+370
-9
lines changed

.storybook/mocks/orpc.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
9191
},
9292
server: {
9393
getLaunchProject: async () => null,
94+
getSshHost: async () => null,
95+
setSshHost: async () => undefined,
9496
},
9597
providers: {
9698
list: async () => providersList,

src/browser/components/Settings/sections/GeneralSection.tsx

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { useEffect, useState, useCallback } from "react";
22
import { useTheme, THEME_OPTIONS, type ThemeMode } from "@/browser/contexts/ThemeContext";
33
import {
44
Select,
@@ -9,6 +9,7 @@ import {
99
} from "@/browser/components/ui/select";
1010
import { Input } from "@/browser/components/ui/input";
1111
import { usePersistedState } from "@/browser/hooks/usePersistedState";
12+
import { useAPI } from "@/browser/contexts/API";
1213
import {
1314
EDITOR_CONFIG_KEY,
1415
DEFAULT_EDITOR_CONFIG,
@@ -23,12 +24,28 @@ const EDITOR_OPTIONS: Array<{ value: EditorType; label: string }> = [
2324
{ value: "custom", label: "Custom" },
2425
];
2526

27+
// Browser mode: window.api is not set (only exists in Electron via preload)
28+
const isBrowserMode = typeof window !== "undefined" && !window.api;
29+
2630
export function GeneralSection() {
2731
const { theme, setTheme } = useTheme();
32+
const { api } = useAPI();
2833
const [editorConfig, setEditorConfig] = usePersistedState<EditorConfig>(
2934
EDITOR_CONFIG_KEY,
3035
DEFAULT_EDITOR_CONFIG
3136
);
37+
const [sshHost, setSshHost] = useState<string>("");
38+
const [sshHostLoaded, setSshHostLoaded] = useState(false);
39+
40+
// Load SSH host from server on mount (browser mode only)
41+
useEffect(() => {
42+
if (isBrowserMode && api) {
43+
void api.server.getSshHost().then((host) => {
44+
setSshHost(host ?? "");
45+
setSshHostLoaded(true);
46+
});
47+
}
48+
}, [api]);
3249

3350
const handleEditorChange = (editor: EditorType) => {
3451
setEditorConfig((prev) => ({ ...prev, editor }));
@@ -38,6 +55,15 @@ export function GeneralSection() {
3855
setEditorConfig((prev) => ({ ...prev, customCommand }));
3956
};
4057

58+
const handleSshHostChange = useCallback(
59+
(value: string) => {
60+
setSshHost(value);
61+
// Save to server (debounced effect would be better, but keeping it simple)
62+
void api?.server.setSshHost({ sshHost: value || null });
63+
},
64+
[api]
65+
);
66+
4167
return (
4268
<div className="space-y-6">
4369
<div>
@@ -82,17 +108,43 @@ export function GeneralSection() {
82108
</div>
83109

84110
{editorConfig.editor === "custom" && (
111+
<div className="space-y-2">
112+
<div className="flex items-center justify-between">
113+
<div>
114+
<div className="text-foreground text-sm">Custom Command</div>
115+
<div className="text-muted text-xs">Command to run (path will be appended)</div>
116+
</div>
117+
<Input
118+
value={editorConfig.customCommand ?? ""}
119+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
120+
handleCustomCommandChange(e.target.value)
121+
}
122+
placeholder="e.g., nvim"
123+
className="border-border-medium bg-background-secondary h-9 w-40"
124+
/>
125+
</div>
126+
{isBrowserMode && (
127+
<div className="text-warning text-xs">
128+
Custom editors are not supported in browser mode. Use VS Code or Cursor instead.
129+
</div>
130+
)}
131+
</div>
132+
)}
133+
134+
{isBrowserMode && sshHostLoaded && (
85135
<div className="flex items-center justify-between">
86136
<div>
87-
<div className="text-foreground text-sm">Custom Command</div>
88-
<div className="text-muted text-xs">Command to run (path will be appended)</div>
137+
<div className="text-foreground text-sm">SSH Host</div>
138+
<div className="text-muted text-xs">
139+
SSH hostname for &apos;Open in Editor&apos; deep links
140+
</div>
89141
</div>
90142
<Input
91-
value={editorConfig.customCommand ?? ""}
143+
value={sshHost}
92144
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
93-
handleCustomCommandChange(e.target.value)
145+
handleSshHostChange(e.target.value)
94146
}
95-
placeholder="e.g., nvim"
147+
placeholder={window.location.hostname}
96148
className="border-border-medium bg-background-secondary h-9 w-40"
97149
/>
98150
</div>

src/browser/hooks/useOpenInEditor.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,27 @@ import {
99
} from "@/common/constants/storage";
1010
import type { RuntimeConfig } from "@/common/types/runtime";
1111
import { isSSHRuntime } from "@/common/types/runtime";
12+
import {
13+
getEditorDeepLink,
14+
isLocalhost,
15+
type DeepLinkEditor,
16+
} from "@/browser/utils/editorDeepLinks";
1217

1318
export interface OpenInEditorResult {
1419
success: boolean;
1520
error?: string;
1621
}
1722

23+
// Browser mode: window.api is not set (only exists in Electron via preload)
24+
const isBrowserMode = typeof window !== "undefined" && !window.api;
25+
1826
/**
1927
* Hook to open a path in the user's configured code editor.
2028
*
29+
* In Electron mode: calls the backend API to spawn the editor process.
30+
* In browser mode: generates deep link URLs (vscode://, cursor://) that open
31+
* the user's locally installed editor.
32+
*
2133
* If no editor is configured, opens Settings to the General section.
2234
* For SSH workspaces with unsupported editors (Zed, custom), returns an error.
2335
*
@@ -66,7 +78,47 @@ export function useOpenInEditor() {
6678
}
6779
}
6880

69-
// Call the backend API
81+
// Browser mode: use deep links instead of backend spawn
82+
if (isBrowserMode) {
83+
// Custom editor can't work via deep links
84+
if (editorConfig.editor === "custom") {
85+
return {
86+
success: false,
87+
error: "Custom editors are not supported in browser mode. Use VS Code or Cursor.",
88+
};
89+
}
90+
91+
// Determine SSH host for deep link
92+
let sshHost: string | undefined;
93+
if (isSSH && runtimeConfig?.type === "ssh") {
94+
// SSH workspace: use the configured SSH host
95+
sshHost = runtimeConfig.host;
96+
} else if (!isLocalhost(window.location.hostname)) {
97+
// Remote server + local workspace: need SSH to reach server's files
98+
const serverSshHost = await api?.server.getSshHost();
99+
sshHost = serverSshHost ?? window.location.hostname;
100+
}
101+
// else: localhost access to local workspace → no SSH needed
102+
103+
const deepLink = getEditorDeepLink({
104+
editor: editorConfig.editor as DeepLinkEditor,
105+
path: targetPath,
106+
sshHost,
107+
});
108+
109+
if (!deepLink) {
110+
return {
111+
success: false,
112+
error: `${editorConfig.editor} does not support SSH remote connections`,
113+
};
114+
}
115+
116+
// Open deep link (browser will handle protocol and launch editor)
117+
window.open(deepLink, "_blank");
118+
return { success: true };
119+
}
120+
121+
// Electron mode: call the backend API
70122
const result = await api?.general.openInEditor({
71123
workspaceId,
72124
targetPath,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { getEditorDeepLink, isLocalhost } from "./editorDeepLinks";
3+
4+
describe("getEditorDeepLink", () => {
5+
describe("local paths", () => {
6+
test("generates vscode:// URL for local path", () => {
7+
const url = getEditorDeepLink({
8+
editor: "vscode",
9+
path: "/home/user/project/file.ts",
10+
});
11+
expect(url).toBe("vscode://file/home/user/project/file.ts");
12+
});
13+
14+
test("generates cursor:// URL for local path", () => {
15+
const url = getEditorDeepLink({
16+
editor: "cursor",
17+
path: "/home/user/project/file.ts",
18+
});
19+
expect(url).toBe("cursor://file/home/user/project/file.ts");
20+
});
21+
22+
test("generates zed:// URL for local path", () => {
23+
const url = getEditorDeepLink({
24+
editor: "zed",
25+
path: "/home/user/project/file.ts",
26+
});
27+
expect(url).toBe("zed://file/home/user/project/file.ts");
28+
});
29+
30+
test("includes line number in local path", () => {
31+
const url = getEditorDeepLink({
32+
editor: "vscode",
33+
path: "/home/user/project/file.ts",
34+
line: 42,
35+
});
36+
expect(url).toBe("vscode://file/home/user/project/file.ts:42");
37+
});
38+
39+
test("includes line and column in local path", () => {
40+
const url = getEditorDeepLink({
41+
editor: "cursor",
42+
path: "/home/user/project/file.ts",
43+
line: 42,
44+
column: 10,
45+
});
46+
expect(url).toBe("cursor://file/home/user/project/file.ts:42:10");
47+
});
48+
});
49+
50+
describe("SSH remote paths", () => {
51+
test("generates vscode-remote URL for SSH host", () => {
52+
const url = getEditorDeepLink({
53+
editor: "vscode",
54+
path: "/home/user/project/file.ts",
55+
sshHost: "devbox",
56+
});
57+
expect(url).toBe("vscode://vscode-remote/ssh-remote+devbox/home/user/project/file.ts");
58+
});
59+
60+
test("generates cursor-remote URL for SSH host", () => {
61+
const url = getEditorDeepLink({
62+
editor: "cursor",
63+
path: "/home/user/project/file.ts",
64+
sshHost: "devbox",
65+
});
66+
expect(url).toBe("cursor://vscode-remote/ssh-remote+devbox/home/user/project/file.ts");
67+
});
68+
69+
test("returns null for zed with SSH host (unsupported)", () => {
70+
const url = getEditorDeepLink({
71+
editor: "zed",
72+
path: "/home/user/project/file.ts",
73+
sshHost: "devbox",
74+
});
75+
expect(url).toBeNull();
76+
});
77+
78+
test("encodes SSH host with special characters", () => {
79+
const url = getEditorDeepLink({
80+
editor: "vscode",
81+
path: "/home/user/project/file.ts",
82+
sshHost: "[email protected]",
83+
});
84+
expect(url).toBe(
85+
"vscode://vscode-remote/ssh-remote+user%40host.example.com/home/user/project/file.ts"
86+
);
87+
});
88+
89+
test("includes line number in SSH remote path", () => {
90+
const url = getEditorDeepLink({
91+
editor: "vscode",
92+
path: "/home/user/project/file.ts",
93+
sshHost: "devbox",
94+
line: 42,
95+
});
96+
expect(url).toBe("vscode://vscode-remote/ssh-remote+devbox/home/user/project/file.ts:42");
97+
});
98+
99+
test("includes line and column in SSH remote path", () => {
100+
const url = getEditorDeepLink({
101+
editor: "cursor",
102+
path: "/home/user/project/file.ts",
103+
sshHost: "devbox",
104+
line: 42,
105+
column: 10,
106+
});
107+
expect(url).toBe("cursor://vscode-remote/ssh-remote+devbox/home/user/project/file.ts:42:10");
108+
});
109+
});
110+
});
111+
112+
describe("isLocalhost", () => {
113+
test("returns true for localhost", () => {
114+
expect(isLocalhost("localhost")).toBe(true);
115+
});
116+
117+
test("returns true for 127.0.0.1", () => {
118+
expect(isLocalhost("127.0.0.1")).toBe(true);
119+
});
120+
121+
test("returns true for ::1", () => {
122+
expect(isLocalhost("::1")).toBe(true);
123+
});
124+
125+
test("returns false for other hostnames", () => {
126+
expect(isLocalhost("devbox")).toBe(false);
127+
expect(isLocalhost("192.168.1.1")).toBe(false);
128+
expect(isLocalhost("example.com")).toBe(false);
129+
});
130+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Editor deep link URL generation for browser mode.
3+
*
4+
* When running `mux server` and accessing via browser, we can't spawn editor
5+
* processes on the server. Instead, we generate deep link URLs that the browser
6+
* opens, triggering the user's locally installed editor.
7+
*/
8+
9+
export type DeepLinkEditor = "vscode" | "cursor" | "zed";
10+
11+
export interface DeepLinkOptions {
12+
editor: DeepLinkEditor;
13+
path: string;
14+
sshHost?: string; // For SSH/remote workspaces
15+
line?: number;
16+
column?: number;
17+
}
18+
19+
/**
20+
* Generate an editor deep link URL.
21+
*
22+
* @returns Deep link URL, or null if the editor doesn't support the requested config
23+
* (e.g., Zed doesn't support SSH remote)
24+
*/
25+
export function getEditorDeepLink(options: DeepLinkOptions): string | null {
26+
const { editor, path, sshHost, line, column } = options;
27+
28+
// Zed doesn't support Remote-SSH
29+
if (sshHost && editor === "zed") {
30+
return null;
31+
}
32+
33+
const scheme = editor; // vscode, cursor, zed all use their name as scheme
34+
35+
if (sshHost) {
36+
// Remote-SSH format: vscode://vscode-remote/ssh-remote+host/path
37+
let url = `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(sshHost)}${path}`;
38+
if (line != null) {
39+
url += `:${line}`;
40+
if (column != null) {
41+
url += `:${column}`;
42+
}
43+
}
44+
return url;
45+
}
46+
47+
// Local format: vscode://file/path
48+
let url = `${scheme}://file${path}`;
49+
if (line != null) {
50+
url += `:${line}`;
51+
if (column != null) {
52+
url += `:${column}`;
53+
}
54+
}
55+
return url;
56+
}
57+
58+
/**
59+
* Check if a hostname represents localhost.
60+
*/
61+
export function isLocalhost(hostname: string): boolean {
62+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
63+
}

0 commit comments

Comments
 (0)