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
127 changes: 9 additions & 118 deletions src/browser/hooks/useOpenInEditor.ts
Original file line number Diff line number Diff line change
@@ -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<OpenInEditorResult> => {
// Read editor config from localStorage
const editorConfig = readPersistedState<EditorConfig>(
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]
);
Expand Down
5 changes: 5 additions & 0 deletions src/browser/utils/chatCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ describe("handlePlanOpenCommand", () => {
api: {
workspace: {
getPlanContent: mock(() => Promise.resolve(getPlanContentResult)),
getInfo: mock(() => Promise.resolve(null)),
},
general: {
openInEditor: mock(() =>
Expand Down Expand Up @@ -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();
});
Expand All @@ -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",
Expand Down
20 changes: 6 additions & 14 deletions src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -975,14 +969,12 @@ export async function handlePlanOpenCommand(
return { clearInput: true, toastShown: true };
}

// Read editor config from localStorage
const editorConfig = readPersistedState<EditorConfig>(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) {
Expand Down
125 changes: 125 additions & 0 deletions src/browser/utils/openInEditor.ts
Original file line number Diff line number Diff line change
@@ -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<OpenInEditorResult> {
const editorConfig = readPersistedState<EditorConfig>(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 };
}
Loading
Loading