mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
Merge pull request #525 from stefandevo/feature/project-settings
feat: Separate Project Settings from Global Settings
This commit is contained in:
@@ -151,7 +151,7 @@ export function SidebarFooter({
|
|||||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||||
'hover:scale-[1.02] active:scale-[0.97]'
|
'hover:scale-[1.02] active:scale-[0.97]'
|
||||||
)}
|
)}
|
||||||
title={!sidebarOpen ? 'Settings' : undefined}
|
title={!sidebarOpen ? 'Global Settings' : undefined}
|
||||||
data-testid="settings-button"
|
data-testid="settings-button"
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
@@ -168,7 +168,7 @@ export function SidebarFooter({
|
|||||||
sidebarOpen ? 'block' : 'hidden'
|
sidebarOpen ? 'block' : 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Settings
|
Global Settings
|
||||||
</span>
|
</span>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
@@ -194,7 +194,7 @@ export function SidebarFooter({
|
|||||||
'translate-x-1 group-hover:translate-x-0'
|
'translate-x-1 group-hover:translate-x-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Settings
|
Global Settings
|
||||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||||
{formatShortcut(shortcuts.settings, true)}
|
{formatShortcut(shortcuts.settings, true)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ export function SidebarNavigation({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>}
|
{/* Separator for sections without label (visual separation) */}
|
||||||
|
{!section.label && sectionIdx > 0 && sidebarOpen && (
|
||||||
|
<div className="h-px bg-border/40 mx-3 mb-4"></div>
|
||||||
|
)}
|
||||||
|
{(section.label || sectionIdx > 0) && !sidebarOpen && (
|
||||||
|
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Nav Items */}
|
{/* Nav Items */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Lightbulb,
|
Lightbulb,
|
||||||
Brain,
|
Brain,
|
||||||
Network,
|
Network,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NavSection, NavItem } from '../types';
|
import type { NavSection, NavItem } from '../types';
|
||||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||||
@@ -32,6 +33,7 @@ interface UseNavigationProps {
|
|||||||
agent: string;
|
agent: string;
|
||||||
terminal: string;
|
terminal: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
projectSettings: string;
|
||||||
ideation: string;
|
ideation: string;
|
||||||
githubIssues: string;
|
githubIssues: string;
|
||||||
githubPrs: string;
|
githubPrs: string;
|
||||||
@@ -199,6 +201,19 @@ export function useNavigation({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Project Settings as a standalone section (no label for visual separation)
|
||||||
|
sections.push({
|
||||||
|
label: '',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'project-settings',
|
||||||
|
label: 'Project Settings',
|
||||||
|
icon: Settings,
|
||||||
|
shortcut: shortcuts.projectSettings,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}, [
|
}, [
|
||||||
shortcuts,
|
shortcuts,
|
||||||
@@ -257,11 +272,11 @@ export function useNavigation({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add settings shortcut
|
// Add global settings shortcut
|
||||||
shortcutsList.push({
|
shortcutsList.push({
|
||||||
key: shortcuts.settings,
|
key: shortcuts.settings,
|
||||||
action: () => navigate({ to: '/settings' }),
|
action: () => navigate({ to: '/settings' }),
|
||||||
description: 'Navigate to Settings',
|
description: 'Navigate to Global Settings',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({
|
|||||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||||
},
|
},
|
||||||
'.cm-activeLine': {
|
'.cm-activeLine': {
|
||||||
backgroundColor: 'var(--accent)',
|
backgroundColor: 'transparent',
|
||||||
opacity: '0.3',
|
|
||||||
},
|
},
|
||||||
'.cm-line': {
|
'.cm-line': {
|
||||||
padding: '0 0.25rem',
|
padding: '0 0.25rem',
|
||||||
@@ -114,7 +113,7 @@ export function ShellSyntaxEditor({
|
|||||||
}: ShellSyntaxEditorProps) {
|
}: ShellSyntaxEditorProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
|
className={cn('w-full rounded-lg border border-border bg-background', className)}
|
||||||
style={{ minHeight }}
|
style={{ minHeight }}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PROJECT_SETTINGS_NAV_ITEMS } from '../config/navigation';
|
||||||
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
|
interface ProjectSettingsNavigationProps {
|
||||||
|
activeSection: ProjectSettingsViewId;
|
||||||
|
onNavigate: (sectionId: ProjectSettingsViewId) => void;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSettingsNavigation({
|
||||||
|
activeSection,
|
||||||
|
onNavigate,
|
||||||
|
isOpen = true,
|
||||||
|
onClose,
|
||||||
|
}: ProjectSettingsNavigationProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="project-settings-nav-backdrop"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation sidebar */}
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
// Mobile: fixed position overlay with slide transition
|
||||||
|
'fixed inset-y-0 left-0 w-72 z-30',
|
||||||
|
'transition-transform duration-200 ease-out',
|
||||||
|
// Hide on mobile when closed, show when open
|
||||||
|
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
|
// Desktop: relative position in layout, always visible
|
||||||
|
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
|
||||||
|
'shrink-0 overflow-y-auto',
|
||||||
|
'border-r border-border/50',
|
||||||
|
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
|
||||||
|
// Desktop background
|
||||||
|
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Mobile close button */}
|
||||||
|
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Navigation</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Close navigation menu"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky top-0 p-4 space-y-1">
|
||||||
|
{PROJECT_SETTINGS_NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = activeSection === item.id;
|
||||||
|
const isDanger = item.id === 'danger';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onNavigate(item.id)}
|
||||||
|
className={cn(
|
||||||
|
'group w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
|
||||||
|
isActive
|
||||||
|
? [
|
||||||
|
isDanger
|
||||||
|
? 'bg-gradient-to-r from-red-500/15 via-red-500/10 to-red-600/5'
|
||||||
|
: 'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
|
||||||
|
'text-foreground',
|
||||||
|
isDanger ? 'border border-red-500/25' : 'border border-brand-500/25',
|
||||||
|
isDanger ? 'shadow-sm shadow-red-500/5' : 'shadow-sm shadow-brand-500/5',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'text-muted-foreground hover:text-foreground',
|
||||||
|
'hover:bg-accent/50',
|
||||||
|
'border border-transparent hover:border-border/40',
|
||||||
|
],
|
||||||
|
'hover:scale-[1.01] active:scale-[0.98]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Active indicator bar */}
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-y-0 left-0 w-0.5 rounded-r-full',
|
||||||
|
isDanger
|
||||||
|
? 'bg-gradient-to-b from-red-400 via-red-500 to-red-600'
|
||||||
|
: 'bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 shrink-0 transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? isDanger
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-brand-500'
|
||||||
|
: isDanger
|
||||||
|
? 'group-hover:text-red-400 group-hover:scale-110'
|
||||||
|
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className={cn(isDanger && !isActive && 'text-red-400/70')}>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react';
|
||||||
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
|
export interface ProjectNavigationItem {
|
||||||
|
id: ProjectSettingsViewId;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||||
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
|
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||||
|
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||||
|
];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
|
||||||
|
|
||||||
|
interface UseProjectSettingsViewOptions {
|
||||||
|
initialView?: ProjectSettingsViewId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectSettingsView({
|
||||||
|
initialView = 'identity',
|
||||||
|
}: UseProjectSettingsViewOptions = {}) {
|
||||||
|
const [activeView, setActiveView] = useState<ProjectSettingsViewId>(initialView);
|
||||||
|
|
||||||
|
const navigateTo = useCallback((viewId: ProjectSettingsViewId) => {
|
||||||
|
setActiveView(viewId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeView,
|
||||||
|
navigateTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { ProjectSettingsView } from './project-settings-view';
|
||||||
|
export { ProjectIdentitySection } from './project-identity-section';
|
||||||
|
export { ProjectThemeSection } from './project-theme-section';
|
||||||
|
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||||
|
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Palette, Upload, X, ImageIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
|
||||||
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
|
interface ProjectIdentitySectionProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps) {
|
||||||
|
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
|
||||||
|
const [projectName, setProjectNameLocal] = useState(project.name || '');
|
||||||
|
const [projectIcon, setProjectIconLocal] = useState<string | null>(project.icon || null);
|
||||||
|
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
|
||||||
|
project.customIconPath || null
|
||||||
|
);
|
||||||
|
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Sync local state when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
setProjectNameLocal(project.name || '');
|
||||||
|
setProjectIconLocal(project.icon || null);
|
||||||
|
setCustomIconPathLocal(project.customIconPath || null);
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
// Auto-save when values change
|
||||||
|
const handleNameChange = (name: string) => {
|
||||||
|
setProjectNameLocal(name);
|
||||||
|
if (name.trim() && name.trim() !== project.name) {
|
||||||
|
setProjectName(project.id, name.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconChange = (icon: string | null) => {
|
||||||
|
setProjectIconLocal(icon);
|
||||||
|
setProjectIcon(project.id, icon);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomIconChange = (path: string | null) => {
|
||||||
|
setCustomIconPathLocal(path);
|
||||||
|
setProjectCustomIcon(project.id, path);
|
||||||
|
// Clear Lucide icon when custom icon is set
|
||||||
|
if (path) {
|
||||||
|
setProjectIconLocal(null);
|
||||||
|
setProjectIcon(project.id, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
toast.error('Invalid file type', {
|
||||||
|
description: 'Please upload a PNG, JPG, GIF, or WebP image.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 2MB for icons)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast.error('File too large', {
|
||||||
|
description: 'Please upload an image smaller than 2MB.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploadingIcon(true);
|
||||||
|
try {
|
||||||
|
// Convert to base64
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
try {
|
||||||
|
const base64Data = reader.result as string;
|
||||||
|
const result = await getHttpApiClient().saveImageToTemp(
|
||||||
|
base64Data,
|
||||||
|
`project-icon-${file.name}`,
|
||||||
|
file.type,
|
||||||
|
project.path
|
||||||
|
);
|
||||||
|
if (result.success && result.path) {
|
||||||
|
handleCustomIconChange(result.path);
|
||||||
|
toast.success('Icon uploaded successfully');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to upload icon', {
|
||||||
|
description: result.error || 'Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to upload icon', {
|
||||||
|
description: 'Network error. Please try again.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUploadingIcon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
toast.error('Failed to read file', {
|
||||||
|
description: 'Please try again with a different file.',
|
||||||
|
});
|
||||||
|
setIsUploadingIcon(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to upload icon');
|
||||||
|
setIsUploadingIcon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCustomIcon = () => {
|
||||||
|
handleCustomIconChange(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<Palette className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">Project Identity</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Customize how your project appears in the sidebar and project switcher.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Project Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="project-name-settings">Project Name</Label>
|
||||||
|
<Input
|
||||||
|
id="project-name-settings"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
|
placeholder="Enter project name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Icon */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Project Icon</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Choose a preset icon or upload a custom image
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Custom Icon Upload */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{customIconPath ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={getAuthenticatedImageUrl(customIconPath, project.path)}
|
||||||
|
alt="Custom project icon"
|
||||||
|
className="w-12 h-12 rounded-lg object-cover border border-border"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveCustomIcon}
|
||||||
|
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
|
||||||
|
<ImageIcon className="w-5 h-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
onChange={handleCustomIconUpload}
|
||||||
|
className="hidden"
|
||||||
|
id="custom-icon-upload"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isUploadingIcon}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
PNG, JPG, GIF or WebP. Max 2MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preset Icon Picker - only show if no custom icon */}
|
||||||
|
{!customIconPath && (
|
||||||
|
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { Settings, FolderOpen, Menu } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ProjectIdentitySection } from './project-identity-section';
|
||||||
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
|
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||||
|
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
|
import { useProjectSettingsView } from './hooks/use-project-settings-view';
|
||||||
|
import type { Project as ElectronProject } from '@/lib/electron';
|
||||||
|
|
||||||
|
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||||
|
const LG_BREAKPOINT = 1024;
|
||||||
|
|
||||||
|
// Convert to the shared types used by components
|
||||||
|
interface SettingsProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
theme?: string;
|
||||||
|
icon?: string | null;
|
||||||
|
customIconPath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSettingsView() {
|
||||||
|
const { currentProject, moveProjectToTrash } = useAppStore();
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
// Use project settings view navigation hook
|
||||||
|
const { activeView, navigateTo } = useProjectSettingsView();
|
||||||
|
|
||||||
|
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||||
|
const [showNavigation, setShowNavigation] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.innerWidth >= LG_BREAKPOINT;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-close navigation on mobile when a section is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) {
|
||||||
|
setShowNavigation(false);
|
||||||
|
}
|
||||||
|
}, [activeView]);
|
||||||
|
|
||||||
|
// Handle window resize to show/hide navigation appropriately
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth >= LG_BREAKPOINT) {
|
||||||
|
setShowNavigation(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Convert electron Project to settings-view Project type
|
||||||
|
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||||
|
if (!project) return null;
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
path: project.path,
|
||||||
|
theme: project.theme,
|
||||||
|
icon: project.icon,
|
||||||
|
customIconPath: project.customIconPath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsProject = convertProject(currentProject);
|
||||||
|
|
||||||
|
// Render the active section based on current view
|
||||||
|
const renderActiveSection = () => {
|
||||||
|
if (!currentProject) return null;
|
||||||
|
|
||||||
|
switch (activeView) {
|
||||||
|
case 'identity':
|
||||||
|
return <ProjectIdentitySection project={currentProject} />;
|
||||||
|
case 'theme':
|
||||||
|
return <ProjectThemeSection project={currentProject} />;
|
||||||
|
case 'worktrees':
|
||||||
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
|
case 'danger':
|
||||||
|
return (
|
||||||
|
<DangerZoneSection
|
||||||
|
project={settingsProject}
|
||||||
|
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <ProjectIdentitySection project={currentProject} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show message if no project is selected
|
||||||
|
if (!currentProject) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||||
|
data-testid="project-settings-view"
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-muted/50 flex items-center justify-center">
|
||||||
|
<FolderOpen className="w-8 h-8 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">No Project Selected</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select a project from the sidebar to configure project-specific settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||||
|
data-testid="project-settings-view"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowNavigation(!showNavigation)}
|
||||||
|
className="lg:hidden h-8 w-8 p-0"
|
||||||
|
aria-label="Toggle navigation menu"
|
||||||
|
>
|
||||||
|
<Menu className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Project Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure settings for {currentProject.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area with Sidebar */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Side Navigation */}
|
||||||
|
<ProjectSettingsNavigation
|
||||||
|
activeSection={activeView}
|
||||||
|
onNavigate={navigateTo}
|
||||||
|
isOpen={showNavigation}
|
||||||
|
onClose={() => setShowNavigation(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content Panel - Shows only the active section */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Project Confirmation Dialog */}
|
||||||
|
<DeleteProjectDialog
|
||||||
|
open={showDeleteDialog}
|
||||||
|
onOpenChange={setShowDeleteDialog}
|
||||||
|
project={currentProject}
|
||||||
|
onConfirm={moveProjectToTrash}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Palette, Moon, Sun } from 'lucide-react';
|
||||||
|
import { darkThemes, lightThemes, type Theme } from '@/config/theme-options';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
|
interface ProjectThemeSectionProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectThemeSection({ project }: ProjectThemeSectionProps) {
|
||||||
|
const { theme: globalTheme, setProjectTheme } = useAppStore();
|
||||||
|
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||||
|
|
||||||
|
const projectTheme = project.theme as Theme | undefined;
|
||||||
|
const hasCustomTheme = projectTheme !== undefined;
|
||||||
|
const effectiveTheme = projectTheme || globalTheme;
|
||||||
|
|
||||||
|
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
||||||
|
|
||||||
|
const handleThemeChange = (theme: Theme) => {
|
||||||
|
setProjectTheme(project.id, theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseGlobalTheme = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
// Clear project theme to use global
|
||||||
|
setProjectTheme(project.id, null);
|
||||||
|
} else {
|
||||||
|
// Set project theme to current global theme
|
||||||
|
setProjectTheme(project.id, globalTheme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<Palette className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">Theme</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Customize the theme for this project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Use Global Theme Toggle */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="use-global-theme"
|
||||||
|
checked={!hasCustomTheme}
|
||||||
|
onCheckedChange={handleUseGlobalTheme}
|
||||||
|
className="mt-1"
|
||||||
|
data-testid="use-global-theme-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="use-global-theme"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Palette className="w-4 h-4 text-brand-500" />
|
||||||
|
Use Global Theme
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
When enabled, this project will use the global theme setting. Disable to set a
|
||||||
|
project-specific theme.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Selection - only show if not using global theme */}
|
||||||
|
{hasCustomTheme && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-foreground font-medium">Project Theme</Label>
|
||||||
|
{/* Dark/Light Tabs */}
|
||||||
|
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('dark')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
|
||||||
|
activeTab === 'dark'
|
||||||
|
? 'bg-brand-500 text-white shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Moon className="w-3.5 h-3.5" />
|
||||||
|
Dark
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('light')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200',
|
||||||
|
activeTab === 'light'
|
||||||
|
? 'bg-brand-500 text-white shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sun className="w-3.5 h-3.5" />
|
||||||
|
Light
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{themesToShow.map(({ value, label, Icon, testId, color }) => {
|
||||||
|
const isActive = effectiveTheme === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => handleThemeChange(value)}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl',
|
||||||
|
'text-sm font-medium transition-all duration-200 ease-out',
|
||||||
|
isActive
|
||||||
|
? [
|
||||||
|
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
|
||||||
|
'border-2 border-brand-500/40',
|
||||||
|
'text-foreground',
|
||||||
|
'shadow-md shadow-brand-500/10',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'bg-accent/30 hover:bg-accent/50',
|
||||||
|
'border border-border/50 hover:border-border',
|
||||||
|
'text-muted-foreground hover:text-foreground',
|
||||||
|
'hover:shadow-sm',
|
||||||
|
],
|
||||||
|
'hover:scale-[1.02] active:scale-[0.98]'
|
||||||
|
)}
|
||||||
|
data-testid={`project-${testId}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 transition-all duration-200" style={{ color }} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info when using global theme */}
|
||||||
|
{!hasCustomTheme && (
|
||||||
|
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This project is using the global theme:{' '}
|
||||||
|
<span className="font-medium text-foreground">{globalTheme}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
Terminal,
|
||||||
|
FileCode,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
PanelBottomClose,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
|
interface WorktreePreferencesSectionProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitScriptResponse {
|
||||||
|
success: boolean;
|
||||||
|
exists: boolean;
|
||||||
|
content: string;
|
||||||
|
path: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) {
|
||||||
|
const globalUseWorktrees = useAppStore((s) => s.useWorktrees);
|
||||||
|
const getProjectUseWorktrees = useAppStore((s) => s.getProjectUseWorktrees);
|
||||||
|
const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees);
|
||||||
|
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
|
||||||
|
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
|
||||||
|
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
|
||||||
|
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
||||||
|
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
||||||
|
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
||||||
|
|
||||||
|
// Get effective worktrees setting (project override or global fallback)
|
||||||
|
const projectUseWorktrees = getProjectUseWorktrees(project.path);
|
||||||
|
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
|
||||||
|
|
||||||
|
const [scriptContent, setScriptContent] = useState('');
|
||||||
|
const [originalContent, setOriginalContent] = useState('');
|
||||||
|
const [scriptExists, setScriptExists] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Get the current settings for this project
|
||||||
|
const showIndicator = getShowInitScriptIndicator(project.path);
|
||||||
|
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
|
||||||
|
const autoDismiss = getAutoDismissInitScriptIndicator(project.path);
|
||||||
|
|
||||||
|
// Check if there are unsaved changes
|
||||||
|
const hasChanges = scriptContent !== originalContent;
|
||||||
|
|
||||||
|
// Load project settings (including useWorktrees) when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
const currentPath = project.path;
|
||||||
|
|
||||||
|
const loadProjectSettings = async () => {
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const response = await httpClient.settings.getProject(currentPath);
|
||||||
|
|
||||||
|
// Avoid updating state if component unmounted or project changed
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
if (response.success && response.settings) {
|
||||||
|
// Sync useWorktrees to store if it has a value
|
||||||
|
if (response.settings.useWorktrees !== undefined) {
|
||||||
|
setProjectUseWorktrees(currentPath, response.settings.useWorktrees);
|
||||||
|
}
|
||||||
|
// Also sync other settings to store
|
||||||
|
if (response.settings.showInitScriptIndicator !== undefined) {
|
||||||
|
setShowInitScriptIndicator(currentPath, response.settings.showInitScriptIndicator);
|
||||||
|
}
|
||||||
|
if (response.settings.defaultDeleteBranchWithWorktree !== undefined) {
|
||||||
|
setDefaultDeleteBranch(currentPath, response.settings.defaultDeleteBranchWithWorktree);
|
||||||
|
}
|
||||||
|
if (response.settings.autoDismissInitScriptIndicator !== undefined) {
|
||||||
|
setAutoDismissInitScriptIndicator(
|
||||||
|
currentPath,
|
||||||
|
response.settings.autoDismissInitScriptIndicator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Failed to load project settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProjectSettings();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
project.path,
|
||||||
|
setProjectUseWorktrees,
|
||||||
|
setShowInitScriptIndicator,
|
||||||
|
setDefaultDeleteBranch,
|
||||||
|
setAutoDismissInitScriptIndicator,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Load init script content when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
const currentPath = project.path;
|
||||||
|
|
||||||
|
const loadInitScript = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiGet<InitScriptResponse>(
|
||||||
|
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentPath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Avoid updating state if component unmounted or project changed
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const content = response.content || '';
|
||||||
|
setScriptContent(content);
|
||||||
|
setOriginalContent(content);
|
||||||
|
setScriptExists(response.exists);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Failed to load init script:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInitScript();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [project.path]);
|
||||||
|
|
||||||
|
// Save script
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await apiPut<{ success: boolean; error?: string }>(
|
||||||
|
'/api/worktree/init-script',
|
||||||
|
{
|
||||||
|
projectPath: project.path,
|
||||||
|
content: scriptContent,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.success) {
|
||||||
|
setOriginalContent(scriptContent);
|
||||||
|
setScriptExists(true);
|
||||||
|
toast.success('Init script saved');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to save init script', {
|
||||||
|
description: response.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save init script:', error);
|
||||||
|
toast.error('Failed to save init script');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [project.path, scriptContent]);
|
||||||
|
|
||||||
|
// Reset to original content
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setScriptContent(originalContent);
|
||||||
|
}, [originalContent]);
|
||||||
|
|
||||||
|
// Delete script
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await apiDelete<{ success: boolean; error?: string }>(
|
||||||
|
'/api/worktree/init-script',
|
||||||
|
{
|
||||||
|
body: { projectPath: project.path },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.success) {
|
||||||
|
setScriptContent('');
|
||||||
|
setOriginalContent('');
|
||||||
|
setScriptExists(false);
|
||||||
|
toast.success('Init script deleted');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to delete init script', {
|
||||||
|
description: response.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete init script:', error);
|
||||||
|
toast.error('Failed to delete init script');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [project.path]);
|
||||||
|
|
||||||
|
// Handle content change (no auto-save)
|
||||||
|
const handleContentChange = useCallback((value: string) => {
|
||||||
|
setScriptContent(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<GitBranch className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
|
Worktree Preferences
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure worktree behavior for this project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Enable Git Worktree Isolation Toggle */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="project-use-worktrees"
|
||||||
|
checked={effectiveUseWorktrees}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
const value = checked === true;
|
||||||
|
setProjectUseWorktrees(project.path, value);
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
await httpClient.settings.updateProject(project.path, {
|
||||||
|
useWorktrees: value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist useWorktrees:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
data-testid="project-use-worktrees-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="project-use-worktrees"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||||
|
Enable Git Worktree Isolation
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Creates isolated git branches for each feature in this project. When disabled, agents
|
||||||
|
work directly in the main project directory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Show Init Script Indicator Toggle */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="show-init-script-indicator"
|
||||||
|
checked={showIndicator}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
const value = checked === true;
|
||||||
|
setShowInitScriptIndicator(project.path, value);
|
||||||
|
// Persist to server
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
await httpClient.settings.updateProject(project.path, {
|
||||||
|
showInitScriptIndicator: value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist showInitScriptIndicator:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="show-init-script-indicator"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<PanelBottomClose className="w-4 h-4 text-brand-500" />
|
||||||
|
Show Init Script Indicator
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Display a floating panel in the bottom-right corner showing init script execution
|
||||||
|
status and output when a worktree is created.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-dismiss Init Script Indicator Toggle */}
|
||||||
|
{showIndicator && (
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
|
||||||
|
<Checkbox
|
||||||
|
id="auto-dismiss-indicator"
|
||||||
|
checked={autoDismiss}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
const value = checked === true;
|
||||||
|
setAutoDismissInitScriptIndicator(project.path, value);
|
||||||
|
// Persist to server
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
await httpClient.settings.updateProject(project.path, {
|
||||||
|
autoDismissInitScriptIndicator: value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="auto-dismiss-indicator"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Auto-dismiss After Completion
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Automatically hide the indicator 5 seconds after the script completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default Delete Branch Toggle */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="default-delete-branch"
|
||||||
|
checked={defaultDeleteBranch}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
const value = checked === true;
|
||||||
|
setDefaultDeleteBranch(project.path, value);
|
||||||
|
// Persist to server
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
await httpClient.settings.updateProject(project.path, {
|
||||||
|
defaultDeleteBranch: value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist defaultDeleteBranch:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="default-delete-branch"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-brand-500" />
|
||||||
|
Delete Branch by Default
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
When deleting a worktree, automatically check the "Also delete the branch" option.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Init Script Section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4 text-brand-500" />
|
||||||
|
<Label className="text-foreground font-medium">Initialization Script</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
|
||||||
|
on Windows for cross-platform compatibility.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* File path indicator */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
|
||||||
|
<FileCode className="w-3.5 h-3.5" />
|
||||||
|
<code className="font-mono">.automaker/worktree-init.sh</code>
|
||||||
|
{hasChanges && <span className="text-amber-500 font-medium">(unsaved changes)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShellSyntaxEditor
|
||||||
|
value={scriptContent}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
placeholder={`# Example initialization commands
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Or use pnpm
|
||||||
|
# pnpm install
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
# cp .env.example .env`}
|
||||||
|
minHeight="200px"
|
||||||
|
maxHeight="500px"
|
||||||
|
data-testid="init-script-editor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!hasChanges || isSaving || isDeleting}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!scriptExists || isSaving || isDeleting}
|
||||||
|
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving || isDeleting}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
|
|||||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||||
import { SettingsHeader } from './settings-view/components/settings-header';
|
import { SettingsHeader } from './settings-view/components/settings-header';
|
||||||
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
||||||
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
|
|
||||||
import { SettingsNavigation } from './settings-view/components/settings-navigation';
|
import { SettingsNavigation } from './settings-view/components/settings-navigation';
|
||||||
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
||||||
import { ModelDefaultsSection } from './settings-view/model-defaults';
|
import { ModelDefaultsSection } from './settings-view/model-defaults';
|
||||||
@@ -16,7 +15,6 @@ import { AudioSection } from './settings-view/audio/audio-section';
|
|||||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||||
import { WorktreesSection } from './settings-view/worktrees';
|
import { WorktreesSection } from './settings-view/worktrees';
|
||||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
|
||||||
import { AccountSection } from './settings-view/account';
|
import { AccountSection } from './settings-view/account';
|
||||||
import { SecuritySection } from './settings-view/security';
|
import { SecuritySection } from './settings-view/security';
|
||||||
import { DeveloperSection } from './settings-view/developer/developer-section';
|
import { DeveloperSection } from './settings-view/developer/developer-section';
|
||||||
@@ -30,8 +28,7 @@ import { MCPServersSection } from './settings-view/mcp-servers';
|
|||||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||||
import { EventHooksSection } from './settings-view/event-hooks';
|
import { EventHooksSection } from './settings-view/event-hooks';
|
||||||
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
|
import { ImportExportDialog } from './settings-view/components/import-export-dialog';
|
||||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
import type { Theme } from './settings-view/shared/types';
|
||||||
import type { Project as ElectronProject } from '@/lib/electron';
|
|
||||||
|
|
||||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||||
const LG_BREAKPOINT = 1024;
|
const LG_BREAKPOINT = 1024;
|
||||||
@@ -40,7 +37,6 @@ export function SettingsView() {
|
|||||||
const {
|
const {
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
setProjectTheme,
|
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
setDefaultSkipTests,
|
setDefaultSkipTests,
|
||||||
enableDependencyBlocking,
|
enableDependencyBlocking,
|
||||||
@@ -54,7 +50,6 @@ export function SettingsView() {
|
|||||||
muteDoneSound,
|
muteDoneSound,
|
||||||
setMuteDoneSound,
|
setMuteDoneSound,
|
||||||
currentProject,
|
currentProject,
|
||||||
moveProjectToTrash,
|
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
setDefaultPlanningMode,
|
setDefaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
@@ -69,34 +64,8 @@ export function SettingsView() {
|
|||||||
setSkipSandboxWarning,
|
setSkipSandboxWarning,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Convert electron Project to settings-view Project type
|
// Global theme (project-specific themes are managed in Project Settings)
|
||||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
const globalTheme = theme as Theme;
|
||||||
if (!project) return null;
|
|
||||||
return {
|
|
||||||
id: project.id,
|
|
||||||
name: project.name,
|
|
||||||
path: project.path,
|
|
||||||
theme: project.theme as Theme | undefined,
|
|
||||||
icon: project.icon,
|
|
||||||
customIconPath: project.customIconPath,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const settingsProject = convertProject(currentProject);
|
|
||||||
|
|
||||||
// Compute the effective theme for the current project
|
|
||||||
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
|
|
||||||
|
|
||||||
// Handler to set theme - always updates global theme (user's preference),
|
|
||||||
// and also sets per-project theme if a project is selected
|
|
||||||
const handleSetTheme = (newTheme: typeof theme) => {
|
|
||||||
// Always update global theme so user's preference persists across all projects
|
|
||||||
setTheme(newTheme);
|
|
||||||
// Also set per-project theme if a project is selected
|
|
||||||
if (currentProject) {
|
|
||||||
setProjectTheme(currentProject.id, newTheme);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get initial view from URL search params
|
// Get initial view from URL search params
|
||||||
const { view: initialView } = useSearch({ from: '/settings' });
|
const { view: initialView } = useSearch({ from: '/settings' });
|
||||||
@@ -113,7 +82,6 @@ export function SettingsView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||||
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
const [showImportExportDialog, setShowImportExportDialog] = useState(false);
|
||||||
|
|
||||||
@@ -172,9 +140,8 @@ export function SettingsView() {
|
|||||||
case 'appearance':
|
case 'appearance':
|
||||||
return (
|
return (
|
||||||
<AppearanceSection
|
<AppearanceSection
|
||||||
effectiveTheme={effectiveTheme as any}
|
effectiveTheme={globalTheme}
|
||||||
currentProject={settingsProject as any}
|
onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
|
||||||
onThemeChange={(theme) => handleSetTheme(theme as any)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
@@ -223,13 +190,6 @@ export function SettingsView() {
|
|||||||
);
|
);
|
||||||
case 'developer':
|
case 'developer':
|
||||||
return <DeveloperSection />;
|
return <DeveloperSection />;
|
||||||
case 'danger':
|
|
||||||
return (
|
|
||||||
<DangerZoneSection
|
|
||||||
project={settingsProject}
|
|
||||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return <ApiKeysSection />;
|
return <ApiKeysSection />;
|
||||||
}
|
}
|
||||||
@@ -265,14 +225,6 @@ export function SettingsView() {
|
|||||||
{/* Keyboard Map Dialog */}
|
{/* Keyboard Map Dialog */}
|
||||||
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
||||||
|
|
||||||
{/* Delete Project Confirmation Dialog */}
|
|
||||||
<DeleteProjectDialog
|
|
||||||
open={showDeleteDialog}
|
|
||||||
onOpenChange={setShowDeleteDialog}
|
|
||||||
project={currentProject}
|
|
||||||
onConfirm={moveProjectToTrash}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Import/Export Settings Dialog */}
|
{/* Import/Export Settings Dialog */}
|
||||||
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
|
<ImportExportDialog open={showImportExportDialog} onOpenChange={setShowImportExportDialog} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,118 +1,20 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Palette, Moon, Sun } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
|
|
||||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import type { Theme } from '../shared/types';
|
||||||
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
|
|
||||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
||||||
import type { Theme, Project } from '../shared/types';
|
|
||||||
|
|
||||||
interface AppearanceSectionProps {
|
interface AppearanceSectionProps {
|
||||||
effectiveTheme: Theme;
|
effectiveTheme: Theme;
|
||||||
currentProject: Project | null;
|
|
||||||
onThemeChange: (theme: Theme) => void;
|
onThemeChange: (theme: Theme) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppearanceSection({
|
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
|
||||||
effectiveTheme,
|
|
||||||
currentProject,
|
|
||||||
onThemeChange,
|
|
||||||
}: AppearanceSectionProps) {
|
|
||||||
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
|
|
||||||
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
|
||||||
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
|
|
||||||
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
|
|
||||||
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
|
|
||||||
currentProject?.customIconPath || null
|
|
||||||
);
|
|
||||||
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Sync local state when currentProject changes
|
|
||||||
useEffect(() => {
|
|
||||||
setProjectNameLocal(currentProject?.name || '');
|
|
||||||
setProjectIconLocal(currentProject?.icon || null);
|
|
||||||
setCustomIconPathLocal(currentProject?.customIconPath || null);
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
|
||||||
|
|
||||||
// Auto-save when values change
|
|
||||||
const handleNameChange = (name: string) => {
|
|
||||||
setProjectNameLocal(name);
|
|
||||||
if (currentProject && name.trim() && name.trim() !== currentProject.name) {
|
|
||||||
setProjectName(currentProject.id, name.trim());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIconChange = (icon: string | null) => {
|
|
||||||
setProjectIconLocal(icon);
|
|
||||||
if (currentProject) {
|
|
||||||
setProjectIcon(currentProject.id, icon);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomIconChange = (path: string | null) => {
|
|
||||||
setCustomIconPathLocal(path);
|
|
||||||
if (currentProject) {
|
|
||||||
setProjectCustomIcon(currentProject.id, path);
|
|
||||||
// Clear Lucide icon when custom icon is set
|
|
||||||
if (path) {
|
|
||||||
setProjectIconLocal(null);
|
|
||||||
setProjectIcon(currentProject.id, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file || !currentProject) return;
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
||||||
if (!validTypes.includes(file.type)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (max 2MB for icons)
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploadingIcon(true);
|
|
||||||
try {
|
|
||||||
// Convert to base64
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async () => {
|
|
||||||
const base64Data = reader.result as string;
|
|
||||||
const result = await getHttpApiClient().saveImageToTemp(
|
|
||||||
base64Data,
|
|
||||||
`project-icon-${file.name}`,
|
|
||||||
file.type,
|
|
||||||
currentProject.path
|
|
||||||
);
|
|
||||||
if (result.success && result.path) {
|
|
||||||
handleCustomIconChange(result.path);
|
|
||||||
}
|
|
||||||
setIsUploadingIcon(false);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
} catch {
|
|
||||||
setIsUploadingIcon(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveCustomIcon = () => {
|
|
||||||
handleCustomIconChange(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -134,94 +36,10 @@ export function AppearanceSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Project Details Section */}
|
|
||||||
{currentProject && (
|
|
||||||
<div className="space-y-4 pb-6 border-b border-border/50">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="project-name-settings">Project Name</Label>
|
|
||||||
<Input
|
|
||||||
id="project-name-settings"
|
|
||||||
value={projectName}
|
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
|
||||||
placeholder="Enter project name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Project Icon</Label>
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
|
||||||
Choose a preset icon or upload a custom image
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Custom Icon Upload */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{customIconPath ? (
|
|
||||||
<div className="relative">
|
|
||||||
<img
|
|
||||||
src={getAuthenticatedImageUrl(customIconPath, currentProject.path)}
|
|
||||||
alt="Custom project icon"
|
|
||||||
className="w-12 h-12 rounded-lg object-cover border border-border"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRemoveCustomIcon}
|
|
||||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
|
|
||||||
<ImageIcon className="w-5 h-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
|
||||||
onChange={handleCustomIconUpload}
|
|
||||||
className="hidden"
|
|
||||||
id="custom-icon-upload"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploadingIcon}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<Upload className="w-3.5 h-3.5" />
|
|
||||||
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
PNG, JPG, GIF or WebP. Max 2MB.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preset Icon Picker - only show if no custom icon */}
|
|
||||||
{!customIconPath && (
|
|
||||||
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Theme Section */}
|
{/* Theme Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-foreground font-medium">
|
<Label className="text-foreground font-medium">Theme</Label>
|
||||||
Theme{' '}
|
|
||||||
<span className="text-muted-foreground font-normal">
|
|
||||||
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
{/* Dark/Light Tabs */}
|
{/* Dark/Light Tabs */}
|
||||||
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
||||||
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
import { GLOBAL_NAV_GROUPS } from '../config/navigation';
|
||||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type { ModelProvider } from '@automaker/types';
|
import type { ModelProvider } from '@automaker/types';
|
||||||
@@ -272,31 +272,6 @@ export function SettingsNavigation({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Project Settings - only show when a project is selected */}
|
|
||||||
{currentProject && (
|
|
||||||
<>
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="my-3 border-t border-border/50" />
|
|
||||||
|
|
||||||
{/* Project Settings Label */}
|
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
|
||||||
Project Settings
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Project Settings Items */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{PROJECT_NAV_ITEMS.map((item) => (
|
|
||||||
<NavButton
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
isActive={activeSection === item.id}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
Volume2,
|
Volume2,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Trash2,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
Plug,
|
Plug,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
Cpu,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Code2,
|
Code2,
|
||||||
Webhook,
|
Webhook,
|
||||||
@@ -84,10 +82,5 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
|||||||
// Flat list of all global nav items for backwards compatibility
|
// Flat list of all global nav items for backwards compatibility
|
||||||
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items);
|
export const GLOBAL_NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_GROUPS.flatMap((group) => group.items);
|
||||||
|
|
||||||
// Project-specific settings - only visible when a project is selected
|
|
||||||
export const PROJECT_NAV_ITEMS: NavigationItem[] = [
|
|
||||||
{ id: 'danger', label: 'Danger Zone', icon: Trash2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Legacy export for backwards compatibility
|
// Legacy export for backwards compatibility
|
||||||
export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS];
|
export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS;
|
||||||
|
|||||||
@@ -1,172 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { GitBranch } from 'lucide-react';
|
||||||
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
|
||||||
import {
|
|
||||||
GitBranch,
|
|
||||||
Terminal,
|
|
||||||
FileCode,
|
|
||||||
Save,
|
|
||||||
RotateCcw,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
PanelBottomClose,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
||||||
|
|
||||||
interface WorktreesSectionProps {
|
interface WorktreesSectionProps {
|
||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
onUseWorktreesChange: (value: boolean) => void;
|
onUseWorktreesChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InitScriptResponse {
|
|
||||||
success: boolean;
|
|
||||||
exists: boolean;
|
|
||||||
content: string;
|
|
||||||
path: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
|
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
|
||||||
const currentProject = useAppStore((s) => s.currentProject);
|
|
||||||
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
|
|
||||||
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
|
|
||||||
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
|
|
||||||
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
|
||||||
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
|
||||||
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
|
||||||
const [scriptContent, setScriptContent] = useState('');
|
|
||||||
const [originalContent, setOriginalContent] = useState('');
|
|
||||||
const [scriptExists, setScriptExists] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
|
|
||||||
// Get the current show indicator setting
|
|
||||||
const showIndicator = currentProject?.path
|
|
||||||
? getShowInitScriptIndicator(currentProject.path)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
// Get the default delete branch setting
|
|
||||||
const defaultDeleteBranch = currentProject?.path
|
|
||||||
? getDefaultDeleteBranch(currentProject.path)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Get the auto-dismiss setting
|
|
||||||
const autoDismiss = currentProject?.path
|
|
||||||
? getAutoDismissInitScriptIndicator(currentProject.path)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
// Check if there are unsaved changes
|
|
||||||
const hasChanges = scriptContent !== originalContent;
|
|
||||||
|
|
||||||
// Load init script content when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject?.path) {
|
|
||||||
setScriptContent('');
|
|
||||||
setOriginalContent('');
|
|
||||||
setScriptExists(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadInitScript = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await apiGet<InitScriptResponse>(
|
|
||||||
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
|
|
||||||
);
|
|
||||||
if (response.success) {
|
|
||||||
const content = response.content || '';
|
|
||||||
setScriptContent(content);
|
|
||||||
setOriginalContent(content);
|
|
||||||
setScriptExists(response.exists);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load init script:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInitScript();
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Save script
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!currentProject?.path) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const response = await apiPut<{ success: boolean; error?: string }>(
|
|
||||||
'/api/worktree/init-script',
|
|
||||||
{
|
|
||||||
projectPath: currentProject.path,
|
|
||||||
content: scriptContent,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (response.success) {
|
|
||||||
setOriginalContent(scriptContent);
|
|
||||||
setScriptExists(true);
|
|
||||||
toast.success('Init script saved');
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to save init script', {
|
|
||||||
description: response.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save init script:', error);
|
|
||||||
toast.error('Failed to save init script');
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path, scriptContent]);
|
|
||||||
|
|
||||||
// Reset to original content
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
setScriptContent(originalContent);
|
|
||||||
}, [originalContent]);
|
|
||||||
|
|
||||||
// Delete script
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
if (!currentProject?.path) return;
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
const response = await apiDelete<{ success: boolean; error?: string }>(
|
|
||||||
'/api/worktree/init-script',
|
|
||||||
{
|
|
||||||
body: { projectPath: currentProject.path },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (response.success) {
|
|
||||||
setScriptContent('');
|
|
||||||
setOriginalContent('');
|
|
||||||
setScriptExists(false);
|
|
||||||
toast.success('Init script deleted');
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to delete init script', {
|
|
||||||
description: response.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete init script:', error);
|
|
||||||
toast.error('Failed to delete init script');
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Handle content change (no auto-save)
|
|
||||||
const handleContentChange = useCallback((value: string) => {
|
|
||||||
setScriptContent(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -184,7 +26,7 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
|
|||||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
Configure git worktree isolation and initialization scripts.
|
Configure git worktree isolation for feature development.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show Init Script Indicator Toggle */}
|
{/* Info about project-specific settings */}
|
||||||
{currentProject && (
|
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4">
|
<p className="text-xs text-muted-foreground">
|
||||||
<Checkbox
|
Project-specific worktree preferences (init script, delete branch behavior) can be
|
||||||
id="show-init-script-indicator"
|
configured in each project's settings via the sidebar.
|
||||||
checked={showIndicator}
|
|
||||||
onCheckedChange={async (checked) => {
|
|
||||||
if (currentProject?.path) {
|
|
||||||
const value = checked === true;
|
|
||||||
setShowInitScriptIndicator(currentProject.path, value);
|
|
||||||
// Persist to server
|
|
||||||
try {
|
|
||||||
const httpClient = getHttpApiClient();
|
|
||||||
await httpClient.settings.updateProject(currentProject.path, {
|
|
||||||
showInitScriptIndicator: value,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to persist showInitScriptIndicator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="show-init-script-indicator"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<PanelBottomClose className="w-4 h-4 text-brand-500" />
|
|
||||||
Show Init Script Indicator
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
Display a floating panel in the bottom-right corner showing init script execution
|
|
||||||
status and output when a worktree is created.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto-dismiss Init Script Indicator Toggle */}
|
|
||||||
{currentProject && showIndicator && (
|
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
|
|
||||||
<Checkbox
|
|
||||||
id="auto-dismiss-indicator"
|
|
||||||
checked={autoDismiss}
|
|
||||||
onCheckedChange={async (checked) => {
|
|
||||||
if (currentProject?.path) {
|
|
||||||
const value = checked === true;
|
|
||||||
setAutoDismissInitScriptIndicator(currentProject.path, value);
|
|
||||||
// Persist to server
|
|
||||||
try {
|
|
||||||
const httpClient = getHttpApiClient();
|
|
||||||
await httpClient.settings.updateProject(currentProject.path, {
|
|
||||||
autoDismissInitScriptIndicator: value,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="auto-dismiss-indicator"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Auto-dismiss After Completion
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
Automatically hide the indicator 5 seconds after the script completes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Default Delete Branch Toggle */}
|
|
||||||
{currentProject && (
|
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
|
||||||
<Checkbox
|
|
||||||
id="default-delete-branch"
|
|
||||||
checked={defaultDeleteBranch}
|
|
||||||
onCheckedChange={async (checked) => {
|
|
||||||
if (currentProject?.path) {
|
|
||||||
const value = checked === true;
|
|
||||||
setDefaultDeleteBranch(currentProject.path, value);
|
|
||||||
// Persist to server
|
|
||||||
try {
|
|
||||||
const httpClient = getHttpApiClient();
|
|
||||||
await httpClient.settings.updateProject(currentProject.path, {
|
|
||||||
defaultDeleteBranch: value,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to persist defaultDeleteBranch:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="default-delete-branch"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 text-brand-500" />
|
|
||||||
Delete Branch by Default
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
When deleting a worktree, automatically check the "Also delete the branch" option.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className="border-t border-border/30" />
|
|
||||||
|
|
||||||
{/* Init Script Section */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Terminal className="w-4 h-4 text-brand-500" />
|
|
||||||
<Label className="text-foreground font-medium">Initialization Script</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
|
|
||||||
on Windows for cross-platform compatibility.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{currentProject ? (
|
|
||||||
<>
|
|
||||||
{/* File path indicator */}
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
|
|
||||||
<FileCode className="w-3.5 h-3.5" />
|
|
||||||
<code className="font-mono">.automaker/worktree-init.sh</code>
|
|
||||||
{hasChanges && (
|
|
||||||
<span className="text-amber-500 font-medium">(unsaved changes)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ShellSyntaxEditor
|
|
||||||
value={scriptContent}
|
|
||||||
onChange={handleContentChange}
|
|
||||||
placeholder={`# Example initialization commands
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Or use pnpm
|
|
||||||
# pnpm install
|
|
||||||
|
|
||||||
# Copy environment file
|
|
||||||
# cp .env.example .env`}
|
|
||||||
minHeight="200px"
|
|
||||||
maxHeight="500px"
|
|
||||||
data-testid="init-script-editor"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={!hasChanges || isSaving || isDeleting}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={!scriptExists || isSaving || isDeleting}
|
|
||||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!hasChanges || isSaving || isDeleting}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground/60 py-4 text-center">
|
|
||||||
Select a project to configure the init script.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2171,6 +2171,9 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
hideScrollbar: boolean;
|
hideScrollbar: boolean;
|
||||||
};
|
};
|
||||||
worktreePanelVisible?: boolean;
|
worktreePanelVisible?: boolean;
|
||||||
|
showInitScriptIndicator?: boolean;
|
||||||
|
defaultDeleteBranchWithWorktree?: boolean;
|
||||||
|
autoDismissInitScriptIndicator?: boolean;
|
||||||
lastSelectedSessionId?: string;
|
lastSelectedSessionId?: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
6
apps/ui/src/routes/project-settings.tsx
Normal file
6
apps/ui/src/routes/project-settings.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { ProjectSettingsView } from '@/components/views/project-settings-view';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/project-settings')({
|
||||||
|
component: ProjectSettingsView,
|
||||||
|
});
|
||||||
@@ -231,6 +231,7 @@ export interface KeyboardShortcuts {
|
|||||||
context: string;
|
context: string;
|
||||||
memory: string;
|
memory: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
projectSettings: string;
|
||||||
terminal: string;
|
terminal: string;
|
||||||
ideation: string;
|
ideation: string;
|
||||||
githubIssues: string;
|
githubIssues: string;
|
||||||
@@ -266,6 +267,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
context: 'C',
|
context: 'C',
|
||||||
memory: 'Y',
|
memory: 'Y',
|
||||||
settings: 'S',
|
settings: 'S',
|
||||||
|
projectSettings: 'Shift+S',
|
||||||
terminal: 'T',
|
terminal: 'T',
|
||||||
ideation: 'I',
|
ideation: 'I',
|
||||||
githubIssues: 'G',
|
githubIssues: 'G',
|
||||||
@@ -730,6 +732,10 @@ export interface AppState {
|
|||||||
// Whether to auto-dismiss the indicator after completion (default: true)
|
// Whether to auto-dismiss the indicator after completion (default: true)
|
||||||
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
|
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
|
||||||
|
|
||||||
|
// Use Worktrees Override (per-project, keyed by project path)
|
||||||
|
// undefined = use global setting, true/false = project-specific override
|
||||||
|
useWorktreesByProject: Record<string, boolean | undefined>;
|
||||||
|
|
||||||
// UI State (previously in localStorage, now synced via API)
|
// UI State (previously in localStorage, now synced via API)
|
||||||
/** Whether worktree panel is collapsed in board view */
|
/** Whether worktree panel is collapsed in board view */
|
||||||
worktreePanelCollapsed: boolean;
|
worktreePanelCollapsed: boolean;
|
||||||
@@ -1183,6 +1189,11 @@ export interface AppActions {
|
|||||||
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
||||||
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
||||||
|
|
||||||
|
// Use Worktrees Override actions (per-project)
|
||||||
|
setProjectUseWorktrees: (projectPath: string, useWorktrees: boolean | null) => void; // null = use global
|
||||||
|
getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global
|
||||||
|
getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback)
|
||||||
|
|
||||||
// UI State actions (previously in localStorage, now synced via API)
|
// UI State actions (previously in localStorage, now synced via API)
|
||||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||||
setLastProjectDir: (dir: string) => void;
|
setLastProjectDir: (dir: string) => void;
|
||||||
@@ -1343,6 +1354,7 @@ const initialState: AppState = {
|
|||||||
showInitScriptIndicatorByProject: {},
|
showInitScriptIndicatorByProject: {},
|
||||||
defaultDeleteBranchByProject: {},
|
defaultDeleteBranchByProject: {},
|
||||||
autoDismissInitScriptIndicatorByProject: {},
|
autoDismissInitScriptIndicatorByProject: {},
|
||||||
|
useWorktreesByProject: {},
|
||||||
// UI State (previously in localStorage, now synced via API)
|
// UI State (previously in localStorage, now synced via API)
|
||||||
worktreePanelCollapsed: false,
|
worktreePanelCollapsed: false,
|
||||||
lastProjectDir: '',
|
lastProjectDir: '',
|
||||||
@@ -3526,6 +3538,31 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
|
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Use Worktrees Override actions (per-project)
|
||||||
|
setProjectUseWorktrees: (projectPath, useWorktrees) => {
|
||||||
|
const newValue = useWorktrees === null ? undefined : useWorktrees;
|
||||||
|
set({
|
||||||
|
useWorktreesByProject: {
|
||||||
|
...get().useWorktreesByProject,
|
||||||
|
[projectPath]: newValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectUseWorktrees: (projectPath) => {
|
||||||
|
// Returns undefined if using global setting, true/false if project-specific
|
||||||
|
return get().useWorktreesByProject[projectPath];
|
||||||
|
},
|
||||||
|
|
||||||
|
getEffectiveUseWorktrees: (projectPath) => {
|
||||||
|
// Returns the actual value to use (project override or global fallback)
|
||||||
|
const projectSetting = get().useWorktreesByProject[projectPath];
|
||||||
|
if (projectSetting !== undefined) {
|
||||||
|
return projectSetting;
|
||||||
|
}
|
||||||
|
return get().useWorktrees;
|
||||||
|
},
|
||||||
|
|
||||||
// UI State actions (previously in localStorage, now synced via API)
|
// UI State actions (previously in localStorage, now synced via API)
|
||||||
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
||||||
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ export interface KeyboardShortcuts {
|
|||||||
context: string;
|
context: string;
|
||||||
/** Open settings */
|
/** Open settings */
|
||||||
settings: string;
|
settings: string;
|
||||||
|
/** Open project settings */
|
||||||
|
projectSettings: string;
|
||||||
/** Open terminal */
|
/** Open terminal */
|
||||||
terminal: string;
|
terminal: string;
|
||||||
/** Toggle sidebar visibility */
|
/** Toggle sidebar visibility */
|
||||||
@@ -799,6 +801,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
spec: 'D',
|
spec: 'D',
|
||||||
context: 'C',
|
context: 'C',
|
||||||
settings: 'S',
|
settings: 'S',
|
||||||
|
projectSettings: 'Shift+S',
|
||||||
terminal: 'T',
|
terminal: 'T',
|
||||||
toggleSidebar: '`',
|
toggleSidebar: '`',
|
||||||
addFeature: 'N',
|
addFeature: 'N',
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "automaker",
|
"name": "automaker",
|
||||||
"version": "0.12.0rc",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "automaker",
|
"name": "automaker",
|
||||||
"version": "0.12.0rc",
|
"version": "1.0.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"apps/server": {
|
"apps/server": {
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.12.0",
|
"version": "0.10.0",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
},
|
},
|
||||||
"apps/ui": {
|
"apps/ui": {
|
||||||
"name": "@automaker/ui",
|
"name": "@automaker/ui",
|
||||||
"version": "0.12.0",
|
"version": "0.10.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -11607,7 +11607,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11629,7 +11628,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11673,7 +11671,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11695,7 +11692,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11717,7 +11713,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11739,7 +11734,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11761,7 +11755,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11783,7 +11776,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11805,7 +11797,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user