From 5df04c3caa54573723c0a0158cebf6e6a4d2647c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sun, 28 Dec 2025 15:57:18 +0200 Subject: [PATCH] feat(ui): add hierarchical Google Drive folder tree browser - Display folders and files with lazy loading - Show different icons for file types (docs, sheets, slides, etc) - Expandable folder tree with proper indentation - Selectable folders for indexing - Handle overflow with proper truncation - Full pagination support for large folder structures --- .../connectors/google-drive-folder-tree.tsx | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 surfsense_web/components/connectors/google-drive-folder-tree.tsx diff --git a/surfsense_web/components/connectors/google-drive-folder-tree.tsx b/surfsense_web/components/connectors/google-drive-folder-tree.tsx new file mode 100644 index 000000000..22ef97556 --- /dev/null +++ b/surfsense_web/components/connectors/google-drive-folder-tree.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { + ChevronDown, + ChevronRight, + File, + FileText, + Folder, + FolderOpen, + HardDrive, + Image, + Loader2, + Sheet, + Presentation, +} from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { authenticatedFetch } from "@/lib/auth-utils"; + +interface DriveItem { + id: string; + name: string; + mimeType: string; + isFolder: boolean; + parents?: string[]; + size?: number; + iconLink?: string; +} + +interface ItemTreeNode { + item: DriveItem; + children: DriveItem[] | null; // null = not loaded, [] = loaded but empty + isExpanded: boolean; + isLoading: boolean; +} + +interface GoogleDriveFolderTreeProps { + connectorId: number; + selectedFolderId: string | null; + onSelectFolder: (folderId: string, folderName: string) => void; +} + +// Helper to get appropriate icon for file type +function getFileIcon(mimeType: string, className: string = "h-4 w-4") { + if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) { + return ; + } + if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) { + return ; + } + if (mimeType.includes("document") || mimeType.includes("word") || mimeType.includes("text")) { + return ; + } + if (mimeType.includes("image")) { + return ; + } + return ; +} + +// Helper to format file size +function formatFileSize(bytes: number | undefined): string { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +export function GoogleDriveFolderTree({ + connectorId, + selectedFolderId, + onSelectFolder, +}: GoogleDriveFolderTreeProps) { + const [rootItems, setRootItems] = useState([]); + const [itemStates, setItemStates] = useState>(new Map()); + const [isLoadingRoot, setIsLoadingRoot] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + + // Load root items (folders and files) on mount + const loadRootItems = async () => { + if (isInitialized) return; // Already loaded + + setIsLoadingRoot(true); + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/connectors/${connectorId}/google-drive/folders` + ); + if (!response.ok) throw new Error("Failed to load items"); + + const data = await response.json(); + setRootItems(data.items || []); + setIsInitialized(true); + } catch (error) { + console.error("Error loading root items:", error); + } finally { + setIsLoadingRoot(false); + } + }; + + // Helper function to find an item recursively through all loaded items + const findItem = (itemId: string): DriveItem | undefined => { + // First check if we have it in itemStates + const state = itemStates.get(itemId); + if (state?.item) return state.item; + + // Check root items + const rootItem = rootItems.find((item) => item.id === itemId); + if (rootItem) return rootItem; + + // Recursively search through all loaded children + for (const [, nodeState] of itemStates) { + if (nodeState.children) { + const found = nodeState.children.find((child) => child.id === itemId); + if (found) return found; + } + } + + return undefined; + }; + + // Load children (folders and files) for a specific folder + const loadFolderContents = async (folderId: string) => { + try { + // Set loading state + setItemStates((prev) => { + const newMap = new Map(prev); + const existing = newMap.get(folderId); + if (existing) { + newMap.set(folderId, { ...existing, isLoading: true }); + } else { + // First time loading this folder - create initial state + const item = findItem(folderId); + if (item) { + newMap.set(folderId, { + item, + children: null, + isExpanded: false, + isLoading: true, + }); + } + } + return newMap; + }); + + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/connectors/${connectorId}/google-drive/folders?parent_id=${folderId}` + ); + if (!response.ok) throw new Error("Failed to load folder contents"); + + const data = await response.json(); + const items = data.items || []; + + // Check if folder only contains files (no subfolders) + const hasSubfolders = items.some((item: DriveItem) => item.isFolder); + + // Update item state with loaded children + setItemStates((prev) => { + const newMap = new Map(prev); + const existing = newMap.get(folderId); + const item = existing?.item || findItem(folderId); + + if (item) { + newMap.set(folderId, { + item, + children: items, + isExpanded: true, // Always expand after loading + isLoading: false, + }); + } else { + console.error(`Could not find item for folderId: ${folderId}`); + } + return newMap; + }); + } catch (error) { + console.error("Error loading folder contents:", error); + // Clear loading state on error + setItemStates((prev) => { + const newMap = new Map(prev); + const existing = newMap.get(folderId); + if (existing) { + newMap.set(folderId, { ...existing, isLoading: false }); + } + return newMap; + }); + } + }; + + // Toggle folder expansion + const toggleFolder = async (item: DriveItem) => { + if (!item.isFolder) return; // Only folders can be expanded + + const state = itemStates.get(item.id); + + if (!state || state.children === null) { + // First time expanding - load children + await loadFolderContents(item.id); + } else { + // Toggle expansion state + setItemStates((prev) => { + const newMap = new Map(prev); + newMap.set(item.id, { + ...state, + isExpanded: !state.isExpanded, + }); + return newMap; + }); + } + }; + + // Recursive render function for item tree + const renderItem = (item: DriveItem, level: number = 0) => { + const state = itemStates.get(item.id); + const isExpanded = state?.isExpanded || false; + const isLoading = state?.isLoading || false; + const children = state?.children; + const isSelected = selectedFolderId === item.id; + const isFolder = item.isFolder; + + // Separate folders and files for children + const childFolders = children?.filter((c) => c.isFolder) || []; + const childFiles = children?.filter((c) => !c.isFolder) || []; + + return ( +
+ + + {/* Render children if expanded (folders first, then files) */} + {isExpanded && isFolder && children && ( +
+ {/* Render folders first */} + {childFolders.map((child) => renderItem(child, level + 1))} + + {/* Render files */} + {childFiles.map((child) => renderItem(child, level + 1))} + + {/* Empty state */} + {children.length === 0 && ( +
+ Empty folder +
+ )} +
+ )} +
+ ); + }; + + // Initialize on first render + if (!isInitialized && !isLoadingRoot) { + loadRootItems(); + } + + return ( +
+ +
+ {/* My Drive Header (always visible, selectable) */} +
+ +
+ + {/* Loading indicator */} + {isLoadingRoot && ( +
+ +
+ )} + + {/* Root items (folders and files) - same level as Google Drive shows */} +
+ {!isLoadingRoot && rootItems.map((item) => renderItem(item, 0))} +
+ + {/* Empty state */} + {!isLoadingRoot && rootItems.length === 0 && ( +
+ No files or folders found in your Google Drive +
+ )} +
+
+
+ ); +}