Skip to content

Commit 6b758c8

Browse files
authored
🤖 fix: fallback to editor deep links when CLI missing (#1113)
When VS Code/Cursor CLI commands (`code`/`cursor`) aren’t available, fall back to opening the corresponding editor deep link (`vscode://`, `cursor://`) even in Electron mode. - Centralized behavior in a shared renderer `openInEditor(...)` util - Hook + `/plan open` now call the shared entry point - Added unit tests for the deep-link fallback helper _Generated with `mux`_
1 parent d1d5a25 commit 6b758c8

File tree

6 files changed

+257
-132
lines changed

6 files changed

+257
-132
lines changed

src/browser/hooks/useOpenInEditor.ts

Lines changed: 9 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,30 @@
11
import { useCallback } from "react";
22
import { useAPI } from "@/browser/contexts/API";
33
import { useSettings } from "@/browser/contexts/SettingsContext";
4-
import { readPersistedState } from "@/browser/hooks/usePersistedState";
5-
import {
6-
EDITOR_CONFIG_KEY,
7-
DEFAULT_EDITOR_CONFIG,
8-
type EditorConfig,
9-
} from "@/common/constants/storage";
104
import type { RuntimeConfig } from "@/common/types/runtime";
11-
import { isSSHRuntime } from "@/common/types/runtime";
12-
import {
13-
getEditorDeepLink,
14-
isLocalhost,
15-
type DeepLinkEditor,
16-
} from "@/browser/utils/editorDeepLinks";
5+
import { openInEditor } from "@/browser/utils/openInEditor";
176

18-
export interface OpenInEditorResult {
19-
success: boolean;
20-
error?: string;
21-
}
22-
23-
// Browser mode: window.api is not set (only exists in Electron via preload)
24-
const isBrowserMode = typeof window !== "undefined" && !window.api;
7+
export type { OpenInEditorResult } from "@/browser/utils/openInEditor";
258

269
/**
2710
* Hook to open a path in the user's configured code editor.
2811
*
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-
*
33-
* If no editor is configured, opens Settings to the General section.
34-
* For SSH workspaces with unsupported editors (Zed, custom), returns an error.
35-
*
36-
* @returns A function that opens a path in the editor:
37-
* - workspaceId: required workspace identifier
38-
* - targetPath: the path to open (workspace directory or specific file)
39-
* - runtimeConfig: optional, used to detect SSH workspaces for validation
12+
* This is a thin wrapper around the shared renderer entry point in
13+
* `src/browser/utils/openInEditor.ts`.
4014
*/
4115
export function useOpenInEditor() {
4216
const { api } = useAPI();
4317
const { open: openSettings } = useSettings();
4418

4519
return useCallback(
46-
async (
47-
workspaceId: string,
48-
targetPath: string,
49-
runtimeConfig?: RuntimeConfig
50-
): Promise<OpenInEditorResult> => {
51-
// Read editor config from localStorage
52-
const editorConfig = readPersistedState<EditorConfig>(
53-
EDITOR_CONFIG_KEY,
54-
DEFAULT_EDITOR_CONFIG
55-
);
56-
57-
const isSSH = isSSHRuntime(runtimeConfig);
58-
59-
// For custom editor with no command configured, open settings
60-
if (editorConfig.editor === "custom" && !editorConfig.customCommand) {
61-
openSettings("general");
62-
return { success: false, error: "Please configure a custom editor command in Settings" };
63-
}
64-
65-
// For SSH workspaces, validate the editor supports Remote-SSH (only VS Code/Cursor)
66-
if (isSSH) {
67-
if (editorConfig.editor === "zed") {
68-
return {
69-
success: false,
70-
error: "Zed does not support Remote-SSH for SSH workspaces",
71-
};
72-
}
73-
if (editorConfig.editor === "custom") {
74-
return {
75-
success: false,
76-
error: "Custom editors do not support Remote-SSH for SSH workspaces",
77-
};
78-
}
79-
}
80-
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
122-
const result = await api?.general.openInEditor({
20+
async (workspaceId: string, targetPath: string, runtimeConfig?: RuntimeConfig) => {
21+
return openInEditor({
22+
api,
23+
openSettings,
12324
workspaceId,
12425
targetPath,
125-
editorConfig,
26+
runtimeConfig,
12627
});
127-
128-
if (!result) {
129-
return { success: false, error: "API not available" };
130-
}
131-
132-
if (!result.success) {
133-
return { success: false, error: result.error };
134-
}
135-
136-
return { success: true };
13728
},
13829
[api, openSettings]
13930
);

src/browser/utils/chatCommands.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ describe("handlePlanOpenCommand", () => {
266266
api: {
267267
workspace: {
268268
getPlanContent: mock(() => Promise.resolve(getPlanContentResult)),
269+
getInfo: mock(() => Promise.resolve(null)),
269270
},
270271
general: {
271272
openInEditor: mock(() =>
@@ -298,6 +299,7 @@ describe("handlePlanOpenCommand", () => {
298299
message: "No plan found for this workspace",
299300
})
300301
);
302+
expect(context.api.workspace.getInfo).not.toHaveBeenCalled();
301303
// Should not attempt to open editor
302304
expect(context.api.general.openInEditor).not.toHaveBeenCalled();
303305
});
@@ -315,6 +317,9 @@ describe("handlePlanOpenCommand", () => {
315317
expect(context.api.workspace.getPlanContent).toHaveBeenCalledWith({
316318
workspaceId: "test-workspace-id",
317319
});
320+
expect(context.api.workspace.getInfo).toHaveBeenCalledWith({
321+
workspaceId: "test-workspace-id",
322+
});
318323
expect(context.api.general.openInEditor).toHaveBeenCalledWith({
319324
workspaceId: "test-workspace-id",
320325
targetPath: "/path/to/plan.md",

src/browser/utils/chatCommands.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,13 @@ import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOpt
2525
import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference";
2626
import type { ImageAttachment } from "../components/ImageAttachments";
2727
import { dispatchWorkspaceSwitch } from "./workspaceEvents";
28-
import {
29-
getRuntimeKey,
30-
copyWorkspaceStorage,
31-
EDITOR_CONFIG_KEY,
32-
DEFAULT_EDITOR_CONFIG,
33-
type EditorConfig,
34-
} from "@/common/constants/storage";
28+
import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage";
3529
import {
3630
DEFAULT_COMPACTION_WORD_TARGET,
3731
WORDS_TO_TOKENS_RATIO,
3832
buildCompactionPrompt,
3933
} from "@/common/constants/ui";
40-
import { readPersistedState } from "@/browser/hooks/usePersistedState";
34+
import { openInEditor } from "@/browser/utils/openInEditor";
4135

4236
// ============================================================================
4337
// Workspace Creation
@@ -975,14 +969,12 @@ export async function handlePlanOpenCommand(
975969
return { clearInput: true, toastShown: true };
976970
}
977971

978-
// Read editor config from localStorage
979-
const editorConfig = readPersistedState<EditorConfig>(EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG);
980-
981-
// Open in editor (runtime-aware)
982-
const openResult = await api.general.openInEditor({
972+
const workspaceInfo = await api.workspace.getInfo({ workspaceId });
973+
const openResult = await openInEditor({
974+
api,
983975
workspaceId,
984976
targetPath: planResult.data.path,
985-
editorConfig,
977+
runtimeConfig: workspaceInfo?.runtimeConfig,
986978
});
987979

988980
if (!openResult.success) {

src/browser/utils/openInEditor.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { readPersistedState } from "@/browser/hooks/usePersistedState";
2+
import {
3+
getEditorDeepLink,
4+
isLocalhost,
5+
type DeepLinkEditor,
6+
} from "@/browser/utils/editorDeepLinks";
7+
import {
8+
DEFAULT_EDITOR_CONFIG,
9+
EDITOR_CONFIG_KEY,
10+
type EditorConfig,
11+
} from "@/common/constants/storage";
12+
import type { RuntimeConfig } from "@/common/types/runtime";
13+
import { isSSHRuntime } from "@/common/types/runtime";
14+
import type { APIClient } from "@/browser/contexts/API";
15+
import { getEditorDeepLinkFallbackUrl } from "@/browser/utils/openInEditorDeepLinkFallback";
16+
17+
export interface OpenInEditorResult {
18+
success: boolean;
19+
error?: string;
20+
}
21+
22+
// Browser mode: window.api is not set (only exists in Electron via preload)
23+
const isBrowserMode = typeof window !== "undefined" && !window.api;
24+
25+
export async function openInEditor(args: {
26+
api: APIClient | null | undefined;
27+
openSettings?: (section?: string) => void;
28+
workspaceId: string;
29+
targetPath: string;
30+
runtimeConfig?: RuntimeConfig;
31+
}): Promise<OpenInEditorResult> {
32+
const editorConfig = readPersistedState<EditorConfig>(EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG);
33+
34+
const isSSH = isSSHRuntime(args.runtimeConfig);
35+
36+
// For custom editor with no command configured, open settings (if available)
37+
if (editorConfig.editor === "custom" && !editorConfig.customCommand) {
38+
args.openSettings?.("general");
39+
return { success: false, error: "Please configure a custom editor command in Settings" };
40+
}
41+
42+
// For SSH workspaces, validate the editor supports Remote-SSH (only VS Code/Cursor)
43+
if (isSSH) {
44+
if (editorConfig.editor === "zed") {
45+
return { success: false, error: "Zed does not support Remote-SSH for SSH workspaces" };
46+
}
47+
if (editorConfig.editor === "custom") {
48+
return {
49+
success: false,
50+
error: "Custom editors do not support Remote-SSH for SSH workspaces",
51+
};
52+
}
53+
}
54+
55+
// Browser mode: use deep links instead of backend spawn
56+
if (isBrowserMode) {
57+
// Custom editor can't work via deep links
58+
if (editorConfig.editor === "custom") {
59+
return {
60+
success: false,
61+
error: "Custom editors are not supported in browser mode. Use VS Code or Cursor.",
62+
};
63+
}
64+
65+
// Determine SSH host for deep link
66+
let sshHost: string | undefined;
67+
if (isSSH && args.runtimeConfig?.type === "ssh") {
68+
// SSH workspace: use the configured SSH host
69+
sshHost = args.runtimeConfig.host;
70+
} else if (!isLocalhost(window.location.hostname)) {
71+
// Remote server + local workspace: need SSH to reach server's files
72+
const serverSshHost = await args.api?.server.getSshHost();
73+
sshHost = serverSshHost ?? window.location.hostname;
74+
}
75+
// else: localhost access to local workspace → no SSH needed
76+
77+
const deepLink = getEditorDeepLink({
78+
editor: editorConfig.editor as DeepLinkEditor,
79+
path: args.targetPath,
80+
sshHost,
81+
});
82+
83+
if (!deepLink) {
84+
return {
85+
success: false,
86+
error: `${editorConfig.editor} does not support SSH remote connections`,
87+
};
88+
}
89+
90+
window.open(deepLink, "_blank");
91+
return { success: true };
92+
}
93+
94+
// Electron mode: call the backend API
95+
const result = await args.api?.general.openInEditor({
96+
workspaceId: args.workspaceId,
97+
targetPath: args.targetPath,
98+
editorConfig,
99+
});
100+
101+
if (!result) {
102+
return { success: false, error: "API not available" };
103+
}
104+
105+
if (!result.success) {
106+
const deepLink =
107+
typeof window === "undefined"
108+
? null
109+
: getEditorDeepLinkFallbackUrl({
110+
editor: editorConfig.editor,
111+
targetPath: args.targetPath,
112+
runtimeConfig: args.runtimeConfig,
113+
error: result.error,
114+
});
115+
116+
if (deepLink) {
117+
window.open(deepLink, "_blank");
118+
return { success: true };
119+
}
120+
121+
return { success: false, error: result.error };
122+
}
123+
124+
return { success: true };
125+
}

0 commit comments

Comments
 (0)