feat: separate project settings from global settings

This PR introduces a new dedicated Project Settings screen accessible from
the sidebar, clearly separating project-specific settings from global
application settings.

- Added new route `/project-settings` with dedicated view
- Sidebar navigation item "Settings" in Tools section (Shift+S shortcut)
- Sidebar-based navigation matching global Settings pattern
- Sections: Identity, Worktrees, Theme, Danger Zone

**Moved to Project Settings:**
- Project name and icon customization
- Project-specific theme override
- Worktree isolation enable/disable (per-project override)
- Init script indicator visibility and auto-dismiss
- Delete branch by default preference
- Initialization script editor
- Delete project (Danger Zone)

**Remains in Global Settings:**
- Global theme (default for all projects)
- Global worktree isolation (default for new projects)
- Feature Defaults, Model Defaults
- API Keys, AI Providers, MCP Servers
- Terminal, Keyboard Shortcuts, Audio
- Account, Security, Developer settings

Both Theme and Worktree Isolation now follow a consistent override pattern:
1. Global Settings defines the default value
2. New projects inherit the global value
3. Project Settings can override for that specific project
4. Changing global setting doesn't affect projects with overrides

- Fixed: Changing global theme was incorrectly overwriting project themes
- Fixed: Project worktree setting not persisting across sessions
- Project settings now properly load from server on component mount

- Shell syntax editor: improved background contrast (bg-background)
- Shell syntax editor: removed distracting active line highlight
- Project Settings header matches Context/Memory views pattern

- `apps/ui/src/routes/project-settings.tsx`
- `apps/ui/src/components/views/project-settings-view/` (9 files)

- Global settings simplified (removed project-specific options)
- Sidebar navigation updated with project settings link
- App store: added project-specific useWorktrees state/actions
- Types: added projectSettings keyboard shortcut
- HTTP client: added missing project settings response fields
This commit is contained in:
Stefan de Vogelaere
2026-01-16 22:28:56 +01:00
parent 97b0028919
commit 2899b6d416
21 changed files with 1249 additions and 1681 deletions

View File

@@ -1,118 +1,20 @@
import { useState, useRef, useEffect } from 'react';
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
import { Palette, Moon, Sun } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
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 type { Theme, Project } from '../shared/types';
import type { Theme } from '../shared/types';
interface AppearanceSectionProps {
effectiveTheme: Theme;
currentProject: Project | null;
onThemeChange: (theme: Theme) => void;
}
export function AppearanceSection({
effectiveTheme,
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
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;
// 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 (
<div
className={cn(
@@ -134,94 +36,10 @@ export function AppearanceSection({
</p>
</div>
<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 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">
Theme{' '}
<span className="text-muted-foreground font-normal">
{currentProject ? `(for ${currentProject.name})` : '(Global)'}
</span>
</Label>
<Label className="text-foreground font-medium">Theme</Label>
{/* Dark/Light Tabs */}
<div className="flex gap-1 p-1 rounded-lg bg-accent/30">
<button

View File

@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import type { Project } from '@/lib/electron';
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 { useAppStore } from '@/store/app-store';
import type { ModelProvider } from '@automaker/types';
@@ -272,31 +272,6 @@ export function SettingsNavigation({
</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>
</nav>
</>

View File

@@ -8,13 +8,11 @@ import {
Settings2,
Volume2,
FlaskConical,
Trash2,
Workflow,
Plug,
MessageSquareText,
User,
Shield,
Cpu,
GitBranch,
Code2,
Webhook,
@@ -84,10 +82,5 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
// Flat list of all global nav items for backwards compatibility
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
export const NAV_ITEMS: NavigationItem[] = [...GLOBAL_NAV_ITEMS, ...PROJECT_NAV_ITEMS];
export const NAV_ITEMS: NavigationItem[] = GLOBAL_NAV_ITEMS;

View File

@@ -1,172 +1,14 @@
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 { GitBranch } 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';
interface WorktreesSectionProps {
useWorktrees: boolean;
onUseWorktreesChange: (value: boolean) => void;
}
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
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 (
<div
className={cn(
@@ -184,7 +26,7 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
</div>
<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>
</div>
<div className="p-6 space-y-5">
@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
</div>
</div>
{/* Show Init Script Indicator 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 mt-4">
<Checkbox
id="show-init-script-indicator"
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.
{/* Info about project-specific settings */}
<div className="rounded-xl border border-border/30 bg-muted/30 p-4">
<p className="text-xs text-muted-foreground">
Project-specific worktree preferences (init script, delete branch behavior) can be
configured in each project's settings via the sidebar.
</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>