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>