diff --git a/frontend/src/components/__tests__/FolderManagementCard.test.tsx b/frontend/src/components/__tests__/FolderManagementCard.test.tsx
new file mode 100644
index 000000000..d9640d562
--- /dev/null
+++ b/frontend/src/components/__tests__/FolderManagementCard.test.tsx
@@ -0,0 +1,243 @@
+import { render, screen } from '@/test-utils';
+import userEvent from '@testing-library/user-event';
+import FolderManagementCard from '@/pages/SettingsPage/components/FolderManagementCard';
+import { FolderDetails } from '@/types/Folder';
+
+const mockDeleteFolders = jest.fn();
+const mockToggleAITagging = jest.fn();
+
+const mockMakeFolder = (id: string, path: string): FolderDetails => ({
+ folder_id: id,
+ folder_path: path,
+ last_modified_time: 0,
+ AI_Tagging: false,
+ indexing_status: 'completed',
+});
+
+const mockFolders: FolderDetails[] = [
+ mockMakeFolder('folder-1', 'C:\\Users\\me\\Pictures\\Holiday'),
+ mockMakeFolder('folder-2', 'C:\\Users\\me\\Pictures\\Screenshots'),
+ mockMakeFolder('folder-3', 'C:\\Users\\me\\Pictures\\Camera'),
+];
+
+jest.mock('@/hooks/useFolderOperations', () => ({
+ useFolderOperations: () => ({
+ folders: mockFolders,
+ toggleAITagging: mockToggleAITagging,
+ deleteFolders: mockDeleteFolders,
+ enableAITaggingPending: false,
+ disableAITaggingPending: false,
+ deleteFoldersPending: false,
+ }),
+}));
+
+jest.mock('@/hooks/useLibraryProcessingStatus', () => ({
+ useLibraryProcessingStatus: () => ({ semanticAvailable: true }),
+}));
+
+jest.mock('@/components/FolderPicker/FolderPicker', () => ({
+ __esModule: true,
+ default: () =>
,
+}));
+
+const SINGLE_TITLE = 'Delete this folder?';
+
+const setup = () => {
+ const user = userEvent.setup();
+ render( );
+ return { user };
+};
+
+const deleteButtonFor = (folder: FolderDetails) =>
+ screen.getByRole('button', { name: `Delete folder ${folder.folder_path}` });
+
+const checkboxFor = (folder: FolderDetails) =>
+ screen.getByRole('checkbox', { name: `Select folder ${folder.folder_path}` });
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('FolderManagementCard - single folder deletion', () => {
+ test('no confirmation is shown until a delete button is clicked', () => {
+ setup();
+
+ expect(screen.queryByText(SINGLE_TITLE)).not.toBeInTheDocument();
+ });
+
+ test('clicking delete asks for confirmation instead of deleting straight away', async () => {
+ const { user } = setup();
+
+ await user.click(deleteButtonFor(mockFolders[0]));
+
+ expect(screen.getByText(SINGLE_TITLE)).toBeInTheDocument();
+ expect(mockDeleteFolders).not.toHaveBeenCalled();
+ });
+
+ test('the confirmation names the folder and warns that it cannot be undone', async () => {
+ const { user } = setup();
+
+ await user.click(deleteButtonFor(mockFolders[0]));
+
+ const description = screen.getByText(/cannot be undone/i);
+ expect(description).toHaveTextContent(mockFolders[0].folder_path);
+ expect(description).toHaveTextContent(/stay on your disk/i);
+ });
+
+ test('cancelling closes the confirmation and deletes nothing', async () => {
+ const { user } = setup();
+
+ await user.click(deleteButtonFor(mockFolders[0]));
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+ expect(mockDeleteFolders).not.toHaveBeenCalled();
+ expect(screen.queryByText(SINGLE_TITLE)).not.toBeInTheDocument();
+ });
+
+ test('confirming deletes the folder and closes the confirmation', async () => {
+ const { user } = setup();
+
+ await user.click(deleteButtonFor(mockFolders[0]));
+ await user.click(screen.getByRole('button', { name: /^delete folder$/i }));
+
+ expect(mockDeleteFolders).toHaveBeenCalledTimes(1);
+ expect(mockDeleteFolders).toHaveBeenCalledWith([mockFolders[0].folder_id]);
+ expect(screen.queryByText(SINGLE_TITLE)).not.toBeInTheDocument();
+ });
+
+ test('confirming deletes the folder whose delete button was clicked', async () => {
+ const { user } = setup();
+
+ await user.click(deleteButtonFor(mockFolders[1]));
+ await user.click(screen.getByRole('button', { name: /^delete folder$/i }));
+
+ expect(mockDeleteFolders).toHaveBeenCalledWith([mockFolders[1].folder_id]);
+ });
+
+ test('the confirmation can be reopened after cancelling', async () => {
+ const { user } = setup();
+
+ await user.click(deleteButtonFor(mockFolders[0]));
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+ await user.click(deleteButtonFor(mockFolders[0]));
+
+ expect(screen.getByText(SINGLE_TITLE)).toBeInTheDocument();
+ expect(mockDeleteFolders).not.toHaveBeenCalled();
+ });
+});
+
+describe('FolderManagementCard - bulk deletion', () => {
+ test('the bulk delete button only appears once something is selected', async () => {
+ const { user } = setup();
+
+ expect(
+ screen.queryByRole('button', { name: /delete selected/i }),
+ ).not.toBeInTheDocument();
+
+ await user.click(checkboxFor(mockFolders[0]));
+
+ expect(
+ screen.getByRole('button', { name: /delete selected \(1\)/i }),
+ ).toBeInTheDocument();
+ });
+
+ test('the bulk delete button counts the selected folders', async () => {
+ const { user } = setup();
+
+ await user.click(checkboxFor(mockFolders[0]));
+ await user.click(checkboxFor(mockFolders[2]));
+
+ expect(
+ screen.getByRole('button', { name: /delete selected \(2\)/i }),
+ ).toBeInTheDocument();
+ });
+
+ test('unselecting a folder updates the count and hides the button at zero', async () => {
+ const { user } = setup();
+
+ await user.click(checkboxFor(mockFolders[0]));
+ await user.click(checkboxFor(mockFolders[0]));
+
+ expect(
+ screen.queryByRole('button', { name: /delete selected/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test('bulk deletion asks for one confirmation naming the count', async () => {
+ const { user } = setup();
+
+ await user.click(checkboxFor(mockFolders[0]));
+ await user.click(checkboxFor(mockFolders[1]));
+ await user.click(
+ screen.getByRole('button', { name: /delete selected \(2\)/i }),
+ );
+
+ expect(screen.getByText('Delete 2 folders?')).toBeInTheDocument();
+ expect(screen.getByText(/cannot be undone/i)).toHaveTextContent(
+ '2 folders will be removed',
+ );
+ expect(mockDeleteFolders).not.toHaveBeenCalled();
+ });
+
+ test('confirming a bulk deletion removes every selected folder in one call', async () => {
+ const { user } = setup();
+
+ await user.click(checkboxFor(mockFolders[0]));
+ await user.click(checkboxFor(mockFolders[2]));
+ await user.click(
+ screen.getByRole('button', { name: /delete selected \(2\)/i }),
+ );
+ await user.click(
+ screen.getByRole('button', { name: /^delete 2 folders$/i }),
+ );
+
+ expect(mockDeleteFolders).toHaveBeenCalledTimes(1);
+ expect(mockDeleteFolders).toHaveBeenCalledWith([
+ mockFolders[0].folder_id,
+ mockFolders[2].folder_id,
+ ]);
+ });
+
+ test('cancelling a bulk deletion keeps the selection and deletes nothing', async () => {
+ const { user } = setup();
+
+ await user.click(checkboxFor(mockFolders[0]));
+ await user.click(checkboxFor(mockFolders[1]));
+ await user.click(
+ screen.getByRole('button', { name: /delete selected \(2\)/i }),
+ );
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+ expect(mockDeleteFolders).not.toHaveBeenCalled();
+ expect(
+ screen.getByRole('button', { name: /delete selected \(2\)/i }),
+ ).toBeInTheDocument();
+ });
+
+ test('select all picks every folder, and unselects them again', async () => {
+ const { user } = setup();
+ const selectAll = screen.getByRole('checkbox', { name: /select all/i });
+
+ await user.click(selectAll);
+
+ expect(
+ screen.getByRole('button', { name: /delete selected \(3\)/i }),
+ ).toBeInTheDocument();
+
+ await user.click(selectAll);
+
+ expect(
+ screen.queryByRole('button', { name: /delete selected/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test('selecting every folder individually ticks the select all box', async () => {
+ const { user } = setup();
+
+ for (const folder of mockFolders) {
+ await user.click(checkboxFor(folder));
+ }
+
+ expect(screen.getByRole('checkbox', { name: /select all/i })).toBeChecked();
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useFolderOperations.test.tsx b/frontend/src/hooks/__tests__/useFolderOperations.test.tsx
index 80c101b23..2b49df009 100644
--- a/frontend/src/hooks/__tests__/useFolderOperations.test.tsx
+++ b/frontend/src/hooks/__tests__/useFolderOperations.test.tsx
@@ -56,7 +56,7 @@ describe('useFolderOperations - delete folder cache invalidation', () => {
deleteFolders.mockResolvedValueOnce({ success: true, data: {} });
const { result, invalidateSpy } = renderUseFolderOperations();
- result.current.deleteFolder('folder-1');
+ result.current.deleteFolders(['folder-1']);
await waitFor(() => {
expect(clustersKeyCalls(invalidateSpy).length).toBeGreaterThan(0);
@@ -70,7 +70,7 @@ describe('useFolderOperations - delete folder cache invalidation', () => {
deleteFolders.mockRejectedValue(new Error('delete failed'));
const { result, invalidateSpy } = renderUseFolderOperations();
- result.current.deleteFolder('folder-1');
+ result.current.deleteFolders(['folder-1']);
// autoInvalidateTags still fires ['folders'] on settle regardless of
// outcome, so wait for that instead of an arbitrary timeout to know the
diff --git a/frontend/src/hooks/useFolderOperations.tsx b/frontend/src/hooks/useFolderOperations.tsx
index cdd0f2c6a..61976382e 100644
--- a/frontend/src/hooks/useFolderOperations.tsx
+++ b/frontend/src/hooks/useFolderOperations.tsx
@@ -131,10 +131,9 @@ export const useFolderOperations = () => {
errorMessage: 'Failed to disable AI tagging. Please try again.',
});
- // Delete folder mutation
+ // Delete folders mutation - takes a list so one confirmation can remove a batch
const deleteFolderMutation = usePictoMutation({
- mutationFn: async (folder_id: string) =>
- deleteFolders({ folder_ids: [folder_id] }),
+ mutationFn: async (folder_ids: string[]) => deleteFolders({ folder_ids }),
autoInvalidateTags: ['folders'],
// Deleting a folder cascades to its images and faces, so any cluster built from
// them is now stale. This has to be a separate call: autoInvalidateTags is passed
@@ -145,15 +144,15 @@ export const useFolderOperations = () => {
},
});
- // Apply feedback to the delete folder mutation
+ // Apply feedback to the delete folder mutation.
+ // No success dialog here: the user has already confirmed the deletion and the
+ // folders disappear from the list, so a second modal only adds a click.
useMutationFeedback(deleteFolderMutation, {
showLoading: true,
- loadingMessage: 'Deleting folder',
- successTitle: 'Folder Deleted',
- successMessage:
- 'The folder has been successfully removed from your library.',
+ loadingMessage: 'Deleting folders',
+ showSuccess: false,
errorTitle: 'Delete Error',
- errorMessage: 'Failed to delete the folder. Please try again.',
+ errorMessage: 'Failed to delete the folders. Please try again.',
});
/**
@@ -168,10 +167,11 @@ export const useFolderOperations = () => {
};
/**
- * Delete a folder
+ * Delete one or more folders in a single request
*/
- const deleteFolder = (folderId: string) => {
- deleteFolderMutation.mutate(folderId);
+ const handleDeleteFolders = (folderIds: string[]) => {
+ if (folderIds.length === 0) return;
+ deleteFolderMutation.mutate(folderIds);
};
return {
@@ -181,12 +181,12 @@ export const useFolderOperations = () => {
// Operations
toggleAITagging,
- deleteFolder,
+ deleteFolders: handleDeleteFolders,
// Mutation states (for use in UI, e.g., disabling buttons)
enableAITaggingPending: enableAITaggingMutation.isPending,
disableAITaggingPending: disableAITaggingMutation.isPending,
- deleteFolderPending: deleteFolderMutation.isPending,
+ deleteFoldersPending: deleteFolderMutation.isPending,
};
};
diff --git a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx
index 25b836d93..5948c112d 100644
--- a/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx
+++ b/frontend/src/pages/SettingsPage/components/FolderManagementCard.tsx
@@ -22,6 +22,7 @@ import { Badge } from '@/components/ui/badge';
import { useFolderOperations } from '@/hooks/useFolderOperations';
import { useLibraryProcessingStatus } from '@/hooks/useLibraryProcessingStatus';
import { FolderDetails, isIndexingPending } from '@/types/Folder';
+import { ConfirmDialog } from '@/components/ConfirmDialog/ConfirmDialog';
import SettingsCard from './SettingsCard';
type TaggingStatus = RootState['folders']['taggingStatus'];
@@ -131,10 +132,10 @@ const FolderManagementCard: React.FC = () => {
const {
folders,
toggleAITagging,
- deleteFolder,
+ deleteFolders,
enableAITaggingPending,
disableAITaggingPending,
- deleteFolderPending,
+ deleteFoldersPending,
} = useFolderOperations();
const taggingStatus = useSelector(
@@ -147,6 +148,24 @@ const FolderManagementCard: React.FC = () => {
const [expandedFolders, setExpandedFolders] = useState>(
new Set(),
);
+ const [selectedFolderIds, setSelectedFolderIds] = useState>(
+ new Set(),
+ );
+ // Folders queued for deletion. Empty means the confirmation is closed.
+ const [foldersToDelete, setFoldersToDelete] = useState([]);
+
+ const visibleFolders = folders.slice(0, visibleFoldersCount);
+ const selectedFolders = folders.filter((folder: FolderDetails) =>
+ selectedFolderIds.has(folder.folder_id),
+ );
+ const allVisibleSelected =
+ visibleFolders.length > 0 &&
+ visibleFolders.every((folder: FolderDetails) =>
+ selectedFolderIds.has(folder.folder_id),
+ );
+ const someVisibleSelected = visibleFolders.some((folder: FolderDetails) =>
+ selectedFolderIds.has(folder.folder_id),
+ );
// --- NEW: Force data refresh when window regains focus or visibility ---
useEffect(() => {
@@ -188,6 +207,50 @@ const FolderManagementCard: React.FC = () => {
});
};
+ const toggleFolderSelection = (folderId: string) => {
+ setSelectedFolderIds((previous) => {
+ const next = new Set(previous);
+ if (next.has(folderId)) {
+ next.delete(folderId);
+ } else {
+ next.add(folderId);
+ }
+ return next;
+ });
+ };
+
+ // Only spans the folders currently on screen, so "View More" never pulls in
+ // folders the user has not seen.
+ const toggleSelectAllVisible = () => {
+ setSelectedFolderIds((previous) => {
+ const next = new Set(previous);
+ visibleFolders.forEach((folder: FolderDetails) => {
+ if (allVisibleSelected) {
+ next.delete(folder.folder_id);
+ } else {
+ next.add(folder.folder_id);
+ }
+ });
+ return next;
+ });
+ };
+
+ const confirmDeletion = () => {
+ deleteFolders(foldersToDelete.map((folder) => folder.folder_id));
+ setSelectedFolderIds((previous) => {
+ const next = new Set(previous);
+ foldersToDelete.forEach((folder) => next.delete(folder.folder_id));
+ return next;
+ });
+ };
+
+ const deletionDescription = () => {
+ if (foldersToDelete.length === 1) {
+ return `"${foldersToDelete[0].folder_path}" will be removed from your library along with its tags and indexing data. This cannot be undone, though the photos themselves stay on your disk.`;
+ }
+ return `${foldersToDelete.length} folders will be removed from your library along with their tags and indexing data. This cannot be undone, though the photos themselves stay on your disk.`;
+ };
+
return (
{
>
{folders.length > 0 ? (
- {folders
- .slice(0, visibleFoldersCount)
- .map((folder: FolderDetails) => (
-
-
-
-
-
-
- {folder.folder_path}
-
-
-
+
+
+ {
+ if (el) {
+ el.indeterminate =
+ someVisibleSelected && !allVisibleSelected;
+ }
+ }}
+ onChange={toggleSelectAllVisible}
+ className="border-border h-4 w-4 shrink-0 cursor-pointer rounded"
+ />
+ Select all shown
+
-
-
-
- AI Tagging
-
- toggleAITagging(folder)}
- disabled={
- enableAITaggingPending || disableAITaggingPending
- }
- />
-
+ {selectedFolders.length > 0 && (
+
setFoldersToDelete(selectedFolders)}
+ variant="outline"
+ size="sm"
+ className="cursor-pointer text-red-600 hover:border-red-300 hover:text-red-700 dark:text-red-400"
+ disabled={deleteFoldersPending}
+ >
+
+ Delete selected ({selectedFolders.length})
+
+ )}
+
-
deleteFolder(folder.folder_id)}
- variant="outline"
- size="sm"
- className="h-8 w-8 cursor-pointer text-gray-500 hover:border-red-300 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
- disabled={deleteFolderPending}
- >
-
-
+ {visibleFolders.map((folder: FolderDetails) => (
+
+
+
- {folder.AI_Tagging && (
-
- {isIndexingPending(folder.indexing_status) ? (
-
-
-
- Indexing Folder...
-
-
- ) : folder.indexing_status === 'interrupted' ? (
- // A previous session died mid-walk, so nothing is
- // running and the folder is only partly indexed.
-
-
-
- Indexing was interrupted - sync to finish
-
-
- ) : !folder.image_count && !folder.video_count ? (
-
- Folder is empty
-
- ) : (
-
- toggleFolderExpanded(folder.folder_id)
- }
- />
- )}
+
+
+
+ AI Tagging
+
+ toggleAITagging(folder)}
+ disabled={
+ enableAITaggingPending || disableAITaggingPending
+ }
+ />
- )}
+
+
setFoldersToDelete([folder])}
+ aria-label={`Delete folder ${folder.folder_path}`}
+ variant="outline"
+ size="sm"
+ className="h-8 w-8 cursor-pointer text-gray-500 hover:border-red-300 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
+ disabled={deleteFoldersPending}
+ >
+
+
+
- ))}
+
+ {folder.AI_Tagging && (
+
+ {isIndexingPending(folder.indexing_status) ? (
+
+
+
+ Indexing Folder...
+
+
+ ) : folder.indexing_status === 'interrupted' ? (
+ // A previous session died mid-walk, so nothing is
+ // running and the folder is only partly indexed.
+
+
+
+ Indexing was interrupted - sync to finish
+
+
+ ) : !folder.image_count && !folder.video_count ? (
+
+ Folder is empty
+
+ ) : (
+
+ toggleFolderExpanded(folder.folder_id)
+ }
+ />
+ )}
+
+ )}
+
+ ))}
) : (
@@ -303,6 +403,26 @@ const FolderManagementCard: React.FC = () => {
+
+
0}
+ onOpenChange={(open) => {
+ if (!open) setFoldersToDelete([]);
+ }}
+ title={
+ foldersToDelete.length > 1
+ ? `Delete ${foldersToDelete.length} folders?`
+ : 'Delete this folder?'
+ }
+ description={foldersToDelete.length > 0 ? deletionDescription() : ''}
+ confirmLabel={
+ foldersToDelete.length > 1
+ ? `Delete ${foldersToDelete.length} Folders`
+ : 'Delete Folder'
+ }
+ destructive
+ onConfirm={confirmDeletion}
+ />
);
};