Undo Button -> review state. Result files added to recents (#4337)

Produced PDFs go into recent files
Undo button added to review state
Undo causes undoConsume which replaces result files with source files.
Removes result files from recent files too

---------

Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
ConnorYoh
2025-09-02 15:09:05 +01:00
committed by GitHub
co-authored by Connor Yoh
parent 96aa43860b
commit 1a3e8e7ecf
29 changed files with 440 additions and 96 deletions
@@ -46,6 +46,7 @@ describe('useAddPasswordOperation', () => {
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
@@ -45,6 +45,7 @@ describe('useChangePermissionsOperation', () => {
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
@@ -44,6 +44,7 @@ describe('useRemovePasswordOperation', () => {
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: vi.fn(),
};
beforeEach(() => {
@@ -21,6 +21,7 @@ interface BaseToolReturn<TParams> {
handleExecute: () => Promise<void>;
handleThumbnailClick: (file: File) => void;
handleSettingsReset: () => void;
handleUndo: () => Promise<void>;
// Standard computed state
hasFiles: boolean;
@@ -88,6 +89,11 @@ export function useBaseTool<TParams>(
onPreviewFile?.(null);
}, [operation, onPreviewFile]);
const handleUndo = useCallback(async () => {
await operation.undoOperation();
onPreviewFile?.(null);
}, [operation, onPreviewFile]);
// Standard computed state
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
@@ -109,6 +115,7 @@ export function useBaseTool<TParams>(
handleExecute,
handleThumbnailClick,
handleSettingsReset,
handleUndo,
// State
hasFiles,
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useRef, useEffect } from 'react';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext';
@@ -9,6 +9,7 @@ import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker';
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { FileId } from '../../../types/file';
import { FileRecord } from '../../../types/fileContext';
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
@@ -107,6 +108,7 @@ export interface ToolOperationHook<TParams = void> {
resetResults: () => void;
clearError: () => void;
cancelOperation: () => void;
undoOperation: () => Promise<void>;
}
// Re-export for backwards compatibility
@@ -128,13 +130,20 @@ export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId } = useFileContext();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
// Track last operation for undo functionality
const lastOperationRef = useRef<{
inputFiles: File[];
inputFileRecords: FileRecord[];
outputFileIds: FileId[];
} | null>(null);
const executeOperation = useCallback(async (
params: TParams,
selectedFiles: File[]
@@ -232,8 +241,33 @@ export const useToolOperation = <TParams>(
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as FileId[];
await consumeFiles(inputFileIds, processedFiles);
const inputFileIds: FileId[] = [];
const inputFileRecords: FileRecord[] = [];
// Build parallel arrays of IDs and records for undo tracking
for (const file of validFiles) {
const fileId = findFileId(file);
if (fileId) {
const record = selectors.getFileRecord(fileId);
if (record) {
inputFileIds.push(fileId);
inputFileRecords.push(record);
} else {
console.warn(`No file record found for file: ${file.name}`);
}
} else {
console.warn(`No file ID found for file: ${file.name}`);
}
}
const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
// Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = {
inputFiles: validFiles, // Keep original File objects for undo
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
outputFileIds
};
markOperationApplied(fileId, operationId);
}
@@ -259,8 +293,65 @@ export const useToolOperation = <TParams>(
const resetResults = useCallback(() => {
cleanupBlobUrls();
actions.resetResults();
// Clear undo data when results are reset to prevent memory leaks
lastOperationRef.current = null;
}, [cleanupBlobUrls, actions]);
// Cleanup on unmount to prevent memory leaks
useEffect(() => {
return () => {
lastOperationRef.current = null;
};
}, []);
const undoOperation = useCallback(async () => {
if (!lastOperationRef.current) {
actions.setError(t('noOperationToUndo', 'No operation to undo'));
return;
}
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
// Validate that we have data to undo
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
return;
}
if (outputFileIds.length === 0) {
actions.setError(t('noFilesToUndo', 'Cannot undo: no files were processed in the last operation'));
return;
}
try {
// Undo the consume operation
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
// Clear results and operation tracking
resetResults();
lastOperationRef.current = null;
// Show success message
actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
} catch (error: any) {
let errorMessage = extractErrorMessage(error);
// Provide more specific error messages based on error type
if (error.message?.includes('Mismatch between input files')) {
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
} else if (error.message?.includes('IndexedDB')) {
errorMessage = t('undoStorageError', 'Undo completed but some files could not be saved to storage');
} else if (error.name === 'QuotaExceededError') {
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
}
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
// Don't clear the operation data if undo failed - user might want to try again
}
}, [undoConsumeFiles, resetResults, actions, t]);
return {
// State
files: state.files,
@@ -277,6 +368,7 @@ export const useToolOperation = <TParams>(
executeOperation,
resetResults,
clearError: actions.clearError,
cancelOperation
cancelOperation,
undoOperation
};
};