import React from 'react'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { RiCheckboxBlankLine, RiCheckboxLine, RiDeleteBinLine, RiGitBranchLine } from '@remixicon/react'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { DirectoryExplorerDialog } from './DirectoryExplorerDialog'; import { cn, formatPathForDisplay } from '@/lib/utils'; import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFileSystemAccess } from '@/hooks/useFileSystemAccess'; import { isDesktopLocalOriginActive, isTauriShell } from '@/lib/desktop'; import { useDeviceInfo } from '@/lib/device'; import { sessionEvents } from '@/lib/sessionEvents'; const renderToastDescription = (text?: string) => text ? {text} : undefined; const normalizeProjectDirectory = (path: string | null | undefined): string => { if (!path) { return ''; } const replaced = path.replace(/\\/g, '/'); if (replaced === '/') { return '/'; } return replaced.replace(/\/+$/, ''); }; type DeleteDialogState = { sessions: Session[]; dateLabel?: string; mode: 'session' | 'worktree'; worktree?: WorktreeMetadata | null; }; export const SessionDialogs: React.FC = () => { const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false); const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false); const [deleteDialog, setDeleteDialog] = React.useState(null); const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState>([]); const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false); const [deleteDialogShouldDeleteLocalBranch, setDeleteDialogShouldDeleteLocalBranch] = React.useState(false); const [isProcessingDelete, setIsProcessingDelete] = React.useState(false); const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false); const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState>(new Set()); const { deleteSession, deleteSessions, loadSessions, getWorktreeMetadata, } = useSessionStore(); const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); const { projects, addProject, activeProjectId } = useProjectsStore(); const { requestAccess, startAccessing } = useFileSystemAccess(); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const useMobileOverlay = isMobile || isTablet || hasTouchInput; const projectDirectory = React.useMemo(() => { const targetProject = activeProjectId ? projects.find((project) => project.id === activeProjectId) ?? null : null; const targetPath = targetProject?.path ?? currentDirectory; return normalizeProjectDirectory(targetPath); }, [activeProjectId, currentDirectory, projects]); const getProjectRefForWorktree = React.useCallback((worktree: WorktreeMetadata) => { const normalized = normalizeProjectDirectory(worktree.projectDirectory); const fallbackPath = normalized || projectDirectory; const match = projects.find((project) => normalizeProjectDirectory(project.path) === fallbackPath) ?? null; return { id: match?.id ?? `path:${fallbackPath}`, path: fallbackPath }; }, [projectDirectory, projects]); const hasDirtyWorktrees = hasCompletedDirtyCheck && dirtyWorktreePaths.size > 0; const canRemoveRemoteBranches = React.useMemo( () => { const targetWorktree = deleteDialog?.worktree; if (targetWorktree && typeof targetWorktree.branch === 'string' && targetWorktree.branch.trim().length > 0) { return true; } return ( deleteDialogSummaries.length > 0 && deleteDialogSummaries.every(({ metadata }) => typeof metadata.branch === 'string' && metadata.branch.trim().length > 0) ); }, [deleteDialog?.worktree, deleteDialogSummaries], ); const isWorktreeDelete = deleteDialog?.mode === 'worktree'; const shouldArchiveWorktree = isWorktreeDelete; const removeRemoteOptionDisabled = isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches; const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete; React.useEffect(() => { loadSessions(); }, [loadSessions, currentDirectory]); const projectsKey = React.useMemo( () => projects.map((project) => `${project.id}:${project.path}`).join('|'), [projects], ); const lastProjectsKeyRef = React.useRef(projectsKey); React.useEffect(() => { if (projectsKey === lastProjectsKeyRef.current) { return; } lastProjectsKeyRef.current = projectsKey; loadSessions(); }, [loadSessions, projectsKey]); React.useEffect(() => { if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) { return; } setHasShownInitialDirectoryPrompt(true); if (isTauriShell() && isDesktopLocalOriginActive()) { requestAccess('') .then(async (result) => { if (!result.success || !result.path) { if (result.error && result.error !== 'Directory selection cancelled') { toast.error('Failed to select directory', { description: result.error, }); } return; } const accessResult = await startAccessing(result.path); if (!accessResult.success) { toast.error('Failed to open directory', { description: accessResult.error || 'Desktop could not grant file access.', }); return; } const added = addProject(result.path, { id: result.projectId }); if (!added) { toast.error('Failed to add project', { description: 'Please select a valid directory path.', }); } }) .catch((error) => { console.error('Desktop: Error selecting directory:', error); toast.error('Failed to select directory'); }); return; } setIsDirectoryDialogOpen(true); }, [ addProject, hasShownInitialDirectoryPrompt, isHomeReady, projects.length, requestAccess, startAccessing, ]); const openDeleteDialog = React.useCallback((payload: { sessions: Session[]; dateLabel?: string; mode?: 'session' | 'worktree'; worktree?: WorktreeMetadata | null }) => { setDeleteDialog({ sessions: payload.sessions, dateLabel: payload.dateLabel, mode: payload.mode ?? 'session', worktree: payload.worktree ?? null, }); }, []); const closeDeleteDialog = React.useCallback(() => { setDeleteDialog(null); setDeleteDialogSummaries([]); setDeleteDialogShouldRemoveRemote(false); setDeleteDialogShouldDeleteLocalBranch(false); setIsProcessingDelete(false); setHasCompletedDirtyCheck(false); setDirtyWorktreePaths(new Set()); }, []); React.useEffect(() => { return sessionEvents.onDeleteRequest((payload) => { openDeleteDialog(payload); }); }, [openDeleteDialog]); React.useEffect(() => { return sessionEvents.onDirectoryRequest(() => { setIsDirectoryDialogOpen(true); }); }, []); React.useEffect(() => { if (!deleteDialog) { setDeleteDialogSummaries([]); setDeleteDialogShouldRemoveRemote(false); setDeleteDialogShouldDeleteLocalBranch(false); setHasCompletedDirtyCheck(false); setDirtyWorktreePaths(new Set()); return; } const summaries = deleteDialog.sessions .map((session) => { const metadata = getWorktreeMetadata(session.id); return metadata ? { session, metadata } : null; }) .filter((entry): entry is { session: Session; metadata: WorktreeMetadata } => Boolean(entry)); setDeleteDialogSummaries(summaries); setDeleteDialogShouldRemoveRemote(false); setHasCompletedDirtyCheck(false); setDirtyWorktreePaths(new Set()); const metadataByPath = new Map(); if (deleteDialog.worktree?.path) { metadataByPath.set(normalizeProjectDirectory(deleteDialog.worktree.path), deleteDialog.worktree); } summaries.forEach(({ metadata }) => { if (metadata.path) { metadataByPath.set(normalizeProjectDirectory(metadata.path), metadata); } }); if (metadataByPath.size === 0) { setHasCompletedDirtyCheck(true); return; } let cancelled = false; (async () => { const statusByPath = new Map(); const nextDirtyPaths = new Set(); await Promise.all( Array.from(metadataByPath.entries()).map(async ([pathKey, metadata]) => { try { const status = await getWorktreeStatus(metadata.path); statusByPath.set(pathKey, status); if (status?.isDirty) { nextDirtyPaths.add(pathKey); } } catch { if (metadata.status) { statusByPath.set(pathKey, metadata.status); if (metadata.status.isDirty) { nextDirtyPaths.add(pathKey); } } } }) ).catch((error) => { console.warn('Failed to inspect worktree status before deletion:', error); }); if (cancelled) { return; } setDirtyWorktreePaths(nextDirtyPaths); setHasCompletedDirtyCheck(true); setDeleteDialog((prev) => { if (!prev?.worktree?.path) { return prev; } const pathKey = normalizeProjectDirectory(prev.worktree.path); const nextStatus = statusByPath.get(pathKey); if (!nextStatus) { return prev; } const prevStatus = prev.worktree.status; if ( prevStatus?.isDirty === nextStatus.isDirty && prevStatus?.ahead === nextStatus.ahead && prevStatus?.behind === nextStatus.behind && prevStatus?.upstream === nextStatus.upstream ) { return prev; } return { ...prev, worktree: { ...prev.worktree, status: nextStatus, }, }; }); setDeleteDialogSummaries((prev) => prev.map((entry) => { const pathKey = normalizeProjectDirectory(entry.metadata.path); const nextStatus = statusByPath.get(pathKey); if (!nextStatus) { return entry; } return { session: entry.session, metadata: { ...entry.metadata, status: nextStatus }, }; }) ); })(); return () => { cancelled = true; }; }, [deleteDialog, getWorktreeMetadata]); React.useEffect(() => { if (!canRemoveRemoteBranches) { setDeleteDialogShouldRemoveRemote(false); } }, [canRemoveRemoteBranches]); const removeSelectedWorktree = React.useCallback(async ( worktree: WorktreeMetadata, deleteLocalBranch: boolean ): Promise => { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; try { await removeProjectWorktree( getProjectRefForWorktree(worktree), worktree, { deleteRemoteBranch: shouldRemoveRemote, deleteLocalBranch } ); return true; } catch (error) { toast.error('Failed to remove worktree', { description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'), }); return false; } }, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, getProjectRefForWorktree]); const handleConfirmDelete = React.useCallback(async () => { if (!deleteDialog) { return; } setIsProcessingDelete(true); try { const shouldArchive = shouldArchiveWorktree; const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote; const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch; if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); if (!removed) { closeDeleteDialog(); return; } const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.'; toast.success('Worktree removed', { description: renderToastDescription(archiveNote), }); closeDeleteDialog(); loadSessions(); return; } if (deleteDialog.sessions.length === 1) { const target = deleteDialog.sessions[0]; const success = await deleteSession(target.id, { // In "worktree" mode, remove the selected worktree explicitly below. // Don't try to derive worktree removal from per-session metadata (may be missing). archiveWorktree: isWorktreeDelete ? false : shouldArchive, deleteRemoteBranch: removeRemoteBranch, deleteLocalBranch, }); if (!success) { toast.error('Failed to delete session'); setIsProcessingDelete(false); return; } const archiveNote = !isWorktreeDelete && shouldArchive ? removeRemoteBranch ? 'Worktree and remote branch removed.' : 'Attached worktree archived.' : undefined; toast.success('Session deleted', { description: renderToastDescription(archiveNote), action: { label: 'OK', onClick: () => { }, }, }); } else { const ids = deleteDialog.sessions.map((session) => session.id); const { deletedIds, failedIds } = await deleteSessions(ids, { archiveWorktree: isWorktreeDelete ? false : shouldArchive, deleteRemoteBranch: removeRemoteBranch, deleteLocalBranch, }); if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { // Remove selected worktree even if per-session metadata is missing. // Use same projectRef logic as the no-sessions path. const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); if (removed) { await loadSessions(); } } if (deletedIds.length > 0) { const archiveNote = !isWorktreeDelete && shouldArchive ? removeRemoteBranch ? 'Archived worktrees and removed remote branches.' : 'Attached worktrees archived.' : undefined; const successDescription = failedIds.length > 0 ? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.` : deleteDialog.dateLabel ? `Removed all sessions from ${deleteDialog.dateLabel}.` : undefined; const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' '); toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, { description: renderToastDescription(combinedDescription || undefined), action: { label: 'OK', onClick: () => { }, }, }); } if (failedIds.length > 0) { toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, { description: renderToastDescription('Please try again in a moment.'), }); if (deletedIds.length === 0) { setIsProcessingDelete(false); return; } } } if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); if (removed) { await loadSessions(); } } closeDeleteDialog(); } finally { setIsProcessingDelete(false); } }, [ deleteDialog, deleteDialogShouldRemoveRemote, deleteDialogShouldDeleteLocalBranch, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, removeSelectedWorktree, loadSessions, ]); const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null; const deleteDialogDescription = deleteDialog ? deleteDialog.mode === 'worktree' ? deleteDialog.sessions.length === 0 ? 'This removes the selected worktree.' : `This removes the selected worktree and ${deleteDialog.sessions.length === 1 ? '1 linked session' : `${deleteDialog.sessions.length} linked sessions`}.` : `This action permanently removes ${deleteDialog.sessions.length === 1 ? '1 session' : `${deleteDialog.sessions.length} sessions`}${deleteDialog.dateLabel ? ` from ${deleteDialog.dateLabel}` : '' }.` : ''; const deleteDialogBody = deleteDialog ? (
{deleteDialog.sessions.length > 0 && (
{isWorktreeDelete && (
{deleteDialog.sessions.length === 1 ? 'Linked session' : 'Linked sessions'} {deleteDialog.sessions.length}
)}
    {deleteDialog.sessions.slice(0, 5).map((session) => (
  • {session.title || 'Untitled Session'}
  • ))} {deleteDialog.sessions.length > 5 && (
  • +{deleteDialog.sessions.length - 5} more
  • )}
)} {isWorktreeDelete ? (
Worktree {targetWorktree?.label ? ( {targetWorktree.label} ) : null}

{targetWorktree ? formatPathForDisplay(targetWorktree.path, homeDirectory) : 'Worktree path unavailable.'}

{hasDirtyWorktrees && (

Uncommitted changes will be discarded.

)}
) : (

Worktree directories stay intact. Subsessions linked to the selected sessions will also be removed.

)}
) : null; const deleteRemoteBranchAction = isWorktreeDelete ? ( canRemoveRemoteBranches ? ( ) : ( Remote branch info unavailable ) ) : null; const deleteLocalBranchAction = isWorktreeDelete ? ( ) : null; const deleteDialogActions = isWorktreeDelete ? (
{deleteLocalBranchAction} {deleteRemoteBranchAction}
) : ( <> ); const deleteDialogTitle = isWorktreeDelete ? 'Delete worktree' : deleteDialog?.sessions.length === 1 ? 'Delete session' : 'Delete sessions'; return ( <> {useMobileOverlay ? ( { if (isProcessingDelete) { return; } closeDeleteDialog(); }} title={deleteDialogTitle} footer={
{deleteDialogActions}
} >
{deleteDialogDescription && (

{deleteDialogDescription}

)} {deleteDialogBody}
) : ( { if (!open) { if (isProcessingDelete) { return; } closeDeleteDialog(); } }} > {isWorktreeDelete && } {deleteDialogTitle} {deleteDialogDescription && {deleteDialogDescription}}
{deleteDialogBody}
{deleteDialogActions}
)} ); };