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
@@ -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.