mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
feat: add default IDE setting and multi-editor support with icons
Add comprehensive editor detection and selection system that allows users to configure their preferred IDE for opening branches and worktrees. ## Server-side Changes - Add `/api/worktree/available-editors` endpoint to detect installed editors - Support detection via CLI commands (cursor, code, zed, subl, etc.) - Support detection via macOS app bundles in /Applications and ~/Applications - Detect editors: Cursor, VS Code, Zed, Sublime Text, Windsurf, Trae, Rider, WebStorm, Xcode, Android Studio, Antigravity, and file managers ## UI Changes ### Editor Icons - Add new `editor-icons.tsx` with SVG icons for all supported editors - Icons: Cursor, VS Code, Zed, Sublime Text, Windsurf, Trae, Rider, WebStorm, Xcode, Android Studio, Antigravity, Finder - `getEditorIcon()` helper maps editor commands to appropriate icons ### Default IDE Setting - Add "Default IDE" selector in Settings > Account section - Options: Auto-detect (Cursor > VS Code > first available) or explicit choice - Setting persists via `defaultEditorCommand` in global settings ### Worktree Dropdown Improvements - Implement split-button UX for "Open In" action - Click main area: opens directly in default IDE (single click) - Click chevron: shows submenu with other editors + Copy Path - Each editor shows with its branded icon ## Type & Store Changes - Add `defaultEditorCommand: string | null` to GlobalSettings - Add to app-store with `setDefaultEditorCommand` action - Add to SETTINGS_FIELDS_TO_SYNC for persistence - Add `useAvailableEditors` hook for fetching detected editors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Upload,
|
||||
Play,
|
||||
@@ -21,10 +23,14 @@ import {
|
||||
MessageSquare,
|
||||
GitMerge,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors } from '../hooks/use-available-editors';
|
||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -41,7 +47,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -77,6 +83,31 @@ export function WorktreeActionsDropdown({
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
const { editors, hasMultipleEditors } = useAvailableEditors();
|
||||
|
||||
// Get the user's preferred default editor from settings
|
||||
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
|
||||
|
||||
// Calculate effective default editor based on user setting or auto-detect (Cursor > VS Code > first)
|
||||
const getEffectiveDefaultEditor = () => {
|
||||
if (defaultEditorCommand) {
|
||||
const found = editors.find((e) => e.command === defaultEditorCommand);
|
||||
if (found) return found;
|
||||
}
|
||||
// Auto-detect: prefer Cursor, then VS Code, then first available
|
||||
const cursor = editors.find((e) => e.command === 'cursor');
|
||||
if (cursor) return cursor;
|
||||
const vscode = editors.find((e) => e.command === 'code');
|
||||
if (vscode) return vscode;
|
||||
return editors[0];
|
||||
};
|
||||
|
||||
const effectiveDefaultEditor = getEffectiveDefaultEditor();
|
||||
|
||||
// Get other editors (excluding the default) for the submenu
|
||||
const otherEditors = editors.filter((e) => e.command !== effectiveDefaultEditor?.command);
|
||||
|
||||
// Check if there's a PR associated with this worktree from stored metadata
|
||||
const hasPR = !!worktree.pr;
|
||||
|
||||
@@ -200,10 +231,50 @@ export function WorktreeActionsDropdown({
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {defaultEditorName}
|
||||
</DropdownMenuItem>
|
||||
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||
{effectiveDefaultEditor && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - opens in default editor */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInEditor(worktree, effectiveDefaultEditor.command)}
|
||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
{(() => {
|
||||
const EditorIcon = getEditorIcon(effectiveDefaultEditor.command);
|
||||
return <EditorIcon className="w-3.5 h-3.5 mr-2" />;
|
||||
})()}
|
||||
Open in {effectiveDefaultEditor.name}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with other editors and Copy Path */}
|
||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
{/* Other editors */}
|
||||
{otherEditors.map((editor) => {
|
||||
const EditorIcon = getEditorIcon(editor.command);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={editor.command}
|
||||
onClick={() => onOpenInEditor(worktree, editor.command)}
|
||||
className="text-xs"
|
||||
>
|
||||
<EditorIcon className="w-3.5 h-3.5 mr-2" />
|
||||
{editor.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{otherEditors.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(worktree.path)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-2" />
|
||||
Copy Path
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
|
||||
@@ -37,7 +37,7 @@ interface WorktreeTabProps {
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
const logger = createLogger('AvailableEditors');
|
||||
|
||||
export interface EditorInfo {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export function useAvailableEditors() {
|
||||
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchAvailableEditors = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getAvailableEditors) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.getAvailableEditors();
|
||||
if (result.success && result.result?.editors) {
|
||||
setEditors(result.result.editors);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch available editors:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailableEditors();
|
||||
}, [fetchAvailableEditors]);
|
||||
|
||||
return {
|
||||
editors,
|
||||
isLoading,
|
||||
// Convenience property: has multiple editors (for deciding whether to show submenu)
|
||||
hasMultipleEditors: editors.length > 1,
|
||||
// The first editor is the "default" one
|
||||
defaultEditor: editors[0] ?? null,
|
||||
};
|
||||
}
|
||||
@@ -125,14 +125,14 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
[isPushing, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInEditor) {
|
||||
logger.warn('Open in editor API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInEditor(worktree.path);
|
||||
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, User } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { LogOut, User, Code2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { logout } from '@/lib/http-api-client';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useAvailableEditors } from '@/components/views/board-view/worktree-panel/hooks/use-available-editors';
|
||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||
|
||||
export function AccountSection() {
|
||||
const navigate = useNavigate();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
// Editor settings
|
||||
const { editors, isLoading: isLoadingEditors } = useAvailableEditors();
|
||||
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
|
||||
const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand);
|
||||
|
||||
// Get effective default editor (respecting auto-detect order: Cursor > VS Code > first)
|
||||
const getEffectiveDefaultEditor = () => {
|
||||
if (defaultEditorCommand) {
|
||||
return editors.find((e) => e.command === defaultEditorCommand) ?? editors[0];
|
||||
}
|
||||
// Auto-detect: prefer Cursor, then VS Code, then first available
|
||||
const cursor = editors.find((e) => e.command === 'cursor');
|
||||
if (cursor) return cursor;
|
||||
const vscode = editors.find((e) => e.command === 'code');
|
||||
if (vscode) return vscode;
|
||||
return editors[0];
|
||||
};
|
||||
|
||||
const effectiveEditor = getEffectiveDefaultEditor();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
@@ -43,6 +73,64 @@ export function AccountSection() {
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Default IDE */}
|
||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-muted/50 to-muted/30 border border-border/30 flex items-center justify-center shrink-0">
|
||||
<Code2 className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">Default IDE</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5">
|
||||
Default IDE to use when opening branches or worktrees
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={defaultEditorCommand ?? 'auto'}
|
||||
onValueChange={(value) => setDefaultEditorCommand(value === 'auto' ? null : value)}
|
||||
disabled={isLoadingEditors || editors.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] shrink-0">
|
||||
<SelectValue placeholder="Select editor">
|
||||
{effectiveEditor ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = getEditorIcon(effectiveEditor.command);
|
||||
return <Icon className="w-4 h-4" />;
|
||||
})()}
|
||||
{effectiveEditor.name}
|
||||
{!defaultEditorCommand && (
|
||||
<span className="text-muted-foreground text-xs">(Auto)</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
'Select editor'
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
<span className="flex items-center gap-2">
|
||||
<Code2 className="w-4 h-4" />
|
||||
Auto-detect
|
||||
</span>
|
||||
</SelectItem>
|
||||
{editors.map((editor) => {
|
||||
const Icon = getEditorIcon(editor.command);
|
||||
return (
|
||||
<SelectItem key={editor.command} value={editor.command}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
{editor.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user