diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx index 24cdafbf..4f864eea 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -151,7 +151,7 @@ export function SidebarFooter({ sidebarOpen ? 'justify-start' : 'justify-center', 'hover:scale-[1.02] active:scale-[0.97]' )} - title={!sidebarOpen ? 'Settings' : undefined} + title={!sidebarOpen ? 'Global Settings' : undefined} data-testid="settings-button" > - Settings + Global Settings {sidebarOpen && ( - Settings + Global Settings {formatShortcut(shortcuts.settings, true)} diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index f1671a78..d95f0c3a 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -41,7 +41,13 @@ export function SidebarNavigation({ )} - {section.label && !sidebarOpen &&
} + {/* Separator for sections without label (visual separation) */} + {!section.label && sectionIdx > 0 && sidebarOpen && ( +
+ )} + {(section.label || sectionIdx > 0) && !sidebarOpen && ( +
+ )} {/* Nav Items */}
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 110fa26c..79462ab7 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -11,6 +11,7 @@ import { Lightbulb, Brain, Network, + Settings, } from 'lucide-react'; import type { NavSection, NavItem } from '../types'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; @@ -32,6 +33,7 @@ interface UseNavigationProps { agent: string; terminal: string; settings: string; + projectSettings: string; ideation: string; githubIssues: 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; }, [ shortcuts, @@ -257,11 +272,11 @@ export function useNavigation({ }); }); - // Add settings shortcut + // Add global settings shortcut shortcutsList.push({ key: shortcuts.settings, action: () => navigate({ to: '/settings' }), - description: 'Navigate to Settings', + description: 'Navigate to Global Settings', }); } diff --git a/apps/ui/src/components/ui/shell-syntax-editor.tsx b/apps/ui/src/components/ui/shell-syntax-editor.tsx index 159123c4..c405309a 100644 --- a/apps/ui/src/components/ui/shell-syntax-editor.tsx +++ b/apps/ui/src/components/ui/shell-syntax-editor.tsx @@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({ backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', }, '.cm-activeLine': { - backgroundColor: 'var(--accent)', - opacity: '0.3', + backgroundColor: 'transparent', }, '.cm-line': { padding: '0 0.25rem', @@ -114,7 +113,7 @@ export function ShellSyntaxEditor({ }: ShellSyntaxEditorProps) { return (
diff --git a/apps/ui/src/components/views/project-settings-view/components/project-settings-navigation.tsx b/apps/ui/src/components/views/project-settings-view/components/project-settings-navigation.tsx new file mode 100644 index 00000000..1c06dad3 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/components/project-settings-navigation.tsx @@ -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 && ( +
+ )} + + {/* Navigation sidebar */} + + + ); +} diff --git a/apps/ui/src/components/views/project-settings-view/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts new file mode 100644 index 00000000..7f052ef5 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts @@ -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 }, +]; diff --git a/apps/ui/src/components/views/project-settings-view/hooks/index.ts b/apps/ui/src/components/views/project-settings-view/hooks/index.ts new file mode 100644 index 00000000..023eca9e --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/hooks/index.ts @@ -0,0 +1 @@ +export { useProjectSettingsView, type ProjectSettingsViewId } from './use-project-settings-view'; diff --git a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts new file mode 100644 index 00000000..19faf5e3 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts @@ -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(initialView); + + const navigateTo = useCallback((viewId: ProjectSettingsViewId) => { + setActiveView(viewId); + }, []); + + return { + activeView, + navigateTo, + }; +} diff --git a/apps/ui/src/components/views/project-settings-view/index.ts b/apps/ui/src/components/views/project-settings-view/index.ts new file mode 100644 index 00000000..bc16ffaf --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/project-settings-view/project-identity-section.tsx b/apps/ui/src/components/views/project-settings-view/project-identity-section.tsx new file mode 100644 index 00000000..669b7879 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-identity-section.tsx @@ -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(project.icon || null); + const [customIconPath, setCustomIconPathLocal] = useState( + project.customIconPath || null + ); + const [isUploadingIcon, setIsUploadingIcon] = useState(false); + const fileInputRef = useRef(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) => { + 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 ( +
+
+
+
+ +
+

Project Identity

+
+

+ Customize how your project appears in the sidebar and project switcher. +

+
+
+ {/* Project Name */} +
+ + handleNameChange(e.target.value)} + placeholder="Enter project name" + /> +
+ + {/* Project Icon */} +
+ +

+ Choose a preset icon or upload a custom image +

+ + {/* Custom Icon Upload */} +
+
+ {customIconPath ? ( +
+ Custom project icon + +
+ ) : ( +
+ +
+ )} +
+ + +

+ PNG, JPG, GIF or WebP. Max 2MB. +

+
+
+
+ + {/* Preset Icon Picker - only show if no custom icon */} + {!customIconPath && ( + + )} +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx new file mode 100644 index 00000000..f441cc72 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -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 ; + case 'theme': + return ; + case 'worktrees': + return ; + case 'danger': + return ( + setShowDeleteDialog(true)} + /> + ); + default: + return ; + } + }; + + // Show message if no project is selected + if (!currentProject) { + return ( +
+
+
+
+ +
+

No Project Selected

+

+ Select a project from the sidebar to configure project-specific settings. +

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ {/* Mobile menu button */} + + +
+

Project Settings

+

+ Configure settings for {currentProject.name} +

+
+
+
+ + {/* Content Area with Sidebar */} +
+ {/* Side Navigation */} + setShowNavigation(false)} + /> + + {/* Content Panel - Shows only the active section */} +
+
{renderActiveSection()}
+
+
+ + {/* Delete Project Confirmation Dialog */} + +
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx b/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx new file mode 100644 index 00000000..d9293df2 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx @@ -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 ( +
+
+
+
+ +
+

Theme

+
+

+ Customize the theme for this project. +

+
+
+ {/* Use Global Theme Toggle */} +
+ +
+ +

+ When enabled, this project will use the global theme setting. Disable to set a + project-specific theme. +

+
+
+ + {/* Theme Selection - only show if not using global theme */} + {hasCustomTheme && ( +
+
+ + {/* Dark/Light Tabs */} +
+ + +
+
+
+ {themesToShow.map(({ value, label, Icon, testId, color }) => { + const isActive = effectiveTheme === value; + return ( + + ); + })} +
+
+ )} + + {/* Info when using global theme */} + {!hasCustomTheme && ( +
+

+ This project is using the global theme:{' '} + {globalTheme} +

+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx new file mode 100644 index 00000000..c289d382 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx @@ -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( + `/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 ( +
+
+
+
+ +
+

+ Worktree Preferences +

+
+

+ Configure worktree behavior for this project. +

+
+
+ {/* Enable Git Worktree Isolation Toggle */} +
+ { + 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" + /> +
+ +

+ Creates isolated git branches for each feature in this project. When disabled, agents + work directly in the main project directory. +

+
+
+ + {/* Separator */} +
+ + {/* Show Init Script Indicator Toggle */} +
+ { + 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" + /> +
+ +

+ Display a floating panel in the bottom-right corner showing init script execution + status and output when a worktree is created. +

+
+
+ + {/* Auto-dismiss Init Script Indicator Toggle */} + {showIndicator && ( +
+ { + 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" + /> +
+ +

+ Automatically hide the indicator 5 seconds after the script completes. +

+
+
+ )} + + {/* Default Delete Branch Toggle */} +
+ { + 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" + /> +
+ +

+ When deleting a worktree, automatically check the "Also delete the branch" option. +

+
+
+ + {/* Separator */} +
+ + {/* Init Script Section */} +
+
+
+ + +
+
+

+ Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash + on Windows for cross-platform compatibility. +

+ + {/* File path indicator */} +
+ + .automaker/worktree-init.sh + {hasChanges && (unsaved changes)} +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + + + {/* Action buttons */} +
+ + + +
+ + )} +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 1ddf0a39..3bcec3bb 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -6,7 +6,6 @@ import { useSettingsView, type SettingsViewId } from './settings-view/hooks'; import { NAV_ITEMS } from './settings-view/config/navigation'; import { SettingsHeader } from './settings-view/components/settings-header'; 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 { ApiKeysSection } from './settings-view/api-keys/api-keys-section'; 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 { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; import { WorktreesSection } from './settings-view/worktrees'; -import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; import { AccountSection } from './settings-view/account'; import { SecuritySection } from './settings-view/security'; 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 { EventHooksSection } from './settings-view/event-hooks'; import { ImportExportDialog } from './settings-view/components/import-export-dialog'; -import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; -import type { Project as ElectronProject } from '@/lib/electron'; +import type { Theme } from './settings-view/shared/types'; // Breakpoint constant for mobile (matches Tailwind lg breakpoint) const LG_BREAKPOINT = 1024; @@ -40,7 +37,6 @@ export function SettingsView() { const { theme, setTheme, - setProjectTheme, defaultSkipTests, setDefaultSkipTests, enableDependencyBlocking, @@ -54,7 +50,6 @@ export function SettingsView() { muteDoneSound, setMuteDoneSound, currentProject, - moveProjectToTrash, defaultPlanningMode, setDefaultPlanningMode, defaultRequirePlanApproval, @@ -69,34 +64,8 @@ export function SettingsView() { setSkipSandboxWarning, } = useAppStore(); - // 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 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); - } - }; + // Global theme (project-specific themes are managed in Project Settings) + const globalTheme = theme as Theme; // Get initial view from URL search params const { view: initialView } = useSearch({ from: '/settings' }); @@ -113,7 +82,6 @@ export function SettingsView() { } }; - const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false); const [showImportExportDialog, setShowImportExportDialog] = useState(false); @@ -172,9 +140,8 @@ export function SettingsView() { case 'appearance': return ( handleSetTheme(theme as any)} + effectiveTheme={globalTheme} + onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)} /> ); case 'terminal': @@ -223,13 +190,6 @@ export function SettingsView() { ); case 'developer': return ; - case 'danger': - return ( - setShowDeleteDialog(true)} - /> - ); default: return ; } @@ -265,14 +225,6 @@ export function SettingsView() { {/* Keyboard Map Dialog */} - {/* Delete Project Confirmation Dialog */} - - {/* Import/Export Settings Dialog */}
diff --git a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx index 003501f9..47646287 100644 --- a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx +++ b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx @@ -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(currentProject?.icon || null); - const [customIconPath, setCustomIconPathLocal] = useState( - currentProject?.customIconPath || null - ); - const [isUploadingIcon, setIsUploadingIcon] = useState(false); - const fileInputRef = useRef(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) => { - 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 (
- {/* Project Details Section */} - {currentProject && ( -
-
-
- - handleNameChange(e.target.value)} - placeholder="Enter project name" - /> -
- -
- -

- Choose a preset icon or upload a custom image -

- - {/* Custom Icon Upload */} -
-
- {customIconPath ? ( -
- Custom project icon - -
- ) : ( -
- -
- )} -
- - -

- PNG, JPG, GIF or WebP. Max 2MB. -

-
-
-
- - {/* Preset Icon Picker - only show if no custom icon */} - {!customIconPath && ( - - )} -
-
-
- )} - {/* Theme Section */}
- + {/* Dark/Light Tabs */}
))} - - {/* Project Settings - only show when a project is selected */} - {currentProject && ( - <> - {/* Divider */} -
- - {/* Project Settings Label */} -
- Project Settings -
- - {/* Project Settings Items */} -
- {PROJECT_NAV_ITEMS.map((item) => ( - - ))} -
- - )}
diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index c5d5d362..107d8678 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -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; diff --git a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx index 2d232a65..062d2d0d 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -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( - `/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 (
Worktrees

- Configure git worktree isolation and initialization scripts. + Configure git worktree isolation for feature development.

@@ -212,217 +54,12 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
- {/* Show Init Script Indicator Toggle */} - {currentProject && ( -
- { - 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" - /> -
- -

- Display a floating panel in the bottom-right corner showing init script execution - status and output when a worktree is created. -

-
-
- )} - - {/* Auto-dismiss Init Script Indicator Toggle */} - {currentProject && showIndicator && ( -
- { - 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" - /> -
- -

- Automatically hide the indicator 5 seconds after the script completes. -

-
-
- )} - - {/* Default Delete Branch Toggle */} - {currentProject && ( -
- { - 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" - /> -
- -

- When deleting a worktree, automatically check the "Also delete the branch" option. -

-
-
- )} - - {/* Separator */} -
- - {/* Init Script Section */} -
-
-
- - -
-
-

- 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 */} +

+

+ Project-specific worktree preferences (init script, delete branch behavior) can be + configured in each project's settings via the sidebar.

- - {currentProject ? ( - <> - {/* File path indicator */} -
- - .automaker/worktree-init.sh - {hasChanges && ( - (unsaved changes) - )} -
- - {isLoading ? ( -
- -
- ) : ( - <> - - - {/* Action buttons */} -
- - - -
- - )} - - ) : ( -
- Select a project to configure the init script. -
- )}
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 90781b59..26e1f308 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2171,6 +2171,9 @@ export class HttpApiClient implements ElectronAPI { hideScrollbar: boolean; }; worktreePanelVisible?: boolean; + showInitScriptIndicator?: boolean; + defaultDeleteBranchWithWorktree?: boolean; + autoDismissInitScriptIndicator?: boolean; lastSelectedSessionId?: string; }; error?: string; diff --git a/apps/ui/src/routes/project-settings.tsx b/apps/ui/src/routes/project-settings.tsx new file mode 100644 index 00000000..e933d58d --- /dev/null +++ b/apps/ui/src/routes/project-settings.tsx @@ -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, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 23fa5371..23886ab6 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -231,6 +231,7 @@ export interface KeyboardShortcuts { context: string; memory: string; settings: string; + projectSettings: string; terminal: string; ideation: string; githubIssues: string; @@ -266,6 +267,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { context: 'C', memory: 'Y', settings: 'S', + projectSettings: 'Shift+S', terminal: 'T', ideation: 'I', githubIssues: 'G', @@ -730,6 +732,10 @@ export interface AppState { // Whether to auto-dismiss the indicator after completion (default: true) autoDismissInitScriptIndicatorByProject: Record; + // Use Worktrees Override (per-project, keyed by project path) + // undefined = use global setting, true/false = project-specific override + useWorktreesByProject: Record; + // UI State (previously in localStorage, now synced via API) /** Whether worktree panel is collapsed in board view */ worktreePanelCollapsed: boolean; @@ -1183,6 +1189,11 @@ export interface AppActions { setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void; 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) setWorktreePanelCollapsed: (collapsed: boolean) => void; setLastProjectDir: (dir: string) => void; @@ -1343,6 +1354,7 @@ const initialState: AppState = { showInitScriptIndicatorByProject: {}, defaultDeleteBranchByProject: {}, autoDismissInitScriptIndicatorByProject: {}, + useWorktreesByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, lastProjectDir: '', @@ -3526,6 +3538,31 @@ export const useAppStore = create()((set, get) => ({ 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) setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }), diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 6e807f66..0715cfc1 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -296,6 +296,8 @@ export interface KeyboardShortcuts { context: string; /** Open settings */ settings: string; + /** Open project settings */ + projectSettings: string; /** Open terminal */ terminal: string; /** Toggle sidebar visibility */ @@ -799,6 +801,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { spec: 'D', context: 'C', settings: 'S', + projectSettings: 'Shift+S', terminal: 'T', toggleSidebar: '`', addFeature: 'N', diff --git a/package-lock.json b/package-lock.json index 1f9e8037..dd96e672 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "automaker", - "version": "0.12.0rc", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "automaker", - "version": "0.12.0rc", + "version": "1.0.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -29,7 +29,7 @@ }, "apps/server": { "name": "@automaker/server", - "version": "0.12.0", + "version": "0.10.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.1.76", @@ -80,7 +80,7 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.12.0", + "version": "0.10.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -11607,7 +11607,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11629,7 +11628,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11673,7 +11671,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11695,7 +11692,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11717,7 +11713,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11739,7 +11734,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11761,7 +11755,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11783,7 +11776,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11805,7 +11797,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" },