---
CLAUDE.md | 118 ++++++++++++++-
frontend/package-lock.json | 10 ++
frontend/package.json | 1 +
.../public/locales/en-GB/translation.toml | 8 ++
frontend/src-tauri/Cargo.lock | 45 ++++++
frontend/src-tauri/Cargo.toml | 1 +
frontend/src-tauri/capabilities/default.json | 13 ++
frontend/src-tauri/src/commands/files.rs | 9 ++
frontend/src-tauri/src/commands/mod.rs | 2 +-
frontend/src-tauri/src/lib.rs | 9 +-
.../components/fileEditor/AddFileCard.tsx | 13 +-
.../core/components/fileEditor/FileEditor.tsx | 24 +++-
.../fileEditor/FileEditorFileName.tsx | 13 ++
.../fileEditor/FileEditorThumbnail.tsx | 86 ++++++++---
.../components/fileManager/FileActions.tsx | 11 +-
.../components/fileManager/FileListItem.tsx | 3 +-
.../components/pageEditor/FileThumbnail.tsx | 9 +-
.../pageEditor/hooks/usePageEditorExport.ts | 15 ++
.../core/components/shared/LandingPage.tsx | 11 +-
.../src/core/components/shared/RightRail.tsx | 48 +++++--
.../EditTableOfContentsWorkbenchView.tsx | 5 +-
.../tools/getPdfInfo/GetPdfInfoResults.tsx | 11 +-
.../tools/shared/ReviewToolStep.tsx | 34 ++++-
.../src/core/components/tools/showJS/utils.ts | 11 +-
.../ValidateSignatureResults.tsx | 10 +-
.../src/core/contexts/FileManagerContext.tsx | 27 +++-
.../src/core/contexts/file/fileActions.ts | 53 ++++++-
.../hooks/tools/shared/useToolOperation.ts | 26 +++-
.../core/hooks/tools/shared/useToolState.ts | 22 +--
frontend/src/core/services/downloadService.ts | 40 ++++++
.../src/core/services/fileDialogService.ts | 28 ++++
.../src/core/services/localFileSaveService.ts | 48 +++++++
.../src/core/services/openFilesFromDisk.ts | 29 ++++
.../services/operationResultsSaveService.ts | 30 ++++
.../src/core/services/pdfExportService.ts | 14 +-
.../tests/convert/ConvertIntegration.test.tsx | 4 +-
.../src/core/tools/EditTableOfContents.tsx | 10 +-
frontend/src/core/types/fileContext.ts | 2 +
.../src/core/utils/automationConverter.ts | 14 +-
frontend/src/core/utils/downloadUtils.ts | 26 ++--
frontend/src/core/utils/fileDialogUtils.ts | 6 +
.../src/desktop/components/AppProviders.tsx | 2 +
.../components/SaveShortcutListener.tsx | 14 ++
.../fileEditor/FileEditorFileName.tsx | 66 +++++++++
frontend/src/desktop/constants/connection.ts | 3 -
.../src/desktop/hooks/useAppInitialization.ts | 62 +++++---
frontend/src/desktop/hooks/useExitWarning.ts | 135 ++++++++++++++++++
frontend/src/desktop/hooks/useOpenedFile.ts | 19 ++-
frontend/src/desktop/hooks/useSaveShortcut.ts | 63 ++++++++
.../src/desktop/services/downloadService.ts | 39 +++++
.../src/desktop/services/fileDialogService.ts | 60 ++++++++
.../src/desktop/services/fileOpenService.ts | 6 +-
.../desktop/services/localFileSaveService.ts | 123 ++++++++++++++++
.../services/operationResultsSaveService.ts | 42 ++++++
frontend/vite.config.ts | 16 +++
testing/test_pdf_1.pdf | Bin 1591 -> 1105 bytes
56 files changed, 1367 insertions(+), 182 deletions(-)
create mode 100644 frontend/src/core/components/fileEditor/FileEditorFileName.tsx
create mode 100644 frontend/src/core/services/downloadService.ts
create mode 100644 frontend/src/core/services/fileDialogService.ts
create mode 100644 frontend/src/core/services/localFileSaveService.ts
create mode 100644 frontend/src/core/services/openFilesFromDisk.ts
create mode 100644 frontend/src/core/services/operationResultsSaveService.ts
create mode 100644 frontend/src/core/utils/fileDialogUtils.ts
create mode 100644 frontend/src/desktop/components/SaveShortcutListener.tsx
create mode 100644 frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx
create mode 100644 frontend/src/desktop/hooks/useExitWarning.ts
create mode 100644 frontend/src/desktop/hooks/useSaveShortcut.ts
create mode 100644 frontend/src/desktop/services/downloadService.ts
create mode 100644 frontend/src/desktop/services/fileDialogService.ts
create mode 100644 frontend/src/desktop/services/localFileSaveService.ts
create mode 100644 frontend/src/desktop/services/operationResultsSaveService.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 2c4cfa537..f1461dcb8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,6 +30,91 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
- **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode
+#### Import Paths - CRITICAL
+**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
+
+```typescript
+// ✅ CORRECT - Use @app/* for all imports
+import { AppLayout } from "@app/components/AppLayout";
+import { useFileContext } from "@app/contexts/FileContext";
+import { FileContext } from "@app/contexts/FileContext";
+
+// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
+import { AppLayout } from "@core/components/AppLayout";
+import { useFileContext } from "@proprietary/contexts/FileContext";
+```
+
+**Only use explicit aliases when:**
+- Building layer-specific override that wraps a lower layer's component
+- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
+
+The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
+
+#### Component Override Pattern (Stub/Shadow)
+Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
+
+**How it works:**
+1. Core defines stub component (returns null or no-op)
+2. Desktop/proprietary overrides with same path/name
+3. Core imports via `@app/*` - higher layer "shadows" core in those builds
+4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
+
+**Example - Desktop-specific footer:**
+
+```typescript
+// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
+interface RightRailFooterExtensionsProps {
+ className?: string;
+}
+
+export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
+ return null; // Stub - does nothing in web builds
+}
+```
+
+```typescript
+// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
+import { Box } from '@mantine/core';
+import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
+
+interface RightRailFooterExtensionsProps {
+ className?: string;
+}
+
+export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
+ return (
+
+
+
+ );
+}
+```
+
+```typescript
+// core/components/shared/RightRail.tsx (usage - works in ALL builds)
+import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
+
+export function RightRail() {
+ return (
+
+ {/* In web builds: renders nothing (stub returns null) */}
+ {/* In desktop builds: renders BackendHealthIndicator */}
+
+
+ );
+}
+```
+
+**Build resolution:**
+- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
+- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
+
+**Benefits:**
+- No runtime checks or feature flags
+- Type-safe across all builds
+- Clean, readable code
+- Build-time optimization (dead code elimination)
+
#### Multi-Tool Workflow Architecture
Frontend designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
@@ -37,7 +122,7 @@ Frontend designed for **stateful document processing**:
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
-**Location**: `src/contexts/FileContext.tsx`
+**Location**: `frontend/src/core/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
@@ -62,7 +147,7 @@ Without cleanup: browser crashes with memory leaks.
**Architecture**: Modular hook-based system with clear separation of concerns:
-- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
+- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
@@ -147,8 +232,34 @@ return useToolOperation({
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
- **Security Layer**: Authentication, authorization, and user management (when enabled)
+### Frontend Directory Structure
+The frontend is organized with a clear separation of concerns:
+
+- **`frontend/src/core/`**: Main application code (shared, production-ready components)
+ - **`core/components/`**: React components organized by feature
+ - `core/components/tools/`: Individual PDF tool implementations
+ - `core/components/viewer/`: PDF viewer components
+ - `core/components/pageEditor/`: Page manipulation UI
+ - `core/components/tooltips/`: Help tooltips for tools
+ - `core/components/shared/`: Reusable UI components
+ - **`core/contexts/`**: React Context providers
+ - `FileContext.tsx`: Central file state management
+ - `file/`: File reducer and selectors
+ - `toolWorkflow/`: Tool workflow state
+ - **`core/hooks/`**: Custom React hooks
+ - `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
+ - `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
+ - **`core/constants/`**: Application constants and configuration
+ - **`core/data/`**: Static data (tool taxonomy, etc.)
+ - **`core/services/`**: Business logic services (PDF processing, storage, etc.)
+
+- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
+- **`frontend/src/proprietary/`**: Proprietary/licensed features
+- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
+- **`frontend/public/`**: Static assets served directly
+ - `public/locales/`: Translation JSON files
+
### Component Architecture
-- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
@@ -203,6 +314,7 @@ return useToolOperation({
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
+- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 46c65fe28..266e9661e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -50,6 +50,7 @@
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "^2.5.6",
"@tauri-apps/plugin-shell": "^2.3.4",
@@ -4198,6 +4199,15 @@
"node": ">= 10"
}
},
+ "node_modules/@tauri-apps/plugin-dialog": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
+ "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.8.0"
+ }
+ },
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index fac874d13..ba9fb5de3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -46,6 +46,7 @@
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "^2.5.6",
"@tauri-apps/plugin-shell": "^2.3.4",
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index b4cb435dc..33dee7950 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -17,6 +17,12 @@ confirmClose = "Confirm Close"
confirmCloseCancel = "Cancel"
confirmCloseConfirm = "Close File"
confirmCloseMessage = "Are you sure you want to close this file?"
+confirmCloseDiscard = "Discard changes and close"
+confirmCloseSave = "Save and close"
+confirmCloseUnsaved = "This file has unsaved changes."
+confirmCloseUnsavedList = "You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}"
+confirmCloseSaveFailedTitle = "Save Failed"
+confirmCloseSaveFailed = "Saved with errors. {{count}} file{{plural}} could not be saved."
confirmPasswordErrorMessage = "New Password and Confirm New Password must match."
custom = "Custom..."
customPosition = "Custom Position"
@@ -39,6 +45,8 @@ edit = "Edit"
editYourNewFiles = "Edit your new file(s)"
exportAndContinue = "Export & Continue"
false = "False"
+fileSavedToDisk = "File saved to disk"
+fileNotSavedToDisk = "Not saved to disk"
fileSelected = "Selected: {{filename}}"
filesSelected = "{{count}} files selected"
font = "Font"
diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock
index bda073963..7029acb12 100644
--- a/frontend/src-tauri/Cargo.lock
+++ b/frontend/src-tauri/Cargo.lock
@@ -907,6 +907,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
+ "block2",
+ "libc",
"objc2",
]
@@ -3531,6 +3533,30 @@ dependencies = [
"webpki-roots 1.0.5",
]
+[[package]]
+name = "rfd"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
+dependencies = [
+ "block2",
+ "dispatch2",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "js-sys",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -4229,6 +4255,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
+ "tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-log",
@@ -4609,6 +4636,24 @@ dependencies = [
"windows-result 0.3.4",
]
+[[package]]
+name = "tauri-plugin-dialog"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
+dependencies = [
+ "log",
+ "raw-window-handle",
+ "rfd",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-plugin-fs",
+ "thiserror 2.0.18",
+ "url",
+]
+
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml
index a415d879f..f25f63d88 100644
--- a/frontend/src-tauri/Cargo.toml
+++ b/frontend/src-tauri/Cargo.toml
@@ -28,6 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
tauri-plugin-log = "2.8.0"
tauri-plugin-shell = "2.3.4"
tauri-plugin-fs = "2.4.5"
+tauri-plugin-dialog = "2.4.2"
tauri-plugin-http = { version = "2.5.6", features = ["dangerous-settings"] }
tauri-plugin-single-instance = { version = "2.3.7", features = ["deep-link"] }
tauri-plugin-store = "2.4.2"
diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json
index ac4e364ae..a9243c2e2 100644
--- a/frontend/src-tauri/capabilities/default.json
+++ b/frontend/src-tauri/capabilities/default.json
@@ -7,6 +7,7 @@
],
"permissions": [
"core:default",
+ "core:window:allow-destroy",
"http:default",
{
"identifier": "http:allow-fetch",
@@ -40,6 +41,18 @@
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
},
+ {
+ "identifier": "fs:allow-write-file",
+ "allow": [{ "path": "**" }]
+ },
+ {
+ "identifier": "fs:allow-remove",
+ "allow": [{ "path": "**" }]
+ },
+ "dialog:default",
+ "dialog:allow-message",
+ "dialog:allow-open",
+ "dialog:allow-save",
"opener:default",
"shell:allow-open"
]
diff --git a/frontend/src-tauri/src/commands/files.rs b/frontend/src-tauri/src/commands/files.rs
index 7faadf0db..1e3c9b61c 100644
--- a/frontend/src-tauri/src/commands/files.rs
+++ b/frontend/src-tauri/src/commands/files.rs
@@ -33,3 +33,12 @@ pub async fn clear_opened_files() -> Result<(), String> {
Ok(())
}
+// Command to atomically get and clear opened file paths
+#[tauri::command]
+pub async fn pop_opened_files() -> Result, String> {
+ let mut opened_files = OPENED_FILES.lock().unwrap();
+ let all_files = opened_files.clone();
+ opened_files.clear();
+ add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len()));
+ Ok(all_files)
+}
diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs
index 30904bbd9..3e9840811 100644
--- a/frontend/src-tauri/src/commands/mod.rs
+++ b/frontend/src-tauri/src/commands/mod.rs
@@ -5,7 +5,7 @@ pub mod auth;
pub mod default_app;
pub use backend::{cleanup_backend, get_backend_port, start_backend};
-pub use files::{add_opened_file, clear_opened_files, get_opened_files};
+pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
pub use connection::{
get_connection_config,
is_first_launch,
diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs
index 673442dcf..5d6a38400 100644
--- a/frontend/src-tauri/src/lib.rs
+++ b/frontend/src-tauri/src/lib.rs
@@ -16,6 +16,7 @@ use commands::{
get_backend_port,
get_connection_config,
get_opened_files,
+ pop_opened_files,
get_refresh_token,
get_user_info,
is_first_launch,
@@ -55,6 +56,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
+ .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_deep_link::init())
@@ -139,6 +141,7 @@ pub fn run() {
start_backend,
get_backend_port,
get_opened_files,
+ pop_opened_files,
clear_opened_files,
get_tauri_logs,
get_connection_config,
@@ -170,9 +173,9 @@ pub fn run() {
app_handle.cleanup_before_exit();
}
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
- add_log("🔄 Window close requested, cleaning up...".to_string());
- cleanup_backend();
- // Allow the window to close
+ add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string());
+ // Don't cleanup here - let JavaScript handler prevent close if needed
+ // Backend cleanup happens in ExitRequested when window actually closes
}
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
use tauri::DragDropEvent;
diff --git a/frontend/src/core/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx
index a4a549d82..c5cafd756 100644
--- a/frontend/src/core/components/fileEditor/AddFileCard.tsx
+++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx
@@ -8,6 +8,7 @@ import { useLogoAssets } from '@app/hooks/useLogoAssets';
import styles from '@app/components/fileEditor/FileEditor.module.css';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
+import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
@@ -33,9 +34,15 @@ const AddFileCard = ({
openFilesModal();
};
- const handleNativeUploadClick = (e: React.MouseEvent) => {
+ const handleNativeUploadClick = async (e: React.MouseEvent) => {
e.stopPropagation();
- fileInputRef.current?.click();
+ const files = await openFilesFromDisk({
+ multiple,
+ onFallbackOpen: () => fileInputRef.current?.click()
+ });
+ if (files.length > 0) {
+ onFileSelect(files);
+ }
};
const handleOpenFilesModal = (e: React.MouseEvent) => {
@@ -179,4 +186,4 @@ const AddFileCard = ({
);
};
-export default AddFileCard;
\ No newline at end of file
+export default AddFileCard;
diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx
index cc825d367..8636d82c0 100644
--- a/frontend/src/core/components/fileEditor/FileEditor.tsx
+++ b/frontend/src/core/components/fileEditor/FileEditor.tsx
@@ -12,7 +12,7 @@ import AddFileCard from '@app/components/fileEditor/AddFileCard';
import FilePickerModal from '@app/components/shared/FilePickerModal';
import { FileId, StirlingFile } from '@app/types/fileContext';
import { alert } from '@app/components/toast';
-import { downloadBlob } from '@app/utils/downloadUtils';
+import { downloadFile } from '@app/services/downloadService';
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
@@ -278,13 +278,29 @@ const FileEditor = ({
}
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
- const handleDownloadFile = useCallback((fileId: FileId) => {
+ const handleDownloadFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
+ console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty });
if (record && file) {
- downloadBlob(file, file.name);
+ const result = await downloadFile({
+ data: file,
+ filename: file.name,
+ localPath: record.localFilePath
+ });
+ console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath });
+ // Mark file as clean after successful save to disk
+ if (result.savedPath) {
+ console.log('[FileEditor] Marking file as clean:', fileId);
+ fileActions.updateStirlingFileStub(fileId, {
+ localFilePath: record.localFilePath ?? result.savedPath,
+ isDirty: false
+ });
+ } else {
+ console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty });
+ }
}
- }, [activeStirlingFileStubs, selectors, _setStatus]);
+ }, [activeStirlingFileStubs, selectors, fileActions]);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
diff --git a/frontend/src/core/components/fileEditor/FileEditorFileName.tsx b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx
new file mode 100644
index 000000000..a3e4cd449
--- /dev/null
+++ b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { StirlingFileStub } from '@app/types/fileContext';
+import { PrivateContent } from '@app/components/shared/PrivateContent';
+
+interface FileEditorFileNameProps {
+ file: StirlingFileStub;
+}
+
+const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
+ {file.name}
+);
+
+export default FileEditorFileName;
diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
index f6339be5e..40dd0966d 100644
--- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
+++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
@@ -23,6 +23,8 @@ import { FileId } from '@app/types/file';
import { formatFileSize } from '@app/utils/fileUtils';
import ToolChain from '@app/components/shared/ToolChain';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
+import { downloadFile } from '@app/services/downloadService';
+import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
import { PrivateContent } from '@app/components/shared/PrivateContent';
@@ -69,7 +71,7 @@ const FileEditorThumbnail = ({
actions: fileActions,
openEncryptedUnlockPrompt,
} = useFileContext();
- const { state } = useFileState();
+ const { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
@@ -191,6 +193,37 @@ const FileEditorThumbnail = ({
setShowCloseModal(false);
}, [file.id, file.name, onCloseFile]);
+ const handleSaveAndClose = useCallback(async () => {
+ const fileToSave = selectors.getFile(file.id);
+ if (fileToSave) {
+ try {
+ const result = await downloadFile({
+ data: fileToSave,
+ filename: file.name,
+ localPath: file.localFilePath
+ });
+ if (!result.cancelled && result.savedPath) {
+ fileActions.updateStirlingFileStub(file.id, {
+ localFilePath: file.localFilePath ?? result.savedPath,
+ isDirty: false
+ });
+ } else if (result.cancelled) {
+ setShowCloseModal(false);
+ return;
+ }
+ } catch (error) {
+ console.error(`Failed to save ${file.name}:`, error);
+ alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true });
+ setShowCloseModal(false);
+ return;
+ }
+ }
+ // Then close
+ onCloseFile(file.id);
+ alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
+ setShowCloseModal(false);
+ }, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
+
const handleCancelClose = useCallback(() => {
setShowCloseModal(false);
}, []);
@@ -213,7 +246,6 @@ const FileEditorThumbnail = ({
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
- alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
},
},
{
@@ -365,8 +397,8 @@ const FileEditorThumbnail = ({
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
-
- {file.name}
+
+
- {t('confirmCloseMessage', 'Are you sure you want to close this file?')}
-
- {file.name}
-
-
-
- {t('confirmCloseCancel', 'Cancel')}
-
-
- {t('confirmCloseConfirm', 'Close File')}
-
-
+ {file.isDirty && file.localFilePath ? (
+ <>
+ {t('confirmCloseUnsaved', 'This file has unsaved changes.')}
+
+ {file.name}
+
+
+
+ {t('confirmCloseCancel', 'Cancel')}
+
+
+ {t('confirmCloseDiscard', 'Discard changes and close')}
+
+
+ {t('confirmCloseSave', 'Save and close')}
+
+
+ >
+ ) : (
+ <>
+ {t('confirmCloseMessage', 'Are you sure you want to close this file?')}
+
+ {file.name}
+
+
+
+ {t('confirmCloseCancel', 'Cancel')}
+
+
+ {t('confirmCloseConfirm', 'Close File')}
+
+
+ >
+ )}
diff --git a/frontend/src/core/components/fileManager/FileActions.tsx b/frontend/src/core/components/fileManager/FileActions.tsx
index 8f919e92c..19d5e634e 100644
--- a/frontend/src/core/components/fileManager/FileActions.tsx
+++ b/frontend/src/core/components/fileManager/FileActions.tsx
@@ -12,8 +12,14 @@ const FileActions: React.FC = () => {
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadIcon = icons.download;
- const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
- useFileManagerContext();
+ const {
+ recentFiles,
+ selectedFileIds,
+ filteredFiles,
+ onSelectAll,
+ onDeleteSelected,
+ onDownloadSelected
+ } = useFileManagerContext();
const handleSelectAll = () => {
onSelectAll();
@@ -31,6 +37,7 @@ const FileActions: React.FC = () => {
}
};
+
// Only show actions if there are files
if (recentFiles.length === 0) {
return null;
diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx
index 87b458e10..bab58c1b2 100644
--- a/frontend/src/core/components/fileManager/FileListItem.tsx
+++ b/frontend/src/core/components/fileManager/FileListItem.tsx
@@ -46,7 +46,7 @@ const FileListItem: React.FC = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
- const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
+ const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
const { removeFiles } = useFileManagement();
// Check if this is a ZIP file
@@ -269,6 +269,7 @@ const FileListItem: React.FC = ({
>
{t('fileManager.delete', 'Delete')}
+
diff --git a/frontend/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/src/core/components/pageEditor/FileThumbnail.tsx
index fffba18b4..b35ae89d0 100644
--- a/frontend/src/core/components/pageEditor/FileThumbnail.tsx
+++ b/frontend/src/core/components/pageEditor/FileThumbnail.tsx
@@ -14,6 +14,7 @@ import { FileId } from '@app/types/file';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
+import { downloadFile } from '@app/services/downloadService';
interface FileItem {
id: FileId;
@@ -79,13 +80,7 @@ const FileThumbnail = ({
// Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof File) {
- const link = document.createElement('a');
- link.href = URL.createObjectURL(maybeFile);
- link.download = maybeFile.name || file.name || 'download';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(link.href);
+ void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || 'download' });
return;
}
diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts
index 3a24d9089..72cefce44 100644
--- a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts
+++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts
@@ -295,6 +295,21 @@ export const usePageEditorExport = ({
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
}
+ if (sourceFileIds.length === 1 && newStirlingFiles.length === 1) {
+ const sourceStub = selectors.getStirlingFileStub(sourceFileIds[0]);
+ if (sourceStub?.localFilePath) {
+ actions.updateStirlingFileStub(newStirlingFiles[0].fileId, {
+ localFilePath: sourceStub.localFilePath,
+ isDirty: true
+ });
+ }
+ }
+
+ // Remove source files from context
+ if (sourceFileIds.length > 0) {
+ await actions.removeFiles(sourceFileIds, true);
+ }
+
setHasUnsavedChanges(false);
setSplitPositions(new Set());
setExportLoading(false);
diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx
index 82af8a9e7..a2ad72e2a 100644
--- a/frontend/src/core/components/shared/LandingPage.tsx
+++ b/frontend/src/core/components/shared/LandingPage.tsx
@@ -14,6 +14,7 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
+import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -41,8 +42,14 @@ const LandingPage = () => {
openFilesModal();
};
- const handleNativeUploadClick = () => {
- fileInputRef.current?.click();
+ const handleNativeUploadClick = async () => {
+ const files = await openFilesFromDisk({
+ multiple: true,
+ onFallbackOpen: () => fileInputRef.current?.click()
+ });
+ if (files.length > 0) {
+ await addFiles(files);
+ }
};
const handleFileSelect = async (event: React.ChangeEvent) => {
diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx
index 78c78a1b3..7a6f78fed 100644
--- a/frontend/src/core/components/shared/RightRail.tsx
+++ b/frontend/src/core/components/shared/RightRail.tsx
@@ -3,7 +3,7 @@ import { ActionIcon, Divider } from '@mantine/core';
import '@app/components/shared/rightRail/RightRail.css';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useRightRail } from '@app/contexts/RightRailContext';
-import { useFileState, useFileSelection } from '@app/contexts/FileContext';
+import { useFileState, useFileSelection, useFileActions } from '@app/contexts/FileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
@@ -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 { downloadFile } from '@app/services/downloadService';
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
@@ -59,6 +60,7 @@ export default function RightRail() {
const { selectors } = useFileState();
const { selectedFiles, selectedFileIds } = useFileSelection();
+ const { actions: fileActions } = useFileActions();
const { signaturesApplied } = useSignature();
const activeFiles = selectors.getFiles();
@@ -141,7 +143,7 @@ export default function RightRail() {
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
return;
}
- viewerContext?.exportActions?.download();
+ viewerContext?.exportActions?.download?.();
return;
}
@@ -150,23 +152,43 @@ 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;
+ const stubsToExport = selectedFiles.length > 0
+ ? selectors.getSelectedStirlingFileStubs()
+ : selectors.getStirlingFileStubs();
+
+ if (filesToExport.length > 0) {
+ for (let i = 0; i < filesToExport.length; i++) {
+ const file = filesToExport[i];
+ const stub = stubsToExport[i];
+ console.log('[RightRail] Exporting file:', { fileName: file.name, stubId: stub?.id, localFilePath: stub?.localFilePath, isDirty: stub?.isDirty });
+ const result = await downloadFile({
+ data: file,
+ filename: file.name,
+ localPath: stub?.localFilePath
+ });
+ console.log('[RightRail] Export complete, checking dirty state:', { localFilePath: stub?.localFilePath, isDirty: stub?.isDirty, savedPath: result.savedPath });
+ // Mark file as clean after successful save to disk
+ if (stub && result.savedPath) {
+ console.log('[RightRail] Marking file as clean:', stub.id);
+ fileActions.updateStirlingFileStub(stub.id, {
+ localFilePath: stub.localFilePath ?? result.savedPath,
+ isDirty: false
+ });
+ } else {
+ console.log('[RightRail] Skipping clean mark:', { savedPath: result.savedPath, isDirty: stub?.isDirty });
+ }
+ }
+ }
}, [
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
- signaturesApplied
+ signaturesApplied,
+ selectors,
+ fileActions,
]);
const downloadTooltip = useMemo(() => {
diff --git a/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx
index b737921e6..d6c80cf62 100644
--- a/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx
+++ b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx
@@ -15,6 +15,7 @@ import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
+import { downloadFromUrl } from '@app/services/downloadService';
export interface EditTableOfContentsWorkbenchViewData {
bookmarks: BookmarkNode[];
@@ -177,10 +178,8 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
{downloadUrl && (
}
+ onClick={() => downloadFromUrl(downloadUrl, downloadFilename ?? "download")}
>
{terminology.download}
diff --git a/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx
index 5ee89db4a..a6be1bcc1 100644
--- a/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx
+++ b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
+import { downloadFile } from '@app/services/downloadService';
interface GetPdfInfoResultsProps {
operation: GetPdfInfoOperationHook;
@@ -21,14 +22,7 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
const handleDownload = useCallback((file: File) => {
- const blobUrl = URL.createObjectURL(file);
- const link = document.createElement('a');
- link.href = blobUrl;
- link.download = file.name;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(blobUrl);
+ void downloadFile({ data: file, filename: file.name });
}, []);
if (isLoading && operation.results.length === 0) {
@@ -76,4 +70,3 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
export default GetPdfInfoResults;
-
diff --git a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx
index 7c6429168..7b324d5dc 100644
--- a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx
+++ b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx
@@ -9,6 +9,9 @@ import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
import { Tooltip } from "@app/components/shared/Tooltip";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
+import { saveOperationResults } from "@app/services/operationResultsSaveService";
+import { useFileActions, useFileState } from "@app/contexts/FileContext";
+import { FileId } from "@app/types/fileContext";
export interface ReviewToolStepProps {
isVisible: boolean;
@@ -34,6 +37,8 @@ function ReviewStepContent({
const icons = useFileActionIcons();
const DownloadIcon = icons.download;
const stepRef = useRef(null);
+ const { actions: fileActions } = useFileActions();
+ const { selectors } = useFileState();
const handleUndo = async () => {
try {
@@ -50,6 +55,31 @@ function ReviewStepContent({
thumbnail: operation.thumbnails[index],
})) || [];
+ const handleDownload = async () => {
+ if (!operation.downloadUrl) return;
+ try {
+ await saveOperationResults({
+ downloadUrl: operation.downloadUrl,
+ downloadFilename: operation.downloadFilename || "download",
+ downloadLocalPath: operation.downloadLocalPath,
+ outputFileIds: operation.outputFileIds,
+ getFile: (fileId) => selectors.getFile(fileId as FileId),
+ getStub: (fileId) => selectors.getStirlingFileStub(fileId as FileId),
+ markSaved: (fileId, savedPath) => {
+ const stub = selectors.getStirlingFileStub(fileId as FileId);
+ fileActions.updateStirlingFileStub(fileId as FileId, {
+ localFilePath: stub?.localFilePath ?? savedPath,
+ isDirty: false
+ });
+ }
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error("[ReviewToolStep] Failed to download file:", message);
+ alert(`Failed to download file: ${message}`);
+ }
+ };
+
// Auto-scroll to bottom when content appears
useEffect(() => {
if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) {
@@ -92,13 +122,11 @@ function ReviewStepContent({
)}
{operation.downloadUrl && (
}
color="blue"
fullWidth
mb="md"
+ onClick={handleDownload}
>
{terminology.download}
diff --git a/frontend/src/core/components/tools/showJS/utils.ts b/frontend/src/core/components/tools/showJS/utils.ts
index c5cca2488..0d891ddc5 100644
--- a/frontend/src/core/components/tools/showJS/utils.ts
+++ b/frontend/src/core/components/tools/showJS/utils.ts
@@ -1,3 +1,5 @@
+import { downloadFromUrl } from "@app/services/downloadService";
+
export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain";
export type ShowJsToken = { type: ShowJsTokenType; text: string };
@@ -372,11 +374,6 @@ export async function copyTextToClipboard(text: string, fallbackElement?: HTMLEl
}
}
-export function triggerDownload(url: string, filename: string): void {
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
+export async function triggerDownload(url: string, filename: string): Promise {
+ await downloadFromUrl(url, filename);
}
diff --git a/frontend/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx b/frontend/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
index ccda07370..acbc1158e 100644
--- a/frontend/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
+++ b/frontend/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
@@ -6,6 +6,7 @@ import type { ValidateSignatureOperationHook } from '@app/hooks/tools/validateSi
import '@app/components/tools/validateSignature/reportView/styles.css';
import FitText from '@app/components/shared/FitText';
import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection';
+import { downloadFile } from '@app/services/downloadService';
interface ValidateSignatureResultsProps {
operation: ValidateSignatureOperationHook;
@@ -80,14 +81,7 @@ const ValidateSignatureResults = ({
];
const handleDownload = useCallback((file: File) => {
- const blobUrl = URL.createObjectURL(file);
- const link = document.createElement('a');
- link.href = blobUrl;
- link.download = file.name;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(blobUrl);
+ void downloadFile({ data: file, filename: file.name });
}, []);
// Show the big loader only while we're still waiting for the first results.
diff --git a/frontend/src/core/contexts/FileManagerContext.tsx b/frontend/src/core/contexts/FileManagerContext.tsx
index 5d6fe3b10..0ae6e4b50 100644
--- a/frontend/src/core/contexts/FileManagerContext.tsx
+++ b/frontend/src/core/contexts/FileManagerContext.tsx
@@ -5,6 +5,11 @@ import { StirlingFileStub } from '@app/types/fileContext';
import { downloadFiles } from '@app/utils/downloadUtils';
import { FileId } from '@app/types/file';
import { groupFilesByOriginal } from '@app/utils/fileHistoryUtils';
+import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
+
+// Module-level storage for file path mappings (quickKey -> localFilePath)
+// Used to pass file paths from Tauri file dialog to FileContext
+export const pendingFilePathMappings = new Map();
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
@@ -121,6 +126,7 @@ export const FileManagerProvider: React.FC = ({
const selectedFiles = selectedFileIds.length === 0 ? [] :
displayFiles.filter(file => selectedFilesSet.has(file.id));
+
const filteredFiles = !searchTerm ? displayFiles :
displayFiles.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
@@ -135,9 +141,23 @@ export const FileManagerProvider: React.FC = ({
}
}, []);
- const handleLocalFileClick = useCallback(() => {
- fileInputRef.current?.click();
- }, []);
+ const handleLocalFileClick = useCallback(async () => {
+ console.log('[FileManager] Opening file dialog...');
+
+ // Try native dialog first (desktop), falls back to empty array (web)
+ const files = await openFilesFromDisk({
+ multiple: true,
+ onFallbackOpen: () => fileInputRef.current?.click()
+ });
+
+ if (files.length > 0) {
+ console.log('[FileManager] Passing files to FileContext:', files.map(f => f.name));
+ onNewFilesSelect(files);
+
+ await refreshRecentFiles();
+ onClose();
+ }
+ }, [onNewFilesSelect, refreshRecentFiles, onClose]);
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id;
@@ -434,7 +454,6 @@ export const FileManagerProvider: React.FC = ({
}
}, [selectedFileIds, handleFileRemoveById]);
-
const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts
index adf05f9e0..e7d03ff9f 100644
--- a/frontend/src/core/contexts/file/fileActions.ts
+++ b/frontend/src/core/contexts/file/fileActions.ts
@@ -178,7 +178,7 @@ export function createChildStub(
// Copy parent metadata but exclude processedFile to prevent stale data
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
- return {
+ const childStub = {
// Copy parent metadata (excluding processedFile)
...parentMetadata,
@@ -197,8 +197,24 @@ export function createChildStub(
thumbnailUrl: thumbnail,
// Set fresh processedFile metadata (no inheritance from parent)
- processedFile: processedFileMetadata
+ processedFile: processedFileMetadata,
+
+ // Mark as dirty if parent has a localFilePath (modified file not yet saved to disk)
+ isDirty: parentStub.localFilePath ? true : undefined
};
+
+ if (DEBUG) {
+ console.log('[createChildStub] Created child:', {
+ childId: newFileId,
+ parentId: parentStub.id,
+ parentLocalFilePath: parentStub.localFilePath,
+ childLocalFilePath: childStub.localFilePath,
+ childIsDirty: childStub.isDirty,
+ versionNumber: newVersionNumber
+ });
+ }
+
+ return childStub;
}
interface AddFileOptions {
@@ -311,6 +327,25 @@ export async function addFiles(
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
const fileStub = createNewStirlingFileStub(file, fileId);
+ // Check for pending file path mapping from Tauri file dialog (desktop only)
+ try {
+ const { pendingFilePathMappings } = await import('@app/contexts/FileManagerContext');
+ console.log(`[FileActions] Checking for localFilePath mapping for quickKey: ${quickKey}`);
+ console.log(`[FileActions] Available mappings:`, Array.from(pendingFilePathMappings.keys()));
+ const localFilePath = pendingFilePathMappings.get(quickKey);
+ if (localFilePath) {
+ console.log(`[FileActions] ✓ Found localFilePath: ${localFilePath}`);
+ fileStub.localFilePath = localFilePath;
+ pendingFilePathMappings.delete(quickKey); // Clean up after use
+ console.log(`[FileActions] Applied localFilePath to file: ${file.name}`);
+ } else {
+ console.log(`[FileActions] ✗ No localFilePath found for this file`);
+ }
+ } catch (error) {
+ console.log('[FileActions] Could not check for localFilePath:', error);
+ // FileManagerContext may not be available in all contexts
+ }
+
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
fileStub.insertAfterPageId = options.insertAfterPageId;
@@ -558,11 +593,20 @@ export async function undoConsumeFiles(
indexedDB
);
+ // Mark restored files as dirty if they have localFilePath
+ // (they now differ from what's saved on disk)
+ const stubsWithDirtyMarked = inputStirlingFileStubs.map(stub => {
+ if (stub.localFilePath) {
+ return { ...stub, isDirty: true };
+ }
+ return stub;
+ });
+
// Dispatch the undo action (only if everything else succeeded)
dispatch({
type: 'UNDO_CONSUME_FILES',
payload: {
- inputStirlingFileStubs,
+ inputStirlingFileStubs: stubsWithDirtyMarked,
outputFileIds
}
});
@@ -697,5 +741,6 @@ export const createFileActions = (dispatch: React.Dispatch) =
resetContext: () => dispatch({ type: 'RESET_CONTEXT' }),
markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }),
clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_FILE_ERROR', payload: { fileId } }),
- clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' })
+ clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' }),
+ updateStirlingFileStub: (fileId: FileId, updates: Partial) => dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } })
});
diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts
index b4881408a..58ee11540 100644
--- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts
+++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts
@@ -11,6 +11,7 @@ import { FILE_EVENTS } from '@app/services/errorUtils';
import { getFilenameWithoutExtension } from '@app/utils/fileUtils';
import { ResponseHandler } from '@app/utils/toolResponseProcessor';
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
+import { createNewStirlingFileStub } from '@app/types/fileContext';
import { ToolOperation } from '@app/types/file';
import { ToolId } from '@app/types/toolId';
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
@@ -140,6 +141,8 @@ export interface ToolOperationHook {
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
downloadFilename: string;
+ downloadLocalPath?: string | null;
+ outputFileIds?: string[] | null;
isLoading: boolean;
status: string;
errorMessage: string | null;
@@ -375,7 +378,10 @@ export const useToolOperation = (
actions.setGeneratingThumbnails(false);
actions.setThumbnails(thumbnails);
- actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
+ const downloadLocalPath =
+ validFiles.length === 1 && processedFiles.length === 1
+ ? selectors.getStirlingFileStub(validFiles[0].fileId)?.localFilePath ?? null
+ : null;
// Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds: FileId[] = [];
@@ -390,6 +396,9 @@ export const useToolOperation = (
inputStirlingFileStubs.push(record);
} else {
console.warn(`No file stub found for file: ${file.name}`);
+ const fallbackStub = createNewStirlingFileStub(file, fileId);
+ inputFileIds.push(fileId);
+ inputStirlingFileStubs.push(fallbackStub);
}
}
@@ -437,6 +446,18 @@ export const useToolOperation = (
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
+ if (toConsumeInputIds.length === 1 && outputFileIds.length === 1) {
+ const inputStub = selectors.getStirlingFileStub(toConsumeInputIds[0]);
+ if (inputStub?.localFilePath) {
+ fileActions.updateStirlingFileStub(outputFileIds[0], {
+ localFilePath: inputStub.localFilePath
+ });
+ }
+ }
+
+ // Pass output file IDs to download info for marking clean after save
+ actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename, downloadLocalPath, outputFileIds);
+
// Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = {
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
@@ -532,7 +553,6 @@ export const useToolOperation = (
// Undo the consume operation
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
-
// Clear results and operation tracking
resetResults();
lastOperationRef.current = null;
@@ -565,6 +585,8 @@ export const useToolOperation = (
isGeneratingThumbnails: state.isGeneratingThumbnails,
downloadUrl: state.downloadUrl,
downloadFilename: state.downloadFilename,
+ downloadLocalPath: state.downloadLocalPath,
+ outputFileIds: state.outputFileIds,
isLoading: state.isLoading,
status: state.status,
errorMessage: state.errorMessage,
diff --git a/frontend/src/core/hooks/tools/shared/useToolState.ts b/frontend/src/core/hooks/tools/shared/useToolState.ts
index 883a1555f..6c7e3b821 100644
--- a/frontend/src/core/hooks/tools/shared/useToolState.ts
+++ b/frontend/src/core/hooks/tools/shared/useToolState.ts
@@ -12,6 +12,8 @@ export interface OperationState {
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
downloadFilename: string;
+ downloadLocalPath?: string | null;
+ outputFileIds?: string[] | null;
isLoading: boolean;
status: string;
errorMessage: string | null;
@@ -23,7 +25,7 @@ type OperationAction =
| { type: 'SET_FILES'; payload: File[] }
| { type: 'SET_THUMBNAILS'; payload: string[] }
| { type: 'SET_GENERATING_THUMBNAILS'; payload: boolean }
- | { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string } }
+ | { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string; localPath?: string | null; outputFileIds?: string[] | null } }
| { type: 'SET_STATUS'; payload: string }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_PROGRESS'; payload: ProcessingProgress | null }
@@ -36,6 +38,8 @@ const initialState: OperationState = {
isGeneratingThumbnails: false,
downloadUrl: null,
downloadFilename: '',
+ downloadLocalPath: null,
+ outputFileIds: null,
isLoading: false,
status: '',
errorMessage: null,
@@ -53,10 +57,12 @@ const operationReducer = (state: OperationState, action: OperationAction): Opera
case 'SET_GENERATING_THUMBNAILS':
return { ...state, isGeneratingThumbnails: action.payload };
case 'SET_DOWNLOAD_INFO':
- return {
- ...state,
- downloadUrl: action.payload.url,
- downloadFilename: action.payload.filename
+ return {
+ ...state,
+ downloadUrl: action.payload.url,
+ downloadFilename: action.payload.filename,
+ downloadLocalPath: action.payload.localPath ?? null,
+ outputFileIds: action.payload.outputFileIds ?? null
};
case 'SET_STATUS':
return { ...state, status: action.payload };
@@ -97,8 +103,8 @@ export const useToolState = () => {
dispatch({ type: 'SET_GENERATING_THUMBNAILS', payload: generating });
}, []);
- const setDownloadInfo = useCallback((url: string | null, filename: string) => {
- dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename } });
+ const setDownloadInfo = useCallback((url: string | null, filename: string, localPath?: string | null, outputFileIds?: string[] | null) => {
+ dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename, localPath, outputFileIds } });
}, []);
const setStatus = useCallback((status: string) => {
@@ -136,4 +142,4 @@ export const useToolState = () => {
clearError,
},
};
-};
\ No newline at end of file
+};
diff --git a/frontend/src/core/services/downloadService.ts b/frontend/src/core/services/downloadService.ts
new file mode 100644
index 000000000..0905402a9
--- /dev/null
+++ b/frontend/src/core/services/downloadService.ts
@@ -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 {
+ 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 {
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ return { savedPath: localPath };
+}
diff --git a/frontend/src/core/services/fileDialogService.ts b/frontend/src/core/services/fileDialogService.ts
new file mode 100644
index 000000000..96cf402c9
--- /dev/null
+++ b/frontend/src/core/services/fileDialogService.ts
@@ -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 {
+ // Web build: no native file dialog support
+ return [];
+}
diff --git a/frontend/src/core/services/localFileSaveService.ts b/frontend/src/core/services/localFileSaveService.ts
new file mode 100644
index 000000000..8717c7bf6
--- /dev/null
+++ b/frontend/src/core/services/localFileSaveService.ts
@@ -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 {
+ 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 {
+ 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 {
+ return { success: false, savedCount: 0, error: "Multi-file save not available in web mode" };
+}
diff --git a/frontend/src/core/services/openFilesFromDisk.ts b/frontend/src/core/services/openFilesFromDisk.ts
new file mode 100644
index 000000000..df1b70946
--- /dev/null
+++ b/frontend/src/core/services/openFilesFromDisk.ts
@@ -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 {
+ 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 [];
+}
diff --git a/frontend/src/core/services/operationResultsSaveService.ts b/frontend/src/core/services/operationResultsSaveService.ts
new file mode 100644
index 000000000..8579da62e
--- /dev/null
+++ b/frontend/src/core/services/operationResultsSaveService.ts
@@ -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 {
+ 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;
+}
diff --git a/frontend/src/core/services/pdfExportService.ts b/frontend/src/core/services/pdfExportService.ts
index ebbd4d2b0..74708a177 100644
--- a/frontend/src/core/services/pdfExportService.ts
+++ b/frontend/src/core/services/pdfExportService.ts
@@ -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 });
}
/**
diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx
index 40bc8583d..e4e7ffeaa 100644
--- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx
+++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx
@@ -192,7 +192,7 @@ describe('Convert Tool Integration Tests', () => {
expect(result.current.downloadUrl).toBeTruthy();
expect(result.current.downloadFilename).toBe('test.png');
expect(result.current.isLoading).toBe(false);
- expect(result.current.errorMessage).not.toBe(null);
+ expect(result.current.errorMessage).toBe(null);
});
test('should handle API error responses correctly', async () => {
@@ -478,7 +478,7 @@ describe('Convert Tool Integration Tests', () => {
expect(result.current.downloadUrl).toBeTruthy();
expect(result.current.downloadFilename).toBe('test.csv');
expect(result.current.isLoading).toBe(false);
- expect(result.current.errorMessage).not.toBe(null);
+ expect(result.current.errorMessage).toBe(null);
});
test('should handle complete unsupported conversion workflow', async () => {
diff --git a/frontend/src/core/tools/EditTableOfContents.tsx b/frontend/src/core/tools/EditTableOfContents.tsx
index a86260394..57334c891 100644
--- a/frontend/src/core/tools/EditTableOfContents.tsx
+++ b/frontend/src/core/tools/EditTableOfContents.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { downloadFile } from '@app/services/downloadService';
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
import { alert } from '@app/components/toast';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
@@ -191,14 +192,7 @@ const EditTableOfContents = (props: BaseToolProps) => {
const exportJsonCallback = () => {
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
const blob = new Blob([data], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const anchor = document.createElement('a');
- anchor.href = url;
- anchor.download = 'bookmarks.json';
- document.body.appendChild(anchor);
- anchor.click();
- document.body.removeChild(anchor);
- URL.revokeObjectURL(url);
+ void downloadFile({ data: blob, filename: 'bookmarks.json' });
alert({
title: t('editTableOfContents.messages.exported', 'JSON download ready'),
alertType: 'success',
diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts
index d28ea3a0d..b0a304c35 100644
--- a/frontend/src/core/types/fileContext.ts
+++ b/frontend/src/core/types/fileContext.ts
@@ -45,9 +45,11 @@ export interface StirlingFileStub extends BaseFileMetadata {
quickKey?: string; // Fast deduplication key: name|size|lastModified
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
blobUrl?: string; // File access blob URL for downloads/processing
+ localFilePath?: string; // Original local filesystem path (desktop app only)
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
insertAfterPageId?: string; // Page ID after which this file should be inserted
isPinned?: boolean; // Protected from tool consumption (replace/remove)
+ isDirty?: boolean; // Has unsaved changes (only for files with localFilePath)
// Note: File object stored in provider ref, not in state
}
diff --git a/frontend/src/core/utils/automationConverter.ts b/frontend/src/core/utils/automationConverter.ts
index 74b8e0a79..b35546f33 100644
--- a/frontend/src/core/utils/automationConverter.ts
+++ b/frontend/src/core/utils/automationConverter.ts
@@ -4,6 +4,7 @@
import { AutomationConfig } from '@app/types/automation';
import { ToolRegistry } from '@app/data/toolsTaxonomy';
+import { downloadFile } from '@app/services/downloadService';
import { ToolId } from '@app/types/toolId';
/**
@@ -95,16 +96,5 @@ export function downloadFolderScanningConfig(
const config = convertToFolderScanningConfig(automation, toolRegistry);
const json = JSON.stringify(config, null, 2);
const blob = new Blob([json], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
-
- const a = document.createElement('a');
- a.href = url;
- a.download = `${automation.name}.json`;
- a.style.display = 'none';
-
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
-
- URL.revokeObjectURL(url);
+ void downloadFile({ data: blob, filename: `${automation.name}.json` });
}
diff --git a/frontend/src/core/utils/downloadUtils.ts b/frontend/src/core/utils/downloadUtils.ts
index 4bf8223fb..f6c1ff347 100644
--- a/frontend/src/core/utils/downloadUtils.ts
+++ b/frontend/src/core/utils/downloadUtils.ts
@@ -1,6 +1,7 @@
import { StirlingFileStub } from '@app/types/fileContext';
import { fileStorage } from '@app/services/fileStorage';
import { zipFileService } from '@app/services/zipFileService';
+import { downloadFile } from '@app/services/downloadService';
/**
* Downloads a blob as a file using browser download API
@@ -8,17 +9,7 @@ import { zipFileService } from '@app/services/zipFileService';
* @param filename - The filename for the download
*/
export function downloadBlob(blob: Blob, filename: string): void {
- const url = URL.createObjectURL(blob);
-
- const link = document.createElement('a');
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- // Clean up the blob URL
- URL.revokeObjectURL(url);
+ void downloadFile({ data: blob, filename });
}
/**
@@ -34,8 +25,11 @@ export async function downloadFileFromStorage(file: StirlingFileStub): Promise
+
{children}
);
diff --git a/frontend/src/desktop/components/SaveShortcutListener.tsx b/frontend/src/desktop/components/SaveShortcutListener.tsx
new file mode 100644
index 000000000..64b5d681a
--- /dev/null
+++ b/frontend/src/desktop/components/SaveShortcutListener.tsx
@@ -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;
+}
diff --git a/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx b/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx
new file mode 100644
index 000000000..1fe75e085
--- /dev/null
+++ b/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx
@@ -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 (
+ <>
+ {file.name}
+ {!file.localFilePath && (
+
+
+
+ )}
+ {file.localFilePath && file.isDirty && (
+
+
+
+ )}
+ {file.localFilePath && !file.isDirty && (
+
+
+
+ )}
+ >
+ );
+};
+
+export default FileEditorFileName;
diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts
index 2a7379a2e..863bc5140 100644
--- a/frontend/src/desktop/constants/connection.ts
+++ b/frontend/src/desktop/constants/connection.ts
@@ -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';
diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts
index 68b5fc219..7796842ca 100644
--- a/frontend/src/desktop/hooks/useAppInitialization.ts
+++ b/frontend/src/desktop/hooks/useAppInitialization.ts
@@ -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 {
diff --git a/frontend/src/desktop/hooks/useExitWarning.ts b/frontend/src/desktop/hooks/useExitWarning.ts
new file mode 100644
index 000000000..4e45a91cc
--- /dev/null
+++ b/frontend/src/desktop/hooks/useExitWarning.ts
@@ -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 };
+ };
+}
diff --git a/frontend/src/desktop/hooks/useOpenedFile.ts b/frontend/src/desktop/hooks/useOpenedFile.ts
index 6347ef736..38ae77cdb 100644
--- a/frontend/src/desktop/hooks/useOpenedFile.ts
+++ b/frontend/src/desktop/hooks/useOpenedFile.ts
@@ -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([]);
const [loading, setLoading] = useState(true);
+ const openedFilePathsRef = useRef([]);
+
+ 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 };
}
diff --git a/frontend/src/desktop/hooks/useSaveShortcut.ts b/frontend/src/desktop/hooks/useSaveShortcut.ts
new file mode 100644
index 000000000..82961138a
--- /dev/null
+++ b/frontend/src/desktop/hooks/useSaveShortcut.ts
@@ -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]);
+}
diff --git a/frontend/src/desktop/services/downloadService.ts b/frontend/src/desktop/services/downloadService.ts
new file mode 100644
index 000000000..3981ec0cc
--- /dev/null
+++ b/frontend/src/desktop/services/downloadService.ts
@@ -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 {
+ 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 {
+ 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 });
+}
diff --git a/frontend/src/desktop/services/fileDialogService.ts b/frontend/src/desktop/services/fileDialogService.ts
new file mode 100644
index 000000000..d7da45b14
--- /dev/null
+++ b/frontend/src/desktop/services/fileDialogService.ts
@@ -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 {
+ 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 [];
+ }
+}
diff --git a/frontend/src/desktop/services/fileOpenService.ts b/frontend/src/desktop/services/fileOpenService.ts
index 67813df38..059191c80 100644
--- a/frontend/src/desktop/services/fileOpenService.ts
+++ b/frontend/src/desktop/services/fileOpenService.ts
@@ -10,9 +10,9 @@ export interface FileOpenService {
class TauriFileOpenService implements FileOpenService {
async getOpenedFiles(): Promise {
try {
- console.log('🔍 Calling invoke(get_opened_files)...');
- const result = await invoke('get_opened_files');
- console.log('🔍 invoke(get_opened_files) returned:', result);
+ console.log('🔍 Calling invoke(pop_opened_files)...');
+ const result = await invoke('pop_opened_files');
+ console.log('🔍 invoke(pop_opened_files) returned:', result);
return result;
} catch (error) {
console.error('❌ Failed to get opened files:', error);
diff --git a/frontend/src/desktop/services/localFileSaveService.ts b/frontend/src/desktop/services/localFileSaveService.ts
new file mode 100644
index 000000000..473892760
--- /dev/null
+++ b/frontend/src/desktop/services/localFileSaveService.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 };
+ }
+}
diff --git a/frontend/src/desktop/services/operationResultsSaveService.ts b/frontend/src/desktop/services/operationResultsSaveService.ts
new file mode 100644
index 000000000..b3e10c3b8
--- /dev/null
+++ b/frontend/src/desktop/services/operationResultsSaveService.ts
@@ -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 {
+ 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;
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index de3dc20fc..1e39ddf9e 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -11,6 +11,22 @@ export default defineConfig(({ mode }) => {
process.env.STIRLING_DESKTOP === 'true' ||
process.env.VITE_DESKTOP === 'true';
+ // Validate required environment variables for desktop builds
+ if (isDesktopMode) {
+ const requiredEnvVars = [
+ 'VITE_SAAS_SERVER_URL',
+ 'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY',
+ ];
+
+ const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
+
+ if (missingVars.length > 0) {
+ throw new Error(
+ `Desktop build failed: Missing required environment variables:\n${missingVars.map(v => ` - ${v}`).join('\n')}\n\nPlease set these variables before building the desktop app.`
+ );
+ }
+ }
+
const baseProject = isProprietary ? './tsconfig.proprietary.vite.json' : './tsconfig.core.vite.json';
const desktopProject = isProprietary ? './tsconfig.desktop.vite.json' : baseProject;
const tsconfigProject = isDesktopMode ? desktopProject : baseProject;
diff --git a/testing/test_pdf_1.pdf b/testing/test_pdf_1.pdf
index 3d63420a29b7ac7507459b9f6fbb0035683f2971..f625c114696ff68d8b3c9b824d6732662c8c0070 100644
GIT binary patch
delta 918
zcmdnabCH8jHNeG9*HF)lOZD56Kldl|GRe7f=?5gHrxq)iD;OvQap{Lt7NjcZJ13ST
z=H#by+1YWW=B4B(WpP;mr9rff4VS)8YF>IthJvAqIhVd$W==_JkwWz3Q_Rvlu^}KAvBVnZiM>|i17N_{;
z)2DApX!ox)WxT;VEnlK!T|?~bJ7TwNn5!>5J5}*Fc1L)0i^uV4U)A1d?^^Rhq}^fe
ziLctF)j6jh+5h<@|3|pq>mm#P;lt;jMP!(6d%xjX$cxFG%@bSP6S$vpxTl$H;Mu!2
zwr}4@)k~eu$q#e(9bw4cHe;Cy|9#J2Z^LXK|Bz5WUg5oFNn@(niUxy6EoG-%jz!pM
z#0q>^@nsdC{xwH$$?R{@(Z7Qegxj?;vX@O=G%{eo*x=7KR)T{7Op?%Z&&!S
zux)B-;xaRVr)R$$)7;)}_NHuiUv|~fTO~!im%5klxE~o_k#R9KuX}&nh3nl`?z1-W
z+?u+jvrOibnziTh@>_2niGF5Sd+P^R@!usI7ku(+7fLofvSg)2XsCy_U+Bw^*@2~x
zjdovF$((CaX8-TYN%tr0{}e7cf}*}2E$$5suth$XzNd>qw2iZqv8juhiHW0;nVW&L
zrIV|%sinE2xtoQdg{70Bk+GcuVI{FZv+~mN70e)sc`^?hPdy|>M+Bv&ap?zVR;4N!
z0#lo3UP@|(LbQQ`p#e}&xI(m{f{}tDkO@uCC}|FnsDUXhIU(W4`6CBTG92k~IKy(}
zOb^c=1v_qm%|=En#s)@KMhp!4?TkMV312oBmhy{B5{pVIih%C0Ff!#*Rdw}u;{pJ%
Cw_nEq
literal 1591
zcmb7E-B#l^5We?QOt&XXST@MNNlaqvwvw3RNJf?y=tWJA~Zb-EGT-ZAPQ*Sj-IzOc2qHvAb)Kjs-S~10Wq69KADIErany8|agjVL%Jk
z5eQ4VEc?t<8dD(qJQhjHr$EkAzQ>F7S#eC~3_&d*!!lA2ISM2EqpTM^L9DeEmG@NKvRa!RFUw()2;V1+1U
z%nDdAsmamQBEqQ@@sKG5Ao@wjh}|P`y*Zt-V7a!JYY#4w1|S2W@CnhKj3ng?NUb+u
zMc64iuMy^<>54FYbR-gF^<%+~1;>q66{|!a8E;cXGcjGmsIOt{m1-s=R4EtyVJ^yI
z6G&S!s#3v2omn;9u}#}Eo0f0-u4lSirnO<38!J2Q{9Kdz+J|W*seFJMysANT>a&MC
zA(H~CC}8&A3WA&_Wz7Cx6ipx2-wlfuR7+muEKVmWOEBapRT>VsF^)^_pJDuzhqd
zWJvH*K%_&)r)mb4+t5IT!dI9Z2UMP$CXHh8*6oIIn#`;7-g{>wy!fYnF#Fo7ps16y5xz@K!Mg)F|%**5ml3!US<_VsM6;um(F{~E99
zV=uq3^IiX0rzA}?CKpq&7lJX~jU&fP>aI)e`oxU=h&i6$pp8k>YWk67JFRb9TpE?N
zK|THFL0zjsl484JiT>Nn-ZTu-;}Ze8Wd{aM3!y-d0NfAL-x+egC>1Kz;t?#XSvM+`
I{gaOIH=2dV2><{9