V2 Make FileId type opaque and use consistently throughout project (#4307)

# Description of Changes
The `FileId` type in V2 currently is just defined to be a string. This
makes it really easy to accidentally pass strings into things accepting
file IDs (such as file names). This PR makes the `FileId` type [an
opaque
type](https://www.geeksforgeeks.org/typescript/opaque-types-in-typescript/),
so it is compatible with things accepting strings (arguably not ideal
for this...) but strings are not compatible with it without explicit
conversion.

The PR also includes changes to use `FileId` consistently throughout the
project (everywhere I could find uses of `fileId: string`), so that we
have the maximum benefit from the type safety.

> [!note]
> I've marked quite a few things as `FIX ME` where we're passing names
in as IDs. If that is intended behaviour, I'm happy to remove the fix me
and insert a cast instead, but they probably need comments explaining
why we're using a file name as an ID.
This commit is contained in:
James Brunton
2025-08-28 10:56:07 +01:00
committed by GitHub
parent 581bafbd37
commit e142af2863
32 changed files with 600 additions and 574 deletions

View File

@@ -1,14 +1,14 @@
/**
* FileContext - Manages PDF files for Stirling PDF multi-tool workflow
*
*
* Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+).
* Users upload PDFs once and chain tools (split → merge → compress → view) without reloading.
*
*
* Key hooks:
* - useFileState() - access file state and UI state
* - useFileActions() - file operations (add/remove/update)
* - useFileActions() - file operations (add/remove/update)
* - useFileSelection() - for file selection state and actions
*
*
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
*/
@@ -19,7 +19,6 @@ import {
FileContextStateValue,
FileContextActionsValue,
FileContextActions,
FileId,
FileRecord
} from '../types/fileContext';
@@ -30,6 +29,7 @@ import { addFiles, consumeFiles, createFileActions } from './file/fileActions';
import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
import { FileId } from '../types/file';
const DEBUG = process.env.NODE_ENV === 'development';
@@ -38,16 +38,16 @@ const DEBUG = process.env.NODE_ENV === 'development';
function FileContextInner({
children,
enableUrlSync = true,
enablePersistence = true
enablePersistence = true
}: FileContextProviderProps) {
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
// IndexedDB context for persistence
const indexedDB = enablePersistence ? useIndexedDB() : null;
// File ref map - stores File objects outside React state
const filesRef = useRef<Map<FileId, File>>(new Map());
// Stable state reference for selectors
const stateRef = useRef(state);
stateRef.current = state;
@@ -60,8 +60,8 @@ function FileContextInner({
const lifecycleManager = lifecycleManagerRef.current;
// Create stable selectors (memoized once to avoid re-renders)
const selectors = useMemo<FileContextSelectors>(() =>
createFileSelectors(stateRef, filesRef),
const selectors = useMemo<FileContextSelectors>(() =>
createFileSelectors(stateRef, filesRef),
[] // Empty deps - selectors are stable
);
@@ -75,7 +75,6 @@ function FileContextInner({
// File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise<File[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Persist to IndexedDB if enabled
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
@@ -86,7 +85,7 @@ function FileContextInner({
}
}));
}
return addedFilesWithIds.map(({ file }) => file);
}, [indexedDB, enablePersistence]);
@@ -110,11 +109,11 @@ function FileContextInner({
// Helper to find FileId from File object
const findFileId = useCallback((file: File): FileId | undefined => {
return Object.keys(stateRef.current.files.byId).find(id => {
return (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
const storedFile = filesRef.current.get(id);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
}, []);
@@ -143,11 +142,11 @@ function FileContextInner({
...baseActions,
addFiles: addRawFiles,
addProcessedFiles,
addStoredFiles,
addStoredFiles,
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
// Remove from memory and cleanup resources
lifecycleManager.removeFiles(fileIds, stateRef);
// Remove from IndexedDB if enabled
if (indexedDB && enablePersistence && deleteFromStorage !== false) {
try {
@@ -157,7 +156,7 @@ function FileContextInner({
}
}
},
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
reorderFiles: (orderedFileIds: FileId[]) => {
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
@@ -166,7 +165,7 @@ function FileContextInner({
lifecycleManager.cleanupAllFiles();
filesRef.current.clear();
dispatch({ type: 'RESET_CONTEXT' });
// Don't clear IndexedDB automatically - only clear in-memory state
// IndexedDB should only be cleared when explicitly requested by user
},
@@ -175,7 +174,7 @@ function FileContextInner({
lifecycleManager.cleanupAllFiles();
filesRef.current.clear();
dispatch({ type: 'RESET_CONTEXT' });
// Then clear IndexedDB storage
if (indexedDB && enablePersistence) {
try {
@@ -191,14 +190,14 @@ function FileContextInner({
consumeFiles: consumeFilesWrapper,
setHasUnsavedChanges,
trackBlobUrl: lifecycleManager.trackBlobUrl,
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
scheduleCleanup: (fileId: string, delay?: number) =>
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
scheduleCleanup: (fileId: FileId, delay?: number) =>
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
}), [
baseActions,
addRawFiles,
addProcessedFiles,
addStoredFiles,
baseActions,
addRawFiles,
addProcessedFiles,
addStoredFiles,
lifecycleManager,
setHasUnsavedChanges,
consumeFilesWrapper,
@@ -247,12 +246,12 @@ function FileContextInner({
export function FileContextProvider({
children,
enableUrlSync = true,
enablePersistence = true
enablePersistence = true
}: FileContextProviderProps) {
if (enablePersistence) {
return (
<IndexedDBProvider>
<FileContextInner
<FileContextInner
enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence}
>
@@ -262,7 +261,7 @@ export function FileContextProvider({
);
} else {
return (
<FileContextInner
<FileContextInner
enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence}
>
@@ -285,4 +284,4 @@ export {
useSelectedFiles,
// Primary API hooks for tools
useFileContext
} from './file/fileHooks';
} from './file/fileHooks';

View File

@@ -2,12 +2,13 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEff
import { FileMetadata } from '../types/file';
import { StoredFile, fileStorage } from '../services/fileStorage';
import { downloadFiles } from '../utils/downloadUtils';
import { FileId } from '../types/file';
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
// State
activeSource: 'recent' | 'local' | 'drive';
selectedFileIds: string[];
selectedFileIds: FileId[];
searchTerm: string;
selectedFiles: FileMetadata[];
filteredFiles: FileMetadata[];
@@ -64,7 +65,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
refreshRecentFiles,
}) => {
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -74,10 +75,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Computed values (with null safety)
const selectedFilesSet = new Set(selectedFileIds);
const selectedFiles = selectedFileIds.length === 0 ? [] :
const selectedFiles = selectedFileIds.length === 0 ? [] :
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
const filteredFiles = !searchTerm ? recentFiles || [] :
(recentFiles || []).filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
@@ -99,15 +100,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id;
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;
@@ -115,23 +116,23 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedSet.add(rangeFileId);
}
}
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);
}
@@ -196,7 +197,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
try {
// Get files to delete based on current filtered view
const filesToDelete = filteredFiles.filter(file =>
const filesToDelete = filteredFiles.filter(file =>
selectedFileIds.includes(file.id)
);
@@ -221,7 +222,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
try {
// Get selected files
const selectedFilesToDownload = filteredFiles.filter(file =>
const selectedFilesToDownload = filteredFiles.filter(file =>
selectedFileIds.includes(file.id)
);

View File

@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '../hooks/useFileHandler';
import { FileMetadata } from '../types/file';
import { FileId } from '../types/file';
interface FilesModalContextType {
isFilesModalOpen: boolean;
@@ -8,7 +9,7 @@ interface FilesModalContextType {
closeFilesModal: () => void;
onFileSelect: (file: File) => void;
onFilesSelect: (files: File[]) => void;
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void;
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void;
onModalClose?: () => void;
setOnModalClose: (callback: () => void) => void;
}
@@ -57,7 +58,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeFilesModal();
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
if (customHandler) {
// Use custom handler for special cases (like page insertion)
const files = filesWithMetadata.map(item => item.file);

View File

@@ -7,7 +7,7 @@ import React, { createContext, useContext, useCallback, useRef } from 'react';
const DEBUG = process.env.NODE_ENV === 'development';
import { fileStorage, StoredFile } from '../services/fileStorage';
import { FileId } from '../types/fileContext';
import { FileId } from '../types/file';
import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
@@ -17,12 +17,12 @@ interface IndexedDBContextValue {
loadFile: (fileId: FileId) => Promise<File | null>;
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
deleteFile: (fileId: FileId) => Promise<void>;
// Batch operations
loadAllMetadata: () => Promise<FileMetadata[]>;
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
clearAll: () => Promise<void>;
// Utilities
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
@@ -59,14 +59,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
// Use existing thumbnail or generate new one if none provided
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
// Store in IndexedDB
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
// Cache the file object for immediate reuse
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
evictLRUEntries();
// Return metadata
return {
id: fileId,
@@ -121,7 +121,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// Load metadata from IndexedDB (efficient - no data field)
const metadata = await fileStorage.getAllFileMetadata();
const fileMetadata = metadata.find(m => m.id === fileId);
if (!fileMetadata) return null;
return {
@@ -137,14 +137,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
// Remove from cache
fileCache.current.delete(fileId);
// Remove from IndexedDB
await fileStorage.deleteFile(fileId);
}, []);
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
const metadata = await fileStorage.getAllFileMetadata();
return metadata.map(m => ({
id: m.id,
name: m.name,
@@ -158,7 +158,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
// Remove from cache
fileIds.forEach(id => fileCache.current.delete(id));
// Remove from IndexedDB in parallel
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
}, []);
@@ -166,7 +166,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const clearAll = useCallback(async (): Promise<void> => {
// Clear cache
fileCache.current.clear();
// Clear IndexedDB
await fileStorage.clearAll();
}, []);
@@ -204,4 +204,4 @@ export function useIndexedDB() {
throw new Error('useIndexedDB must be used within an IndexedDBProvider');
}
return context;
}
}

View File

@@ -2,10 +2,10 @@
* FileContext reducer - Pure state management for file operations
*/
import {
FileContextState,
FileContextAction,
FileId,
import { FileId } from '../../types/file';
import {
FileContextState,
FileContextAction,
FileRecord
} from '../../types/fileContext';
@@ -32,7 +32,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
const { fileRecords } = action.payload;
const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
fileRecords.forEach(record => {
// Only add if not already present (dedupe by stable ID)
if (!newById[record.id]) {
@@ -40,7 +40,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
newById[record.id] = record;
}
});
return {
...state,
files: {
@@ -49,20 +49,20 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'REMOVE_FILES': {
const { fileIds } = action.payload;
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
const newById = { ...state.files.byId };
// Remove files from state (resource cleanup handled by lifecycle manager)
fileIds.forEach(id => {
delete newById[id];
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
return {
...state,
files: {
@@ -75,15 +75,15 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'UPDATE_FILE_RECORD': {
const { id, updates } = action.payload;
const existingRecord = state.files.byId[id];
if (!existingRecord) {
return state; // File doesn't exist, no-op
}
return {
...state,
files: {
@@ -98,13 +98,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'REORDER_FILES': {
const { orderedFileIds } = action.payload;
// Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
return {
...state,
files: {
@@ -113,7 +113,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'SET_SELECTED_FILES': {
const { fileIds } = action.payload;
return {
@@ -124,7 +124,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'SET_SELECTED_PAGES': {
const { pageNumbers } = action.payload;
return {
@@ -135,7 +135,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'CLEAR_SELECTIONS': {
return {
...state,
@@ -146,7 +146,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'SET_PROCESSING': {
const { isProcessing, progress } = action.payload;
return {
@@ -158,7 +158,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'SET_UNSAVED_CHANGES': {
return {
...state,
@@ -168,42 +168,42 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'PIN_FILE': {
const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.add(fileId);
return {
...state,
pinnedFiles: newPinnedFiles
};
}
case 'UNPIN_FILE': {
const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.delete(fileId);
return {
...state,
pinnedFiles: newPinnedFiles
};
}
case 'CONSUME_FILES': {
const { inputFileIds, outputFileRecords } = action.payload;
// Only remove unpinned input files
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
// Remove unpinned files from state
const newById = { ...state.files.byId };
unpinnedInputIds.forEach(id => {
delete newById[id];
});
// Add output files
const outputIds: FileId[] = [];
outputFileRecords.forEach(record => {
@@ -212,10 +212,10 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
newById[record.id] = record;
}
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
return {
...state,
files: {
@@ -228,13 +228,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
}
};
}
case 'RESET_CONTEXT': {
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
return { ...initialFileContextState };
}
default:
return state;
}
}
}

View File

@@ -2,16 +2,15 @@
* File actions - Unified file operations with single addFiles helper
*/
import {
FileId,
FileRecord,
import {
FileRecord,
FileContextAction,
FileContextState,
toFileRecord,
createFileId,
createQuickKey
} from '../../types/fileContext';
import { FileMetadata } from '../../types/file';
import { FileId, FileMetadata } from '../../types/file';
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle';
import { fileProcessingService } from '../../services/fileProcessingService';
@@ -78,13 +77,13 @@ type AddFileKind = 'raw' | 'processed' | 'stored';
interface AddFileOptions {
// For 'raw' files
files?: File[];
// For 'processed' files
// For 'processed' files
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
// For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
// Insertion position
insertAfterPageId?: string;
}
@@ -102,37 +101,37 @@ export async function addFiles(
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
// Acquire mutex to prevent race conditions
await addFilesMutex.lock();
try {
const fileRecords: FileRecord[] = [];
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
// Build quickKey lookup from existing files for deduplication
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
switch (kind) {
case 'raw': {
const { files = [] } = options;
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
for (const file of files) {
const quickKey = createQuickKey(file);
// Soft deduplication: Check if file already exists by metadata
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
continue;
}
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
const fileId = createFileId();
filesRef.current.set(fileId, file);
// Generate thumbnail and page count immediately
let thumbnail: string | undefined;
let pageCount: number = 1;
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
if (file.type.startsWith('application/pdf')) {
try {
@@ -156,7 +155,7 @@ export async function addFiles(
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
}
}
// Create record with immediate thumbnail and page metadata
const record = toFileRecord(file, fileId);
if (thumbnail) {
@@ -166,40 +165,40 @@ export async function addFiles(
lifecycleManager.trackBlobUrl(thumbnail);
}
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
// Create initial processedFile metadata with page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail });
}
break;
}
case 'processed': {
const { filesWithThumbnails = [] } = options;
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
continue;
}
const fileId = createFileId();
filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
@@ -208,64 +207,64 @@ export async function addFiles(
lifecycleManager.trackBlobUrl(thumbnail);
}
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
// Create processedFile with provided metadata
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail });
}
break;
}
case 'stored': {
const { filesWithMetadata = [] } = options;
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
for (const { file, originalId, metadata } of filesWithMetadata) {
const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
continue;
}
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
// Try to preserve original ID, but generate new if it conflicts
let fileId = originalId;
if (filesRef.current.has(originalId)) {
fileId = createFileId();
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
}
filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId);
// Generate processedFile metadata for stored files
let pageCount: number = 1;
// Only process PDFs through PDF worker manager, non-PDFs have no page count
if (file.type.startsWith('application/pdf')) {
try {
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
// Get page count from PDF
const arrayBuffer = await file.arrayBuffer();
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
pageCount = pdf.numPages;
pdfWorkerManager.destroyDocument(pdf);
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
} catch (error) {
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
@@ -274,7 +273,7 @@ export async function addFiles(
pageCount = 0; // Non-PDFs have no page count
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
}
// Restore metadata from storage
if (metadata.thumbnail) {
record.thumbnailUrl = metadata.thumbnail;
@@ -283,33 +282,33 @@ export async function addFiles(
lifecycleManager.trackBlobUrl(metadata.thumbnail);
}
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
// Create processedFile metadata with correct page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
}
break;
}
}
// Dispatch ADD_FILES action if we have new files
if (fileRecords.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
}
return addedFiles;
} finally {
// Always release mutex even if error occurs
@@ -328,17 +327,17 @@ export async function consumeFiles(
dispatch: React.Dispatch<FileContextAction>
): Promise<void> {
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
// Process output files through the 'processed' path to generate thumbnails
const outputFileRecords = await Promise.all(
outputFiles.map(async (file) => {
const fileId = createFileId();
filesRef.current.set(fileId, file);
// Generate thumbnail and page count for output file
let thumbnail: string | undefined;
let pageCount: number = 1;
try {
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
@@ -347,29 +346,29 @@ export async function consumeFiles(
} catch (error) {
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
}
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
}
return record;
})
);
// Dispatch the consume action
dispatch({
type: 'CONSUME_FILES',
payload: {
inputFileIds,
outputFileRecords
}
dispatch({
type: 'CONSUME_FILES',
payload: {
inputFileIds,
outputFileRecords
}
});
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
}

View File

@@ -3,13 +3,14 @@
*/
import { useContext, useMemo } from 'react';
import {
FileStateContext,
import {
FileStateContext,
FileActionsContext,
FileContextStateValue,
FileContextActionsValue
FileContextActionsValue
} from './contexts';
import { FileId, FileRecord } from '../../types/fileContext';
import { FileRecord } from '../../types/fileContext';
import { FileId } from '../../types/file';
/**
* Hook for accessing file state (will re-render on any state change)
@@ -39,7 +40,7 @@ export function useFileActions(): FileContextActionsValue {
*/
export function useCurrentFile(): { file?: File; record?: FileRecord } {
const { state, selectors } = useFileState();
const primaryFileId = state.files.ids[0];
return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
@@ -81,7 +82,7 @@ export function useFileSelection() {
*/
export function useFileManagement() {
const { actions } = useFileActions();
return useMemo(() => ({
addFiles: actions.addFiles,
removeFiles: actions.removeFiles,
@@ -112,7 +113,7 @@ export function useFileUI() {
*/
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
const { selectors } = useFileState();
return useMemo(() => ({
file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId)
@@ -124,7 +125,7 @@ export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecor
*/
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getFiles(),
records: selectors.getFileRecords(),
@@ -137,7 +138,7 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
*/
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getSelectedFiles(),
records: selectors.getSelectedFileRecords(),
@@ -160,31 +161,31 @@ export function useFileContext() {
trackBlobUrl: actions.trackBlobUrl,
scheduleCleanup: actions.scheduleCleanup,
setUnsavedChanges: actions.setHasUnsavedChanges,
// File management
addFiles: actions.addFiles,
consumeFiles: actions.consumeFiles,
recordOperation: (fileId: string, operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
// File ID lookup
findFileId: (file: File) => {
return state.files.ids.find(id => {
const record = state.files.byId[id];
return record &&
record.name === file.name &&
record.size === file.size &&
return record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified;
});
},
// Pinned files
pinnedFiles: state.pinnedFiles,
pinFile: actions.pinFile,
unpinFile: actions.unpinFile,
isFilePinned: selectors.isFilePinned,
// Active files
activeFiles: selectors.getFiles()
}), [state, selectors, actions]);

View File

@@ -2,11 +2,11 @@
* File selectors - Pure functions for accessing file state
*/
import {
FileId,
FileRecord,
import { FileId } from '../../types/file';
import {
FileRecord,
FileContextState,
FileContextSelectors
FileContextSelectors
} from '../../types/fileContext';
/**
@@ -18,62 +18,62 @@ export function createFileSelectors(
): FileContextSelectors {
return {
getFile: (id: FileId) => filesRef.current.get(id),
getFiles: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
},
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
getFileRecords: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
},
getAllFileIds: () => stateRef.current.files.ids,
getSelectedFiles: () => {
return stateRef.current.ui.selectedFileIds
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
},
getSelectedFileRecords: () => {
return stateRef.current.ui.selectedFileIds
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
},
// Pinned files selectors
getPinnedFileIds: () => {
return Array.from(stateRef.current.pinnedFiles);
},
getPinnedFiles: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
},
getPinnedFileRecords: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
},
isFilePinned: (file: File) => {
// Find FileId by matching File object properties
const fileId = Object.keys(stateRef.current.files.byId).find(id => {
const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
const storedFile = filesRef.current.get(id);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
},
// Stable signature for effects - prevents unnecessary re-renders
getFilesSignature: () => {
return stateRef.current.files.ids
@@ -122,9 +122,9 @@ export function getPrimaryFile(
): { file?: File; record?: FileRecord } {
const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {};
return {
file: filesRef.current.get(primaryFileId),
record: stateRef.current.files.byId[primaryFileId]
};
}
}

View File

@@ -2,7 +2,8 @@
* File lifecycle management - Resource cleanup and memory management
*/
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
import { FileId } from '../../types/file';
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development';
@@ -33,10 +34,10 @@ export class FileLifecycleManager {
/**
* Clean up resources for a specific file (with stateRef access for complete cleanup)
*/
cleanupFile = (fileId: string, stateRef?: React.MutableRefObject<any>): void => {
cleanupFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
// Use comprehensive cleanup (same as removeFiles)
this.cleanupAllResourcesForFile(fileId, stateRef);
// Remove file from state
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
};
@@ -54,12 +55,12 @@ export class FileLifecycleManager {
}
});
this.blobUrls.clear();
// Clear all cleanup timers and generations
this.cleanupTimers.forEach(timer => clearTimeout(timer));
this.cleanupTimers.clear();
this.fileGenerations.clear();
// Clear files ref
this.filesRef.current.clear();
};
@@ -67,7 +68,7 @@ export class FileLifecycleManager {
/**
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
*/
scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
scheduleCleanup = (fileId: FileId, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
// Cancel existing timer
const existingTimer = this.cleanupTimers.get(fileId);
if (existingTimer) {
@@ -105,7 +106,7 @@ export class FileLifecycleManager {
// Clean up all resources for this file
this.cleanupAllResourcesForFile(fileId, stateRef);
});
// Dispatch removal action once for all files (reducer only updates state)
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
};
@@ -116,7 +117,7 @@ export class FileLifecycleManager {
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
// Remove from files ref
this.filesRef.current.delete(fileId);
// Cancel cleanup timer and generation
const timer = this.cleanupTimers.get(fileId);
if (timer) {
@@ -124,7 +125,7 @@ export class FileLifecycleManager {
this.cleanupTimers.delete(fileId);
}
this.fileGenerations.delete(fileId);
// Clean up blob URLs from file record if we have access to state
if (stateRef) {
const record = stateRef.current.files.byId[fileId];
@@ -137,7 +138,7 @@ export class FileLifecycleManager {
// Ignore revocation errors
}
}
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.blobUrl);
@@ -145,7 +146,7 @@ export class FileLifecycleManager {
// Ignore revocation errors
}
}
// Clean up processed file thumbnails
if (record.processedFile?.pages) {
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
@@ -171,13 +172,13 @@ export class FileLifecycleManager {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
return;
}
// Additional state guard for rare race conditions
if (stateRef && !stateRef.current.files.byId[fileId]) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
return;
}
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
};
@@ -187,4 +188,4 @@ export class FileLifecycleManager {
destroy = (): void => {
this.cleanupAllFiles();
};
}
}