From d72ed12705e2cc404963f2bb64f0365d1a9bdbd2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:29:24 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fallback=20to=20editor=20?= =?UTF-8?q?deep=20links=20when=20CLI=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/hooks/useOpenInEditor.ts | 127 ++---------------- src/browser/utils/chatCommands.test.ts | 5 + src/browser/utils/chatCommands.ts | 20 +-- src/browser/utils/openInEditor.ts | 125 +++++++++++++++++ .../openInEditorDeepLinkFallback.test.ts | 73 ++++++++++ .../utils/openInEditorDeepLinkFallback.ts | 39 ++++++ 6 files changed, 257 insertions(+), 132 deletions(-) create mode 100644 src/browser/utils/openInEditor.ts create mode 100644 src/browser/utils/openInEditorDeepLinkFallback.test.ts create mode 100644 src/browser/utils/openInEditorDeepLinkFallback.ts diff --git a/src/browser/hooks/useOpenInEditor.ts b/src/browser/hooks/useOpenInEditor.ts index 2896b71238..75200ac055 100644 --- a/src/browser/hooks/useOpenInEditor.ts +++ b/src/browser/hooks/useOpenInEditor.ts @@ -1,139 +1,30 @@ import { useCallback } from "react"; import { useAPI } from "@/browser/contexts/API"; import { useSettings } from "@/browser/contexts/SettingsContext"; -import { readPersistedState } from "@/browser/hooks/usePersistedState"; -import { - EDITOR_CONFIG_KEY, - DEFAULT_EDITOR_CONFIG, - type EditorConfig, -} 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"; +import { openInEditor } from "@/browser/utils/openInEditor"; -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; +export type { OpenInEditorResult } from "@/browser/utils/openInEditor"; /** * 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. - * - * @returns A function that opens a path in the editor: - * - workspaceId: required workspace identifier - * - targetPath: the path to open (workspace directory or specific file) - * - runtimeConfig: optional, used to detect SSH workspaces for validation + * This is a thin wrapper around the shared renderer entry point in + * `src/browser/utils/openInEditor.ts`. */ export function useOpenInEditor() { const { api } = useAPI(); const { open: openSettings } = useSettings(); return useCallback( - async ( - workspaceId: string, - targetPath: string, - runtimeConfig?: RuntimeConfig - ): Promise => { - // Read editor config from localStorage - const editorConfig = readPersistedState( - EDITOR_CONFIG_KEY, - DEFAULT_EDITOR_CONFIG - ); - - const isSSH = isSSHRuntime(runtimeConfig); - - // For custom editor with no command configured, open settings - if (editorConfig.editor === "custom" && !editorConfig.customCommand) { - openSettings("general"); - return { success: false, error: "Please configure a custom editor command in Settings" }; - } - - // For SSH workspaces, validate the editor supports Remote-SSH (only VS Code/Cursor) - if (isSSH) { - if (editorConfig.editor === "zed") { - return { - success: false, - error: "Zed does not support Remote-SSH for SSH workspaces", - }; - } - if (editorConfig.editor === "custom") { - return { - success: false, - error: "Custom editors do not support Remote-SSH for SSH workspaces", - }; - } - } - - // 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({ + async (workspaceId: string, targetPath: string, runtimeConfig?: RuntimeConfig) => { + return openInEditor({ + api, + openSettings, workspaceId, targetPath, - editorConfig, + runtimeConfig, }); - - if (!result) { - return { success: false, error: "API not available" }; - } - - if (!result.success) { - return { success: false, error: result.error }; - } - - return { success: true }; }, [api, openSettings] ); diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index e306929343..9b79ec9530 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -266,6 +266,7 @@ describe("handlePlanOpenCommand", () => { api: { workspace: { getPlanContent: mock(() => Promise.resolve(getPlanContentResult)), + getInfo: mock(() => Promise.resolve(null)), }, general: { openInEditor: mock(() => @@ -298,6 +299,7 @@ describe("handlePlanOpenCommand", () => { message: "No plan found for this workspace", }) ); + expect(context.api.workspace.getInfo).not.toHaveBeenCalled(); // Should not attempt to open editor expect(context.api.general.openInEditor).not.toHaveBeenCalled(); }); @@ -315,6 +317,9 @@ describe("handlePlanOpenCommand", () => { expect(context.api.workspace.getPlanContent).toHaveBeenCalledWith({ workspaceId: "test-workspace-id", }); + 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", diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 2b322dd621..e1e2f484a8 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -25,19 +25,13 @@ import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOpt import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import type { ImageAttachment } from "../components/ImageAttachments"; import { dispatchWorkspaceSwitch } from "./workspaceEvents"; -import { - getRuntimeKey, - copyWorkspaceStorage, - EDITOR_CONFIG_KEY, - DEFAULT_EDITOR_CONFIG, - type EditorConfig, -} from "@/common/constants/storage"; +import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage"; import { DEFAULT_COMPACTION_WORD_TARGET, WORDS_TO_TOKENS_RATIO, buildCompactionPrompt, } from "@/common/constants/ui"; -import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { openInEditor } from "@/browser/utils/openInEditor"; // ============================================================================ // Workspace Creation @@ -975,14 +969,12 @@ export async function handlePlanOpenCommand( return { clearInput: true, toastShown: true }; } - // Read editor config from localStorage - const editorConfig = readPersistedState(EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG); - - // Open in editor (runtime-aware) - const openResult = await api.general.openInEditor({ + const workspaceInfo = await api.workspace.getInfo({ workspaceId }); + const openResult = await openInEditor({ + api, workspaceId, targetPath: planResult.data.path, - editorConfig, + runtimeConfig: workspaceInfo?.runtimeConfig, }); if (!openResult.success) { diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts new file mode 100644 index 0000000000..98440e65ac --- /dev/null +++ b/src/browser/utils/openInEditor.ts @@ -0,0 +1,125 @@ +import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { + getEditorDeepLink, + isLocalhost, + type DeepLinkEditor, +} from "@/browser/utils/editorDeepLinks"; +import { + DEFAULT_EDITOR_CONFIG, + EDITOR_CONFIG_KEY, + type EditorConfig, +} from "@/common/constants/storage"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { isSSHRuntime } from "@/common/types/runtime"; +import type { APIClient } from "@/browser/contexts/API"; +import { getEditorDeepLinkFallbackUrl } from "@/browser/utils/openInEditorDeepLinkFallback"; + +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; + +export async function openInEditor(args: { + api: APIClient | null | undefined; + openSettings?: (section?: string) => void; + workspaceId: string; + targetPath: string; + runtimeConfig?: RuntimeConfig; +}): Promise { + const editorConfig = readPersistedState(EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG); + + const isSSH = isSSHRuntime(args.runtimeConfig); + + // For custom editor with no command configured, open settings (if available) + if (editorConfig.editor === "custom" && !editorConfig.customCommand) { + args.openSettings?.("general"); + return { success: false, error: "Please configure a custom editor command in Settings" }; + } + + // For SSH workspaces, validate the editor supports Remote-SSH (only VS Code/Cursor) + if (isSSH) { + if (editorConfig.editor === "zed") { + return { success: false, error: "Zed does not support Remote-SSH for SSH workspaces" }; + } + if (editorConfig.editor === "custom") { + return { + success: false, + error: "Custom editors do not support Remote-SSH for SSH workspaces", + }; + } + } + + // 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 && args.runtimeConfig?.type === "ssh") { + // SSH workspace: use the configured SSH host + sshHost = args.runtimeConfig.host; + } else if (!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; + } + // else: localhost access to local workspace → no SSH needed + + const deepLink = getEditorDeepLink({ + editor: editorConfig.editor as DeepLinkEditor, + path: args.targetPath, + sshHost, + }); + + if (!deepLink) { + return { + success: false, + error: `${editorConfig.editor} does not support SSH remote connections`, + }; + } + + window.open(deepLink, "_blank"); + return { success: true }; + } + + // Electron mode: call the backend API + const result = await args.api?.general.openInEditor({ + workspaceId: args.workspaceId, + targetPath: args.targetPath, + editorConfig, + }); + + if (!result) { + return { success: false, error: "API not available" }; + } + + 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 }; + } + + return { success: true }; +} diff --git a/src/browser/utils/openInEditorDeepLinkFallback.test.ts b/src/browser/utils/openInEditorDeepLinkFallback.test.ts new file mode 100644 index 0000000000..f2c6796655 --- /dev/null +++ b/src/browser/utils/openInEditorDeepLinkFallback.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { + getEditorDeepLinkFallbackUrl, + shouldAttemptEditorDeepLinkFallback, +} from "./openInEditorDeepLinkFallback"; + +import type { RuntimeConfig } from "@/common/types/runtime"; + +describe("shouldAttemptEditorDeepLinkFallback", () => { + test("returns true for EditorService command-not-found error", () => { + expect(shouldAttemptEditorDeepLinkFallback("Editor command not found: code")).toBe(true); + }); + + test("returns false for unrelated errors", () => { + expect(shouldAttemptEditorDeepLinkFallback("Some other error")).toBe(false); + expect(shouldAttemptEditorDeepLinkFallback(undefined)).toBe(false); + }); +}); + +describe("getEditorDeepLinkFallbackUrl", () => { + test("returns vscode://file URL for local path", () => { + const url = getEditorDeepLinkFallbackUrl({ + editor: "vscode", + targetPath: "/home/user/project/file.ts", + error: "Editor command not found: code", + }); + expect(url).toBe("vscode://file/home/user/project/file.ts"); + }); + + test("returns cursor://vscode-remote URL for SSH runtime", () => { + const runtimeConfig: RuntimeConfig = { + type: "ssh", + host: "devbox", + srcBaseDir: "~/mux", + }; + + const url = getEditorDeepLinkFallbackUrl({ + editor: "cursor", + targetPath: "/home/user/project/file.ts", + runtimeConfig, + error: "Editor command not found: cursor", + }); + + expect(url).toBe("cursor://vscode-remote/ssh-remote+devbox/home/user/project/file.ts"); + }); + + test("returns null for zed + SSH runtime (unsupported)", () => { + const runtimeConfig: RuntimeConfig = { + type: "ssh", + host: "devbox", + srcBaseDir: "~/mux", + }; + + const url = getEditorDeepLinkFallbackUrl({ + editor: "zed", + targetPath: "/home/user/project/file.ts", + runtimeConfig, + error: "Editor command not found: zed", + }); + + expect(url).toBeNull(); + }); + + test("returns null when error is not command-not-found", () => { + const url = getEditorDeepLinkFallbackUrl({ + editor: "vscode", + targetPath: "/home/user/project/file.ts", + error: "Permission denied", + }); + + expect(url).toBeNull(); + }); +}); diff --git a/src/browser/utils/openInEditorDeepLinkFallback.ts b/src/browser/utils/openInEditorDeepLinkFallback.ts new file mode 100644 index 0000000000..d6727af5e9 --- /dev/null +++ b/src/browser/utils/openInEditorDeepLinkFallback.ts @@ -0,0 +1,39 @@ +import type { EditorType } from "@/common/constants/storage"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import { isSSHRuntime } from "@/common/types/runtime"; +import { getEditorDeepLink, type DeepLinkEditor } from "@/browser/utils/editorDeepLinks"; + +export function shouldAttemptEditorDeepLinkFallback(error: string | undefined): boolean { + if (!error) return false; + + // Primary signal from our backend EditorService. + if (error.startsWith("Editor command not found:")) return true; + + return false; +} + +export function getEditorDeepLinkFallbackUrl(args: { + editor: EditorType; + targetPath: string; + runtimeConfig?: RuntimeConfig; + error?: string; +}): string | null { + if (!shouldAttemptEditorDeepLinkFallback(args.error)) return null; + + if (args.editor === "custom") return null; + + const deepLinkEditor: DeepLinkEditor | null = + args.editor === "vscode" || args.editor === "cursor" || args.editor === "zed" + ? args.editor + : null; + + if (!deepLinkEditor) return null; + + const sshHost = isSSHRuntime(args.runtimeConfig) ? args.runtimeConfig.host : undefined; + + return getEditorDeepLink({ + editor: deepLinkEditor, + path: args.targetPath, + sshHost, + }); +}