experiments in a post-browser web
at main 237 lines 6.7 kB view raw view rendered
1# Backend Implementation Notes for Command Chaining 2 3This document describes backend-specific implementations required for the cmd extension's chaining feature. These notes are intended for implementing the same functionality in other backends (Tauri, etc.). 4 5## Overview 6 7The command chaining feature requires one new backend capability: 8- **File Save Dialog**: A native save-as dialog that writes content to a user-selected path 9 10## Electron Implementation 11 12### 1. Preload API (`preload.js`) 13 14The preload script exposes `api.files.save()` to renderer processes: 15 16```javascript 17api.files = { 18 /** 19 * Show native save dialog and write content to file 20 * @param {string} content - Content to save 21 * @param {object} options - Options { filename, mimeType } 22 * @returns {Promise<{success: boolean, path?: string, canceled?: boolean, error?: string}>} 23 */ 24 save: (content, options = {}) => { 25 return ipcRenderer.invoke('file-save-dialog', { 26 content, 27 filename: options.filename, 28 mimeType: options.mimeType 29 }); 30 } 31}; 32``` 33 34**Location**: `preload.js:785-799` 35 36### 2. IPC Handler (`backend/electron/ipc.ts`) 37 38The main process handles `file-save-dialog` IPC messages: 39 40```typescript 41// File save dialog - shows native save dialog and writes file 42ipcMain.handle('file-save-dialog', async (ev, data: { 43 content: string; 44 filename?: string; 45 mimeType?: string; 46}) => { 47 try { 48 // Determine file filters based on MIME type 49 const filters: Electron.FileFilter[] = []; 50 if (data.mimeType) { 51 const extMap: Record<string, { name: string; extensions: string[] }> = { 52 'application/json': { name: 'JSON', extensions: ['json'] }, 53 'text/csv': { name: 'CSV', extensions: ['csv'] }, 54 'text/plain': { name: 'Text', extensions: ['txt'] }, 55 'text/html': { name: 'HTML', extensions: ['html', 'htm'] }, 56 }; 57 const filter = extMap[data.mimeType]; 58 if (filter) { 59 filters.push(filter); 60 } 61 } 62 filters.push({ name: 'All Files', extensions: ['*'] }); 63 64 // Get the sender's window to parent the dialog 65 const senderWindow = BrowserWindow.fromWebContents(ev.sender); 66 67 const result = await dialog.showSaveDialog(senderWindow!, { 68 defaultPath: data.filename, 69 filters, 70 }); 71 72 if (result.canceled || !result.filePath) { 73 return { success: false, canceled: true }; 74 } 75 76 // Write the file 77 fs.writeFileSync(result.filePath, data.content, 'utf-8'); 78 79 return { success: true, path: result.filePath }; 80 } catch (error) { 81 const message = error instanceof Error ? error.message : String(error); 82 return { success: false, error: message }; 83 } 84}); 85``` 86 87**Location**: `backend/electron/ipc.ts:1131-1174` 88 89**Imports required**: 90```typescript 91import { ipcMain, dialog, BrowserWindow } from 'electron'; 92import fs from 'node:fs'; 93``` 94 95## Tauri Implementation Guide 96 97### Rust Command 98 99Create a command in `backend/tauri/src-tauri/src/commands/`: 100 101```rust 102use tauri::api::dialog::FileDialogBuilder; 103use std::fs; 104 105#[tauri::command] 106pub async fn file_save_dialog( 107 window: tauri::Window, 108 content: String, 109 filename: Option<String>, 110 mime_type: Option<String>, 111) -> Result<serde_json::Value, String> { 112 // Determine file filter based on MIME type 113 let (name, extensions) = match mime_type.as_deref() { 114 Some("application/json") => ("JSON", vec!["json"]), 115 Some("text/csv") => ("CSV", vec!["csv"]), 116 Some("text/plain") => ("Text", vec!["txt"]), 117 Some("text/html") => ("HTML", vec!["html", "htm"]), 118 _ => ("All Files", vec!["*"]), 119 }; 120 121 // Use FileDialogBuilder for async dialog 122 let file_path = FileDialogBuilder::new() 123 .add_filter(name, &extensions) 124 .set_parent(&window) 125 .set_file_name(filename.unwrap_or_default()) 126 .save_file() 127 .await; 128 129 match file_path { 130 Some(path) => { 131 match fs::write(&path, content) { 132 Ok(_) => Ok(serde_json::json!({ 133 "success": true, 134 "path": path.to_string_lossy() 135 })), 136 Err(e) => Ok(serde_json::json!({ 137 "success": false, 138 "error": e.to_string() 139 })) 140 } 141 } 142 None => Ok(serde_json::json!({ 143 "success": false, 144 "canceled": true 145 })) 146 } 147} 148``` 149 150### Preload Bridge 151 152In `backend/tauri/preload.js`, add: 153 154```javascript 155api.files = { 156 save: async (content, options = {}) => { 157 return window.__TAURI__.invoke('file_save_dialog', { 158 content, 159 filename: options.filename, 160 mimeType: options.mimeType 161 }); 162 } 163}; 164``` 165 166## API Contract 167 168### Request 169 170```typescript 171interface FileSaveRequest { 172 content: string; // File content to save 173 filename?: string; // Suggested filename (e.g., "data.csv") 174 mimeType?: string; // MIME type for file filter (e.g., "text/csv") 175} 176``` 177 178### Response 179 180```typescript 181interface FileSaveResponse { 182 success: boolean; // True if file was saved 183 path?: string; // Path where file was saved (on success) 184 canceled?: boolean; // True if user canceled dialog 185 error?: string; // Error message (on failure) 186} 187``` 188 189### MIME Type to Extension Mapping 190 191| MIME Type | Filter Name | Extensions | 192|-----------|-------------|------------| 193| `application/json` | JSON | `.json` | 194| `text/csv` | CSV | `.csv` | 195| `text/plain` | Text | `.txt` | 196| `text/html` | HTML | `.html`, `.htm` | 197| (other/none) | All Files | `*` | 198 199## Architecture Note: Why a Separate Window? 200 201The save command uses a separate download window (`download.html`) instead of calling `api.files.save()` directly from the cmd panel. This is because: 202 2031. **Modal Blur Issue**: The cmd panel is a modal window with a blur handler that closes it when focus is lost 2042. **Native Dialog Focus**: When a native save dialog opens, it takes focus, triggering the blur handler 2053. **Result**: Panel closes before user can interact with save dialog 206 207**Solution**: Use a non-modal download window that: 208- Receives data via pubsub from background script 209- Calls `api.files.save()` without blur concerns 210- Closes itself after save completes 211 212This pattern may need to be replicated in other backends if they have similar modal window behaviors. 213 214## Build Notes 215 216After modifying `backend/electron/ipc.ts`: 217```bash 218yarn build # Compiles TypeScript 219``` 220 221For Tauri: 222```bash 223yarn tauri:build # Builds Rust backend 224``` 225 226## Testing 227 228The smoke tests in `tests/desktop/smoke.spec.ts` cover: 229- Command chaining flow (lists → csv → save) 230- Output selection mode 231- Chain mode UI elements 232- MIME type filtering 233 234Run tests: 235```bash 236yarn test 237```