diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index dd65b777a..f18dd96c4 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -10,7 +10,8 @@
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)",
- "Bash(npx tsc:*)"
+ "Bash(npx tsc:*)",
+ "Bash(node:*)"
],
"deny": [],
"defaultMode": "acceptEdits"
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 852204b25..45b7f3045 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import { FileContextProvider } from './contexts/FileContext';
+import { NavigationProvider } from './contexts/NavigationContext';
import { FilesModalProvider } from './contexts/FilesModalContext';
import HomePage from './pages/HomePage';
@@ -12,9 +13,11 @@ export default function App() {
return (
-
-
-
+
+
+
+
+
);
diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx
index 8d6a2243d..657c807fd 100644
--- a/frontend/src/components/layout/Workbench.tsx
+++ b/frontend/src/components/layout/Workbench.tsx
@@ -5,6 +5,7 @@ import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState, useFileActions } from '../../contexts/FileContext';
+import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import TopControls from '../shared/TopControls';
import FileEditor from '../fileEditor/FileEditor';
@@ -22,9 +23,10 @@ export default function Workbench() {
// Use context-based hooks to eliminate all prop drilling
const { state } = useFileState();
const { actions } = useFileActions();
+ const { currentMode: currentView } = useNavigationState();
+ const { actions: navActions } = useNavigationActions();
+ const setCurrentView = navActions.setMode;
const activeFiles = state.files.ids;
- const currentView = state.ui.currentMode;
- const setCurrentView = actions.setCurrentMode;
const {
previewFile,
pageEditorFunctions,
@@ -51,7 +53,7 @@ export default function Workbench() {
handleToolSelect('convert');
sessionStorage.removeItem('previousMode');
} else {
- actions.setMode('fileEditor');
+ setCurrentView('fileEditor');
}
};
@@ -73,11 +75,11 @@ export default function Workbench() {
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolKey && {
onOpenPageEditor: (file) => {
- actions.setMode("pageEditor");
+ setCurrentView("pageEditor");
},
onMergeFiles: (filesToMerge) => {
filesToMerge.forEach(addToActiveFiles);
- actions.setMode("viewer");
+ setCurrentView("viewer");
}
})}
/>
diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx
index 3e9fd2206..ac2290dc1 100644
--- a/frontend/src/components/pageEditor/DragDropGrid.tsx
+++ b/frontend/src/components/pageEditor/DragDropGrid.tsx
@@ -65,7 +65,7 @@ const DragDropGrid = ({
}, [items.length, isLargeDocument, BUFFER_SIZE]);
// Throttled scroll handler to prevent excessive re-renders
- const throttleRef = useRef();
+ const throttleRef = useRef(undefined);
// Detect scroll position from parent container
useEffect(() => {
diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx
index 99accb3f1..c4d0456b8 100644
--- a/frontend/src/components/pageEditor/PageEditor.tsx
+++ b/frontend/src/components/pageEditor/PageEditor.tsx
@@ -6,7 +6,7 @@ import {
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
-import { ModeType } from "../../types/fileContext";
+import { ModeType } from "../../contexts/NavigationContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo";
diff --git a/frontend/src/components/shared/NavigationWarningModal.tsx b/frontend/src/components/shared/NavigationWarningModal.tsx
index 3a7bf25b5..c7f591c71 100644
--- a/frontend/src/components/shared/NavigationWarningModal.tsx
+++ b/frontend/src/components/shared/NavigationWarningModal.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
-import { useFileState, useFileActions } from '../../contexts/FileContext';
+import { useNavigationGuard } from '../../contexts/NavigationContext';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise;
@@ -11,34 +11,37 @@ const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
- const { state } = useFileState();
- const { actions } = useFileActions();
- const showNavigationWarning = state.ui.showNavigationWarning;
- const hasUnsavedChanges = state.ui.hasUnsavedChanges;
+ const {
+ showNavigationWarning,
+ hasUnsavedChanges,
+ cancelNavigation,
+ confirmNavigation,
+ setHasUnsavedChanges
+ } = useNavigationGuard();
const handleKeepWorking = () => {
- actions.cancelNavigation();
+ cancelNavigation();
};
const handleDiscardChanges = () => {
- actions.setHasUnsavedChanges(false);
- actions.confirmNavigation();
+ setHasUnsavedChanges(false);
+ confirmNavigation();
};
const handleApplyAndContinue = async () => {
if (onApplyAndContinue) {
await onApplyAndContinue();
}
- actions.setHasUnsavedChanges(false);
- actions.confirmNavigation();
+ setHasUnsavedChanges(false);
+ confirmNavigation();
};
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
- actions.setHasUnsavedChanges(false);
- actions.confirmNavigation();
+ setHasUnsavedChanges(false);
+ confirmNavigation();
};
if (!hasUnsavedChanges) {
diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx
index 0ea6dd10c..ee5591694 100644
--- a/frontend/src/components/shared/TopControls.tsx
+++ b/frontend/src/components/shared/TopControls.tsx
@@ -10,7 +10,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
-import { ModeType } from '../../types/fileContext';
+import { ModeType } from '../../contexts/NavigationContext';
// Stable view option objects that don't recreate on every render
const VIEW_OPTIONS_BASE = [
diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx
index edae69dbe..9c738d301 100644
--- a/frontend/src/contexts/FileContext.tsx
+++ b/frontend/src/contexts/FileContext.tsx
@@ -1,245 +1,36 @@
/**
- * Refactored FileContext with reducer pattern and normalized state
+ * FileContext - Manages PDF files for Stirling PDF multi-tool workflow
*
- * PERFORMANCE IMPROVEMENTS:
- * - Normalized state: File objects stored in refs, only IDs in state
- * - Pure reducer: No object creation in reducer functions
- * - Split contexts: StateContext vs ActionsContext prevents unnecessary rerenders
- * - Individual selector hooks: Avoid selector object recreation
- * - Stable actions: useCallback + stateRef prevents action recreation
- * - Throttled persistence: Debounced localStorage writes
- * - Proper resource cleanup: Automatic blob URL revocation
+ * 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.
*
- * USAGE:
- * - State access: useFileState(), useFileRecord(), useFileSelection()
- * - Actions only: useFileActions(), useFileManagement(), useViewerActions()
- * - Combined: useFileContext() (legacy - causes rerenders on any state change)
- * - FileRecord is the new lightweight "processed file" - no heavy processing needed
+ * Key hooks:
+ * - useFileState() - access file state and UI state
+ * - useFileActions() - file operations (add/remove/update)
+ * - useToolFileSelection() - for tool components
*
- * PERFORMANCE NOTES:
- * - useFileState() still rerenders on ANY state change (selectors object recreation)
- * - For list UIs: consider ids-only context or use-context-selector
- * - Individual hooks (useFileRecord) are the most performant option
+ * Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
*/
-import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
+import React, { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
import {
- FileContextState,
FileContextProviderProps,
FileContextSelectors,
FileContextStateValue,
FileContextActionsValue,
FileContextActions,
- FileContextAction,
- ModeType,
FileId,
- FileRecord,
- toFileRecord,
- revokeFileResources,
- createFileId,
- createQuickKey
+ FileRecord
} from '../types/fileContext';
-import { FileMetadata } from '../types/file';
-// Import real services
-import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
-import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
-import { fileStorage } from '../services/fileStorage';
-import { fileProcessingService } from '../services/fileProcessingService';
-import { generateThumbnailWithMetadata } from '../utils/thumbnailUtils';
+// Import modular components
+import { fileContextReducer, initialFileContextState } from './file/FileReducer';
+import { createFileSelectors } from './file/fileSelectors';
+import { addFiles, createFileActions } from './file/fileActions';
+import { FileLifecycleManager } from './file/lifecycle';
+import { FileStateContext, FileActionsContext } from './file/contexts';
-// Get service instances
-const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
-
-// Initial state
-const initialFileContextState: FileContextState = {
- files: {
- ids: [],
- byId: {}
- },
- ui: {
- currentMode: 'pageEditor' as ModeType,
- selectedFileIds: [],
- selectedPageNumbers: [],
- isProcessing: false,
- processingProgress: 0,
- hasUnsavedChanges: false,
- pendingNavigation: null,
- showNavigationWarning: false
- }
-};
-
-// Reducer
-function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
- switch (action.type) {
- case 'ADD_FILES': {
- const { fileRecords } = action.payload;
- const newIds: FileId[] = [];
- const newById: Record = { ...state.files.byId };
-
- fileRecords.forEach(record => {
- // Only add if not already present (dedupe by stable ID)
- if (!newById[record.id]) {
- newIds.push(record.id);
- newById[record.id] = record;
- }
- });
-
- return {
- ...state,
- files: {
- ids: [...state.files.ids, ...newIds],
- byId: newById
- }
- };
- }
-
- case 'REMOVE_FILES': {
- const { fileIds } = action.payload;
- const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
- const newById = { ...state.files.byId };
-
- // Clean up removed files
- fileIds.forEach(id => {
- const record = newById[id];
- if (record) {
- revokeFileResources(record);
- delete newById[id];
- }
- });
-
- return {
- ...state,
- files: {
- ids: remainingIds,
- byId: newById
- },
- ui: {
- ...state.ui,
- selectedFileIds: state.ui.selectedFileIds.filter(id => !fileIds.includes(id))
- }
- };
- }
-
- case 'UPDATE_FILE_RECORD': {
- const { id, updates } = action.payload;
- const existingRecord = state.files.byId[id];
- if (!existingRecord) return state;
-
- // Immutable merge supports all FileRecord fields
- return {
- ...state,
- files: {
- ...state.files,
- byId: {
- ...state.files.byId,
- [id]: { ...existingRecord, ...updates }
- }
- }
- };
- }
-
- case 'SET_CURRENT_MODE': {
- return {
- ...state,
- ui: {
- ...state.ui,
- currentMode: action.payload
- }
- };
- }
-
-
- case 'SET_SELECTED_FILES': {
- return {
- ...state,
- ui: {
- ...state.ui,
- selectedFileIds: action.payload.fileIds
- }
- };
- }
-
- case 'SET_SELECTED_PAGES': {
- return {
- ...state,
- ui: {
- ...state.ui,
- selectedPageNumbers: action.payload.pageNumbers
- }
- };
- }
-
- case 'CLEAR_SELECTIONS': {
- return {
- ...state,
- ui: {
- ...state.ui,
- selectedFileIds: [],
- selectedPageNumbers: []
- }
- };
- }
-
- case 'SET_PROCESSING': {
- return {
- ...state,
- ui: {
- ...state.ui,
- isProcessing: action.payload.isProcessing,
- processingProgress: action.payload.progress || 0
- }
- };
- }
-
- case 'SET_UNSAVED_CHANGES': {
- return {
- ...state,
- ui: {
- ...state.ui,
- hasUnsavedChanges: action.payload.hasChanges
- }
- };
- }
-
- case 'SET_PENDING_NAVIGATION': {
- return {
- ...state,
- ui: {
- ...state.ui,
- pendingNavigation: action.payload.navigationFn
- }
- };
- }
-
- case 'SHOW_NAVIGATION_WARNING': {
- return {
- ...state,
- ui: {
- ...state.ui,
- showNavigationWarning: action.payload.show
- }
- };
- }
-
-
- case 'RESET_CONTEXT': {
- // Clean up all resources before reset
- Object.values(state.files.byId).forEach(revokeFileResources);
- return { ...initialFileContextState };
- }
-
- default:
- return state;
- }
-}
-
-// Split contexts for performance
-const FileStateContext = createContext(undefined);
-const FileActionsContext = createContext(undefined);
-
-// Legacy context for backward compatibility
-const FileContext = createContext(undefined);
+const DEBUG = process.env.NODE_ENV === 'development';
// Provider component
export function FileContextProvider({
@@ -252,748 +43,116 @@ export function FileContextProvider({
// File ref map - stores File objects outside React state
const filesRef = useRef