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
@@ -0,0 +1,40 @@
export interface DownloadRequest {
data: Blob | File;
filename: string;
localPath?: string;
}
export interface DownloadResult {
savedPath?: string;
cancelled?: boolean;
}
export async function downloadFile(request: DownloadRequest): Promise<DownloadResult> {
const url = URL.createObjectURL(request.data);
const link = document.createElement("a");
link.href = url;
link.download = request.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return { savedPath: request.localPath };
}
export async function downloadFromUrl(
url: string,
filename: string,
localPath?: string
): Promise<DownloadResult> {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return { savedPath: localPath };
}
@@ -0,0 +1,28 @@
// Core stub - no-op implementation for web builds
// Desktop overrides this with actual Tauri implementation
export interface FileWithPath {
file: File;
path: string;
quickKey: string;
}
export interface FileDialogOptions {
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
}
/**
* Open native file dialog and read selected files
* Core stub - returns empty array (no native dialog in web)
* Desktop builds override this with actual Tauri implementation
*/
export async function openFileDialog(
_options?: FileDialogOptions
): Promise<FileWithPath[]> {
// Web build: no native file dialog support
return [];
}
@@ -0,0 +1,48 @@
// Core stub - no-op implementation for web builds
// Desktop overrides this with actual Tauri implementation
export interface SaveResult {
success: boolean;
error?: string;
}
export interface MultiFileSaveResult {
success: boolean;
savedCount: number;
cancelledByUser?: boolean;
error?: string;
}
/**
* Save file data to a local filesystem path
* Core stub - always returns failure
* Desktop builds override this with actual implementation
*/
export async function saveToLocalPath(
_data: Blob | File,
_filePath: string
): Promise<SaveResult> {
return { success: false, error: "Local file save not available in web mode" };
}
/**
* Show native save dialog
* Core stub - always returns null
*/
export async function showSaveDialog(
_defaultFilename: string,
_defaultDirectory?: string
): Promise<string | null> {
return null;
}
/**
* Prompt user to select folder and save multiple files
* Core stub - always returns failure
*/
export async function saveMultipleFilesWithPrompt(
_files: (Blob | File)[],
_defaultDirectory?: string
): Promise<MultiFileSaveResult> {
return { success: false, savedCount: 0, error: "Multi-file save not available in web mode" };
}
@@ -0,0 +1,29 @@
import { openFileDialog } from '@app/services/fileDialogService';
import { pendingFilePathMappings } from '@app/contexts/FileManagerContext';
import { getDocumentFileDialogFilter } from '@app/utils/fileDialogUtils';
interface OpenFilesFromDiskOptions {
multiple?: boolean;
filters?: Array<{
name: string;
extensions: string[];
}>;
onFallbackOpen?: () => void;
}
export async function openFilesFromDisk(options: OpenFilesFromDiskOptions = {}): Promise<File[]> {
const filesWithPaths = await openFileDialog({
multiple: options.multiple ?? true,
filters: options.filters ?? getDocumentFileDialogFilter()
});
if (filesWithPaths.length > 0) {
for (const { quickKey, path } of filesWithPaths) {
pendingFilePathMappings.set(quickKey, path);
}
return filesWithPaths.map((entry) => entry.file);
}
options.onFallbackOpen?.();
return [];
}
@@ -0,0 +1,30 @@
import type { FileId, StirlingFileStub } from '@app/types/fileContext';
import { downloadFromUrl, DownloadResult } from '@app/services/downloadService';
export interface OperationSaveContext {
downloadUrl: string | null;
downloadFilename: string;
downloadLocalPath?: string | null;
outputFileIds?: string[] | null;
getFile: (fileId: FileId) => File | undefined;
getStub: (fileId: FileId) => StirlingFileStub | undefined;
markSaved: (fileId: FileId, savedPath?: string) => void;
}
export async function saveOperationResults(context: OperationSaveContext): Promise<DownloadResult | null> {
if (!context.downloadUrl) return null;
const result = await downloadFromUrl(
context.downloadUrl,
context.downloadFilename || 'download',
context.downloadLocalPath || undefined
);
if (context.outputFileIds && result.savedPath) {
for (const fileId of context.outputFileIds) {
context.markSaved(fileId as FileId, result.savedPath);
}
}
return result;
}
+2 -12
View File
@@ -1,4 +1,5 @@
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import { downloadFile } from '@app/services/downloadService';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
export interface ExportOptions {
@@ -181,18 +182,7 @@ export class PDFExportService {
* Download a single file
*/
downloadFile(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the URL after a short delay
setTimeout(() => URL.revokeObjectURL(url), 1000);
void downloadFile({ data: blob, filename });
}
/**