From eaf34767fdecd9cfdb20daf9cb652217287b29c0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 9 Dec 2025 18:11:35 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20vim=20editor?= =?UTF-8?q?=20support=20and=20editors.js=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ~/.mux/editors.js configuration file for editor definitions - Support two editor types: 'native' (GUI) and 'web_term' (terminal) - Ship with VS Code, Cursor, Zed, and Vim/Neovim as built-in editors - Add initialCommand support to terminal windows for vim integration - Refactor EditorService to load user-customizable JavaScript config - Add listEditors/setDefaultEditor IPC endpoints - Update Settings UI to fetch editor list from backend - Add comprehensive documentation at /docs/editor - Remove old localStorage-based editor configuration The editors.js file allows users to define custom editors with full control over how they open workspaces, supporting different behaviors for SSH vs local workspaces, browser vs desktop mode, etc. _Generated with mux_ --- docs/docs.json | 1 + docs/editor.mdx | 173 +++++++++++++ .../Settings/sections/GeneralSection.tsx | 122 ++++++---- src/browser/components/TerminalView.tsx | 18 +- src/browser/components/WorkspaceHeader.tsx | 4 +- .../components/tools/ProposePlanToolCall.tsx | 7 +- src/browser/hooks/useOpenInEditor.ts | 86 +++---- src/browser/hooks/useTerminalSession.ts | 4 + src/browser/terminal-window.tsx | 10 +- src/browser/utils/chatCommands.test.ts | 2 - src/browser/utils/chatCommands.ts | 15 +- src/common/constants/storage.ts | 18 +- src/common/orpc/schemas/api.ts | 36 ++- src/common/types/editor.ts | 170 +++++++++++++ src/desktop/terminalWindowManager.ts | 22 +- src/node/config.ts | 66 +++++ src/node/orpc/router.ts | 16 +- src/node/services/editorService.ts | 230 ++++++++++++------ src/node/services/serviceContainer.ts | 2 + src/node/services/terminalService.test.ts | 7 +- src/node/services/terminalService.ts | 8 +- 21 files changed, 770 insertions(+), 247 deletions(-) create mode 100644 docs/editor.mdx create mode 100644 src/common/types/editor.ts diff --git a/docs/docs.json b/docs/docs.json index 155381e307..5d17430738 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -47,6 +47,7 @@ ] }, "plan-mode", + "editor", "vscode-extension", "models", { diff --git a/docs/editor.mdx b/docs/editor.mdx new file mode 100644 index 0000000000..d7f3d2b153 --- /dev/null +++ b/docs/editor.mdx @@ -0,0 +1,173 @@ +--- +title: Editor Configuration +description: Configure which editor opens your workspaces +--- + +mux can open workspaces in your preferred code editor. Click the pencil icon in the workspace header or use the keyboard shortcut to open the current workspace in your editor. + +## Selecting Your Editor + +Go to **Settings → General → Editor** to choose your default editor. mux ships with support for: + +- **VS Code** - Opens with Remote-SSH for SSH workspaces +- **Cursor** - Opens with Remote-SSH for SSH workspaces +- **Zed** - Local workspaces only (no SSH support) +- **Vim/Neovim** - Opens in mux's web terminal + +## Editor Types + +Editors fall into two categories: + +### Native Editors + +Native editors (VS Code, Cursor, Zed) spawn as separate GUI applications. They work best in **desktop mode** (the Electron app). In browser mode, native editors aren't available since the browser can't launch applications on your computer. + +For SSH workspaces, VS Code and Cursor use their Remote-SSH extension to connect directly to the remote host. + +### Terminal Editors + +Terminal editors (Vim/Neovim) run inside mux's web terminal. They work in **all modes**: + +- Desktop (Electron) - Opens in a terminal window +- Browser mode - Opens in a browser popup +- SSH workspaces - Runs on the remote host via the terminal + +This makes terminal editors the most portable option. + +## Custom Editors + +You can customize the editor configuration by editing `~/.mux/editors.js`. This file is created automatically on first run with the default editors. + +### File Structure + +```javascript +export default { + // Which editor to use by default + default: "vscode", + + // Editor definitions + editors: { + vscode: { + name: "VS Code", + open: async (ctx) => { + // Return instructions for opening + }, + }, + // ... more editors + }, +}; +``` + +### Adding a Custom Editor + +Each editor has a `name` and an `open` function. The `open` function receives a context object and returns instructions: + +```javascript +// Example: Add Sublime Text +sublime: { + name: "Sublime Text", + open: async (ctx) => { + if (ctx.isBrowser) { + return { error: "Sublime Text requires the desktop app" }; + } + if (ctx.isSSH) { + return { error: "Sublime Text does not support SSH workspaces" }; + } + return { type: "native", command: "subl", args: [ctx.path] }; + }, +}, +``` + +### Context Object + +The `open` function receives these properties: + +| Property | Type | Description | +| ------------- | ---------- | ------------------------------------------ | +| `path` | `string` | Absolute path to open | +| `host` | `string?` | SSH host (if SSH workspace) | +| `isSSH` | `boolean` | Whether this is an SSH workspace | +| `isBrowser` | `boolean` | Whether running in browser mode | +| `isDesktop` | `boolean` | Whether running in desktop (Electron) mode | +| `platform` | `string` | OS platform: "darwin", "linux", or "win32" | +| `findCommand` | `function` | Find first available command from a list | + +### Return Values + +Return one of these objects: + +**Native editor (GUI application):** + +```javascript +{ type: "native", command: "code", args: ["--new-window", ctx.path] } +``` + +**Terminal editor (runs in web terminal):** + +```javascript +{ type: "web_term", command: `nvim ${ctx.path}` } +``` + +**Error (show message to user):** + +```javascript +{ + error: "This editor doesn't support SSH workspaces"; +} +``` + +### Example: Emacs + +```javascript +emacs: { + name: "Emacs", + open: async (ctx) => { + // Use emacsclient for GUI, or run in terminal for SSH + if (ctx.isSSH) { + return { type: "web_term", command: `emacs -nw ${ctx.path}` }; + } + if (ctx.isBrowser) { + return { type: "web_term", command: `emacs -nw ${ctx.path}` }; + } + // Desktop mode - use GUI emacs + return { type: "native", command: "emacsclient", args: ["-c", ctx.path] }; + }, +}, +``` + +### Example: Helix + +```javascript +helix: { + name: "Helix", + open: async (ctx) => { + const cmd = await ctx.findCommand(["hx", "helix"]); + if (!cmd) { + return { error: "Helix not found (tried hx, helix)" }; + } + return { type: "web_term", command: `${cmd} ${ctx.path}` }; + }, +}, +``` + +## SSH Workspace Support + +| Editor | SSH Support | Method | +| ---------- | ----------- | ------------------------------ | +| VS Code | ✅ | Remote-SSH extension | +| Cursor | ✅ | Remote-SSH extension | +| Zed | ❌ | Not supported | +| Vim/Neovim | ✅ | Runs in web terminal on remote | + +## Keyboard Shortcut + +Open the current workspace in your editor: + +- **macOS**: `Cmd+Shift+E` +- **Windows/Linux**: `Ctrl+Shift+E` + +## Related + +- [VS Code Extension](/vscode-extension) - Deeper VS Code integration +- [Workspaces](/workspaces) - Workspace management +- [SSH Runtime](/runtime/ssh) - SSH workspace setup diff --git a/src/browser/components/Settings/sections/GeneralSection.tsx b/src/browser/components/Settings/sections/GeneralSection.tsx index fcf6be82b4..23d83a4b40 100644 --- a/src/browser/components/Settings/sections/GeneralSection.tsx +++ b/src/browser/components/Settings/sections/GeneralSection.tsx @@ -8,21 +8,8 @@ import { SelectValue, } from "@/browser/components/ui/select"; import { Input } from "@/browser/components/ui/input"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useAPI } from "@/browser/contexts/API"; -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" }, -]; +import type { EditorInfo } from "@/common/types/editor"; // Browser mode: window.api is not set (only exists in Electron via preload) const isBrowserMode = typeof window !== "undefined" && !window.api; @@ -30,13 +17,34 @@ const isBrowserMode = typeof window !== "undefined" && !window.api; export function GeneralSection() { const { theme, setTheme } = useTheme(); const { api } = useAPI(); - const [editorConfig, setEditorConfig] = usePersistedState( - EDITOR_CONFIG_KEY, - DEFAULT_EDITOR_CONFIG - ); + const [editors, setEditors] = useState([]); + const [defaultEditor, setDefaultEditor] = useState(""); + const [loading, setLoading] = useState(true); const [sshHost, setSshHost] = useState(""); const [sshHostLoaded, setSshHostLoaded] = useState(false); + // Load editors from backend + useEffect(() => { + if (!api) return; + + const loadEditors = async () => { + try { + const editorList = await api.general.listEditors(); + setEditors(editorList); + const current = editorList.find((e) => e.isDefault); + if (current) { + setDefaultEditor(current.id); + } + } catch (err) { + console.error("Failed to load editors:", err); + } finally { + setLoading(false); + } + }; + + void loadEditors(); + }, [api]); + // Load SSH host from server on mount (browser mode only) useEffect(() => { if (isBrowserMode && api) { @@ -47,12 +55,30 @@ export function GeneralSection() { } }, [api]); - const handleEditorChange = (editor: EditorType) => { - setEditorConfig((prev) => ({ ...prev, editor })); - }; + const handleEditorChange = async (editorId: string) => { + if (!api) return; - const handleCustomCommandChange = (customCommand: string) => { - setEditorConfig((prev) => ({ ...prev, customCommand })); + // Optimistic update + setDefaultEditor(editorId); + setEditors((prev) => + prev.map((e) => ({ + ...e, + isDefault: e.id === editorId, + })) + ); + + try { + await api.general.setDefaultEditor({ editorId }); + } catch (err) { + console.error("Failed to set default editor:", err); + // Revert on error + const editorList = await api.general.listEditors(); + setEditors(editorList); + const current = editorList.find((e) => e.isDefault); + if (current) { + setDefaultEditor(current.id); + } + } }; const handleSshHostChange = useCallback( @@ -91,46 +117,36 @@ export function GeneralSection() {
Editor
-
Editor to open files in
+
+ Default editor for opening workspaces.{" "} + + Learn more + +
- void handleEditorChange(value)} + disabled={loading} + > - + - {EDITOR_OPTIONS.map((option) => ( - - {option.label} + {editors.map((editor) => ( + + {editor.name} ))}
- {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" - /> -
- {isBrowserMode && ( -
- Custom editors are not supported in browser mode. Use VS Code or Cursor instead. -
- )} -
- )} - {isBrowserMode && sshHostLoaded && (
diff --git a/src/browser/components/TerminalView.tsx b/src/browser/components/TerminalView.tsx index ed5c663cde..491d5d74b5 100644 --- a/src/browser/components/TerminalView.tsx +++ b/src/browser/components/TerminalView.tsx @@ -6,10 +6,16 @@ import { useAPI } from "@/browser/contexts/API"; interface TerminalViewProps { workspaceId: string; sessionId?: string; + initialCommand?: string; visible: boolean; } -export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewProps) { +export function TerminalView({ + workspaceId, + sessionId, + initialCommand, + visible, +}: TerminalViewProps) { const containerRef = useRef(null); const termRef = useRef(null); const fitAddonRef = useRef(null); @@ -57,7 +63,15 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr sendInput, resize, error: sessionError, - } = useTerminalSession(workspaceId, sessionId, visible, terminalSize, handleOutput, handleExit); + } = useTerminalSession( + workspaceId, + sessionId, + initialCommand, + visible, + terminalSize, + handleOutput, + handleExit + ); // Keep refs to latest functions so callbacks always use current version const sendInputRef = useRef(sendInput); diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index 5dce2aec82..e00297ff3f 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -40,13 +40,13 @@ export const WorkspaceHeader: React.FC = ({ const handleOpenInEditor = useCallback(async () => { setEditorError(null); - const result = await openInEditor(workspaceId, namedWorkspacePath, runtimeConfig); + const result = await openInEditor(workspaceId, namedWorkspacePath); if (!result.success && result.error) { setEditorError(result.error); // Clear error after 3 seconds setTimeout(() => setEditorError(null), 3000); } - }, [workspaceId, namedWorkspacePath, openInEditor, runtimeConfig]); + }, [workspaceId, namedWorkspacePath, openInEditor]); // Start workspace tutorial on first entry (only if settings tutorial is done) useEffect(() => { diff --git a/src/browser/components/tools/ProposePlanToolCall.tsx b/src/browser/components/tools/ProposePlanToolCall.tsx index d916a41e6d..8c22e97482 100644 --- a/src/browser/components/tools/ProposePlanToolCall.tsx +++ b/src/browser/components/tools/ProposePlanToolCall.tsx @@ -22,7 +22,6 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { cn } from "@/common/lib/utils"; import { useAPI } from "@/browser/contexts/API"; import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor"; -import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { usePopoverError } from "@/browser/hooks/usePopoverError"; import { PopoverError } from "../PopoverError"; @@ -117,12 +116,8 @@ export const ProposePlanToolCall: React.FC = (props) = const [showRaw, setShowRaw] = useState(false); const { api } = useAPI(); const openInEditor = useOpenInEditor(); - const { workspaceMetadata } = useWorkspaceContext(); const editorError = usePopoverError(); - // Get runtimeConfig for the workspace (needed for SSH-aware editor opening) - const runtimeConfig = workspaceId ? workspaceMetadata.get(workspaceId)?.runtimeConfig : undefined; - // Fresh content from disk for the latest plan (external edit detection) // Skip for ephemeral previews which already have fresh content const [freshContent, setFreshContent] = useState(null); @@ -233,7 +228,7 @@ export const ProposePlanToolCall: React.FC = (props) = if (!planPath || !workspaceId) { return; } - const result = await openInEditor(workspaceId, planPath, runtimeConfig); + const result = await openInEditor(workspaceId, planPath); if (!result.success && result.error) { const rect = (event.target as HTMLElement).getBoundingClientRect(); editorError.showError("plan-editor", result.error, { diff --git a/src/browser/hooks/useOpenInEditor.ts b/src/browser/hooks/useOpenInEditor.ts index 2896b71238..c2f5031e97 100644 --- a/src/browser/hooks/useOpenInEditor.ts +++ b/src/browser/hooks/useOpenInEditor.ts @@ -1,12 +1,5 @@ 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 { @@ -23,71 +16,62 @@ export interface OpenInEditorResult { // Browser mode: window.api is not set (only exists in Electron via preload) const isBrowserMode = typeof window !== "undefined" && !window.api; +// Editors that support deep links in browser mode +const DEEP_LINK_EDITORS = ["vscode", "cursor", "zed"]; + /** * 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 Electron mode: calls the backend API which uses ~/.mux/editors.js config. * In browser mode: generates deep link URLs (vscode://, cursor://) that open - * the user's locally installed editor. + * the user's locally installed editor. Only VS Code/Cursor support SSH remote. * - * If no editor is configured, opens Settings to the General section. - * For SSH workspaces with unsupported editors (Zed, custom), returns an error. + * Editor configuration is handled by the backend via ~/.mux/editors.js. + * The backend determines the appropriate editor based on: + * - User's default editor preference + * - Workspace type (local vs SSH) + * - Runtime environment (desktop vs browser) * * @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 + * - runtimeConfig: optional, used for SSH host in browser mode deep links + * - editorId: optional, override the default editor */ export function useOpenInEditor() { const { api } = useAPI(); - const { open: openSettings } = useSettings(); return useCallback( async ( workspaceId: string, targetPath: string, - runtimeConfig?: RuntimeConfig + runtimeConfig?: RuntimeConfig, + editorId?: string ): 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 for supported editors + if (isBrowserMode) { + // Get the editor to use - either specified or fetch default from backend + let editor = editorId; + if (!editor) { + try { + const editors = await api?.general.listEditors(); + const defaultEditor = editors?.find((e) => e.isDefault); + editor = defaultEditor?.id; + } catch { + // Fall back to vscode if we can't fetch + editor = "vscode"; + } } - } - // Browser mode: use deep links instead of backend spawn - if (isBrowserMode) { - // Custom editor can't work via deep links - if (editorConfig.editor === "custom") { + if (!editor || !DEEP_LINK_EDITORS.includes(editor)) { return { success: false, - error: "Custom editors are not supported in browser mode. Use VS Code or Cursor.", + error: `${editor || "This editor"} is not supported in browser mode. Use VS Code or Cursor.`, }; } + const isSSH = isSSHRuntime(runtimeConfig); + // Determine SSH host for deep link let sshHost: string | undefined; if (isSSH && runtimeConfig?.type === "ssh") { @@ -101,7 +85,7 @@ export function useOpenInEditor() { // else: localhost access to local workspace → no SSH needed const deepLink = getEditorDeepLink({ - editor: editorConfig.editor as DeepLinkEditor, + editor: editor as DeepLinkEditor, path: targetPath, sshHost, }); @@ -109,7 +93,7 @@ export function useOpenInEditor() { if (!deepLink) { return { success: false, - error: `${editorConfig.editor} does not support SSH remote connections`, + error: `${editor} does not support SSH remote connections`, }; } @@ -122,7 +106,7 @@ export function useOpenInEditor() { const result = await api?.general.openInEditor({ workspaceId, targetPath, - editorConfig, + editorId, }); if (!result) { @@ -135,6 +119,6 @@ export function useOpenInEditor() { return { success: true }; }, - [api, openSettings] + [api] ); } diff --git a/src/browser/hooks/useTerminalSession.ts b/src/browser/hooks/useTerminalSession.ts index 8a228cdbfd..23f65e30e8 100644 --- a/src/browser/hooks/useTerminalSession.ts +++ b/src/browser/hooks/useTerminalSession.ts @@ -10,10 +10,13 @@ import type { TerminalSession } from "@/common/types/terminal"; * 1. Create new session: when existingSessionId is undefined, creates a new PTY session * 2. Reattach to existing session: when existingSessionId is provided (e.g., from openInEditor), * subscribes to that session without creating a new one + * + * @param initialCommand - Optional command to run immediately after terminal creation (e.g., "vim /path/to/file") */ export function useTerminalSession( workspaceId: string, existingSessionId: string | undefined, + initialCommand: string | undefined, enabled: boolean, terminalSize?: { cols: number; rows: number } | null, onOutput?: (data: string) => void, @@ -60,6 +63,7 @@ export function useTerminalSession( workspaceId, cols: terminalSize.cols, rows: terminalSize.rows, + initialCommand, }); if (!mounted) { diff --git a/src/browser/terminal-window.tsx b/src/browser/terminal-window.tsx index bb1b22c0ba..3bc6d892a5 100644 --- a/src/browser/terminal-window.tsx +++ b/src/browser/terminal-window.tsx @@ -11,10 +11,11 @@ import { TerminalView } from "@/browser/components/TerminalView"; import { APIProvider } from "@/browser/contexts/API"; import "./styles/globals.css"; -// Get workspace ID from query parameter +// Get parameters from query string const params = new URLSearchParams(window.location.search); const workspaceId = params.get("workspaceId"); const sessionId = params.get("sessionId"); // Reserved for future reload support +const initialCommand = params.get("initialCommand"); // Command to run on terminal start (e.g., vim) if (!workspaceId) { document.body.innerHTML = ` @@ -30,7 +31,12 @@ if (!workspaceId) { // race conditions with WebSocket connections and terminal lifecycle ReactDOM.createRoot(document.getElementById("root")!).render( - + ); } diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index e306929343..d139f7d2fc 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -318,8 +318,6 @@ describe("handlePlanOpenCommand", () => { 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), }); }); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 2b322dd621..5f7f19e357 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -25,19 +25,12 @@ 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"; // ============================================================================ // Workspace Creation @@ -975,14 +968,10 @@ 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) + // Open in editor - backend handles editor selection from ~/.mux/editors.js const openResult = await api.general.openInEditor({ workspaceId, targetPath: planResult.data.path, - editorConfig, }); if (!openResult.success) { diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 9337e8f54b..d5117bb0c4 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -157,22 +157,8 @@ 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' -} - -export const DEFAULT_EDITOR_CONFIG: EditorConfig = { - editor: "vscode", -}; +// Note: Editor configuration moved to ~/.mux/editors.js +// See src/common/types/editor.ts for types /** * Tutorial state storage key (global) diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index eda301d4bf..cf8d47a5b2 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -417,7 +417,11 @@ export const terminal = { output: eventIterator(z.number()), }, openWindow: { - input: z.object({ workspaceId: z.string() }), + input: z.object({ + workspaceId: z.string(), + /** Optional command to run immediately after terminal opens (e.g., "vim /path/to/file") */ + initialCommand: z.string().optional(), + }), output: z.void(), }, closeWindow: { @@ -471,11 +475,11 @@ 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(), +// Editor info for listing available editors +const EditorInfoSchema = z.object({ + id: z.string(), + name: z.string(), + isDefault: z.boolean(), }); // General @@ -501,20 +505,34 @@ export const general = { }, /** * Open a path in the user's configured code editor. - * For SSH workspaces with useRemoteExtension enabled, uses Remote-SSH extension. + * Uses the default editor from ~/.mux/editors.js unless editorId is specified. * * @param workspaceId - The workspace (used to determine if SSH and get remote host) * @param targetPath - The path to open (workspace directory or specific file) - * @param editorConfig - Editor configuration from user settings + * @param editorId - Optional editor ID override (uses default if not provided) */ openInEditor: { input: z.object({ workspaceId: z.string(), targetPath: z.string(), - editorConfig: EditorConfigSchema, + editorId: z.string().optional(), }), output: ResultSchema(z.void(), z.string()), }, + /** + * List available editors from ~/.mux/editors.js + */ + listEditors: { + input: z.void(), + output: z.array(EditorInfoSchema), + }, + /** + * Set the default editor + */ + setDefaultEditor: { + input: z.object({ editorId: z.string() }), + output: z.void(), + }, }; // Menu events (main→renderer notifications) diff --git a/src/common/types/editor.ts b/src/common/types/editor.ts new file mode 100644 index 0000000000..284c756af6 --- /dev/null +++ b/src/common/types/editor.ts @@ -0,0 +1,170 @@ +/** + * Editor configuration types for the ~/.mux/editors.js system. + * + * Editors can open workspaces in two modes: + * - "native": Spawns a detached GUI process (VS Code, Cursor, Zed) + * - "web_term": Opens in mux's web terminal with a command (vim, nvim) + */ + +/** + * Context passed to editor open functions. + * Provides information about the workspace and environment. + */ +export interface EditorContext { + /** Absolute path to open (workspace directory or specific file) */ + path: string; + /** SSH host if this is an SSH workspace */ + host?: string; + /** Whether this is an SSH workspace */ + isSSH: boolean; + /** Whether running in browser mode (no native process spawning) */ + isBrowser: boolean; + /** Whether running in desktop (Electron) mode */ + isDesktop: boolean; + /** Operating system platform */ + platform: NodeJS.Platform; + /** Find the first available command from a list of candidates */ + findCommand: (commands: string[]) => Promise; +} + +/** + * Result from an editor's open function. + */ +export type EditorOpenResult = + | { + /** Spawn a native/detached GUI process */ + type: "native"; + /** Command to execute */ + command: string; + /** Arguments to pass */ + args: string[]; + } + | { + /** Open in web terminal with this command */ + type: "web_term"; + /** Full command string to run in terminal */ + command: string; + } + | { + /** Editor cannot handle this context */ + error: string; + }; + +/** + * Editor definition in editors.js + */ +export interface EditorDefinition { + /** Display name shown in UI */ + name: string; + /** Function that determines how to open the editor */ + open: (ctx: EditorContext) => Promise | EditorOpenResult; +} + +/** + * Structure of ~/.mux/editors.js default export + */ +export interface EditorsConfig { + /** ID of the default editor */ + default: string; + /** Map of editor ID to editor definition */ + editors: Record; +} + +/** + * Simplified editor info for frontend display (no functions) + */ +export interface EditorInfo { + id: string; + name: string; + isDefault: boolean; +} + +/** + * Default editors.js content shipped with mux. + * This is written to ~/.mux/editors.js on first run. + */ +export const DEFAULT_EDITORS_JS = `// Editor configuration for mux +// Customize how editors open workspaces +// +// Each editor has: +// name: Display name in the UI +// open(ctx): Function that returns how to open the editor +// +// ctx provides: +// path - Workspace path to open +// host - SSH host (if SSH workspace) +// isSSH - Whether this is an SSH workspace +// isBrowser - Whether running in browser mode +// isDesktop - Whether running in Electron desktop mode +// platform - "darwin", "linux", or "win32" +// findCommand(cmds) - Find first available command from list +// +// Return one of: +// { type: "native", command: "...", args: [...] } - Spawn GUI process +// { type: "web_term", command: "..." } - Open in web terminal +// { error: "..." } - Show error to user + +export default { + default: "vscode", + + editors: { + vscode: { + name: "VS Code", + open: async (ctx) => { + if (ctx.isBrowser) { + return { error: "VS Code requires the desktop app" }; + } + if (ctx.isSSH) { + return { + type: "native", + command: "code", + args: ["--remote", \`ssh-remote+\${ctx.host}\`, ctx.path], + }; + } + return { type: "native", command: "code", args: [ctx.path] }; + }, + }, + + cursor: { + name: "Cursor", + open: async (ctx) => { + if (ctx.isBrowser) { + return { error: "Cursor requires the desktop app" }; + } + if (ctx.isSSH) { + return { + type: "native", + command: "cursor", + args: ["--remote", \`ssh-remote+\${ctx.host}\`, ctx.path], + }; + } + return { type: "native", command: "cursor", args: [ctx.path] }; + }, + }, + + zed: { + name: "Zed", + open: async (ctx) => { + if (ctx.isBrowser) { + return { error: "Zed requires the desktop app" }; + } + if (ctx.isSSH) { + return { error: "Zed does not support SSH workspaces" }; + } + return { type: "native", command: "zed", args: [ctx.path] }; + }, + }, + + vim: { + name: "Vim/Neovim", + open: async (ctx) => { + const cmd = await ctx.findCommand(["nvim", "vim", "vi"]); + if (!cmd) { + return { error: "No vim-like editor found (tried nvim, vim, vi)" }; + } + return { type: "web_term", command: \`\${cmd} \${ctx.path}\` }; + }, + }, + }, +}; +`; diff --git a/src/desktop/terminalWindowManager.ts b/src/desktop/terminalWindowManager.ts index 1b581cc4a3..5785dcfce6 100644 --- a/src/desktop/terminalWindowManager.ts +++ b/src/desktop/terminalWindowManager.ts @@ -22,8 +22,9 @@ export class TerminalWindowManager { /** * Open a new terminal window for a workspace * Multiple windows can be open for the same workspace + * @param initialCommand - Optional command to run immediately after terminal opens (e.g., "vim /path/to/file") */ - async openTerminalWindow(workspaceId: string): Promise { + async openTerminalWindow(workspaceId: string, initialCommand?: string): Promise { this.windowCount++; const windowId = this.windowCount; @@ -75,20 +76,25 @@ export class TerminalWindowManager { const forceDistLoad = process.env.MUX_E2E_LOAD_DIST === "1"; const useDevServer = !app.isPackaged && !forceDistLoad; + // Build query params + const query: Record = { workspaceId }; + if (initialCommand) { + query.initialCommand = initialCommand; + } + if (useDevServer) { // Development mode - load from Vite dev server - await terminalWindow.loadURL( - `http://localhost:5173/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}` - ); + const params = new URLSearchParams(query); + await terminalWindow.loadURL(`http://localhost:5173/terminal.html?${params.toString()}`); terminalWindow.webContents.openDevTools(); } else { // Production mode (or E2E dist mode) - load from built files - await terminalWindow.loadFile(path.join(__dirname, "../terminal.html"), { - query: { workspaceId }, - }); + await terminalWindow.loadFile(path.join(__dirname, "../terminal.html"), { query }); } - log.info(`Terminal window ${windowId} opened for workspace: ${workspaceId}`); + log.info( + `Terminal window ${windowId} opened for workspace: ${workspaceId}${initialCommand ? ` with command: ${initialCommand}` : ""}` + ); } /** diff --git a/src/node/config.ts b/src/node/config.ts index cab4b63536..69e40f4625 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -39,6 +39,7 @@ export class Config { private readonly configFile: string; private readonly providersFile: string; private readonly secretsFile: string; + private readonly editorsFile: string; constructor(rootDir?: string) { this.rootDir = rootDir ?? getMuxHome(); @@ -47,6 +48,7 @@ export class Config { this.configFile = path.join(this.rootDir, "config.json"); this.providersFile = path.join(this.rootDir, "providers.jsonc"); this.secretsFile = path.join(this.rootDir, "secrets.json"); + this.editorsFile = path.join(this.rootDir, "editors.js"); } loadConfigOrDefault(): ProjectsConfig { @@ -588,6 +590,70 @@ ${jsonString}`; config[projectPath] = secrets; await this.saveSecretsConfig(config); } + + /** + * Get the path to the editors.js config file + */ + getEditorsFilePath(): string { + return this.editorsFile; + } + + /** + * Check if editors.js exists + */ + editorsFileExists(): boolean { + return fs.existsSync(this.editorsFile); + } + + /** + * Initialize editors.js with default content if it doesn't exist + */ + async ensureEditorsFile(defaultContent: string): Promise { + if (!this.editorsFileExists()) { + if (!fs.existsSync(this.rootDir)) { + fs.mkdirSync(this.rootDir, { recursive: true }); + } + await writeFileAtomic(this.editorsFile, defaultContent, "utf-8"); + } + } + + /** + * Get the default editor ID from editors.js + * Returns "vscode" if file doesn't exist or can't be parsed + */ + getDefaultEditorId(): string { + try { + if (!this.editorsFileExists()) { + return "vscode"; + } + // Read file and extract default using regex (avoid full import for this simple query) + const content = fs.readFileSync(this.editorsFile, "utf-8"); + const defaultRegex = /default:\s*["']([^"']+)["']/; + const match = defaultRegex.exec(content); + return match?.[1] ?? "vscode"; + } catch (error) { + log.error("Error reading default editor:", error); + return "vscode"; + } + } + + /** + * Set the default editor ID in editors.js + */ + async setDefaultEditorId(editorId: string): Promise { + try { + if (!this.editorsFileExists()) { + throw new Error("editors.js does not exist"); + } + const content = fs.readFileSync(this.editorsFile, "utf-8"); + // Replace the default: "..." line + const updated = content.replace(/default:\s*["'][^"']+["']/, `default: "${editorId}"`); + await writeFileAtomic(this.editorsFile, updated, "utf-8"); + } catch (error) { + log.error("Error setting default editor:", error); + throw error; + } + } } // Default instance for application use diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index bc63fab134..00100c7269 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -163,9 +163,21 @@ export const router = (authToken?: string) => { return context.editorService.openInEditor( input.workspaceId, input.targetPath, - input.editorConfig + input.editorId ); }), + listEditors: t + .input(schemas.general.listEditors.input) + .output(schemas.general.listEditors.output) + .handler(async ({ context }) => { + return context.editorService.listEditors(); + }), + setDefaultEditor: t + .input(schemas.general.setDefaultEditor.input) + .output(schemas.general.setDefaultEditor.output) + .handler(async ({ context, input }) => { + return context.editorService.setDefaultEditor(input.editorId); + }), }, projects: { list: t @@ -786,7 +798,7 @@ export const router = (authToken?: string) => { .input(schemas.terminal.openWindow.input) .output(schemas.terminal.openWindow.output) .handler(async ({ context, input }) => { - return context.terminalService.openWindow(input.workspaceId); + return context.terminalService.openWindow(input.workspaceId, input.initialCommand); }), closeWindow: t .input(schemas.terminal.closeWindow.input) diff --git a/src/node/services/editorService.ts b/src/node/services/editorService.ts index 5cf3f89181..b315bdf908 100644 --- a/src/node/services/editorService.ts +++ b/src/node/services/editorService.ts @@ -1,55 +1,135 @@ -import { spawn, spawnSync } from "child_process"; +import { spawn } from "child_process"; +import { statSync } from "fs"; import type { Config } from "@/node/config"; import { isSSHRuntime } from "@/common/types/runtime"; import { log } from "@/node/services/log"; import { createRuntime } from "@/node/runtime/runtimeFactory"; - -/** - * Quote a string for safe use in shell commands. - * Uses single quotes with proper escaping for embedded single quotes. - */ -function shellQuote(value: string): string { - if (value.length === 0) return "''"; - return "'" + value.replace(/'/g, "'\"'\"'") + "'"; -} - -export interface EditorConfig { - editor: string; - customCommand?: string; -} +import { findAvailableCommand } from "@/node/utils/commandDiscovery"; +import type { TerminalService } from "@/node/services/terminalService"; +import type { + EditorContext, + EditorsConfig, + EditorInfo, + EditorOpenResult, +} from "@/common/types/editor"; +import { DEFAULT_EDITORS_JS } from "@/common/types/editor"; /** * 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). + * + * Editor configuration is loaded from ~/.mux/editors.js which exports: + * - default: string (editor ID) + * - editors: Record + * + * Each editor's open() function receives context about the workspace + * and returns instructions for how to open it (native spawn or web terminal). */ export class EditorService { private readonly config: Config; - - private static readonly EDITOR_COMMANDS: Record = { - vscode: "code", - cursor: "cursor", - zed: "zed", - }; + private terminalService?: TerminalService; + private editorsConfig: EditorsConfig | null = null; + private editorsConfigMtime = 0; constructor(config: Config) { this.config = config; } + /** + * Set the terminal service reference (for opening web terminals). + * Called after service container initialization to avoid circular deps. + */ + setTerminalService(terminalService: TerminalService): void { + this.terminalService = terminalService; + } + + /** + * Check if running in browser mode (no native process spawning) + */ + isBrowserMode(): boolean { + return !this.terminalService?.isDesktopMode(); + } + + /** + * Load editors config from ~/.mux/editors.js + * Creates default file if it doesn't exist. + * Caches config and reloads when file changes. + */ + private async loadEditorsConfig(): Promise { + const editorsPath = this.config.getEditorsFilePath(); + + // Ensure file exists with defaults + await this.config.ensureEditorsFile(DEFAULT_EDITORS_JS); + + // Check if we need to reload (file modified) + let mtime = 0; + try { + mtime = statSync(editorsPath).mtimeMs; + } catch { + // File doesn't exist, will be created + } + + if (this.editorsConfig && mtime === this.editorsConfigMtime) { + return this.editorsConfig; + } + + // Dynamic import the JS file + // Add cache-busting query to force reload when file changes + // Note: Dynamic import is required here - we're loading user-defined JavaScript config + // eslint-disable-next-line no-restricted-syntax + const module = (await import(/* @vite-ignore */ `file://${editorsPath}?t=${mtime}`)) as { + default: EditorsConfig; + }; + this.editorsConfig = module.default; + this.editorsConfigMtime = mtime; + return this.editorsConfig; + } + + /** + * Get list of available editors for the UI + */ + async listEditors(): Promise { + const config = await this.loadEditorsConfig(); + return Object.entries(config.editors).map(([id, editor]) => ({ + id, + name: editor.name, + isDefault: id === config.default, + })); + } + + /** + * Get the current default editor ID + */ + getDefaultEditorId(): string { + return this.config.getDefaultEditorId(); + } + + /** + * Set the default editor + */ + async setDefaultEditor(editorId: string): Promise { + const config = await this.loadEditorsConfig(); + if (!config.editors[editorId]) { + throw new Error(`Unknown editor: ${editorId}`); + } + await this.config.setDefaultEditorId(editorId); + // Invalidate cache so next load picks up the change + this.editorsConfigMtime = 0; + } + /** * Open a path in the user's configured code editor. - * For SSH workspaces with Remote-SSH extension enabled, opens directly in the editor. * * @param workspaceId - The workspace (used to determine if SSH and get remote host) * @param targetPath - The path to open (workspace directory or specific file) - * @param editorConfig - Editor configuration from user settings + * @param editorId - Optional editor ID override (uses default if not provided) */ async openInEditor( workspaceId: string, targetPath: string, - editorConfig: EditorConfig + editorId?: string ): Promise<{ success: true; data: void } | { success: false; error: string }> { try { + // Load workspace metadata const allMetadata = await this.config.getAllWorkspaceMetadata(); const workspace = allMetadata.find((w) => w.id === workspaceId); @@ -57,80 +137,76 @@ export class EditorService { return { success: false, error: `Workspace not found: ${workspaceId}` }; } + // Load editor config + const editorsConfig = await this.loadEditorsConfig(); + const selectedEditorId = editorId ?? editorsConfig.default; + const editor = editorsConfig.editors[selectedEditorId]; + + if (!editor) { + return { success: false, error: `Unknown editor: ${selectedEditorId}` }; + } + + // Build context for the editor's open function const runtimeConfig = workspace.runtimeConfig; const isSSH = isSSHRuntime(runtimeConfig); - // Determine the editor command - const editorCommand = - editorConfig.editor === "custom" - ? editorConfig.customCommand - : EditorService.EDITOR_COMMANDS[editorConfig.editor]; + // Resolve path for SSH workspaces (VS Code doesn't expand ~) + let resolvedPath = targetPath; + if (isSSH) { + const runtime = createRuntime(runtimeConfig, { projectPath: workspace.projectPath }); + resolvedPath = await runtime.resolvePath(targetPath); + } - if (!editorCommand) { - return { success: false, error: "No editor command configured" }; + const ctx: EditorContext = { + path: resolvedPath, + host: isSSH ? runtimeConfig.host : undefined, + isSSH, + isBrowser: this.isBrowserMode(), + isDesktop: !this.isBrowserMode(), + platform: process.platform, + findCommand: (commands: string[]) => findAvailableCommand(commands), + }; + + // Call the editor's open function + const result: EditorOpenResult = await editor.open(ctx); + + // Handle error response + if ("error" in result) { + return { success: false, error: result.error }; } - // Check if editor is available - const isAvailable = this.isCommandAvailable(editorCommand); - if (!isAvailable) { - return { success: false, error: `Editor command not found: ${editorCommand}` }; + // Handle web terminal response + if (result.type === "web_term") { + if (!this.terminalService) { + return { success: false, error: "Terminal service not available" }; + } + await this.terminalService.openWindow(workspaceId, result.command); + return { success: true, data: undefined }; } - if (isSSH) { - // SSH workspace handling - only VS Code and Cursor support Remote-SSH - if (editorConfig.editor !== "vscode" && editorConfig.editor !== "cursor") { + // Handle native spawn response + if (result.type === "native") { + if (this.isBrowserMode()) { return { success: false, - error: `${editorConfig.editor} does not support Remote-SSH for SSH workspaces`, + error: "Native editors are not available in browser mode", }; } - // Resolve tilde paths to absolute paths for SSH (VS Code doesn't expand ~) - const runtime = createRuntime(runtimeConfig, { projectPath: workspace.projectPath }); - const resolvedPath = await runtime.resolvePath(targetPath); - - // Build the remote command: code --remote ssh-remote+host /remote/path - // Quote the path to handle spaces; the remote host arg doesn't need quoting - const shellCmd = `${editorCommand} --remote ${shellQuote(`ssh-remote+${runtimeConfig.host}`)} ${shellQuote(resolvedPath)}`; - - log.info(`Opening SSH path in editor: ${shellCmd}`); - const child = spawn(shellCmd, [], { - detached: true, - stdio: "ignore", - shell: true, - }); - child.unref(); - } else { - // Local - just open the path (quote to handle spaces) - const shellCmd = `${editorCommand} ${shellQuote(targetPath)}`; - log.info(`Opening local path in editor: ${shellCmd}`); - const child = spawn(shellCmd, [], { + log.info(`Opening in editor: ${result.command} ${result.args.join(" ")}`); + const child = spawn(result.command, result.args, { detached: true, stdio: "ignore", - shell: true, }); child.unref(); + return { success: true, data: undefined }; } - return { success: true, data: undefined }; + return { success: false, error: "Invalid editor response" }; } 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. - * Uses shell: true to ensure we get the full PATH from user's shell profile, - * which is necessary for commands installed via Homebrew or similar. - */ - private isCommandAvailable(command: string): boolean { - try { - const result = spawnSync("which", [command], { encoding: "utf8", shell: true }); - return result.status === 0; - } catch { - return false; - } - } } diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 8807c754e7..562f43acf8 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -94,6 +94,8 @@ export class ServiceContainer { this.workspaceService.setTerminalService(this.terminalService); // Editor service for opening workspaces in code editors this.editorService = new EditorService(config); + // Wire terminal service to editor service for web_term editors + this.editorService.setTerminalService(this.terminalService); this.windowService = new WindowService(); this.updateService = new UpdateService(); this.tokenizerService = new TokenizerService(); diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 49712b2b04..b8462706f6 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -116,7 +116,12 @@ describe("TerminalService", () => { it("should open terminal window via manager", async () => { await service.openWindow("ws-1"); - expect(openTerminalWindowMock).toHaveBeenCalledWith("ws-1"); + expect(openTerminalWindowMock).toHaveBeenCalledWith("ws-1", undefined); + }); + + it("should open terminal window with initial command", async () => { + await service.openWindow("ws-1", "vim /path/to/file"); + expect(openTerminalWindowMock).toHaveBeenCalledWith("ws-1", "vim /path/to/file"); }); it("should handle session exit", async () => { diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 7a5563d018..61f8f69b10 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -170,7 +170,7 @@ export class TerminalService { } } - async openWindow(workspaceId: string): Promise { + async openWindow(workspaceId: string, initialCommand?: string): Promise { try { const allMetadata = await this.config.getAllWorkspaceMetadata(); const workspace = allMetadata.find((w) => w.id === workspaceId); @@ -184,8 +184,10 @@ export class TerminalService { const isDesktop = !!this.terminalWindowManager; if (isDesktop) { - log.info(`Opening terminal window for workspace: ${workspaceId}`); - await this.terminalWindowManager!.openTerminalWindow(workspaceId); + log.info( + `Opening terminal window for workspace: ${workspaceId}${initialCommand ? ` with command: ${initialCommand}` : ""}` + ); + await this.terminalWindowManager!.openTerminalWindow(workspaceId, initialCommand); } else { log.info( `Browser mode: terminal UI handled by browser for ${isSSH ? "SSH" : "local"} workspace: ${workspaceId}` From aeb1c737c25703a172ab9f7b1045627be6281b1a Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 9 Dec 2025 19:29:01 -0600 Subject: [PATCH 2/2] feat: add editor dropdown selector in workspace header - Add shadcn dropdown-menu component - Replace single edit button with dropdown showing all available editors - Default editor is marked with a checkmark - User can select any editor for one-off use - Update useOpenInEditor to accept optional editorId parameter --- src/browser/components/WorkspaceHeader.tsx | 92 +++++++--- src/browser/components/ui/dropdown-menu.tsx | 186 ++++++++++++++++++++ src/browser/hooks/useOpenInEditor.ts | 2 +- tests/ipc/planCommands.test.ts | 68 +++---- 4 files changed, 275 insertions(+), 73 deletions(-) create mode 100644 src/browser/components/ui/dropdown-menu.tsx diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index e00297ff3f..31076e9f6f 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -1,8 +1,16 @@ import React, { useCallback, useEffect, useState } from "react"; -import { Pencil } from "lucide-react"; +import { Pencil, ChevronDown, Check } from "lucide-react"; import { GitStatusIndicator } from "./GitStatusIndicator"; import { RuntimeBadge } from "./RuntimeBadge"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "./ui/dropdown-menu"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useGitStatus } from "@/browser/stores/GitStatusStore"; import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; @@ -11,6 +19,8 @@ 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"; +import { useAPI } from "@/browser/contexts/API"; +import type { EditorInfo } from "@/common/types/editor"; interface WorkspaceHeaderProps { workspaceId: string; @@ -27,26 +37,37 @@ export const WorkspaceHeader: React.FC = ({ namedWorkspacePath, runtimeConfig, }) => { + const { api } = useAPI(); 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 [editors, setEditors] = useState([]); + + // Load editors from backend + useEffect(() => { + if (!api) return; + void api.general.listEditors().then(setEditors).catch(console.error); + }, [api]); const handleOpenTerminal = useCallback(() => { openTerminal(workspaceId, runtimeConfig); }, [workspaceId, openTerminal, runtimeConfig]); - const handleOpenInEditor = useCallback(async () => { - setEditorError(null); - const result = await openInEditor(workspaceId, namedWorkspacePath); - if (!result.success && result.error) { - setEditorError(result.error); - // Clear error after 3 seconds - setTimeout(() => setEditorError(null), 3000); - } - }, [workspaceId, namedWorkspacePath, openInEditor]); + const handleOpenInEditor = useCallback( + async (editorId?: string) => { + setEditorError(null); + const result = await openInEditor(workspaceId, namedWorkspacePath, runtimeConfig, editorId); + if (!result.success && result.error) { + setEditorError(result.error); + // Clear error after 3 seconds + setTimeout(() => setEditorError(null), 3000); + } + }, + [workspaceId, namedWorkspacePath, openInEditor, runtimeConfig] + ); // Start workspace tutorial on first entry (only if settings tutorial is done) useEffect(() => { @@ -81,21 +102,42 @@ export const WorkspaceHeader: React.FC = ({
{editorError && {editorError}} - - - - - - Open in editor ({formatKeybind(KEYBINDS.OPEN_IN_EDITOR)}) - - + + + + + + + + + Open in editor ({formatKeybind(KEYBINDS.OPEN_IN_EDITOR)}) + + + + Open in Editor + + {editors.map((editor) => ( + void handleOpenInEditor(editor.id)} + className="cursor-pointer" + > + {editor.name} + {editor.isDefault && } + + ))} + {editors.length === 0 && ( + Loading editors... + )} + +