import React, { useState, useCallback, useRef, useEffect, forwardRef, } from "react"; import { Loader } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useFileState, useFileActions } from "@app/contexts/file/fileHooks"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker"; import { useNavigationState, useNavigationActions, useNavigationGuard, } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import type { StirlingFileStub } from "@app/types/fileContext"; import MenuIcon from "@mui/icons-material/Menu"; import SearchIcon from "@mui/icons-material/Search"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import CloseIcon from "@mui/icons-material/Close"; import AddIcon from "@mui/icons-material/Add"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import SettingsIcon from "@mui/icons-material/Settings"; import type { FileId } from "@app/types/file"; import { FileItem } from "@app/components/shared/FileSidebarFileItem"; import "@app/components/shared/FileSidebar.css"; const COLLAPSED_WIDTH = "3.5rem"; const EXPANDED_WIDTH = "16.25rem"; // ~260px export interface FileSidebarProps { collapsed?: boolean; onToggleCollapse?: () => void; onOpenSettings?: () => void; } const FileSidebar = forwardRef( function FileSidebar( { collapsed = false, onToggleCollapse, onOpenSettings }, ref, ) { const { t } = useTranslation(); const [searchActive, setSearchActive] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); const nativeFileInputRef = useRef(null); // State (not ref) so setting it triggers a re-render — avoids racing addFiles state updates. const [pendingViewFileId, setPendingViewFileId] = useState( null, ); const { openFilesModal } = useFilesModalContext(); const { config } = useAppConfig(); const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker, } = useGoogleDrivePicker(); const { state } = useFileState(); const { actions: fileActions } = useFileActions(); const { actions: navActions } = useNavigationActions(); const { workbench: currentWorkbench, selectedTool } = useNavigationState(); const isMultiTool = currentWorkbench === "pageEditor" && selectedTool === "multiTool"; const { requestNavigation } = useNavigationGuard(); const { activeFileId, setActiveFileId } = useViewer(); const { addFiles } = useFileHandler(); const indexedDB = useIndexedDB(); const [displayName, setDisplayName] = useState("Guest"); useEffect(() => { if (!config?.enableLogin) return; accountService .getAccountData() .then((data) => { if (data?.username) setDisplayName(data.username); }) .catch(() => { /* not logged in or security disabled */ }); }, [config?.enableLogin]); // Leaf files = user-visible files (excludes intermediate tool outputs) const [allFileStubs, setAllFileStubs] = useState([]); const [stubsLoaded, setStubsLoaded] = useState(false); const refreshStubs = useCallback(async () => { // Leaf files from IDB — same source as the file selection modal. const stubs = await indexedDB.loadLeafMetadata(); const idbIds = new Set(stubs.map((s) => s.id as string)); // Also include workbench files not yet flushed to IDB. const pendingStubs = state.files.ids .map((id) => state.files.byId[id]) .filter( (stub): stub is NonNullable => !!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string), ); const allStubs = [...stubs, ...pendingStubs]; setAllFileStubs( allStubs.sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)), ); setStubsLoaded(true); }, [indexedDB, state.files.ids, state.files.byId]); // Refresh on mount, workbench changes, or external IndexedDB writes useEffect(() => { refreshStubs(); }, [refreshStubs, indexedDB.revision]); // Once a pending file lands in state, open it in the viewer. useEffect(() => { if (!pendingViewFileId) return; const isInWorkbench = state.files.ids.some( (id) => (id as string) === pendingViewFileId, ); if (isInWorkbench) { setPendingViewFileId(null); setActiveFileId(pendingViewFileId); navActions.setWorkbench("viewer"); } }, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]); const filteredFileStubs = searchQuery.trim() ? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(searchQuery.toLowerCase()), ) : allFileStubs; // Handle search activation const handleSearchClick = useCallback(() => { if (collapsed && onToggleCollapse) { onToggleCollapse(); } setSearchActive(true); }, [collapsed, onToggleCollapse]); const handleSearchClose = useCallback(() => { setSearchActive(false); setSearchQuery(""); }, []); useEffect(() => { if (searchActive && searchInputRef.current) { searchInputRef.current.focus(); } }, [searchActive]); // Handle Google Drive const handleGoogleDriveClick = useCallback(async () => { if (!isGoogleDriveEnabled) return; const files = await openGoogleDrivePicker({ multiple: true }); if (files.length > 0) { await addFiles(files); if (!isMultiTool) { navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); } } }, [ isGoogleDriveEnabled, openGoogleDrivePicker, addFiles, navActions, isMultiTool, ]); // Toggle file in/out of workbench const handleFileClick = useCallback( async (fileId: FileId) => { const stub = allFileStubs.find((s) => s.id === fileId); if (!stub) return; const workbenchFileId = state.files.ids.find( (id) => (id as string) === (stub.id as string), ); if (workbenchFileId) { // If this is the file currently open in the viewer, route through the // navigation guard so the save modal fires when there are unsaved changes. const isCurrentlyViewed = workbenchFileId === viewedWorkbenchId; if (isCurrentlyViewed) { requestNavigation(() => { void fileActions.removeFiles([workbenchFileId], false); }); return; } await fileActions.removeFiles([workbenchFileId], false); } else { // Re-add by stub to preserve its ID — addFiles() would create a new UUID + IDB entry. const workbenchCount = state.files.ids.length; if (workbenchCount > 0 && currentWorkbench === "viewer") { navActions.setWorkbench("fileEditor"); } await fileActions.addStirlingFileStubs([stub]); if (isMultiTool) { fileActions.setSelectedFiles([ ...state.ui.selectedFileIds, stub.id, ]); } else { if (workbenchCount === 0) { navActions.setWorkbench("viewer"); } else { navActions.setWorkbench("fileEditor"); } } } }, [ allFileStubs, state.files.ids, state.ui.selectedFileIds, fileActions, navActions, currentWorkbench, activeFileId, requestNavigation, isMultiTool, ], ); // Which file is currently open in the viewer — stable ID, never index-derived. const viewedWorkbenchId = currentWorkbench === "viewer" ? activeFileId : null; const handleEyeClick = useCallback( async (fileId: FileId, _e: React.MouseEvent) => { const stub = allFileStubs.find((s) => s.id === fileId); if (!stub) return; const isCurrentlyViewed = !!( viewedWorkbenchId && (viewedWorkbenchId as string) === (stub.id as string) ); if (isCurrentlyViewed) { // Closing the currently-viewed file — guard against unsaved changes. navActions.setWorkbench("fileEditor"); return; } // Switching to a different file while viewer is open — guard against unsaved changes. const performSwitch = async () => { const alreadyInWorkbench = state.files.ids.some( (id) => (id as string) === (stub.id as string), ); if (!alreadyInWorkbench) { // Leave viewer before mutating workbench (prevents PSPDFKit crash). if (state.files.ids.length > 0 && currentWorkbench === "viewer") { navActions.setWorkbench("fileEditor"); } await fileActions.addStirlingFileStubs([stub]); } // Route through pendingViewFileId so both setActiveFileIndex + setWorkbench fire together. setPendingViewFileId(stub.id as string); }; if (currentWorkbench === "viewer" && viewedWorkbenchId) { requestNavigation(() => { void performSwitch(); }); } else { await performSwitch(); } }, [ allFileStubs, viewedWorkbenchId, state.files.ids, fileActions, navActions, currentWorkbench, setPendingViewFileId, requestNavigation, ], ); const handleNativeFilePick = useCallback( async (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []); if (files.length > 0) { await addFiles(files); if (!isMultiTool) { navActions.setWorkbench( files.length === 1 ? "viewer" : "fileEditor", ); } } e.target.value = ""; }, [addFiles, navActions, isMultiTool], ); const shouldHideGoogleDrive = !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive; const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; return (
{/* Header: hamburger + branding */}
onToggleCollapse?.()} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()} aria-label={ collapsed ? t("fileSidebar.expand", "Expand sidebar") : t("fileSidebar.collapse", "Collapse sidebar") } > {!collapsed && ( Stirling PDF )}
{/* Search row */} {
e.key === "Enter" && handleSearchClick() : undefined } > {searchActive && !collapsed ? ( { e.stopPropagation(); handleSearchClose(); }} /> ) : ( )} {!collapsed && (searchActive ? ( setSearchQuery(e.target.value)} placeholder={t( "fileSidebar.searchPlaceholder", "Search files...", )} onClick={(e) => e.stopPropagation()} /> ) : ( {t("fileSidebar.search", "Search")} ))}
} {/* Scrollable content */}
{/* Open from Computer + Google Drive */} { <>
{ if (collapsed && onToggleCollapse) onToggleCollapse(); openFilesModal(); }} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && openFilesModal()} > {!collapsed && ( {t("fileSidebar.openFromComputer", "Open from computer")} )}
{!shouldHideGoogleDrive && (
{isGoogleDriveEnabled && ( )}
{!collapsed && ( {t("fileSidebar.googleDrive", "Google Drive")} )}
)} } {/* Files section - always visible when expanded */} {!collapsed && (
{t("fileSidebar.files", "Files")}
{!stubsLoaded ? (
) : filteredFileStubs.length > 0 ? (
{filteredFileStubs.map((stub) => { const workbenchFileId = state.files.ids.find( (id) => (id as string) === (stub.id as string), ); const isInWorkbench = !!workbenchFileId; // Both active and viewed-in-viewer are ID-based — never index-based. const isViewedInViewer = !!( viewedWorkbenchId && viewedWorkbenchId === (stub.id as string) ); const isActive = isViewedInViewer; // In-memory thumbnail may be fresher than the IndexedDB stub. // Encrypted files never get a raster thumbnail — use undefined // so the sidebar icon is shown instead of a stale canvas thumbnail. const isEncryptedFile = stub.processedFile?.isEncrypted === true; const thumbnailUrl = isEncryptedFile ? undefined : (workbenchFileId ? state.files.byId[workbenchFileId]?.thumbnailUrl : undefined) || stub.thumbnailUrl; return ( ); })}
) : ( !searchActive && (

{t("fileSidebar.noFiles", "No files yet")}

{t("fileSidebar.dropHint", "Open files to get started")}

) )}
)}
{/* Bottom bar: user name + settings */}
e.key === "Enter" && onOpenSettings() : undefined } data-testid={onOpenSettings ? "config-button" : undefined} aria-label={ onOpenSettings ? t("fileSidebar.openSettings", "Open settings") : undefined } title={ onOpenSettings ? t("fileSidebar.openSettings", "Open settings") : undefined } style={onOpenSettings ? { cursor: "pointer" } : undefined} >
{displayName.charAt(0).toUpperCase()}
{!collapsed && ( {displayName} )} {onOpenSettings && !collapsed && (
)}
); }, ); export default FileSidebar;