reactoradar 1.6.6 → 1.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,968 @@
1
+ // ─── Network Panel ─────────────────────────────────────────────────────────
2
+
3
+ const NET_COLS = [
4
+ { key: 'name', label: 'Name', width: 380, min: 150 },
5
+ { key: 'status', label: 'Status', width: 60, min: 40 },
6
+ { key: 'type', label: 'Type', width: 70, min: 40 },
7
+ { key: 'initiator', label: 'Initiator', width: 80, min: 50 },
8
+ { key: 'size', label: 'Size', width: 65, min: 40 },
9
+ { key: 'time', label: 'Time', width: 65, min: 40 },
10
+ { key: 'waterfall', label: 'Waterfall', width: 100, min: 60 },
11
+ ];
12
+
13
+ function initNetworkPanel() {
14
+ const panel = $('panel-network');
15
+ panel.innerHTML = `
16
+ <div class="panel-toolbar">
17
+ <span class="panel-label">Network</span>
18
+ <span class="badge" id="nBadge">0</span>
19
+ <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
20
+ <button class="panel-clear-btn" id="networkExport" title="Export as HAR">Export HAR</button>
21
+ <button class="panel-clear-btn" id="networkClear" title="Clear network">Clear</button>
22
+ <label class="toggle-label" for="netToggle">
23
+ <span class="toggle-text" id="netToggleText">Capture ON</span>
24
+ <input type="checkbox" id="netToggle" class="toggle-input" checked />
25
+ <span class="toggle-slider"></span>
26
+ </label>
27
+ </div>
28
+ </div>
29
+ <div class="net-filter-bar" id="netFilterBar">
30
+ <input id="netSearchInput" class="net-search-input" placeholder="Filter URLs..." />
31
+ <div class="net-type-filters" id="netTypeFilters">
32
+ <button class="net-type-btn" data-type="all">All</button>
33
+ <button class="net-type-btn active" data-type="fetch">Fetch/XHR</button>
34
+ <button class="net-type-btn" data-type="js">JS</button>
35
+ <button class="net-type-btn" data-type="css">CSS</button>
36
+ <button class="net-type-btn" data-type="img">Img</button>
37
+ <button class="net-type-btn" data-type="media">Media</button>
38
+ <button class="net-type-btn" data-type="font">Font</button>
39
+ <button class="net-type-btn" data-type="doc">Doc</button>
40
+ <button class="net-type-btn" data-type="ws">WS</button>
41
+ </div>
42
+ <div class="net-status-filters" id="netStatusFilters">
43
+ <button class="net-status-btn active" data-status="all">All</button>
44
+ <button class="net-status-btn" data-status="2xx">2xx</button>
45
+ <button class="net-status-btn" data-status="errors">Errors</button>
46
+ <button class="net-status-btn net-slow-btn" data-status="slow">Slow (>1s)</button>
47
+ </div>
48
+ <div class="net-hidden-wrap" style="position:relative;margin-left:4px">
49
+ <button class="net-status-btn net-hidden-btn" id="netHiddenBtn" style="display:none" title="Manage hidden URLs">Hidden</button>
50
+ <div class="net-hidden-dropdown" id="netHiddenDropdown" style="display:none"></div>
51
+ </div>
52
+ <div class="net-throttle" id="netThrottle">
53
+ <select id="netThrottleSelect" class="net-throttle-select">
54
+ <option value="none">No throttling</option>
55
+ <option value="fast3g">Fast 3G</option>
56
+ <option value="slow3g">Slow 3G</option>
57
+ <option value="offline">Offline</option>
58
+ </select>
59
+ </div>
60
+ </div>
61
+ <div class="net-layout">
62
+ <div class="net-table-wrap" id="netTableWrap">
63
+ <div class="net-header" id="netHeader"></div>
64
+ <div class="net-rows" id="netRows">
65
+ <div class="empty-state" id="networkEmpty">
66
+ <div class="icon">📡</div>
67
+ <div class="label">No requests yet</div>
68
+ <div class="hint">API calls will appear here automatically</div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ <div class="net-detail-pane" id="netDetailPane">
73
+ <div class="net-detail-bar">
74
+ <div class="detail-tabs" id="netDetailTabs"></div>
75
+ <div class="detail-search-wrap" id="detailSearchWrap" style="display:none">
76
+ <input id="detailSearchInput" class="detail-search-input" placeholder="Search key or value..." />
77
+ <span id="detailSearchCount" class="detail-search-count"></span>
78
+ <button class="detail-search-nav" id="detailSearchPrev" title="Previous">&#9650;</button>
79
+ <button class="detail-search-nav" id="detailSearchNext" title="Next">&#9660;</button>
80
+ <button class="detail-search-close" id="detailSearchClose" title="Close search">&times;</button>
81
+ </div>
82
+ <button class="detail-close" id="netDetailClose" title="Close">&times;</button>
83
+ </div>
84
+ <div class="detail-content" id="netDetailContent"></div>
85
+ </div>
86
+ </div>
87
+ <div class="net-stats-bar" id="netStatsBar">
88
+ <span id="netStatsTotal">0 requests</span>
89
+ <span class="net-stats-sep">|</span>
90
+ <span id="netStatsAvg">Avg: —</span>
91
+ <span class="net-stats-sep">|</span>
92
+ <span id="netStatsSlowest">Slowest: —</span>
93
+ <span class="net-stats-sep">|</span>
94
+ <span id="netStatsErrors">Errors: 0</span>
95
+ <span class="net-stats-sep">|</span>
96
+ <span id="netStatsSlow">Slow (>1s): 0</span>
97
+ </div>`;
98
+
99
+ $('netToggle').addEventListener('change', (e) => {
100
+ state.network.enabled = e.target.checked;
101
+ $('netToggleText').textContent = e.target.checked ? 'Capture ON' : 'Capture OFF';
102
+ window.electronAPI?.setNetworkCapture(e.target.checked);
103
+ });
104
+
105
+ // Network search input
106
+ $('netSearchInput').addEventListener('input', (e) => {
107
+ state.network.searchFilter = e.target.value.toLowerCase().trim();
108
+ renderNetwork();
109
+ });
110
+
111
+ // Type filter buttons
112
+ $('netTypeFilters').addEventListener('click', (e) => {
113
+ const btn = e.target.closest('.net-type-btn');
114
+ if (!btn) return;
115
+ $('netTypeFilters').querySelectorAll('.net-type-btn').forEach(b => b.classList.remove('active'));
116
+ btn.classList.add('active');
117
+ state.network.typeFilter = btn.dataset.type;
118
+ renderNetwork();
119
+ });
120
+
121
+ // Status filter buttons (All / 2xx / Errors / Slow)
122
+ $('netStatusFilters').addEventListener('click', (e) => {
123
+ const btn = e.target.closest('.net-status-btn');
124
+ if (!btn) return;
125
+ $('netStatusFilters').querySelectorAll('.net-status-btn').forEach(b => b.classList.remove('active'));
126
+ btn.classList.add('active');
127
+ state.network.statusFilter = btn.dataset.status;
128
+ renderNetwork();
129
+ });
130
+
131
+ // Hidden URLs button
132
+ $('netHiddenBtn')?.addEventListener('click', () => {
133
+ const dd = $('netHiddenDropdown');
134
+ if (!dd) return;
135
+ const isOpen = dd.style.display !== 'none';
136
+ if (isOpen) { dd.style.display = 'none'; return; }
137
+ // Build dropdown with hidden URL list
138
+ const hidden = getHiddenURLs();
139
+ dd.innerHTML = '';
140
+ if (!hidden.length) { dd.style.display = 'none'; return; }
141
+ const title = document.createElement('div');
142
+ title.className = 'net-hidden-title';
143
+ title.innerHTML = `<span>Hidden URLs (${hidden.length})</span><button class="net-hidden-clear" id="netHiddenClearAll">Clear All</button>`;
144
+ dd.appendChild(title);
145
+ hidden.forEach(pattern => {
146
+ const row = document.createElement('div');
147
+ row.className = 'net-hidden-row';
148
+ const label = document.createElement('span');
149
+ label.className = 'net-hidden-url';
150
+ label.textContent = pattern;
151
+ label.title = pattern;
152
+ row.appendChild(label);
153
+ const btn = document.createElement('button');
154
+ btn.className = 'net-hidden-unhide';
155
+ btn.textContent = 'Unhide';
156
+ btn.addEventListener('click', () => {
157
+ removeHiddenURL(pattern);
158
+ row.remove();
159
+ renderNetwork();
160
+ if (!getHiddenURLs().length) dd.style.display = 'none';
161
+ });
162
+ row.appendChild(btn);
163
+ dd.appendChild(row);
164
+ });
165
+ dd.style.display = 'block';
166
+ // Clear all handler
167
+ dd.querySelector('#netHiddenClearAll')?.addEventListener('click', () => {
168
+ setHiddenURLs([]);
169
+ _updateHiddenBadge();
170
+ dd.style.display = 'none';
171
+ renderNetwork();
172
+ });
173
+ });
174
+ // Close dropdown when clicking outside
175
+ document.addEventListener('click', (e) => {
176
+ const dd = $('netHiddenDropdown');
177
+ if (dd && dd.style.display !== 'none' && !e.target.closest('.net-hidden-wrap')) {
178
+ dd.style.display = 'none';
179
+ }
180
+ });
181
+ // Initialize hidden badge
182
+ _updateHiddenBadge();
183
+
184
+ // Throttle select
185
+ $('netThrottleSelect').addEventListener('change', (e) => {
186
+ state.network.throttle = e.target.value;
187
+ // Send throttle config to the RN app
188
+ window.electronAPI?.setNetworkThrottle(state.network.throttle);
189
+ });
190
+
191
+ // Export network as HAR
192
+ $('networkExport')?.addEventListener('click', () => {
193
+ const entries = state.network.order.map(id => {
194
+ const r = state.network.requests[id];
195
+ if (!r) return null;
196
+ return {
197
+ startedDateTime: new Date(r.ts || Date.now()).toISOString(),
198
+ time: r.duration || 0,
199
+ request: {
200
+ method: r.method || 'GET',
201
+ url: r.url || '',
202
+ headers: Object.entries(r.requestHeaders || {}).map(([n, v]) => ({ name: n, value: v })),
203
+ postData: r.requestBody ? { mimeType: 'application/json', text: typeof r.requestBody === 'object' ? JSON.stringify(r.requestBody) : String(r.requestBody) } : undefined,
204
+ },
205
+ response: {
206
+ status: r.status || 0,
207
+ statusText: r.statusText || '',
208
+ headers: Object.entries(r.responseHeaders || {}).map(([n, v]) => ({ name: n, value: v })),
209
+ content: { size: -1, mimeType: 'application/json', text: r.responseBody ? (typeof r.responseBody === 'object' ? JSON.stringify(r.responseBody) : String(r.responseBody)) : '' },
210
+ },
211
+ timings: { send: 0, wait: r.duration || 0, receive: 0 },
212
+ };
213
+ }).filter(Boolean);
214
+ const har = { log: { version: '1.2', creator: { name: 'ReactoRadar', version: '1.6.0' }, entries } };
215
+ const blob = new Blob([JSON.stringify(har, null, 2)], { type: 'application/json' });
216
+ const url = URL.createObjectURL(blob);
217
+ const a = document.createElement('a');
218
+ a.href = url; a.download = `reactoradar-network-${Date.now()}.har`; a.click();
219
+ URL.revokeObjectURL(url);
220
+ });
221
+
222
+ // Clear network
223
+ $('networkClear').addEventListener('click', () => {
224
+ state.network.requests = {};
225
+ state.network.order = [];
226
+ state.network.selectedId = null;
227
+ closeNetDetail();
228
+ $('nBadge').textContent = '0';
229
+ renderNetwork();
230
+ });
231
+
232
+ // Close detail button
233
+ $('netDetailClose').addEventListener('click', closeNetDetail);
234
+
235
+ // Detail panel search
236
+ let _detailSearchMatches = [];
237
+ let _detailSearchIdx = -1;
238
+
239
+ function _detailSearch() {
240
+ const term = $('detailSearchInput')?.value?.trim().toLowerCase();
241
+ const body = $('netDetailContent');
242
+ if (!body || !term) { _detailClearSearch(); return; }
243
+
244
+ // Remove old highlights
245
+ body.querySelectorAll('.detail-search-hl').forEach(el => {
246
+ const parent = el.parentNode;
247
+ parent.replaceChild(document.createTextNode(el.textContent), el);
248
+ parent.normalize();
249
+ });
250
+
251
+ _detailSearchMatches = [];
252
+ _detailSearchIdx = -1;
253
+
254
+ // Walk all text nodes and highlight matches
255
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null);
256
+ const textNodes = [];
257
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
258
+
259
+ textNodes.forEach(node => {
260
+ const text = node.textContent;
261
+ const lower = text.toLowerCase();
262
+ if (!lower.includes(term)) return;
263
+
264
+ const frag = document.createDocumentFragment();
265
+ let lastIdx = 0;
266
+ let idx;
267
+ while ((idx = lower.indexOf(term, lastIdx)) !== -1) {
268
+ if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
269
+ const hl = document.createElement('span');
270
+ hl.className = 'detail-search-hl';
271
+ hl.textContent = text.slice(idx, idx + term.length);
272
+ _detailSearchMatches.push(hl);
273
+ frag.appendChild(hl);
274
+ lastIdx = idx + term.length;
275
+ }
276
+ if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
277
+ node.parentNode.replaceChild(frag, node);
278
+ });
279
+
280
+ // Update count
281
+ const countEl = $('detailSearchCount');
282
+ if (countEl) countEl.textContent = _detailSearchMatches.length ? `${_detailSearchMatches.length} found` : 'No match';
283
+
284
+ // Navigate to first match
285
+ if (_detailSearchMatches.length) _detailNavTo(0);
286
+ }
287
+
288
+ function _detailNavTo(idx) {
289
+ // Remove active highlight from previous
290
+ if (_detailSearchIdx >= 0 && _detailSearchMatches[_detailSearchIdx]) {
291
+ _detailSearchMatches[_detailSearchIdx].classList.remove('active');
292
+ }
293
+ _detailSearchIdx = idx;
294
+ const el = _detailSearchMatches[idx];
295
+ if (!el) return;
296
+ el.classList.add('active');
297
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
298
+ // Update count
299
+ const countEl = $('detailSearchCount');
300
+ if (countEl) countEl.textContent = `${idx + 1}/${_detailSearchMatches.length}`;
301
+ }
302
+
303
+ function _detailClearSearch() {
304
+ const body = $('netDetailContent');
305
+ if (body) {
306
+ body.querySelectorAll('.detail-search-hl').forEach(el => {
307
+ const parent = el.parentNode;
308
+ parent.replaceChild(document.createTextNode(el.textContent), el);
309
+ parent.normalize();
310
+ });
311
+ }
312
+ _detailSearchMatches = [];
313
+ _detailSearchIdx = -1;
314
+ const countEl = $('detailSearchCount');
315
+ if (countEl) countEl.textContent = '';
316
+ }
317
+
318
+ $('detailSearchInput')?.addEventListener('input', () => {
319
+ clearTimeout($('detailSearchInput')._debounce);
320
+ $('detailSearchInput')._debounce = setTimeout(_detailSearch, 200);
321
+ });
322
+ $('detailSearchInput')?.addEventListener('keydown', (e) => {
323
+ if (e.key === 'Enter') {
324
+ e.preventDefault();
325
+ if (!_detailSearchMatches.length) return;
326
+ const next = e.shiftKey
327
+ ? (_detailSearchIdx - 1 + _detailSearchMatches.length) % _detailSearchMatches.length
328
+ : (_detailSearchIdx + 1) % _detailSearchMatches.length;
329
+ _detailNavTo(next);
330
+ }
331
+ if (e.key === 'Escape') {
332
+ _detailClearSearch();
333
+ $('detailSearchWrap').style.display = 'none';
334
+ }
335
+ });
336
+ $('detailSearchNext')?.addEventListener('click', () => {
337
+ if (!_detailSearchMatches.length) return;
338
+ _detailNavTo((_detailSearchIdx + 1) % _detailSearchMatches.length);
339
+ });
340
+ $('detailSearchPrev')?.addEventListener('click', () => {
341
+ if (!_detailSearchMatches.length) return;
342
+ _detailNavTo((_detailSearchIdx - 1 + _detailSearchMatches.length) % _detailSearchMatches.length);
343
+ });
344
+ $('detailSearchClose')?.addEventListener('click', () => {
345
+ _detailClearSearch();
346
+ $('detailSearchInput').value = '';
347
+ $('detailSearchWrap').style.display = 'none';
348
+ });
349
+
350
+ buildNetHeader();
351
+ }
352
+
353
+ // ─── Column header with sort icons + full-height resize handles ──────────────
354
+ function buildNetHeader() {
355
+ const header = $('netHeader');
356
+ header.innerHTML = '';
357
+ NET_COLS.forEach((col, i) => {
358
+ const cell = document.createElement('div');
359
+ cell.className = 'net-hcell';
360
+ cell.style.width = col.width + 'px';
361
+ cell.dataset.col = col.key;
362
+
363
+ const label = document.createElement('span');
364
+ label.className = 'net-hcell-label';
365
+ label.textContent = col.label;
366
+ cell.appendChild(label);
367
+
368
+ if (col.key !== 'waterfall') {
369
+ const sortIcon = document.createElement('span');
370
+ sortIcon.className = 'net-sort-icon';
371
+ if (state.network.sortCol === col.key) {
372
+ sortIcon.textContent = state.network.sortDir === 'asc' ? ' \u25B2' : ' \u25BC';
373
+ sortIcon.classList.add('active');
374
+ }
375
+ cell.appendChild(sortIcon);
376
+ cell.addEventListener('click', (e) => {
377
+ if (e.target.closest('.net-hcell-resize')) return;
378
+ if (state.network.sortCol === col.key) {
379
+ state.network.sortDir = state.network.sortDir === 'asc' ? 'desc' : 'asc';
380
+ } else {
381
+ state.network.sortCol = col.key;
382
+ state.network.sortDir = col.key === 'name' ? 'asc' : 'desc';
383
+ }
384
+ buildNetHeader();
385
+ renderNetwork();
386
+ });
387
+ cell.style.cursor = 'pointer';
388
+ }
389
+
390
+ // Resize handle in header
391
+ if (i < NET_COLS.length - 1) {
392
+ const handle = document.createElement('div');
393
+ handle.className = 'net-hcell-resize';
394
+ handle.addEventListener('mousedown', (e) => startColResize(e, col));
395
+ cell.appendChild(handle);
396
+ }
397
+ header.appendChild(cell);
398
+ });
399
+
400
+ // Build full-height resize overlay lines
401
+ buildResizeOverlays();
402
+ }
403
+
404
+ function buildResizeOverlays() {
405
+ // Remove old overlays
406
+ document.querySelectorAll('.net-resize-overlay').forEach(e => e.remove());
407
+ const tableWrap = $('netTableWrap');
408
+ if (!tableWrap) return;
409
+ // Make the table wrap position:relative for overlay positioning
410
+ tableWrap.style.position = 'relative';
411
+
412
+ let leftOffset = 0;
413
+ NET_COLS.forEach((col, i) => {
414
+ leftOffset += col.width;
415
+ if (i >= NET_COLS.length - 1) return; // no handle after last column
416
+
417
+ const overlay = document.createElement('div');
418
+ overlay.className = 'net-resize-overlay';
419
+ overlay.style.left = (leftOffset - 3) + 'px';
420
+ overlay.addEventListener('mousedown', (e) => startColResize(e, col));
421
+ tableWrap.appendChild(overlay);
422
+ });
423
+ }
424
+
425
+ function startColResize(e, col) {
426
+ e.preventDefault();
427
+ e.stopPropagation();
428
+ const startX = e.clientX;
429
+ const startW = col.width;
430
+
431
+ // Add visual feedback
432
+ document.body.style.cursor = 'col-resize';
433
+ document.body.style.userSelect = 'none';
434
+
435
+ function onMove(ev) {
436
+ const delta = ev.clientX - startX;
437
+ col.width = Math.max(col.min, startW + delta);
438
+ // Update header + all data cells for this column
439
+ document.querySelectorAll(`.net-cell[data-col="${col.key}"], .net-hcell[data-col="${col.key}"]`)
440
+ .forEach(el => el.style.width = col.width + 'px');
441
+ // Keep detail pane aligned with Name column
442
+ if (col.key === 'name' && state.network.selectedId) {
443
+ const pane = $('netDetailPane');
444
+ if (pane) pane.style.left = (col.width + 1) + 'px';
445
+ }
446
+ // Reposition overlays
447
+ buildResizeOverlays();
448
+ }
449
+ function onUp() {
450
+ document.body.style.cursor = '';
451
+ document.body.style.userSelect = '';
452
+ document.removeEventListener('mousemove', onMove);
453
+ document.removeEventListener('mouseup', onUp);
454
+ }
455
+ document.addEventListener('mousemove', onMove);
456
+ document.addEventListener('mouseup', onUp);
457
+ }
458
+
459
+ // ─── Network type matching ──────────────────────────────────────────────────
460
+ function matchNetType(r, type) {
461
+ const ct = (r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '').toLowerCase();
462
+ const url = (r.url || '').toLowerCase();
463
+ switch (type) {
464
+ case 'fetch': // Fetch/XHR — show API calls (JSON, text, form data), exclude static assets
465
+ return !ct.includes('image') && !ct.includes('font') && !ct.includes('video') && !ct.includes('audio')
466
+ && !/\.(png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp4|mp3|css)(\?|$)/.test(url);
467
+ case 'js': return ct.includes('javascript') || /\.(js|jsx|bundle)(\?|$)/.test(url);
468
+ case 'css': return ct.includes('css') || /\.css(\?|$)/.test(url);
469
+ case 'img': return ct.includes('image') || /\.(png|jpg|jpeg|gif|svg|webp|ico|avif|bmp)(\?|$)/.test(url);
470
+ case 'media': return ct.includes('video') || ct.includes('audio') || /\.(mp4|mp3|wav|webm|ogg|m3u8)(\?|$)/.test(url);
471
+ case 'font': return ct.includes('font') || /\.(woff2?|ttf|otf|eot)(\?|$)/.test(url);
472
+ case 'doc': return ct.includes('html') || ct.includes('xml') || /\.(html?|xml)(\?|$)/.test(url);
473
+ case 'ws': return url.startsWith('ws://') || url.startsWith('wss://');
474
+ default: return true;
475
+ }
476
+ }
477
+
478
+ let _netRAF = null;
479
+
480
+ function handleNetworkEvent(event) {
481
+ if (event.type === 'console') { addConsoleLog(event); return; }
482
+ if (event.type !== 'network') return;
483
+ if (!state.network.enabled) return;
484
+
485
+ const { id, phase } = event;
486
+ if (phase === 'request') {
487
+ state.network.requests[id] = { ...event, _tab: 'headers' };
488
+ if (!state.network.order.includes(id)) state.network.order.push(id);
489
+ // Cap network history to prevent memory leak
490
+ const MAX_NET_HISTORY = 1000;
491
+ if (state.network.order.length > MAX_NET_HISTORY) {
492
+ const trimIds = state.network.order.splice(0, state.network.order.length - MAX_NET_HISTORY);
493
+ trimIds.forEach(tid => delete state.network.requests[tid]);
494
+ }
495
+ $('nBadge').textContent = state.network.order.length;
496
+ } else {
497
+ Object.assign(state.network.requests[id] || (state.network.requests[id] = {}), event);
498
+ // Toast for errors and slow APIs
499
+ const r = state.network.requests[id];
500
+ if (r && (phase === 'response' || phase === 'error')) {
501
+ const name = r.url?.split('/').pop()?.split('?')[0] || r.url || '?';
502
+ if (r.phase === 'error' || (r.status && r.status >= 400)) {
503
+ showToast(`API Error: ${r.status || 'ERR'} ${name}`, 'error', 'network');
504
+ } else if ((r.duration || 0) >= 3000) {
505
+ showToast(`Slow API: ${(r.duration/1000).toFixed(1)}s — ${name}`, 'warn', 'network');
506
+ }
507
+ }
508
+ }
509
+ if (!_netRAF) {
510
+ _netRAF = requestAnimationFrame(() => {
511
+ _netRAF = null;
512
+ renderNetwork();
513
+ });
514
+ }
515
+ }
516
+
517
+ // ─── Sort network IDs ───────────────────────────────────────────────────────
518
+ function sortNetworkIds(ids) {
519
+ const { sortCol, sortDir } = state.network;
520
+ const reqs = state.network.requests;
521
+ const sorted = [...ids].sort((a, b) => {
522
+ const ra = reqs[a], rb = reqs[b];
523
+ if (!ra || !rb) return 0;
524
+ let va, vb;
525
+ switch (sortCol) {
526
+ case 'name':
527
+ va = (ra.url || '').toLowerCase(); vb = (rb.url || '').toLowerCase();
528
+ return va < vb ? -1 : va > vb ? 1 : 0;
529
+ case 'status':
530
+ va = ra.status || 0; vb = rb.status || 0;
531
+ return va - vb;
532
+ case 'type':
533
+ va = (ra.responseHeaders?.['content-type'] || '').toLowerCase();
534
+ vb = (rb.responseHeaders?.['content-type'] || '').toLowerCase();
535
+ return va < vb ? -1 : va > vb ? 1 : 0;
536
+ case 'size':
537
+ // Use cached size or estimate — avoid JSON.stringify in sort comparator
538
+ va = ra._cachedSize ?? (ra._cachedSize = typeof ra.responseBody === 'string' ? ra.responseBody.length : (ra.responseBody != null ? 100 : 0));
539
+ vb = rb._cachedSize ?? (rb._cachedSize = typeof rb.responseBody === 'string' ? rb.responseBody.length : (rb.responseBody != null ? 100 : 0));
540
+ return va - vb;
541
+ case 'time':
542
+ default:
543
+ va = ra.ts || 0; vb = rb.ts || 0;
544
+ return va - vb;
545
+ }
546
+ });
547
+ if (sortDir === 'desc') sorted.reverse();
548
+ return sorted;
549
+ }
550
+
551
+ // ─── Render network rows ────────────────────────────────────────────────────
552
+ function renderNetwork() {
553
+ const rows = $('netRows');
554
+ const empty = $('networkEmpty');
555
+ if (!rows) return;
556
+
557
+ const { statusFilter, typeFilter, searchFilter } = state.network;
558
+ const visible = state.network.order.filter(id => {
559
+ const r = state.network.requests[id];
560
+ if (!r) return false;
561
+ if (statusFilter === '2xx' && !(r.status >= 200 && r.status < 300)) return false;
562
+ if (statusFilter === 'errors' && !(r.phase === 'error' || r.status >= 400)) return false;
563
+ if (statusFilter === 'slow' && !((r.duration || 0) >= 1000)) return false;
564
+ if (searchFilter && !r.url?.toLowerCase().includes(searchFilter)) return false;
565
+ if (typeFilter !== 'all' && !matchNetType(r, typeFilter)) return false;
566
+ if (isURLHidden(r.url || '')) return false;
567
+ return true;
568
+ });
569
+
570
+ // Sort: apply current sort, default = newest first
571
+ const sortedVisible = sortNetworkIds(visible);
572
+
573
+ empty.style.display = sortedVisible.length ? 'none' : 'flex';
574
+ rows.querySelectorAll('.net-row').forEach(e => e.remove());
575
+
576
+ // Waterfall scale: find min/max timestamps
577
+ let wfMin = Infinity, wfMax = 0;
578
+ sortedVisible.forEach(id => {
579
+ const r = state.network.requests[id];
580
+ if (r.ts) { wfMin = Math.min(wfMin, r.ts); wfMax = Math.max(wfMax, r.ts + (r.duration || 0)); }
581
+ });
582
+ const wfRange = Math.max(wfMax - wfMin, 1);
583
+
584
+ // Render max 300 rows for performance
585
+ const MAX_NET_ROWS = 300;
586
+ const toRender = sortedVisible.length > MAX_NET_ROWS ? sortedVisible.slice(0, MAX_NET_ROWS) : sortedVisible;
587
+
588
+ const frag = document.createDocumentFragment();
589
+ if (sortedVisible.length > MAX_NET_ROWS) {
590
+ const info = document.createElement('div');
591
+ info.className = 'net-row';
592
+ info.style.cssText = 'color:var(--text-dim);font-size:10px;padding:6px 14px;justify-content:center;font-style:italic';
593
+ info.textContent = `Showing ${MAX_NET_ROWS} of ${sortedVisible.length} requests`;
594
+ frag.appendChild(info);
595
+ }
596
+ toRender.forEach(id => {
597
+ const r = state.network.requests[id];
598
+ frag.appendChild(buildNetRow(r, wfMin, wfRange));
599
+ });
600
+ rows.appendChild(frag);
601
+ _updateNetStats();
602
+ }
603
+
604
+ function _updateNetStats() {
605
+ const allReqs = state.network.order.map(id => state.network.requests[id]).filter(Boolean);
606
+ const completed = allReqs.filter(r => r.duration != null);
607
+ const total = allReqs.length;
608
+ const errors = allReqs.filter(r => r.phase === 'error' || (r.status && r.status >= 400)).length;
609
+ const slow = completed.filter(r => r.duration >= 1000).length;
610
+ const durations = completed.map(r => r.duration);
611
+ const avg = durations.length ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
612
+ const slowest = durations.length ? Math.max(...durations) : 0;
613
+ const slowestReq = completed.find(r => r.duration === slowest);
614
+ const slowestName = slowestReq ? (tryURL(slowestReq.url)?.pathname?.split('/').pop() || slowestReq.url?.split('/').pop() || '?') : '—';
615
+
616
+ const el = (id, text) => { const e = $(id); if (e) e.textContent = text; };
617
+ el('netStatsTotal', `${total} requests`);
618
+ el('netStatsAvg', `Avg: ${avg ? (avg > 999 ? `${(avg/1000).toFixed(1)}s` : `${avg}ms`) : '—'}`);
619
+ el('netStatsSlowest', `Slowest: ${slowest ? (slowest > 999 ? `${(slowest/1000).toFixed(1)}s` : `${slowest}ms`) + ` (${slowestName})` : '—'}`);
620
+ el('netStatsErrors', `Errors: ${errors}`);
621
+ el('netStatsSlow', `Slow (>1s): ${slow}`);
622
+ // Highlight if there are slow or errored requests
623
+ if (slow > 0) $('netStatsSlow')?.classList.add('warn');
624
+ else $('netStatsSlow')?.classList.remove('warn');
625
+ if (errors > 0) $('netStatsErrors')?.classList.add('err');
626
+ else $('netStatsErrors')?.classList.remove('err');
627
+ }
628
+
629
+ function _isHttpError(r) {
630
+ return r.phase === 'error' || (r.status && r.status >= 400);
631
+ }
632
+
633
+ function buildNetRow(r, wfMin, wfRange) {
634
+ const row = document.createElement('div');
635
+ const rowSlow = !_isHttpError(r) && (r.duration || 0) >= 1000;
636
+ const rowVerySlow = !_isHttpError(r) && (r.duration || 0) >= 3000;
637
+ row.className = 'net-row' + (r.id === state.network.selectedId ? ' selected' : '') + (_isHttpError(r) ? ' error' : '') + (rowVerySlow ? ' very-slow' : rowSlow ? ' slow' : '');
638
+ row.dataset.id = r.id;
639
+
640
+ const urlObj = tryURL(r.url);
641
+ const pathname = urlObj ? urlObj.pathname : r.url || '';
642
+ const filename = pathname.split('/').filter(Boolean).pop() || pathname;
643
+ const host = urlObj ? urlObj.host : '';
644
+
645
+ // Name — show method + full path (expands with column)
646
+ const nameCell = document.createElement('div');
647
+ nameCell.className = 'net-cell net-cell-name';
648
+ nameCell.dataset.col = 'name';
649
+ nameCell.style.width = NET_COLS[0].width + 'px';
650
+ const method = r.method || '?';
651
+ const mClass = ['GET','POST','PUT','PATCH','DELETE'].includes(method) ? `m-${method}` : 'm-other';
652
+ const fullPath = urlObj ? urlObj.pathname + urlObj.search : r.url || '';
653
+ const isErr = _isHttpError(r);
654
+ const pathCls = isErr ? ' net-path-error' : '';
655
+ nameCell.innerHTML = `<span class="method-badge ${mClass}">${method}</span> <span class="net-path${pathCls}" title="${esc(r.url)}">${esc(fullPath)}</span><span class="net-host">${esc(host)}</span>`;
656
+ row.appendChild(nameCell);
657
+
658
+ // Status
659
+ const statusCell = document.createElement('div');
660
+ statusCell.className = 'net-cell net-status';
661
+ statusCell.dataset.col = 'status';
662
+ statusCell.style.width = NET_COLS[1].width + 'px';
663
+ let statusStr = '...', sCls = 's-pending';
664
+ if (r.phase === 'error') { statusStr = 'ERR'; sCls = 's-err'; }
665
+ else if (r.status) {
666
+ statusStr = String(r.status);
667
+ const group = Math.floor(r.status / 100);
668
+ // 1xx info, 2xx success, 3xx redirect, 4xx client error, 5xx server error
669
+ if (group >= 4) sCls = 's-err';
670
+ else sCls = `s-${group}`;
671
+ }
672
+ statusCell.className += ` ${sCls}`;
673
+ statusCell.textContent = statusStr;
674
+ row.appendChild(statusCell);
675
+
676
+ // Type (content-type from response headers)
677
+ const typeCell = document.createElement('div');
678
+ typeCell.className = 'net-cell net-type';
679
+ typeCell.dataset.col = 'type';
680
+ typeCell.style.width = NET_COLS[2].width + 'px';
681
+ const ct = r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '';
682
+ typeCell.textContent = ct.split(';')[0].replace('application/', '').replace('text/', '') || '—';
683
+ row.appendChild(typeCell);
684
+
685
+ // Initiator
686
+ const initCell = document.createElement('div');
687
+ initCell.className = 'net-cell net-initiator';
688
+ initCell.dataset.col = 'initiator';
689
+ initCell.style.width = NET_COLS[3].width + 'px';
690
+ initCell.textContent = r.initiator || 'xhr';
691
+ row.appendChild(initCell);
692
+
693
+ // Size
694
+ const sizeCell = document.createElement('div');
695
+ sizeCell.className = 'net-cell net-size';
696
+ sizeCell.dataset.col = 'size';
697
+ sizeCell.style.width = NET_COLS[4].width + 'px';
698
+ const bodyStr = typeof r.responseBody === 'string' ? r.responseBody : (r.responseBody != null ? JSON.stringify(r.responseBody) : '');
699
+ sizeCell.textContent = bodyStr.length > 0 ? formatSize(bodyStr.length) : '—';
700
+ row.appendChild(sizeCell);
701
+
702
+ // Time
703
+ const timeCell = document.createElement('div');
704
+ const dur = r.duration || 0;
705
+ const slowClass = dur >= 3000 ? ' very-slow' : dur >= 1000 ? ' slow' : '';
706
+ timeCell.className = 'net-cell net-time' + slowClass;
707
+ timeCell.dataset.col = 'time';
708
+ timeCell.style.width = NET_COLS[5].width + 'px';
709
+ timeCell.textContent = r.duration != null ? (r.duration > 999 ? `${(r.duration/1000).toFixed(1)}s` : `${r.duration}ms`) : '...';
710
+ row.appendChild(timeCell);
711
+
712
+ // Waterfall
713
+ const wfCell = document.createElement('div');
714
+ wfCell.className = 'net-cell net-waterfall';
715
+ wfCell.dataset.col = 'waterfall';
716
+ wfCell.style.width = NET_COLS[6].width + 'px';
717
+ if (r.ts) {
718
+ const left = ((r.ts - wfMin) / wfRange) * 100;
719
+ const width = Math.max(2, ((r.duration || 50) / wfRange) * 100);
720
+ let barCls = 'pending';
721
+ if (r.phase === 'error') barCls = 'err';
722
+ else if (r.status && r.status >= 400) barCls = 'err';
723
+ else if (r.status) barCls = `s${Math.floor(r.status/100)}`;
724
+ wfCell.innerHTML = `<div class="wf-bar ${barCls}" style="left:${left}%;width:${width}%"></div>`;
725
+ }
726
+ row.appendChild(wfCell);
727
+
728
+ // Click to select and show detail
729
+ row.addEventListener('click', () => selectNetRequest(r.id));
730
+
731
+ // Right-click for context menu (copy as cURL)
732
+ row.addEventListener('contextmenu', (e) => {
733
+ e.preventDefault();
734
+ showNetContextMenu(e, r);
735
+ });
736
+
737
+ return row;
738
+ }
739
+
740
+ // ─── Select request → overlay detail pane over Status/Type/etc columns ───────
741
+ function selectNetRequest(id) {
742
+ state.network.selectedId = id;
743
+ const r = state.network.requests[id];
744
+ if (!r) return;
745
+
746
+ // Highlight selected row
747
+ document.querySelectorAll('#netRows .net-row').forEach(el =>
748
+ el.classList.toggle('selected', el.dataset.id === id)
749
+ );
750
+
751
+ // Position detail pane to overlay everything after the Name column
752
+ const pane = $('netDetailPane');
753
+ const nameColWidth = NET_COLS[0].width;
754
+ pane.style.left = (nameColWidth + 1) + 'px'; // +1 for the border
755
+ pane.classList.add('open');
756
+ r._tab = r._tab || 'headers';
757
+ renderNetDetailTabs(r);
758
+ renderNetDetailContent(r);
759
+ }
760
+
761
+ function closeNetDetail() {
762
+ state.network.selectedId = null;
763
+ const pane = $('netDetailPane');
764
+ if (pane) pane.classList.remove('open');
765
+ document.querySelectorAll('#netRows .net-row').forEach(el =>
766
+ el.classList.remove('selected')
767
+ );
768
+ }
769
+
770
+ function _estimateSize(val) {
771
+ if (val == null) return 0;
772
+ if (typeof val === 'string') return val.length;
773
+ try { return JSON.stringify(val).length; } catch { return 0; }
774
+ }
775
+
776
+ function _formatBytes(bytes) {
777
+ if (bytes < 1024) return `${bytes}B`;
778
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`;
779
+ return `${(bytes / 1048576).toFixed(1)}MB`;
780
+ }
781
+
782
+ function renderNetDetailTabs(r) {
783
+ const tabs = $('netDetailTabs');
784
+ tabs.innerHTML = '';
785
+
786
+ const tabDefs = [
787
+ { label: 'Headers', key: 'headers' },
788
+ { label: 'Request', key: 'request', sizeFrom: 'requestBody' },
789
+ { label: 'Preview', key: 'preview', sizeFrom: 'responseBody' },
790
+ { label: 'Response', key: 'response', sizeFrom: 'responseBody' },
791
+ ];
792
+
793
+ tabDefs.forEach(({ label, key, sizeFrom }) => {
794
+ const btn = document.createElement('button');
795
+ btn.className = 'detail-tab' + (r._tab === key ? ' active' : '');
796
+ let text = label;
797
+ if (sizeFrom && r[sizeFrom]) {
798
+ const size = _estimateSize(r[sizeFrom]);
799
+ if (size > 0) text += ` (${_formatBytes(size)})`;
800
+ }
801
+ btn.textContent = text;
802
+ btn.addEventListener('click', () => {
803
+ r._tab = key;
804
+ tabs.querySelectorAll('.detail-tab').forEach(b => b.classList.remove('active'));
805
+ btn.classList.add('active');
806
+ renderNetDetailContent(r);
807
+ });
808
+ tabs.appendChild(btn);
809
+ });
810
+
811
+ // Show search box for Preview/Response tabs
812
+ const searchWrap = $('detailSearchWrap');
813
+ if (searchWrap) {
814
+ searchWrap.style.display = (r._tab === 'preview' || r._tab === 'response' || r._tab === 'headers') ? 'flex' : 'none';
815
+ }
816
+ }
817
+
818
+ function renderNetDetailContent(r) {
819
+ let body = $('netDetailContent');
820
+ if (!body) return;
821
+ // Clone-replace to remove all stale event listeners (prevents contextmenu leak)
822
+ const fresh = body.cloneNode(false);
823
+ body.parentNode.replaceChild(fresh, body);
824
+ body = fresh;
825
+ const tab = r._tab || 'headers';
826
+
827
+ if (tab === 'headers') {
828
+ const rqH = r.requestHeaders || {};
829
+ const rsH = r.responseHeaders || {};
830
+ const renderH = (title, h) => {
831
+ const keys = Object.keys(h);
832
+ if (!keys.length) return `<div class="section-label">${title}</div><span style="color:var(--text-dim)">none</span>`;
833
+ return `<div class="section-label">${title}</div><div class="kv-grid">${keys.map(k => {
834
+ let val = h[k];
835
+ if (val && typeof val === 'object') { try { val = JSON.stringify(val); } catch { val = String(val); } }
836
+ return `<span class="kv-key">${esc(k)}</span><span class="kv-val">${esc(val)}</span>`;
837
+ }).join('')}</div>`;
838
+ };
839
+ body.innerHTML = `<div class="section-label" style="margin-top:0">General</div>
840
+ <div class="kv-grid">
841
+ <span class="kv-key">Request URL</span><span class="kv-val">${esc(r.url)}</span>
842
+ <span class="kv-key">Method</span><span class="kv-val">${esc(r.method)}</span>
843
+ <span class="kv-key">Status</span><span class="kv-val ${r.phase === 'error' ? 's-err' : r.status ? (r.status >= 400 ? 's-err' : 's-' + Math.floor(r.status/100)) : 's-pending'}">${r.phase === 'error' ? (r.status || 'ERR') : (r.status || 'Pending')} ${r.statusText || (r.phase === 'error' ? r.error || 'Network Error' : '')}</span>
844
+ </div>
845
+ ${renderH('Response Headers', rsH)}
846
+ ${renderH('Request Headers', rqH)}`;
847
+ } else if (tab === 'request') {
848
+ if (!r.requestBody) {
849
+ body.innerHTML = '<span style="color:var(--text-dim)">No request body</span>';
850
+ } else {
851
+ body.innerHTML = '';
852
+ let reqData = r.requestBody;
853
+ if (typeof reqData === 'string') {
854
+ try { reqData = JSON.parse(reqData); } catch {}
855
+ }
856
+ if (reqData && typeof reqData === 'object') {
857
+ body.appendChild(createTreeNode(null, reqData, false));
858
+ body.addEventListener('contextmenu', (e) => {
859
+ e.preventDefault();
860
+ showPreviewCopyMenu(e, reqData);
861
+ });
862
+ } else {
863
+ body.innerHTML = renderJSON(r.requestBody);
864
+ }
865
+ }
866
+ } else if (tab === 'preview') {
867
+ const isErrStatus = _isHttpError(r);
868
+ if (r.phase === 'error' && !r.responseBody) { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
869
+ if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
870
+ // Render as collapsible JSON tree with right-click copy
871
+ const val = r.responseBody;
872
+ let treeData = val;
873
+ if (typeof val === 'string') {
874
+ try { treeData = JSON.parse(val); } catch {
875
+ body.innerHTML = `<span style="color:${isErrStatus ? 'var(--red)' : 'inherit'}">${esc(val)}</span>`;
876
+ return;
877
+ }
878
+ }
879
+ if (treeData && typeof treeData === 'object') {
880
+ body.innerHTML = '';
881
+ // Show error status banner above the response body
882
+ if (isErrStatus) {
883
+ const errBanner = document.createElement('div');
884
+ errBanner.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0 8px;font-size:11px;border-bottom:1px solid rgba(255,94,114,.15);margin-bottom:8px';
885
+ errBanner.textContent = `${r.status || 'ERR'} ${r.statusText || r.error || 'Error'}`;
886
+ body.appendChild(errBanner);
887
+ }
888
+ body.appendChild(createTreeNode(null, treeData, false));
889
+ // Right-click on preview to copy the whole object or clicked node value
890
+ body.addEventListener('contextmenu', (e) => {
891
+ e.preventDefault();
892
+ showPreviewCopyMenu(e, treeData);
893
+ });
894
+ } else {
895
+ body.innerHTML = isErrStatus
896
+ ? `<span style="color:var(--red)">${esc(String(r.responseBody))}</span>`
897
+ : '<span style="color:var(--text-dim)">No preview available</span>';
898
+ }
899
+ } else if (tab === 'response') {
900
+ const isErrStatus = _isHttpError(r);
901
+ if (r.phase === 'error' && !r.responseBody) { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
902
+ if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
903
+ if (isErrStatus) {
904
+ const errBanner = document.createElement('div');
905
+ errBanner.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0 8px;font-size:11px;border-bottom:1px solid rgba(255,94,114,.15);margin-bottom:8px';
906
+ errBanner.textContent = `${r.status || 'ERR'} ${r.statusText || r.error || 'Error'}`;
907
+ body.innerHTML = '';
908
+ body.appendChild(errBanner);
909
+ const raw = document.createElement('div');
910
+ raw.style.color = 'var(--red)';
911
+ raw.innerHTML = renderJSON(r.responseBody);
912
+ body.appendChild(raw);
913
+ } else {
914
+ body.innerHTML = renderJSON(r.responseBody);
915
+ }
916
+ }
917
+ }
918
+
919
+ // ─── Network context menus ──────────────────────────────────────────────────
920
+ function showNetContextMenu(e, r) {
921
+ const items = [
922
+ { label: 'Copy as cURL', action: () => navigator.clipboard.writeText(buildCurlCommand(r)) },
923
+ { label: 'Copy URL', action: () => navigator.clipboard.writeText(r.url || '') },
924
+ ];
925
+ if (r.responseBody) {
926
+ items.push({ label: 'Copy Response', action: () => {
927
+ const text = typeof r.responseBody === 'string' ? r.responseBody : JSON.stringify(r.responseBody, null, 2);
928
+ navigator.clipboard.writeText(text);
929
+ }});
930
+ }
931
+ // Hide URL option
932
+ items.push({ label: '—', action: null }); // separator
933
+ items.push({ label: 'Hide this URL', action: () => {
934
+ addHiddenURL(r.url || '');
935
+ renderNetwork();
936
+ }});
937
+ showContextMenu(e, items);
938
+ }
939
+
940
+ function showPreviewCopyMenu(e, fullData) {
941
+ const items = [
942
+ { label: 'Copy Object', action: () => navigator.clipboard.writeText(JSON.stringify(fullData, null, 2)) },
943
+ ];
944
+ const sel = window.getSelection();
945
+ if (sel && sel.toString().length > 0) {
946
+ items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
947
+ }
948
+ const keyEl = e.target.closest('.ov-key');
949
+ const leafEl = e.target.closest('.ov-leaf');
950
+ if (keyEl || leafEl) {
951
+ items.push({ label: 'Copy Value', action: () => navigator.clipboard.writeText((leafEl || keyEl.parentElement).textContent) });
952
+ }
953
+ showContextMenu(e, items);
954
+ }
955
+
956
+ function buildCurlCommand(r) {
957
+ let cmd = `curl '${r.url}'`;
958
+ if (r.method && r.method !== 'GET') cmd += ` -X ${r.method}`;
959
+ const headers = r.requestHeaders || {};
960
+ Object.entries(headers).forEach(([k, v]) => {
961
+ cmd += ` \\\n -H '${k}: ${v}'`;
962
+ });
963
+ if (r.requestBody) {
964
+ const body = typeof r.requestBody === 'string' ? r.requestBody : JSON.stringify(r.requestBody);
965
+ cmd += ` \\\n --data-raw '${body.replace(/'/g, "'\\''")}'`;
966
+ }
967
+ return cmd;
968
+ }