diff --git a/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx b/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx new file mode 100644 index 00000000..9980f0a5 --- /dev/null +++ b/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2/client'; + +import { Button } from '@/components/ui/button'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; +import { removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useAllLiveSessions } from '@/sync/sync-context'; +import type { WorktreeMetadata } from '@/types/worktree'; + +type MobileDeleteWorktreeDialogProps = { + open: boolean; + project: ProjectRef; + worktree: WorktreeMetadata | null; + onClose: () => void; + onDeleted?: () => void; +}; + +const normalizePath = (value?: string | null): string => + (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); + +const getSessionDirectory = (session: Session): string => { + const record = session as Session & { directory?: string | null; project?: { worktree?: string | null } | null }; + return normalizePath(record.directory ?? record.project?.worktree ?? null); +}; + +/** + * Mobile worktree-deletion confirmation. Built directly on the shared + * primitives (getWorktreeStatus / removeProjectWorktree / archiveSessions) so + * it mirrors the desktop SessionDialogs worktree flow without mounting it: + * linked sessions are archived, the worktree is removed, and remote/local + * branch deletion are optional. + */ +export const MobileDeleteWorktreeDialog: React.FC = ({ + open, + project, + worktree, + onClose, + onDeleted, +}) => { + const { t } = useI18n(); + const liveSessions = useAllLiveSessions(); + const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); + const archiveSessions = useSessionUIStore((state) => state.archiveSessions); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + + const [deleteLocalBranch, setDeleteLocalBranch] = React.useState(false); + const [deleteRemoteBranch, setDeleteRemoteBranch] = React.useState(false); + const [isDirty, setIsDirty] = React.useState(false); + const [isProcessing, setIsProcessing] = React.useState(false); + + const worktreePath = normalizePath(worktree?.path); + const hasBranch = typeof worktree?.branch === 'string' && worktree.branch.trim().length > 0; + + // Sessions attached to this worktree — archived (not deleted) on removal, + // matching the desktop behavior. + const linkedSessions = React.useMemo(() => { + if (!worktreePath) return [] as Session[]; + const merged = new Map(); + for (const session of [...globalActiveSessions, ...liveSessions]) { + if (getSessionDirectory(session) === worktreePath) merged.set(session.id, session); + } + return Array.from(merged.values()); + }, [globalActiveSessions, liveSessions, worktreePath]); + + React.useEffect(() => { + if (!open) { + setDeleteLocalBranch(false); + setDeleteRemoteBranch(false); + setIsDirty(false); + setIsProcessing(false); + return; + } + if (!worktree?.path) return; + let cancelled = false; + void getWorktreeStatus(worktree.path) + .then((status) => { + if (!cancelled) setIsDirty(Boolean(status?.isDirty)); + }) + .catch(() => { + if (!cancelled) setIsDirty(Boolean(worktree.status?.isDirty)); + }); + return () => { + cancelled = true; + }; + }, [open, worktree?.path, worktree?.status?.isDirty]); + + const handleConfirm = async () => { + if (!worktree || isProcessing) return; + setIsProcessing(true); + try { + if (linkedSessions.length > 0) { + await archiveSessions(linkedSessions.map((session) => session.id)); + } + await removeProjectWorktree(project, worktree, { + deleteRemoteBranch: hasBranch && deleteRemoteBranch, + deleteLocalBranch: hasBranch && deleteLocalBranch, + }); + + // If the removed worktree was the active directory, fall back to the project root. + if (normalizePath(currentDirectory) === worktreePath && normalizePath(project.path)) { + useDirectoryStore.getState().setDirectory(normalizePath(project.path), { showOverlay: false }); + } + + toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), { + description: + hasBranch && deleteRemoteBranch + ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') + : t('sessions.sidebar.sessionDialogs.worktree.removed'), + }); + onDeleted?.(); + onClose(); + } catch (error) { + toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { + description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'), + }); + } finally { + setIsProcessing(false); + } + }; + + if (!worktree) return null; + + const worktreeName = worktree.branch || worktree.label || worktree.path; + + const toggle = (checked: boolean, onChange: (value: boolean) => void, label: string, disabled?: boolean) => ( + + ); + + return ( + + + + + } + > +
+

+ {t('mobile.projectEdit.deleteWorktreeConfirm', { name: worktreeName })} +

+ + {isDirty ? ( +

+ {t('mobile.projectEdit.deleteWorktreeDirty')} +

+ ) : null} + + {linkedSessions.length > 0 ? ( +

+ {t('mobile.projectEdit.deleteWorktreeArchiveNote', { count: linkedSessions.length })} +

+ ) : null} + + {hasBranch ? ( +
+ {toggle(deleteLocalBranch, setDeleteLocalBranch, t('mobile.projectEdit.deleteLocalBranch'), isProcessing)} + {toggle(deleteRemoteBranch, setDeleteRemoteBranch, t('mobile.projectEdit.deleteRemoteBranch'), isProcessing)} +
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/apps/MobileProjectEditSurface.tsx b/packages/ui/src/apps/MobileProjectEditSurface.tsx new file mode 100644 index 00000000..519822c2 --- /dev/null +++ b/packages/ui/src/apps/MobileProjectEditSurface.tsx @@ -0,0 +1,423 @@ +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'; + +export 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} + + ); +}; diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index e0c6786a..c873fadf 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { RiAddLine, - RiArrowDownLine, RiArrowDownSLine, - RiArrowUpLine, RiArrowUpSLine, RiCheckLine, RiCloseLine, @@ -49,10 +47,12 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useAllLiveSessions } from '@/sync/sync-context'; import type { WorktreeMetadata } from '@/types/worktree'; +import { MobileProjectEditSurface } from './MobileProjectEditSurface'; import { MobileSurfaceShell } from './MobileSurfaceShell'; type MobileSessionsSheetProps = { @@ -332,21 +332,15 @@ const ShowFewerRow: React.FC<{ const SortableProjectRow: React.FC<{ project: ProjectMeta; totalSessions: number; - isFirst: boolean; - isLast: boolean; confirmingDelete: boolean; - onMoveUp: () => void; - onMoveDown: () => void; + onEdit: () => void; onRequestRemove: () => void; onConfirmRemove: () => void; }> = ({ project, totalSessions, - isFirst, - isLast, confirmingDelete, - onMoveUp, - onMoveDown, + onEdit, onRequestRemove, onConfirmRemove, }) => { @@ -394,23 +388,12 @@ const SortableProjectRow: React.FC<{ {totalSessions} - )} @@ -455,7 +438,11 @@ export const MobileSessionsSheet: React.FC = ({ open, const worktreeExpandedMap = useMobileSessionTreeStore((state) => state.worktreeExpanded); const setProjectExpanded = useMobileSessionTreeStore((state) => state.setProjectExpanded); const setWorktreeExpanded = useMobileSessionTreeStore((state) => state.setWorktreeExpanded); + const worktreeOrderByProject = useWorktreeOrderStore((state) => state.orderByProject); const [query, setQuery] = React.useState(''); + const [editingProjectId, setEditingProjectId] = React.useState(null); + // Bumped to force a re-list of worktrees (e.g. after one is deleted in the editor). + const [worktreeRefreshKey, setWorktreeRefreshKey] = React.useState(0); const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false); const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); const [worktreeDialogProjectId, setWorktreeDialogProjectId] = React.useState(null); @@ -475,6 +462,7 @@ export const MobileSessionsSheet: React.FC = ({ open, setEditingOrder(false); setConfirmingDeleteId(null); setVisibleCountByBucket(new Map()); + setEditingProjectId(null); return; } void refreshGlobalSessions(liveSessions); @@ -517,7 +505,7 @@ export const MobileSessionsSheet: React.FC = ({ open, return () => { cancelled = true; }; - }, [git, open, projects]); + }, [git, open, projects, worktreeRefreshKey]); const projectsMeta = React.useMemo( () => @@ -530,9 +518,12 @@ export const MobileSessionsSheet: React.FC = ({ open, iconImage: project.iconImage, iconBackground: project.iconBackground, isGitRepo: gitProjectPaths.has(normalizePath(project.path)), - worktrees: worktreesByProject.get(normalizePath(project.path)) ?? [], + worktrees: orderWorktrees( + worktreeOrderByProject[project.id], + worktreesByProject.get(normalizePath(project.path)) ?? [], + ), })), - [gitProjectPaths, projects, worktreesByProject], + [gitProjectPaths, projects, worktreeOrderByProject, worktreesByProject], ); /** @@ -1001,18 +992,15 @@ export const MobileSessionsSheet: React.FC = ({ open, strategy={verticalListSortingStrategy} >
- {projectsMeta.map((project, index) => { + {projectsMeta.map((project) => { const node = projectNodes.find((n) => n.project.id === project.id); return ( reorderProjects(index, index - 1)} - onMoveDown={() => reorderProjects(index, index + 1)} + onEdit={() => setEditingProjectId(project.id)} onRequestRemove={() => handleRequestRemoveProject(project.id)} onConfirmRemove={() => handleConfirmRemoveProject(project)} /> @@ -1158,6 +1146,12 @@ export const MobileSessionsSheet: React.FC = ({ open, onOpenChange(false); }} /> + entry.id === editingProjectId) ?? null} + onClose={() => setEditingProjectId(null)} + onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)} + />
); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index bde2990e..3abd3e17 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -57,7 +57,8 @@ export const dict = { 'mobile.sessions.newChat': 'New chat', 'mobile.sessions.editOrder': 'Reorder projects', 'mobile.sessions.doneEditing': 'Done', - 'mobile.sessions.editOrderHint': 'Drag the handle or use the arrows to reorder projects. Tap the check to finish.', + 'mobile.sessions.editOrderHint': 'Drag the handle to reorder projects. Tap the check to finish.', + 'mobile.sessions.editProjectAria': 'Edit {label}', 'mobile.sessions.dragHandleAria': 'Drag {label} to reorder', 'mobile.sessions.moveUpAria': 'Move {label} up', 'mobile.sessions.moveDownAria': 'Move {label} down', @@ -66,6 +67,18 @@ export const dict = { 'mobile.sessions.confirmRemoveProject': 'Delete', 'mobile.sessions.confirmRemoveProjectAria': 'Confirm removing {label}', 'mobile.sessions.toast.projectRemoved': 'Removed {label}', + 'mobile.projectEdit.worktreesTitle': 'Worktrees', + 'mobile.projectEdit.worktreesEmpty': 'No worktrees in this project yet.', + 'mobile.projectEdit.reorderHint': 'Drag to reorder worktrees.', + 'mobile.projectEdit.dragWorktreeAria': 'Drag {label} to reorder', + 'mobile.projectEdit.deleteWorktreeAria': 'Remove worktree {label}', + 'mobile.projectEdit.deleteWorktreeTitle': 'Remove worktree', + 'mobile.projectEdit.deleteWorktreeConfirmButton': 'Remove', + 'mobile.projectEdit.deleteWorktreeConfirm': 'Remove the worktree “{name}”? This can’t be undone.', + 'mobile.projectEdit.deleteWorktreeDirty': 'This worktree has uncommitted changes that will be lost.', + 'mobile.projectEdit.deleteWorktreeArchiveNote': '{count} attached session(s) will be archived.', + 'mobile.projectEdit.deleteLocalBranch': 'Also delete local branch', + 'mobile.projectEdit.deleteRemoteBranch': 'Also delete remote branch', 'mobile.sessions.showMore': 'Show {count} more', 'mobile.sessions.search.section.sessions': 'Sessions', 'mobile.sessions.search.section.archived': 'Archived', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index d6e3c01e..d9306b41 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -67,6 +67,19 @@ export const dict: Record = { "mobile.sessions.confirmRemoveProject": "Eliminar", "mobile.sessions.confirmRemoveProjectAria": "Confirmar eliminación de {label}", "mobile.sessions.toast.projectRemoved": "Se eliminó {label}", + "mobile.sessions.editProjectAria": "Editar {label}", + "mobile.projectEdit.worktreesTitle": "Worktrees", + "mobile.projectEdit.worktreesEmpty": "Este proyecto aún no tiene worktrees.", + "mobile.projectEdit.reorderHint": "Arrastra para reordenar los worktrees.", + "mobile.projectEdit.dragWorktreeAria": "Arrastra {label} para reordenar", + "mobile.projectEdit.deleteWorktreeAria": "Eliminar worktree {label}", + "mobile.projectEdit.deleteWorktreeTitle": "Eliminar worktree", + "mobile.projectEdit.deleteWorktreeConfirmButton": "Eliminar", + "mobile.projectEdit.deleteWorktreeConfirm": "¿Eliminar el worktree «{name}»? No se puede deshacer.", + "mobile.projectEdit.deleteWorktreeDirty": "Este worktree tiene cambios sin confirmar que se perderán.", + "mobile.projectEdit.deleteWorktreeArchiveNote": "Se archivarán {count} sesión(es) adjunta(s).", + "mobile.projectEdit.deleteLocalBranch": "Eliminar también la rama local", + "mobile.projectEdit.deleteRemoteBranch": "Eliminar también la rama remota", "mobile.sessions.showMore": "Mostrar {count} más", "mobile.sessions.search.section.sessions": "Sesiones", "mobile.sessions.search.section.archived": "Archivadas", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 244c84a2..cc02c091 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -67,6 +67,19 @@ export const dict: Record = { 'mobile.sessions.confirmRemoveProject': '삭제', 'mobile.sessions.confirmRemoveProjectAria': '{label} 제거 확인', 'mobile.sessions.toast.projectRemoved': '{label} 제거됨', + 'mobile.sessions.editProjectAria': '{label} 편집', + 'mobile.projectEdit.worktreesTitle': '워크트리', + 'mobile.projectEdit.worktreesEmpty': '이 프로젝트에는 아직 워크트리가 없습니다.', + 'mobile.projectEdit.reorderHint': '드래그하여 워크트리 순서를 변경합니다.', + 'mobile.projectEdit.dragWorktreeAria': '{label} 드래그하여 순서 변경', + 'mobile.projectEdit.deleteWorktreeAria': '워크트리 {label} 제거', + 'mobile.projectEdit.deleteWorktreeTitle': '워크트리 제거', + 'mobile.projectEdit.deleteWorktreeConfirmButton': '제거', + 'mobile.projectEdit.deleteWorktreeConfirm': '워크트리 “{name}”을(를) 제거할까요? 되돌릴 수 없습니다.', + 'mobile.projectEdit.deleteWorktreeDirty': '이 워크트리에는 커밋되지 않은 변경 사항이 있어 손실됩니다.', + 'mobile.projectEdit.deleteWorktreeArchiveNote': '연결된 세션 {count}개가 보관됩니다.', + 'mobile.projectEdit.deleteLocalBranch': '로컬 브랜치도 삭제', + 'mobile.projectEdit.deleteRemoteBranch': '원격 브랜치도 삭제', 'mobile.sessions.showMore': '{count}개 더 보기', 'mobile.sessions.search.section.sessions': '세션', 'mobile.sessions.search.section.archived': '보관됨', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index f1560b8c..6dea2fac 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -68,6 +68,19 @@ export const dict: Record = { 'mobile.sessions.confirmRemoveProject': 'Usuń', 'mobile.sessions.confirmRemoveProjectAria': 'Potwierdź usunięcie {label}', 'mobile.sessions.toast.projectRemoved': 'Usunięto {label}', + 'mobile.sessions.editProjectAria': 'Edytuj {label}', + 'mobile.projectEdit.worktreesTitle': 'Worktree', + 'mobile.projectEdit.worktreesEmpty': 'Ten projekt nie ma jeszcze worktree.', + 'mobile.projectEdit.reorderHint': 'Przeciągnij, aby zmienić kolejność worktree.', + 'mobile.projectEdit.dragWorktreeAria': 'Przeciągnij {label}, aby zmienić kolejność', + 'mobile.projectEdit.deleteWorktreeAria': 'Usuń worktree {label}', + 'mobile.projectEdit.deleteWorktreeTitle': 'Usuń worktree', + 'mobile.projectEdit.deleteWorktreeConfirmButton': 'Usuń', + 'mobile.projectEdit.deleteWorktreeConfirm': 'Usunąć worktree „{name}”? Tej operacji nie można cofnąć.', + 'mobile.projectEdit.deleteWorktreeDirty': 'Ten worktree ma niezatwierdzone zmiany, które zostaną utracone.', + 'mobile.projectEdit.deleteWorktreeArchiveNote': 'Powiązane sesje ({count}) zostaną zarchiwizowane.', + 'mobile.projectEdit.deleteLocalBranch': 'Usuń także gałąź lokalną', + 'mobile.projectEdit.deleteRemoteBranch': 'Usuń także gałąź zdalną', 'mobile.sessions.showMore': 'Pokaż jeszcze {count}', 'mobile.sessions.search.section.sessions': 'Sesje', 'mobile.sessions.search.section.archived': 'Zarchiwizowane', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 10d528e5..c945adf9 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -67,6 +67,19 @@ export const dict: Record = { "mobile.sessions.confirmRemoveProject": "Excluir", "mobile.sessions.confirmRemoveProjectAria": "Confirmar remoção de {label}", "mobile.sessions.toast.projectRemoved": "Removido {label}", + "mobile.sessions.editProjectAria": "Editar {label}", + "mobile.projectEdit.worktreesTitle": "Worktrees", + "mobile.projectEdit.worktreesEmpty": "Este projeto ainda não tem worktrees.", + "mobile.projectEdit.reorderHint": "Arraste para reordenar os worktrees.", + "mobile.projectEdit.dragWorktreeAria": "Arraste {label} para reordenar", + "mobile.projectEdit.deleteWorktreeAria": "Remover worktree {label}", + "mobile.projectEdit.deleteWorktreeTitle": "Remover worktree", + "mobile.projectEdit.deleteWorktreeConfirmButton": "Remover", + "mobile.projectEdit.deleteWorktreeConfirm": "Remover o worktree “{name}”? Isso não pode ser desfeito.", + "mobile.projectEdit.deleteWorktreeDirty": "Este worktree tem alterações não confirmadas que serão perdidas.", + "mobile.projectEdit.deleteWorktreeArchiveNote": "{count} sessão(ões) anexada(s) serão arquivadas.", + "mobile.projectEdit.deleteLocalBranch": "Também excluir o branch local", + "mobile.projectEdit.deleteRemoteBranch": "Também excluir o branch remoto", "mobile.sessions.showMore": "Mostrar mais {count}", "mobile.sessions.search.section.sessions": "Sessões", "mobile.sessions.search.section.archived": "Arquivadas", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a1851107..a821c6e4 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -67,6 +67,19 @@ export const dict: Record = { "mobile.sessions.confirmRemoveProject": "Видалити", "mobile.sessions.confirmRemoveProjectAria": "Підтвердити видалення {label}", "mobile.sessions.toast.projectRemoved": "Видалено {label}", + "mobile.sessions.editProjectAria": "Редагувати {label}", + "mobile.projectEdit.worktreesTitle": "Ворктрі", + "mobile.projectEdit.worktreesEmpty": "У цьому проєкті ще немає ворктрі.", + "mobile.projectEdit.reorderHint": "Перетягніть, щоб змінити порядок ворктрі.", + "mobile.projectEdit.dragWorktreeAria": "Перетягнути {label} для зміни порядку", + "mobile.projectEdit.deleteWorktreeAria": "Видалити ворктрі {label}", + "mobile.projectEdit.deleteWorktreeTitle": "Видалити ворктрі", + "mobile.projectEdit.deleteWorktreeConfirmButton": "Видалити", + "mobile.projectEdit.deleteWorktreeConfirm": "Видалити ворктрі «{name}»? Цю дію не можна скасувати.", + "mobile.projectEdit.deleteWorktreeDirty": "У цьому ворктрі є незакомічені зміни, які буде втрачено.", + "mobile.projectEdit.deleteWorktreeArchiveNote": "Прикріплені сесії ({count}) буде заархівовано.", + "mobile.projectEdit.deleteLocalBranch": "Також видалити локальну гілку", + "mobile.projectEdit.deleteRemoteBranch": "Також видалити віддалену гілку", "mobile.sessions.showMore": "Показати ще {count}", "mobile.sessions.search.section.sessions": "Сесії", "mobile.sessions.search.section.archived": "Архів", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index a6664499..37ff2824 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -67,6 +67,19 @@ export const dict: Record = { 'mobile.sessions.confirmRemoveProject': '删除', 'mobile.sessions.confirmRemoveProjectAria': '确认移除 {label}', 'mobile.sessions.toast.projectRemoved': '已移除 {label}', + 'mobile.sessions.editProjectAria': '编辑 {label}', + 'mobile.projectEdit.worktreesTitle': '工作树', + 'mobile.projectEdit.worktreesEmpty': '此项目还没有工作树。', + 'mobile.projectEdit.reorderHint': '拖动以重新排序工作树。', + 'mobile.projectEdit.dragWorktreeAria': '拖动 {label} 以重新排序', + 'mobile.projectEdit.deleteWorktreeAria': '移除工作树 {label}', + 'mobile.projectEdit.deleteWorktreeTitle': '移除工作树', + 'mobile.projectEdit.deleteWorktreeConfirmButton': '移除', + 'mobile.projectEdit.deleteWorktreeConfirm': '移除工作树“{name}”?此操作无法撤销。', + 'mobile.projectEdit.deleteWorktreeDirty': '此工作树有未提交的更改,将会丢失。', + 'mobile.projectEdit.deleteWorktreeArchiveNote': '将归档 {count} 个关联会话。', + 'mobile.projectEdit.deleteLocalBranch': '同时删除本地分支', + 'mobile.projectEdit.deleteRemoteBranch': '同时删除远程分支', 'mobile.sessions.showMore': '再显示 {count} 个', 'mobile.sessions.search.section.sessions': '会话', 'mobile.sessions.search.section.archived': '已归档', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index ee2c1214..249ca51d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -67,6 +67,19 @@ export const dict: Record = { 'mobile.sessions.confirmRemoveProject': '刪除', 'mobile.sessions.confirmRemoveProjectAria': '確認移除 {label}', 'mobile.sessions.toast.projectRemoved': '已移除 {label}', + 'mobile.sessions.editProjectAria': '編輯 {label}', + 'mobile.projectEdit.worktreesTitle': '工作樹', + 'mobile.projectEdit.worktreesEmpty': '此專案還沒有工作樹。', + 'mobile.projectEdit.reorderHint': '拖曳以重新排序工作樹。', + 'mobile.projectEdit.dragWorktreeAria': '拖曳 {label} 以重新排序', + 'mobile.projectEdit.deleteWorktreeAria': '移除工作樹 {label}', + 'mobile.projectEdit.deleteWorktreeTitle': '移除工作樹', + 'mobile.projectEdit.deleteWorktreeConfirmButton': '移除', + 'mobile.projectEdit.deleteWorktreeConfirm': '移除工作樹「{name}」?此操作無法復原。', + 'mobile.projectEdit.deleteWorktreeDirty': '此工作樹有未提交的變更,將會遺失。', + 'mobile.projectEdit.deleteWorktreeArchiveNote': '將封存 {count} 個關聯工作階段。', + 'mobile.projectEdit.deleteLocalBranch': '同時刪除本機分支', + 'mobile.projectEdit.deleteRemoteBranch': '同時刪除遠端分支', 'mobile.sessions.showMore': '再顯示 {count} 個', 'mobile.sessions.search.section.sessions': '會話', 'mobile.sessions.search.section.archived': '已封存', diff --git a/packages/ui/src/stores/useWorktreeOrderStore.ts b/packages/ui/src/stores/useWorktreeOrderStore.ts new file mode 100644 index 00000000..a4673fd3 --- /dev/null +++ b/packages/ui/src/stores/useWorktreeOrderStore.ts @@ -0,0 +1,57 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import type { WorktreeMetadata } from '@/types/worktree'; + +/** + * Persisted display order for worktrees within a project, mirroring how + * projects persist their order (the array position IS the order). Keyed by + * project id, the value is an ordered list of normalized worktree paths. + * + * Worktrees come from git and are otherwise listed alphabetically; this store + * lets the user reorder them (e.g. in the mobile project editor) and have that + * order stick across restarts. + */ +type WorktreeOrderStore = { + orderByProject: Record; + setWorktreeOrder: (projectId: string, orderedPaths: string[]) => void; +}; + +export const useWorktreeOrderStore = create()( + persist( + (set) => ({ + orderByProject: {}, + setWorktreeOrder: (projectId, orderedPaths) => + set((state) => ({ orderByProject: { ...state.orderByProject, [projectId]: orderedPaths } })), + }), + { + name: 'mobile-worktree-order', + }, + ), +); + +const normalizeWorktreePath = (value: string): string => + value.replace(/\\/g, '/').replace(/\/+$/, ''); + +/** + * Stable-sort worktrees by a stored order of paths. Worktrees not present in + * the stored order keep their incoming (alphabetical) order, appended after + * the known ones. + */ +export const orderWorktrees = ( + orderedPaths: string[] | undefined, + worktrees: WorktreeMetadata[], +): WorktreeMetadata[] => { + if (!orderedPaths || orderedPaths.length === 0) return worktrees; + const rank = new Map(orderedPaths.map((path, index) => [normalizeWorktreePath(path), index] as const)); + const rankOf = (worktree: WorktreeMetadata): number => + rank.get(normalizeWorktreePath(worktree.path)) ?? Number.MAX_SAFE_INTEGER; + return worktrees + .map((worktree, index) => ({ worktree, index })) + .sort((a, b) => { + const byRank = rankOf(a.worktree) - rankOf(b.worktree); + // Preserve incoming order for ties (unknown worktrees / equal ranks). + return byRank !== 0 ? byRank : a.index - b.index; + }) + .map((entry) => entry.worktree); +};