feat(mobile): add project edit panel on long press (#584)

- Add draggable project list with reorder support
- Use MobileOverlayPanel for consistent bottom sheet UI
- Enhance useLongPress hook with movement detection
- Integrate with ProjectEditDialog for editing project details

Co-authored-by: Jovines <jovines@qq.com>
This commit is contained in:
Jovines
2026-03-03 13:44:05 +02:00
committed by GitHub
co-authored by Jovines
parent c9bcf79ca7
commit 855412ce79
@@ -7,7 +7,32 @@ import type { Session } from '@opencode-ai/sdk/v2';
import type { ProjectEntry } from '@/lib/api/types';
import { cn, formatDirectoryName } from '@/lib/utils';
import { getAgentColor } from '@/lib/agentColors';
import { RiLoader4Line, RiAddLine } from '@remixicon/react';
import {
RiLoader4Line,
RiAddLine,
RiDragMove2Line,
RiDeleteBinLine,
RiEditLine,
RiArrowUpLine,
RiArrowDownLine,
} from '@remixicon/react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -23,7 +48,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
interface MobileSessionStatusBarProps {
onSessionSwitch?: (sessionId: string) => void;
@@ -486,7 +513,7 @@ function SessionStatusHeader({
// Hook for long press
// Hook for long press with movement detection
function useLongPress(
onLongPress: () => void,
onClick: () => void,
@@ -494,20 +521,43 @@ function useLongPress(
) {
const timerRef = React.useRef<NodeJS.Timeout | null>(null);
const isLongPress = React.useRef(false);
const startPosRef = React.useRef<{ x: number; y: number } | null>(null);
const hasMovedRef = React.useRef(false);
const MOVE_THRESHOLD = 10; // pixels
const start = React.useCallback(() => {
const start = React.useCallback((clientX: number, clientY: number) => {
isLongPress.current = false;
hasMovedRef.current = false;
startPosRef.current = { x: clientX, y: clientY };
timerRef.current = setTimeout(() => {
isLongPress.current = true;
onLongPress();
if (!hasMovedRef.current) {
isLongPress.current = true;
onLongPress();
}
}, ms);
}, [onLongPress, ms]);
const move = React.useCallback((clientX: number, clientY: number) => {
if (!startPosRef.current) return;
const dx = Math.abs(clientX - startPosRef.current.x);
const dy = Math.abs(clientY - startPosRef.current.y);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
hasMovedRef.current = true;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}
}, []);
const end = React.useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
startPosRef.current = null;
}, []);
const handleClick = React.useCallback(() => {
@@ -517,15 +567,271 @@ function useLongPress(
}, [onClick]);
return {
onMouseDown: start,
onMouseDown: (e: React.MouseEvent) => start(e.clientX, e.clientY),
onMouseUp: end,
onMouseLeave: end,
onTouchStart: start,
onMouseMove: (e: React.MouseEvent) => move(e.clientX, e.clientY),
onTouchStart: (e: React.TouchEvent) => {
const touch = e.touches[0];
start(touch.clientX, touch.clientY);
},
onTouchMove: (e: React.TouchEvent) => {
const touch = e.touches[0];
move(touch.clientX, touch.clientY);
},
onTouchEnd: end,
onClick: handleClick,
};
}
// Sortable project item for edit panel
interface SortableProjectItemProps {
project: ProjectEntry;
isFirst: boolean;
isLast: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onEdit: () => void;
onDelete: () => void;
formatProjectLabel: (project: ProjectEntry) => string;
}
function SortableProjectItem({
project,
isFirst,
isLast,
onMoveUp,
onMoveDown,
onEdit,
onDelete,
formatProjectLabel,
}: SortableProjectItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: project.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
};
const [imageFailed, setImageFailed] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project) : null;
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-2 p-3 bg-[var(--surface-elevated)] rounded-lg border border-[var(--interactive-border)]",
isDragging && "shadow-lg opacity-90"
)}
>
{/* Drag handle */}
<button
type="button"
className="flex-shrink-0 p-1.5 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] cursor-grab active:cursor-grabbing touch-none"
{...attributes}
{...listeners}
>
<RiDragMove2Line className="h-4 w-4" />
</button>
{/* Project info */}
<div className="flex-1 flex items-center gap-2 min-w-0">
{projectIconImageUrl ? (
<span
className="inline-flex h-5 w-5 items-center justify-center overflow-hidden rounded-[2px] flex-shrink-0"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img
src={projectIconImageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setImageFailed(true)}
/>
</span>
) : ProjectIcon ? (
<ProjectIcon
className="h-5 w-5 flex-shrink-0"
style={projectColorVar ? { color: projectColorVar } : undefined}
/>
) : (
<div className="h-5 w-5 rounded bg-[var(--surface-muted)] flex-shrink-0" />
)}
<span className="text-sm text-[var(--surface-foreground)] truncate">
{formatProjectLabel(project)}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{/* Move up/down buttons (for non-drag sorting) */}
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<RiArrowUpLine className="h-4 w-4" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<RiArrowDownLine className="h-4 w-4" />
</button>
<div className="w-px h-5 bg-[var(--interactive-border)] mx-1" />
{/* Edit button */}
<button
type="button"
onClick={onEdit}
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10"
>
<RiEditLine className="h-4 w-4" />
</button>
{/* Delete button */}
<button
type="button"
onClick={onDelete}
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--status-error)] hover:bg-[var(--status-error)]/10"
>
<RiDeleteBinLine className="h-4 w-4" />
</button>
</div>
</div>
);
}
// Project edit panel for mobile
interface ProjectEditPanelProps {
isOpen: boolean;
onClose: () => void;
projects: ProjectEntry[];
onReorder: (fromIndex: number, toIndex: number) => void;
onEdit: (project: ProjectEntry) => void;
onDelete: (project: ProjectEntry) => void;
homeDirectory: string | null;
}
function ProjectEditPanel({
isOpen,
onClose,
projects,
onReorder,
onEdit,
onDelete,
homeDirectory,
}: ProjectEditPanelProps) {
const [localProjects, setLocalProjects] = React.useState(projects);
React.useEffect(() => {
setLocalProjects(projects);
}, [projects, isOpen]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = localProjects.findIndex((p) => p.id === active.id);
const newIndex = localProjects.findIndex((p) => p.id === over.id);
setLocalProjects((items) => arrayMove(items, oldIndex, newIndex));
onReorder(oldIndex, newIndex);
}
};
const handleMoveUp = (index: number) => {
if (index > 0) {
setLocalProjects((items) => arrayMove(items, index, index - 1));
onReorder(index, index - 1);
}
};
const handleMoveDown = (index: number) => {
if (index < localProjects.length - 1) {
setLocalProjects((items) => arrayMove(items, index, index + 1));
onReorder(index, index + 1);
}
};
const formatProjectLabel = (project: ProjectEntry): string => {
return project.label?.trim()
|| formatDirectoryName(project.path, homeDirectory)
|| project.path;
};
return (
<MobileOverlayPanel
open={isOpen}
onClose={onClose}
title="Edit Projects"
footer={
<p className="text-xs text-[var(--surface-mutedForeground)] text-center">
Drag items to reorder, or use arrows to move. Tap edit to change details.
</p>
}
>
<div className="flex flex-col gap-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={localProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
{localProjects.map((project, index) => (
<SortableProjectItem
key={project.id}
project={project}
isFirst={index === 0}
isLast={index === localProjects.length - 1}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
onEdit={() => onEdit(project)}
onDelete={() => onDelete(project)}
formatProjectLabel={formatProjectLabel}
/>
))}
</SortableContext>
</DndContext>
{localProjects.length === 0 && (
<div className="text-center py-8 text-[var(--surface-mutedForeground)]">
No projects to edit
</div>
)}
</div>
</MobileOverlayPanel>
);
}
// Project button component with long press support
interface ProjectButtonProps {
project: ProjectEntry;
@@ -533,7 +839,7 @@ interface ProjectButtonProps {
status: { hasRunning: boolean; hasUnread: boolean };
projectColorVar: string | null;
onProjectSwitch: () => void;
onRemoveProject?: () => void;
onOpenEditPanel?: () => void;
formatProjectLabel: (project: ProjectEntry) => string;
}
@@ -543,7 +849,7 @@ function ProjectButton({
status,
projectColorVar,
onProjectSwitch,
onRemoveProject,
onOpenEditPanel,
formatProjectLabel,
}: ProjectButtonProps) {
const [imageFailed, setImageFailed] = React.useState(false);
@@ -556,8 +862,8 @@ function ProjectButton({
const longPressHandlers = useLongPress(
() => {
if (onRemoveProject) {
onRemoveProject();
if (onOpenEditPanel) {
onOpenEditPanel();
}
},
onProjectSwitch,
@@ -639,8 +945,10 @@ function ProjectBar({
homeDirectory
}: ProjectBarProps) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [editPanelOpen, setEditPanelOpen] = React.useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [projectToDelete, setProjectToDelete] = React.useState<ProjectEntry | null>(null);
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
// Scroll active project into view
React.useEffect(() => {
@@ -652,7 +960,29 @@ function ProjectBar({
}
}, [activeProjectId]);
const handleLongPress = (project: ProjectEntry) => {
const handleOpenEditPanel = () => {
setEditPanelOpen(true);
};
const handleReorder = (fromIndex: number, toIndex: number) => {
reorderProjects(fromIndex, toIndex);
};
const [editingProject, setEditingProject] = React.useState<ProjectEntry | null>(null);
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
const handleEditProject = (project: ProjectEntry) => {
setEditingProject(project);
};
const handleSaveProjectEdit = (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
if (editingProject) {
updateProjectMeta(editingProject.id, data);
}
setEditingProject(null);
};
const handleDeleteProject = (project: ProjectEntry) => {
setProjectToDelete(project);
setDeleteDialogOpen(true);
};
@@ -737,7 +1067,7 @@ function ProjectBar({
status={status}
projectColorVar={projectColorVar}
onProjectSwitch={() => onProjectSwitch(project.id)}
onRemoveProject={onRemoveProject ? () => handleLongPress(project) : undefined}
onOpenEditPanel={handleOpenEditPanel}
formatProjectLabel={formatProjectLabel}
/>
);
@@ -773,6 +1103,34 @@ function ProjectBar({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Project edit panel */}
<ProjectEditPanel
isOpen={editPanelOpen}
onClose={() => setEditPanelOpen(false)}
projects={projects}
onReorder={handleReorder}
onEdit={handleEditProject}
onDelete={handleDeleteProject}
homeDirectory={homeDirectory}
/>
{/* Project edit dialog */}
{editingProject && (
<ProjectEditDialog
open={!!editingProject}
onOpenChange={(open) => {
if (!open) setEditingProject(null);
}}
projectId={editingProject.id}
projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)}
projectPath={editingProject.path}
initialIcon={editingProject.icon}
initialColor={editingProject.color}
initialIconBackground={editingProject.iconBackground}
onSave={handleSaveProjectEdit}
/>
)}
</div>
);
}