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..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, runtimeConfig); - if (!result.success && result.error) { - setEditorError(result.error); - // Clear error after 3 seconds - setTimeout(() => setEditorError(null), 3000); - } - }, [workspaceId, namedWorkspacePath, openInEditor, runtimeConfig]); + 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... + )} + +