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
@@ -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,
},
};
};
};