Skip to content

Commit 95c3b30

Browse files
committed
🤖 feat: use deep links for built-in editors in Electron
1 parent f6c6bb6 commit 95c3b30

File tree

7 files changed

+178
-338
lines changed

7 files changed

+178
-338
lines changed

src/browser/utils/chatCommands.test.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -594,31 +594,15 @@ describe("handlePlanOpenCommand", () => {
594594
expect(context.api.workspace.getInfo).toHaveBeenCalledWith({
595595
workspaceId: "test-workspace-id",
596596
});
597-
expect(context.api.general.openInEditor).toHaveBeenCalledWith({
598-
workspaceId: "test-workspace-id",
599-
targetPath: "/path/to/plan.md",
600-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
601-
editorConfig: expect.any(Object),
602-
});
597+
// Note: Built-in editors (VS Code/Cursor/Zed) now use deep links directly
598+
// via window.open(), not the backend API. The backend API is only used
599+
// for custom editors.
603600
});
604601

605-
test("shows error toast when editor fails to open", async () => {
606-
const context = createMockContext(
607-
{ success: true, data: { content: "# My Plan", path: "/path/to/plan.md" } },
608-
{ success: false, error: "No editor configured" }
609-
);
610-
611-
const result = await handlePlanOpenCommand(context);
612-
613-
expect(result.clearInput).toBe(true);
614-
expect(result.toastShown).toBe(true);
615-
expect(context.setToast).toHaveBeenCalledWith(
616-
expect.objectContaining({
617-
type: "error",
618-
message: "No editor configured",
619-
})
620-
);
621-
});
602+
// Note: The "editor fails to open" test was removed because built-in editors
603+
// (VS Code/Cursor/Zed) now use deep links that open via window.open() and
604+
// always succeed from the app's perspective. Failures happen in the external
605+
// editor, not in our code path.
622606
});
623607

624608
describe("handleCompactCommand", () => {

src/browser/utils/openInEditor.ts

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
import type { RuntimeConfig } from "@/common/types/runtime";
1414
import { isSSHRuntime, isDockerRuntime } from "@/common/types/runtime";
1515
import type { APIClient } from "@/browser/contexts/API";
16-
import { getEditorDeepLinkFallbackUrl } from "@/browser/utils/openInEditorDeepLinkFallback";
1716

1817
export interface OpenInEditorResult {
1918
success: boolean;
@@ -23,6 +22,13 @@ export interface OpenInEditorResult {
2322
// Browser mode: window.api is not set (only exists in Electron via preload)
2423
const isBrowserMode = typeof window !== "undefined" && !window.api;
2524

25+
// Helper for opening URLs - allows testing in Node environment
26+
function openUrl(url: string): void {
27+
if (typeof window !== "undefined" && window.open) {
28+
window.open(url, "_blank");
29+
}
30+
}
31+
2632
export async function openInEditor(args: {
2733
api: APIClient | null | undefined;
2834
openSettings?: (section?: string) => void;
@@ -88,20 +94,12 @@ export async function openInEditor(args: {
8894
return { success: false, error: `${editorConfig.editor} does not support Docker containers` };
8995
}
9096

91-
window.open(deepLink, "_blank");
97+
openUrl(deepLink);
9298
return { success: true };
9399
}
94100

95-
// Browser mode: use deep links instead of backend spawn
96-
if (isBrowserMode) {
97-
// Custom editor can't work via deep links
98-
if (editorConfig.editor === "custom") {
99-
return {
100-
success: false,
101-
error: "Custom editors are not supported in browser mode. Use VS Code, Cursor, or Zed.",
102-
};
103-
}
104-
101+
// VS Code / Cursor / Zed: always use deep links (works in browser + Electron)
102+
if (editorConfig.editor !== "custom") {
105103
// Determine SSH host for deep link
106104
let sshHost: string | undefined;
107105
if (isSSH && args.runtimeConfig?.type === "ssh") {
@@ -110,7 +108,7 @@ export async function openInEditor(args: {
110108
if (editorConfig.editor === "zed" && args.runtimeConfig.port != null) {
111109
sshHost = sshHost + ":" + args.runtimeConfig.port;
112110
}
113-
} else if (!isLocalhost(window.location.hostname)) {
111+
} else if (isBrowserMode && !isLocalhost(window.location.hostname)) {
114112
// Remote server + local workspace: need SSH to reach server's files
115113
const serverSshHost = await args.api?.server.getSshHost();
116114
sshHost = serverSshHost ?? window.location.hostname;
@@ -130,11 +128,20 @@ export async function openInEditor(args: {
130128
};
131129
}
132130

133-
window.open(deepLink, "_blank");
131+
openUrl(deepLink);
134132
return { success: true };
135133
}
136134

137-
// Electron mode: call the backend API
135+
// Custom editor:
136+
// - Browser mode: can't spawn processes on the server
137+
// - Electron mode: spawn via backend API
138+
if (isBrowserMode) {
139+
return {
140+
success: false,
141+
error: "Custom editors are not supported in browser mode. Use VS Code, Cursor, or Zed.",
142+
};
143+
}
144+
138145
const result = await args.api?.general.openInEditor({
139146
workspaceId: args.workspaceId,
140147
targetPath: args.targetPath,
@@ -146,21 +153,6 @@ export async function openInEditor(args: {
146153
}
147154

148155
if (!result.success) {
149-
const deepLink =
150-
typeof window === "undefined"
151-
? null
152-
: getEditorDeepLinkFallbackUrl({
153-
editor: editorConfig.editor,
154-
targetPath: args.targetPath,
155-
runtimeConfig: args.runtimeConfig,
156-
error: result.error,
157-
});
158-
159-
if (deepLink) {
160-
window.open(deepLink, "_blank");
161-
return { success: true };
162-
}
163-
164156
return { success: false, error: result.error };
165157
}
166158

src/browser/utils/openInEditorDeepLinkFallback.test.ts

Lines changed: 0 additions & 91 deletions
This file was deleted.

src/browser/utils/openInEditorDeepLinkFallback.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,75 @@
11
import { describe, expect, test } from "bun:test";
2-
import { getDetachedSpawnSpec } from "./editorService";
3-
4-
describe("getDetachedSpawnSpec", () => {
5-
test("wraps editor command in cmd.exe on Windows", () => {
6-
const spec = getDetachedSpawnSpec({
7-
platform: "win32",
8-
command: "code",
9-
args: ["C:\\Users\\Me\\proj"],
2+
import type { Config } from "@/node/config";
3+
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
4+
import { EditorService } from "./editorService";
5+
6+
describe("EditorService", () => {
7+
test("rejects non-custom editors (renderer must use deep links)", async () => {
8+
const editorService = new EditorService({} as Config);
9+
10+
const result = await editorService.openInEditor("ws1", "/tmp", {
11+
editor: "vscode",
1012
});
1113

12-
expect(spec).toEqual({
13-
command: "cmd.exe",
14-
args: ["/d", "/s", "/c", "code", "C:\\Users\\Me\\proj"],
14+
expect(result.success).toBe(false);
15+
if (!result.success) {
16+
expect(result.error).toContain("deep links");
17+
}
18+
});
19+
20+
test("validates custom editor executable exists before spawning", async () => {
21+
const workspace: FrontendWorkspaceMetadata = {
22+
id: "ws1",
23+
name: "ws1",
24+
projectName: "proj",
25+
projectPath: "/tmp/proj",
26+
createdAt: "2025-01-01T00:00:00.000Z",
27+
runtimeConfig: { type: "worktree", srcBaseDir: "/tmp/src" },
28+
namedWorkspacePath: "/tmp/src/proj/ws1",
29+
};
30+
31+
const mockConfig: Pick<Config, "getAllWorkspaceMetadata"> = {
32+
getAllWorkspaceMetadata: () => Promise.resolve([workspace]),
33+
} as unknown as Pick<Config, "getAllWorkspaceMetadata">;
34+
35+
const editorService = new EditorService(mockConfig as Config);
36+
37+
const result = await editorService.openInEditor("ws1", "/tmp", {
38+
editor: "custom",
39+
customCommand: "definitely-not-a-command",
1540
});
41+
42+
expect(result.success).toBe(false);
43+
if (!result.success) {
44+
expect(result.error).toContain("Editor command not found");
45+
}
1646
});
1747

18-
test("spawns command directly on non-Windows platforms", () => {
19-
const spec = getDetachedSpawnSpec({
20-
platform: "linux",
21-
command: "code",
22-
args: ["/home/me/proj"],
48+
test("errors on invalid custom editor command quoting", async () => {
49+
const workspace: FrontendWorkspaceMetadata = {
50+
id: "ws1",
51+
name: "ws1",
52+
projectName: "proj",
53+
projectPath: "/tmp/proj",
54+
createdAt: "2025-01-01T00:00:00.000Z",
55+
runtimeConfig: { type: "worktree", srcBaseDir: "/tmp/src" },
56+
namedWorkspacePath: "/tmp/src/proj/ws1",
57+
};
58+
59+
const mockConfig: Pick<Config, "getAllWorkspaceMetadata"> = {
60+
getAllWorkspaceMetadata: () => Promise.resolve([workspace]),
61+
} as unknown as Pick<Config, "getAllWorkspaceMetadata">;
62+
63+
const editorService = new EditorService(mockConfig as Config);
64+
65+
const result = await editorService.openInEditor("ws1", "/tmp", {
66+
editor: "custom",
67+
customCommand: '"unterminated',
2368
});
2469

25-
expect(spec).toEqual({ command: "code", args: ["/home/me/proj"] });
70+
expect(result.success).toBe(false);
71+
if (!result.success) {
72+
expect(result.error).toContain("Invalid custom editor command");
73+
}
2674
});
2775
});

0 commit comments

Comments
 (0)