experiments in a post-browser web
at main 460 lines 12 kB view raw
1/** 2 * Scripts Manager UI 3 * 4 * Three-panel layout: 5 * - Left: Scripts list 6 * - Center: Editor 7 * - Right: Preview/Test 8 */ 9 10import { scriptExecutor } from './script-executor.js'; 11 12const api = window.app; 13 14// ===== BroadcastChannel for intra-extension messaging ===== 15// Falls back to IPC pubsub if BroadcastChannel is unavailable (e.g., custom protocol origins) 16 17let scriptsChannel = null; 18const scriptsChannelHandlers = {}; 19 20try { 21 scriptsChannel = new BroadcastChannel('scripts'); 22 scriptsChannel.onmessage = (e) => { 23 const { topic, data } = e.data; 24 if (scriptsChannelHandlers[topic]) { 25 for (const handler of scriptsChannelHandlers[topic]) { 26 handler(data); 27 } 28 } 29 }; 30} catch (err) { 31 console.warn('[scripts] BroadcastChannel unavailable, using IPC fallback:', err.message); 32} 33 34function onChannel(topic, handler) { 35 if (!scriptsChannelHandlers[topic]) scriptsChannelHandlers[topic] = []; 36 scriptsChannelHandlers[topic].push(handler); 37 if (!scriptsChannel) api.subscribe(topic, handler, api.scopes.GLOBAL); 38} 39 40function emitChannel(topic, data) { 41 if (scriptsChannel) { 42 scriptsChannel.postMessage({ topic, data }); 43 } else { 44 api.publish(topic, data, api.scopes.GLOBAL); 45 } 46} 47 48// UI Elements 49let scriptsList; 50let editorForm; 51let scriptName; 52let scriptDescription; 53let matchPattern; 54let runAt; 55let codeEditor; 56let testUrl; 57let previewContent; 58let saveBtn; 59let revertBtn; 60let deleteBtn; 61let testBtn; 62let newScriptBtn; 63 64// State 65let scripts = []; 66let currentScript = null; 67let originalScript = null; // For revert 68 69/** 70 * Initialize UI 71 */ 72async function init() { 73 console.log('[scripts-manager] Initializing...'); 74 75 // Get DOM elements 76 scriptsList = document.getElementById('scriptsList'); 77 editorForm = document.getElementById('editorForm'); 78 scriptName = document.getElementById('scriptName'); 79 scriptDescription = document.getElementById('scriptDescription'); 80 matchPattern = document.getElementById('matchPattern'); 81 runAt = document.getElementById('runAt'); 82 codeEditor = document.getElementById('codeEditor'); 83 testUrl = document.getElementById('testUrl'); 84 previewContent = document.getElementById('previewContent'); 85 saveBtn = document.getElementById('saveBtn'); 86 revertBtn = document.getElementById('revertBtn'); 87 deleteBtn = document.getElementById('deleteBtn'); 88 testBtn = document.getElementById('testBtn'); 89 newScriptBtn = document.getElementById('newScriptBtn'); 90 91 // Attach event listeners 92 newScriptBtn.addEventListener('click', handleNewScript); 93 saveBtn.addEventListener('click', handleSave); 94 revertBtn.addEventListener('click', handleRevert); 95 deleteBtn.addEventListener('click', handleDelete); 96 testBtn.addEventListener('click', handleTest); 97 98 // Load scripts from backend 99 await loadScripts(); 100 101 // Subscribe to updates via BroadcastChannel 102 onChannel('scripts:created', () => loadScripts()); 103 onChannel('scripts:updated', () => loadScripts()); 104 onChannel('scripts:deleted', () => loadScripts()); 105 106 // Check URL params for scriptId 107 const params = new URLSearchParams(window.location.search); 108 const scriptId = params.get('scriptId'); 109 if (scriptId) { 110 const script = scripts.find(s => s.id === scriptId); 111 if (script) { 112 selectScript(script); 113 } 114 } 115 116 console.log('[scripts-manager] Initialized with', scripts.length, 'scripts'); 117} 118 119/** 120 * Load all scripts from backend 121 */ 122async function loadScripts() { 123 return new Promise((resolve) => { 124 onChannel('scripts:get-all:response', (msg) => { 125 if (msg.success) { 126 scripts = msg.data || []; 127 renderScriptsList(); 128 resolve(); 129 } 130 }); 131 132 emitChannel('scripts:get-all', {}); 133 }); 134} 135 136/** 137 * Render scripts list in left panel 138 */ 139function renderScriptsList() { 140 if (scripts.length === 0) { 141 scriptsList.innerHTML = '<div class="empty-state">No scripts yet. Click "+ New Script" to create one.</div>'; 142 return; 143 } 144 145 scriptsList.innerHTML = scripts.map(script => { 146 const status = script.lastExecutedAt 147 ? (script.enabled ? 'success' : 'disabled') 148 : 'never'; 149 150 const lastRun = script.lastExecutedAt 151 ? formatTime(script.lastExecutedAt) 152 : 'Never run'; 153 154 const isActive = currentScript && currentScript.id === script.id; 155 156 return ` 157 <div class="script-item ${isActive ? 'active' : ''}" data-script-id="${script.id}"> 158 <div class="script-item-header"> 159 <input type="checkbox" class="script-checkbox" ${script.enabled ? 'checked' : ''} data-script-id="${script.id}"> 160 <span class="script-name">${escapeHtml(script.name)}</span> 161 <span class="script-status ${status}"></span> 162 </div> 163 <div class="script-meta">${lastRun}</div> 164 </div> 165 `; 166 }).join(''); 167 168 // Attach click handlers 169 scriptsList.querySelectorAll('.script-item').forEach(item => { 170 item.addEventListener('click', (e) => { 171 if (e.target.classList.contains('script-checkbox')) { 172 return; // Handle checkbox separately 173 } 174 const scriptId = item.dataset.scriptId; 175 const script = scripts.find(s => s.id === scriptId); 176 if (script) { 177 selectScript(script); 178 } 179 }); 180 }); 181 182 // Attach checkbox handlers 183 scriptsList.querySelectorAll('.script-checkbox').forEach(checkbox => { 184 checkbox.addEventListener('change', async (e) => { 185 e.stopPropagation(); 186 const scriptId = checkbox.dataset.scriptId; 187 const enabled = checkbox.checked; 188 189 await updateScriptField(scriptId, { enabled }); 190 }); 191 }); 192} 193 194/** 195 * Select a script to edit 196 */ 197function selectScript(script) { 198 currentScript = script; 199 originalScript = JSON.parse(JSON.stringify(script)); // Deep clone for revert 200 201 // Populate form 202 scriptName.value = script.name; 203 scriptDescription.value = script.description || ''; 204 matchPattern.value = script.matchPatterns[0] || ''; 205 runAt.value = script.runAt; 206 codeEditor.value = script.code; 207 208 // Enable actions 209 deleteBtn.disabled = false; 210 211 // Re-render to show active state 212 renderScriptsList(); 213} 214 215/** 216 * Handle new script creation 217 */ 218async function handleNewScript() { 219 return new Promise((resolve) => { 220 onChannel('scripts:create:response', async (msg) => { 221 if (msg.success) { 222 await loadScripts(); 223 selectScript(msg.data); 224 resolve(); 225 } 226 }); 227 228 emitChannel('scripts:create', { 229 name: 'New Script', 230 code: '// Your script here\nconst h1 = document.querySelector(\'h1\');\nreturn { title: h1?.textContent || \'No h1 found\' };' 231 }); 232 }); 233} 234 235/** 236 * Handle save 237 */ 238async function handleSave() { 239 if (!currentScript) return; 240 241 const updates = { 242 name: scriptName.value, 243 description: scriptDescription.value, 244 matchPatterns: [matchPattern.value], 245 runAt: runAt.value, 246 code: codeEditor.value 247 }; 248 249 await updateScriptField(currentScript.id, updates); 250} 251 252/** 253 * Handle revert 254 */ 255function handleRevert() { 256 if (!originalScript) return; 257 selectScript(originalScript); 258} 259 260/** 261 * Handle delete 262 */ 263async function handleDelete() { 264 if (!currentScript) return; 265 266 if (!confirm(`Delete script "${currentScript.name}"?`)) { 267 return; 268 } 269 270 return new Promise((resolve) => { 271 onChannel('scripts:delete:response', async (msg) => { 272 if (msg.success) { 273 currentScript = null; 274 originalScript = null; 275 await loadScripts(); 276 277 // Clear form 278 scriptName.value = ''; 279 scriptDescription.value = ''; 280 matchPattern.value = ''; 281 codeEditor.value = ''; 282 deleteBtn.disabled = true; 283 284 resolve(); 285 } 286 }); 287 288 emitChannel('scripts:delete', { 289 scriptId: currentScript.id 290 }); 291 }); 292} 293 294/** 295 * Handle test execution 296 */ 297async function handleTest() { 298 if (!currentScript) { 299 showPreviewError('No script selected'); 300 return; 301 } 302 303 const url = testUrl.value.trim(); 304 if (!url) { 305 showPreviewError('Please enter a test URL'); 306 return; 307 } 308 309 testBtn.disabled = true; 310 testBtn.loading = true; 311 312 try { 313 // Use current form values (not saved script) 314 const testScript = { 315 ...currentScript, 316 name: scriptName.value, 317 code: codeEditor.value, 318 matchPatterns: [matchPattern.value], 319 runAt: runAt.value 320 }; 321 322 const result = await scriptExecutor.executeScript(testScript, { 323 url: url, 324 pageDOM: document, 325 pageWindow: window 326 }); 327 328 showPreviewResult(result); 329 } catch (error) { 330 showPreviewError(error.message); 331 } finally { 332 testBtn.disabled = false; 333 testBtn.loading = false; 334 } 335} 336 337/** 338 * Update script field 339 */ 340async function updateScriptField(scriptId, updates) { 341 return new Promise((resolve) => { 342 onChannel('scripts:update:response', async (msg) => { 343 if (msg.success) { 344 await loadScripts(); 345 if (currentScript && currentScript.id === scriptId) { 346 const updatedScript = scripts.find(s => s.id === scriptId); 347 if (updatedScript) { 348 currentScript = updatedScript; 349 originalScript = JSON.parse(JSON.stringify(updatedScript)); 350 } 351 } 352 resolve(); 353 } 354 }); 355 356 emitChannel('scripts:update', { 357 scriptId, 358 updates 359 }); 360 }); 361} 362 363/** 364 * Show preview result 365 */ 366function showPreviewResult(result) { 367 let html = ''; 368 369 if (result.status === 'success') { 370 html = ` 371 <div class="result-box"> 372 <div class="result-status success">Success</div> 373 <div class="result-time">Execution time: ${result.executionTime}ms</div> 374 ${result.result !== undefined ? ` 375 <div> 376 <div class="result-label">Result:</div> 377 <div class="result-data">${JSON.stringify(result.result, null, 2)}</div> 378 </div> 379 ` : ''} 380 ${result.output && result.output.length > 0 ? ` 381 <div class="console-output"> 382 <h3>Console Output:</h3> 383 ${result.output.map(log => ` 384 <div class="console-line ${log.level}">${escapeHtml(log.message)}</div> 385 `).join('')} 386 </div> 387 ` : ''} 388 </div> 389 `; 390 } else if (result.status === 'error') { 391 html = ` 392 <div class="result-box"> 393 <div class="result-status error">Error</div> 394 <div class="result-data">${escapeHtml(result.error)}</div> 395 ${result.stack ? ` 396 <div class="result-stack"> 397 <strong>Stack:</strong> 398 <pre>${escapeHtml(result.stack)}</pre> 399 </div> 400 ` : ''} 401 </div> 402 `; 403 } else if (result.status === 'skipped') { 404 html = ` 405 <div class="result-box"> 406 <div class="result-status skipped">Skipped</div> 407 <div class="result-reason">${escapeHtml(result.reason)}</div> 408 </div> 409 `; 410 } 411 412 previewContent.innerHTML = html; 413} 414 415/** 416 * Show preview error 417 */ 418function showPreviewError(message) { 419 previewContent.innerHTML = ` 420 <div class="result-box"> 421 <div class="result-status error">Error</div> 422 <div class="result-data">${escapeHtml(message)}</div> 423 </div> 424 `; 425} 426 427/** 428 * Format timestamp 429 */ 430function formatTime(timestamp) { 431 const now = Date.now(); 432 const diff = now - timestamp; 433 434 const seconds = Math.floor(diff / 1000); 435 const minutes = Math.floor(seconds / 60); 436 const hours = Math.floor(minutes / 60); 437 const days = Math.floor(hours / 24); 438 439 if (days > 0) return `${days}d ago`; 440 if (hours > 0) return `${hours}h ago`; 441 if (minutes > 0) return `${minutes}m ago`; 442 if (seconds > 0) return `${seconds}s ago`; 443 return 'Just now'; 444} 445 446/** 447 * Escape HTML 448 */ 449function escapeHtml(text) { 450 const div = document.createElement('div'); 451 div.textContent = text; 452 return div.innerHTML; 453} 454 455// Initialize on load 456if (document.readyState === 'loading') { 457 document.addEventListener('DOMContentLoaded', init); 458} else { 459 init(); 460}