Files
Stirling-PDF/frontend/src/desktop/services/fileOpenService.ts
T
b8ce4e47c1 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]>
2026-02-13 23:15:28 +00:00

140 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { invoke, isTauri } from '@tauri-apps/api/core';
export interface FileOpenService {
getOpenedFiles(): Promise<string[]>;
readFileAsArrayBuffer(filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>;
clearOpenedFiles(): Promise<void>;
onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function
}
class TauriFileOpenService implements FileOpenService {
async getOpenedFiles(): Promise<string[]> {
try {
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);
return [];
}
}
async readFileAsArrayBuffer(filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> {
try {
const { readFile } = await import('@tauri-apps/plugin-fs');
const fileData = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || 'opened-file.pdf';
return {
fileName,
arrayBuffer: fileData.buffer.slice(fileData.byteOffset, fileData.byteOffset + fileData.byteLength)
};
} catch (error) {
console.error('Failed to read file:', error);
return null;
}
}
async clearOpenedFiles(): Promise<void> {
try {
console.log('🔍 Calling invoke(clear_opened_files)...');
await invoke('clear_opened_files');
console.log('✅ Successfully cleared opened files');
} catch (error) {
console.error('❌ Failed to clear opened files:', error);
}
}
onFileOpened(callback: (filePath: string) => void): () => void {
let cleanup: (() => void) | null = null;
let isCleanedUp = false;
const setupEventListeners = async () => {
try {
// Check if already cleaned up before async setup completes
if (isCleanedUp) {
return;
}
// Only import if in Tauri environment
if (isTauri()) {
const { listen } = await import('@tauri-apps/api/event');
// Check again after async import
if (isCleanedUp) {
return;
}
// Listen for unified file open events (all platforms)
const unlisten = await listen('file-opened', (event) => {
console.log('📂 File open event received:', event.payload);
callback(event.payload as string);
});
// Set up cleanup function only if not already cleaned up
if (!isCleanedUp) {
cleanup = () => {
try {
unlisten();
console.log('✅ File event listeners cleaned up');
} catch (error) {
console.error('❌ Error during file event cleanup:', error);
}
};
} else {
// Clean up immediately if cleanup was called during setup
try {
unlisten();
} catch (error) {
console.error('❌ Error during immediate cleanup:', error);
}
}
}
} catch (error) {
console.error('❌ Failed to setup file event listeners:', error);
}
};
setupEventListeners();
// Return cleanup function
return () => {
isCleanedUp = true;
if (cleanup) {
cleanup();
}
};
}
}
class WebFileOpenService implements FileOpenService {
async getOpenedFiles(): Promise<string[]> {
// In web mode, there's no file association support
return [];
}
async readFileAsArrayBuffer(_filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> {
// In web mode, cannot read arbitrary file paths
return null;
}
async clearOpenedFiles(): Promise<void> {
// In web mode, no file clearing needed
}
onFileOpened(_callback: (filePath: string) => void): () => void {
// In web mode, no file events - return no-op cleanup function
console.log('️ Web mode: File event listeners not supported');
return () => {
// No-op cleanup for web mode
};
}
}
// Export the appropriate service based on environment
export const fileOpenService: FileOpenService = isTauri()
? new TauriFileOpenService()
: new WebFileOpenService();