experiments in a post-browser web
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```