From 2c69e867571c6a4e0d314b51b1a0540bcdc15a95 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:21:38 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20Open=20in=20Editor?= =?UTF-8?q?=20button=20to=20workspace=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a button next to the terminal icon that opens the workspace in the user's configured code editor (VS Code, Cursor, Zed, or custom). - EditorService handles spawning editor processes - Supports Remote-SSH extension for VS Code/Cursor with SSH workspaces - Editor config persisted in localStorage with VS Code + Remote-SSH as default - Settings UI in General section for editor selection and options - Keybind: Cmd+Shift+E (Mac) / Ctrl+Shift+E (Win/Linux) _Generated with `mux`_ --- .../Settings/sections/GeneralSection.tsx | 98 ++++++++++++++ src/browser/components/WorkspaceHeader.tsx | 71 +++++++--- src/browser/components/ui/input.tsx | 23 ++++ src/browser/components/ui/select.tsx | 2 +- src/browser/hooks/useOpenInEditor.ts | 86 ++++++++++++ src/browser/utils/ui/keybinds.ts | 4 + src/cli/cli.test.ts | 1 + src/cli/server.test.ts | 1 + src/cli/server.ts | 1 + src/common/constants/storage.ts | 19 +++ src/common/orpc/schemas/api.ts | 19 +++ src/desktop/main.ts | 1 + src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 6 + src/node/services/editorService.ts | 123 ++++++++++++++++++ src/node/services/serviceContainer.ts | 4 + tests/ipc/setup.ts | 1 + 17 files changed, 442 insertions(+), 20 deletions(-) create mode 100644 src/browser/components/ui/input.tsx create mode 100644 src/browser/hooks/useOpenInEditor.ts create mode 100644 src/node/services/editorService.ts diff --git a/src/browser/components/Settings/sections/GeneralSection.tsx b/src/browser/components/Settings/sections/GeneralSection.tsx index fcb2fff95a..a3eb595bb9 100644 --- a/src/browser/components/Settings/sections/GeneralSection.tsx +++ b/src/browser/components/Settings/sections/GeneralSection.tsx @@ -7,9 +7,45 @@ import { SelectTrigger, SelectValue, } from "@/browser/components/ui/select"; +import { Input } from "@/browser/components/ui/input"; +import { Checkbox } from "@/browser/components/ui/checkbox"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/ui/tooltip"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + EDITOR_CONFIG_KEY, + DEFAULT_EDITOR_CONFIG, + type EditorConfig, + type EditorType, +} from "@/common/constants/storage"; + +const EDITOR_OPTIONS: Array<{ value: EditorType; label: string }> = [ + { value: "vscode", label: "VS Code" }, + { value: "cursor", label: "Cursor" }, + { value: "zed", label: "Zed" }, + { value: "custom", label: "Custom" }, +]; export function GeneralSection() { const { theme, setTheme } = useTheme(); + const [editorConfig, setEditorConfig] = usePersistedState( + EDITOR_CONFIG_KEY, + DEFAULT_EDITOR_CONFIG + ); + + const handleEditorChange = (editor: EditorType) => { + setEditorConfig((prev) => ({ ...prev, editor })); + }; + + const handleCustomCommandChange = (customCommand: string) => { + setEditorConfig((prev) => ({ ...prev, customCommand })); + }; + + const handleRemoteExtensionChange = (useRemoteExtension: boolean) => { + setEditorConfig((prev) => ({ ...prev, useRemoteExtension })); + }; + + // Remote-SSH is only supported for VS Code and Cursor + const supportsRemote = editorConfig.editor === "vscode" || editorConfig.editor === "cursor"; return (
@@ -34,6 +70,68 @@ export function GeneralSection() {
+ +
+

Editor

+
+
+
+
Default Editor
+
Editor to open workspaces in
+
+ +
+ + {editorConfig.editor === "custom" && ( +
+
+
Custom Command
+
Command to run (path will be appended)
+
+ ) => + handleCustomCommandChange(e.target.value) + } + placeholder="e.g., nvim" + className="border-border-medium bg-background-secondary h-9 w-40" + /> +
+ )} + + {supportsRemote && ( +
+
+
Use Remote-SSH for SSH workspaces
+ + + (?) + + + When enabled, opens SSH workspaces directly in the editor using the Remote-SSH + extension. Requires the Remote-SSH extension to be installed. + + +
+ +
+ )} +
+
); } diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index 669657a11b..db12425124 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import { Pencil } from "lucide-react"; import { GitStatusIndicator } from "./GitStatusIndicator"; import { RuntimeBadge } from "./RuntimeBadge"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; @@ -9,6 +10,7 @@ import { Button } from "@/browser/components/ui/button"; import type { RuntimeConfig } from "@/common/types/runtime"; import { useTutorial } from "@/browser/contexts/TutorialContext"; import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal"; +import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor"; interface WorkspaceHeaderProps { workspaceId: string; @@ -26,13 +28,26 @@ export const WorkspaceHeader: React.FC = ({ runtimeConfig, }) => { const openTerminal = useOpenTerminal(); + const openInEditor = useOpenInEditor(); const gitStatus = useGitStatus(workspaceId); const { canInterrupt } = useWorkspaceSidebarState(workspaceId); const { startSequence: startTutorial, isSequenceCompleted } = useTutorial(); + const [editorError, setEditorError] = useState(null); + const handleOpenTerminal = useCallback(() => { openTerminal(workspaceId, runtimeConfig); }, [workspaceId, openTerminal, runtimeConfig]); + const handleOpenInEditor = useCallback(async () => { + setEditorError(null); + const result = await openInEditor(workspaceId, runtimeConfig); + if (!result.success && result.error) { + setEditorError(result.error); + // Clear error after 3 seconds + setTimeout(() => setEditorError(null), 3000); + } + }, [workspaceId, openInEditor, runtimeConfig]); + // Start workspace tutorial on first entry (only if settings tutorial is done) useEffect(() => { // Don't show workspace tutorial until settings tutorial is completed @@ -64,24 +79,42 @@ export const WorkspaceHeader: React.FC = ({ {namedWorkspacePath} - - - - - - Open terminal window ({formatKeybind(KEYBINDS.OPEN_TERMINAL)}) - - +
+ {editorError && {editorError}} + + + + + + Open in editor ({formatKeybind(KEYBINDS.OPEN_IN_EDITOR)}) + + + + + + + + Open terminal window ({formatKeybind(KEYBINDS.OPEN_TERMINAL)}) + + +
); }; diff --git a/src/browser/components/ui/input.tsx b/src/browser/components/ui/input.tsx new file mode 100644 index 0000000000..bbc2bcb08c --- /dev/null +++ b/src/browser/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { cn } from "@/common/lib/utils"; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/browser/components/ui/select.tsx b/src/browser/components/ui/select.tsx index 1c8633f86d..e93d06c7f3 100644 --- a/src/browser/components/ui/select.tsx +++ b/src/browser/components/ui/select.tsx @@ -66,7 +66,7 @@ const SelectContent = React.forwardRef< => { + // 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 + if (isSSH && editorConfig.useRemoteExtension) { + 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", + }; + } + } + + // For SSH workspaces without remote extension, we can't open + if (isSSH && !editorConfig.useRemoteExtension) { + return { + success: false, + error: "Enable 'Use Remote-SSH' in Settings to open SSH workspaces in editor", + }; + } + + // Call the backend API + const result = await api?.general.openWorkspaceInEditor({ + workspaceId, + editorConfig, + }); + + 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/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 130e4069ba..e3fc026c24 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -244,6 +244,10 @@ export const KEYBINDS = { // macOS: Cmd+T, Win/Linux: Ctrl+T OPEN_TERMINAL: { key: "T", ctrl: true }, + /** Open workspace in editor */ + // macOS: Cmd+Shift+E, Win/Linux: Ctrl+Shift+E + OPEN_IN_EDITOR: { key: "E", ctrl: true, shift: true }, + /** Open Command Palette */ // VS Code-style palette // macOS: Cmd+Shift+P, Win/Linux: Ctrl+Shift+P diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 426efe04a0..578f18899b 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -63,6 +63,7 @@ async function createTestServer(authToken?: string): Promise { workspaceService: services.workspaceService, providerService: services.providerService, terminalService: services.terminalService, + editorService: services.editorService, windowService: services.windowService, updateService: services.updateService, tokenizerService: services.tokenizerService, diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index dfc9576cb7..ce3be52355 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -66,6 +66,7 @@ async function createTestServer(): Promise { workspaceService: services.workspaceService, providerService: services.providerService, terminalService: services.terminalService, + editorService: services.editorService, windowService: services.windowService, updateService: services.updateService, tokenizerService: services.tokenizerService, diff --git a/src/cli/server.ts b/src/cli/server.ts index 6e6849bf1a..01677bbd9a 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -74,6 +74,7 @@ const mockWindow: BrowserWindow = { workspaceService: serviceContainer.workspaceService, providerService: serviceContainer.providerService, terminalService: serviceContainer.terminalService, + editorService: serviceContainer.editorService, windowService: serviceContainer.windowService, updateService: serviceContainer.updateService, tokenizerService: serviceContainer.tokenizerService, diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 48571c8aea..d0903efcf4 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -157,6 +157,25 @@ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel"; */ export const VIM_ENABLED_KEY = "vimEnabled"; +/** + * Editor configuration for "Open in Editor" feature (global) + * Format: "editorConfig" + */ +export const EDITOR_CONFIG_KEY = "editorConfig"; + +export type EditorType = "vscode" | "cursor" | "zed" | "custom"; + +export interface EditorConfig { + editor: EditorType; + customCommand?: string; // Only when editor='custom' + useRemoteExtension: boolean; // For SSH workspaces, use Remote-SSH +} + +export const DEFAULT_EDITOR_CONFIG: EditorConfig = { + editor: "vscode", + useRemoteExtension: true, +}; + /** * Tutorial state storage key (global) * Stores: { disabled: boolean, completed: { settings?: true, creation?: true, workspace?: true } } diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 184c517b28..357ab9e04c 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -411,6 +411,14 @@ export const update = { }, }; +// Editor config schema for openWorkspaceInEditor +const EditorTypeSchema = z.enum(["vscode", "cursor", "zed", "custom"]); +const EditorConfigSchema = z.object({ + editor: EditorTypeSchema, + customCommand: z.string().optional(), + useRemoteExtension: z.boolean(), +}); + // General export const general = { listDirectory: { @@ -481,6 +489,17 @@ export const general = { requiresTerminal: z.boolean().optional(), }), }, + /** + * Open the workspace in the user's configured editor. + * For SSH workspaces with useRemoteExtension enabled, uses Remote-SSH extension. + */ + openWorkspaceInEditor: { + input: z.object({ + workspaceId: z.string(), + editorConfig: EditorConfigSchema, + }), + output: ResultSchema(z.void(), z.string()), + }, }; // Menu events (main→renderer notifications) diff --git a/src/desktop/main.ts b/src/desktop/main.ts index d56953d31d..e40b710033 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -328,6 +328,7 @@ async function loadServices(): Promise { workspaceService: services.workspaceService, providerService: services.providerService, terminalService: services.terminalService, + editorService: services.editorService, windowService: services.windowService, updateService: services.updateService, tokenizerService: services.tokenizerService, diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 887c497d12..9d22148ab0 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -5,6 +5,7 @@ import type { ProjectService } from "@/node/services/projectService"; import type { WorkspaceService } from "@/node/services/workspaceService"; import type { ProviderService } from "@/node/services/providerService"; import type { TerminalService } from "@/node/services/terminalService"; +import type { EditorService } from "@/node/services/editorService"; import type { WindowService } from "@/node/services/windowService"; import type { UpdateService } from "@/node/services/updateService"; import type { TokenizerService } from "@/node/services/tokenizerService"; @@ -22,6 +23,7 @@ export interface ORPCContext { workspaceService: WorkspaceService; providerService: ProviderService; terminalService: TerminalService; + editorService: EditorService; windowService: WindowService; updateService: UpdateService; tokenizerService: TokenizerService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 5947759882..a04c84d90b 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -262,6 +262,12 @@ export const router = (authToken?: string) => { return { method: "none" as const }; }), + openWorkspaceInEditor: t + .input(schemas.general.openWorkspaceInEditor.input) + .output(schemas.general.openWorkspaceInEditor.output) + .handler(async ({ context, input }) => { + return context.editorService.openWorkspaceInEditor(input.workspaceId, input.editorConfig); + }), }, projects: { list: t diff --git a/src/node/services/editorService.ts b/src/node/services/editorService.ts new file mode 100644 index 0000000000..9bbdb30ea0 --- /dev/null +++ b/src/node/services/editorService.ts @@ -0,0 +1,123 @@ +import { spawn, spawnSync } from "child_process"; +import type { Config } from "@/node/config"; +import { isSSHRuntime } from "@/common/types/runtime"; +import { log } from "@/node/services/log"; + +export interface EditorConfig { + editor: string; + customCommand?: string; + useRemoteExtension: boolean; +} + +/** + * Service for opening workspaces in code editors. + * Supports VS Code, Cursor, Zed, and custom editors. + * For SSH workspaces, can use Remote-SSH extension (VS Code/Cursor only). + */ +export class EditorService { + private readonly config: Config; + + private static readonly EDITOR_COMMANDS: Record = { + vscode: "code", + cursor: "cursor", + zed: "zed", + }; + + constructor(config: Config) { + this.config = config; + } + + /** + * Open the workspace in the user's configured code editor. + * For SSH workspaces with Remote-SSH extension enabled, opens directly in the editor. + */ + async openWorkspaceInEditor( + workspaceId: string, + editorConfig: EditorConfig + ): Promise<{ success: true; data: void } | { success: false; error: string }> { + try { + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const workspace = allMetadata.find((w) => w.id === workspaceId); + + if (!workspace) { + return { success: false, error: `Workspace not found: ${workspaceId}` }; + } + + const runtimeConfig = workspace.runtimeConfig; + const isSSH = isSSHRuntime(runtimeConfig); + + // Determine the editor command + const editorCommand = + editorConfig.editor === "custom" + ? editorConfig.customCommand + : EditorService.EDITOR_COMMANDS[editorConfig.editor]; + + if (!editorCommand) { + return { success: false, error: "No editor command configured" }; + } + + // Check if editor is available + const isAvailable = this.isCommandAvailable(editorCommand); + if (!isAvailable) { + return { success: false, error: `Editor command not found: ${editorCommand}` }; + } + + if (isSSH) { + // SSH workspace handling + if (!editorConfig.useRemoteExtension) { + return { + success: false, + error: "Cannot open SSH workspace without Remote-SSH extension enabled", + }; + } + + // Only VS Code and Cursor support Remote-SSH + if (editorConfig.editor !== "vscode" && editorConfig.editor !== "cursor") { + return { + success: false, + error: `${editorConfig.editor} does not support Remote-SSH for SSH workspaces`, + }; + } + + // Build the remote command: code --remote ssh-remote+host /remote/path + const sshHost = runtimeConfig.host; + const remotePath = workspace.namedWorkspacePath; + const args = ["--remote", `ssh-remote+${sshHost}`, remotePath]; + + log.info(`Opening SSH workspace in editor: ${editorCommand} ${args.join(" ")}`); + const child = spawn(editorCommand, args, { + detached: true, + stdio: "ignore", + }); + child.unref(); + } else { + // Local workspace - just open the path + const workspacePath = workspace.namedWorkspacePath; + log.info(`Opening local workspace in editor: ${editorCommand} ${workspacePath}`); + const child = spawn(editorCommand, [workspacePath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + } + + return { success: true, data: undefined }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error(`Failed to open in editor: ${message}`); + return { success: false, error: message }; + } + } + + /** + * Check if a command is available in the system PATH + */ + private isCommandAvailable(command: string): boolean { + try { + const result = spawnSync("which", [command], { encoding: "utf8" }); + return result.status === 0; + } catch { + return false; + } + } +} diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 0608b294e1..d22cd65d1f 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -11,6 +11,7 @@ import { WorkspaceService } from "@/node/services/workspaceService"; import { ProviderService } from "@/node/services/providerService"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { TerminalService } from "@/node/services/terminalService"; +import { EditorService } from "@/node/services/editorService"; import { WindowService } from "@/node/services/windowService"; import { UpdateService } from "@/node/services/updateService"; import { TokenizerService } from "@/node/services/tokenizerService"; @@ -37,6 +38,7 @@ export class ServiceContainer { public readonly workspaceService: WorkspaceService; public readonly providerService: ProviderService; public readonly terminalService: TerminalService; + public readonly editorService: EditorService; public readonly windowService: WindowService; public readonly updateService: UpdateService; public readonly tokenizerService: TokenizerService; @@ -87,6 +89,8 @@ export class ServiceContainer { this.terminalService = new TerminalService(config, this.ptyService); // Wire terminal service to workspace service for cleanup on removal this.workspaceService.setTerminalService(this.terminalService); + // Editor service for opening workspaces in code editors + this.editorService = new EditorService(config); this.windowService = new WindowService(); this.updateService = new UpdateService(); this.tokenizerService = new TokenizerService(); diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 469d72756f..8cd1908c1d 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -75,6 +75,7 @@ export async function createTestEnvironment(): Promise { workspaceService: services.workspaceService, providerService: services.providerService, terminalService: services.terminalService, + editorService: services.editorService, windowService: services.windowService, updateService: services.updateService, tokenizerService: services.tokenizerService,