Skip to content

Commit e5f03f6

Browse files
committed
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
1 parent 0e3c2fa commit e5f03f6

File tree

3 files changed

+261
-27
lines changed

3 files changed

+261
-27
lines changed

src/browser/components/WorkspaceHeader.tsx

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import React, { useCallback, useEffect, useState } from "react";
2-
import { Pencil } from "lucide-react";
2+
import { Pencil, ChevronDown, Check } from "lucide-react";
33
import { GitStatusIndicator } from "./GitStatusIndicator";
44
import { RuntimeBadge } from "./RuntimeBadge";
55
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
6+
import {
7+
DropdownMenu,
8+
DropdownMenuTrigger,
9+
DropdownMenuContent,
10+
DropdownMenuItem,
11+
DropdownMenuLabel,
12+
DropdownMenuSeparator,
13+
} from "./ui/dropdown-menu";
614
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
715
import { useGitStatus } from "@/browser/stores/GitStatusStore";
816
import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore";
@@ -11,6 +19,8 @@ import type { RuntimeConfig } from "@/common/types/runtime";
1119
import { useTutorial } from "@/browser/contexts/TutorialContext";
1220
import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal";
1321
import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor";
22+
import { useAPI } from "@/browser/contexts/API";
23+
import type { EditorInfo } from "@/common/types/editor";
1424

1525
interface WorkspaceHeaderProps {
1626
workspaceId: string;
@@ -27,26 +37,37 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
2737
namedWorkspacePath,
2838
runtimeConfig,
2939
}) => {
40+
const { api } = useAPI();
3041
const openTerminal = useOpenTerminal();
3142
const openInEditor = useOpenInEditor();
3243
const gitStatus = useGitStatus(workspaceId);
3344
const { canInterrupt } = useWorkspaceSidebarState(workspaceId);
3445
const { startSequence: startTutorial, isSequenceCompleted } = useTutorial();
3546
const [editorError, setEditorError] = useState<string | null>(null);
47+
const [editors, setEditors] = useState<EditorInfo[]>([]);
48+
49+
// Load editors from backend
50+
useEffect(() => {
51+
if (!api) return;
52+
void api.general.listEditors().then(setEditors).catch(console.error);
53+
}, [api]);
3654

3755
const handleOpenTerminal = useCallback(() => {
3856
openTerminal(workspaceId, runtimeConfig);
3957
}, [workspaceId, openTerminal, runtimeConfig]);
4058

41-
const handleOpenInEditor = useCallback(async () => {
42-
setEditorError(null);
43-
const result = await openInEditor(workspaceId, namedWorkspacePath);
44-
if (!result.success && result.error) {
45-
setEditorError(result.error);
46-
// Clear error after 3 seconds
47-
setTimeout(() => setEditorError(null), 3000);
48-
}
49-
}, [workspaceId, namedWorkspacePath, openInEditor]);
59+
const handleOpenInEditor = useCallback(
60+
async (editorId?: string) => {
61+
setEditorError(null);
62+
const result = await openInEditor(workspaceId, namedWorkspacePath, editorId);
63+
if (!result.success && result.error) {
64+
setEditorError(result.error);
65+
// Clear error after 3 seconds
66+
setTimeout(() => setEditorError(null), 3000);
67+
}
68+
},
69+
[workspaceId, namedWorkspacePath, openInEditor]
70+
);
5071

5172
// Start workspace tutorial on first entry (only if settings tutorial is done)
5273
useEffect(() => {
@@ -81,21 +102,42 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
81102
</div>
82103
<div className="flex items-center">
83104
{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>
105+
<DropdownMenu>
106+
<Tooltip>
107+
<TooltipTrigger asChild>
108+
<DropdownMenuTrigger asChild>
109+
<Button
110+
variant="ghost"
111+
size="icon"
112+
className="text-muted hover:text-foreground h-6 w-6 shrink-0"
113+
>
114+
<Pencil className="h-3.5 w-3.5" />
115+
<ChevronDown className="h-2.5 w-2.5" />
116+
</Button>
117+
</DropdownMenuTrigger>
118+
</TooltipTrigger>
119+
<TooltipContent side="bottom" align="center">
120+
Open in editor ({formatKeybind(KEYBINDS.OPEN_IN_EDITOR)})
121+
</TooltipContent>
122+
</Tooltip>
123+
<DropdownMenuContent align="end">
124+
<DropdownMenuLabel>Open in Editor</DropdownMenuLabel>
125+
<DropdownMenuSeparator />
126+
{editors.map((editor) => (
127+
<DropdownMenuItem
128+
key={editor.id}
129+
onClick={() => void handleOpenInEditor(editor.id)}
130+
className="cursor-pointer"
131+
>
132+
<span className="flex-1">{editor.name}</span>
133+
{editor.isDefault && <Check className="text-muted ml-2 h-4 w-4" />}
134+
</DropdownMenuItem>
135+
))}
136+
{editors.length === 0 && (
137+
<DropdownMenuItem disabled>Loading editors...</DropdownMenuItem>
138+
)}
139+
</DropdownMenuContent>
140+
</DropdownMenu>
99141
<Tooltip>
100142
<TooltipTrigger asChild>
101143
<Button
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import * as React from "react";
2+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
3+
import { Check, ChevronRight, Circle } from "lucide-react";
4+
5+
import { cn } from "@/common/lib/utils";
6+
7+
const DropdownMenu = DropdownMenuPrimitive.Root;
8+
9+
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
10+
11+
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
12+
13+
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
14+
15+
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
16+
17+
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
18+
19+
const DropdownMenuSubTrigger = React.forwardRef<
20+
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
21+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
22+
inset?: boolean;
23+
}
24+
>(({ className, inset, children, ...props }, ref) => (
25+
<DropdownMenuPrimitive.SubTrigger
26+
ref={ref}
27+
className={cn(
28+
"focus:bg-hover data-[state=open]:bg-hover flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
29+
inset && "pl-8",
30+
className
31+
)}
32+
{...props}
33+
>
34+
{children}
35+
<ChevronRight className="ml-auto h-4 w-4" />
36+
</DropdownMenuPrimitive.SubTrigger>
37+
));
38+
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
39+
40+
const DropdownMenuSubContent = React.forwardRef<
41+
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
42+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
43+
>(({ className, ...props }, ref) => (
44+
<DropdownMenuPrimitive.SubContent
45+
ref={ref}
46+
className={cn(
47+
"bg-dark border-border text-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg 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",
48+
className
49+
)}
50+
{...props}
51+
/>
52+
));
53+
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
54+
55+
const DropdownMenuContent = React.forwardRef<
56+
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
57+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
58+
>(({ className, sideOffset = 4, ...props }, ref) => (
59+
<DropdownMenuPrimitive.Portal>
60+
<DropdownMenuPrimitive.Content
61+
ref={ref}
62+
sideOffset={sideOffset}
63+
className={cn(
64+
"bg-dark border-border text-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
65+
"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",
66+
className
67+
)}
68+
{...props}
69+
/>
70+
</DropdownMenuPrimitive.Portal>
71+
));
72+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
73+
74+
const DropdownMenuItem = React.forwardRef<
75+
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
76+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
77+
inset?: boolean;
78+
}
79+
>(({ className, inset, ...props }, ref) => (
80+
<DropdownMenuPrimitive.Item
81+
ref={ref}
82+
className={cn(
83+
"focus:bg-hover relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
84+
inset && "pl-8",
85+
className
86+
)}
87+
{...props}
88+
/>
89+
));
90+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
91+
92+
const DropdownMenuCheckboxItem = React.forwardRef<
93+
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
94+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
95+
>(({ className, children, checked, ...props }, ref) => (
96+
<DropdownMenuPrimitive.CheckboxItem
97+
ref={ref}
98+
className={cn(
99+
"focus:bg-hover relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
100+
className
101+
)}
102+
checked={checked}
103+
{...props}
104+
>
105+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
106+
<DropdownMenuPrimitive.ItemIndicator>
107+
<Check className="h-4 w-4" />
108+
</DropdownMenuPrimitive.ItemIndicator>
109+
</span>
110+
{children}
111+
</DropdownMenuPrimitive.CheckboxItem>
112+
));
113+
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
114+
115+
const DropdownMenuRadioItem = React.forwardRef<
116+
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
117+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
118+
>(({ className, children, ...props }, ref) => (
119+
<DropdownMenuPrimitive.RadioItem
120+
ref={ref}
121+
className={cn(
122+
"focus:bg-hover relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
123+
className
124+
)}
125+
{...props}
126+
>
127+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
128+
<DropdownMenuPrimitive.ItemIndicator>
129+
<Circle className="h-2 w-2 fill-current" />
130+
</DropdownMenuPrimitive.ItemIndicator>
131+
</span>
132+
{children}
133+
</DropdownMenuPrimitive.RadioItem>
134+
));
135+
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
136+
137+
const DropdownMenuLabel = React.forwardRef<
138+
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
139+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
140+
inset?: boolean;
141+
}
142+
>(({ className, inset, ...props }, ref) => (
143+
<DropdownMenuPrimitive.Label
144+
ref={ref}
145+
className={cn("text-muted px-2 py-1.5 text-xs font-medium", inset && "pl-8", className)}
146+
{...props}
147+
/>
148+
));
149+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
150+
151+
const DropdownMenuSeparator = React.forwardRef<
152+
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
153+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
154+
>(({ className, ...props }, ref) => (
155+
<DropdownMenuPrimitive.Separator
156+
ref={ref}
157+
className={cn("bg-border -mx-1 my-1 h-px", className)}
158+
{...props}
159+
/>
160+
));
161+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
162+
163+
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
164+
return (
165+
<span className={cn("text-muted ml-auto text-xs tracking-widest", className)} {...props} />
166+
);
167+
};
168+
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
169+
170+
export {
171+
DropdownMenu,
172+
DropdownMenuTrigger,
173+
DropdownMenuContent,
174+
DropdownMenuItem,
175+
DropdownMenuCheckboxItem,
176+
DropdownMenuRadioItem,
177+
DropdownMenuLabel,
178+
DropdownMenuSeparator,
179+
DropdownMenuShortcut,
180+
DropdownMenuGroup,
181+
DropdownMenuPortal,
182+
DropdownMenuSub,
183+
DropdownMenuSubContent,
184+
DropdownMenuSubTrigger,
185+
DropdownMenuRadioGroup,
186+
};

src/browser/hooks/useOpenInEditor.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,29 @@ export interface OpenInEditorResult {
1111
*
1212
* Editor configuration is handled by the backend via ~/.mux/editors.js.
1313
* The backend determines the appropriate editor based on:
14-
* - User's default editor preference
14+
* - User's default editor preference (or specified editorId)
1515
* - Workspace type (local vs SSH)
1616
* - Runtime environment (desktop vs browser)
1717
*
1818
* @returns A function that opens a path in the editor:
1919
* - workspaceId: required workspace identifier
2020
* - targetPath: the path to open (workspace directory or specific file)
21+
* - editorId: optional editor ID to use instead of default
2122
*/
2223
export function useOpenInEditor() {
2324
const { api } = useAPI();
2425

2526
return useCallback(
26-
async (workspaceId: string, targetPath: string): Promise<OpenInEditorResult> => {
27+
async (
28+
workspaceId: string,
29+
targetPath: string,
30+
editorId?: string
31+
): Promise<OpenInEditorResult> => {
2732
// Call the backend API - editor selection is handled server-side
2833
const result = await api?.general.openInEditor({
2934
workspaceId,
3035
targetPath,
36+
editorId,
3137
});
3238

3339
if (!result) {

0 commit comments

Comments
 (0)