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
@@ -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(() => {