Preserve local paths for desktop saves (#5543)

# Summary

- Adds desktop file tracking: local paths are preserved and save buttons
now work as expcted (doing Save/Save As as appropriate)
- Adds logic to track whether files are 'dirty' (they've been modified
by some tool, and not saved to disk yet).
- Improves file state UX (dirty vs saved) and close warnings
- Web behaviour should be unaffected by these changes

## Indicators
Files now have indicators in desktop mode to tell you their state.

### File up-to-date with disk

<img width="318" height="393" alt="image"
src="https://github.com/user-attachments/assets/06325f9a-afd7-4c2f-8a5b-6d11e3093115"
/>

### File modified by a tool but not saved to disk yet

<img width="357" height="385" alt="image"
src="https://github.com/user-attachments/assets/1a7716d9-c6f7-4d13-be0d-c1de6493954b"
/>

### File not tracked on disk

<img width="312" height="379" alt="image"
src="https://github.com/user-attachments/assets/9cffe300-bd9a-4e19-97c7-9b98bebefacc"
/>

# Limitations
- It's a bit weird that we still have files stored in indexeddb in the
app, which are still loadable. We might want to change this behaviour in
the future
- Viewer's Save doesn't persist to disk. I've left that out here because
it'd need a lot of testing to make sure the logic's right with making
sure you can leave the Viewer with applying the changes to the PDF
_without_ saving to disk
- There's no current way to do Save As on a file that has already been
persisted to disk - it's only ever Save. Similarly, there's no way to
duplicate a file.

---------

Co-authored-by: James Brunton <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Anthony Stirling
2026-02-13 23:15:28 +00:00
committed by GitHub
co-authored by James Brunton James Brunton
parent 946196de43
commit b8ce4e47c1
56 changed files with 1367 additions and 182 deletions
@@ -8,6 +8,7 @@ import { useLogoAssets } from '@app/hooks/useLogoAssets';
import styles from '@app/components/fileEditor/FileEditor.module.css';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
@@ -33,9 +34,15 @@ const AddFileCard = ({
openFilesModal();
};
const handleNativeUploadClick = (e: React.MouseEvent) => {
const handleNativeUploadClick = async (e: React.MouseEvent) => {
e.stopPropagation();
fileInputRef.current?.click();
const files = await openFilesFromDisk({
multiple,
onFallbackOpen: () => fileInputRef.current?.click()
});
if (files.length > 0) {
onFileSelect(files);
}
};
const handleOpenFilesModal = (e: React.MouseEvent) => {
@@ -179,4 +186,4 @@ const AddFileCard = ({
);
};
export default AddFileCard;
export default AddFileCard;
@@ -12,7 +12,7 @@ import AddFileCard from '@app/components/fileEditor/AddFileCard';
import FilePickerModal from '@app/components/shared/FilePickerModal';
import { FileId, StirlingFile } from '@app/types/fileContext';
import { alert } from '@app/components/toast';
import { downloadBlob } from '@app/utils/downloadUtils';
import { downloadFile } from '@app/services/downloadService';
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
@@ -278,13 +278,29 @@ const FileEditor = ({
}
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleDownloadFile = useCallback((fileId: FileId) => {
const handleDownloadFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty });
if (record && file) {
downloadBlob(file, file.name);
const result = await downloadFile({
data: file,
filename: file.name,
localPath: record.localFilePath
});
console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath });
// Mark file as clean after successful save to disk
if (result.savedPath) {
console.log('[FileEditor] Marking file as clean:', fileId);
fileActions.updateStirlingFileStub(fileId, {
localFilePath: record.localFilePath ?? result.savedPath,
isDirty: false
});
} else {
console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty });
}
}
}, [activeStirlingFileStubs, selectors, _setStatus]);
}, [activeStirlingFileStubs, selectors, fileActions]);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
@@ -0,0 +1,13 @@
import React from 'react';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface FileEditorFileNameProps {
file: StirlingFileStub;
}
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
<PrivateContent>{file.name}</PrivateContent>
);
export default FileEditorFileName;
@@ -23,6 +23,8 @@ import { FileId } from '@app/types/file';
import { formatFileSize } from '@app/utils/fileUtils';
import ToolChain from '@app/components/shared/ToolChain';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { downloadFile } from '@app/services/downloadService';
import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
import { PrivateContent } from '@app/components/shared/PrivateContent';
@@ -69,7 +71,7 @@ const FileEditorThumbnail = ({
actions: fileActions,
openEncryptedUnlockPrompt,
} = useFileContext();
const { state } = useFileState();
const { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
@@ -191,6 +193,37 @@ const FileEditorThumbnail = ({
setShowCloseModal(false);
}, [file.id, file.name, onCloseFile]);
const handleSaveAndClose = useCallback(async () => {
const fileToSave = selectors.getFile(file.id);
if (fileToSave) {
try {
const result = await downloadFile({
data: fileToSave,
filename: file.name,
localPath: file.localFilePath
});
if (!result.cancelled && result.savedPath) {
fileActions.updateStirlingFileStub(file.id, {
localFilePath: file.localFilePath ?? result.savedPath,
isDirty: false
});
} else if (result.cancelled) {
setShowCloseModal(false);
return;
}
} catch (error) {
console.error(`Failed to save ${file.name}:`, error);
alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true });
setShowCloseModal(false);
return;
}
}
// Then close
onCloseFile(file.id);
alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowCloseModal(false);
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
const handleCancelClose = useCallback(() => {
setShowCloseModal(false);
}, []);
@@ -213,7 +246,6 @@ const FileEditorThumbnail = ({
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
},
},
{
@@ -365,8 +397,8 @@ const FileEditorThumbnail = ({
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
<PrivateContent>{file.name}</PrivateContent>
<Text size="lg" fw={700} className={styles.title} lineClamp={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
<FileEditorFileName file={file} />
</Text>
<Text
size="sm"
@@ -469,18 +501,40 @@ const FileEditorThumbnail = ({
size="auto"
>
<Stack gap="md">
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseConfirm', 'Close File')}
</Button>
</Group>
{file.isDirty && file.localFilePath ? (
<>
<Text size="md">{t('confirmCloseUnsaved', 'This file has unsaved changes.')}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseDiscard', 'Discard changes and close')}
</Button>
<Button variant="filled" onClick={handleSaveAndClose}>
{t('confirmCloseSave', 'Save and close')}
</Button>
</Group>
</>
) : (
<>
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseConfirm', 'Close File')}
</Button>
</Group>
</>
)}
</Stack>
</Modal>
</div>
@@ -12,8 +12,14 @@ const FileActions: React.FC = () => {
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadIcon = icons.download;
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
useFileManagerContext();
const {
recentFiles,
selectedFileIds,
filteredFiles,
onSelectAll,
onDeleteSelected,
onDownloadSelected
} = useFileManagerContext();
const handleSelectAll = () => {
onSelectAll();
@@ -31,6 +37,7 @@ const FileActions: React.FC = () => {
}
};
// Only show actions if there are files
if (recentFiles.length === 0) {
return null;
@@ -46,7 +46,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
const { removeFiles } = useFileManagement();
// Check if this is a ZIP file
@@ -269,6 +269,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
>
{t('fileManager.delete', 'Delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
@@ -14,6 +14,7 @@ import { FileId } from '@app/types/file';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { downloadFile } from '@app/services/downloadService';
interface FileItem {
id: FileId;
@@ -79,13 +80,7 @@ const FileThumbnail = ({
// Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof File) {
const link = document.createElement('a');
link.href = URL.createObjectURL(maybeFile);
link.download = maybeFile.name || file.name || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || 'download' });
return;
}
@@ -295,6 +295,21 @@ export const usePageEditorExport = ({
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
}
if (sourceFileIds.length === 1 && newStirlingFiles.length === 1) {
const sourceStub = selectors.getStirlingFileStub(sourceFileIds[0]);
if (sourceStub?.localFilePath) {
actions.updateStirlingFileStub(newStirlingFiles[0].fileId, {
localFilePath: sourceStub.localFilePath,
isDirty: true
});
}
}
// Remove source files from context
if (sourceFileIds.length > 0) {
await actions.removeFiles(sourceFileIds, true);
}
setHasUnsavedChanges(false);
setSplitPositions(new Set());
setExportLoading(false);
@@ -14,6 +14,7 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -41,8 +42,14 @@ const LandingPage = () => {
openFilesModal();
};
const handleNativeUploadClick = () => {
fileInputRef.current?.click();
const handleNativeUploadClick = async () => {
const files = await openFilesFromDisk({
multiple: true,
onFallbackOpen: () => fileInputRef.current?.click()
});
if (files.length > 0) {
await addFiles(files);
}
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -3,7 +3,7 @@ import { ActionIcon, Divider } from '@mantine/core';
import '@app/components/shared/rightRail/RightRail.css';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useRightRail } from '@app/contexts/RightRailContext';
import { useFileState, useFileSelection } from '@app/contexts/FileContext';
import { useFileState, useFileSelection, useFileActions } from '@app/contexts/FileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
@@ -22,6 +22,7 @@ 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 { downloadFile } from '@app/services/downloadService';
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
@@ -59,6 +60,7 @@ export default function RightRail() {
const { selectors } = useFileState();
const { selectedFiles, selectedFileIds } = useFileSelection();
const { actions: fileActions } = useFileActions();
const { signaturesApplied } = useSignature();
const activeFiles = selectors.getFiles();
@@ -141,7 +143,7 @@ export default function RightRail() {
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
return;
}
viewerContext?.exportActions?.download();
viewerContext?.exportActions?.download?.();
return;
}
@@ -150,23 +152,43 @@ export default function RightRail() {
return;
}
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => {
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
});
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
const stubsToExport = selectedFiles.length > 0
? selectors.getSelectedStirlingFileStubs()
: selectors.getStirlingFileStubs();
if (filesToExport.length > 0) {
for (let i = 0; i < filesToExport.length; i++) {
const file = filesToExport[i];
const stub = stubsToExport[i];
console.log('[RightRail] Exporting file:', { fileName: file.name, stubId: stub?.id, localFilePath: stub?.localFilePath, isDirty: stub?.isDirty });
const result = await downloadFile({
data: file,
filename: file.name,
localPath: stub?.localFilePath
});
console.log('[RightRail] Export complete, checking dirty state:', { localFilePath: stub?.localFilePath, isDirty: stub?.isDirty, savedPath: result.savedPath });
// Mark file as clean after successful save to disk
if (stub && result.savedPath) {
console.log('[RightRail] Marking file as clean:', stub.id);
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false
});
} else {
console.log('[RightRail] Skipping clean mark:', { savedPath: result.savedPath, isDirty: stub?.isDirty });
}
}
}
}, [
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
signaturesApplied
signaturesApplied,
selectors,
fileActions,
]);
const downloadTooltip = useMemo(() => {
@@ -15,6 +15,7 @@ import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { downloadFromUrl } from '@app/services/downloadService';
export interface EditTableOfContentsWorkbenchViewData {
bookmarks: BookmarkNode[];
@@ -177,10 +178,8 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
<Group justify="flex-end" gap="sm">
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
download={downloadFilename ?? undefined}
leftSection={<LocalIcon icon='download-rounded' />}
onClick={() => downloadFromUrl(downloadUrl, downloadFilename ?? "download")}
>
{terminology.download}
</Button>
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
import { downloadFile } from '@app/services/downloadService';
interface GetPdfInfoResultsProps {
operation: GetPdfInfoOperationHook;
@@ -21,14 +22,7 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
const handleDownload = useCallback((file: File) => {
const blobUrl = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
void downloadFile({ data: file, filename: file.name });
}, []);
if (isLoading && operation.results.length === 0) {
@@ -76,4 +70,3 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
export default GetPdfInfoResults;
@@ -9,6 +9,9 @@ import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
import { Tooltip } from "@app/components/shared/Tooltip";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { saveOperationResults } from "@app/services/operationResultsSaveService";
import { useFileActions, useFileState } from "@app/contexts/FileContext";
import { FileId } from "@app/types/fileContext";
export interface ReviewToolStepProps<TParams = unknown> {
isVisible: boolean;
@@ -34,6 +37,8 @@ function ReviewStepContent<TParams = unknown>({
const icons = useFileActionIcons();
const DownloadIcon = icons.download;
const stepRef = useRef<HTMLDivElement>(null);
const { actions: fileActions } = useFileActions();
const { selectors } = useFileState();
const handleUndo = async () => {
try {
@@ -50,6 +55,31 @@ function ReviewStepContent<TParams = unknown>({
thumbnail: operation.thumbnails[index],
})) || [];
const handleDownload = async () => {
if (!operation.downloadUrl) return;
try {
await saveOperationResults({
downloadUrl: operation.downloadUrl,
downloadFilename: operation.downloadFilename || "download",
downloadLocalPath: operation.downloadLocalPath,
outputFileIds: operation.outputFileIds,
getFile: (fileId) => selectors.getFile(fileId as FileId),
getStub: (fileId) => selectors.getStirlingFileStub(fileId as FileId),
markSaved: (fileId, savedPath) => {
const stub = selectors.getStirlingFileStub(fileId as FileId);
fileActions.updateStirlingFileStub(fileId as FileId, {
localFilePath: stub?.localFilePath ?? savedPath,
isDirty: false
});
}
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("[ReviewToolStep] Failed to download file:", message);
alert(`Failed to download file: ${message}`);
}
};
// Auto-scroll to bottom when content appears
useEffect(() => {
if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) {
@@ -92,13 +122,11 @@ function ReviewStepContent<TParams = unknown>({
)}
{operation.downloadUrl && (
<Button
component="a"
href={operation.downloadUrl}
download={operation.downloadFilename}
leftSection={<DownloadIcon />}
color="blue"
fullWidth
mb="md"
onClick={handleDownload}
>
{terminology.download}
</Button>
@@ -1,3 +1,5 @@
import { downloadFromUrl } from "@app/services/downloadService";
export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain";
export type ShowJsToken = { type: ShowJsTokenType; text: string };
@@ -372,11 +374,6 @@ export async function copyTextToClipboard(text: string, fallbackElement?: HTMLEl
}
}
export function triggerDownload(url: string, filename: string): void {
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
export async function triggerDownload(url: string, filename: string): Promise<void> {
await downloadFromUrl(url, filename);
}
@@ -6,6 +6,7 @@ import type { ValidateSignatureOperationHook } from '@app/hooks/tools/validateSi
import '@app/components/tools/validateSignature/reportView/styles.css';
import FitText from '@app/components/shared/FitText';
import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection';
import { downloadFile } from '@app/services/downloadService';
interface ValidateSignatureResultsProps {
operation: ValidateSignatureOperationHook;
@@ -80,14 +81,7 @@ const ValidateSignatureResults = ({
];
const handleDownload = useCallback((file: File) => {
const blobUrl = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
void downloadFile({ data: file, filename: file.name });
}, []);
// Show the big loader only while we're still waiting for the first results.
@@ -5,6 +5,11 @@ import { StirlingFileStub } from '@app/types/fileContext';
import { downloadFiles } from '@app/utils/downloadUtils';
import { FileId } from '@app/types/file';
import { groupFilesByOriginal } from '@app/utils/fileHistoryUtils';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
// Module-level storage for file path mappings (quickKey -> localFilePath)
// Used to pass file paths from Tauri file dialog to FileContext
export const pendingFilePathMappings = new Map<string, string>();
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
@@ -121,6 +126,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const selectedFiles = selectedFileIds.length === 0 ? [] :
displayFiles.filter(file => selectedFilesSet.has(file.id));
const filteredFiles = !searchTerm ? displayFiles :
displayFiles.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
@@ -135,9 +141,23 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, []);
const handleLocalFileClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleLocalFileClick = useCallback(async () => {
console.log('[FileManager] Opening file dialog...');
// Try native dialog first (desktop), falls back to empty array (web)
const files = await openFilesFromDisk({
multiple: true,
onFallbackOpen: () => fileInputRef.current?.click()
});
if (files.length > 0) {
console.log('[FileManager] Passing files to FileContext:', files.map(f => f.name));
onNewFilesSelect(files);
await refreshRecentFiles();
onClose();
}
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id;
@@ -434,7 +454,6 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, [selectedFileIds, handleFileRemoveById]);
const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
+49 -4
View File
@@ -178,7 +178,7 @@ export function createChildStub(
// Copy parent metadata but exclude processedFile to prevent stale data
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
return {
const childStub = {
// Copy parent metadata (excluding processedFile)
...parentMetadata,
@@ -197,8 +197,24 @@ export function createChildStub(
thumbnailUrl: thumbnail,
// Set fresh processedFile metadata (no inheritance from parent)
processedFile: processedFileMetadata
processedFile: processedFileMetadata,
// Mark as dirty if parent has a localFilePath (modified file not yet saved to disk)
isDirty: parentStub.localFilePath ? true : undefined
};
if (DEBUG) {
console.log('[createChildStub] Created child:', {
childId: newFileId,
parentId: parentStub.id,
parentLocalFilePath: parentStub.localFilePath,
childLocalFilePath: childStub.localFilePath,
childIsDirty: childStub.isDirty,
versionNumber: newVersionNumber
});
}
return childStub;
}
interface AddFileOptions {
@@ -311,6 +327,25 @@ export async function addFiles(
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
const fileStub = createNewStirlingFileStub(file, fileId);
// Check for pending file path mapping from Tauri file dialog (desktop only)
try {
const { pendingFilePathMappings } = await import('@app/contexts/FileManagerContext');
console.log(`[FileActions] Checking for localFilePath mapping for quickKey: ${quickKey}`);
console.log(`[FileActions] Available mappings:`, Array.from(pendingFilePathMappings.keys()));
const localFilePath = pendingFilePathMappings.get(quickKey);
if (localFilePath) {
console.log(`[FileActions] ✓ Found localFilePath: ${localFilePath}`);
fileStub.localFilePath = localFilePath;
pendingFilePathMappings.delete(quickKey); // Clean up after use
console.log(`[FileActions] Applied localFilePath to file: ${file.name}`);
} else {
console.log(`[FileActions] ✗ No localFilePath found for this file`);
}
} catch (error) {
console.log('[FileActions] Could not check for localFilePath:', error);
// FileManagerContext may not be available in all contexts
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
fileStub.insertAfterPageId = options.insertAfterPageId;
@@ -558,11 +593,20 @@ export async function undoConsumeFiles(
indexedDB
);
// Mark restored files as dirty if they have localFilePath
// (they now differ from what's saved on disk)
const stubsWithDirtyMarked = inputStirlingFileStubs.map(stub => {
if (stub.localFilePath) {
return { ...stub, isDirty: true };
}
return stub;
});
// Dispatch the undo action (only if everything else succeeded)
dispatch({
type: 'UNDO_CONSUME_FILES',
payload: {
inputStirlingFileStubs,
inputStirlingFileStubs: stubsWithDirtyMarked,
outputFileIds
}
});
@@ -697,5 +741,6 @@ export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) =
resetContext: () => dispatch({ type: 'RESET_CONTEXT' }),
markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }),
clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_FILE_ERROR', payload: { fileId } }),
clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' })
clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' }),
updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) => dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } })
});
@@ -11,6 +11,7 @@ import { FILE_EVENTS } from '@app/services/errorUtils';
import { getFilenameWithoutExtension } from '@app/utils/fileUtils';
import { ResponseHandler } from '@app/utils/toolResponseProcessor';
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
import { createNewStirlingFileStub } from '@app/types/fileContext';
import { ToolOperation } from '@app/types/file';
import { ToolId } from '@app/types/toolId';
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
@@ -140,6 +141,8 @@ export interface ToolOperationHook<TParams = void> {
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
downloadFilename: string;
downloadLocalPath?: string | null;
outputFileIds?: string[] | null;
isLoading: boolean;
status: string;
errorMessage: string | null;
@@ -375,7 +378,10 @@ export const useToolOperation = <TParams>(
actions.setGeneratingThumbnails(false);
actions.setThumbnails(thumbnails);
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
const downloadLocalPath =
validFiles.length === 1 && processedFiles.length === 1
? selectors.getStirlingFileStub(validFiles[0].fileId)?.localFilePath ?? null
: null;
// Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds: FileId[] = [];
@@ -390,6 +396,9 @@ export const useToolOperation = <TParams>(
inputStirlingFileStubs.push(record);
} else {
console.warn(`No file stub found for file: ${file.name}`);
const fallbackStub = createNewStirlingFileStub(file, fileId);
inputFileIds.push(fileId);
inputStirlingFileStubs.push(fallbackStub);
}
}
@@ -437,6 +446,18 @@ export const useToolOperation = <TParams>(
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
if (toConsumeInputIds.length === 1 && outputFileIds.length === 1) {
const inputStub = selectors.getStirlingFileStub(toConsumeInputIds[0]);
if (inputStub?.localFilePath) {
fileActions.updateStirlingFileStub(outputFileIds[0], {
localFilePath: inputStub.localFilePath
});
}
}
// Pass output file IDs to download info for marking clean after save
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename, downloadLocalPath, outputFileIds);
// Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = {
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
@@ -532,7 +553,6 @@ export const useToolOperation = <TParams>(
// Undo the consume operation
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
// Clear results and operation tracking
resetResults();
lastOperationRef.current = null;
@@ -565,6 +585,8 @@ export const useToolOperation = <TParams>(
isGeneratingThumbnails: state.isGeneratingThumbnails,
downloadUrl: state.downloadUrl,
downloadFilename: state.downloadFilename,
downloadLocalPath: state.downloadLocalPath,
outputFileIds: state.outputFileIds,
isLoading: state.isLoading,
status: state.status,
errorMessage: state.errorMessage,
@@ -12,6 +12,8 @@ export interface OperationState {
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
downloadFilename: string;
downloadLocalPath?: string | null;
outputFileIds?: string[] | null;
isLoading: boolean;
status: string;
errorMessage: string | null;
@@ -23,7 +25,7 @@ type OperationAction =
| { type: 'SET_FILES'; payload: File[] }
| { type: 'SET_THUMBNAILS'; payload: string[] }
| { type: 'SET_GENERATING_THUMBNAILS'; payload: boolean }
| { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string } }
| { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string; localPath?: string | null; outputFileIds?: string[] | null } }
| { type: 'SET_STATUS'; payload: string }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_PROGRESS'; payload: ProcessingProgress | null }
@@ -36,6 +38,8 @@ const initialState: OperationState = {
isGeneratingThumbnails: false,
downloadUrl: null,
downloadFilename: '',
downloadLocalPath: null,
outputFileIds: null,
isLoading: false,
status: '',
errorMessage: null,
@@ -53,10 +57,12 @@ const operationReducer = (state: OperationState, action: OperationAction): Opera
case 'SET_GENERATING_THUMBNAILS':
return { ...state, isGeneratingThumbnails: action.payload };
case 'SET_DOWNLOAD_INFO':
return {
...state,
downloadUrl: action.payload.url,
downloadFilename: action.payload.filename
return {
...state,
downloadUrl: action.payload.url,
downloadFilename: action.payload.filename,
downloadLocalPath: action.payload.localPath ?? null,
outputFileIds: action.payload.outputFileIds ?? null
};
case 'SET_STATUS':
return { ...state, status: action.payload };
@@ -97,8 +103,8 @@ export const useToolState = () => {
dispatch({ type: 'SET_GENERATING_THUMBNAILS', payload: generating });
}, []);
const setDownloadInfo = useCallback((url: string | null, filename: string) => {
dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename } });
const setDownloadInfo = useCallback((url: string | null, filename: string, localPath?: string | null, outputFileIds?: string[] | null) => {
dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename, localPath, outputFileIds } });
}, []);
const setStatus = useCallback((status: string) => {
@@ -136,4 +142,4 @@ export const useToolState = () => {
clearError,
},
};
};
};
@@ -0,0 +1,40 @@
export interface DownloadRequest {
data: Blob | File;
filename: string;
localPath?: string;
}
export interface DownloadResult {
savedPath?: string;
cancelled?: boolean;
}
export async function downloadFile(request: DownloadRequest): Promise<DownloadResult> {
const url = URL.createObjectURL(request.data);
const link = document.createElement("a");
link.href = url;
link.download = request.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return { savedPath: request.localPath };
}
export async function downloadFromUrl(
url: string,
filename: string,
localPath?: string
): Promise<DownloadResult> {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return { savedPath: localPath };
}
@@ -0,0 +1,28 @@
// Core stub - no-op implementation for web builds
// Desktop overrides this with actual Tauri implementation
export interface FileWithPath {
file: File;
path: string;
quickKey: string;
}
export interface FileDialogOptions {
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
/**
* Open native file dialog and read selected files
* Core stub - returns empty array (no native dialog in web)
* Desktop builds override this with actual Tauri implementation
*/
export async function openFileDialog(
_options?: FileDialogOptions
): Promise<FileWithPath[]> {
// Web build: no native file dialog support
return [];
}
@@ -0,0 +1,48 @@
// Core stub - no-op implementation for web builds
// Desktop overrides this with actual Tauri implementation
export interface SaveResult {
success: boolean;
error?: string;
}
export interface MultiFileSaveResult {
success: boolean;
savedCount: number;
cancelledByUser?: boolean;
error?: string;
}
/**
* Save file data to a local filesystem path
* Core stub - always returns failure
* Desktop builds override this with actual implementation
*/
export async function saveToLocalPath(
_data: Blob | File,
_filePath: string
): Promise<SaveResult> {
return { success: false, error: "Local file save not available in web mode" };
}
/**
* Show native save dialog
* Core stub - always returns null
*/
export async function showSaveDialog(
_defaultFilename: string,
_defaultDirectory?: string
): Promise<string | null> {
return null;
}
/**
* Prompt user to select folder and save multiple files
* Core stub - always returns failure
*/
export async function saveMultipleFilesWithPrompt(
_files: (Blob | File)[],
_defaultDirectory?: string
): Promise<MultiFileSaveResult> {
return { success: false, savedCount: 0, error: "Multi-file save not available in web mode" };
}
@@ -0,0 +1,29 @@
import { openFileDialog } from '@app/services/fileDialogService';
import { pendingFilePathMappings } from '@app/contexts/FileManagerContext';
import { getDocumentFileDialogFilter } from '@app/utils/fileDialogUtils';
interface OpenFilesFromDiskOptions {
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
onFallbackOpen?: () => void;
}
export async function openFilesFromDisk(options: OpenFilesFromDiskOptions = {}): Promise<File[]> {
const filesWithPaths = await openFileDialog({
multiple: options.multiple ?? true,
filters: options.filters ?? getDocumentFileDialogFilter()
});
if (filesWithPaths.length > 0) {
for (const { quickKey, path } of filesWithPaths) {
pendingFilePathMappings.set(quickKey, path);
}
return filesWithPaths.map((entry) => entry.file);
}
options.onFallbackOpen?.();
return [];
}
@@ -0,0 +1,30 @@
import type { FileId, StirlingFileStub } from '@app/types/fileContext';
import { downloadFromUrl, DownloadResult } from '@app/services/downloadService';
export interface OperationSaveContext {
downloadUrl: string | null;
downloadFilename: string;
downloadLocalPath?: string | null;
outputFileIds?: string[] | null;
getFile: (fileId: FileId) => File | undefined;
getStub: (fileId: FileId) => StirlingFileStub | undefined;
markSaved: (fileId: FileId, savedPath?: string) => void;
}
export async function saveOperationResults(context: OperationSaveContext): Promise<DownloadResult | null> {
if (!context.downloadUrl) return null;
const result = await downloadFromUrl(
context.downloadUrl,
context.downloadFilename || 'download',
context.downloadLocalPath || undefined
);
if (context.outputFileIds && result.savedPath) {
for (const fileId of context.outputFileIds) {
context.markSaved(fileId as FileId, result.savedPath);
}
}
return result;
}
+2 -12
View File
@@ -1,4 +1,5 @@
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import { downloadFile } from '@app/services/downloadService';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
export interface ExportOptions {
@@ -181,18 +182,7 @@ export class PDFExportService {
* Download a single file
*/
downloadFile(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the URL after a short delay
setTimeout(() => URL.revokeObjectURL(url), 1000);
void downloadFile({ data: blob, filename });
}
/**
@@ -192,7 +192,7 @@ describe('Convert Tool Integration Tests', () => {
expect(result.current.downloadUrl).toBeTruthy();
expect(result.current.downloadFilename).toBe('test.png');
expect(result.current.isLoading).toBe(false);
expect(result.current.errorMessage).not.toBe(null);
expect(result.current.errorMessage).toBe(null);
});
test('should handle API error responses correctly', async () => {
@@ -478,7 +478,7 @@ describe('Convert Tool Integration Tests', () => {
expect(result.current.downloadUrl).toBeTruthy();
expect(result.current.downloadFilename).toBe('test.csv');
expect(result.current.isLoading).toBe(false);
expect(result.current.errorMessage).not.toBe(null);
expect(result.current.errorMessage).toBe(null);
});
test('should handle complete unsupported conversion workflow', async () => {
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { downloadFile } from '@app/services/downloadService';
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
import { alert } from '@app/components/toast';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
@@ -191,14 +192,7 @@ const EditTableOfContents = (props: BaseToolProps) => {
const exportJsonCallback = () => {
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'bookmarks.json';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
void downloadFile({ data: blob, filename: 'bookmarks.json' });
alert({
title: t('editTableOfContents.messages.exported', 'JSON download ready'),
alertType: 'success',
+2
View File
@@ -45,9 +45,11 @@ 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)
isDirty?: boolean; // Has unsaved changes (only for files with localFilePath)
// Note: File object stored in provider ref, not in state
}
+2 -12
View File
@@ -4,6 +4,7 @@
import { AutomationConfig } from '@app/types/automation';
import { ToolRegistry } from '@app/data/toolsTaxonomy';
import { downloadFile } from '@app/services/downloadService';
import { ToolId } from '@app/types/toolId';
/**
@@ -95,16 +96,5 @@ export function downloadFolderScanningConfig(
const config = convertToFolderScanningConfig(automation, toolRegistry);
const json = JSON.stringify(config, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${automation.name}.json`;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
void downloadFile({ data: blob, filename: `${automation.name}.json` });
}
+10 -16
View File
@@ -1,6 +1,7 @@
import { StirlingFileStub } from '@app/types/fileContext';
import { fileStorage } from '@app/services/fileStorage';
import { zipFileService } from '@app/services/zipFileService';
import { downloadFile } from '@app/services/downloadService';
/**
* Downloads a blob as a file using browser download API
@@ -8,17 +9,7 @@ import { zipFileService } from '@app/services/zipFileService';
* @param filename - The filename for the download
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the blob URL
URL.revokeObjectURL(url);
void downloadFile({ data: blob, filename });
}
/**
@@ -34,8 +25,11 @@ export async function downloadFileFromStorage(file: StirlingFileStub): Promise<v
throw new Error(`File "${file.name}" not found in storage`);
}
// StirlingFile is already a File object, just download it
downloadBlob(stirlingFile, stirlingFile.name);
await downloadFile({
data: stirlingFile,
filename: stirlingFile.name,
localPath: file.localFilePath
});
}
/**
@@ -80,7 +74,7 @@ export async function downloadFilesAsZip(files: StirlingFileStub[], zipFilename?
// Create and download ZIP
const { zipFile } = await zipFileService.createZipFromFiles(filesToZip, finalZipFilename);
downloadBlob(zipFile, finalZipFilename);
await downloadFile({ data: zipFile, filename: finalZipFilename });
}
/**
@@ -120,7 +114,7 @@ export async function downloadFiles(
* @param filename - Optional custom filename
*/
export function downloadFileObject(file: File, filename?: string): void {
downloadBlob(file, filename || file.name);
void downloadFile({ data: file, filename: filename || file.name });
}
/**
@@ -135,7 +129,7 @@ export function downloadTextAsFile(
mimeType: string = 'text/plain'
): void {
const blob = new Blob([content], { type: mimeType });
downloadBlob(blob, filename);
void downloadFile({ data: blob, filename });
}
/**
@@ -0,0 +1,6 @@
export function getDocumentFileDialogFilter() {
return [{
name: 'Documents',
extensions: ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'tiff', 'bmp', 'html', 'zip']
}];
}