Files
openchamber/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx
T
𝖎𝖚𝖑𝖎𝖎𝖆 0d8709a72e fix(worktrees): remove worktrees in the background (#3319)
* refactor(worktrees): fetch source once during creation

* fix(worktrees): remove worktrees in background

* fix(worktrees): show background removal progress

* fix(worktrees): name the worktree in removal toasts
2026-09-03 15:17:28 +03:00

229 lines
8.9 KiB
TypeScript

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 { getWorktreeDisplayName, 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 removeWorktreeInBackground = React.useCallback((target: WorktreeMetadata, sessionIds: string[]) => {
const name = getWorktreeDisplayName(target);
const toastId = toast.loading(t('sessions.sidebar.sessionDialogs.worktree.removingTitle', { name }));
void (async () => {
try {
if (sessionIds.length > 0) {
const { failedIds } = await archiveSessions(sessionIds);
if (failedIds.length > 0) {
toast.error(
failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }),
{ id: toastId, description: t('sessions.sidebar.dialogs.deleteResult.tryAgain') },
);
return;
}
}
await removeProjectWorktree(project, target, {
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', { name }), {
id: toastId,
description:
hasBranch && deleteRemoteBranch
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
: t('sessions.sidebar.sessionDialogs.worktree.removed'),
});
onDeleted?.();
} catch (error) {
toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle', { name }), {
id: toastId,
description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'),
});
}
})();
}, [archiveSessions, currentDirectory, deleteLocalBranch, deleteRemoteBranch, hasBranch, onDeleted, project, t, worktreePath]);
const handleConfirm = () => {
if (!worktree || isProcessing) return;
setIsProcessing(true);
removeWorktreeInBackground(worktree, linkedSessions.map((session) => session.id));
onClose();
};
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/70 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>
);
};