Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
},
server: {
getLaunchProject: async () => null,
getSshHost: async () => null,
setSshHost: async () => undefined,
},
providers: {
list: async () => providersList,
Expand Down
64 changes: 58 additions & 6 deletions src/browser/components/Settings/sections/GeneralSection.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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<EditorConfig>(
EDITOR_CONFIG_KEY,
DEFAULT_EDITOR_CONFIG
);
const [sshHost, setSshHost] = useState<string>("");
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 }));
Expand All @@ -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 (
<div className="space-y-6">
<div>
Expand Down Expand Up @@ -82,17 +108,43 @@ export function GeneralSection() {
</div>

{editorConfig.editor === "custom" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-foreground text-sm">Custom Command</div>
<div className="text-muted text-xs">Command to run (path will be appended)</div>
</div>
<Input
value={editorConfig.customCommand ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleCustomCommandChange(e.target.value)
}
placeholder="e.g., nvim"
className="border-border-medium bg-background-secondary h-9 w-40"
/>
</div>
{isBrowserMode && (
<div className="text-warning text-xs">
Custom editors are not supported in browser mode. Use VS Code or Cursor instead.
</div>
)}
</div>
)}

{isBrowserMode && sshHostLoaded && (
<div className="flex items-center justify-between">
<div>
<div className="text-foreground text-sm">Custom Command</div>
<div className="text-muted text-xs">Command to run (path will be appended)</div>
<div className="text-foreground text-sm">SSH Host</div>
<div className="text-muted text-xs">
SSH hostname for &apos;Open in Editor&apos; deep links
</div>
</div>
<Input
value={editorConfig.customCommand ?? ""}
value={sshHost}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
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"
/>
</div>
Expand Down
54 changes: 53 additions & 1 deletion src/browser/hooks/useOpenInEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down
130 changes: 130 additions & 0 deletions src/browser/utils/editorDeepLinks.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions src/browser/utils/editorDeepLinks.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading