2025-08-21 17:30:26 +01:00
|
|
|
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
2025-09-05 12:16:17 +01:00
|
|
|
import { fileStorage } from '../services/fileStorage';
|
2025-09-16 15:08:11 +01:00
|
|
|
import { StirlingFileStub } from '../types/fileContext';
|
2025-08-20 16:51:55 +01:00
|
|
|
import { downloadFiles } from '../utils/downloadUtils';
|
2025-08-28 10:56:07 +01:00
|
|
|
import { FileId } from '../types/file';
|
2025-09-16 15:08:11 +01:00
|
|
|
import { groupFilesByOriginal } from '../utils/fileHistoryUtils';
|
2025-08-08 15:15:09 +01:00
|
|
|
|
|
|
|
// Type for the context value - now contains everything directly
|
|
|
|
interface FileManagerContextValue {
|
|
|
|
// State
|
|
|
|
activeSource: 'recent' | 'local' | 'drive';
|
2025-08-28 10:56:07 +01:00
|
|
|
selectedFileIds: FileId[];
|
2025-08-08 15:15:09 +01:00
|
|
|
searchTerm: string;
|
2025-09-16 15:08:11 +01:00
|
|
|
selectedFiles: StirlingFileStub[];
|
|
|
|
filteredFiles: StirlingFileStub[];
|
2025-08-11 09:16:16 +01:00
|
|
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
2025-09-16 15:08:11 +01:00
|
|
|
selectedFilesSet: Set<FileId>;
|
|
|
|
expandedFileIds: Set<FileId>;
|
|
|
|
fileGroups: Map<FileId, StirlingFileStub[]>;
|
|
|
|
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
2025-08-11 09:16:16 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
// Handlers
|
|
|
|
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
|
|
|
onLocalFileClick: () => void;
|
2025-09-16 15:08:11 +01:00
|
|
|
onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void;
|
2025-08-08 15:15:09 +01:00
|
|
|
onFileRemove: (index: number) => void;
|
2025-09-16 15:08:11 +01:00
|
|
|
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
|
|
|
onFileDoubleClick: (file: StirlingFileStub) => void;
|
2025-08-08 15:15:09 +01:00
|
|
|
onOpenFiles: () => void;
|
|
|
|
onSearchChange: (value: string) => void;
|
|
|
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
2025-08-20 16:51:55 +01:00
|
|
|
onSelectAll: () => void;
|
|
|
|
onDeleteSelected: () => void;
|
|
|
|
onDownloadSelected: () => void;
|
2025-09-16 15:08:11 +01:00
|
|
|
onDownloadSingle: (file: StirlingFileStub) => void;
|
|
|
|
onToggleExpansion: (fileId: FileId) => void;
|
|
|
|
onAddToRecents: (file: StirlingFileStub) => void;
|
|
|
|
onNewFilesSelect: (files: File[]) => void;
|
2025-08-11 09:16:16 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
// External props
|
2025-09-16 15:08:11 +01:00
|
|
|
recentFiles: StirlingFileStub[];
|
2025-08-08 15:15:09 +01:00
|
|
|
isFileSupported: (fileName: string) => boolean;
|
|
|
|
modalHeight: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create the context
|
|
|
|
const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
|
|
|
|
|
|
|
// Provider component props
|
|
|
|
interface FileManagerProviderProps {
|
|
|
|
children: React.ReactNode;
|
2025-09-16 15:08:11 +01:00
|
|
|
recentFiles: StirlingFileStub[];
|
|
|
|
onRecentFilesSelected: (files: StirlingFileStub[]) => void; // For selecting stored files
|
2025-08-21 17:30:26 +01:00
|
|
|
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
|
2025-08-08 15:15:09 +01:00
|
|
|
onClose: () => void;
|
|
|
|
isFileSupported: (fileName: string) => boolean;
|
|
|
|
isOpen: boolean;
|
|
|
|
onFileRemove: (index: number) => void;
|
|
|
|
modalHeight: string;
|
|
|
|
refreshRecentFiles: () => Promise<void>;
|
|
|
|
}
|
|
|
|
|
|
|
|
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|
|
|
children,
|
|
|
|
recentFiles,
|
2025-09-16 15:08:11 +01:00
|
|
|
onRecentFilesSelected,
|
2025-08-21 17:30:26 +01:00
|
|
|
onNewFilesSelect,
|
2025-08-08 15:15:09 +01:00
|
|
|
onClose,
|
|
|
|
isFileSupported,
|
|
|
|
isOpen,
|
|
|
|
onFileRemove,
|
|
|
|
modalHeight,
|
|
|
|
refreshRecentFiles,
|
|
|
|
}) => {
|
|
|
|
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
2025-08-28 10:56:07 +01:00
|
|
|
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
2025-08-08 15:15:09 +01:00
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
2025-08-20 16:51:55 +01:00
|
|
|
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
2025-09-16 15:08:11 +01:00
|
|
|
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(new Set());
|
|
|
|
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StirlingFileStub[]>>(new Map()); // Cache for loaded history
|
2025-08-08 15:15:09 +01:00
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
2025-08-11 09:16:16 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
// Track blob URLs for cleanup
|
|
|
|
const createdBlobUrls = useRef<Set<string>>(new Set());
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
// Computed values (with null safety)
|
2025-08-20 16:51:55 +01:00
|
|
|
const selectedFilesSet = new Set(selectedFileIds);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Group files by original file ID for version management
|
|
|
|
const fileGroups = useMemo(() => {
|
|
|
|
if (!recentFiles || recentFiles.length === 0) return new Map();
|
|
|
|
|
|
|
|
// Convert StirlingFileStub to FileRecord-like objects for grouping utility
|
|
|
|
const recordsForGrouping = recentFiles.map(file => ({
|
|
|
|
...file,
|
|
|
|
originalFileId: file.originalFileId,
|
|
|
|
versionNumber: file.versionNumber || 1
|
|
|
|
}));
|
|
|
|
|
|
|
|
return groupFilesByOriginal(recordsForGrouping);
|
|
|
|
}, [recentFiles]);
|
|
|
|
|
|
|
|
// Get files to display with expansion logic
|
|
|
|
const displayFiles = useMemo(() => {
|
|
|
|
if (!recentFiles || recentFiles.length === 0) return [];
|
|
|
|
|
|
|
|
// Only return leaf files - history files will be handled by separate components
|
|
|
|
return recentFiles;
|
|
|
|
}, [recentFiles]);
|
|
|
|
|
2025-08-28 10:56:07 +01:00
|
|
|
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
2025-09-16 15:08:11 +01:00
|
|
|
displayFiles.filter(file => selectedFilesSet.has(file.id));
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const filteredFiles = !searchTerm ? displayFiles :
|
|
|
|
displayFiles.filter(file =>
|
2025-08-20 16:51:55 +01:00
|
|
|
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
|
|
);
|
2025-08-08 15:15:09 +01:00
|
|
|
|
|
|
|
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
|
|
|
|
setActiveSource(source);
|
|
|
|
if (source !== 'recent') {
|
|
|
|
setSelectedFileIds([]);
|
|
|
|
setSearchTerm('');
|
2025-08-20 16:51:55 +01:00
|
|
|
setLastClickedIndex(null);
|
2025-08-08 15:15:09 +01:00
|
|
|
}
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
const handleLocalFileClick = useCallback(() => {
|
|
|
|
fileInputRef.current?.click();
|
|
|
|
}, []);
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
|
2025-08-21 17:30:26 +01:00
|
|
|
const fileId = file.id;
|
2025-08-20 16:51:55 +01:00
|
|
|
if (!fileId) return;
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-20 16:51:55 +01:00
|
|
|
if (shiftKey && lastClickedIndex !== null) {
|
|
|
|
// Range selection with shift-click
|
|
|
|
const startIndex = Math.min(lastClickedIndex, currentIndex);
|
|
|
|
const endIndex = Math.max(lastClickedIndex, currentIndex);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-20 16:51:55 +01:00
|
|
|
setSelectedFileIds(prev => {
|
|
|
|
const selectedSet = new Set(prev);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-20 16:51:55 +01:00
|
|
|
// Add all files in the range to selection
|
|
|
|
for (let i = startIndex; i <= endIndex; i++) {
|
2025-08-21 17:30:26 +01:00
|
|
|
const rangeFileId = filteredFiles[i]?.id;
|
2025-08-20 16:51:55 +01:00
|
|
|
if (rangeFileId) {
|
|
|
|
selectedSet.add(rangeFileId);
|
|
|
|
}
|
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-20 16:51:55 +01:00
|
|
|
return Array.from(selectedSet);
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
// Normal click behavior - optimized with Set for O(1) lookup
|
|
|
|
setSelectedFileIds(prev => {
|
|
|
|
const selectedSet = new Set(prev);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-20 16:51:55 +01:00
|
|
|
if (selectedSet.has(fileId)) {
|
|
|
|
selectedSet.delete(fileId);
|
2025-08-11 09:16:16 +01:00
|
|
|
} else {
|
2025-08-20 16:51:55 +01:00
|
|
|
selectedSet.add(fileId);
|
2025-08-11 09:16:16 +01:00
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-20 16:51:55 +01:00
|
|
|
return Array.from(selectedSet);
|
|
|
|
});
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-20 16:51:55 +01:00
|
|
|
// Update last clicked index for future range selections
|
|
|
|
setLastClickedIndex(currentIndex);
|
|
|
|
}
|
|
|
|
}, [filteredFiles, lastClickedIndex]);
|
2025-08-08 15:15:09 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Helper function to safely determine which files can be deleted
|
|
|
|
const getSafeFilesToDelete = useCallback((
|
|
|
|
fileIds: FileId[],
|
|
|
|
allStoredStubs: StirlingFileStub[]
|
|
|
|
): FileId[] => {
|
|
|
|
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
|
|
|
const filesToDelete = new Set<FileId>();
|
|
|
|
const filesToPreserve = new Set<FileId>();
|
|
|
|
|
|
|
|
// First, identify all files in the lineages of the leaf files being deleted
|
|
|
|
for (const leafFileId of fileIds) {
|
|
|
|
const currentFile = fileMap.get(leafFileId);
|
|
|
|
if (!currentFile) continue;
|
|
|
|
|
|
|
|
// Always include the leaf file itself for deletion
|
|
|
|
filesToDelete.add(leafFileId);
|
|
|
|
|
|
|
|
// If this is a processed file with history, trace back through its lineage
|
|
|
|
if (currentFile.versionNumber && currentFile.versionNumber > 1) {
|
|
|
|
const originalFileId = currentFile.originalFileId || currentFile.id;
|
|
|
|
|
|
|
|
// Find all files in this history chain
|
|
|
|
const chainFiles = allStoredStubs.filter((file: StirlingFileStub) =>
|
|
|
|
(file.originalFileId || file.id) === originalFileId
|
|
|
|
);
|
|
|
|
|
|
|
|
// Add all files in this lineage as candidates for deletion
|
|
|
|
chainFiles.forEach(file => filesToDelete.add(file.id));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Now identify files that must be preserved because they're referenced by OTHER lineages
|
|
|
|
for (const file of allStoredStubs) {
|
|
|
|
const fileOriginalId = file.originalFileId || file.id;
|
|
|
|
|
|
|
|
// If this file is a leaf node (not being deleted) and its lineage overlaps with files we want to delete
|
|
|
|
if (file.isLeaf !== false && !fileIds.includes(file.id)) {
|
|
|
|
// Find all files in this preserved lineage
|
|
|
|
const preservedChainFiles = allStoredStubs.filter((chainFile: StirlingFileStub) =>
|
|
|
|
(chainFile.originalFileId || chainFile.id) === fileOriginalId
|
|
|
|
);
|
|
|
|
|
|
|
|
// Mark all files in this preserved lineage as must-preserve
|
|
|
|
preservedChainFiles.forEach(chainFile => filesToPreserve.add(chainFile.id));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Final list: files to delete minus files that must be preserved
|
|
|
|
let safeToDelete = Array.from(filesToDelete).filter(fileId => !filesToPreserve.has(fileId));
|
|
|
|
|
|
|
|
// Check for orphaned non-leaf files after main deletion
|
|
|
|
const remainingFiles = allStoredStubs.filter(file => !safeToDelete.includes(file.id));
|
|
|
|
const orphanedNonLeafFiles: FileId[] = [];
|
|
|
|
|
|
|
|
for (const file of remainingFiles) {
|
|
|
|
// Only check non-leaf files (files that have been processed and have children)
|
|
|
|
if (file.isLeaf === false) {
|
|
|
|
const fileOriginalId = file.originalFileId || file.id;
|
|
|
|
|
|
|
|
// Check if this non-leaf file has any living descendants
|
|
|
|
const hasLivingDescendants = remainingFiles.some(otherFile => {
|
|
|
|
// Check if otherFile is a descendant of this file
|
|
|
|
const otherOriginalId = otherFile.originalFileId || otherFile.id;
|
|
|
|
return (
|
|
|
|
// Direct parent relationship
|
|
|
|
otherFile.parentFileId === file.id ||
|
|
|
|
// Same lineage but different from this file
|
|
|
|
(otherOriginalId === fileOriginalId && otherFile.id !== file.id)
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!hasLivingDescendants) {
|
|
|
|
orphanedNonLeafFiles.push(file.id);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add orphaned non-leaf files to deletion list
|
|
|
|
safeToDelete = [...safeToDelete, ...orphanedNonLeafFiles];
|
|
|
|
|
|
|
|
return safeToDelete;
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
// Shared internal delete logic
|
|
|
|
const performFileDelete = useCallback(async (fileToRemove: StirlingFileStub, fileIndex: number) => {
|
|
|
|
const deletedFileId = fileToRemove.id;
|
|
|
|
|
|
|
|
// Get all stored files to analyze lineages
|
|
|
|
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
|
|
|
|
|
|
|
// Get safe files to delete (respecting shared lineages)
|
|
|
|
const filesToDelete = getSafeFilesToDelete([deletedFileId], allStoredStubs);
|
|
|
|
|
|
|
|
// Clear from selection immediately
|
|
|
|
setSelectedFileIds(prev => prev.filter(id => !filesToDelete.includes(id)));
|
|
|
|
|
|
|
|
// Clear from expanded state to prevent ghost entries
|
|
|
|
setExpandedFileIds(prev => {
|
|
|
|
const newExpanded = new Set(prev);
|
|
|
|
filesToDelete.forEach(id => newExpanded.delete(id));
|
|
|
|
return newExpanded;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Clear from history cache - remove all files in the chain
|
|
|
|
setLoadedHistoryFiles(prev => {
|
|
|
|
const newCache = new Map(prev);
|
|
|
|
|
|
|
|
// Remove cache entries for all deleted files
|
|
|
|
filesToDelete.forEach(id => newCache.delete(id as FileId));
|
|
|
|
|
|
|
|
// Also remove deleted files from any other file's history cache
|
|
|
|
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
|
|
|
const filteredHistory = historyFiles.filter(histFile => !filesToDelete.includes(histFile.id));
|
|
|
|
if (filteredHistory.length !== historyFiles.length) {
|
|
|
|
newCache.set(mainFileId, filteredHistory);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return newCache;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Delete safe files from IndexedDB
|
|
|
|
try {
|
|
|
|
for (const fileId of filesToDelete) {
|
|
|
|
await fileStorage.deleteStirlingFile(fileId as FileId);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to delete files from chain:', error);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Call the parent's deletion logic for the main file only
|
|
|
|
onFileRemove(fileIndex);
|
|
|
|
|
|
|
|
// Refresh to ensure consistent state
|
|
|
|
await refreshRecentFiles();
|
|
|
|
}, [getSafeFilesToDelete, setSelectedFileIds, setExpandedFileIds, setLoadedHistoryFiles, onFileRemove, refreshRecentFiles]);
|
|
|
|
|
|
|
|
const handleFileRemove = useCallback(async (index: number) => {
|
2025-08-08 15:15:09 +01:00
|
|
|
const fileToRemove = filteredFiles[index];
|
|
|
|
if (fileToRemove) {
|
2025-09-16 15:08:11 +01:00
|
|
|
await performFileDelete(fileToRemove, index);
|
2025-08-08 15:15:09 +01:00
|
|
|
}
|
2025-09-16 15:08:11 +01:00
|
|
|
}, [filteredFiles, performFileDelete]);
|
|
|
|
|
|
|
|
// Handle deletion by fileId (more robust than index-based)
|
|
|
|
const handleFileRemoveById = useCallback(async (fileId: FileId) => {
|
|
|
|
// Find the file and its index in filteredFiles
|
|
|
|
const fileIndex = filteredFiles.findIndex(file => file.id === fileId);
|
|
|
|
const fileToRemove = filteredFiles[fileIndex];
|
|
|
|
|
|
|
|
if (fileToRemove && fileIndex !== -1) {
|
|
|
|
await performFileDelete(fileToRemove, fileIndex);
|
|
|
|
}
|
|
|
|
}, [filteredFiles, performFileDelete]);
|
|
|
|
|
|
|
|
// Handle deletion of specific history files (not index-based)
|
|
|
|
const handleHistoryFileRemove = useCallback(async (fileToRemove: StirlingFileStub) => {
|
|
|
|
const deletedFileId = fileToRemove.id;
|
|
|
|
|
|
|
|
// Clear from expanded state to prevent ghost entries
|
|
|
|
setExpandedFileIds(prev => {
|
|
|
|
const newExpanded = new Set(prev);
|
|
|
|
newExpanded.delete(deletedFileId);
|
|
|
|
return newExpanded;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Clear from history cache - remove all files in the chain
|
|
|
|
setLoadedHistoryFiles(prev => {
|
|
|
|
const newCache = new Map(prev);
|
|
|
|
|
|
|
|
// Remove cache entries for all deleted files
|
|
|
|
newCache.delete(deletedFileId);
|
|
|
|
|
|
|
|
// Also remove deleted files from any other file's history cache
|
|
|
|
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
|
|
|
const filteredHistory = historyFiles.filter(histFile => deletedFileId != histFile.id);
|
|
|
|
if (filteredHistory.length !== historyFiles.length) {
|
|
|
|
newCache.set(mainFileId, filteredHistory);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return newCache;
|
|
|
|
});
|
2025-08-08 15:15:09 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Delete safe files from IndexedDB
|
|
|
|
try {
|
|
|
|
await fileStorage.deleteStirlingFile(deletedFileId);
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to delete files from chain:', error);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Refresh to ensure consistent state
|
|
|
|
await refreshRecentFiles();
|
|
|
|
}, [filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete]);
|
|
|
|
|
|
|
|
const handleFileDoubleClick = useCallback((file: StirlingFileStub) => {
|
2025-08-08 15:15:09 +01:00
|
|
|
if (isFileSupported(file.name)) {
|
2025-09-16 15:08:11 +01:00
|
|
|
onRecentFilesSelected([file]);
|
2025-08-08 15:15:09 +01:00
|
|
|
onClose();
|
|
|
|
}
|
2025-09-16 15:08:11 +01:00
|
|
|
}, [isFileSupported, onRecentFilesSelected, onClose]);
|
2025-08-08 15:15:09 +01:00
|
|
|
|
|
|
|
const handleOpenFiles = useCallback(() => {
|
|
|
|
if (selectedFiles.length > 0) {
|
2025-09-16 15:08:11 +01:00
|
|
|
onRecentFilesSelected(selectedFiles);
|
2025-08-08 15:15:09 +01:00
|
|
|
onClose();
|
|
|
|
}
|
2025-09-16 15:08:11 +01:00
|
|
|
}, [selectedFiles, onRecentFilesSelected, onClose]);
|
2025-08-08 15:15:09 +01:00
|
|
|
|
|
|
|
const handleSearchChange = useCallback((value: string) => {
|
|
|
|
setSearchTerm(value);
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
const handleFileInputChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
const files = Array.from(event.target.files || []);
|
|
|
|
if (files.length > 0) {
|
|
|
|
try {
|
2025-08-21 17:30:26 +01:00
|
|
|
// For local file uploads, pass File objects directly to FileContext
|
|
|
|
onNewFilesSelect(files);
|
2025-08-08 15:15:09 +01:00
|
|
|
await refreshRecentFiles();
|
|
|
|
onClose();
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to process selected files:', error);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
event.target.value = '';
|
2025-08-21 17:30:26 +01:00
|
|
|
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
2025-08-08 15:15:09 +01:00
|
|
|
|
2025-08-20 16:51:55 +01:00
|
|
|
const handleSelectAll = useCallback(() => {
|
|
|
|
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
|
|
|
if (allFilesSelected) {
|
|
|
|
// Deselect all
|
|
|
|
setSelectedFileIds([]);
|
|
|
|
setLastClickedIndex(null);
|
|
|
|
} else {
|
|
|
|
// Select all filtered files
|
2025-08-21 17:30:26 +01:00
|
|
|
setSelectedFileIds(filteredFiles.map(file => file.id).filter(Boolean));
|
2025-08-20 16:51:55 +01:00
|
|
|
setLastClickedIndex(null);
|
|
|
|
}
|
|
|
|
}, [filteredFiles, selectedFileIds]);
|
|
|
|
|
|
|
|
const handleDeleteSelected = useCallback(async () => {
|
|
|
|
if (selectedFileIds.length === 0) return;
|
|
|
|
|
|
|
|
try {
|
2025-09-16 15:08:11 +01:00
|
|
|
// Delete each selected file using the proven single delete logic
|
|
|
|
for (const fileId of selectedFileIds) {
|
|
|
|
await handleFileRemoveById(fileId);
|
2025-08-20 16:51:55 +01:00
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to delete selected files:', error);
|
|
|
|
}
|
2025-09-16 15:08:11 +01:00
|
|
|
}, [selectedFileIds, handleFileRemoveById]);
|
2025-08-20 16:51:55 +01:00
|
|
|
|
|
|
|
|
|
|
|
const handleDownloadSelected = useCallback(async () => {
|
|
|
|
if (selectedFileIds.length === 0) return;
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Get selected files
|
2025-08-28 10:56:07 +01:00
|
|
|
const selectedFilesToDownload = filteredFiles.filter(file =>
|
2025-08-21 17:30:26 +01:00
|
|
|
selectedFileIds.includes(file.id)
|
2025-08-20 16:51:55 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
// 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]);
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const handleDownloadSingle = useCallback(async (file: StirlingFileStub) => {
|
2025-08-20 16:51:55 +01:00
|
|
|
try {
|
|
|
|
await downloadFiles([file]);
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to download file:', error);
|
|
|
|
}
|
|
|
|
}, []);
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const handleToggleExpansion = useCallback(async (fileId: FileId) => {
|
|
|
|
const isCurrentlyExpanded = expandedFileIds.has(fileId);
|
|
|
|
|
|
|
|
// Update expansion state
|
|
|
|
setExpandedFileIds(prev => {
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
if (newSet.has(fileId)) {
|
|
|
|
newSet.delete(fileId);
|
|
|
|
} else {
|
|
|
|
newSet.add(fileId);
|
|
|
|
}
|
|
|
|
return newSet;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Load complete history chain if expanding
|
|
|
|
if (!isCurrentlyExpanded) {
|
|
|
|
const currentFileMetadata = recentFiles.find(f => f.id === fileId);
|
|
|
|
if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) {
|
|
|
|
try {
|
|
|
|
// Get all stored file metadata for chain traversal
|
|
|
|
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
|
|
|
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
|
|
|
|
|
|
|
// Get the current file's IndexedDB data
|
|
|
|
const currentStoredStub = fileMap.get(fileId as FileId);
|
|
|
|
if (!currentStoredStub) {
|
|
|
|
console.warn(`No stored file found for ${fileId}`);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Build complete history chain using IndexedDB metadata
|
|
|
|
const historyFiles: StirlingFileStub[] = [];
|
|
|
|
|
|
|
|
// Find the original file
|
|
|
|
|
|
|
|
// Collect only files in this specific branch (ancestors of current file)
|
|
|
|
const chainFiles: StirlingFileStub[] = [];
|
|
|
|
const allFiles = Array.from(fileMap.values());
|
|
|
|
|
|
|
|
// Build a map for fast parent lookups
|
|
|
|
const fileIdMap = new Map<FileId, StirlingFileStub>();
|
|
|
|
allFiles.forEach(f => fileIdMap.set(f.id, f));
|
|
|
|
|
|
|
|
// Trace back from current file through parent chain
|
|
|
|
let currentFile = fileIdMap.get(fileId);
|
|
|
|
while (currentFile?.parentFileId) {
|
|
|
|
const parentFile = fileIdMap.get(currentFile.parentFileId);
|
|
|
|
if (parentFile) {
|
|
|
|
chainFiles.push(parentFile);
|
|
|
|
currentFile = parentFile;
|
|
|
|
} else {
|
|
|
|
break; // Parent not found, stop tracing
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sort by version number (oldest first for history display)
|
|
|
|
chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
|
|
|
|
|
|
|
|
// StirlingFileStubs already have all the data we need - no conversion required!
|
|
|
|
historyFiles.push(...chainFiles);
|
|
|
|
|
|
|
|
// Cache the loaded history files
|
|
|
|
setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles)));
|
|
|
|
} catch (error) {
|
|
|
|
console.warn(`Failed to load history chain for file ${fileId}:`, error);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// Clear loaded history when collapsing
|
|
|
|
setLoadedHistoryFiles(prev => {
|
|
|
|
const newMap = new Map(prev);
|
|
|
|
newMap.delete(fileId as FileId);
|
|
|
|
return newMap;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}, [expandedFileIds, recentFiles]);
|
|
|
|
|
|
|
|
const handleAddToRecents = useCallback(async (file: StirlingFileStub) => {
|
|
|
|
try {
|
|
|
|
// Mark the file as a leaf node so it appears in recent files
|
|
|
|
await fileStorage.markFileAsLeaf(file.id);
|
|
|
|
|
|
|
|
// Refresh the recent files list to show updated state
|
|
|
|
await refreshRecentFiles();
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to add to recents:', error);
|
|
|
|
}
|
|
|
|
}, [refreshRecentFiles]);
|
2025-08-20 16:51:55 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
// Cleanup blob URLs when component unmounts
|
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
|
|
|
// Clean up all created blob URLs
|
|
|
|
createdBlobUrls.current.forEach(url => {
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
});
|
|
|
|
createdBlobUrls.current.clear();
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
// Reset state when modal closes
|
|
|
|
useEffect(() => {
|
|
|
|
if (!isOpen) {
|
|
|
|
setActiveSource('recent');
|
|
|
|
setSelectedFileIds([]);
|
|
|
|
setSearchTerm('');
|
2025-08-20 16:51:55 +01:00
|
|
|
setLastClickedIndex(null);
|
2025-08-08 15:15:09 +01:00
|
|
|
}
|
|
|
|
}, [isOpen]);
|
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
const contextValue: FileManagerContextValue = useMemo(() => ({
|
2025-08-08 15:15:09 +01:00
|
|
|
// State
|
|
|
|
activeSource,
|
|
|
|
selectedFileIds,
|
|
|
|
searchTerm,
|
|
|
|
selectedFiles,
|
|
|
|
filteredFiles,
|
|
|
|
fileInputRef,
|
2025-08-20 16:51:55 +01:00
|
|
|
selectedFilesSet,
|
2025-09-16 15:08:11 +01:00
|
|
|
expandedFileIds,
|
|
|
|
fileGroups,
|
|
|
|
loadedHistoryFiles,
|
2025-08-11 09:16:16 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
// Handlers
|
|
|
|
onSourceChange: handleSourceChange,
|
|
|
|
onLocalFileClick: handleLocalFileClick,
|
|
|
|
onFileSelect: handleFileSelect,
|
|
|
|
onFileRemove: handleFileRemove,
|
2025-09-16 15:08:11 +01:00
|
|
|
onHistoryFileRemove: handleHistoryFileRemove,
|
2025-08-08 15:15:09 +01:00
|
|
|
onFileDoubleClick: handleFileDoubleClick,
|
|
|
|
onOpenFiles: handleOpenFiles,
|
|
|
|
onSearchChange: handleSearchChange,
|
|
|
|
onFileInputChange: handleFileInputChange,
|
2025-08-20 16:51:55 +01:00
|
|
|
onSelectAll: handleSelectAll,
|
|
|
|
onDeleteSelected: handleDeleteSelected,
|
|
|
|
onDownloadSelected: handleDownloadSelected,
|
|
|
|
onDownloadSingle: handleDownloadSingle,
|
2025-09-16 15:08:11 +01:00
|
|
|
onToggleExpansion: handleToggleExpansion,
|
|
|
|
onAddToRecents: handleAddToRecents,
|
|
|
|
onNewFilesSelect,
|
2025-08-11 09:16:16 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
// External props
|
|
|
|
recentFiles,
|
|
|
|
isFileSupported,
|
|
|
|
modalHeight,
|
2025-08-21 17:30:26 +01:00
|
|
|
}), [
|
|
|
|
activeSource,
|
|
|
|
selectedFileIds,
|
|
|
|
searchTerm,
|
|
|
|
selectedFiles,
|
|
|
|
filteredFiles,
|
|
|
|
fileInputRef,
|
2025-09-16 15:08:11 +01:00
|
|
|
expandedFileIds,
|
|
|
|
fileGroups,
|
|
|
|
loadedHistoryFiles,
|
2025-08-21 17:30:26 +01:00
|
|
|
handleSourceChange,
|
|
|
|
handleLocalFileClick,
|
|
|
|
handleFileSelect,
|
|
|
|
handleFileRemove,
|
2025-09-16 15:08:11 +01:00
|
|
|
handleFileRemoveById,
|
|
|
|
performFileDelete,
|
2025-08-21 17:30:26 +01:00
|
|
|
handleFileDoubleClick,
|
|
|
|
handleOpenFiles,
|
|
|
|
handleSearchChange,
|
|
|
|
handleFileInputChange,
|
|
|
|
handleSelectAll,
|
|
|
|
handleDeleteSelected,
|
|
|
|
handleDownloadSelected,
|
2025-09-16 15:08:11 +01:00
|
|
|
handleToggleExpansion,
|
|
|
|
handleAddToRecents,
|
|
|
|
onNewFilesSelect,
|
2025-08-21 17:30:26 +01:00
|
|
|
recentFiles,
|
|
|
|
isFileSupported,
|
|
|
|
modalHeight,
|
|
|
|
]);
|
2025-08-08 15:15:09 +01:00
|
|
|
|
|
|
|
return (
|
|
|
|
<FileManagerContext.Provider value={contextValue}>
|
|
|
|
{children}
|
|
|
|
</FileManagerContext.Provider>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
// Custom hook to use the context
|
|
|
|
export const useFileManagerContext = (): FileManagerContextValue => {
|
|
|
|
const context = useContext(FileManagerContext);
|
2025-08-11 09:16:16 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
if (!context) {
|
|
|
|
throw new Error(
|
|
|
|
'useFileManagerContext must be used within a FileManagerProvider. ' +
|
|
|
|
'Make sure you wrap your component with <FileManagerProvider>.'
|
|
|
|
);
|
|
|
|
}
|
2025-08-11 09:16:16 +01:00
|
|
|
|
2025-08-08 15:15:09 +01:00
|
|
|
return context;
|
|
|
|
};
|
|
|
|
|
|
|
|
// Export the context for advanced use cases
|
2025-08-11 09:16:16 +01:00
|
|
|
export { FileManagerContext };
|