mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Preserve local paths for desktop saves (#5543)
# Summary - Adds desktop file tracking: local paths are preserved and save buttons now work as expcted (doing Save/Save As as appropriate) - Adds logic to track whether files are 'dirty' (they've been modified by some tool, and not saved to disk yet). - Improves file state UX (dirty vs saved) and close warnings - Web behaviour should be unaffected by these changes ## Indicators Files now have indicators in desktop mode to tell you their state. ### File up-to-date with disk <img width="318" height="393" alt="image" src="https://github.com/user-attachments/assets/06325f9a-afd7-4c2f-8a5b-6d11e3093115" /> ### File modified by a tool but not saved to disk yet <img width="357" height="385" alt="image" src="https://github.com/user-attachments/assets/1a7716d9-c6f7-4d13-be0d-c1de6493954b" /> ### File not tracked on disk <img width="312" height="379" alt="image" src="https://github.com/user-attachments/assets/9cffe300-bd9a-4e19-97c7-9b98bebefacc" /> # Limitations - It's a bit weird that we still have files stored in indexeddb in the app, which are still loadable. We might want to change this behaviour in the future - Viewer's Save doesn't persist to disk. I've left that out here because it'd need a lot of testing to make sure the logic's right with making sure you can leave the Viewer with applying the changes to the PDF _without_ saving to disk - There's no current way to do Save As on a file that has already been persisted to disk - it's only ever Save. Similarly, there's no way to duplicate a file. --------- Co-authored-by: James Brunton <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
James Brunton
parent
946196de43
commit
b8ce4e47c1
@@ -30,6 +30,91 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
|
|||||||
- **Web Server**: `npm run build` then serve dist/ folder
|
- **Web Server**: `npm run build` then serve dist/ folder
|
||||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
- **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 (
|
||||||
|
<Box className={className}>
|
||||||
|
<BackendHealthIndicator />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||||
|
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||||
|
|
||||||
|
export function RightRail() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* In web builds: renders nothing (stub returns null) */}
|
||||||
|
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||||
|
<RightRailFooterExtensions className="right-rail-footer" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
#### Multi-Tool Workflow Architecture
|
||||||
Frontend designed for **stateful document processing**:
|
Frontend designed for **stateful document processing**:
|
||||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
- 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+)
|
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||||
|
|
||||||
#### FileContext - Central State Management
|
#### FileContext - Central State Management
|
||||||
**Location**: `src/contexts/FileContext.tsx`
|
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||||
- **Active files**: Currently loaded PDFs and their variants
|
- **Active files**: Currently loaded PDFs and their variants
|
||||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
- **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:
|
**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
|
- Coordinates all tool operations with consistent interface
|
||||||
- Integrates with FileContext for operation tracking
|
- Integrates with FileContext for operation tracking
|
||||||
- Handles validation, error handling, and UI state management
|
- Handles validation, error handling, and UI state management
|
||||||
@@ -147,8 +232,34 @@ return useToolOperation({
|
|||||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
- **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
|
### 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)
|
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||||
- **Internationalization**:
|
- **Internationalization**:
|
||||||
- Backend: `messages_*.properties` files
|
- Backend: `messages_*.properties` files
|
||||||
@@ -203,6 +314,7 @@ return useToolOperation({
|
|||||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
- **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)
|
- **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
|
- **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
|
- **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
|
- **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`)
|
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||||
|
|||||||
Generated
+10
@@ -50,6 +50,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-http": "^2.5.6",
|
"@tauri-apps/plugin-http": "^2.5.6",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||||
@@ -4198,6 +4199,15 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@tauri-apps/plugin-fs": {
|
||||||
"version": "2.4.5",
|
"version": "2.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-http": "^2.5.6",
|
"@tauri-apps/plugin-http": "^2.5.6",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ confirmClose = "Confirm Close"
|
|||||||
confirmCloseCancel = "Cancel"
|
confirmCloseCancel = "Cancel"
|
||||||
confirmCloseConfirm = "Close File"
|
confirmCloseConfirm = "Close File"
|
||||||
confirmCloseMessage = "Are you sure you want to close this 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."
|
confirmPasswordErrorMessage = "New Password and Confirm New Password must match."
|
||||||
custom = "Custom..."
|
custom = "Custom..."
|
||||||
customPosition = "Custom Position"
|
customPosition = "Custom Position"
|
||||||
@@ -39,6 +45,8 @@ edit = "Edit"
|
|||||||
editYourNewFiles = "Edit your new file(s)"
|
editYourNewFiles = "Edit your new file(s)"
|
||||||
exportAndContinue = "Export & Continue"
|
exportAndContinue = "Export & Continue"
|
||||||
false = "False"
|
false = "False"
|
||||||
|
fileSavedToDisk = "File saved to disk"
|
||||||
|
fileNotSavedToDisk = "Not saved to disk"
|
||||||
fileSelected = "Selected: {{filename}}"
|
fileSelected = "Selected: {{filename}}"
|
||||||
filesSelected = "{{count}} files selected"
|
filesSelected = "{{count}} files selected"
|
||||||
font = "Font"
|
font = "Font"
|
||||||
|
|||||||
Generated
+45
@@ -907,6 +907,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3531,6 +3533,30 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.5",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -4229,6 +4255,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-deep-link",
|
"tauri-plugin-deep-link",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
@@ -4609,6 +4636,24 @@ dependencies = [
|
|||||||
"windows-result 0.3.4",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-fs"
|
name = "tauri-plugin-fs"
|
||||||
version = "2.4.5"
|
version = "2.4.5"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
|
|||||||
tauri-plugin-log = "2.8.0"
|
tauri-plugin-log = "2.8.0"
|
||||||
tauri-plugin-shell = "2.3.4"
|
tauri-plugin-shell = "2.3.4"
|
||||||
tauri-plugin-fs = "2.4.5"
|
tauri-plugin-fs = "2.4.5"
|
||||||
|
tauri-plugin-dialog = "2.4.2"
|
||||||
tauri-plugin-http = { version = "2.5.6", features = ["dangerous-settings"] }
|
tauri-plugin-http = { version = "2.5.6", features = ["dangerous-settings"] }
|
||||||
tauri-plugin-single-instance = { version = "2.3.7", features = ["deep-link"] }
|
tauri-plugin-single-instance = { version = "2.3.7", features = ["deep-link"] }
|
||||||
tauri-plugin-store = "2.4.2"
|
tauri-plugin-store = "2.4.2"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:window:allow-destroy",
|
||||||
"http:default",
|
"http:default",
|
||||||
{
|
{
|
||||||
"identifier": "http:allow-fetch",
|
"identifier": "http:allow-fetch",
|
||||||
@@ -40,6 +41,18 @@
|
|||||||
"identifier": "fs:allow-read-file",
|
"identifier": "fs:allow-read-file",
|
||||||
"allow": [{ "path": "**" }]
|
"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",
|
"opener:default",
|
||||||
"shell:allow-open"
|
"shell:allow-open"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -33,3 +33,12 @@ pub async fn clear_opened_files() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Command to atomically get and clear opened file paths
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pop_opened_files() -> Result<Vec<String>, 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ pub mod auth;
|
|||||||
pub mod default_app;
|
pub mod default_app;
|
||||||
|
|
||||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
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::{
|
pub use connection::{
|
||||||
get_connection_config,
|
get_connection_config,
|
||||||
is_first_launch,
|
is_first_launch,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use commands::{
|
|||||||
get_backend_port,
|
get_backend_port,
|
||||||
get_connection_config,
|
get_connection_config,
|
||||||
get_opened_files,
|
get_opened_files,
|
||||||
|
pop_opened_files,
|
||||||
get_refresh_token,
|
get_refresh_token,
|
||||||
get_user_info,
|
get_user_info,
|
||||||
is_first_launch,
|
is_first_launch,
|
||||||
@@ -55,6 +56,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_deep_link::init())
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
@@ -139,6 +141,7 @@ pub fn run() {
|
|||||||
start_backend,
|
start_backend,
|
||||||
get_backend_port,
|
get_backend_port,
|
||||||
get_opened_files,
|
get_opened_files,
|
||||||
|
pop_opened_files,
|
||||||
clear_opened_files,
|
clear_opened_files,
|
||||||
get_tauri_logs,
|
get_tauri_logs,
|
||||||
get_connection_config,
|
get_connection_config,
|
||||||
@@ -170,9 +173,9 @@ pub fn run() {
|
|||||||
app_handle.cleanup_before_exit();
|
app_handle.cleanup_before_exit();
|
||||||
}
|
}
|
||||||
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
|
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
|
||||||
add_log("🔄 Window close requested, cleaning up...".to_string());
|
add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string());
|
||||||
cleanup_backend();
|
// Don't cleanup here - let JavaScript handler prevent close if needed
|
||||||
// Allow the window to close
|
// Backend cleanup happens in ExitRequested when window actually closes
|
||||||
}
|
}
|
||||||
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
|
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
|
||||||
use tauri::DragDropEvent;
|
use tauri::DragDropEvent;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
|||||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||||
|
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
|
||||||
|
|
||||||
interface AddFileCardProps {
|
interface AddFileCardProps {
|
||||||
onFileSelect: (files: File[]) => void;
|
onFileSelect: (files: File[]) => void;
|
||||||
@@ -33,9 +34,15 @@ const AddFileCard = ({
|
|||||||
openFilesModal();
|
openFilesModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNativeUploadClick = (e: React.MouseEvent) => {
|
const handleNativeUploadClick = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
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) => {
|
const handleOpenFilesModal = (e: React.MouseEvent) => {
|
||||||
@@ -179,4 +186,4 @@ const AddFileCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AddFileCard;
|
export default AddFileCard;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import AddFileCard from '@app/components/fileEditor/AddFileCard';
|
|||||||
import FilePickerModal from '@app/components/shared/FilePickerModal';
|
import FilePickerModal from '@app/components/shared/FilePickerModal';
|
||||||
import { FileId, StirlingFile } from '@app/types/fileContext';
|
import { FileId, StirlingFile } from '@app/types/fileContext';
|
||||||
import { alert } from '@app/components/toast';
|
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 { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
|
|
||||||
@@ -278,13 +278,29 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
}, [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 record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
const file = record ? selectors.getFile(record.id) : null;
|
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) {
|
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 handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<PrivateContent>{file.name}</PrivateContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FileEditorFileName;
|
||||||
@@ -23,6 +23,8 @@ import { FileId } from '@app/types/file';
|
|||||||
import { formatFileSize } from '@app/utils/fileUtils';
|
import { formatFileSize } from '@app/utils/fileUtils';
|
||||||
import ToolChain from '@app/components/shared/ToolChain';
|
import ToolChain from '@app/components/shared/ToolChain';
|
||||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
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';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ const FileEditorThumbnail = ({
|
|||||||
actions: fileActions,
|
actions: fileActions,
|
||||||
openEncryptedUnlockPrompt,
|
openEncryptedUnlockPrompt,
|
||||||
} = useFileContext();
|
} = useFileContext();
|
||||||
const { state } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||||
|
|
||||||
// ---- Drag state ----
|
// ---- Drag state ----
|
||||||
@@ -191,6 +193,37 @@ const FileEditorThumbnail = ({
|
|||||||
setShowCloseModal(false);
|
setShowCloseModal(false);
|
||||||
}, [file.id, file.name, onCloseFile]);
|
}, [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(() => {
|
const handleCancelClose = useCallback(() => {
|
||||||
setShowCloseModal(false);
|
setShowCloseModal(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -213,7 +246,6 @@ const FileEditorThumbnail = ({
|
|||||||
onClick: (e) => {
|
onClick: (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDownloadFile(file.id);
|
onDownloadFile(file.id);
|
||||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -365,8 +397,8 @@ const FileEditorThumbnail = ({
|
|||||||
marginTop: '0.5rem',
|
marginTop: '0.5rem',
|
||||||
marginBottom: '0.5rem',
|
marginBottom: '0.5rem',
|
||||||
}}>
|
}}>
|
||||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
<Text size="lg" fw={700} className={styles.title} lineClamp={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
|
||||||
<PrivateContent>{file.name}</PrivateContent>
|
<FileEditorFileName file={file} />
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -469,18 +501,40 @@ const FileEditorThumbnail = ({
|
|||||||
size="auto"
|
size="auto"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
{file.isDirty && file.localFilePath ? (
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
<>
|
||||||
{file.name}
|
<Text size="md">{t('confirmCloseUnsaved', 'This file has unsaved changes.')}</Text>
|
||||||
</Text>
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
<Group justify="flex-end" gap="sm">
|
{file.name}
|
||||||
<Button variant="light" onClick={handleCancelClose}>
|
</Text>
|
||||||
{t('confirmCloseCancel', 'Cancel')}
|
<Group justify="flex-end" gap="sm">
|
||||||
</Button>
|
<Button variant="light" onClick={handleCancelClose}>
|
||||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
{t('confirmCloseCancel', 'Cancel')}
|
||||||
{t('confirmCloseConfirm', 'Close File')}
|
</Button>
|
||||||
</Button>
|
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||||
</Group>
|
{t('confirmCloseDiscard', 'Discard changes and close')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="filled" onClick={handleSaveAndClose}>
|
||||||
|
{t('confirmCloseSave', 'Save and close')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="light" onClick={handleCancelClose}>
|
||||||
|
{t('confirmCloseCancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||||
|
{t('confirmCloseConfirm', 'Close File')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ const FileActions: React.FC = () => {
|
|||||||
const terminology = useFileActionTerminology();
|
const terminology = useFileActionTerminology();
|
||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const DownloadIcon = icons.download;
|
const DownloadIcon = icons.download;
|
||||||
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
|
const {
|
||||||
useFileManagerContext();
|
recentFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
filteredFiles,
|
||||||
|
onSelectAll,
|
||||||
|
onDeleteSelected,
|
||||||
|
onDownloadSelected
|
||||||
|
} = useFileManagerContext();
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
onSelectAll();
|
onSelectAll();
|
||||||
@@ -31,6 +37,7 @@ const FileActions: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Only show actions if there are files
|
// Only show actions if there are files
|
||||||
if (recentFiles.length === 0) {
|
if (recentFiles.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
||||||
const { removeFiles } = useFileManagement();
|
const { removeFiles } = useFileManagement();
|
||||||
|
|
||||||
// Check if this is a ZIP file
|
// Check if this is a ZIP file
|
||||||
@@ -269,6 +269,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
>
|
>
|
||||||
{t('fileManager.delete', 'Delete')}
|
{t('fileManager.delete', 'Delete')}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { FileId } from '@app/types/file';
|
|||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
id: FileId;
|
id: FileId;
|
||||||
@@ -79,13 +80,7 @@ const FileThumbnail = ({
|
|||||||
// Fallback: attempt to download using the File object if provided
|
// Fallback: attempt to download using the File object if provided
|
||||||
const maybeFile = (file as unknown as { file?: File }).file;
|
const maybeFile = (file as unknown as { file?: File }).file;
|
||||||
if (maybeFile instanceof File) {
|
if (maybeFile instanceof File) {
|
||||||
const link = document.createElement('a');
|
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || 'download' });
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,21 @@ export const usePageEditorExport = ({
|
|||||||
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
|
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);
|
setHasUnsavedChanges(false);
|
||||||
setSplitPositions(new Set());
|
setSplitPositions(new Set());
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
|||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||||
|
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
|
||||||
|
|
||||||
const LandingPage = () => {
|
const LandingPage = () => {
|
||||||
const { addFiles } = useFileHandler();
|
const { addFiles } = useFileHandler();
|
||||||
@@ -41,8 +42,14 @@ const LandingPage = () => {
|
|||||||
openFilesModal();
|
openFilesModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNativeUploadClick = () => {
|
const handleNativeUploadClick = async () => {
|
||||||
fileInputRef.current?.click();
|
const files = await openFilesFromDisk({
|
||||||
|
multiple: true,
|
||||||
|
onFallbackOpen: () => fileInputRef.current?.click()
|
||||||
|
});
|
||||||
|
if (files.length > 0) {
|
||||||
|
await addFiles(files);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ActionIcon, Divider } from '@mantine/core';
|
|||||||
import '@app/components/shared/rightRail/RightRail.css';
|
import '@app/components/shared/rightRail/RightRail.css';
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
import { useRightRail } from '@app/contexts/RightRailContext';
|
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 { useNavigationState } from '@app/contexts/NavigationContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||||
@@ -22,6 +22,7 @@ import LightModeIcon from '@mui/icons-material/LightMode';
|
|||||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
|
|
||||||
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export default function RightRail() {
|
|||||||
|
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||||
|
const { actions: fileActions } = useFileActions();
|
||||||
const { signaturesApplied } = useSignature();
|
const { signaturesApplied } = useSignature();
|
||||||
|
|
||||||
const activeFiles = selectors.getFiles();
|
const activeFiles = selectors.getFiles();
|
||||||
@@ -141,7 +143,7 @@ export default function RightRail() {
|
|||||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
viewerContext?.exportActions?.download();
|
viewerContext?.exportActions?.download?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,23 +152,43 @@ export default function RightRail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||||
filesToDownload.forEach(file => {
|
const stubsToExport = selectedFiles.length > 0
|
||||||
const link = document.createElement('a');
|
? selectors.getSelectedStirlingFileStubs()
|
||||||
link.href = URL.createObjectURL(file);
|
: selectors.getStirlingFileStubs();
|
||||||
link.download = file.name;
|
|
||||||
document.body.appendChild(link);
|
if (filesToExport.length > 0) {
|
||||||
link.click();
|
for (let i = 0; i < filesToExport.length; i++) {
|
||||||
document.body.removeChild(link);
|
const file = filesToExport[i];
|
||||||
URL.revokeObjectURL(link.href);
|
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,
|
currentView,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
activeFiles,
|
activeFiles,
|
||||||
pageEditorFunctions,
|
pageEditorFunctions,
|
||||||
viewerContext,
|
viewerContext,
|
||||||
signaturesApplied
|
signaturesApplied,
|
||||||
|
selectors,
|
||||||
|
fileActions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const downloadTooltip = useMemo(() => {
|
const downloadTooltip = useMemo(() => {
|
||||||
|
|||||||
+2
-3
@@ -15,6 +15,7 @@ import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
|
|||||||
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
|
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
|
||||||
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
|
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||||
|
import { downloadFromUrl } from '@app/services/downloadService';
|
||||||
|
|
||||||
export interface EditTableOfContentsWorkbenchViewData {
|
export interface EditTableOfContentsWorkbenchViewData {
|
||||||
bookmarks: BookmarkNode[];
|
bookmarks: BookmarkNode[];
|
||||||
@@ -177,10 +178,8 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
|
|||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
{downloadUrl && (
|
{downloadUrl && (
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
|
||||||
href={downloadUrl}
|
|
||||||
download={downloadFilename ?? undefined}
|
|
||||||
leftSection={<LocalIcon icon='download-rounded' />}
|
leftSection={<LocalIcon icon='download-rounded' />}
|
||||||
|
onClick={() => downloadFromUrl(downloadUrl, downloadFilename ?? "download")}
|
||||||
>
|
>
|
||||||
{terminology.download}
|
{terminology.download}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
|||||||
import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
|
import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
|
import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
|
|
||||||
interface GetPdfInfoResultsProps {
|
interface GetPdfInfoResultsProps {
|
||||||
operation: GetPdfInfoOperationHook;
|
operation: GetPdfInfoOperationHook;
|
||||||
@@ -21,14 +22,7 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
|
|||||||
const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
|
const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
|
||||||
|
|
||||||
const handleDownload = useCallback((file: File) => {
|
const handleDownload = useCallback((file: File) => {
|
||||||
const blobUrl = URL.createObjectURL(file);
|
void downloadFile({ data: file, filename: file.name });
|
||||||
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);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isLoading && operation.results.length === 0) {
|
if (isLoading && operation.results.length === 0) {
|
||||||
@@ -76,4 +70,3 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
|
|||||||
|
|
||||||
export default GetPdfInfoResults;
|
export default GetPdfInfoResults;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
|
|||||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
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<TParams = unknown> {
|
export interface ReviewToolStepProps<TParams = unknown> {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -34,6 +37,8 @@ function ReviewStepContent<TParams = unknown>({
|
|||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const DownloadIcon = icons.download;
|
const DownloadIcon = icons.download;
|
||||||
const stepRef = useRef<HTMLDivElement>(null);
|
const stepRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { actions: fileActions } = useFileActions();
|
||||||
|
const { selectors } = useFileState();
|
||||||
|
|
||||||
const handleUndo = async () => {
|
const handleUndo = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +55,31 @@ function ReviewStepContent<TParams = unknown>({
|
|||||||
thumbnail: operation.thumbnails[index],
|
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
|
// Auto-scroll to bottom when content appears
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) {
|
if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) {
|
||||||
@@ -92,13 +122,11 @@ function ReviewStepContent<TParams = unknown>({
|
|||||||
)}
|
)}
|
||||||
{operation.downloadUrl && (
|
{operation.downloadUrl && (
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
|
||||||
href={operation.downloadUrl}
|
|
||||||
download={operation.downloadFilename}
|
|
||||||
leftSection={<DownloadIcon />}
|
leftSection={<DownloadIcon />}
|
||||||
color="blue"
|
color="blue"
|
||||||
fullWidth
|
fullWidth
|
||||||
mb="md"
|
mb="md"
|
||||||
|
onClick={handleDownload}
|
||||||
>
|
>
|
||||||
{terminology.download}
|
{terminology.download}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { downloadFromUrl } from "@app/services/downloadService";
|
||||||
|
|
||||||
export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain";
|
export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain";
|
||||||
export type ShowJsToken = { type: ShowJsTokenType; text: string };
|
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 {
|
export async function triggerDownload(url: string, filename: string): Promise<void> {
|
||||||
const a = document.createElement("a");
|
await downloadFromUrl(url, filename);
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ValidateSignatureOperationHook } from '@app/hooks/tools/validateSi
|
|||||||
import '@app/components/tools/validateSignature/reportView/styles.css';
|
import '@app/components/tools/validateSignature/reportView/styles.css';
|
||||||
import FitText from '@app/components/shared/FitText';
|
import FitText from '@app/components/shared/FitText';
|
||||||
import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection';
|
import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
|
|
||||||
interface ValidateSignatureResultsProps {
|
interface ValidateSignatureResultsProps {
|
||||||
operation: ValidateSignatureOperationHook;
|
operation: ValidateSignatureOperationHook;
|
||||||
@@ -80,14 +81,7 @@ const ValidateSignatureResults = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleDownload = useCallback((file: File) => {
|
const handleDownload = useCallback((file: File) => {
|
||||||
const blobUrl = URL.createObjectURL(file);
|
void downloadFile({ data: file, filename: file.name });
|
||||||
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);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show the big loader only while we're still waiting for the first results.
|
// Show the big loader only while we're still waiting for the first results.
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { StirlingFileStub } from '@app/types/fileContext';
|
|||||||
import { downloadFiles } from '@app/utils/downloadUtils';
|
import { downloadFiles } from '@app/utils/downloadUtils';
|
||||||
import { FileId } from '@app/types/file';
|
import { FileId } from '@app/types/file';
|
||||||
import { groupFilesByOriginal } from '@app/utils/fileHistoryUtils';
|
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<string, string>();
|
||||||
|
|
||||||
// Type for the context value - now contains everything directly
|
// Type for the context value - now contains everything directly
|
||||||
interface FileManagerContextValue {
|
interface FileManagerContextValue {
|
||||||
@@ -121,6 +126,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||||
displayFiles.filter(file => selectedFilesSet.has(file.id));
|
displayFiles.filter(file => selectedFilesSet.has(file.id));
|
||||||
|
|
||||||
|
|
||||||
const filteredFiles = !searchTerm ? displayFiles :
|
const filteredFiles = !searchTerm ? displayFiles :
|
||||||
displayFiles.filter(file =>
|
displayFiles.filter(file =>
|
||||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
@@ -135,9 +141,23 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLocalFileClick = useCallback(() => {
|
const handleLocalFileClick = useCallback(async () => {
|
||||||
fileInputRef.current?.click();
|
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 handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
|
||||||
const fileId = file.id;
|
const fileId = file.id;
|
||||||
@@ -434,7 +454,6 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selectedFileIds, handleFileRemoveById]);
|
}, [selectedFileIds, handleFileRemoveById]);
|
||||||
|
|
||||||
|
|
||||||
const handleDownloadSelected = useCallback(async () => {
|
const handleDownloadSelected = useCallback(async () => {
|
||||||
if (selectedFileIds.length === 0) return;
|
if (selectedFileIds.length === 0) return;
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export function createChildStub(
|
|||||||
// Copy parent metadata but exclude processedFile to prevent stale data
|
// Copy parent metadata but exclude processedFile to prevent stale data
|
||||||
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
|
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
|
||||||
|
|
||||||
return {
|
const childStub = {
|
||||||
// Copy parent metadata (excluding processedFile)
|
// Copy parent metadata (excluding processedFile)
|
||||||
...parentMetadata,
|
...parentMetadata,
|
||||||
|
|
||||||
@@ -197,8 +197,24 @@ export function createChildStub(
|
|||||||
thumbnailUrl: thumbnail,
|
thumbnailUrl: thumbnail,
|
||||||
|
|
||||||
// Set fresh processedFile metadata (no inheritance from parent)
|
// 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 {
|
interface AddFileOptions {
|
||||||
@@ -311,6 +327,25 @@ export async function addFiles(
|
|||||||
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
|
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
|
||||||
const fileStub = createNewStirlingFileStub(file, fileId);
|
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
|
// Store insertion position if provided
|
||||||
if (options.insertAfterPageId !== undefined) {
|
if (options.insertAfterPageId !== undefined) {
|
||||||
fileStub.insertAfterPageId = options.insertAfterPageId;
|
fileStub.insertAfterPageId = options.insertAfterPageId;
|
||||||
@@ -558,11 +593,20 @@ export async function undoConsumeFiles(
|
|||||||
indexedDB
|
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 the undo action (only if everything else succeeded)
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UNDO_CONSUME_FILES',
|
type: 'UNDO_CONSUME_FILES',
|
||||||
payload: {
|
payload: {
|
||||||
inputStirlingFileStubs,
|
inputStirlingFileStubs: stubsWithDirtyMarked,
|
||||||
outputFileIds
|
outputFileIds
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -697,5 +741,6 @@ export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) =
|
|||||||
resetContext: () => dispatch({ type: 'RESET_CONTEXT' }),
|
resetContext: () => dispatch({ type: 'RESET_CONTEXT' }),
|
||||||
markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }),
|
markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }),
|
||||||
clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_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<StirlingFileStub>) => dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } })
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FILE_EVENTS } from '@app/services/errorUtils';
|
|||||||
import { getFilenameWithoutExtension } from '@app/utils/fileUtils';
|
import { getFilenameWithoutExtension } from '@app/utils/fileUtils';
|
||||||
import { ResponseHandler } from '@app/utils/toolResponseProcessor';
|
import { ResponseHandler } from '@app/utils/toolResponseProcessor';
|
||||||
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
|
import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
|
||||||
|
import { createNewStirlingFileStub } from '@app/types/fileContext';
|
||||||
import { ToolOperation } from '@app/types/file';
|
import { ToolOperation } from '@app/types/file';
|
||||||
import { ToolId } from '@app/types/toolId';
|
import { ToolId } from '@app/types/toolId';
|
||||||
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
|
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
|
||||||
@@ -140,6 +141,8 @@ export interface ToolOperationHook<TParams = void> {
|
|||||||
isGeneratingThumbnails: boolean;
|
isGeneratingThumbnails: boolean;
|
||||||
downloadUrl: string | null;
|
downloadUrl: string | null;
|
||||||
downloadFilename: string;
|
downloadFilename: string;
|
||||||
|
downloadLocalPath?: string | null;
|
||||||
|
outputFileIds?: string[] | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
@@ -375,7 +378,10 @@ export const useToolOperation = <TParams>(
|
|||||||
actions.setGeneratingThumbnails(false);
|
actions.setGeneratingThumbnails(false);
|
||||||
|
|
||||||
actions.setThumbnails(thumbnails);
|
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)
|
// Replace input files with processed files (consumeFiles handles pinning)
|
||||||
const inputFileIds: FileId[] = [];
|
const inputFileIds: FileId[] = [];
|
||||||
@@ -390,6 +396,9 @@ export const useToolOperation = <TParams>(
|
|||||||
inputStirlingFileStubs.push(record);
|
inputStirlingFileStubs.push(record);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`No file stub found for file: ${file.name}`);
|
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 = <TParams>(
|
|||||||
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
||||||
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
|
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)
|
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||||
lastOperationRef.current = {
|
lastOperationRef.current = {
|
||||||
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
|
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
|
||||||
@@ -532,7 +553,6 @@ export const useToolOperation = <TParams>(
|
|||||||
// Undo the consume operation
|
// Undo the consume operation
|
||||||
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
|
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
|
||||||
|
|
||||||
|
|
||||||
// Clear results and operation tracking
|
// Clear results and operation tracking
|
||||||
resetResults();
|
resetResults();
|
||||||
lastOperationRef.current = null;
|
lastOperationRef.current = null;
|
||||||
@@ -565,6 +585,8 @@ export const useToolOperation = <TParams>(
|
|||||||
isGeneratingThumbnails: state.isGeneratingThumbnails,
|
isGeneratingThumbnails: state.isGeneratingThumbnails,
|
||||||
downloadUrl: state.downloadUrl,
|
downloadUrl: state.downloadUrl,
|
||||||
downloadFilename: state.downloadFilename,
|
downloadFilename: state.downloadFilename,
|
||||||
|
downloadLocalPath: state.downloadLocalPath,
|
||||||
|
outputFileIds: state.outputFileIds,
|
||||||
isLoading: state.isLoading,
|
isLoading: state.isLoading,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
errorMessage: state.errorMessage,
|
errorMessage: state.errorMessage,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface OperationState {
|
|||||||
isGeneratingThumbnails: boolean;
|
isGeneratingThumbnails: boolean;
|
||||||
downloadUrl: string | null;
|
downloadUrl: string | null;
|
||||||
downloadFilename: string;
|
downloadFilename: string;
|
||||||
|
downloadLocalPath?: string | null;
|
||||||
|
outputFileIds?: string[] | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
@@ -23,7 +25,7 @@ type OperationAction =
|
|||||||
| { type: 'SET_FILES'; payload: File[] }
|
| { type: 'SET_FILES'; payload: File[] }
|
||||||
| { type: 'SET_THUMBNAILS'; payload: string[] }
|
| { type: 'SET_THUMBNAILS'; payload: string[] }
|
||||||
| { type: 'SET_GENERATING_THUMBNAILS'; payload: boolean }
|
| { 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_STATUS'; payload: string }
|
||||||
| { type: 'SET_ERROR'; payload: string | null }
|
| { type: 'SET_ERROR'; payload: string | null }
|
||||||
| { type: 'SET_PROGRESS'; payload: ProcessingProgress | null }
|
| { type: 'SET_PROGRESS'; payload: ProcessingProgress | null }
|
||||||
@@ -36,6 +38,8 @@ const initialState: OperationState = {
|
|||||||
isGeneratingThumbnails: false,
|
isGeneratingThumbnails: false,
|
||||||
downloadUrl: null,
|
downloadUrl: null,
|
||||||
downloadFilename: '',
|
downloadFilename: '',
|
||||||
|
downloadLocalPath: null,
|
||||||
|
outputFileIds: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
status: '',
|
status: '',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -53,10 +57,12 @@ const operationReducer = (state: OperationState, action: OperationAction): Opera
|
|||||||
case 'SET_GENERATING_THUMBNAILS':
|
case 'SET_GENERATING_THUMBNAILS':
|
||||||
return { ...state, isGeneratingThumbnails: action.payload };
|
return { ...state, isGeneratingThumbnails: action.payload };
|
||||||
case 'SET_DOWNLOAD_INFO':
|
case 'SET_DOWNLOAD_INFO':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
downloadUrl: action.payload.url,
|
downloadUrl: action.payload.url,
|
||||||
downloadFilename: action.payload.filename
|
downloadFilename: action.payload.filename,
|
||||||
|
downloadLocalPath: action.payload.localPath ?? null,
|
||||||
|
outputFileIds: action.payload.outputFileIds ?? null
|
||||||
};
|
};
|
||||||
case 'SET_STATUS':
|
case 'SET_STATUS':
|
||||||
return { ...state, status: action.payload };
|
return { ...state, status: action.payload };
|
||||||
@@ -97,8 +103,8 @@ export const useToolState = () => {
|
|||||||
dispatch({ type: 'SET_GENERATING_THUMBNAILS', payload: generating });
|
dispatch({ type: 'SET_GENERATING_THUMBNAILS', payload: generating });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setDownloadInfo = useCallback((url: string | null, filename: string) => {
|
const setDownloadInfo = useCallback((url: string | null, filename: string, localPath?: string | null, outputFileIds?: string[] | null) => {
|
||||||
dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename } });
|
dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename, localPath, outputFileIds } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setStatus = useCallback((status: string) => {
|
const setStatus = useCallback((status: string) => {
|
||||||
@@ -136,4 +142,4 @@ export const useToolState = () => {
|
|||||||
clearError,
|
clearError,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<DownloadResult> {
|
||||||
|
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<DownloadResult> {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
return { savedPath: localPath };
|
||||||
|
}
|
||||||
@@ -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<FileWithPath[]> {
|
||||||
|
// Web build: no native file dialog support
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -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<SaveResult> {
|
||||||
|
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<string | null> {
|
||||||
|
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<MultiFileSaveResult> {
|
||||||
|
return { success: false, savedCount: 0, error: "Multi-file save not available in web mode" };
|
||||||
|
}
|
||||||
@@ -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<File[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
@@ -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<DownloadResult | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
|
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
@@ -181,18 +182,7 @@ export class PDFExportService {
|
|||||||
* Download a single file
|
* Download a single file
|
||||||
*/
|
*/
|
||||||
downloadFile(blob: Blob, filename: string): void {
|
downloadFile(blob: Blob, filename: string): void {
|
||||||
const url = URL.createObjectURL(blob);
|
void downloadFile({ data: blob, filename });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
expect(result.current.downloadUrl).toBeTruthy();
|
expect(result.current.downloadUrl).toBeTruthy();
|
||||||
expect(result.current.downloadFilename).toBe('test.png');
|
expect(result.current.downloadFilename).toBe('test.png');
|
||||||
expect(result.current.isLoading).toBe(false);
|
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 () => {
|
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.downloadUrl).toBeTruthy();
|
||||||
expect(result.current.downloadFilename).toBe('test.csv');
|
expect(result.current.downloadFilename).toBe('test.csv');
|
||||||
expect(result.current.isLoading).toBe(false);
|
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 () => {
|
test('should handle complete unsupported conversion workflow', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
|
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||||
@@ -191,14 +192,7 @@ const EditTableOfContents = (props: BaseToolProps) => {
|
|||||||
const exportJsonCallback = () => {
|
const exportJsonCallback = () => {
|
||||||
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
|
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
|
||||||
const blob = new Blob([data], { type: 'application/json' });
|
const blob = new Blob([data], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
void downloadFile({ data: blob, filename: 'bookmarks.json' });
|
||||||
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);
|
|
||||||
alert({
|
alert({
|
||||||
title: t('editTableOfContents.messages.exported', 'JSON download ready'),
|
title: t('editTableOfContents.messages.exported', 'JSON download ready'),
|
||||||
alertType: 'success',
|
alertType: 'success',
|
||||||
|
|||||||
@@ -45,9 +45,11 @@ export interface StirlingFileStub extends BaseFileMetadata {
|
|||||||
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
||||||
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
|
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
|
||||||
blobUrl?: string; // File access blob URL for downloads/processing
|
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
|
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
|
||||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||||
isPinned?: boolean; // Protected from tool consumption (replace/remove)
|
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
|
// Note: File object stored in provider ref, not in state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { AutomationConfig } from '@app/types/automation';
|
import { AutomationConfig } from '@app/types/automation';
|
||||||
import { ToolRegistry } from '@app/data/toolsTaxonomy';
|
import { ToolRegistry } from '@app/data/toolsTaxonomy';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
import { ToolId } from '@app/types/toolId';
|
import { ToolId } from '@app/types/toolId';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,16 +96,5 @@ export function downloadFolderScanningConfig(
|
|||||||
const config = convertToFolderScanningConfig(automation, toolRegistry);
|
const config = convertToFolderScanningConfig(automation, toolRegistry);
|
||||||
const json = JSON.stringify(config, null, 2);
|
const json = JSON.stringify(config, null, 2);
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
void downloadFile({ data: blob, filename: `${automation.name}.json` });
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from '@app/types/fileContext';
|
||||||
import { fileStorage } from '@app/services/fileStorage';
|
import { fileStorage } from '@app/services/fileStorage';
|
||||||
import { zipFileService } from '@app/services/zipFileService';
|
import { zipFileService } from '@app/services/zipFileService';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a blob as a file using browser download API
|
* 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
|
* @param filename - The filename for the download
|
||||||
*/
|
*/
|
||||||
export function downloadBlob(blob: Blob, filename: string): void {
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
const url = URL.createObjectURL(blob);
|
void downloadFile({ data: blob, filename });
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,8 +25,11 @@ export async function downloadFileFromStorage(file: StirlingFileStub): Promise<v
|
|||||||
throw new Error(`File "${file.name}" not found in storage`);
|
throw new Error(`File "${file.name}" not found in storage`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// StirlingFile is already a File object, just download it
|
await downloadFile({
|
||||||
downloadBlob(stirlingFile, stirlingFile.name);
|
data: stirlingFile,
|
||||||
|
filename: stirlingFile.name,
|
||||||
|
localPath: file.localFilePath
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,7 +74,7 @@ export async function downloadFilesAsZip(files: StirlingFileStub[], zipFilename?
|
|||||||
|
|
||||||
// Create and download ZIP
|
// Create and download ZIP
|
||||||
const { zipFile } = await zipFileService.createZipFromFiles(filesToZip, finalZipFilename);
|
const { zipFile } = await zipFileService.createZipFromFiles(filesToZip, finalZipFilename);
|
||||||
downloadBlob(zipFile, finalZipFilename);
|
await downloadFile({ data: zipFile, filename: finalZipFilename });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,7 +114,7 @@ export async function downloadFiles(
|
|||||||
* @param filename - Optional custom filename
|
* @param filename - Optional custom filename
|
||||||
*/
|
*/
|
||||||
export function downloadFileObject(file: File, filename?: string): void {
|
export function downloadFileObject(file: File, filename?: string): void {
|
||||||
downloadBlob(file, filename || file.name);
|
void downloadFile({ data: file, filename: filename || file.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,7 +129,7 @@ export function downloadTextAsFile(
|
|||||||
mimeType: string = 'text/plain'
|
mimeType: string = 'text/plain'
|
||||||
): void {
|
): void {
|
||||||
const blob = new Blob([content], { type: mimeType });
|
const blob = new Blob([content], { type: mimeType });
|
||||||
downloadBlob(blob, filename);
|
void downloadFile({ data: blob, filename });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function getDocumentFileDialogFilter() {
|
||||||
|
return [{
|
||||||
|
name: 'Documents',
|
||||||
|
extensions: ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'tiff', 'bmp', 'html', 'zip']
|
||||||
|
}];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ReactNode, useEffect, useState } from "react";
|
|||||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||||
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
|
||||||
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
|
||||||
|
import { SaveShortcutListener } from '@app/components/SaveShortcutListener';
|
||||||
import { SetupWizard } from '@app/components/SetupWizard';
|
import { SetupWizard } from '@app/components/SetupWizard';
|
||||||
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
|
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
|
||||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||||
@@ -149,6 +150,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
>
|
>
|
||||||
<DesktopConfigSync />
|
<DesktopConfigSync />
|
||||||
<DesktopBannerInitializer />
|
<DesktopBannerInitializer />
|
||||||
|
<SaveShortcutListener />
|
||||||
{children}
|
{children}
|
||||||
</ProprietaryAppProviders>
|
</ProprietaryAppProviders>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useSaveShortcut } from '@app/hooks/useSaveShortcut';
|
||||||
|
import { useExitWarning } from '@app/hooks/useExitWarning';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-only component that sets up keyboard shortcuts and exit warnings
|
||||||
|
* - Ctrl/Cmd+S to save selected files
|
||||||
|
* - Warning on app exit if unsaved files
|
||||||
|
* Renders nothing, just sets up the listeners
|
||||||
|
*/
|
||||||
|
export function SaveShortcutListener() {
|
||||||
|
useSaveShortcut();
|
||||||
|
useExitWarning();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tooltip } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { StirlingFileStub } from '@app/types/fileContext';
|
||||||
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
|
|
||||||
|
interface FileEditorFileNameProps {
|
||||||
|
file: StirlingFileStub;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PrivateContent>{file.name}</PrivateContent>
|
||||||
|
{!file.localFilePath && (
|
||||||
|
<Tooltip label={t('fileNotSavedToDisk', 'Not saved to disk')}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--mantine-color-red-6)',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
aria-label={t('fileNotSavedToDisk', 'Not saved to disk')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{file.localFilePath && file.isDirty && (
|
||||||
|
<Tooltip label={t('unsavedChanges', 'Unsaved changes')}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--mantine-color-yellow-6)',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
aria-label={t('unsavedChanges', 'Unsaved changes')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{file.localFilePath && !file.isDirty && (
|
||||||
|
<Tooltip label={t('fileSavedToDisk', 'File saved to disk')}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--mantine-color-green-6)',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
aria-label={t('fileSavedToDisk', 'File saved to disk')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileEditorFileName;
|
||||||
@@ -6,9 +6,6 @@
|
|||||||
// The SaaS authentication server
|
// The SaaS authentication server
|
||||||
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
|
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
|
// Supabase publishable key from environment variable
|
||||||
// Used for SaaS authentication
|
// Used for SaaS authentication
|
||||||
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
|
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
||||||
import { fileOpenService } from '@app/services/fileOpenService';
|
import { fileOpenService } from '@app/services/fileOpenService';
|
||||||
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||||
|
import { createQuickKey } from '@app/types/fileContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App initialization hook
|
* App initialization hook
|
||||||
@@ -11,10 +12,10 @@ import { useFileManagement } from '@app/contexts/file/fileHooks';
|
|||||||
*/
|
*/
|
||||||
export function useAppInitialization(): void {
|
export function useAppInitialization(): void {
|
||||||
// Get file management actions
|
// Get file management actions
|
||||||
const { addFiles } = useFileManagement();
|
const { addFiles, updateStirlingFileStub } = useFileManagement();
|
||||||
|
|
||||||
// Handle files opened with app (Tauri mode)
|
// 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
|
// Load opened files and add directly to FileContext
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,29 +24,50 @@ export function useAppInitialization(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadOpenedFiles = async () => {
|
const loadOpenedFiles = async () => {
|
||||||
|
const filePaths = consumeOpenedFilePaths();
|
||||||
|
if (filePaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
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, {
|
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
||||||
type: 'application/pdf'
|
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) {
|
console.log('[Desktop] Loaded file:', fileData.fileName);
|
||||||
await addFiles(filesArray);
|
|
||||||
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
|
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) {
|
} catch (error) {
|
||||||
console.error('[Desktop] Failed to load opened files:', error);
|
console.error('[Desktop] Failed to load opened files:', error);
|
||||||
@@ -53,7 +75,7 @@ export function useAppInitialization(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadOpenedFiles();
|
loadOpenedFiles();
|
||||||
}, [openedFilePaths, openedFileLoading, addFiles]);
|
}, [openedFilePaths, openedFileLoading, addFiles, updateStirlingFileStub, consumeOpenedFilePaths]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetupCompletion(): (completed: boolean) => void {
|
export function useSetupCompletion(): (completed: boolean) => void {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
import { message } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { useFileState, useFileActions } from '@app/contexts/FileContext';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
|
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export function useExitWarning() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { selectors } = useFileState();
|
||||||
|
const { actions: fileActions } = useFileActions();
|
||||||
|
const selectorsRef = useRef(selectors);
|
||||||
|
const isClosingRef = useRef(false);
|
||||||
|
|
||||||
|
selectorsRef.current = selectors;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
|
const handleCloseRequested = async (event: { preventDefault: () => void }) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (isClosingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allStubs = selectorsRef.current.getStirlingFileStubs();
|
||||||
|
const dirtyStubs = allStubs.filter(stub => stub.isDirty);
|
||||||
|
|
||||||
|
if (dirtyStubs.length > 0) {
|
||||||
|
const fileList = dirtyStubs.map(f => `• ${f.name}`).join('\n');
|
||||||
|
const saveLabel = t('confirmCloseSave', 'Save and close');
|
||||||
|
const discardLabel = t('confirmCloseDiscard', 'Discard changes and close');
|
||||||
|
const cancelLabel = t('confirmCloseCancel', 'Cancel');
|
||||||
|
|
||||||
|
const choice = await message(
|
||||||
|
t(
|
||||||
|
'confirmCloseUnsavedList',
|
||||||
|
'You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}',
|
||||||
|
{ count: dirtyStubs.length, plural: dirtyStubs.length > 1 ? 's' : '', fileList }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
title: t('confirmCloseUnsaved', 'This file has unsaved changes.'),
|
||||||
|
kind: 'warning',
|
||||||
|
buttons: {
|
||||||
|
yes: saveLabel,
|
||||||
|
no: discardLabel,
|
||||||
|
cancel: cancelLabel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (choice === cancelLabel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === saveLabel) {
|
||||||
|
const { failedCount, cancelled } = await saveDirtyFiles(dirtyStubs);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (failedCount > 0) {
|
||||||
|
await message(
|
||||||
|
t(
|
||||||
|
'confirmCloseSaveFailed',
|
||||||
|
'Saved with errors. {{count}} file{{plural}} could not be saved.',
|
||||||
|
{ count: failedCount, plural: failedCount > 1 ? 's' : '' }
|
||||||
|
),
|
||||||
|
{ title: t('confirmCloseSaveFailedTitle', 'Save Failed'), kind: 'error' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (choice !== discardLabel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosingRef.current = true;
|
||||||
|
try {
|
||||||
|
await appWindow.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[exit-warning] destroy failed', error);
|
||||||
|
isClosingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlisten = appWindow.onCloseRequested(handleCloseRequested);
|
||||||
|
return () => {
|
||||||
|
unlisten.then(fn => {
|
||||||
|
fn();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [fileActions, t]);
|
||||||
|
|
||||||
|
const saveDirtyFiles = async (dirtyStubs: StirlingFileStub[]) => {
|
||||||
|
const filesById = new Map(selectorsRef.current.getFiles().map(file => [file.fileId, file]));
|
||||||
|
let failedCount = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
for (const stub of dirtyStubs) {
|
||||||
|
const file = filesById.get(stub.id);
|
||||||
|
if (!file || !stub.localFilePath) {
|
||||||
|
if (!file) {
|
||||||
|
failedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await downloadFile({
|
||||||
|
data: file,
|
||||||
|
filename: file.name,
|
||||||
|
localPath: stub.localFilePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.savedPath) {
|
||||||
|
fileActions.updateStirlingFileStub(stub.id, {
|
||||||
|
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||||
|
isDirty: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { failedCount, cancelled };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { fileOpenService } from '@app/services/fileOpenService';
|
import { fileOpenService } from '@app/services/fileOpenService';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
export function useOpenedFile() {
|
export function useOpenedFile() {
|
||||||
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
|
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const openedFilePathsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
const clearOpenedFilePaths = useCallback(() => {
|
||||||
|
openedFilePathsRef.current = [];
|
||||||
|
setOpenedFilePaths([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const consumeOpenedFilePaths = useCallback(() => {
|
||||||
|
const current = openedFilePathsRef.current;
|
||||||
|
openedFilePathsRef.current = [];
|
||||||
|
setOpenedFilePaths([]);
|
||||||
|
return current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Function to read and process files from storage
|
// Function to read and process files from storage
|
||||||
@@ -16,8 +29,8 @@ export function useOpenedFile() {
|
|||||||
|
|
||||||
if (filePaths.length > 0) {
|
if (filePaths.length > 0) {
|
||||||
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
|
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
|
||||||
|
openedFilePathsRef.current = filePaths;
|
||||||
setOpenedFilePaths(filePaths);
|
setOpenedFilePaths(filePaths);
|
||||||
await fileOpenService.clearOpenedFiles();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to read files from storage:', error);
|
console.error('❌ Failed to read files from storage:', error);
|
||||||
@@ -44,5 +57,5 @@ export function useOpenedFile() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { openedFilePaths, loading };
|
return { openedFilePaths, loading, clearOpenedFilePaths, consumeOpenedFilePaths };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFileState, useFileActions } from '@app/contexts/FileContext';
|
||||||
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
|
||||||
|
* Only saves files that have a localFilePath (came from disk)
|
||||||
|
* Matches Right Rail button behavior: saves selected files if any, otherwise all files
|
||||||
|
*/
|
||||||
|
export function useSaveShortcut() {
|
||||||
|
const { selectors, state } = useFileState();
|
||||||
|
const { actions: fileActions } = useFileActions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||||
|
// Check for Ctrl+S (Windows/Linux) or Cmd+S (Mac)
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Get selected files or all files if nothing selected
|
||||||
|
const selectedFileIds = state.ui.selectedFileIds;
|
||||||
|
const filesToSave = selectedFileIds.length > 0
|
||||||
|
? selectors.getFiles(selectedFileIds)
|
||||||
|
: selectors.getFiles();
|
||||||
|
const stubsToSave = selectedFileIds.length > 0
|
||||||
|
? selectors.getStirlingFileStubs(selectedFileIds)
|
||||||
|
: selectors.getStirlingFileStubs();
|
||||||
|
|
||||||
|
if (filesToSave.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save files (Save As for files without localFilePath)
|
||||||
|
for (let i = 0; i < filesToSave.length; i++) {
|
||||||
|
const file = filesToSave[i];
|
||||||
|
const stub = stubsToSave[i];
|
||||||
|
if (!stub) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await downloadFile({
|
||||||
|
data: file,
|
||||||
|
filename: file.name,
|
||||||
|
localPath: stub.localFilePath
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark file as clean after successful save
|
||||||
|
if (result.savedPath) {
|
||||||
|
fileActions.updateStirlingFileStub(stub.id, {
|
||||||
|
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||||
|
isDirty: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to save ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [selectors, state.ui.selectedFileIds, fileActions]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DownloadRequest, DownloadResult } from "@core/services/downloadService";
|
||||||
|
import { saveToLocalPath, showSaveDialog } from "@app/services/localFileSaveService";
|
||||||
|
|
||||||
|
export type { DownloadRequest, DownloadResult };
|
||||||
|
|
||||||
|
export async function downloadFile(request: DownloadRequest): Promise<DownloadResult> {
|
||||||
|
if (request.localPath) {
|
||||||
|
const result = await saveToLocalPath(request.data, request.localPath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || "Failed to save file");
|
||||||
|
}
|
||||||
|
return { savedPath: request.localPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePath = await showSaveDialog(request.filename);
|
||||||
|
if (!savePath) {
|
||||||
|
return { cancelled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await saveToLocalPath(request.data, savePath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || "Failed to save file");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { savedPath: savePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFromUrl(
|
||||||
|
url: string,
|
||||||
|
filename: string,
|
||||||
|
localPath?: string
|
||||||
|
): Promise<DownloadResult> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
return downloadFile({ data: blob, filename, localPath });
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Desktop implementation - Tauri native file dialogs
|
||||||
|
import type { FileWithPath, FileDialogOptions } from '@core/services/fileDialogService';
|
||||||
|
import { createQuickKey } from '@app/types/fileContext';
|
||||||
|
import { getDocumentFileDialogFilter } from '@app/utils/fileDialogUtils';
|
||||||
|
|
||||||
|
export type { FileWithPath, FileDialogOptions };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open native file dialog and read selected files (Desktop/Tauri only)
|
||||||
|
*/
|
||||||
|
export async function openFileDialog(
|
||||||
|
options?: FileDialogOptions
|
||||||
|
): Promise<FileWithPath[]> {
|
||||||
|
try {
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const { readFile } = await import('@tauri-apps/plugin-fs');
|
||||||
|
|
||||||
|
console.log('[FileDialog] Opening file dialog...');
|
||||||
|
const selectedPaths = await open({
|
||||||
|
multiple: options?.multiple ?? true,
|
||||||
|
filters: options?.filters ?? getDocumentFileDialogFilter()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectedPaths) {
|
||||||
|
console.log('[FileDialog] User cancelled');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = Array.isArray(selectedPaths) ? selectedPaths : [selectedPaths];
|
||||||
|
console.log(`[FileDialog] Selected ${paths.length} file(s):`, paths);
|
||||||
|
|
||||||
|
const filesWithPaths: FileWithPath[] = [];
|
||||||
|
|
||||||
|
for (const filePath of paths) {
|
||||||
|
try {
|
||||||
|
console.log(`[FileDialog] Reading file: ${filePath}`);
|
||||||
|
const fileData = await readFile(filePath);
|
||||||
|
const fileName = filePath.split(/[/\\]/).pop() || 'document';
|
||||||
|
const file = new File([fileData], fileName, {
|
||||||
|
type: fileName.endsWith('.pdf') ? 'application/pdf' : undefined
|
||||||
|
});
|
||||||
|
const quickKey = createQuickKey(file);
|
||||||
|
console.log(`[FileDialog] Created File: ${fileName}, quickKey: ${quickKey}`);
|
||||||
|
|
||||||
|
filesWithPaths.push({
|
||||||
|
file,
|
||||||
|
path: filePath,
|
||||||
|
quickKey
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[FileDialog] Failed to read ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filesWithPaths;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[FileDialog] Error:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ export interface FileOpenService {
|
|||||||
class TauriFileOpenService implements FileOpenService {
|
class TauriFileOpenService implements FileOpenService {
|
||||||
async getOpenedFiles(): Promise<string[]> {
|
async getOpenedFiles(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Calling invoke(get_opened_files)...');
|
console.log('🔍 Calling invoke(pop_opened_files)...');
|
||||||
const result = await invoke<string[]>('get_opened_files');
|
const result = await invoke<string[]>('pop_opened_files');
|
||||||
console.log('🔍 invoke(get_opened_files) returned:', result);
|
console.log('🔍 invoke(pop_opened_files) returned:', result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to get opened files:', error);
|
console.error('❌ Failed to get opened files:', error);
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { SaveResult, MultiFileSaveResult } from "@core/services/localFileSaveService";
|
||||||
|
export type { SaveResult, MultiFileSaveResult };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save file data to a local filesystem path (Tauri desktop only)
|
||||||
|
*
|
||||||
|
* @param data - Blob or File to save
|
||||||
|
* @param filePath - Absolute path to save to
|
||||||
|
* @returns Result indicating success or failure with error message
|
||||||
|
*/
|
||||||
|
export async function saveToLocalPath(
|
||||||
|
data: Blob | File,
|
||||||
|
filePath: string
|
||||||
|
): Promise<SaveResult> {
|
||||||
|
try {
|
||||||
|
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const arrayBuffer = await data.arrayBuffer();
|
||||||
|
await writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[LocalFileSave] Failed to save:', message);
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show native save dialog and return selected path
|
||||||
|
*
|
||||||
|
* @param defaultFilename - Suggested filename
|
||||||
|
* @param defaultDirectory - Optional default directory
|
||||||
|
* @returns Selected file path or null if cancelled
|
||||||
|
*/
|
||||||
|
export async function showSaveDialog(
|
||||||
|
defaultFilename: string,
|
||||||
|
defaultDirectory?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
|
||||||
|
const selectedPath = await save({
|
||||||
|
defaultPath: defaultDirectory ? `${defaultDirectory}/${defaultFilename}` : defaultFilename,
|
||||||
|
filters: [{
|
||||||
|
name: 'PDF',
|
||||||
|
extensions: ['pdf']
|
||||||
|
}],
|
||||||
|
title: 'Save As'
|
||||||
|
});
|
||||||
|
|
||||||
|
return selectedPath;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SaveDialog] Failed to show dialog:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt user to select a folder and save multiple files there
|
||||||
|
*
|
||||||
|
* @param files - Array of files to save
|
||||||
|
* @param defaultDirectory - Optional default directory to open dialog in
|
||||||
|
* @returns Result with count of files saved
|
||||||
|
*/
|
||||||
|
export async function saveMultipleFilesWithPrompt(
|
||||||
|
files: (Blob | File)[],
|
||||||
|
defaultDirectory?: string
|
||||||
|
): Promise<MultiFileSaveResult> {
|
||||||
|
try {
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const { join } = await import("@tauri-apps/api/path");
|
||||||
|
|
||||||
|
// Prompt user to select folder
|
||||||
|
const selectedFolder = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: defaultDirectory,
|
||||||
|
title: `Save ${files.length} file${files.length > 1 ? 's' : ''}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// User cancelled
|
||||||
|
if (!selectedFolder) {
|
||||||
|
return { success: false, savedCount: 0, cancelledByUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save each file to the selected folder
|
||||||
|
let savedCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const fileName = file instanceof File ? file.name : `output_${savedCount + 1}.pdf`;
|
||||||
|
const filePath = await join(selectedFolder as string, fileName);
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
await writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
savedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
errors.push(`${file instanceof File ? file.name : 'file'}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedCount === files.length) {
|
||||||
|
return { success: true, savedCount };
|
||||||
|
} else if (savedCount > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
savedCount,
|
||||||
|
error: `Saved ${savedCount}/${files.length} files. Errors: ${errors.join(', ')}`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
savedCount: 0,
|
||||||
|
error: `Failed to save files: ${errors.join(', ')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[LocalFileSave] Failed to save multiple files:', message);
|
||||||
|
return { success: false, savedCount: 0, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { FileId } from '@app/types/fileContext';
|
||||||
|
import type { OperationSaveContext } from '@core/services/operationResultsSaveService';
|
||||||
|
import { downloadFile, downloadFromUrl, DownloadResult } from '@app/services/downloadService';
|
||||||
|
|
||||||
|
export type { OperationSaveContext };
|
||||||
|
|
||||||
|
export async function saveOperationResults(context: OperationSaveContext): Promise<DownloadResult | null> {
|
||||||
|
if (!context.downloadUrl) return null;
|
||||||
|
|
||||||
|
if (context.outputFileIds && context.outputFileIds.length > 0) {
|
||||||
|
for (const fileId of context.outputFileIds) {
|
||||||
|
const file = context.getFile(fileId as FileId);
|
||||||
|
const stub = context.getStub(fileId as FileId);
|
||||||
|
if (!file) continue;
|
||||||
|
|
||||||
|
const result = await downloadFile({
|
||||||
|
data: file,
|
||||||
|
filename: file.name,
|
||||||
|
localPath: stub?.localFilePath
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.savedPath) {
|
||||||
|
context.markSaved(fileId as FileId, result.savedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await downloadFromUrl(
|
||||||
|
context.downloadUrl,
|
||||||
|
context.downloadFilename || 'download',
|
||||||
|
context.downloadLocalPath || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.outputFileIds && result.savedPath) {
|
||||||
|
for (const fileId of context.outputFileIds) {
|
||||||
|
context.markSaved(fileId as FileId, result.savedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -11,6 +11,22 @@ export default defineConfig(({ mode }) => {
|
|||||||
process.env.STIRLING_DESKTOP === 'true' ||
|
process.env.STIRLING_DESKTOP === 'true' ||
|
||||||
process.env.VITE_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 baseProject = isProprietary ? './tsconfig.proprietary.vite.json' : './tsconfig.core.vite.json';
|
||||||
const desktopProject = isProprietary ? './tsconfig.desktop.vite.json' : baseProject;
|
const desktopProject = isProprietary ? './tsconfig.desktop.vite.json' : baseProject;
|
||||||
const tsconfigProject = isDesktopMode ? desktopProject : baseProject;
|
const tsconfigProject = isDesktopMode ? desktopProject : baseProject;
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user