reactoradar 1.6.6 → 1.6.8

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