diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3fed705..61ad83f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,7 @@ For complete details on contribution terms and rights assignment, please review - [Development Setup](#development-setup) - [Project Structure](#project-structure) - [Pull Request Process](#pull-request-process) + - [Branching Strategy (RC Branches)](#branching-strategy-rc-branches) - [Branch Naming Convention](#branch-naming-convention) - [Commit Message Format](#commit-message-format) - [Submitting a Pull Request](#submitting-a-pull-request) @@ -186,6 +187,59 @@ automaker/ This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged. +### Branching Strategy (RC Branches) + +Automaker uses **Release Candidate (RC) branches** for all development work. Understanding this workflow is essential before contributing. + +**How it works:** + +1. **All development happens on RC branches** - We maintain version-specific RC branches (e.g., `v0.10.0rc`, `v0.11.0rc`) where all active development occurs +2. **RC branches are eventually merged to main** - Once an RC branch is stable and ready for release, it gets merged into `main` +3. **Main branch is for releases only** - The `main` branch contains only released, stable code + +**Before creating a PR:** + +1. **Check for the latest RC branch** - Before starting work, check the repository for the current RC branch: + + ```bash + git fetch upstream + git branch -r | grep rc + ``` + +2. **Base your work on the RC branch** - Create your feature branch from the latest RC branch, not from `main`: + + ```bash + # Find the latest RC branch (e.g., v0.11.0rc) + git checkout upstream/v0.11.0rc + git checkout -b feature/your-feature-name + ``` + +3. **Target the RC branch in your PR** - When opening your pull request, set the base branch to the current RC branch, not `main` + +**Example workflow:** + +```bash +# 1. Fetch latest changes +git fetch upstream + +# 2. Check for RC branches +git branch -r | grep rc +# Output: upstream/v0.11.0rc + +# 3. Create your branch from the RC +git checkout -b feature/add-dark-mode upstream/v0.11.0rc + +# 4. Make your changes and commit +git commit -m "feat: Add dark mode support" + +# 5. Push to your fork +git push origin feature/add-dark-mode + +# 6. Open PR targeting the RC branch (v0.11.0rc), NOT main +``` + +**Important:** PRs opened directly against `main` will be asked to retarget to the current RC branch. + ### Branch Naming Convention We use a consistent branch naming pattern to keep our repository organized: @@ -275,14 +329,14 @@ Follow these steps to submit your contribution: #### 1. Prepare Your Changes -Ensure you've synced with the latest upstream changes: +Ensure you've synced with the latest upstream changes from the RC branch: ```bash # Fetch latest changes from upstream git fetch upstream -# Rebase your branch on main (if needed) -git rebase upstream/main +# Rebase your branch on the current RC branch (if needed) +git rebase upstream/v0.11.0rc # Use the current RC branch name ``` #### 2. Run Pre-submission Checks @@ -314,18 +368,19 @@ git push origin feature/your-feature-name 1. Go to your fork on GitHub 2. Click "Compare & pull request" for your branch -3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main` +3. **Important:** Set the base repository to `AutoMaker-Org/automaker` and the base branch to the **current RC branch** (e.g., `v0.11.0rc`), not `main` 4. Fill out the PR template completely #### PR Requirements Checklist Your PR should include: +- [ ] **Targets the current RC branch** (not `main`) - see [Branching Strategy](#branching-strategy-rc-branches) - [ ] **Clear title** describing the change (use conventional commit format) - [ ] **Description** explaining what changed and why - [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456` - [ ] **All CI checks passing** (format, lint, build, tests) -- [ ] **No merge conflicts** with main branch +- [ ] **No merge conflicts** with the RC branch - [ ] **Tests included** for new functionality - [ ] **Documentation updated** if adding/changing public APIs diff --git a/apps/ui/package.json b/apps/ui/package.json index 61bd5ae8..384dc581 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -56,6 +56,7 @@ "@radix-ui/react-label": "2.1.8", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", diff --git a/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx index 0059b2fa..31e39367 100644 --- a/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { Dialog, DialogContent, @@ -9,7 +9,10 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Upload, X, ImageIcon } from 'lucide-react'; import { useAppStore } from '@/store/app-store'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; +import { getHttpApiClient } from '@/lib/http-api-client'; import type { Project } from '@/lib/electron'; import { IconPicker } from './icon-picker'; @@ -20,20 +23,75 @@ interface EditProjectDialogProps { } export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) { - const { setProjectName, setProjectIcon } = useAppStore(); + const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore(); const [name, setName] = useState(project.name); - const [icon, setIcon] = useState(project.icon || null); + const [icon, setIcon] = useState((project as any).icon || null); + const [customIconPath, setCustomIconPath] = useState( + (project as any).customIconPath || null + ); + const [isUploadingIcon, setIsUploadingIcon] = useState(false); + const fileInputRef = useRef(null); const handleSave = () => { if (name.trim() !== project.name) { setProjectName(project.id, name.trim()); } - if (icon !== project.icon) { + if (icon !== (project as any).icon) { setProjectIcon(project.id, icon); } + if (customIconPath !== (project as any).customIconPath) { + setProjectCustomIcon(project.id, customIconPath); + } onOpenChange(false); }; + 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)) { + 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, + project.path + ); + if (result.success && result.path) { + setCustomIconPath(result.path); + // Clear the Lucide icon when custom icon is set + setIcon(null); + } + setIsUploadingIcon(false); + }; + reader.readAsDataURL(file); + } catch { + setIsUploadingIcon(false); + } + }; + + const handleRemoveCustomIcon = () => { + setCustomIconPath(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + return ( @@ -41,7 +99,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi Edit Project -
+
{/* Project Name */}
@@ -56,11 +114,66 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi {/* Icon Picker */}
- +

+ 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/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index 93b0091a..84b6ea9a 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,20 +1,24 @@ -import { useEffect, useRef, useState } from 'react'; -import { Edit2, Trash2, ImageIcon } from 'lucide-react'; +import { useEffect, useRef } from 'react'; +import { Edit2, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import type { Project } from '@/lib/electron'; -import { EditProjectDialog } from './edit-project-dialog'; interface ProjectContextMenuProps { project: Project; position: { x: number; y: number }; onClose: () => void; + onEdit: (project: Project) => void; } -export function ProjectContextMenu({ project, position, onClose }: ProjectContextMenuProps) { +export function ProjectContextMenu({ + project, + position, + onClose, + onEdit, +}: ProjectContextMenuProps) { const menuRef = useRef(null); const { moveProjectToTrash } = useAppStore(); - const [showEditDialog, setShowEditDialog] = useState(false); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -39,72 +43,61 @@ export function ProjectContextMenu({ project, position, onClose }: ProjectContex }, [onClose]); const handleEdit = () => { - setShowEditDialog(true); - onClose(); + onEdit(project); }; const handleRemove = () => { - if (confirm(`Move "${project.name}" to trash?`)) { + if (confirm(`Remove "${project.name}" from the project list?`)) { moveProjectToTrash(project.id); } onClose(); }; return ( - <> -
-
- - - -
-
- - {showEditDialog && ( - +
+ style={{ + top: position.y, + left: position.x, + }} + data-testid="project-context-menu" + > +
+ + + +
+
); } diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx index fc7794c2..b4434f8b 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -1,11 +1,13 @@ import { Folder, LucideIcon } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; import { cn } from '@/lib/utils'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import type { Project } from '@/lib/electron'; interface ProjectSwitcherItemProps { project: Project; isActive: boolean; + hotkeyIndex?: number; // 0-9 for hotkeys 1-9, 0 onClick: () => void; onContextMenu: (event: React.MouseEvent) => void; } @@ -13,9 +15,17 @@ interface ProjectSwitcherItemProps { export function ProjectSwitcherItem({ project, isActive, + hotkeyIndex, onClick, onContextMenu, }: ProjectSwitcherItemProps) { + // Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0" + const hotkeyLabel = + hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9 + ? hotkeyIndex === 9 + ? '0' + : String(hotkeyIndex + 1) + : undefined; // Get the icon component from lucide-react const getIconComponent = (): LucideIcon => { if (project.icon && project.icon in LucideIcons) { @@ -25,6 +35,7 @@ export function ProjectSwitcherItem({ }; const IconComponent = getIconComponent(); + const hasCustomIcon = !!project.customIconPath; return ( ); } diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index dc08ef96..e6080ab4 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -1,19 +1,72 @@ -import { useState } from 'react'; -import { Plus } from 'lucide-react'; +import { useState, useCallback, useEffect } from 'react'; +import { Plus, Bug } from 'lucide-react'; import { useNavigate } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; +import { useOSDetection } from '@/hooks/use-os-detection'; import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectContextMenu } from './components/project-context-menu'; +import { EditProjectDialog } from './components/edit-project-dialog'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; +import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; +import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks'; import type { Project } from '@/lib/electron'; +import { getElectronAPI } from '@/lib/electron'; + +function getOSAbbreviation(os: string): string { + switch (os) { + case 'mac': + return 'M'; + case 'windows': + return 'W'; + case 'linux': + return 'L'; + default: + return '?'; + } +} export function ProjectSwitcher() { const navigate = useNavigate(); - const { projects, currentProject, setCurrentProject } = useAppStore(); + const { + projects, + currentProject, + setCurrentProject, + trashedProjects, + upsertAndSetCurrentProject, + } = useAppStore(); const [contextMenuProject, setContextMenuProject] = useState(null); const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( null ); + const [editDialogProject, setEditDialogProject] = useState(null); + + // Version info + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + const { os } = useOSDetection(); + const appMode = import.meta.env.VITE_APP_MODE || '?'; + const versionSuffix = `${getOSAbbreviation(os)}${appMode}`; + + // Get global theme for project creation + const { globalTheme } = useProjectTheme(); + + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + }); const handleContextMenu = (project: Project, event: React.MouseEvent) => { event.preventDefault(); @@ -26,16 +79,70 @@ export function ProjectSwitcher() { setContextMenuPosition(null); }; - const handleProjectClick = (project: Project) => { - setCurrentProject(project); - // Navigate to board view when switching projects + const handleEditProject = (project: Project) => { + setEditDialogProject(project); + handleCloseContextMenu(); + }; + + const handleProjectClick = useCallback( + (project: Project) => { + setCurrentProject(project); + // Navigate to board view when switching projects + navigate({ to: '/board' }); + }, + [setCurrentProject, navigate] + ); + + const handleNewProject = () => { + // Open the new project modal + setShowNewProjectModal(true); + }; + + const handleOnboardingSkip = () => { + setShowOnboardingDialog(false); navigate({ to: '/board' }); }; - const handleNewProject = () => { - // Navigate to dashboard where users can create new projects - navigate({ to: '/dashboard' }); - }; + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); + + // Keyboard shortcuts for project switching (1-9, 0) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Ignore if user is typing in an input, textarea, or contenteditable + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + // Ignore if modifier keys are pressed (except for standalone number keys) + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + // Map key to project index: "1" -> 0, "2" -> 1, ..., "9" -> 8, "0" -> 9 + const key = event.key; + let projectIndex: number | null = null; + + if (key >= '1' && key <= '9') { + projectIndex = parseInt(key, 10) - 1; // "1" -> 0, "9" -> 8 + } else if (key === '0') { + projectIndex = 9; // "0" -> 9 + } + + if (projectIndex !== null && projectIndex < projects.length) { + const targetProject = projects[projectIndex]; + if (targetProject && targetProject.id !== currentProject?.id) { + handleProjectClick(targetProject); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [projects, currentProject, handleProjectClick]); return ( <> @@ -49,23 +156,110 @@ export function ProjectSwitcher() { )} data-testid="project-switcher" > + {/* Automaker Logo and Version */} +
+ +
+
+ {/* Projects List */}
- {projects.map((project) => ( + {projects.map((project, index) => ( handleProjectClick(project)} onContextMenu={(e) => handleContextMenu(project, e)} /> ))} + + {/* Horizontal rule and Add Project Button - only show if there are projects */} + {projects.length > 0 && ( + <> +
+ + + )} + + {/* Add Project Button - when no projects, show without rule */} + {projects.length === 0 && ( + + )}
- {/* Add Project Button */} + {/* Bug Report Button at the very bottom */}
@@ -87,8 +281,37 @@ export function ProjectSwitcher() { project={contextMenuProject} position={contextMenuPosition} onClose={handleCloseContextMenu} + onEdit={handleEditProject} /> )} + + {/* Edit Project Dialog */} + {editDialogProject && ( + !open && setEditDialogProject(null)} + /> + )} + + {/* New Project Modal */} + + + {/* Onboarding Dialog */} + ); } diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index a8c70cb6..613b113f 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -18,7 +18,6 @@ import { CollapseToggleButton, SidebarHeader, SidebarNavigation, - ProjectSelectorWithOptions, SidebarFooter, } from './sidebar/components'; import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; @@ -64,9 +63,6 @@ export function Sidebar() { // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); - // State for project picker (needed for keyboard shortcuts) - const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); - // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); @@ -240,7 +236,6 @@ export function Sidebar() { navigate, toggleSidebar, handleOpenFolder, - setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, unviewedValidationsCount, @@ -288,14 +283,7 @@ export function Sidebar() { />
- - - + void; + currentProject: Project | null; } -export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) { - return ( - <> - {/* Logo */} -
- - {/* Bug Report Button - Inside logo container when expanded */} - {sidebarOpen && } -
+export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) { + // Get the icon component from lucide-react + const getIconComponent = (): LucideIcon => { + if (currentProject?.icon && currentProject.icon in LucideIcons) { + return (LucideIcons as Record)[currentProject.icon]; + } + return Folder; + }; - {/* Bug Report Button - Collapsed sidebar version */} - {!sidebarOpen && ( -
- + const IconComponent = getIconComponent(); + const hasCustomIcon = !!currentProject?.customIconPath; + + return ( +
+ {/* Project name and icon display */} + {currentProject && ( +
+ {/* Project Icon */} +
+ {hasCustomIcon ? ( + {currentProject.name} + ) : ( +
+ +
+ )} +
+ + {/* Project Name - only show when sidebar is open */} + {sidebarOpen && ( +
+

+ {currentProject.name} +

+
+ )}
)} - +
); } 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 085606ae..110fa26c 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -45,7 +45,6 @@ interface UseNavigationProps { navigate: (opts: NavigateOptions) => void; toggleSidebar: () => void; handleOpenFolder: () => void; - setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; cyclePrevProject: () => void; cycleNextProject: () => void; /** Count of unviewed validations to show on GitHub Issues nav item */ @@ -65,7 +64,6 @@ export function useNavigation({ navigate, toggleSidebar, handleOpenFolder, - setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, unviewedValidationsCount, @@ -230,15 +228,6 @@ export function useNavigation({ description: 'Open folder selection dialog', }); - // Project picker shortcut - only when we have projects - if (projects.length > 0) { - shortcutsList.push({ - key: shortcuts.projectPicker, - action: () => setIsProjectPickerOpen((prev) => !prev), - description: 'Toggle project picker', - }); - } - // Project cycling shortcuts - only when we have project history if (projectHistory.length > 1) { shortcutsList.push({ @@ -288,7 +277,6 @@ export function useNavigation({ cyclePrevProject, cycleNextProject, navSections, - setIsProjectPickerOpen, ]); return { diff --git a/apps/ui/src/components/ui/scroll-area.tsx b/apps/ui/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..d3dd5cd2 --- /dev/null +++ b/apps/ui/src/components/ui/scroll-area.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; + +import { cn } from '@/lib/utils'; + +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const ScrollAreaRootPrimitive = ScrollAreaPrimitive.Root as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const ScrollAreaViewportPrimitive = ScrollAreaPrimitive.Viewport as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const ScrollAreaScrollbarPrimitive = + ScrollAreaPrimitive.Scrollbar as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes + >; + +const ScrollAreaThumbPrimitive = ScrollAreaPrimitive.Thumb as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + +const ScrollArea = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index aa6a8a84..61da2032 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -67,6 +67,8 @@ export function SettingsView() { name: project.name, path: project.path, theme: project.theme as Theme | undefined, + icon: project.icon, + customIconPath: project.customIconPath, }; }; 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 0858765a..003501f9 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,12 +1,14 @@ -import { useState } from 'react'; +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, Moon, Sun, Edit2 } from 'lucide-react'; +import { Palette, Moon, Sun, Upload, X, ImageIcon } 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'; interface AppearanceSectionProps { @@ -20,31 +22,95 @@ export function AppearanceSection({ currentProject, onThemeChange, }: AppearanceSectionProps) { - const { setProjectIcon, setProjectName } = useAppStore(); + const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore(); const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); - const [editingProject, setEditingProject] = useState(false); const [projectName, setProjectNameLocal] = useState(currentProject?.name || ''); - const [projectIcon, setProjectIconLocal] = useState( - (currentProject as any)?.icon || null + 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; - const handleSaveProjectDetails = () => { - if (!currentProject) return; - if (projectName.trim() !== currentProject.name) { - setProjectName(currentProject.id, projectName.trim()); + // Auto-save when values change + const handleNameChange = (name: string) => { + setProjectNameLocal(name); + if (currentProject && name.trim() && name.trim() !== currentProject.name) { + setProjectName(currentProject.id, name.trim()); } - if (projectIcon !== (currentProject as any)?.icon) { - setProjectIcon(currentProject.id, projectIcon); - } - setEditingProject(false); }; - const handleCancelEdit = () => { - setProjectNameLocal(currentProject?.name || ''); - setProjectIconLocal((currentProject as any)?.icon || null); - setEditingProject(false); + 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 ( @@ -71,60 +137,79 @@ export function AppearanceSection({ {/* Project Details Section */} {currentProject && (
-
- - {!editingProject && ( - - )} -
- - {editingProject ? ( -
-
- - setProjectNameLocal(e.target.value)} - placeholder="Enter project name" - /> -
- -
- - -
- -
- - -
+
+
+ + handleNameChange(e.target.value)} + placeholder="Enter project name" + />
- ) : ( -
-
-
-
{currentProject.name}
-
{currentProject.path}
+ +
+ +

+ 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/settings-view/shared/types.ts b/apps/ui/src/components/views/settings-view/shared/types.ts index 0795829f..9bb2cd48 100644 --- a/apps/ui/src/components/views/settings-view/shared/types.ts +++ b/apps/ui/src/components/views/settings-view/shared/types.ts @@ -26,6 +26,8 @@ export interface Project { name: string; path: string; theme?: string; + icon?: string; + customIconPath?: string; } export interface ApiKeys { diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index bb86c10c..01369322 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -533,6 +533,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { lastOpened: ref.lastOpened, theme: ref.theme, isFavorite: ref.isFavorite, + icon: ref.icon, + customIconPath: ref.customIconPath, features: [], // Features are loaded separately when project is opened })); diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 2d750a49..a860ff46 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -3105,6 +3105,7 @@ export interface Project { theme?: string; // Per-project theme override (uses ThemeMode from app-store) isFavorite?: boolean; // Pin project to top of dashboard icon?: string; // Lucide icon name for project identification + customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/ } export interface TrashedProject extends Project { diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index ef650112..99ecffff 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -874,6 +874,7 @@ export interface AppActions { clearProjectHistory: () => void; // Clear history, keeping only current project toggleProjectFavorite: (projectId: string) => void; // Toggle project favorite status setProjectIcon: (projectId: string, icon: string | null) => void; // Set project icon (null to clear) + setProjectCustomIcon: (projectId: string, customIconPath: string | null) => void; // Set custom project icon image path (null to clear) setProjectName: (projectId: string, name: string) => void; // Update project name // View actions @@ -1579,6 +1580,25 @@ export const useAppStore = create()((set, get) => ({ } }, + setProjectCustomIcon: (projectId, customIconPath) => { + const { projects, currentProject } = get(); + const updatedProjects = projects.map((p) => + p.id === projectId + ? { ...p, customIconPath: customIconPath === null ? undefined : customIconPath } + : p + ); + set({ projects: updatedProjects }); + // Also update currentProject if it matches + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + customIconPath: customIconPath === null ? undefined : customIconPath, + }, + }); + } + }, + setProjectName: (projectId, name) => { const { projects, currentProject } = get(); const updatedProjects = projects.map((p) => (p.id === projectId ? { ...p, name } : p)); diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 814daa67..c442f066 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -295,6 +295,8 @@ export interface ProjectRef { isFavorite?: boolean; /** Lucide icon name for project identification */ icon?: string; + /** Custom icon image path for project switcher */ + customIconPath?: string; } /** @@ -600,6 +602,10 @@ export interface ProjectSettings { /** Project-specific board background settings */ boardBackground?: BoardBackgroundSettings; + // Project Branding + /** Custom icon image path for project switcher (relative to .automaker/) */ + customIconPath?: string; + // UI Visibility /** Whether the worktree panel row is visible (default: true) */ worktreePanelVisible?: boolean; diff --git a/package-lock.json b/package-lock.json index 7662b3ab..25445b96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,6 +101,7 @@ "@radix-ui/react-label": "2.1.8", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", @@ -1479,7 +1480,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -4744,6 +4745,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",