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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '보관됨',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user