Replace @remixicon/react with a shared Icon component that renders via <use href> references to a single hidden SVG sprite. This reduces DOM node count by replacing inline SVGs with lightweight references. - Create Icon component with sprite injection (packages/ui/src/components/icon/) - Migrate all 164 files from @remixicon/react to Icon component - Auto-generate sprite data from remixicon bundle (scripts/generate-icon-sprite.mjs) - Add bun run icons:generate to package.json - Move @remixicon/react to devDependencies - Add icon usage instructions to theme-system skill
802 lines
37 KiB
TypeScript
802 lines
37 KiB
TypeScript
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 { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
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 { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import * as sessionActions from '@/sync/session-actions';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { sessionEvents } from '@/lib/sessionEvents';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
const renderToastDescription = (text?: string) =>
|
|
text ? <span className="text-foreground/80 dark:text-foreground/70">{text}</span> : 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 { t } = useI18n();
|
|
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
|
|
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
|
|
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
|
|
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
|
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<Set<string>>(new Set());
|
|
|
|
const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata);
|
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
|
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
|
|
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
|
|
const deleteSession = sessionActions.deleteSession;
|
|
const archiveSession = sessionActions.archiveSession;
|
|
const deleteSessions = useSessionUIStore((s) => s.deleteSessions);
|
|
const archiveSessions = useSessionUIStore((s) => s.archiveSessions);
|
|
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
|
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
|
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
|
const isHomeReady = useDirectoryStore((s) => s.isHomeReady);
|
|
const projects = useProjectsStore((s) => s.projects);
|
|
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
|
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;
|
|
|
|
// Session loading is handled by sync bootstrap — no manual loadSessions needed.
|
|
|
|
React.useEffect(() => {
|
|
if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) {
|
|
return;
|
|
}
|
|
|
|
setHasShownInitialDirectoryPrompt(true);
|
|
|
|
setIsDirectoryDialogOpen(true);
|
|
}, [
|
|
hasShownInitialDirectoryPrompt,
|
|
isHomeReady,
|
|
projects.length,
|
|
]);
|
|
|
|
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());
|
|
}, []);
|
|
|
|
const deleteSessionsWithoutDialog = React.useCallback(async (payload: { sessions: Session[]; dateLabel?: string }) => {
|
|
if (payload.sessions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (payload.sessions.length === 1) {
|
|
const target = payload.sessions[0];
|
|
const success = await deleteSession(target.id);
|
|
if (success) {
|
|
toast.success(t('sessions.sidebar.session.delete.success'));
|
|
} else {
|
|
toast.error(t('sessions.sidebar.session.delete.error'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const ids = payload.sessions.map((session) => session.id);
|
|
const { deletedIds, failedIds } = await deleteSessions(ids);
|
|
|
|
if (deletedIds.length > 0) {
|
|
const successDescription = failedIds.length > 0
|
|
? (failedIds.length === 1
|
|
? t('sessions.sidebar.dialogs.deleteResult.singleFailedToDelete', { count: failedIds.length })
|
|
: t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length }))
|
|
: payload.dateLabel
|
|
? t('sessions.sidebar.dialogs.deleteResult.removedFromDate', { dateLabel: payload.dateLabel })
|
|
: undefined;
|
|
toast.success(deletedIds.length === 1
|
|
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
|
|
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }), {
|
|
description: renderToastDescription(successDescription),
|
|
});
|
|
}
|
|
|
|
if (failedIds.length > 0) {
|
|
toast.error(failedIds.length === 1
|
|
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
|
|
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }), {
|
|
description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')),
|
|
});
|
|
}
|
|
}, [deleteSession, deleteSessions, t]);
|
|
|
|
React.useEffect(() => {
|
|
return sessionEvents.onDeleteRequest((payload) => {
|
|
if (!showDeletionDialog && (payload.mode ?? 'session') === 'session') {
|
|
void deleteSessionsWithoutDialog(payload);
|
|
return;
|
|
}
|
|
openDeleteDialog(payload);
|
|
});
|
|
}, [openDeleteDialog, showDeletionDialog, deleteSessionsWithoutDialog]);
|
|
|
|
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<string, WorktreeMetadata>();
|
|
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<string, WorktreeMetadata['status']>();
|
|
const nextDirtyPaths = new Set<string>();
|
|
|
|
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<boolean> => {
|
|
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
|
const projectRef = getProjectRefForWorktree(worktree);
|
|
const normalizedWorktreePath = normalizeProjectDirectory(worktree.path);
|
|
const normalizedProjectPath = normalizeProjectDirectory(projectRef.path);
|
|
try {
|
|
await removeProjectWorktree(
|
|
projectRef,
|
|
worktree,
|
|
{ deleteRemoteBranch: shouldRemoveRemote, deleteLocalBranch }
|
|
);
|
|
|
|
const draftDirectory = normalizeProjectDirectory(newSessionDraft?.directoryOverride);
|
|
if (
|
|
newSessionDraft?.open
|
|
&& normalizedWorktreePath
|
|
&& draftDirectory === normalizedWorktreePath
|
|
&& normalizedProjectPath
|
|
) {
|
|
setDraftBootstrapPendingDirectory(null);
|
|
setNewSessionDraftTarget({
|
|
projectId: projectRef.id,
|
|
directoryOverride: normalizedProjectPath,
|
|
}, { force: true });
|
|
}
|
|
|
|
if (normalizeProjectDirectory(currentDirectory) === normalizedWorktreePath && normalizedProjectPath) {
|
|
useDirectoryStore.getState().setDirectory(normalizedProjectPath, { showOverlay: false });
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), {
|
|
description: renderToastDescription(error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain')),
|
|
});
|
|
return false;
|
|
}
|
|
}, [canRemoveRemoteBranches, currentDirectory, deleteDialogShouldRemoveRemote, getProjectRefForWorktree, newSessionDraft?.directoryOverride, newSessionDraft?.open, setDraftBootstrapPendingDirectory, setNewSessionDraftTarget, t]);
|
|
|
|
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
|
|
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
|
|
: t('sessions.sidebar.sessionDialogs.worktree.removed');
|
|
toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), {
|
|
description: renderToastDescription(archiveNote),
|
|
});
|
|
closeDeleteDialog();
|
|
return;
|
|
}
|
|
|
|
if (deleteDialog.sessions.length === 1) {
|
|
const target = deleteDialog.sessions[0];
|
|
const success = isWorktreeDelete
|
|
? await archiveSession(target.id)
|
|
: 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: false,
|
|
deleteRemoteBranch: removeRemoteBranch,
|
|
deleteLocalBranch,
|
|
});
|
|
if (!success) {
|
|
toast.error(isWorktreeDelete
|
|
? t('sessions.sidebar.session.archive.error')
|
|
: t('sessions.sidebar.session.delete.error'));
|
|
setIsProcessingDelete(false);
|
|
return;
|
|
}
|
|
const archiveNote = !isWorktreeDelete && shouldArchive
|
|
? removeRemoteBranch
|
|
? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote')
|
|
: t('sessions.sidebar.sessionDialogs.worktree.attachedArchived')
|
|
: undefined;
|
|
toast.success(isWorktreeDelete
|
|
? t('sessions.sidebar.session.archive.success')
|
|
: t('sessions.sidebar.session.delete.success'), {
|
|
description: renderToastDescription(archiveNote),
|
|
action: {
|
|
label: t('sessions.sidebar.sessionDialogs.ok'),
|
|
onClick: () => { },
|
|
},
|
|
});
|
|
} else {
|
|
const ids = deleteDialog.sessions.map((session) => session.id);
|
|
let deletedIds: string[] = [];
|
|
let failedIds: string[] = [];
|
|
if (isWorktreeDelete) {
|
|
const result = await archiveSessions(ids);
|
|
deletedIds = result.archivedIds;
|
|
failedIds = result.failedIds;
|
|
} else {
|
|
const result = await deleteSessions(ids);
|
|
deletedIds = result.deletedIds;
|
|
failedIds = result.failedIds;
|
|
}
|
|
|
|
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.
|
|
await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
|
// sync handles session refresh automatically
|
|
}
|
|
|
|
if (deletedIds.length > 0) {
|
|
const archiveNote = !isWorktreeDelete && shouldArchive
|
|
? removeRemoteBranch
|
|
? t('sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved')
|
|
: t('sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural')
|
|
: undefined;
|
|
const successDescription =
|
|
failedIds.length > 0
|
|
? (isWorktreeDelete
|
|
? (failedIds.length === 1
|
|
? t('sessions.sidebar.dialogs.deleteResult.singleFailedToArchive', { count: failedIds.length })
|
|
: t('sessions.sidebar.dialogs.deleteResult.manyFailedToArchive', { count: failedIds.length }))
|
|
: (failedIds.length === 1
|
|
? t('sessions.sidebar.dialogs.deleteResult.singleFailedToDelete', { count: failedIds.length })
|
|
: t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length })))
|
|
: deleteDialog.dateLabel
|
|
? t('sessions.sidebar.dialogs.deleteResult.removedFromDate', { dateLabel: deleteDialog.dateLabel })
|
|
: undefined;
|
|
const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' ');
|
|
toast.success(isWorktreeDelete
|
|
? (deletedIds.length === 1
|
|
? t('sessions.sidebar.bulkActions.archivedSingle', { count: deletedIds.length })
|
|
: t('sessions.sidebar.bulkActions.archivedPlural', { count: deletedIds.length }))
|
|
: (deletedIds.length === 1
|
|
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
|
|
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length })), {
|
|
description: renderToastDescription(combinedDescription || undefined),
|
|
action: {
|
|
label: t('sessions.sidebar.sessionDialogs.ok'),
|
|
onClick: () => { },
|
|
},
|
|
});
|
|
}
|
|
|
|
if (failedIds.length > 0) {
|
|
toast.error(isWorktreeDelete
|
|
? (failedIds.length === 1
|
|
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
|
|
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }))
|
|
: (failedIds.length === 1
|
|
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
|
|
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length })), {
|
|
description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')),
|
|
});
|
|
if (deletedIds.length === 0) {
|
|
setIsProcessingDelete(false);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) {
|
|
await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
|
|
// sync bootstrap refreshes sessions automatically
|
|
}
|
|
|
|
closeDeleteDialog();
|
|
} finally {
|
|
setIsProcessingDelete(false);
|
|
}
|
|
}, [
|
|
deleteDialog,
|
|
deleteDialogShouldRemoveRemote,
|
|
deleteDialogShouldDeleteLocalBranch,
|
|
deleteSession,
|
|
deleteSessions,
|
|
archiveSession,
|
|
archiveSessions,
|
|
closeDeleteDialog,
|
|
shouldArchiveWorktree,
|
|
isWorktreeDelete,
|
|
canRemoveRemoteBranches,
|
|
removeSelectedWorktree,
|
|
t,
|
|
]);
|
|
|
|
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
|
|
const deleteDialogDescription = deleteDialog
|
|
? deleteDialog.mode === 'worktree'
|
|
? deleteDialog.sessions.length === 0
|
|
? t('sessions.sidebar.dialogs.worktreeDelete.descriptionNoLinked')
|
|
: (deleteDialog.sessions.length === 1
|
|
? t('sessions.sidebar.dialogs.worktreeDelete.descriptionOneLinked', { count: deleteDialog.sessions.length })
|
|
: t('sessions.sidebar.dialogs.worktreeDelete.descriptionManyLinked', { count: deleteDialog.sessions.length }))
|
|
: (deleteDialog.sessions.length === 1
|
|
? (deleteDialog.dateLabel
|
|
? t('sessions.sidebar.dialogs.sessionDelete.descriptionOneWithDate', {
|
|
dateLabel: deleteDialog.dateLabel,
|
|
})
|
|
: t('sessions.sidebar.dialogs.sessionDelete.descriptionOne'))
|
|
: (deleteDialog.dateLabel
|
|
? t('sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate', {
|
|
count: deleteDialog.sessions.length,
|
|
dateLabel: deleteDialog.dateLabel,
|
|
})
|
|
: t('sessions.sidebar.dialogs.sessionDelete.descriptionMany', {
|
|
count: deleteDialog.sessions.length,
|
|
})))
|
|
: '';
|
|
|
|
const deleteDialogBody = deleteDialog ? (
|
|
<div className={cn(isWorktreeDelete ? 'space-y-3' : 'space-y-2')}>
|
|
{deleteDialog.sessions.length > 0 && (
|
|
<div className={cn(
|
|
isWorktreeDelete ? 'rounded-lg bg-muted/30 p-3' : 'space-y-1.5 rounded-xl border border-border/40 bg-sidebar/60 p-3'
|
|
)}>
|
|
{isWorktreeDelete && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="typography-meta font-medium text-foreground">
|
|
{deleteDialog.sessions.length === 1
|
|
? t('sessions.sidebar.sessionDialogs.linkedSessionSingle')
|
|
: t('sessions.sidebar.sessionDialogs.linkedSessionPlural')}
|
|
</span>
|
|
<span className="typography-micro text-muted-foreground/70">
|
|
{deleteDialog.sessions.length}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<ul className={cn(isWorktreeDelete ? 'mt-2 space-y-1' : 'space-y-0.5')}>
|
|
{deleteDialog.sessions.slice(0, 5).map((session) => (
|
|
<li
|
|
key={session.id}
|
|
className={cn(
|
|
isWorktreeDelete
|
|
? 'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-muted-foreground'
|
|
: 'typography-micro text-muted-foreground/80'
|
|
)}
|
|
>
|
|
<span className={cn(!isWorktreeDelete && 'hidden')}>
|
|
•
|
|
</span>
|
|
<span className="truncate">
|
|
{session.title || t('sessions.sidebar.session.untitled')}
|
|
</span>
|
|
</li>
|
|
))}
|
|
{deleteDialog.sessions.length > 5 && (
|
|
<li className={cn(
|
|
isWorktreeDelete
|
|
? 'px-2.5 py-1 text-xs text-muted-foreground/70'
|
|
: 'typography-micro text-muted-foreground/70'
|
|
)}>
|
|
{t('sessions.sidebar.dialogs.sessionList.more', { count: deleteDialog.sessions.length - 5 })}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{isWorktreeDelete ? (
|
|
<div className="space-y-2 rounded-lg bg-muted/30 p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground" />
|
|
<span className="typography-meta font-medium text-foreground">{t('sessions.sidebar.sessionDialogs.worktree.label')}</span>
|
|
{targetWorktree?.label ? (
|
|
<span className="typography-micro text-muted-foreground/70">{targetWorktree.label}</span>
|
|
) : null}
|
|
</div>
|
|
<p className="typography-micro text-muted-foreground/80 break-all">
|
|
{targetWorktree
|
|
? formatPathForDisplay(targetWorktree.path, homeDirectory)
|
|
: t('sessions.sidebar.sessionDialogs.worktree.pathUnavailable')}
|
|
</p>
|
|
{hasDirtyWorktrees && (
|
|
<p className="typography-micro text-status-warning">{t('sessions.sidebar.sessionDialogs.worktree.uncommittedWarning')}</p>
|
|
)}
|
|
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-border/40 bg-sidebar/60 p-3">
|
|
<p className="typography-meta text-muted-foreground/80">
|
|
{t('sessions.sidebar.sessionDialogs.delete.note')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
const deleteRemoteBranchAction = isWorktreeDelete ? (
|
|
canRemoveRemoteBranches ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (removeRemoteOptionDisabled) {
|
|
return;
|
|
}
|
|
setDeleteDialogShouldRemoveRemote((prev) => !prev);
|
|
}}
|
|
disabled={removeRemoteOptionDisabled}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors',
|
|
removeRemoteOptionDisabled
|
|
? 'cursor-not-allowed opacity-60'
|
|
: 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
|
)}
|
|
>
|
|
{deleteDialogShouldRemoveRemote ? (
|
|
<Icon name="checkbox" className="size-4 text-primary" />
|
|
) : (
|
|
<Icon name="checkbox-blank" className="size-4" />
|
|
)}
|
|
{t('sessions.sidebar.sessionDialogs.actions.deleteRemoteBranch')}
|
|
</button>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground/70">{t('sessions.sidebar.sessionDialogs.actions.remoteBranchInfoUnavailable')}</span>
|
|
)
|
|
) : null;
|
|
|
|
const deleteLocalBranchAction = isWorktreeDelete ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (deleteLocalOptionDisabled) {
|
|
return;
|
|
}
|
|
setDeleteDialogShouldDeleteLocalBranch((prev) => !prev);
|
|
}}
|
|
disabled={deleteLocalOptionDisabled}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors',
|
|
deleteLocalOptionDisabled
|
|
? 'cursor-not-allowed opacity-60'
|
|
: 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
|
)}
|
|
>
|
|
{deleteDialogShouldDeleteLocalBranch ? (
|
|
<Icon name="checkbox" className="size-4 text-primary" />
|
|
) : (
|
|
<Icon name="checkbox-blank" className="size-4" />
|
|
)}
|
|
{t('sessions.sidebar.sessionDialogs.actions.deleteLocalBranch')}
|
|
</button>
|
|
) : null;
|
|
|
|
const deleteDialogActions = isWorktreeDelete ? (
|
|
<div className="flex w-full items-center justify-between gap-3">
|
|
<div className="flex flex-col items-start gap-1">
|
|
{deleteLocalBranchAction}
|
|
{deleteRemoteBranchAction}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
|
|
{t('sessions.sidebar.dialogs.cancel')}
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
|
|
{isProcessingDelete
|
|
? t('sessions.sidebar.sessionDialogs.actions.deleting')
|
|
: t('sessions.sidebar.sessionDialogs.actions.deleteWorktree')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex w-full items-center justify-between gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
|
|
className="inline-flex items-center gap-1.5 typography-meta text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
|
aria-pressed={!showDeletionDialog}
|
|
>
|
|
{!showDeletionDialog ? <Icon name="checkbox" className="size-4 text-primary" /> : <Icon name="checkbox-blank" className="size-4" />}
|
|
{t('sessions.sidebar.dialogs.neverAsk')}
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
|
|
{t('sessions.sidebar.dialogs.cancel')}
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
|
|
{isProcessingDelete
|
|
? t('sessions.sidebar.sessionDialogs.actions.deleting')
|
|
: deleteDialog?.sessions.length === 1
|
|
? t('sessions.sidebar.dialogs.deleteSession.titleAction')
|
|
: t('sessions.sidebar.dialogs.deleteSessions.titleAction')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const deleteDialogTitle = isWorktreeDelete
|
|
? t('sessions.sidebar.sessionDialogs.actions.deleteWorktree')
|
|
: deleteDialog?.sessions.length === 1
|
|
? t('sessions.sidebar.dialogs.deleteSession.titleAction')
|
|
: t('sessions.sidebar.dialogs.deleteSessions.titleAction');
|
|
|
|
return (
|
|
<>
|
|
{useMobileOverlay ? (
|
|
<MobileOverlayPanel
|
|
open={Boolean(deleteDialog)}
|
|
onClose={() => {
|
|
if (isProcessingDelete) {
|
|
return;
|
|
}
|
|
closeDeleteDialog();
|
|
}}
|
|
title={deleteDialogTitle}
|
|
footer={<div className="flex justify-end gap-2">{deleteDialogActions}</div>}
|
|
>
|
|
<div className="space-y-2 pb-2">
|
|
{deleteDialogDescription && (
|
|
<p className="typography-meta text-muted-foreground/80">{deleteDialogDescription}</p>
|
|
)}
|
|
{deleteDialogBody}
|
|
</div>
|
|
</MobileOverlayPanel>
|
|
) : (
|
|
<Dialog
|
|
open={Boolean(deleteDialog)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
if (isProcessingDelete) {
|
|
return;
|
|
}
|
|
closeDeleteDialog();
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className={cn(
|
|
isWorktreeDelete
|
|
? 'max-w-xl max-h-[70vh] flex flex-col overflow-hidden gap-3'
|
|
: 'max-w-[min(520px,100vw-2rem)] space-y-2 pb-2'
|
|
)}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className={cn(isWorktreeDelete && 'flex items-center gap-2')}>
|
|
{isWorktreeDelete && <Icon name="delete-bin" className="h-5 w-5" />}
|
|
{deleteDialogTitle}
|
|
</DialogTitle>
|
|
{deleteDialogDescription && <DialogDescription>{deleteDialogDescription}</DialogDescription>}
|
|
</DialogHeader>
|
|
<div className={cn(isWorktreeDelete && 'flex-1 min-h-0 overflow-y-auto space-y-2')}>
|
|
{deleteDialogBody}
|
|
</div>
|
|
<DialogFooter className="mt-2 gap-2 pt-1 pb-1">{deleteDialogActions}</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
<DirectoryExplorerDialog
|
|
open={isDirectoryDialogOpen}
|
|
onOpenChange={setIsDirectoryDialogOpen}
|
|
/>
|
|
</>
|
|
);
|
|
};
|