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 React from 'react';
|
||||||
import {
|
import {
|
||||||
RiAddLine,
|
RiAddLine,
|
||||||
RiArrowDownLine,
|
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
RiArrowUpLine,
|
|
||||||
RiArrowUpSLine,
|
RiArrowUpSLine,
|
||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
@@ -49,10 +47,12 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|||||||
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
|
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useAllLiveSessions } from '@/sync/sync-context';
|
import { useAllLiveSessions } from '@/sync/sync-context';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
|
|
||||||
|
import { MobileProjectEditSurface } from './MobileProjectEditSurface';
|
||||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||||
|
|
||||||
type MobileSessionsSheetProps = {
|
type MobileSessionsSheetProps = {
|
||||||
@@ -332,21 +332,15 @@ const ShowFewerRow: React.FC<{
|
|||||||
const SortableProjectRow: React.FC<{
|
const SortableProjectRow: React.FC<{
|
||||||
project: ProjectMeta;
|
project: ProjectMeta;
|
||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
isFirst: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
confirmingDelete: boolean;
|
confirmingDelete: boolean;
|
||||||
onMoveUp: () => void;
|
onEdit: () => void;
|
||||||
onMoveDown: () => void;
|
|
||||||
onRequestRemove: () => void;
|
onRequestRemove: () => void;
|
||||||
onConfirmRemove: () => void;
|
onConfirmRemove: () => void;
|
||||||
}> = ({
|
}> = ({
|
||||||
project,
|
project,
|
||||||
totalSessions,
|
totalSessions,
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
confirmingDelete,
|
confirmingDelete,
|
||||||
onMoveUp,
|
onEdit,
|
||||||
onMoveDown,
|
|
||||||
onRequestRemove,
|
onRequestRemove,
|
||||||
onConfirmRemove,
|
onConfirmRemove,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -394,23 +388,12 @@ const SortableProjectRow: React.FC<{
|
|||||||
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{totalSessions}</span>
|
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{totalSessions}</span>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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.moveUpAria', { label: project.label })}
|
aria-label={t('mobile.sessions.editProjectAria', { label: project.label })}
|
||||||
onClick={onMoveUp}
|
onClick={onEdit}
|
||||||
disabled={isFirst}
|
|
||||||
style={{ touchAction: 'manipulation' }}
|
style={{ touchAction: 'manipulation' }}
|
||||||
>
|
>
|
||||||
<RiArrowUpLine className="size-4" />
|
<RiEdit2Line 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" />
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -455,7 +438,11 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
const worktreeExpandedMap = useMobileSessionTreeStore((state) => state.worktreeExpanded);
|
const worktreeExpandedMap = useMobileSessionTreeStore((state) => state.worktreeExpanded);
|
||||||
const setProjectExpanded = useMobileSessionTreeStore((state) => state.setProjectExpanded);
|
const setProjectExpanded = useMobileSessionTreeStore((state) => state.setProjectExpanded);
|
||||||
const setWorktreeExpanded = useMobileSessionTreeStore((state) => state.setWorktreeExpanded);
|
const setWorktreeExpanded = useMobileSessionTreeStore((state) => state.setWorktreeExpanded);
|
||||||
|
const worktreeOrderByProject = useWorktreeOrderStore((state) => state.orderByProject);
|
||||||
const [query, setQuery] = React.useState('');
|
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 [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false);
|
||||||
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
|
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
|
||||||
const [worktreeDialogProjectId, setWorktreeDialogProjectId] = React.useState<string | null>(null);
|
const [worktreeDialogProjectId, setWorktreeDialogProjectId] = React.useState<string | null>(null);
|
||||||
@@ -475,6 +462,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
setEditingOrder(false);
|
setEditingOrder(false);
|
||||||
setConfirmingDeleteId(null);
|
setConfirmingDeleteId(null);
|
||||||
setVisibleCountByBucket(new Map());
|
setVisibleCountByBucket(new Map());
|
||||||
|
setEditingProjectId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshGlobalSessions(liveSessions);
|
void refreshGlobalSessions(liveSessions);
|
||||||
@@ -517,7 +505,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [git, open, projects]);
|
}, [git, open, projects, worktreeRefreshKey]);
|
||||||
|
|
||||||
const projectsMeta = React.useMemo<ProjectMeta[]>(
|
const projectsMeta = React.useMemo<ProjectMeta[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -530,9 +518,12 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
iconImage: project.iconImage,
|
iconImage: project.iconImage,
|
||||||
iconBackground: project.iconBackground,
|
iconBackground: project.iconBackground,
|
||||||
isGitRepo: gitProjectPaths.has(normalizePath(project.path)),
|
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}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<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);
|
const node = projectNodes.find((n) => n.project.id === project.id);
|
||||||
return (
|
return (
|
||||||
<SortableProjectRow
|
<SortableProjectRow
|
||||||
key={project.id}
|
key={project.id}
|
||||||
project={project}
|
project={project}
|
||||||
totalSessions={node?.totalSessions ?? 0}
|
totalSessions={node?.totalSessions ?? 0}
|
||||||
isFirst={index === 0}
|
|
||||||
isLast={index === projectsMeta.length - 1}
|
|
||||||
confirmingDelete={confirmingDeleteId === project.id}
|
confirmingDelete={confirmingDeleteId === project.id}
|
||||||
onMoveUp={() => reorderProjects(index, index - 1)}
|
onEdit={() => setEditingProjectId(project.id)}
|
||||||
onMoveDown={() => reorderProjects(index, index + 1)}
|
|
||||||
onRequestRemove={() => handleRequestRemoveProject(project.id)}
|
onRequestRemove={() => handleRequestRemoveProject(project.id)}
|
||||||
onConfirmRemove={() => handleConfirmRemoveProject(project)}
|
onConfirmRemove={() => handleConfirmRemoveProject(project)}
|
||||||
/>
|
/>
|
||||||
@@ -1158,6 +1146,12 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<MobileProjectEditSurface
|
||||||
|
open={editingProjectId !== null}
|
||||||
|
project={projectsMeta.find((entry) => entry.id === editingProjectId) ?? null}
|
||||||
|
onClose={() => setEditingProjectId(null)}
|
||||||
|
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</MobileSurfaceShell>
|
</MobileSurfaceShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export const dict = {
|
|||||||
'mobile.sessions.newChat': 'New chat',
|
'mobile.sessions.newChat': 'New chat',
|
||||||
'mobile.sessions.editOrder': 'Reorder projects',
|
'mobile.sessions.editOrder': 'Reorder projects',
|
||||||
'mobile.sessions.doneEditing': 'Done',
|
'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.dragHandleAria': 'Drag {label} to reorder',
|
||||||
'mobile.sessions.moveUpAria': 'Move {label} up',
|
'mobile.sessions.moveUpAria': 'Move {label} up',
|
||||||
'mobile.sessions.moveDownAria': 'Move {label} down',
|
'mobile.sessions.moveDownAria': 'Move {label} down',
|
||||||
@@ -66,6 +67,18 @@ export const dict = {
|
|||||||
'mobile.sessions.confirmRemoveProject': 'Delete',
|
'mobile.sessions.confirmRemoveProject': 'Delete',
|
||||||
'mobile.sessions.confirmRemoveProjectAria': 'Confirm removing {label}',
|
'mobile.sessions.confirmRemoveProjectAria': 'Confirm removing {label}',
|
||||||
'mobile.sessions.toast.projectRemoved': 'Removed {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.showMore': 'Show {count} more',
|
||||||
'mobile.sessions.search.section.sessions': 'Sessions',
|
'mobile.sessions.search.section.sessions': 'Sessions',
|
||||||
'mobile.sessions.search.section.archived': 'Archived',
|
'mobile.sessions.search.section.archived': 'Archived',
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.sessions.confirmRemoveProject": "Eliminar",
|
"mobile.sessions.confirmRemoveProject": "Eliminar",
|
||||||
"mobile.sessions.confirmRemoveProjectAria": "Confirmar eliminación de {label}",
|
"mobile.sessions.confirmRemoveProjectAria": "Confirmar eliminación de {label}",
|
||||||
"mobile.sessions.toast.projectRemoved": "Se eliminó {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.showMore": "Mostrar {count} más",
|
||||||
"mobile.sessions.search.section.sessions": "Sesiones",
|
"mobile.sessions.search.section.sessions": "Sesiones",
|
||||||
"mobile.sessions.search.section.archived": "Archivadas",
|
"mobile.sessions.search.section.archived": "Archivadas",
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.sessions.confirmRemoveProject': '삭제',
|
'mobile.sessions.confirmRemoveProject': '삭제',
|
||||||
'mobile.sessions.confirmRemoveProjectAria': '{label} 제거 확인',
|
'mobile.sessions.confirmRemoveProjectAria': '{label} 제거 확인',
|
||||||
'mobile.sessions.toast.projectRemoved': '{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.showMore': '{count}개 더 보기',
|
||||||
'mobile.sessions.search.section.sessions': '세션',
|
'mobile.sessions.search.section.sessions': '세션',
|
||||||
'mobile.sessions.search.section.archived': '보관됨',
|
'mobile.sessions.search.section.archived': '보관됨',
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.sessions.confirmRemoveProject': 'Usuń',
|
'mobile.sessions.confirmRemoveProject': 'Usuń',
|
||||||
'mobile.sessions.confirmRemoveProjectAria': 'Potwierdź usunięcie {label}',
|
'mobile.sessions.confirmRemoveProjectAria': 'Potwierdź usunięcie {label}',
|
||||||
'mobile.sessions.toast.projectRemoved': 'Usunięto {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.showMore': 'Pokaż jeszcze {count}',
|
||||||
'mobile.sessions.search.section.sessions': 'Sesje',
|
'mobile.sessions.search.section.sessions': 'Sesje',
|
||||||
'mobile.sessions.search.section.archived': 'Zarchiwizowane',
|
'mobile.sessions.search.section.archived': 'Zarchiwizowane',
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.sessions.confirmRemoveProject": "Excluir",
|
"mobile.sessions.confirmRemoveProject": "Excluir",
|
||||||
"mobile.sessions.confirmRemoveProjectAria": "Confirmar remoção de {label}",
|
"mobile.sessions.confirmRemoveProjectAria": "Confirmar remoção de {label}",
|
||||||
"mobile.sessions.toast.projectRemoved": "Removido {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.showMore": "Mostrar mais {count}",
|
||||||
"mobile.sessions.search.section.sessions": "Sessões",
|
"mobile.sessions.search.section.sessions": "Sessões",
|
||||||
"mobile.sessions.search.section.archived": "Arquivadas",
|
"mobile.sessions.search.section.archived": "Arquivadas",
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.sessions.confirmRemoveProject": "Видалити",
|
"mobile.sessions.confirmRemoveProject": "Видалити",
|
||||||
"mobile.sessions.confirmRemoveProjectAria": "Підтвердити видалення {label}",
|
"mobile.sessions.confirmRemoveProjectAria": "Підтвердити видалення {label}",
|
||||||
"mobile.sessions.toast.projectRemoved": "Видалено {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.showMore": "Показати ще {count}",
|
||||||
"mobile.sessions.search.section.sessions": "Сесії",
|
"mobile.sessions.search.section.sessions": "Сесії",
|
||||||
"mobile.sessions.search.section.archived": "Архів",
|
"mobile.sessions.search.section.archived": "Архів",
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.sessions.confirmRemoveProject': '删除',
|
'mobile.sessions.confirmRemoveProject': '删除',
|
||||||
'mobile.sessions.confirmRemoveProjectAria': '确认移除 {label}',
|
'mobile.sessions.confirmRemoveProjectAria': '确认移除 {label}',
|
||||||
'mobile.sessions.toast.projectRemoved': '已移除 {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.showMore': '再显示 {count} 个',
|
||||||
'mobile.sessions.search.section.sessions': '会话',
|
'mobile.sessions.search.section.sessions': '会话',
|
||||||
'mobile.sessions.search.section.archived': '已归档',
|
'mobile.sessions.search.section.archived': '已归档',
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.sessions.confirmRemoveProject': '刪除',
|
'mobile.sessions.confirmRemoveProject': '刪除',
|
||||||
'mobile.sessions.confirmRemoveProjectAria': '確認移除 {label}',
|
'mobile.sessions.confirmRemoveProjectAria': '確認移除 {label}',
|
||||||
'mobile.sessions.toast.projectRemoved': '已移除 {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.showMore': '再顯示 {count} 個',
|
||||||
'mobile.sessions.search.section.sessions': '會話',
|
'mobile.sessions.search.section.sessions': '會話',
|
||||||
'mobile.sessions.search.section.archived': '已封存',
|
'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