diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index 87f74c43..5683d46e 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -53,6 +53,7 @@ interface FolderCardProps { onOpenFolder: (folderId: string) => void; onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; + onDeleteFolderWithContents?: (folderId: string) => void; // Callback to delete folder and all items inside onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move folder to another location } @@ -70,10 +71,12 @@ function FolderCardComponent({ onOpenFolder, onUpdateItem, onDeleteItem, + onDeleteFolderWithContents, onMoveItem, }: FolderCardProps) { const [showColorPicker, setShowColorPicker] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteOption, setDeleteOption] = useState<'keep' | 'delete' | null>(null); const [showMoveDialog, setShowMoveDialog] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDragHover, setIsDragHover] = useState(false); @@ -210,9 +213,21 @@ function FolderCardComponent({ }, [item.id, onUpdateItem]); const handleDelete = useCallback(() => { - onDeleteItem(item.id); + if (deleteOption === 'delete' && onDeleteFolderWithContents) { + onDeleteFolderWithContents(item.id); + } else { + onDeleteItem(item.id); + } setShowDeleteConfirm(false); - }, [item.id, onDeleteItem]); + setDeleteOption(null); + }, [item.id, onDeleteItem, onDeleteFolderWithContents, deleteOption]); + + // Reset delete option when dialog closes + useEffect(() => { + if (!showDeleteConfirm) { + setDeleteOption(null); + } + }, [showDeleteConfirm]); // Calculate colors using the same utilities as WorkspaceCard const bodyBgColor = getCardColorCSS(folderColor, 0.4); // Body is more transparent @@ -436,14 +451,57 @@ function FolderCardComponent({ > Delete Folder - - Are you sure you want to delete "{item.name}"? Items in this - folder will be moved out, but not deleted. + +
+
+ Choose what happens to the {itemCount} {itemCount === 1 ? 'item' : 'items'} in "{item.name}": +
+
+ + +
+
e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setDeleteOption(null); + }} onMouseDown={(e) => e.stopPropagation()} > Cancel @@ -454,7 +512,8 @@ function FolderCardComponent({ handleDelete(); }} onMouseDown={(e) => e.stopPropagation()} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + disabled={deleteOption === null} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 disabled:cursor-not-allowed" > Delete diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index c2dd612f..7702d8a2 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -32,6 +32,7 @@ interface WorkspaceContentProps { onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move item to folder onMoveItems?: (itemIds: string[], folderId: string | null) => void; // Callback to move multiple items to folder (bulk move) onOpenFolder?: (folderId: string) => void; // Callback when folder is clicked + onDeleteFolderWithContents?: (folderId: string) => void; // Callback to delete folder and all items inside onPDFUpload?: (files: File[]) => Promise; // Function to handle PDF upload } @@ -55,6 +56,7 @@ export default function WorkspaceContent({ onMoveItem, onMoveItems, onOpenFolder, + onDeleteFolderWithContents, onPDFUpload, }: WorkspaceContentProps) { // Use external ref if provided (from dashboard page), otherwise create local one @@ -406,6 +408,7 @@ export default function WorkspaceContent({ onMoveItem={onMoveItem} onMoveItems={onMoveItems} onOpenFolder={handleOpenFolder} + onDeleteFolderWithContents={onDeleteFolderWithContents} addItem={addItem} onPDFUpload={onPDFUpload} setOpenModalItemId={setOpenModalItemId} diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index 235b2ec3..f67e5cb1 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -30,6 +30,7 @@ interface WorkspaceGridProps { onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move item to folder onMoveItems?: (itemIds: string[], folderId: string | null) => void; // Callback to move multiple items to folder (bulk move) onOpenFolder?: (folderId: string) => void; // Callback when folder is clicked + onDeleteFolderWithContents?: (folderId: string) => void; // Callback to delete folder and all items inside addItem?: (type: CardType, name?: string, initialData?: Partial) => string | void; // Function to add new items onPDFUpload?: (files: File[]) => Promise; // Function to handle PDF upload setOpenModalItemId?: (id: string | null) => void; // Function to open modal for newly created items @@ -59,6 +60,7 @@ export function WorkspaceGrid({ onMoveItem, onMoveItems, onOpenFolder, + onDeleteFolderWithContents, addItem, onPDFUpload, setOpenModalItemId, @@ -531,6 +533,7 @@ export function WorkspaceGrid({ onOpenFolder={handleOpenFolder} onUpdateItem={handleUpdateItem} onDeleteItem={handleDeleteItem} + onDeleteFolderWithContents={onDeleteFolderWithContents} onMoveItem={onMoveItem} /> ) : item.type === 'flashcard' ? ( @@ -570,6 +573,7 @@ export function WorkspaceGrid({ handleOpenModal, existingColors, onMoveItem, + onDeleteFolderWithContents, handleOpenFolder, folderItemCounts, workspaceName, diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index a2f7a30c..05432f7e 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -501,6 +501,7 @@ export function WorkspaceSection({ workspaceColor={workspaceColor} onMoveItem={operations?.moveItemToFolder} onMoveItems={operations?.moveItemsToFolder} + onDeleteFolderWithContents={operations?.deleteFolderWithContents} onPDFUpload={handlePDFUpload} /> )} diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index fd96e654..2080721b 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -33,6 +33,7 @@ export interface WorkspaceOperations { createFolderWithItems: (name: string, itemIds: string[], color?: CardColor) => string; updateFolder: (folderId: string, changes: Partial) => void; deleteFolder: (folderId: string) => void; + deleteFolderWithContents: (folderId: string) => void; moveItemToFolder: (itemId: string, folderId: string | null) => void; moveItemsToFolder: (itemIds: string[], folderId: string | null) => void; isPending: boolean; @@ -484,6 +485,87 @@ export function useWorkspaceOperations( [deleteItem, currentState.items] ); + // Helper to recursively find all descendant IDs (items in folder and nested subfolders) + const getAllDescendantIds = useCallback( + (folderId: string, items: Item[]): string[] => { + const directChildren = items.filter(item => item.folderId === folderId); + const descendantIds: string[] = []; + + for (const child of directChildren) { + descendantIds.push(child.id); + // Recursively get descendants of nested folders + if (child.type === 'folder') { + descendantIds.push(...getAllDescendantIds(child.id, items)); + } + } + + return descendantIds; + }, + [] + ); + + // deleteFolderWithContents deletes the folder and all items inside it (including nested) + // Uses atomic bulk update pattern (same as handleBulkDelete in WorkspaceSection) + const deleteFolderWithContents = useCallback( + (folderId: string) => { + // CRITICAL: Read latest state from cache to avoid stale data issues + // (same pattern as updateAllItems - currentState prop can be stale) + let latestItems: Item[]; + if (workspaceId) { + const cacheData = queryClient.getQueryData([ + "workspace", + workspaceId, + "events", + ]); + if (cacheData?.events) { + const latestState = replayEvents(cacheData.events, workspaceId, cacheData.snapshot?.state); + latestItems = latestState.items; + } else { + latestItems = currentState.items; + } + } else { + latestItems = currentState.items; + } + + const folder = latestItems.find(i => i.id === folderId && i.type === 'folder'); + logger.debug("📁 [FOLDER-DELETE-WITH-CONTENTS] Deleting folder and contents:", { folderId, folderName: folder?.name }); + + // Find all descendant items recursively (handles nested folders) + const allDescendantIds = getAllDescendantIds(folderId, latestItems); + + // Create set of all IDs to delete (descendants + folder itself) + const idsToDelete = new Set([...allDescendantIds, folderId]); + const itemCount = allDescendantIds.length; + + logger.debug("📁 [FOLDER-DELETE-WITH-CONTENTS] Found items to delete:", { itemCount, itemIds: [...idsToDelete] }); + + // Delete PDF files from storage (fire-and-forget, non-blocking) + // This is best-effort cleanup - files may become orphaned if this fails + const itemsToDelete = latestItems.filter(item => idsToDelete.has(item.id)); + for (const item of itemsToDelete) { + if (item.type === 'pdf') { + const pdfData = item.data as { fileUrl?: string }; + if (pdfData?.fileUrl) { + fetch(`/api/delete-file?url=${encodeURIComponent(pdfData.fileUrl)}`, { + method: 'DELETE', + }).catch(err => logger.warn("📁 [FOLDER-DELETE-WITH-CONTENTS] Failed to delete PDF file:", err)); + } + } + } + + // Atomic bulk delete using updateAllItems pattern (single BULK_ITEMS_UPDATED event) + const remainingItems = latestItems.filter(item => !idsToDelete.has(item.id)); + updateAllItems(remainingItems); + + toast.success( + folder + ? `Folder "${folder.name}" and ${itemCount} ${itemCount === 1 ? 'item' : 'items'} deleted` + : `Folder and ${itemCount} ${itemCount === 1 ? 'item' : 'items'} deleted` + ); + }, + [workspaceId, queryClient, currentState.items, getAllDescendantIds, updateAllItems] + ); + const moveItemToFolder = useCallback( (itemId: string, folderId: string | null) => { logger.debug("📁 [ITEM-MOVE] Moving item to folder:", { itemId, folderId }); @@ -566,6 +648,7 @@ export function useWorkspaceOperations( createFolderWithItems, updateFolder, deleteFolder, + deleteFolderWithContents, moveItemToFolder, moveItemsToFolder, isPending: mutation.isPending, diff --git a/src/lib/workspace/event-reducer.ts b/src/lib/workspace/event-reducer.ts index 1c48373a..8b22ef33 100644 --- a/src/lib/workspace/event-reducer.ts +++ b/src/lib/workspace/event-reducer.ts @@ -74,14 +74,34 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta workspaceId: state.workspaceId, // Preserve workspace ID }; - case 'BULK_ITEMS_UPDATED': - // Used for layout changes (drag/resize) and reordering + case 'BULK_ITEMS_UPDATED': { + // Used for layout changes (drag/resize), reordering, and bulk delete // Support both new format (layoutUpdates only) and legacy format (full items array) if (event.payload.items) { - // Legacy format: full items array (for backwards compatibility) + // Full items array format (used for bulk delete and item add/remove operations) + const newItems = event.payload.items; + + // Find folders that were deleted (existed in old state but not in new state) + const newItemIds = new Set(newItems.map(item => item.id)); + const deletedFolderIds = new Set( + state.items + .filter(item => item.type === 'folder' && !newItemIds.has(item.id)) + .map(item => item.id) + ); + + // If any folders were deleted, clear folderId on orphaned items + // This prevents items from pointing to non-existent folders + const cleanedItems = deletedFolderIds.size > 0 + ? newItems.map(item => + item.folderId && deletedFolderIds.has(item.folderId) + ? { ...item, folderId: undefined } + : item + ) + : newItems; + return { ...state, - items: event.payload.items, + items: cleanedItems, }; } else { // New format: only layout changes - apply to existing items @@ -107,6 +127,7 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta }), }; } + } case 'BULK_ITEMS_CREATED': // Create multiple items atomically in a single event