Skip to content

Commit d5dff0d

Browse files
authored
🤖 feat: add Open in Editor button to workspace header (#1030)
Adds a button next to the terminal icon that opens the workspace in the user's configured code editor (VS Code, Cursor, Zed, or custom). - EditorService handles spawning editor processes - Supports Remote-SSH extension for VS Code/Cursor with SSH workspaces - Editor config persisted in localStorage with VS Code + Remote-SSH as default - Settings UI in General section for editor selection and options - Keybind: Cmd+Shift+E (Mac) / Ctrl+Shift+E (Win/Linux) _Generated with `mux`_
1 parent 70c7ee2 commit d5dff0d

File tree

17 files changed

+442
-20
lines changed

17 files changed

+442
-20
lines changed

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,45 @@ import {
77
SelectTrigger,
88
SelectValue,
99
} from "@/browser/components/ui/select";
10+
import { Input } from "@/browser/components/ui/input";
11+
import { Checkbox } from "@/browser/components/ui/checkbox";
12+
import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/ui/tooltip";
13+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
14+
import {
15+
EDITOR_CONFIG_KEY,
16+
DEFAULT_EDITOR_CONFIG,
17+
type EditorConfig,
18+
type EditorType,
19+
} from "@/common/constants/storage";
20+
21+
const EDITOR_OPTIONS: Array<{ value: EditorType; label: string }> = [
22+
{ value: "vscode", label: "VS Code" },
23+
{ value: "cursor", label: "Cursor" },
24+
{ value: "zed", label: "Zed" },
25+
{ value: "custom", label: "Custom" },
26+
];
1027

1128
export function GeneralSection() {
1229
const { theme, setTheme } = useTheme();
30+
const [editorConfig, setEditorConfig] = usePersistedState<EditorConfig>(
31+
EDITOR_CONFIG_KEY,
32+
DEFAULT_EDITOR_CONFIG
33+
);
34+
35+
const handleEditorChange = (editor: EditorType) => {
36+
setEditorConfig((prev) => ({ ...prev, editor }));
37+
};
38+
39+
const handleCustomCommandChange = (customCommand: string) => {
40+
setEditorConfig((prev) => ({ ...prev, customCommand }));
41+
};
42+
43+
const handleRemoteExtensionChange = (useRemoteExtension: boolean) => {
44+
setEditorConfig((prev) => ({ ...prev, useRemoteExtension }));
45+
};
46+
47+
// Remote-SSH is only supported for VS Code and Cursor
48+
const supportsRemote = editorConfig.editor === "vscode" || editorConfig.editor === "cursor";
1349

1450
return (
1551
<div className="space-y-6">
@@ -34,6 +70,68 @@ export function GeneralSection() {
3470
</Select>
3571
</div>
3672
</div>
73+
74+
<div>
75+
<h3 className="text-foreground mb-4 text-sm font-medium">Editor</h3>
76+
<div className="space-y-4">
77+
<div className="flex items-center justify-between">
78+
<div>
79+
<div className="text-foreground text-sm">Default Editor</div>
80+
<div className="text-muted text-xs">Editor to open workspaces in</div>
81+
</div>
82+
<Select value={editorConfig.editor} onValueChange={handleEditorChange}>
83+
<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">
84+
<SelectValue />
85+
</SelectTrigger>
86+
<SelectContent>
87+
{EDITOR_OPTIONS.map((option) => (
88+
<SelectItem key={option.value} value={option.value}>
89+
{option.label}
90+
</SelectItem>
91+
))}
92+
</SelectContent>
93+
</Select>
94+
</div>
95+
96+
{editorConfig.editor === "custom" && (
97+
<div className="flex items-center justify-between">
98+
<div>
99+
<div className="text-foreground text-sm">Custom Command</div>
100+
<div className="text-muted text-xs">Command to run (path will be appended)</div>
101+
</div>
102+
<Input
103+
value={editorConfig.customCommand ?? ""}
104+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
105+
handleCustomCommandChange(e.target.value)
106+
}
107+
placeholder="e.g., nvim"
108+
className="border-border-medium bg-background-secondary h-9 w-40"
109+
/>
110+
</div>
111+
)}
112+
113+
{supportsRemote && (
114+
<div className="flex items-center justify-between">
115+
<div className="flex items-center gap-2">
116+
<div className="text-foreground text-sm">Use Remote-SSH for SSH workspaces</div>
117+
<Tooltip>
118+
<TooltipTrigger asChild>
119+
<span className="text-muted cursor-help text-xs">(?)</span>
120+
</TooltipTrigger>
121+
<TooltipContent side="top" className="max-w-xs">
122+
When enabled, opens SSH workspaces directly in the editor using the Remote-SSH
123+
extension. Requires the Remote-SSH extension to be installed.
124+
</TooltipContent>
125+
</Tooltip>
126+
</div>
127+
<Checkbox
128+
checked={editorConfig.useRemoteExtension}
129+
onCheckedChange={handleRemoteExtensionChange}
130+
/>
131+
</div>
132+
)}
133+
</div>
134+
</div>
37135
</div>
38136
);
39137
}

src/browser/components/WorkspaceHeader.tsx

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useCallback, useEffect } from "react";
1+
import React, { useCallback, useEffect, useState } from "react";
2+
import { Pencil } from "lucide-react";
23
import { GitStatusIndicator } from "./GitStatusIndicator";
34
import { RuntimeBadge } from "./RuntimeBadge";
45
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
@@ -9,6 +10,7 @@ import { Button } from "@/browser/components/ui/button";
910
import type { RuntimeConfig } from "@/common/types/runtime";
1011
import { useTutorial } from "@/browser/contexts/TutorialContext";
1112
import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal";
13+
import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor";
1214

1315
interface WorkspaceHeaderProps {
1416
workspaceId: string;
@@ -26,13 +28,26 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
2628
runtimeConfig,
2729
}) => {
2830
const openTerminal = useOpenTerminal();
31+
const openInEditor = useOpenInEditor();
2932
const gitStatus = useGitStatus(workspaceId);
3033
const { canInterrupt } = useWorkspaceSidebarState(workspaceId);
3134
const { startSequence: startTutorial, isSequenceCompleted } = useTutorial();
35+
const [editorError, setEditorError] = useState<string | null>(null);
36+
3237
const handleOpenTerminal = useCallback(() => {
3338
openTerminal(workspaceId, runtimeConfig);
3439
}, [workspaceId, openTerminal, runtimeConfig]);
3540

41+
const handleOpenInEditor = useCallback(async () => {
42+
setEditorError(null);
43+
const result = await openInEditor(workspaceId, runtimeConfig);
44+
if (!result.success && result.error) {
45+
setEditorError(result.error);
46+
// Clear error after 3 seconds
47+
setTimeout(() => setEditorError(null), 3000);
48+
}
49+
}, [workspaceId, openInEditor, runtimeConfig]);
50+
3651
// Start workspace tutorial on first entry (only if settings tutorial is done)
3752
useEffect(() => {
3853
// Don't show workspace tutorial until settings tutorial is completed
@@ -64,24 +79,42 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
6479
{namedWorkspacePath}
6580
</span>
6681
</div>
67-
<Tooltip>
68-
<TooltipTrigger asChild>
69-
<Button
70-
variant="ghost"
71-
size="icon"
72-
onClick={handleOpenTerminal}
73-
className="text-muted hover:text-foreground ml-2 h-6 w-6 shrink-0 [&_svg]:h-4 [&_svg]:w-4"
74-
data-tutorial="terminal-button"
75-
>
76-
<svg viewBox="0 0 16 16" fill="currentColor">
77-
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H1.75zM7.25 8a.75.75 0 01-.22.53l-2.25 2.25a.75.75 0 01-1.06-1.06L5.44 8 3.72 6.28a.75.75 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z" />
78-
</svg>
79-
</Button>
80-
</TooltipTrigger>
81-
<TooltipContent side="bottom" align="center">
82-
Open terminal window ({formatKeybind(KEYBINDS.OPEN_TERMINAL)})
83-
</TooltipContent>
84-
</Tooltip>
82+
<div className="flex items-center">
83+
{editorError && <span className="text-danger-soft mr-2 text-xs">{editorError}</span>}
84+
<Tooltip>
85+
<TooltipTrigger asChild>
86+
<Button
87+
variant="ghost"
88+
size="icon"
89+
onClick={() => void handleOpenInEditor()}
90+
className="text-muted hover:text-foreground h-6 w-6 shrink-0"
91+
>
92+
<Pencil className="h-4 w-4" />
93+
</Button>
94+
</TooltipTrigger>
95+
<TooltipContent side="bottom" align="center">
96+
Open in editor ({formatKeybind(KEYBINDS.OPEN_IN_EDITOR)})
97+
</TooltipContent>
98+
</Tooltip>
99+
<Tooltip>
100+
<TooltipTrigger asChild>
101+
<Button
102+
variant="ghost"
103+
size="icon"
104+
onClick={handleOpenTerminal}
105+
className="text-muted hover:text-foreground ml-1 h-6 w-6 shrink-0 [&_svg]:h-4 [&_svg]:w-4"
106+
data-tutorial="terminal-button"
107+
>
108+
<svg viewBox="0 0 16 16" fill="currentColor">
109+
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H1.75zM7.25 8a.75.75 0 01-.22.53l-2.25 2.25a.75.75 0 01-1.06-1.06L5.44 8 3.72 6.28a.75.75 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z" />
110+
</svg>
111+
</Button>
112+
</TooltipTrigger>
113+
<TooltipContent side="bottom" align="center">
114+
Open terminal window ({formatKeybind(KEYBINDS.OPEN_TERMINAL)})
115+
</TooltipContent>
116+
</Tooltip>
117+
</div>
85118
</div>
86119
);
87120
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as React from "react";
2+
import { cn } from "@/common/lib/utils";
3+
4+
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
5+
6+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
7+
({ className, type, ...props }, ref) => {
8+
return (
9+
<input
10+
type={type}
11+
className={cn(
12+
"border-input placeholder:text-muted focus-visible:ring-ring flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
13+
className
14+
)}
15+
ref={ref}
16+
{...props}
17+
/>
18+
);
19+
}
20+
);
21+
Input.displayName = "Input";
22+
23+
export { Input };

src/browser/components/ui/select.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const SelectContent = React.forwardRef<
6666
<SelectPrimitive.Content
6767
ref={ref}
6868
className={cn(
69-
"bg-dark border-border text-foreground relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
69+
"bg-dark border-border text-foreground relative z-[1600] max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
7070
position === "popper" &&
7171
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
7272
className
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useCallback } from "react";
2+
import { useAPI } from "@/browser/contexts/API";
3+
import { useSettings } from "@/browser/contexts/SettingsContext";
4+
import { readPersistedState } from "@/browser/hooks/usePersistedState";
5+
import {
6+
EDITOR_CONFIG_KEY,
7+
DEFAULT_EDITOR_CONFIG,
8+
type EditorConfig,
9+
} from "@/common/constants/storage";
10+
import type { RuntimeConfig } from "@/common/types/runtime";
11+
import { isSSHRuntime } from "@/common/types/runtime";
12+
13+
export interface OpenInEditorResult {
14+
success: boolean;
15+
error?: string;
16+
}
17+
18+
/**
19+
* Hook to open a workspace in the user's configured code editor.
20+
*
21+
* If no editor is configured, opens Settings to the General section.
22+
* For SSH workspaces with unsupported editors (Zed, custom), returns an error.
23+
*
24+
* @returns A function that takes workspaceId and optional runtimeConfig,
25+
* returns a result object with success/error status.
26+
*/
27+
export function useOpenInEditor() {
28+
const { api } = useAPI();
29+
const { open: openSettings } = useSettings();
30+
31+
return useCallback(
32+
async (workspaceId: string, runtimeConfig?: RuntimeConfig): Promise<OpenInEditorResult> => {
33+
// Read editor config from localStorage
34+
const editorConfig = readPersistedState<EditorConfig>(
35+
EDITOR_CONFIG_KEY,
36+
DEFAULT_EDITOR_CONFIG
37+
);
38+
39+
const isSSH = isSSHRuntime(runtimeConfig);
40+
41+
// For custom editor with no command configured, open settings
42+
if (editorConfig.editor === "custom" && !editorConfig.customCommand) {
43+
openSettings("general");
44+
return { success: false, error: "Please configure a custom editor command in Settings" };
45+
}
46+
47+
// For SSH workspaces, validate the editor supports remote
48+
if (isSSH && editorConfig.useRemoteExtension) {
49+
if (editorConfig.editor === "zed") {
50+
return { success: false, error: "Zed does not support Remote-SSH for SSH workspaces" };
51+
}
52+
if (editorConfig.editor === "custom") {
53+
return {
54+
success: false,
55+
error: "Custom editors do not support Remote-SSH for SSH workspaces",
56+
};
57+
}
58+
}
59+
60+
// For SSH workspaces without remote extension, we can't open
61+
if (isSSH && !editorConfig.useRemoteExtension) {
62+
return {
63+
success: false,
64+
error: "Enable 'Use Remote-SSH' in Settings to open SSH workspaces in editor",
65+
};
66+
}
67+
68+
// Call the backend API
69+
const result = await api?.general.openWorkspaceInEditor({
70+
workspaceId,
71+
editorConfig,
72+
});
73+
74+
if (!result) {
75+
return { success: false, error: "API not available" };
76+
}
77+
78+
if (!result.success) {
79+
return { success: false, error: result.error };
80+
}
81+
82+
return { success: true };
83+
},
84+
[api, openSettings]
85+
);
86+
}

src/browser/utils/ui/keybinds.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ export const KEYBINDS = {
244244
// macOS: Cmd+T, Win/Linux: Ctrl+T
245245
OPEN_TERMINAL: { key: "T", ctrl: true },
246246

247+
/** Open workspace in editor */
248+
// macOS: Cmd+Shift+E, Win/Linux: Ctrl+Shift+E
249+
OPEN_IN_EDITOR: { key: "E", ctrl: true, shift: true },
250+
247251
/** Open Command Palette */
248252
// VS Code-style palette
249253
// macOS: Cmd+Shift+P, Win/Linux: Ctrl+Shift+P

src/cli/cli.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
6363
workspaceService: services.workspaceService,
6464
providerService: services.providerService,
6565
terminalService: services.terminalService,
66+
editorService: services.editorService,
6667
windowService: services.windowService,
6768
updateService: services.updateService,
6869
tokenizerService: services.tokenizerService,

src/cli/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ async function createTestServer(): Promise<TestServerHandle> {
6666
workspaceService: services.workspaceService,
6767
providerService: services.providerService,
6868
terminalService: services.terminalService,
69+
editorService: services.editorService,
6970
windowService: services.windowService,
7071
updateService: services.updateService,
7172
tokenizerService: services.tokenizerService,

src/cli/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const mockWindow: BrowserWindow = {
7474
workspaceService: serviceContainer.workspaceService,
7575
providerService: serviceContainer.providerService,
7676
terminalService: serviceContainer.terminalService,
77+
editorService: serviceContainer.editorService,
7778
windowService: serviceContainer.windowService,
7879
updateService: serviceContainer.updateService,
7980
tokenizerService: serviceContainer.tokenizerService,

src/common/constants/storage.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,25 @@ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel";
157157
*/
158158
export const VIM_ENABLED_KEY = "vimEnabled";
159159

160+
/**
161+
* Editor configuration for "Open in Editor" feature (global)
162+
* Format: "editorConfig"
163+
*/
164+
export const EDITOR_CONFIG_KEY = "editorConfig";
165+
166+
export type EditorType = "vscode" | "cursor" | "zed" | "custom";
167+
168+
export interface EditorConfig {
169+
editor: EditorType;
170+
customCommand?: string; // Only when editor='custom'
171+
useRemoteExtension: boolean; // For SSH workspaces, use Remote-SSH
172+
}
173+
174+
export const DEFAULT_EDITOR_CONFIG: EditorConfig = {
175+
editor: "vscode",
176+
useRemoteExtension: true,
177+
};
178+
160179
/**
161180
* Tutorial state storage key (global)
162181
* Stores: { disabled: boolean, completed: { settings?: true, creation?: true, workspace?: true } }

0 commit comments

Comments
 (0)