feat: add mobile project editor with worktree reorder and delete

Replace the up/down arrows in the mobile project reorder list (drag already
covers reordering) with an edit button that opens a dedicated project editor
surface: rename, pick icon and color, and discover a favicon (no upload).

The editor lists the project's worktrees with drag-to-reorder (persisted per
project, like project order) and a delete button that opens a mobile
confirmation built on the shared worktree primitives — archiving attached
sessions and optionally removing the local/remote branch.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 16:10:20 +03:00
parent 51682dc7af
commit 79d68ba583
12 changed files with 822 additions and 34 deletions
@@ -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<MobileDeleteWorktreeDialogProps> = ({
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<string, Session>();
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) => (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl border border-border/50 px-3.5 py-3 text-left transition-colors',
'hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
disabled && 'pointer-events-none opacity-40',
)}
style={{ touchAction: 'manipulation' }}
>
<span className="typography-ui-label text-foreground">{label}</span>
<span
className={cn(
'relative h-6 w-10 shrink-0 rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted-foreground/30',
)}
>
<span
className={cn(
'absolute top-0.5 size-5 rounded-full bg-white transition-transform',
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5',
)}
/>
</span>
</button>
);
return (
<MobileOverlayPanel
open={open}
onClose={onClose}
title={t('mobile.projectEdit.deleteWorktreeTitle')}
footer={
<div className="flex gap-2">
<Button variant="ghost" className="flex-1" onClick={onClose} disabled={isProcessing}>
{t('projectEditDialog.actions.cancel')}
</Button>
<Button
variant="destructive"
className="flex-1"
onClick={() => void handleConfirm()}
disabled={isProcessing}
>
{t('mobile.projectEdit.deleteWorktreeConfirmButton')}
</Button>
</div>
}
>
<div className="flex flex-col gap-4 px-1 py-1">
<p className="typography-ui-body text-foreground">
{t('mobile.projectEdit.deleteWorktreeConfirm', { name: worktreeName })}
</p>
{isDirty ? (
<p className="rounded-xl border border-[var(--warning)]/40 bg-[color-mix(in_srgb,var(--warning)_10%,transparent)] px-3.5 py-3 typography-meta text-foreground">
{t('mobile.projectEdit.deleteWorktreeDirty')}
</p>
) : null}
{linkedSessions.length > 0 ? (
<p className="typography-meta text-muted-foreground">
{t('mobile.projectEdit.deleteWorktreeArchiveNote', { count: linkedSessions.length })}
</p>
) : null}
{hasBranch ? (
<div className="flex flex-col gap-2">
{toggle(deleteLocalBranch, setDeleteLocalBranch, t('mobile.projectEdit.deleteLocalBranch'), isProcessing)}
{toggle(deleteRemoteBranch, setDeleteRemoteBranch, t('mobile.projectEdit.deleteRemoteBranch'), isProcessing)}
</div>
) : null}
</div>
</MobileOverlayPanel>
);
};
@@ -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 (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-1 rounded-2xl border border-border/40 bg-[var(--surface-elevated)] px-1.5 py-1.5 transition-colors',
isDragging && 'shadow-lg shadow-black/20',
)}
>
<button
type="button"
className="flex size-9 shrink-0 cursor-grab touch-none items-center justify-center rounded-xl text-muted-foreground/70 transition-colors hover:text-foreground active:cursor-grabbing"
aria-label={t('mobile.projectEdit.dragWorktreeAria', { label })}
{...attributes}
{...listeners}
>
<RiDragMove2Line className="size-4" />
</button>
<Icon name="node-tree" className="size-4 shrink-0 text-muted-foreground" />
<span className="block min-w-0 flex-1 truncate typography-ui-label text-foreground">{label}</span>
<button
type="button"
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
aria-label={t('mobile.projectEdit.deleteWorktreeAria', { label })}
onClick={onDelete}
style={{ touchAction: 'manipulation' }}
>
<RiDeleteBinLine className="size-4" />
</button>
</div>
);
};
export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> = ({
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<string | null>(null);
const [color, setColor] = React.useState<string | null>(null);
const [isDiscovering, setIsDiscovering] = React.useState(false);
const [orderedWorktrees, setOrderedWorktrees] = React.useState<WorktreeMetadata[]>([]);
const [worktreeToDelete, setWorktreeToDelete] = React.useState<WorktreeMetadata | null>(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 (
<>
<MobileSurfaceShell
open={open}
onClose={onClose}
onBack={onClose}
title={t('projectEditDialog.title')}
ariaLabel={t('projectEditDialog.title')}
trailing={
<Button
type="button"
variant="default"
size="sm"
aria-label={t('projectEditDialog.actions.save')}
onClick={handleSave}
disabled={!name.trim()}
style={{ touchAction: 'manipulation' }}
>
<RiCheckLine className="size-4" />
{t('projectEditDialog.actions.save')}
</Button>
}
>
{project ? (
<div className="h-full space-y-6 overflow-y-auto px-4 pb-8 pt-2">
{/* Icon preview */}
<div className="flex justify-center pt-2">
<span
className="flex size-16 items-center justify-center overflow-hidden rounded-2xl bg-[var(--surface-muted)] text-muted-foreground"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
{hasImageIcon ? (
<ProjectIconImage
project={{ id: project.id, iconImage: currentIconImage }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="size-full object-contain"
fallback={<RiFolder6Line className="size-7" />}
/>
) : previewIconName ? (
<Icon name={previewIconName} className="size-7" style={currentColorVar ? { color: currentColorVar } : undefined} />
) : (
<RiFolder6Line className="size-7" style={currentColorVar ? { color: currentColorVar } : undefined} />
)}
</span>
</div>
{/* Name */}
<div className="flex flex-col gap-1.5">
<label className="typography-ui-label font-medium text-foreground">
{t('projectEditDialog.field.name')}
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('projectEditDialog.field.namePlaceholder')}
className="h-11"
/>
<p className="truncate typography-meta text-muted-foreground" title={project.path}>
{project.path}
</p>
</div>
{/* Color */}
<div className="flex flex-col gap-2">
<label className="typography-ui-label font-medium text-foreground">
{t('projectEditDialog.field.color')}
</label>
<div className="flex flex-wrap gap-2.5">
<button
type="button"
onClick={() => setColor(null)}
aria-label={t('projectEditDialog.option.none')}
className={cn(
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
color === null ? 'border-foreground' : 'border-border hover:border-border/80',
)}
style={{ touchAction: 'manipulation' }}
>
<span className="h-0.5 w-4 rotate-45 rounded-full bg-muted-foreground/40" />
</button>
{PROJECT_COLORS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => setColor(c.key)}
aria-label={c.label}
title={c.label}
className={cn(
'size-9 rounded-xl border-2 transition-all',
color === c.key ? 'border-foreground' : 'border-transparent hover:border-border',
)}
style={{ backgroundColor: c.cssVar, touchAction: 'manipulation' }}
/>
))}
</div>
</div>
{/* Icon */}
<div className="flex flex-col gap-2">
<label className="typography-ui-label font-medium text-foreground">
{t('projectEditDialog.field.icon')}
</label>
<div className="flex flex-wrap gap-2.5">
<button
type="button"
onClick={() => setIcon(null)}
aria-label={t('projectEditDialog.option.none')}
className={cn(
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
icon === null ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border hover:border-border/80',
)}
style={{ touchAction: 'manipulation' }}
>
<span className="h-0.5 w-4 rotate-45 rounded-full bg-muted-foreground/40" />
</button>
{PROJECT_ICONS.map((i) => (
<button
key={i.key}
type="button"
onClick={() => setIcon(i.key)}
aria-label={i.label}
title={i.label}
className={cn(
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
icon === i.key ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border hover:border-border/80',
)}
style={{ touchAction: 'manipulation' }}
>
<Icon name={i.Icon} className="size-4" style={currentColorVar ? { color: currentColorVar } : undefined} />
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button size="sm" variant="outline" onClick={handleDiscoverIcon} disabled={isDiscovering}>
{isDiscovering
? t('projectEditDialog.actions.discovering')
: t('projectEditDialog.actions.discoverFavicon')}
</Button>
{hasImageIcon ? (
<Button size="sm" variant="outline" onClick={handleRemoveDiscoveredIcon}>
{t('projectEditDialog.actions.removeProjectIcon')}
</Button>
) : null}
</div>
</div>
{/* Worktrees */}
{project.isGitRepo ? (
<div className="flex flex-col gap-2">
<label className="typography-ui-label font-medium text-foreground">
{t('mobile.projectEdit.worktreesTitle')}
</label>
{orderedWorktrees.length === 0 ? (
<p className="typography-meta text-muted-foreground">
{t('mobile.projectEdit.worktreesEmpty')}
</p>
) : (
<>
<p className="typography-meta text-muted-foreground">
{t('mobile.projectEdit.reorderHint')}
</p>
<DndContext sensors={dndSensors} collisionDetection={closestCenter} onDragEnd={handleWorktreeDragEnd}>
<SortableContext
items={orderedWorktrees.map((worktree) => worktree.path)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-1.5">
{orderedWorktrees.map((worktree) => (
<SortableWorktreeRow
key={worktree.path}
worktree={worktree}
onDelete={() => setWorktreeToDelete(worktree)}
/>
))}
</div>
</SortableContext>
</DndContext>
</>
)}
</div>
) : null}
</div>
) : null}
</MobileSurfaceShell>
{project ? (
<MobileDeleteWorktreeDialog
open={Boolean(worktreeToDelete)}
project={{ id: project.id, path: project.path }}
worktree={worktreeToDelete}
onClose={() => setWorktreeToDelete(null)}
onDeleted={() => {
setOrderedWorktrees((previous) =>
previous.filter((worktree) => worktree.path !== worktreeToDelete?.path),
);
onWorktreesChanged?.();
}}
/>
) : null}
</>
);
};
+27 -33
View File
@@ -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<{
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{totalSessions}</span>
<button
type="button"
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-30"
aria-label={t('mobile.sessions.moveUpAria', { label: project.label })}
onClick={onMoveUp}
disabled={isFirst}
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: project.label })}
onClick={onEdit}
style={{ touchAction: 'manipulation' }}
>
<RiArrowUpLine className="size-4" />
</button>
<button
type="button"
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-30"
aria-label={t('mobile.sessions.moveDownAria', { label: project.label })}
onClick={onMoveDown}
disabled={isLast}
style={{ touchAction: 'manipulation' }}
>
<RiArrowDownLine className="size-4" />
<RiEdit2Line className="size-4" />
</button>
</>
)}
@@ -455,7 +438,11 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<string | null>(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<string | null>(null);
@@ -475,6 +462,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
setEditingOrder(false);
setConfirmingDeleteId(null);
setVisibleCountByBucket(new Map());
setEditingProjectId(null);
return;
}
void refreshGlobalSessions(liveSessions);
@@ -517,7 +505,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
return () => {
cancelled = true;
};
}, [git, open, projects]);
}, [git, open, projects, worktreeRefreshKey]);
const projectsMeta = React.useMemo<ProjectMeta[]>(
() =>
@@ -530,9 +518,12 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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<MobileSessionsSheetProps> = ({ open,
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-1.5">
{projectsMeta.map((project, index) => {
{projectsMeta.map((project) => {
const node = projectNodes.find((n) => n.project.id === project.id);
return (
<SortableProjectRow
key={project.id}
project={project}
totalSessions={node?.totalSessions ?? 0}
isFirst={index === 0}
isLast={index === projectsMeta.length - 1}
confirmingDelete={confirmingDeleteId === project.id}
onMoveUp={() => 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<MobileSessionsSheetProps> = ({ open,
onOpenChange(false);
}}
/>
<MobileProjectEditSurface
open={editingProjectId !== null}
project={projectsMeta.find((entry) => entry.id === editingProjectId) ?? null}
onClose={() => setEditingProjectId(null)}
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
/>
</div>
</MobileSurfaceShell>
);
+14 -1
View File
@@ -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 cant 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',
+13
View File
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
"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",
+13
View File
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
'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': '보관됨',
+13
View File
@@ -68,6 +68,19 @@ export const dict: Record<I18nKey, string> = {
'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',
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
"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",
+13
View File
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
"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": "Архів",
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
'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': '已归档',
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
'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': '已封存',
@@ -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<string, string[]>;
setWorktreeOrder: (projectId: string, orderedPaths: string[]) => void;
};
export const useWorktreeOrderStore = create<WorktreeOrderStore>()(
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);
};