mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-17 11:45:05 +02:00
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:
co-authored by
James Brunton
James Brunton
parent
946196de43
commit
b8ce4e47c1
@@ -2,6 +2,7 @@ import { ReactNode, useEffect, useState } from "react";
|
||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
||||
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
||||
import { SaveShortcutListener } from '@app/components/SaveShortcutListener';
|
||||
import { SetupWizard } from '@app/components/SetupWizard';
|
||||
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
|
||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||
@@ -149,6 +150,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
<SaveShortcutListener />
|
||||
{children}
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useSaveShortcut } from '@app/hooks/useSaveShortcut';
|
||||
import { useExitWarning } from '@app/hooks/useExitWarning';
|
||||
|
||||
/**
|
||||
* Desktop-only component that sets up keyboard shortcuts and exit warnings
|
||||
* - Ctrl/Cmd+S to save selected files
|
||||
* - Warning on app exit if unsaved files
|
||||
* Renders nothing, just sets up the listeners
|
||||
*/
|
||||
export function SaveShortcutListener() {
|
||||
useSaveShortcut();
|
||||
useExitWarning();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
|
||||
interface FileEditorFileNameProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
{!file.localFilePath && (
|
||||
<Tooltip label={t('fileNotSavedToDisk', 'Not saved to disk')}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-red-6)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
aria-label={t('fileNotSavedToDisk', 'Not saved to disk')}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{file.localFilePath && file.isDirty && (
|
||||
<Tooltip label={t('unsavedChanges', 'Unsaved changes')}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-yellow-6)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
aria-label={t('unsavedChanges', 'Unsaved changes')}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{file.localFilePath && !file.isDirty && (
|
||||
<Tooltip label={t('fileSavedToDisk', 'File saved to disk')}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-green-6)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
aria-label={t('fileSavedToDisk', 'File saved to disk')}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditorFileName;
|
||||
@@ -6,9 +6,6 @@
|
||||
// The SaaS authentication server
|
||||
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
|
||||
|
||||
// SaaS signup URL for creating new cloud accounts
|
||||
export const STIRLING_SAAS_SIGNUP_URL: string = import.meta.env.VITE_SAAS_SIGNUP_URL || '';
|
||||
|
||||
// Supabase publishable key from environment variable
|
||||
// Used for SaaS authentication
|
||||
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
||||
import { fileOpenService } from '@app/services/fileOpenService';
|
||||
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||
import { createQuickKey } from '@app/types/fileContext';
|
||||
|
||||
/**
|
||||
* App initialization hook
|
||||
@@ -11,10 +12,10 @@ import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||
*/
|
||||
export function useAppInitialization(): void {
|
||||
// Get file management actions
|
||||
const { addFiles } = useFileManagement();
|
||||
const { addFiles, updateStirlingFileStub } = useFileManagement();
|
||||
|
||||
// Handle files opened with app (Tauri mode)
|
||||
const { openedFilePaths, loading: openedFileLoading } = useOpenedFile();
|
||||
const { openedFilePaths, loading: openedFileLoading, consumeOpenedFilePaths } = useOpenedFile();
|
||||
|
||||
// Load opened files and add directly to FileContext
|
||||
useEffect(() => {
|
||||
@@ -23,29 +24,50 @@ export function useAppInitialization(): void {
|
||||
}
|
||||
|
||||
const loadOpenedFiles = async () => {
|
||||
const filePaths = consumeOpenedFilePaths();
|
||||
if (filePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const filesArray: File[] = [];
|
||||
const loadedFiles = (
|
||||
await Promise.all(
|
||||
filePaths.map(async (filePath) => {
|
||||
try {
|
||||
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
|
||||
if (!fileData) return null;
|
||||
|
||||
await Promise.all(
|
||||
openedFilePaths.map(async (filePath) => {
|
||||
try {
|
||||
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
|
||||
if (fileData) {
|
||||
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
filesArray.push(file);
|
||||
console.log('[Desktop] Loaded file:', fileData.fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load file:', filePath, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (filesArray.length > 0) {
|
||||
await addFiles(filesArray);
|
||||
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
|
||||
console.log('[Desktop] Loaded file:', fileData.fileName);
|
||||
|
||||
return {
|
||||
file,
|
||||
filePath,
|
||||
quickKey: createQuickKey(file),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load file:', filePath, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter((entry): entry is { file: File; filePath: string; quickKey: string } => Boolean(entry));
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
const filesArray = loadedFiles.map(entry => entry.file);
|
||||
const quickKeyToPath = new Map(loadedFiles.map(entry => [entry.quickKey, entry.filePath]));
|
||||
|
||||
const addedFiles = await addFiles(filesArray);
|
||||
addedFiles.forEach(file => {
|
||||
const localFilePath = quickKeyToPath.get(file.quickKey);
|
||||
if (localFilePath) {
|
||||
updateStirlingFileStub(file.fileId, { localFilePath });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load opened files:', error);
|
||||
@@ -53,7 +75,7 @@ export function useAppInitialization(): void {
|
||||
};
|
||||
|
||||
loadOpenedFiles();
|
||||
}, [openedFilePaths, openedFileLoading, addFiles]);
|
||||
}, [openedFilePaths, openedFileLoading, addFiles, updateStirlingFileStub, consumeOpenedFilePaths]);
|
||||
}
|
||||
|
||||
export function useSetupCompletion(): (completed: boolean) => void {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { message } from '@tauri-apps/plugin-dialog';
|
||||
import { useFileState, useFileActions } from '@app/contexts/FileContext';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useExitWarning() {
|
||||
const { t } = useTranslation();
|
||||
const { selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const selectorsRef = useRef(selectors);
|
||||
const isClosingRef = useRef(false);
|
||||
|
||||
selectorsRef.current = selectors;
|
||||
|
||||
useEffect(() => {
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
const handleCloseRequested = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isClosingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allStubs = selectorsRef.current.getStirlingFileStubs();
|
||||
const dirtyStubs = allStubs.filter(stub => stub.isDirty);
|
||||
|
||||
if (dirtyStubs.length > 0) {
|
||||
const fileList = dirtyStubs.map(f => `• ${f.name}`).join('\n');
|
||||
const saveLabel = t('confirmCloseSave', 'Save and close');
|
||||
const discardLabel = t('confirmCloseDiscard', 'Discard changes and close');
|
||||
const cancelLabel = t('confirmCloseCancel', 'Cancel');
|
||||
|
||||
const choice = await message(
|
||||
t(
|
||||
'confirmCloseUnsavedList',
|
||||
'You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}',
|
||||
{ count: dirtyStubs.length, plural: dirtyStubs.length > 1 ? 's' : '', fileList }
|
||||
),
|
||||
{
|
||||
title: t('confirmCloseUnsaved', 'This file has unsaved changes.'),
|
||||
kind: 'warning',
|
||||
buttons: {
|
||||
yes: saveLabel,
|
||||
no: discardLabel,
|
||||
cancel: cancelLabel,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (choice === cancelLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === saveLabel) {
|
||||
const { failedCount, cancelled } = await saveDirtyFiles(dirtyStubs);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
await message(
|
||||
t(
|
||||
'confirmCloseSaveFailed',
|
||||
'Saved with errors. {{count}} file{{plural}} could not be saved.',
|
||||
{ count: failedCount, plural: failedCount > 1 ? 's' : '' }
|
||||
),
|
||||
{ title: t('confirmCloseSaveFailedTitle', 'Save Failed'), kind: 'error' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (choice !== discardLabel) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isClosingRef.current = true;
|
||||
try {
|
||||
await appWindow.destroy();
|
||||
} catch (error) {
|
||||
console.error('[exit-warning] destroy failed', error);
|
||||
isClosingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unlisten = appWindow.onCloseRequested(handleCloseRequested);
|
||||
return () => {
|
||||
unlisten.then(fn => {
|
||||
fn();
|
||||
});
|
||||
};
|
||||
}, [fileActions, t]);
|
||||
|
||||
const saveDirtyFiles = async (dirtyStubs: StirlingFileStub[]) => {
|
||||
const filesById = new Map(selectorsRef.current.getFiles().map(file => [file.fileId, file]));
|
||||
let failedCount = 0;
|
||||
let cancelled = false;
|
||||
|
||||
for (const stub of dirtyStubs) {
|
||||
const file = filesById.get(stub.id);
|
||||
if (!file || !stub.localFilePath) {
|
||||
if (!file) {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: stub.localFilePath,
|
||||
});
|
||||
|
||||
if (result.cancelled) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
failedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { failedCount, cancelled };
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { fileOpenService } from '@app/services/fileOpenService';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
export function useOpenedFile() {
|
||||
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const openedFilePathsRef = useRef<string[]>([]);
|
||||
|
||||
const clearOpenedFilePaths = useCallback(() => {
|
||||
openedFilePathsRef.current = [];
|
||||
setOpenedFilePaths([]);
|
||||
}, []);
|
||||
|
||||
const consumeOpenedFilePaths = useCallback(() => {
|
||||
const current = openedFilePathsRef.current;
|
||||
openedFilePathsRef.current = [];
|
||||
setOpenedFilePaths([]);
|
||||
return current;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Function to read and process files from storage
|
||||
@@ -16,8 +29,8 @@ export function useOpenedFile() {
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
|
||||
openedFilePathsRef.current = filePaths;
|
||||
setOpenedFilePaths(filePaths);
|
||||
await fileOpenService.clearOpenedFiles();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to read files from storage:', error);
|
||||
@@ -44,5 +57,5 @@ export function useOpenedFile() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { openedFilePaths, loading };
|
||||
return { openedFilePaths, loading, clearOpenedFilePaths, consumeOpenedFilePaths };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFileState, useFileActions } from '@app/contexts/FileContext';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
|
||||
/**
|
||||
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
|
||||
* Only saves files that have a localFilePath (came from disk)
|
||||
* Matches Right Rail button behavior: saves selected files if any, otherwise all files
|
||||
*/
|
||||
export function useSaveShortcut() {
|
||||
const { selectors, state } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
// Check for Ctrl+S (Windows/Linux) or Cmd+S (Mac)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
|
||||
// Get selected files or all files if nothing selected
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const filesToSave = selectedFileIds.length > 0
|
||||
? selectors.getFiles(selectedFileIds)
|
||||
: selectors.getFiles();
|
||||
const stubsToSave = selectedFileIds.length > 0
|
||||
? selectors.getStirlingFileStubs(selectedFileIds)
|
||||
: selectors.getStirlingFileStubs();
|
||||
|
||||
if (filesToSave.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save files (Save As for files without localFilePath)
|
||||
for (let i = 0; i < filesToSave.length; i++) {
|
||||
const file = filesToSave[i];
|
||||
const stub = stubsToSave[i];
|
||||
if (!stub) continue;
|
||||
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: stub.localFilePath
|
||||
});
|
||||
|
||||
// Mark file as clean after successful save
|
||||
if (result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to save ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectors, state.ui.selectedFileIds, fileActions]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user