import React from 'react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import { RiCloseLine } from '@remixicon/react'; import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent'; import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection'; import { useThemeSystem } from '@/contexts/useThemeSystem'; export const ProjectsPage: React.FC = () => { const projects = useProjectsStore((state) => state.projects); const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta); const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon); const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon); const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon); const selectedId = useUIStore((state) => state.settingsProjectsSelectedId); const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId); const { currentTheme } = useThemeSystem(); const selectedProject = React.useMemo(() => { if (!selectedId) return null; return projects.find((p) => p.id === selectedId) ?? null; }, [projects, selectedId]); React.useEffect(() => { if (projects.length === 0) { setSelectedId(null); return; } if (selectedId && projects.some((p) => p.id === selectedId)) { return; } setSelectedId(projects[0].id); }, [projects, selectedId, setSelectedId]); const [name, setName] = React.useState(''); const [icon, setIcon] = React.useState(null); const [color, setColor] = React.useState(null); const [iconBackground, setIconBackground] = React.useState(null); const [isUploadingIcon, setIsUploadingIcon] = React.useState(false); const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false); const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false); const [pendingRemoveImageIcon, setPendingRemoveImageIcon] = React.useState(false); const [pendingUploadIconFile, setPendingUploadIconFile] = React.useState(null); const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState(null); const [previewImageFailed, setPreviewImageFailed] = React.useState(false); const fileInputRef = React.useRef(null); const clearPendingUploadIcon = React.useCallback(() => { setPendingUploadIconFile(null); setPendingUploadIconPreviewUrl((previousUrl) => { if (previousUrl) { URL.revokeObjectURL(previousUrl); } return null; }); }, []); const selectedProjectRef = React.useMemo(() => { if (!selectedProject) { return null; } return { id: selectedProject.id, path: selectedProject.path }; }, [selectedProject]); React.useEffect(() => { if (!selectedProject) { setName(''); setIcon(null); setColor(null); setIconBackground(null); return; } setName(selectedProject.label ?? ''); setIcon(selectedProject.icon ?? null); setColor(selectedProject.color ?? null); setIconBackground(selectedProject.iconBackground ?? null); setPendingRemoveImageIcon(false); clearPendingUploadIcon(); setPreviewImageFailed(false); }, [selectedProject, clearPendingUploadIcon]); React.useEffect(() => { return () => { clearPendingUploadIcon(); }; }, [clearPendingUploadIcon]); const hasChanges = Boolean(selectedProject) && ( name.trim() !== (selectedProject?.label ?? '').trim() || icon !== (selectedProject?.icon ?? null) || color !== (selectedProject?.color ?? null) || iconBackground !== (selectedProject?.iconBackground ?? null) || pendingRemoveImageIcon || Boolean(pendingUploadIconFile) ); const handleSave = React.useCallback(async () => { if (!selectedProject) return; if (pendingUploadIconFile) { setIsUploadingIcon(true); const uploadResult = await uploadProjectIcon(selectedProject.id, pendingUploadIconFile); setIsUploadingIcon(false); if (!uploadResult.ok) { toast.error(uploadResult.error || 'Failed to upload project icon'); return; } toast.success('Project icon updated'); clearPendingUploadIcon(); setPendingRemoveImageIcon(false); } const willRemoveImageIcon = pendingRemoveImageIcon && Boolean(selectedProject.iconImage); if (willRemoveImageIcon) { setIsRemovingCustomIcon(true); const removeResult = await removeProjectIcon(selectedProject.id); setIsRemovingCustomIcon(false); if (!removeResult.ok) { toast.error(removeResult.error || 'Failed to remove project icon'); return; } toast.success('Project icon removed'); setPendingRemoveImageIcon(false); setIconBackground(null); } updateProjectMeta(selectedProject.id, { label: name.trim(), icon, color, iconBackground: willRemoveImageIcon ? null : iconBackground, }); }, [ color, icon, iconBackground, name, pendingUploadIconFile, pendingRemoveImageIcon, clearPendingUploadIcon, uploadProjectIcon, removeProjectIcon, selectedProject, updateProjectMeta, ]); const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null; const hasStoredImageIcon = Boolean(selectedProject?.iconImage); const hasPendingUploadImageIcon = Boolean(pendingUploadIconFile && pendingUploadIconPreviewUrl); const hasCustomIcon = selectedProject?.iconImage?.source === 'custom'; const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon; const hasRemovableImageIcon = effectiveHasImageIcon; const iconPreviewUrl = !previewImageFailed ? (hasPendingUploadImageIcon ? pendingUploadIconPreviewUrl : (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon ? getProjectIconImageUrl(selectedProject, { themeVariant: currentTheme.metadata.variant, iconColor: currentTheme.colors.surface.foreground, }) : null)) : null; const handleUploadIcon = React.useCallback((file: File | null) => { if (!selectedProject || !file || isUploadingIcon) { return; } setPendingRemoveImageIcon(false); setPreviewImageFailed(false); setPendingUploadIconFile(file); setPendingUploadIconPreviewUrl((previousUrl) => { if (previousUrl) { URL.revokeObjectURL(previousUrl); } return URL.createObjectURL(file); }); }, [isUploadingIcon, selectedProject]); const handleRemoveImageIcon = React.useCallback(() => { if (!selectedProject || !hasRemovableImageIcon || isRemovingCustomIcon) { return; } if (hasPendingUploadImageIcon) { clearPendingUploadIcon(); } if (hasStoredImageIcon) { setPendingRemoveImageIcon(true); } else { setPendingRemoveImageIcon(false); } setPreviewImageFailed(false); }, [ clearPendingUploadIcon, hasPendingUploadImageIcon, hasRemovableImageIcon, hasStoredImageIcon, isRemovingCustomIcon, selectedProject, ]); const handleDiscoverIcon = React.useCallback(async () => { if (!selectedProject || isDiscoveringIcon) { return; } clearPendingUploadIcon(); setPendingRemoveImageIcon(false); setPreviewImageFailed(false); setIsDiscoveringIcon(true); void discoverProjectIcon(selectedProject.id) .then((result) => { if (!result.ok) { toast.error(result.error || 'Failed to discover project icon'); return; } if (result.skipped) { toast.success('Custom icon already set for this project'); return; } toast.success('Project icon discovered'); }) .finally(() => { setIsDiscoveringIcon(false); }); }, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject]); if (!selectedProject) { return (

No projects available.

); } return (
{/* Top Header & Actions */}

{selectedProject.label ?? 'Project Settings'}

{selectedProject.path}

{/* Identity Controls */}
{/* Name */}
Project Name
setName(e.target.value)} placeholder="Project name" className="h-7 min-w-0 w-full sm:max-w-[19rem]" />
{/* Color */}
Accent Color
{PROJECT_COLORS.map((c) => (
{/* Icon */}
Project Icon
{ const file = event.target.files?.[0] ?? null; void handleUploadIcon(file); event.currentTarget.value = ''; }} />
{PROJECT_ICONS.map((i) => { const IconComponent = i.Icon; return ( ); })}
{effectiveHasImageIcon && iconPreviewUrl && (
Preview setPreviewImageFailed(true)} />
)} {effectiveHasImageIcon && (
setIconBackground(event.target.value)} className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-1" aria-label="Project icon background color" /> setIconBackground(event.target.value)} placeholder="#000000" className="h-7 w-[8rem]" />
)}
{!hasCustomIcon && ( <> )} {hasRemovableImageIcon && ( )} {pendingRemoveImageIcon && ( )}
{/* Worktree Group */}
{selectedProjectRef && }
{/* Worktree Group */}

Worktree

{selectedProjectRef && }
); };