import React from 'react'; import { RiCheckLine, RiDeleteBinLine, RiDragMove2Line, RiFolder6Line } from '@remixicon/react'; import { DndContext, type DragEndEvent, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from '@dnd-kit/core'; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useI18n } from '@/lib/i18n'; import { PROJECT_COLORS, PROJECT_COLOR_MAP, PROJECT_ICONS, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { cn } from '@/lib/utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore'; import type { WorktreeMetadata } from '@/types/worktree'; import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog'; import { MobileSurfaceShell } from './MobileSurfaceShell'; type MobileEditableProject = { id: string; label: string; path: string; icon?: string | null; color?: string | null; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; iconBackground?: string | null; isGitRepo: boolean; worktrees: WorktreeMetadata[]; }; type MobileProjectEditSurfaceProps = { open: boolean; project: MobileEditableProject | null; onClose: () => void; /** Called after a worktree is deleted so the parent can re-list worktrees. */ onWorktreesChanged?: () => void; }; const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); const SortableWorktreeRow: React.FC<{ worktree: WorktreeMetadata; onDelete: () => void; }> = ({ worktree, onDelete }) => { const { t } = useI18n(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: worktree.path }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 10 : 1, }; const label = worktree.branch || worktree.label || worktree.path; return (
{label}
); }; export const MobileProjectEditSurface: React.FC = ({ open, project, onClose, onWorktreesChanged, }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta); const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon); const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon); const setWorktreeOrder = useWorktreeOrderStore((state) => state.setWorktreeOrder); // Read the live icon image from the store so discover/remove reflect instantly. const currentIconImage = useProjectsStore((state) => project ? state.projects.find((entry) => entry.id === project.id)?.iconImage ?? null : null, ); const [name, setName] = React.useState(''); const [icon, setIcon] = React.useState(null); const [color, setColor] = React.useState(null); const [isDiscovering, setIsDiscovering] = React.useState(false); const [orderedWorktrees, setOrderedWorktrees] = React.useState([]); const [worktreeToDelete, setWorktreeToDelete] = React.useState(null); const projectId = project?.id ?? null; React.useEffect(() => { if (!open || !project) return; setName(project.label); setIcon(project.icon ?? null); setColor(project.color ?? null); setOrderedWorktrees(project.worktrees); // Re-seed only when the edited project or sheet visibility changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, projectId]); // Keep the worktree list in sync as the underlying project data updates // (e.g. after a deletion), without clobbering an in-progress drag order. React.useEffect(() => { if (!open || !project) return; setOrderedWorktrees((previous) => { const incomingPaths = project.worktrees.map((worktree) => worktree.path); const previousPaths = previous.map((worktree) => worktree.path); const sameSet = incomingPaths.length === previousPaths.length && incomingPaths.every((path) => previousPaths.includes(path)); return sameSet ? previous : project.worktrees; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [project?.worktrees]); const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleSave = () => { if (!project) return; const trimmed = name.trim(); updateProjectMeta(project.id, { label: trimmed || project.label, icon, color, }); onClose(); }; const handleDiscoverIcon = () => { if (!project || isDiscovering) return; setIsDiscovering(true); void discoverProjectIcon(project.id) .then((result) => { if (!result.ok) { toast.error(result.error || t('projectEditDialog.toast.failedToDiscoverIcon')); return; } if (result.skipped) { toast.success(t('projectEditDialog.toast.customIconAlreadySet')); return; } toast.success(t('projectEditDialog.toast.iconDiscovered')); }) .finally(() => setIsDiscovering(false)); }; const handleRemoveDiscoveredIcon = () => { if (!project) return; void removeProjectIcon(project.id).then((result) => { if (!result.ok) { toast.error(result.error || t('projectEditDialog.toast.failedToRemoveIcon')); return; } toast.success(t('projectEditDialog.toast.iconRemoved')); }); }; const handleWorktreeDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id || !project) return; const fromIndex = orderedWorktrees.findIndex((worktree) => worktree.path === active.id); const toIndex = orderedWorktrees.findIndex((worktree) => worktree.path === over.id); if (fromIndex < 0 || toIndex < 0) return; const next = [...orderedWorktrees]; const [moved] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moved); setOrderedWorktrees(next); setWorktreeOrder( project.id, next.map((worktree) => normalizePath(worktree.path)), ); }; const currentColorVar = color ? PROJECT_COLOR_MAP[color] ?? null : null; const previewIconName = icon ? PROJECT_ICON_MAP[icon] : null; const hasImageIcon = Boolean(currentIconImage); return ( <> {t('projectEditDialog.actions.save')} } > {project ? (
{/* Icon preview */}
{hasImageIcon ? ( } /> ) : previewIconName ? ( ) : ( )}
{/* Name */}
setName(event.target.value)} placeholder={t('projectEditDialog.field.namePlaceholder')} className="h-11" />

{project.path}

{/* Color */}
{PROJECT_COLORS.map((c) => (
{/* Icon */}
{PROJECT_ICONS.map((i) => ( ))}
{hasImageIcon ? ( ) : null}
{/* Worktrees */} {project.isGitRepo ? (
{orderedWorktrees.length === 0 ? (

{t('mobile.projectEdit.worktreesEmpty')}

) : ( <>

{t('mobile.projectEdit.reorderHint')}

worktree.path)} strategy={verticalListSortingStrategy} >
{orderedWorktrees.map((worktree) => ( setWorktreeToDelete(worktree)} /> ))}
)}
) : null}
) : null}
{project ? ( setWorktreeToDelete(null)} onDeleted={() => { setOrderedWorktrees((previous) => previous.filter((worktree) => worktree.path !== worktreeToDelete?.path), ); onWorktreesChanged?.(); }} /> ) : null} ); };