Skip to content

Commit 9dddf8a

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 038007e commit 9dddf8a

File tree

22 files changed

+781
-292
lines changed

22 files changed

+781
-292
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

src/browser/components/Settings/sections/GeneralSection.tsx

Lines changed: 69 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,43 @@ import {
88
SelectValue,
99
} from "@/browser/components/ui/select";
1010
import { Input } from "@/browser/components/ui/input";
11-
import { usePersistedState } from "@/browser/hooks/usePersistedState";
1211
import { useAPI } from "@/browser/contexts/API";
13-
import {
14-
EDITOR_CONFIG_KEY,
15-
DEFAULT_EDITOR_CONFIG,
16-
type EditorConfig,
17-
type EditorType,
18-
} from "@/common/constants/storage";
19-
20-
const EDITOR_OPTIONS: Array<{ value: EditorType; label: string }> = [
21-
{ value: "vscode", label: "VS Code" },
22-
{ value: "cursor", label: "Cursor" },
23-
{ value: "zed", label: "Zed" },
24-
{ value: "custom", label: "Custom" },
25-
];
12+
import type { EditorInfo } from "@/common/types/editor";
2613

2714
// Browser mode: window.api is not set (only exists in Electron via preload)
2815
const isBrowserMode = typeof window !== "undefined" && !window.api;
2916

3017
export function GeneralSection() {
3118
const { theme, setTheme } = useTheme();
3219
const { api } = useAPI();
33-
const [editorConfig, setEditorConfig] = usePersistedState<EditorConfig>(
34-
EDITOR_CONFIG_KEY,
35-
DEFAULT_EDITOR_CONFIG
36-
);
20+
const [editors, setEditors] = useState<EditorInfo[]>([]);
21+
const [defaultEditor, setDefaultEditor] = useState<string>("");
22+
const [loading, setLoading] = useState(true);
3723
const [sshHost, setSshHost] = useState<string>("");
3824
const [sshHostLoaded, setSshHostLoaded] = useState(false);
3925

26+
// Load editors from backend
27+
useEffect(() => {
28+
if (!api) return;
29+
30+
const loadEditors = async () => {
31+
try {
32+
const editorList = await api.general.listEditors();
33+
setEditors(editorList);
34+
const current = editorList.find((e) => e.isDefault);
35+
if (current) {
36+
setDefaultEditor(current.id);
37+
}
38+
} catch (err) {
39+
console.error("Failed to load editors:", err);
40+
} finally {
41+
setLoading(false);
42+
}
43+
};
44+
45+
void loadEditors();
46+
}, [api]);
47+
4048
// Load SSH host from server on mount (browser mode only)
4149
useEffect(() => {
4250
if (isBrowserMode && api) {
@@ -47,12 +55,30 @@ export function GeneralSection() {
4755
}
4856
}, [api]);
4957

50-
const handleEditorChange = (editor: EditorType) => {
51-
setEditorConfig((prev) => ({ ...prev, editor }));
52-
};
58+
const handleEditorChange = async (editorId: string) => {
59+
if (!api) return;
5360

54-
const handleCustomCommandChange = (customCommand: string) => {
55-
setEditorConfig((prev) => ({ ...prev, customCommand }));
61+
// Optimistic update
62+
setDefaultEditor(editorId);
63+
setEditors((prev) =>
64+
prev.map((e) => ({
65+
...e,
66+
isDefault: e.id === editorId,
67+
}))
68+
);
69+
70+
try {
71+
await api.general.setDefaultEditor({ editorId });
72+
} catch (err) {
73+
console.error("Failed to set default editor:", err);
74+
// Revert on error
75+
const editorList = await api.general.listEditors();
76+
setEditors(editorList);
77+
const current = editorList.find((e) => e.isDefault);
78+
if (current) {
79+
setDefaultEditor(current.id);
80+
}
81+
}
5682
};
5783

5884
const handleSshHostChange = useCallback(
@@ -91,46 +117,36 @@ export function GeneralSection() {
91117
<div className="flex items-center justify-between">
92118
<div>
93119
<div className="text-foreground text-sm">Editor</div>
94-
<div className="text-muted text-xs">Editor to open files in</div>
120+
<div className="text-muted text-xs">
121+
Default editor for opening workspaces.{" "}
122+
<a
123+
href="https://mux.coder.com/docs/editor"
124+
target="_blank"
125+
rel="noopener noreferrer"
126+
className="text-blue-400 hover:underline"
127+
>
128+
Learn more
129+
</a>
130+
</div>
95131
</div>
96-
<Select value={editorConfig.editor} onValueChange={handleEditorChange}>
132+
<Select
133+
value={defaultEditor}
134+
onValueChange={(value) => void handleEditorChange(value)}
135+
disabled={loading}
136+
>
97137
<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">
98-
<SelectValue />
138+
<SelectValue placeholder={loading ? "Loading..." : "Select editor"} />
99139
</SelectTrigger>
100140
<SelectContent>
101-
{EDITOR_OPTIONS.map((option) => (
102-
<SelectItem key={option.value} value={option.value}>
103-
{option.label}
141+
{editors.map((editor) => (
142+
<SelectItem key={editor.id} value={editor.id}>
143+
{editor.name}
104144
</SelectItem>
105145
))}
106146
</SelectContent>
107147
</Select>
108148
</div>
109149

110-
{editorConfig.editor === "custom" && (
111-
<div className="space-y-2">
112-
<div className="flex items-center justify-between">
113-
<div>
114-
<div className="text-foreground text-sm">Custom Command</div>
115-
<div className="text-muted text-xs">Command to run (path will be appended)</div>
116-
</div>
117-
<Input
118-
value={editorConfig.customCommand ?? ""}
119-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
120-
handleCustomCommandChange(e.target.value)
121-
}
122-
placeholder="e.g., nvim"
123-
className="border-border-medium bg-background-secondary h-9 w-40"
124-
/>
125-
</div>
126-
{isBrowserMode && (
127-
<div className="text-warning text-xs">
128-
Custom editors are not supported in browser mode. Use VS Code or Cursor instead.
129-
</div>
130-
)}
131-
</div>
132-
)}
133-
134150
{isBrowserMode && sshHostLoaded && (
135151
<div className="flex items-center justify-between">
136152
<div>

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)