Skip to content

Commit 0e3c2fa

Browse files
committed
🤖 feat: add vim editor support and editors.js configuration
- 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_
1 parent 620aced commit 0e3c2fa

File tree

22 files changed

+758
-276
lines changed

22 files changed

+758
-276
lines changed

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
]
4848
},
4949
"plan-mode",
50+
"editor",
5051
"vscode-extension",
5152
"models",
5253
{

docs/editor.mdx

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
---
2+
title: Editor Configuration
3+
description: Configure which editor opens your workspaces
4+
---
5+
6+
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.
7+
8+
## Selecting Your Editor
9+
10+
Go to **Settings → General → Editor** to choose your default editor. mux ships with support for:
11+
12+
- **VS Code** - Opens with Remote-SSH for SSH workspaces
13+
- **Cursor** - Opens with Remote-SSH for SSH workspaces
14+
- **Zed** - Local workspaces only (no SSH support)
15+
- **Vim/Neovim** - Opens in mux's web terminal
16+
17+
## Editor Types
18+
19+
Editors fall into two categories:
20+
21+
### Native Editors
22+
23+
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.
24+
25+
For SSH workspaces, VS Code and Cursor use their Remote-SSH extension to connect directly to the remote host.
26+
27+
### Terminal Editors
28+
29+
Terminal editors (Vim/Neovim) run inside mux's web terminal. They work in **all modes**:
30+
31+
- Desktop (Electron) - Opens in a terminal window
32+
- Browser mode - Opens in a browser popup
33+
- SSH workspaces - Runs on the remote host via the terminal
34+
35+
This makes terminal editors the most portable option.
36+
37+
## Custom Editors
38+
39+
You can customize the editor configuration by editing `~/.mux/editors.js`. This file is created automatically on first run with the default editors.
40+
41+
### File Structure
42+
43+
```javascript
44+
export default {
45+
// Which editor to use by default
46+
default: "vscode",
47+
48+
// Editor definitions
49+
editors: {
50+
vscode: {
51+
name: "VS Code",
52+
open: async (ctx) => {
53+
// Return instructions for opening
54+
},
55+
},
56+
// ... more editors
57+
},
58+
};
59+
```
60+
61+
### Adding a Custom Editor
62+
63+
Each editor has a `name` and an `open` function. The `open` function receives a context object and returns instructions:
64+
65+
```javascript
66+
// Example: Add Sublime Text
67+
sublime: {
68+
name: "Sublime Text",
69+
open: async (ctx) => {
70+
if (ctx.isBrowser) {
71+
return { error: "Sublime Text requires the desktop app" };
72+
}
73+
if (ctx.isSSH) {
74+
return { error: "Sublime Text does not support SSH workspaces" };
75+
}
76+
return { type: "native", command: "subl", args: [ctx.path] };
77+
},
78+
},
79+
```
80+
81+
### Context Object
82+
83+
The `open` function receives these properties:
84+
85+
| Property | Type | Description |
86+
| ------------- | ---------- | ------------------------------------------ |
87+
| `path` | `string` | Absolute path to open |
88+
| `host` | `string?` | SSH host (if SSH workspace) |
89+
| `isSSH` | `boolean` | Whether this is an SSH workspace |
90+
| `isBrowser` | `boolean` | Whether running in browser mode |
91+
| `isDesktop` | `boolean` | Whether running in desktop (Electron) mode |
92+
| `platform` | `string` | OS platform: "darwin", "linux", or "win32" |
93+
| `findCommand` | `function` | Find first available command from a list |
94+
95+
### Return Values
96+
97+
Return one of these objects:
98+
99+
**Native editor (GUI application):**
100+
101+
```javascript
102+
{ type: "native", command: "code", args: ["--new-window", ctx.path] }
103+
```
104+
105+
**Terminal editor (runs in web terminal):**
106+
107+
```javascript
108+
{ type: "web_term", command: `nvim ${ctx.path}` }
109+
```
110+
111+
**Error (show message to user):**
112+
113+
```javascript
114+
{
115+
error: "This editor doesn't support SSH workspaces";
116+
}
117+
```
118+
119+
### Example: Emacs
120+
121+
```javascript
122+
emacs: {
123+
name: "Emacs",
124+
open: async (ctx) => {
125+
// Use emacsclient for GUI, or run in terminal for SSH
126+
if (ctx.isSSH) {
127+
return { type: "web_term", command: `emacs -nw ${ctx.path}` };
128+
}
129+
if (ctx.isBrowser) {
130+
return { type: "web_term", command: `emacs -nw ${ctx.path}` };
131+
}
132+
// Desktop mode - use GUI emacs
133+
return { type: "native", command: "emacsclient", args: ["-c", ctx.path] };
134+
},
135+
},
136+
```
137+
138+
### Example: Helix
139+
140+
```javascript
141+
helix: {
142+
name: "Helix",
143+
open: async (ctx) => {
144+
const cmd = await ctx.findCommand(["hx", "helix"]);
145+
if (!cmd) {
146+
return { error: "Helix not found (tried hx, helix)" };
147+
}
148+
return { type: "web_term", command: `${cmd} ${ctx.path}` };
149+
},
150+
},
151+
```
152+
153+
## SSH Workspace Support
154+
155+
| Editor | SSH Support | Method |
156+
| ---------- | ----------- | ------------------------------ |
157+
| VS Code || Remote-SSH extension |
158+
| Cursor || Remote-SSH extension |
159+
| Zed || Not supported |
160+
| Vim/Neovim || Runs in web terminal on remote |
161+
162+
## Keyboard Shortcut
163+
164+
Open the current workspace in your editor:
165+
166+
- **macOS**: `Cmd+Shift+E`
167+
- **Windows/Linux**: `Ctrl+Shift+E`
168+
169+
## Related
170+
171+
- [VS Code Extension](/vscode-extension) - Deeper VS Code integration
172+
- [Workspaces](/workspaces) - Workspace management
173+
- [SSH Runtime](/runtime/ssh) - SSH workspace setup
Lines changed: 72 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { useEffect, useState } from "react";
22
import { useTheme, THEME_OPTIONS, type ThemeMode } from "@/browser/contexts/ThemeContext";
33
import {
44
Select,
@@ -7,35 +7,62 @@ import {
77
SelectTrigger,
88
SelectValue,
99
} from "@/browser/components/ui/select";
10-
import { Input } from "@/browser/components/ui/input";
11-
import { usePersistedState } from "@/browser/hooks/usePersistedState";
12-
import {
13-
EDITOR_CONFIG_KEY,
14-
DEFAULT_EDITOR_CONFIG,
15-
type EditorConfig,
16-
type EditorType,
17-
} from "@/common/constants/storage";
18-
19-
const EDITOR_OPTIONS: Array<{ value: EditorType; label: string }> = [
20-
{ value: "vscode", label: "VS Code" },
21-
{ value: "cursor", label: "Cursor" },
22-
{ value: "zed", label: "Zed" },
23-
{ value: "custom", label: "Custom" },
24-
];
10+
import { useAPI } from "@/browser/contexts/API";
11+
import type { EditorInfo } from "@/common/types/editor";
2512

2613
export function GeneralSection() {
2714
const { theme, setTheme } = useTheme();
28-
const [editorConfig, setEditorConfig] = usePersistedState<EditorConfig>(
29-
EDITOR_CONFIG_KEY,
30-
DEFAULT_EDITOR_CONFIG
31-
);
15+
const { api } = useAPI();
16+
const [editors, setEditors] = useState<EditorInfo[]>([]);
17+
const [defaultEditor, setDefaultEditor] = useState<string>("");
18+
const [loading, setLoading] = useState(true);
3219

33-
const handleEditorChange = (editor: EditorType) => {
34-
setEditorConfig((prev) => ({ ...prev, editor }));
35-
};
20+
// Load editors from backend
21+
useEffect(() => {
22+
if (!api) return;
23+
24+
const loadEditors = async () => {
25+
try {
26+
const editorList = await api.general.listEditors();
27+
setEditors(editorList);
28+
const current = editorList.find((e) => e.isDefault);
29+
if (current) {
30+
setDefaultEditor(current.id);
31+
}
32+
} catch (err) {
33+
console.error("Failed to load editors:", err);
34+
} finally {
35+
setLoading(false);
36+
}
37+
};
38+
39+
void loadEditors();
40+
}, [api]);
41+
42+
const handleEditorChange = async (editorId: string) => {
43+
if (!api) return;
3644

37-
const handleCustomCommandChange = (customCommand: string) => {
38-
setEditorConfig((prev) => ({ ...prev, customCommand }));
45+
// Optimistic update
46+
setDefaultEditor(editorId);
47+
setEditors((prev) =>
48+
prev.map((e) => ({
49+
...e,
50+
isDefault: e.id === editorId,
51+
}))
52+
);
53+
54+
try {
55+
await api.general.setDefaultEditor({ editorId });
56+
} catch (err) {
57+
console.error("Failed to set default editor:", err);
58+
// Revert on error
59+
const editorList = await api.general.listEditors();
60+
setEditors(editorList);
61+
const current = editorList.find((e) => e.isDefault);
62+
if (current) {
63+
setDefaultEditor(current.id);
64+
}
65+
}
3966
};
4067

4168
return (
@@ -65,38 +92,35 @@ export function GeneralSection() {
6592
<div className="flex items-center justify-between">
6693
<div>
6794
<div className="text-foreground text-sm">Editor</div>
68-
<div className="text-muted text-xs">Editor to open files in</div>
95+
<div className="text-muted text-xs">
96+
Default editor for opening workspaces.{" "}
97+
<a
98+
href="https://mux.coder.com/docs/editor"
99+
target="_blank"
100+
rel="noopener noreferrer"
101+
className="text-blue-400 hover:underline"
102+
>
103+
Learn more
104+
</a>
105+
</div>
69106
</div>
70-
<Select value={editorConfig.editor} onValueChange={handleEditorChange}>
107+
<Select
108+
value={defaultEditor}
109+
onValueChange={(value) => void handleEditorChange(value)}
110+
disabled={loading}
111+
>
71112
<SelectTrigger className="border-border-medium bg-background-secondary hover:bg-hover h-9 w-auto cursor-pointer rounded-md border px-3 text-sm transition-colors">
72-
<SelectValue />
113+
<SelectValue placeholder={loading ? "Loading..." : "Select editor"} />
73114
</SelectTrigger>
74115
<SelectContent>
75-
{EDITOR_OPTIONS.map((option) => (
76-
<SelectItem key={option.value} value={option.value}>
77-
{option.label}
116+
{editors.map((editor) => (
117+
<SelectItem key={editor.id} value={editor.id}>
118+
{editor.name}
78119
</SelectItem>
79120
))}
80121
</SelectContent>
81122
</Select>
82123
</div>
83-
84-
{editorConfig.editor === "custom" && (
85-
<div className="flex items-center justify-between">
86-
<div>
87-
<div className="text-foreground text-sm">Custom Command</div>
88-
<div className="text-muted text-xs">Command to run (path will be appended)</div>
89-
</div>
90-
<Input
91-
value={editorConfig.customCommand ?? ""}
92-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
93-
handleCustomCommandChange(e.target.value)
94-
}
95-
placeholder="e.g., nvim"
96-
className="border-border-medium bg-background-secondary h-9 w-40"
97-
/>
98-
</div>
99-
)}
100124
</div>
101125
);
102126
}

src/browser/components/TerminalView.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ import { useAPI } from "@/browser/contexts/API";
66
interface TerminalViewProps {
77
workspaceId: string;
88
sessionId?: string;
9+
initialCommand?: string;
910
visible: boolean;
1011
}
1112

12-
export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewProps) {
13+
export function TerminalView({
14+
workspaceId,
15+
sessionId,
16+
initialCommand,
17+
visible,
18+
}: TerminalViewProps) {
1319
const containerRef = useRef<HTMLDivElement>(null);
1420
const termRef = useRef<Terminal | null>(null);
1521
const fitAddonRef = useRef<FitAddon | null>(null);
@@ -57,7 +63,15 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr
5763
sendInput,
5864
resize,
5965
error: sessionError,
60-
} = useTerminalSession(workspaceId, sessionId, visible, terminalSize, handleOutput, handleExit);
66+
} = useTerminalSession(
67+
workspaceId,
68+
sessionId,
69+
initialCommand,
70+
visible,
71+
terminalSize,
72+
handleOutput,
73+
handleExit
74+
);
6175

6276
// Keep refs to latest functions so callbacks always use current version
6377
const sendInputRef = useRef(sendInput);

src/browser/components/WorkspaceHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
4040

4141
const handleOpenInEditor = useCallback(async () => {
4242
setEditorError(null);
43-
const result = await openInEditor(workspaceId, namedWorkspacePath, runtimeConfig);
43+
const result = await openInEditor(workspaceId, namedWorkspacePath);
4444
if (!result.success && result.error) {
4545
setEditorError(result.error);
4646
// Clear error after 3 seconds
4747
setTimeout(() => setEditorError(null), 3000);
4848
}
49-
}, [workspaceId, namedWorkspacePath, openInEditor, runtimeConfig]);
49+
}, [workspaceId, namedWorkspacePath, openInEditor]);
5050

5151
// Start workspace tutorial on first entry (only if settings tutorial is done)
5252
useEffect(() => {

0 commit comments

Comments
 (0)