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 */}
{/* 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}
>
);
};