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
30 changes: 7 additions & 23 deletions src/browser/utils/chatCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,31 +594,15 @@ describe("handlePlanOpenCommand", () => {
expect(context.api.workspace.getInfo).toHaveBeenCalledWith({
workspaceId: "test-workspace-id",
});
expect(context.api.general.openInEditor).toHaveBeenCalledWith({
workspaceId: "test-workspace-id",
targetPath: "/path/to/plan.md",
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
editorConfig: expect.any(Object),
});
// Note: Built-in editors (VS Code/Cursor/Zed) now use deep links directly
// via window.open(), not the backend API. The backend API is only used
// for custom editors.
});

test("shows error toast when editor fails to open", async () => {
const context = createMockContext(
{ success: true, data: { content: "# My Plan", path: "/path/to/plan.md" } },
{ success: false, error: "No editor configured" }
);

const result = await handlePlanOpenCommand(context);

expect(result.clearInput).toBe(true);
expect(result.toastShown).toBe(true);
expect(context.setToast).toHaveBeenCalledWith(
expect.objectContaining({
type: "error",
message: "No editor configured",
})
);
});
// Note: The "editor fails to open" test was removed because built-in editors
// (VS Code/Cursor/Zed) now use deep links that open via window.open() and
// always succeed from the app's perspective. Failures happen in the external
// editor, not in our code path.
});

describe("handleCompactCommand", () => {
Expand Down
25 changes: 25 additions & 0 deletions src/browser/utils/editorDeepLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@ describe("getEditorDeepLink", () => {
expect(url).toBe("cursor://file/home/user/project/file.ts");
});

test("normalizes Windows drive paths for local deep links", () => {
const url = getEditorDeepLink({
editor: "vscode",
path: "C:\\Users\\Me\\proj\\file.ts",
});
expect(url).toBe("vscode://file/C:/Users/Me/proj/file.ts");
});

test("normalizes Windows drive paths with forward slashes", () => {
const url = getEditorDeepLink({
editor: "cursor",
path: "C:/Users/Me/proj/file.ts",
line: 42,
column: 10,
});
expect(url).toBe("cursor://file/C:/Users/Me/proj/file.ts:42:10");
});

test("strips surrounding quotes from local deep link paths", () => {
const url = getEditorDeepLink({
editor: "vscode",
path: "'C:\\Users\\Me\\proj\\file.ts'",
});
expect(url).toBe("vscode://file/C:/Users/Me/proj/file.ts");
});
test("generates zed:// URL for local path", () => {
const url = getEditorDeepLink({
editor: "zed",
Expand Down
26 changes: 25 additions & 1 deletion src/browser/utils/editorDeepLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ export function getEditorDeepLink(options: DeepLinkOptions): string | null {
}

// Local format: vscode://file/path
let url = `${scheme}://file${path}`;
//
// Note: On Windows, callers may provide native paths like `C:\\Users\\...`.
// VS Code/Cursor/Zed expect forward slashes and a leading `/` after `file`:
// vscode://file/C:/Users/...
const normalizedPath = normalizeLocalPathForEditorDeepLink(path);

let url = `${scheme}://file${normalizedPath}`;
if (line != null) {
url += `:${line}`;
if (column != null) {
Expand All @@ -62,6 +68,24 @@ export function getEditorDeepLink(options: DeepLinkOptions): string | null {
return url;
}

function normalizeLocalPathForEditorDeepLink(path: string): string {
const trimmed = path.trim();
const unquoted =
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
? trimmed.slice(1, -1)
: trimmed;

const pathWithSlashes = unquoted.replace(/\\/g, "/");

// Ensure the URL parses as `scheme://file/<path>`.
if (pathWithSlashes.startsWith("/")) {
return pathWithSlashes;
}

return `/${pathWithSlashes}`;
}

/**
* Check if a hostname represents localhost.
*/
Expand Down
52 changes: 22 additions & 30 deletions src/browser/utils/openInEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
import type { RuntimeConfig } from "@/common/types/runtime";
import { isSSHRuntime, isDockerRuntime } from "@/common/types/runtime";
import type { APIClient } from "@/browser/contexts/API";
import { getEditorDeepLinkFallbackUrl } from "@/browser/utils/openInEditorDeepLinkFallback";

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

// Helper for opening URLs - allows testing in Node environment
function openUrl(url: string): void {
if (typeof window !== "undefined" && window.open) {
window.open(url, "_blank");
}
}

export async function openInEditor(args: {
api: APIClient | null | undefined;
openSettings?: (section?: string) => void;
Expand Down Expand Up @@ -88,20 +94,12 @@ export async function openInEditor(args: {
return { success: false, error: `${editorConfig.editor} does not support Docker containers` };
}

window.open(deepLink, "_blank");
openUrl(deepLink);
return { success: true };
}

// 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, Cursor, or Zed.",
};
}

// VS Code / Cursor / Zed: always use deep links (works in browser + Electron)
if (editorConfig.editor !== "custom") {
// Determine SSH host for deep link
let sshHost: string | undefined;
if (isSSH && args.runtimeConfig?.type === "ssh") {
Expand All @@ -110,7 +108,7 @@ export async function openInEditor(args: {
if (editorConfig.editor === "zed" && args.runtimeConfig.port != null) {
sshHost = sshHost + ":" + args.runtimeConfig.port;
}
} else if (!isLocalhost(window.location.hostname)) {
} else if (isBrowserMode && !isLocalhost(window.location.hostname)) {
// Remote server + local workspace: need SSH to reach server's files
const serverSshHost = await args.api?.server.getSshHost();
sshHost = serverSshHost ?? window.location.hostname;
Expand All @@ -130,11 +128,20 @@ export async function openInEditor(args: {
};
}

window.open(deepLink, "_blank");
openUrl(deepLink);
return { success: true };
}

// Electron mode: call the backend API
// Custom editor:
// - Browser mode: can't spawn processes on the server
// - Electron mode: spawn via backend API
if (isBrowserMode) {
return {
success: false,
error: "Custom editors are not supported in browser mode. Use VS Code, Cursor, or Zed.",
};
}

const result = await args.api?.general.openInEditor({
workspaceId: args.workspaceId,
targetPath: args.targetPath,
Expand All @@ -146,21 +153,6 @@ export async function openInEditor(args: {
}

if (!result.success) {
const deepLink =
typeof window === "undefined"
? null
: getEditorDeepLinkFallbackUrl({
editor: editorConfig.editor,
targetPath: args.targetPath,
runtimeConfig: args.runtimeConfig,
error: result.error,
});

if (deepLink) {
window.open(deepLink, "_blank");
return { success: true };
}

return { success: false, error: result.error };
}

Expand Down
91 changes: 0 additions & 91 deletions src/browser/utils/openInEditorDeepLinkFallback.test.ts

This file was deleted.

45 changes: 0 additions & 45 deletions src/browser/utils/openInEditorDeepLinkFallback.ts

This file was deleted.

Loading
Loading