Feature/v2/filemanagerimprovements (#4243)

- Select all/deselect all
- Delete Selected
- Download  Selected
- Recent file delete -> menu button with drop down for delete and
download
- Shift click selection added
<img width="1220" height="751"
alt="{330DF96D-7040-4CCB-B089-523F370E3185}"
src="https://github.com/user-attachments/assets/976e42cc-2124-4e62-83a8-25f184e8da3b"
/>
<img width="1160" height="749"
alt="{2D2F4876-7D35-45C3-B0CD-3127EEEEF7B5}"
src="https://github.com/user-attachments/assets/6879a174-a135-41f4-a876-984e7c2f96e2"
/>

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh
2025-08-20 16:51:55 +01:00
committed by GitHub
parent eada9e43ec
commit cd2b82d614
9 changed files with 558 additions and 91 deletions

View File

@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { FileWithUrl } from '../types/file';
import { StoredFile } from '../services/fileStorage';
import { StoredFile, fileStorage } from '../services/fileStorage';
import { downloadFiles } from '../utils/downloadUtils';
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
@@ -11,16 +12,21 @@ interface FileManagerContextValue {
selectedFiles: FileWithUrl[];
filteredFiles: FileWithUrl[];
fileInputRef: React.RefObject<HTMLInputElement | null>;
selectedFilesSet: Set<string>;
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void;
onFileSelect: (file: FileWithUrl) => void;
onFileSelect: (file: FileWithUrl, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void;
onFileDoubleClick: (file: FileWithUrl) => void;
onOpenFiles: () => void;
onSearchChange: (value: string) => void;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectAll: () => void;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
onDownloadSingle: (file: FileWithUrl) => void;
// External props
recentFiles: FileWithUrl[];
@@ -60,22 +66,29 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set());
// Computed values (with null safety)
const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
const filteredFiles = (recentFiles || []).filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedFilesSet = new Set(selectedFileIds);
const selectedFiles = selectedFileIds.length === 0 ? [] :
(recentFiles || []).filter(file => selectedFilesSet.has(file.id || file.name));
const filteredFiles = !searchTerm ? recentFiles || [] :
(recentFiles || []).filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
setActiveSource(source);
if (source !== 'recent') {
setSelectedFileIds([]);
setSearchTerm('');
setLastClickedIndex(null);
}
}, []);
@@ -83,19 +96,46 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef.current?.click();
}, []);
const handleFileSelect = useCallback((file: FileWithUrl) => {
setSelectedFileIds(prev => {
if (file.id) {
if (prev.includes(file.id)) {
return prev.filter(id => id !== file.id);
} else {
return [...prev, file.id];
const handleFileSelect = useCallback((file: FileWithUrl, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id || file.name;
if (!fileId) return;
if (shiftKey && lastClickedIndex !== null) {
// Range selection with shift-click
const startIndex = Math.min(lastClickedIndex, currentIndex);
const endIndex = Math.max(lastClickedIndex, currentIndex);
setSelectedFileIds(prev => {
const selectedSet = new Set(prev);
// Add all files in the range to selection
for (let i = startIndex; i <= endIndex; i++) {
const rangeFileId = filteredFiles[i]?.id || filteredFiles[i]?.name;
if (rangeFileId) {
selectedSet.add(rangeFileId);
}
}
} else {
return prev;
}
});
}, []);
return Array.from(selectedSet);
});
} else {
// Normal click behavior - optimized with Set for O(1) lookup
setSelectedFileIds(prev => {
const selectedSet = new Set(prev);
if (selectedSet.has(fileId)) {
selectedSet.delete(fileId);
} else {
selectedSet.add(fileId);
}
return Array.from(selectedSet);
});
// Update last clicked index for future range selections
setLastClickedIndex(currentIndex);
}
}, [filteredFiles, lastClickedIndex]);
const handleFileRemove = useCallback((index: number) => {
const fileToRemove = filteredFiles[index];
@@ -152,6 +192,72 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
event.target.value = '';
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
const handleSelectAll = useCallback(() => {
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
if (allFilesSelected) {
// Deselect all
setSelectedFileIds([]);
setLastClickedIndex(null);
} else {
// Select all filtered files
setSelectedFileIds(filteredFiles.map(file => file.id || file.name));
setLastClickedIndex(null);
}
}, [filteredFiles, selectedFileIds]);
const handleDeleteSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Get files to delete based on current filtered view
const filesToDelete = filteredFiles.filter(file =>
selectedFileIds.includes(file.id || file.name)
);
// Delete files from storage
for (const file of filesToDelete) {
const lookupKey = file.id || file.name;
await fileStorage.deleteFile(lookupKey);
}
// Clear selection
setSelectedFileIds([]);
// Refresh the file list
await refreshRecentFiles();
} catch (error) {
console.error('Failed to delete selected files:', error);
}
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Get selected files
const selectedFilesToDownload = filteredFiles.filter(file =>
selectedFileIds.includes(file.id || file.name)
);
// Use generic download utility
await downloadFiles(selectedFilesToDownload, {
zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`
});
} catch (error) {
console.error('Failed to download selected files:', error);
}
}, [selectedFileIds, filteredFiles]);
const handleDownloadSingle = useCallback(async (file: FileWithUrl) => {
try {
await downloadFiles([file]);
} catch (error) {
console.error('Failed to download file:', error);
}
}, []);
// Cleanup blob URLs when component unmounts
useEffect(() => {
return () => {
@@ -169,6 +275,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setActiveSource('recent');
setSelectedFileIds([]);
setSearchTerm('');
setLastClickedIndex(null);
}
}, [isOpen]);
@@ -180,6 +287,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedFiles,
filteredFiles,
fileInputRef,
selectedFilesSet,
// Handlers
onSourceChange: handleSourceChange,
@@ -190,6 +298,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onOpenFiles: handleOpenFiles,
onSearchChange: handleSearchChange,
onFileInputChange: handleFileInputChange,
onSelectAll: handleSelectAll,
onDeleteSelected: handleDeleteSelected,
onDownloadSelected: handleDownloadSelected,
onDownloadSingle: handleDownloadSingle,
// External props
recentFiles,