Chore/v2/improve uploads (#5351)

Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
Reece Browne 2026-01-06 11:54:14 -05:00 committed by GitHub
parent d9caa3482c
commit c158664d2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 160 additions and 116 deletions

View File

@ -43,7 +43,7 @@ const FileEditor = ({
const { clearAllFileErrors } = fileContextActions;
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [state.files.byId, state.files.ids]);
const selectedFileIds = state.ui.selectedFileIds;
const totalItems = state.files.ids.length;
const selectedCount = selectedFileIds.length;
@ -365,7 +365,7 @@ const FileEditor = ({
activateOnDrag={true}
>
<Box pos="relative" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<LoadingOverlay visible={state.ui.isProcessing} />
<Box p="md">

View File

@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from '@mantine/core';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
@ -389,7 +389,7 @@ const FileEditorThumbnail = ({
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
{file.thumbnailUrl ? (
<PrivateContent>
<img
src={file.thumbnailUrl}
@ -416,7 +416,12 @@ const FileEditorThumbnail = ({
}}
/>
</PrivateContent>
)}
) : file.type?.startsWith('application/pdf') ? (
<Stack align="center" justify="center" gap="xs" style={{ height: '100%' }}>
<Loader size="sm" />
<Text size="xs" c="dimmed">Loading thumbnail...</Text>
</Stack>
) : null}
</div>
{/* Drag handle (span wrapper so we can attach a ref reliably) */}

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
@ -29,6 +29,10 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
// Show loading state during hydration: PDF file without thumbnail yet
const isPdf = file.type === 'application/pdf';
const isHydrating = isPdf && !thumb && !isGenerating;
return (
<Card
shadow="xs"
@ -125,24 +129,11 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
fit="contain"
radius="sm"
/>
) : isGenerating ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: 8
}} />
<Text size="xs" c="dimmed">Generating...</Text>
</div>
) : (isGenerating || isHydrating) ? (
<Stack align="center" justify="center" gap="xs">
<Loader size="sm" />
<Text size="xs" c="dimmed">Loading...</Text>
</Stack>
) : (
<div style={{
display: 'flex',

View File

@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
import { Button, Text, Stack, Group, Card, Progress, Loader } from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import CheckIcon from "@mui/icons-material/Check";
import { useFileSelection } from "@app/contexts/FileContext";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
import { AUTOMATION_CONSTANTS, EXECUTION_STATUS } from "@app/constants/automation";
import { EXECUTION_STATUS } from "@app/constants/automation";
import { useResourceCleanup } from "@app/utils/resourceManager";
interface AutomationRunProps {
@ -119,14 +119,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
case EXECUTION_STATUS.ERROR:
return <span style={{ fontSize: 16, color: 'red' }}></span>;
case EXECUTION_STATUS.RUNNING:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderTop: '2px solid #007bff',
borderRadius: '50%',
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
}} />;
return <Loader size={16} />;
default:
return <div style={{
width: 16,

View File

@ -20,6 +20,31 @@ import { StirlingFile } from '@app/types/fileContext';
import { fileStorage } from '@app/services/fileStorage';
import { zipFileService } from '@app/services/zipFileService';
const DEBUG = process.env.NODE_ENV === 'development';
const HYDRATION_CONCURRENCY = 2;
let activeHydrations = 0;
const hydrationQueue: Array<() => Promise<void>> = [];
const scheduleMetadataHydration = (task: () => Promise<void>): void => {
hydrationQueue.push(task);
// Defer start to next tick to ensure initial ADD_FILES dispatch completes
Promise.resolve().then(drainHydrationQueue);
};
const drainHydrationQueue = (): void => {
if (activeHydrations >= HYDRATION_CONCURRENCY) return;
const nextTask = hydrationQueue.shift();
if (!nextTask) return;
activeHydrations++;
nextTask()
.catch(() => {
// Silently handle hydration failures
})
.finally(() => {
activeHydrations--;
drainHydrationQueue();
});
};
/**
* Simple mutex to prevent race conditions in addFiles
@ -210,12 +235,12 @@ export async function addFiles(
try {
const stirlingFileStubs: StirlingFileStub[] = [];
const stirlingFiles: StirlingFile[] = [];
// Hydration tasks are scheduled per-file to update thumbnails/metadata without blocking add flow
// Build quickKey lookup from existing files for deduplication
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
const { files = [] } = options;
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
// ZIP pre-processing: Extract ZIP files with configurable behavior
// - File uploads: skipAutoUnzip=true → always extract (except HTML)
@ -275,43 +300,14 @@ export async function addFiles(
// 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 processedFile metadata using centralized function
const processedFileMetadata = await generateProcessedFileMetadata(file);
// Extract thumbnail for non-PDF files or use from processedFile for PDFs
let thumbnail: string | undefined;
if (processedFileMetadata) {
// PDF file - use thumbnail from processedFile metadata
thumbnail = processedFileMetadata.thumbnailUrl;
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${processedFileMetadata.totalPages} pages, thumbnail: SUCCESS`);
} else if (!file.type.startsWith('application/pdf')) {
// Non-PDF files: simple thumbnail generation, no processedFile metadata
try {
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
const { generateThumbnailForFile } = await import('@app/utils/thumbnailUtils');
thumbnail = await generateThumbnailForFile(file);
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
}
}
// Create new filestub with processedFile metadata
const fileStub = createNewStirlingFileStub(file, fileId, thumbnail, processedFileMetadata);
if (thumbnail) {
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(thumbnail);
}
}
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
const fileStub = createNewStirlingFileStub(file, fileId);
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
@ -321,9 +317,53 @@ export async function addFiles(
existingQuickKeys.add(quickKey);
stirlingFileStubs.push(fileStub);
// Dispatch immediately so each file appears as soon as it is processed
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: [fileStub] } });
// Create StirlingFile directly
const stirlingFile = createStirlingFile(file, fileId);
stirlingFiles.push(stirlingFile);
// Queue background hydration so add flow doesn't block on thumbnail/metadata work
scheduleMetadataHydration(async () => {
const targetFile = filesRef.current.get(fileId);
if (!targetFile) {
return;
}
let processedFileMetadata: ProcessedFileMetadata | undefined;
let thumbnail: string | undefined;
if (targetFile.type.startsWith('application/pdf')) {
processedFileMetadata = await generateProcessedFileMetadata(targetFile);
thumbnail = processedFileMetadata?.thumbnailUrl;
} else {
try {
const { generateThumbnailForFile } = await import('@app/utils/thumbnailUtils');
thumbnail = await generateThumbnailForFile(targetFile);
} catch {
// Silently handle thumbnail generation failures
}
}
const updates: Partial<StirlingFileStub> = {};
const primaryThumbnail = thumbnail || processedFileMetadata?.thumbnailUrl || processedFileMetadata?.pages?.[0]?.thumbnail;
if (processedFileMetadata) {
updates.processedFile = processedFileMetadata;
updates.thumbnailUrl = primaryThumbnail;
} else if (thumbnail) {
updates.thumbnailUrl = primaryThumbnail;
}
if (primaryThumbnail && primaryThumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(primaryThumbnail);
}
if (Object.keys(updates).length > 0) {
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef);
}
});
}
// Persist to storage if enabled using fileStorage service
@ -343,11 +383,6 @@ export async function addFiles(
}));
}
// Dispatch ADD_FILES action if we have new files
if (stirlingFileStubs.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
}
return stirlingFiles;
} finally {
// Always release mutex even if error occurs
@ -557,16 +592,21 @@ export async function addStirlingFileStubs(
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>,
_lifecycleManager: FileLifecycleManager
lifecycleManager: FileLifecycleManager
): Promise<StirlingFile[]> {
await addFilesMutex.lock();
try {
// Show loading indicator while preparing files from storage
if (stirlingFileStubs.length > 0) {
dispatch({ type: 'SET_PROCESSING', payload: { isProcessing: true, progress: 0 } });
}
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
const validStubs: StirlingFileStub[] = [];
const loadedFiles: StirlingFile[] = [];
let firstFileDispatched = false;
// Process and dispatch files one by one for progressive UI updates
for (const stub of stirlingFileStubs) {
// Check for duplicates using quickKey
if (existingQuickKeys.has(stub.quickKey || '')) {
@ -574,16 +614,6 @@ export async function addStirlingFileStubs(
continue;
}
// Load the actual StirlingFile from storage
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
if (!stirlingFile) {
console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${stub.id})`);
continue;
}
// Store the loaded file in filesRef
filesRef.current.set(stub.id, stirlingFile);
// Use the original stub (preserves thumbnails, history, metadata!)
const record = { ...stub };
@ -592,38 +622,60 @@ export async function addStirlingFileStubs(
record.insertAfterPageId = options.insertAfterPageId;
}
// Check if processedFile data needs regeneration for proper Page Editor support
if (stirlingFile.type.startsWith('application/pdf')) {
const needsProcessing = !record.processedFile ||
!record.processedFile.pages ||
record.processedFile.pages.length === 0 ||
record.processedFile.totalPages !== record.processedFile.pages.length;
existingQuickKeys.add(stub.quickKey || '');
if (needsProcessing) {
// Dispatch each file immediately as we process it (progressive loading)
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: [record] } });
// Use centralized metadata generation function
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
if (processedFileMetadata) {
record.processedFile = processedFileMetadata;
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
} else {
// Fallback for files that couldn't be processed
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
if (!record.processedFile) {
record.processedFile = createProcessedFile(1); // Fallback to 1 page
// Clear loading indicator after first file appears
if (!firstFileDispatched) {
firstFileDispatched = true;
dispatch({ type: 'SET_PROCESSING', payload: { isProcessing: false, progress: 0 } });
}
// Load File object and hydrate metadata in background (non-blocking)
const fileId = stub.id;
// Load File object from IndexedDB asynchronously
scheduleMetadataHydration(async () => {
const stirlingFile = await fileStorage.getStirlingFile(fileId);
if (!stirlingFile) {
console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${fileId})`);
return;
}
// Store the loaded file in filesRef
filesRef.current.set(fileId, stirlingFile);
// Check if processedFile data needs regeneration
if (stirlingFile.type.startsWith('application/pdf')) {
const needsProcessing = !stub.processedFile ||
!stub.processedFile.pages ||
stub.processedFile.pages.length === 0 ||
stub.processedFile.totalPages !== stub.processedFile.pages.length;
if (needsProcessing) {
// Regenerate metadata
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
if (processedFileMetadata) {
const updates: Partial<StirlingFileStub> = {
processedFile: processedFileMetadata
};
// Update thumbnail only if current stub doesn't have one
const currentStub = stateRef.current.files.byId[fileId];
if (!currentStub?.thumbnailUrl && processedFileMetadata.thumbnailUrl) {
updates.thumbnailUrl = processedFileMetadata.thumbnailUrl;
if (processedFileMetadata.thumbnailUrl.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(processedFileMetadata.thumbnailUrl);
}
}
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef);
}
}
}
}
existingQuickKeys.add(stub.quickKey || '');
validStubs.push(record);
loadedFiles.push(stirlingFile);
}
// Dispatch ADD_FILES action if we have new files
if (validStubs.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
});
}
return loadedFiles;

View File

@ -42,10 +42,12 @@ export function useCurrentFile(): { file?: File; record?: StirlingFileStub } {
const { state, selectors } = useFileState();
const primaryFileId = state.files.ids[0];
const primaryFileRecord = primaryFileId ? state.files.byId[primaryFileId] : undefined;
return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
}), [primaryFileId, selectors]);
}), [primaryFileId, primaryFileRecord, selectors]);
}
/**
@ -58,7 +60,7 @@ export function useFileSelection() {
// Memoize selected files to avoid recreating arrays
const selectedFiles = useMemo(() => {
return selectors.getSelectedFiles();
}, [state.ui.selectedFileIds, selectors]);
}, [state.ui.selectedFileIds, state.files.byId, selectors]);
return useMemo(() => ({
selectedFiles,
@ -112,12 +114,13 @@ export function useFileUI() {
* Hook for specific file by ID (optimized for individual file access)
*/
export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } {
const { selectors } = useFileState();
const { state, selectors } = useFileState();
const fileRecord = state.files.byId[fileId];
return useMemo(() => ({
file: selectors.getFile(fileId),
record: selectors.getStirlingFileStub(fileId)
}), [fileId, selectors]);
}), [fileId, fileRecord, selectors]);
}
/**
@ -130,7 +133,7 @@ export function useAllFiles(): { files: StirlingFile[]; fileStubs: StirlingFileS
files: selectors.getFiles(),
fileStubs: selectors.getStirlingFileStubs(),
fileIds: state.files.ids
}), [state.files.ids, selectors]);
}), [state.files.ids, state.files.byId, selectors]);
}
/**
@ -143,7 +146,7 @@ export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedFil
selectedFiles: selectors.getSelectedFiles(),
selectedFileStubs: selectors.getSelectedStirlingFileStubs(),
selectedFileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}), [state.ui.selectedFileIds, state.files.byId, selectors]);
}
// Navigation management removed - moved to NavigationContext