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,39 @@
import type { DownloadRequest, DownloadResult } from "@core/services/downloadService";
import { saveToLocalPath, showSaveDialog } from "@app/services/localFileSaveService";
export type { DownloadRequest, DownloadResult };
export async function downloadFile(request: DownloadRequest): Promise<DownloadResult> {
if (request.localPath) {
const result = await saveToLocalPath(request.data, request.localPath);
if (!result.success) {
throw new Error(result.error || "Failed to save file");
}
return { savedPath: request.localPath };
}
const savePath = await showSaveDialog(request.filename);
if (!savePath) {
return { cancelled: true };
}
const result = await saveToLocalPath(request.data, savePath);
if (!result.success) {
throw new Error(result.error || "Failed to save file");
}
return { savedPath: savePath };
}
export async function downloadFromUrl(
url: string,
filename: string,
localPath?: string
): Promise<DownloadResult> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Download failed (${response.status})`);
}
const blob = await response.blob();
return downloadFile({ data: blob, filename, localPath });
}
@@ -0,0 +1,60 @@
// Desktop implementation - Tauri native file dialogs
import type { FileWithPath, FileDialogOptions } from '@core/services/fileDialogService';
import { createQuickKey } from '@app/types/fileContext';
import { getDocumentFileDialogFilter } from '@app/utils/fileDialogUtils';
export type { FileWithPath, FileDialogOptions };
/**
* Open native file dialog and read selected files (Desktop/Tauri only)
*/
export async function openFileDialog(
options?: FileDialogOptions
): Promise<FileWithPath[]> {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const { readFile } = await import('@tauri-apps/plugin-fs');
console.log('[FileDialog] Opening file dialog...');
const selectedPaths = await open({
multiple: options?.multiple ?? true,
filters: options?.filters ?? getDocumentFileDialogFilter()
});
if (!selectedPaths) {
console.log('[FileDialog] User cancelled');
return [];
}
const paths = Array.isArray(selectedPaths) ? selectedPaths : [selectedPaths];
console.log(`[FileDialog] Selected ${paths.length} file(s):`, paths);
const filesWithPaths: FileWithPath[] = [];
for (const filePath of paths) {
try {
console.log(`[FileDialog] Reading file: ${filePath}`);
const fileData = await readFile(filePath);
const fileName = filePath.split(/[/\\]/).pop() || 'document';
const file = new File([fileData], fileName, {
type: fileName.endsWith('.pdf') ? 'application/pdf' : undefined
});
const quickKey = createQuickKey(file);
console.log(`[FileDialog] Created File: ${fileName}, quickKey: ${quickKey}`);
filesWithPaths.push({
file,
path: filePath,
quickKey
});
} catch (error) {
console.error(`[FileDialog] Failed to read ${filePath}:`, error);
}
}
return filesWithPaths;
} catch (error) {
console.error('[FileDialog] Error:', error);
return [];
}
}
@@ -10,9 +10,9 @@ export interface FileOpenService {
class TauriFileOpenService implements FileOpenService {
async getOpenedFiles(): Promise<string[]> {
try {
console.log('🔍 Calling invoke(get_opened_files)...');
const result = await invoke<string[]>('get_opened_files');
console.log('🔍 invoke(get_opened_files) returned:', result);
console.log('🔍 Calling invoke(pop_opened_files)...');
const result = await invoke<string[]>('pop_opened_files');
console.log('🔍 invoke(pop_opened_files) returned:', result);
return result;
} catch (error) {
console.error('❌ Failed to get opened files:', error);
@@ -0,0 +1,123 @@
import type { SaveResult, MultiFileSaveResult } from "@core/services/localFileSaveService";
export type { SaveResult, MultiFileSaveResult };
/**
* Save file data to a local filesystem path (Tauri desktop only)
*
* @param data - Blob or File to save
* @param filePath - Absolute path to save to
* @returns Result indicating success or failure with error message
*/
export async function saveToLocalPath(
data: Blob | File,
filePath: string
): Promise<SaveResult> {
try {
const { writeFile } = await import("@tauri-apps/plugin-fs");
const arrayBuffer = await data.arrayBuffer();
await writeFile(filePath, new Uint8Array(arrayBuffer));
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[LocalFileSave] Failed to save:', message);
return { success: false, error: message };
}
}
/**
* Show native save dialog and return selected path
*
* @param defaultFilename - Suggested filename
* @param defaultDirectory - Optional default directory
* @returns Selected file path or null if cancelled
*/
export async function showSaveDialog(
defaultFilename: string,
defaultDirectory?: string
): Promise<string | null> {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const selectedPath = await save({
defaultPath: defaultDirectory ? `${defaultDirectory}/${defaultFilename}` : defaultFilename,
filters: [{
name: 'PDF',
extensions: ['pdf']
}],
title: 'Save As'
});
return selectedPath;
} catch (error) {
console.error('[SaveDialog] Failed to show dialog:', error);
return null;
}
}
/**
* Prompt user to select a folder and save multiple files there
*
* @param files - Array of files to save
* @param defaultDirectory - Optional default directory to open dialog in
* @returns Result with count of files saved
*/
export async function saveMultipleFilesWithPrompt(
files: (Blob | File)[],
defaultDirectory?: string
): Promise<MultiFileSaveResult> {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const { writeFile } = await import("@tauri-apps/plugin-fs");
const { join } = await import("@tauri-apps/api/path");
// Prompt user to select folder
const selectedFolder = await open({
directory: true,
multiple: false,
defaultPath: defaultDirectory,
title: `Save ${files.length} file${files.length > 1 ? 's' : ''}`
});
// User cancelled
if (!selectedFolder) {
return { success: false, savedCount: 0, cancelledByUser: true };
}
// Save each file to the selected folder
let savedCount = 0;
const errors: string[] = [];
for (const file of files) {
try {
const fileName = file instanceof File ? file.name : `output_${savedCount + 1}.pdf`;
const filePath = await join(selectedFolder as string, fileName);
const arrayBuffer = await file.arrayBuffer();
await writeFile(filePath, new Uint8Array(arrayBuffer));
savedCount++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`${file instanceof File ? file.name : 'file'}: ${message}`);
}
}
if (savedCount === files.length) {
return { success: true, savedCount };
} else if (savedCount > 0) {
return {
success: false,
savedCount,
error: `Saved ${savedCount}/${files.length} files. Errors: ${errors.join(', ')}`
};
} else {
return {
success: false,
savedCount: 0,
error: `Failed to save files: ${errors.join(', ')}`
};
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[LocalFileSave] Failed to save multiple files:', message);
return { success: false, savedCount: 0, error: message };
}
}
@@ -0,0 +1,42 @@
import type { FileId } from '@app/types/fileContext';
import type { OperationSaveContext } from '@core/services/operationResultsSaveService';
import { downloadFile, downloadFromUrl, DownloadResult } from '@app/services/downloadService';
export type { OperationSaveContext };
export async function saveOperationResults(context: OperationSaveContext): Promise<DownloadResult | null> {
if (!context.downloadUrl) return null;
if (context.outputFileIds && context.outputFileIds.length > 0) {
for (const fileId of context.outputFileIds) {
const file = context.getFile(fileId as FileId);
const stub = context.getStub(fileId as FileId);
if (!file) continue;
const result = await downloadFile({
data: file,
filename: file.name,
localPath: stub?.localFilePath
});
if (result.savedPath) {
context.markSaved(fileId as FileId, result.savedPath);
}
}
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;
}