mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
co-authored by
James Brunton
James Brunton
parent
946196de43
commit
b8ce4e47c1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user