diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 64febcdf17..3ccb4abb2e 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -44,8 +44,13 @@ "identifier": "fs:allow-write-file", "allow": [{ "path": "**" }] }, + { + "identifier": "fs:allow-remove", + "allow": [{ "path": "**" }] + }, "dialog:default", "dialog:allow-open", + "dialog:allow-save", "opener:default", "shell:allow-open" ] diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx index 7148b4c3bc..4e17210d75 100644 --- a/frontend/src/core/components/shared/RightRail.tsx +++ b/frontend/src/core/components/shared/RightRail.tsx @@ -22,6 +22,7 @@ import LightModeIcon from '@mui/icons-material/LightMode'; import { useSidebarContext } from '@app/contexts/SidebarContext'; import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; +import { isTauri } from '@tauri-apps/api/core'; const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom']; @@ -151,16 +152,51 @@ export default function RightRail() { return; } - const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; - filesToDownload.forEach(file => { - const link = document.createElement('a'); - link.href = URL.createObjectURL(file); - link.download = file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(link.href); - }); + const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles; + + // Desktop mode: Show "Save As" dialog for each file + if (isTauri()) { + try { + // @ts-ignore - Desktop module not available in proprietary build + const { showSaveDialog, saveToLocalPath } = await import('@desktop/services/localFileSaveService'); + + for (const file of filesToExport) { + const savePath = await showSaveDialog(file.name); + if (savePath) { + const result = await saveToLocalPath(file, savePath); + if (result.success) { + console.log(`[RightRail] Saved to: ${savePath}`); + } else { + console.error(`[RightRail] Failed to save: ${result.error}`); + alert(`Failed to save ${file.name}: ${result.error}`); + } + } + } + } catch (error) { + console.error('[RightRail] Save As error:', error); + // Fallback to browser download + filesToExport.forEach(file => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(file); + link.download = file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + }); + } + } else { + // Web mode: Regular download + filesToExport.forEach(file => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(file); + link.download = file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + }); + } }, [ currentView, selectedFiles, @@ -174,11 +210,21 @@ export default function RightRail() { if (currentView === 'pageEditor') { return t('rightRail.exportAll', 'Export PDF'); } + + // Desktop mode: "Save As" instead of "Download" + if (isTauri()) { + if (selectedCount > 0) { + return selectedCount === 1 ? 'Save As...' : `Save ${selectedCount} Files As...`; + } + return 'Save As...'; + } + + // Web mode: "Download" if (selectedCount > 0) { return terminology.downloadSelected; } return terminology.downloadAll; - }, [currentView, selectedCount, t]); + }, [currentView, selectedCount, t, terminology]); return (
diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index f63ae0f694..2c85163183 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -328,6 +328,47 @@ function FileContextInner({ addFiles: addRawFiles, addStirlingFileStubs: addStirlingFileStubsAction, removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => { + // Check if any files have localFilePath (desktop mode) + const filesWithLocalPaths: Array<{ id: FileId; path: string; name: string }> = []; + for (const fileId of fileIds) { + const stub = stateRef.current.files.byId[fileId]; + if (stub?.localFilePath) { + filesWithLocalPaths.push({ + id: fileId, + path: stub.localFilePath, + name: stub.name + }); + } + } + + // Ask user if they want to delete from disk (desktop only) + if (filesWithLocalPaths.length > 0) { + const fileList = filesWithLocalPaths.map(f => `• ${f.name}`).join('\n'); + const message = filesWithLocalPaths.length === 1 + ? `Delete "${filesWithLocalPaths[0].name}" from disk?\n\nThis will permanently delete the file from:\n${filesWithLocalPaths[0].path}` + : `Delete ${filesWithLocalPaths.length} files from disk?\n\n${fileList}\n\nThis will permanently delete these files from your computer.`; + + const shouldDeleteFromDisk = window.confirm(message); + + if (shouldDeleteFromDisk) { + try { + // @ts-ignore - Desktop module not available in proprietary build + const { deleteLocalFile } = await import('@desktop/services/localFileSaveService'); + + for (const file of filesWithLocalPaths) { + const result = await deleteLocalFile(file.path); + if (result.success) { + console.log(`[FileContext] Deleted from disk: ${file.name}`); + } else { + console.warn(`[FileContext] Failed to delete from disk: ${file.name}`, result.error); + } + } + } catch (error) { + console.error('[FileContext] Failed to delete files from disk:', error); + } + } + } + // Remove from memory and cleanup resources lifecycleManager.removeFiles(fileIds, stateRef); diff --git a/frontend/src/desktop/services/localFileSaveService.ts b/frontend/src/desktop/services/localFileSaveService.ts index 527b8e82c3..7e44ab216f 100644 --- a/frontend/src/desktop/services/localFileSaveService.ts +++ b/frontend/src/desktop/services/localFileSaveService.ts @@ -43,6 +43,63 @@ export function shouldAutoSave(inputCount: number, outputCount: number): boolean return isTauri() && inputCount === 1 && outputCount === 1; } +/** + * Delete a file from local filesystem (Tauri desktop only) + * + * @param filePath - Absolute path to delete + * @returns Result indicating success or failure + */ +export async function deleteLocalFile(filePath: string): Promise { + if (!isTauri()) { + return { success: false, error: "Not running in Tauri desktop app" }; + } + + try { + const { remove } = await import("@tauri-apps/plugin-fs"); + await remove(filePath); + console.log(`[LocalFileDelete] Deleted: ${filePath}`); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[LocalFileDelete] Failed to delete:', 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 { + if (!isTauri()) { + return 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; + } +} + export interface MultiFileSaveResult { success: boolean; savedCount: number;