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