diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts index a41027a98c..69e264a9aa 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts @@ -9,6 +9,7 @@ import { pdfExportService } from "@app/services/pdfExportService"; import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers"; import { FileId } from "@app/types/file"; import { PDFDocument } from "@app/types/pageEditor"; +import { isTauri } from "@tauri-apps/api/core"; type FileActions = ReturnType["actions"]; type FileSelectors = ReturnType["selectors"]; @@ -68,6 +69,23 @@ export const usePageEditorExport = ({ setExportLoading, setSplitPositions, }: UsePageEditorExportParams) => { + const saveBlobToLocalPath = useCallback(async (blob: Blob, filePath?: string): Promise => { + if (!filePath || !isTauri()) { + return false; + } + + const { writeFile } = await import("@tauri-apps/plugin-fs"); + + try { + const arrayBuffer = await blob.arrayBuffer(); + await writeFile(filePath, new Uint8Array(arrayBuffer)); + return true; + } catch (error) { + console.error("[PageEditor] Failed to save file to local path:", error); + return false; + } + }, []); + const getSourceFiles = useCallback((): Map | null => { const sourceFiles = new Map(); @@ -144,7 +162,13 @@ export const usePageEditorExport = ({ { selectedOnly: true, filename: exportFilename } ); - pdfExportService.downloadFile(result.blob, result.filename); + const selectedFileId = selectedFileIds.length === 1 ? selectedFileIds[0] : null; + const selectedStub = selectedFileId ? selectors.getStirlingFileStub(selectedFileId) : null; + const savedToLocalPath = await saveBlobToLocalPath(result.blob, selectedStub?.localFilePath); + + if (!savedToLocalPath) { + pdfExportService.downloadFile(result.blob, result.filename); + } setHasUnsavedChanges(false); setSplitPositions(new Set()); setExportLoading(false); @@ -158,6 +182,9 @@ export const usePageEditorExport = ({ splitPositions, getSourceFiles, getExportFilename, + selectedFileIds, + selectors, + saveBlobToLocalPath, setHasUnsavedChanges, setExportLoading, ]); @@ -207,7 +234,13 @@ export const usePageEditorExport = ({ pdfExportService.downloadFile(zipBlob, zipFilename); } else { const file = files[0]; - pdfExportService.downloadFile(file, file.name); + const selectedFileId = selectedFileIds.length === 1 ? selectedFileIds[0] : null; + const selectedStub = selectedFileId ? selectors.getStirlingFileStub(selectedFileId) : null; + const savedToLocalPath = await saveBlobToLocalPath(file, selectedStub?.localFilePath); + + if (!savedToLocalPath) { + pdfExportService.downloadFile(file, file.name); + } } setHasUnsavedChanges(false); @@ -222,6 +255,9 @@ export const usePageEditorExport = ({ splitPositions, getSourceFiles, getExportFilename, + selectedFileIds, + selectors, + saveBlobToLocalPath, setHasUnsavedChanges, setExportLoading, ]); diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx index 08e7ed3785..abe67aebcb 100644 --- a/frontend/src/core/components/shared/RightRail.tsx +++ b/frontend/src/core/components/shared/RightRail.tsx @@ -22,6 +22,8 @@ import LightModeIcon from '@mui/icons-material/LightMode'; import { useSidebarContext } from '@app/contexts/SidebarContext'; import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; +import { isTauri } from '@tauri-apps/api/core'; +import { isStirlingFile } from '@app/types/fileContext'; const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom']; @@ -62,6 +64,7 @@ export default function RightRail() { const { signaturesApplied } = useSignature(); const activeFiles = selectors.getFiles(); + const activeFileIndex = viewerContext?.activeFileIndex ?? 0; const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0; const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0; const exportState = viewerContext?.getExportState?.(); @@ -135,13 +138,48 @@ export default function RightRail() { [actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset] ); + const saveToLocalPathIfPossible = useCallback(async (file: File, filePath?: string | null) => { + if (!filePath || !isTauri()) { + return false; + } + + const { writeFile } = await import('@tauri-apps/plugin-fs'); + + try { + const arrayBuffer = await file.arrayBuffer(); + await writeFile(filePath, new Uint8Array(arrayBuffer)); + return true; + } catch (error) { + console.error('[RightRail] Failed to save file to local path:', error); + return false; + } + }, []); + const handleExportAll = useCallback(async () => { if (currentView === 'viewer') { if (!signaturesApplied) { alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.'); return; } - viewerContext?.exportActions?.download(); + const activeFile = activeFiles[activeFileIndex] ?? activeFiles[0]; + const activeFileId = isStirlingFile(activeFile) ? activeFile.fileId : undefined; + const activeStub = activeFileId ? selectors.getStirlingFileStub(activeFileId) : undefined; + const localFilePath = activeStub?.localFilePath; + + if (localFilePath && viewerContext?.exportActions?.saveAsCopy && isTauri()) { + const { writeFile } = await import('@tauri-apps/plugin-fs'); + try { + const arrayBuffer = await viewerContext.exportActions.saveAsCopy(); + if (arrayBuffer) { + await writeFile(localFilePath, new Uint8Array(arrayBuffer)); + return; + } + } catch (error) { + console.error('[RightRail] Failed to overwrite local PDF:', error); + } + } + + viewerContext?.exportActions?.download?.(); return; } @@ -151,7 +189,16 @@ export default function RightRail() { } const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; - filesToDownload.forEach(file => { + for (const file of filesToDownload) { + const fileId = isStirlingFile(file) ? file.fileId : undefined; + const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined; + const localFilePath = stub?.localFilePath; + + const savedToLocalPath = await saveToLocalPathIfPossible(file, localFilePath); + if (savedToLocalPath) { + continue; + } + const link = document.createElement('a'); link.href = URL.createObjectURL(file); link.download = file.name; @@ -159,14 +206,17 @@ export default function RightRail() { link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); - }); + } }, [ currentView, selectedFiles, activeFiles, pageEditorFunctions, viewerContext, - signaturesApplied + signaturesApplied, + activeFileIndex, + selectors, + saveToLocalPathIfPossible, ]); const downloadTooltip = useMemo(() => { diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index d28ea3a0de..4bd5eda655 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -45,6 +45,7 @@ export interface StirlingFileStub extends BaseFileMetadata { quickKey?: string; // Fast deduplication key: name|size|lastModified thumbnailUrl?: string; // Generated thumbnail blob URL for visual display blobUrl?: string; // File access blob URL for downloads/processing + localFilePath?: string; // Original local filesystem path (desktop app only) processedFile?: ProcessedFileMetadata; // PDF page data and processing results insertAfterPageId?: string; // Page ID after which this file should be inserted isPinned?: boolean; // Protected from tool consumption (replace/remove) diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index 68b5fc2191..10c1f15f4d 100644 --- a/frontend/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useOpenedFile } from '@app/hooks/useOpenedFile'; import { fileOpenService } from '@app/services/fileOpenService'; import { useFileManagement } from '@app/contexts/file/fileHooks'; +import { createQuickKey } from '@app/types/fileContext'; /** * App initialization hook @@ -11,7 +12,7 @@ import { useFileManagement } from '@app/contexts/file/fileHooks'; */ export function useAppInitialization(): void { // Get file management actions - const { addFiles } = useFileManagement(); + const { addFiles, updateStirlingFileStub } = useFileManagement(); // Handle files opened with app (Tauri mode) const { openedFilePaths, loading: openedFileLoading } = useOpenedFile(); @@ -24,28 +25,45 @@ export function useAppInitialization(): void { const loadOpenedFiles = async () => { try { - const filesArray: File[] = []; + const loadedFiles = ( + await Promise.all( + openedFilePaths.map(async (filePath) => { + try { + const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); + if (!fileData) return null; - await Promise.all( - openedFilePaths.map(async (filePath) => { - try { - const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); - if (fileData) { const file = new File([fileData.arrayBuffer], fileData.fileName, { type: 'application/pdf' }); - filesArray.push(file); - console.log('[Desktop] Loaded file:', fileData.fileName); - } - } catch (error) { - console.error('[Desktop] Failed to load file:', filePath, error); - } - }) - ); - if (filesArray.length > 0) { - await addFiles(filesArray); - console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`); + console.log('[Desktop] Loaded file:', fileData.fileName); + + return { + file, + filePath, + quickKey: createQuickKey(file), + }; + } catch (error) { + console.error('[Desktop] Failed to load file:', filePath, error); + return null; + } + }) + ) + ).filter((entry): entry is { file: File; filePath: string; quickKey: string } => Boolean(entry)); + + if (loadedFiles.length > 0) { + const filesArray = loadedFiles.map(entry => entry.file); + const quickKeyToPath = new Map(loadedFiles.map(entry => [entry.quickKey, entry.filePath])); + + const addedFiles = await addFiles(filesArray); + addedFiles.forEach(file => { + const localFilePath = quickKeyToPath.get(file.quickKey); + if (localFilePath) { + updateStirlingFileStub(file.fileId, { localFilePath }); + } + }); + + console.log(`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`); } } catch (error) { console.error('[Desktop] Failed to load opened files:', error); @@ -53,7 +71,7 @@ export function useAppInitialization(): void { }; loadOpenedFiles(); - }, [openedFilePaths, openedFileLoading, addFiles]); + }, [openedFilePaths, openedFileLoading, addFiles, updateStirlingFileStub]); } export function useSetupCompletion(): (completed: boolean) => void {