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,788 @@
1
+ // ─── Console Panel ─────────────────────────────────────────────────────────
2
+
3
+ // Load saved log level filters from localStorage
4
+ function getStoredLogLevels() {
5
+ try {
6
+ const saved = localStorage.getItem('rn-debug-log-levels');
7
+ if (saved) return JSON.parse(saved);
8
+ } catch {}
9
+ return { log: true, info: true, warn: true, error: true, debug: true, redux: false };
10
+ }
11
+ function setStoredLogLevels(levels) {
12
+ try { localStorage.setItem('rn-debug-log-levels', JSON.stringify(levels)); } catch {}
13
+ }
14
+
15
+ function initConsolePanel() {
16
+ const panel = $('panel-console');
17
+ const levels = getStoredLogLevels();
18
+ state.console.levelFilters = levels;
19
+ state.console.showRedux = !!levels.redux;
20
+
21
+ panel.innerHTML = `
22
+ <div class="panel-toolbar">
23
+ <span class="panel-label">Console</span>
24
+ <span class="badge" id="cBadge">0</span>
25
+ <input id="consoleSearch" class="net-search-input" style="margin-left:12px" placeholder="Filter logs..." />
26
+ <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
27
+ <button class="panel-clear-btn" id="consoleExport" title="Export logs as JSON">Export</button>
28
+ <button class="panel-clear-btn" id="consoleClear" title="Clear console">Clear</button>
29
+ <div class="console-level-dropdown" id="consoleLevelDropdown">
30
+ <button class="console-level-btn" id="consoleLevelBtn">Levels ▾</button>
31
+ <div class="console-level-menu" id="consoleLevelMenu">
32
+ <label class="console-level-option"><input type="checkbox" data-level="log" ${levels.log ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--text-mid)"></span>Log</label>
33
+ <label class="console-level-option"><input type="checkbox" data-level="info" ${levels.info ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--accent)"></span>Info</label>
34
+ <label class="console-level-option"><input type="checkbox" data-level="warn" ${levels.warn ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--yellow)"></span>Warn</label>
35
+ <label class="console-level-option"><input type="checkbox" data-level="error" ${levels.error ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--red)"></span>Error</label>
36
+ <label class="console-level-option"><input type="checkbox" data-level="debug" ${levels.debug ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--accent2)"></span>Debug</label>
37
+ <div style="border-top:1px solid var(--border);margin:4px 0"></div>
38
+ <label class="console-level-option"><input type="checkbox" data-level="redux" ${levels.redux ? 'checked' : ''} /><span class="lvl-dot" style="background:var(--green)"></span>Redux Actions</label>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ <div class="scroll-area" id="consoleList">
44
+ <div class="empty-state" id="consoleEmpty">
45
+ <div class="icon">⬛</div>
46
+ <div class="label">No logs yet</div>
47
+ <div class="hint">Logs will appear here automatically</div>
48
+ </div>
49
+ </div>
50
+ <div class="console-find-bar" id="consoleFindBar" style="display:none">
51
+ <input id="consoleFindInput" class="console-find-input" placeholder="Find in logs... (Cmd+F)" />
52
+ <span id="consoleFindCount" class="console-find-count"></span>
53
+ <button class="console-find-btn" id="consoleFindPrev" title="Previous">▲</button>
54
+ <button class="console-find-btn" id="consoleFindNext" title="Next">▼</button>
55
+ <button class="console-find-btn" id="consoleFindClose" title="Close (Esc)">✕</button>
56
+ </div>`;
57
+
58
+ // Search filter
59
+ $('consoleSearch').addEventListener('input', (e) => {
60
+ state.console.searchFilter = e.target.value.toLowerCase().trim();
61
+ renderConsole();
62
+ });
63
+
64
+ // Level dropdown toggle
65
+ $('consoleLevelBtn').addEventListener('click', (e) => {
66
+ e.stopPropagation();
67
+ $('consoleLevelMenu').classList.toggle('open');
68
+ });
69
+
70
+ // Close dropdown when clicking outside
71
+ document.addEventListener('click', (e) => {
72
+ if (!e.target.closest('#consoleLevelDropdown')) {
73
+ $('consoleLevelMenu')?.classList.remove('open');
74
+ }
75
+ });
76
+
77
+ // Level checkbox changes
78
+ $('consoleLevelMenu').addEventListener('change', (e) => {
79
+ const checkbox = e.target;
80
+ const level = checkbox.dataset.level;
81
+ if (level) {
82
+ state.console.levelFilters[level] = checkbox.checked;
83
+ if (level === 'redux') state.console.showRedux = checkbox.checked;
84
+ setStoredLogLevels(state.console.levelFilters);
85
+ updateLevelBtnText();
86
+ renderConsole();
87
+ }
88
+ });
89
+
90
+ updateLevelBtnText();
91
+
92
+ $('consoleExport')?.addEventListener('click', () => {
93
+ const data = JSON.stringify(state.console.logs, null, 2);
94
+ const blob = new Blob([data], { type: 'application/json' });
95
+ const url = URL.createObjectURL(blob);
96
+ const a = document.createElement('a');
97
+ a.href = url; a.download = `reactoradar-console-${Date.now()}.json`; a.click();
98
+ URL.revokeObjectURL(url);
99
+ });
100
+
101
+ $('consoleClear').addEventListener('click', () => {
102
+ state.console.logs = [];
103
+ _consolePending = [];
104
+ _lastLogMsg = ''; _lastLogRow = null; _lastLogCount = 1;
105
+ $('cBadge').textContent = '0';
106
+ renderConsole();
107
+ });
108
+
109
+ // Find bar (Cmd+F)
110
+ let _findMatches = [];
111
+ let _findIdx = -1;
112
+
113
+ function doFind(term) {
114
+ // Clear previous highlights
115
+ document.querySelectorAll('.console-find-highlight').forEach(el => {
116
+ el.replaceWith(el.textContent);
117
+ });
118
+ _findMatches = [];
119
+ _findIdx = -1;
120
+ if (!term) { $('consoleFindCount').textContent = ''; return; }
121
+
122
+ const rows = document.querySelectorAll('#consoleList .log-row');
123
+ rows.forEach(row => {
124
+ const text = row.textContent.toLowerCase();
125
+ if (text.includes(term.toLowerCase())) _findMatches.push(row);
126
+ });
127
+ $('consoleFindCount').textContent = _findMatches.length ? `${_findMatches.length} found` : 'No matches';
128
+ if (_findMatches.length) { _findIdx = 0; _findMatches[0].scrollIntoView({ block: 'nearest' }); _findMatches[0].style.outline = '1px solid var(--accent)'; }
129
+ }
130
+
131
+ function findNav(dir) {
132
+ if (!_findMatches.length) return;
133
+ if (_findMatches[_findIdx]) _findMatches[_findIdx].style.outline = '';
134
+ _findIdx = (_findIdx + dir + _findMatches.length) % _findMatches.length;
135
+ _findMatches[_findIdx].scrollIntoView({ block: 'nearest' });
136
+ _findMatches[_findIdx].style.outline = '1px solid var(--accent)';
137
+ $('consoleFindCount').textContent = `${_findIdx + 1}/${_findMatches.length}`;
138
+ }
139
+
140
+ $('consoleFindInput').addEventListener('input', (e) => doFind(e.target.value));
141
+ $('consoleFindPrev').addEventListener('click', () => findNav(-1));
142
+ $('consoleFindNext').addEventListener('click', () => findNav(1));
143
+ $('consoleFindClose').addEventListener('click', () => {
144
+ $('consoleFindBar').style.display = 'none';
145
+ if (_findMatches[_findIdx]) _findMatches[_findIdx].style.outline = '';
146
+ _findMatches = []; _findIdx = -1;
147
+ $('consoleFindInput').value = '';
148
+ $('consoleFindCount').textContent = '';
149
+ });
150
+ $('consoleFindInput').addEventListener('keydown', (e) => {
151
+ if (e.key === 'Escape') $('consoleFindClose').click();
152
+ if (e.key === 'Enter') findNav(e.shiftKey ? -1 : 1);
153
+ });
154
+ }
155
+
156
+ function updateLevelBtnText() {
157
+ const levels = state.console.levelFilters;
158
+ const logLevels = { log: levels.log, info: levels.info, warn: levels.warn, error: levels.error, debug: levels.debug };
159
+ const allOn = Object.values(logLevels).every(v => v);
160
+ const allOff = Object.values(logLevels).every(v => !v);
161
+ const btn = $('consoleLevelBtn');
162
+ if (!btn) return;
163
+ let text = '';
164
+ if (allOn) text = 'All Levels';
165
+ else if (allOff) text = 'None';
166
+ else text = Object.entries(logLevels).filter(([, v]) => v).map(([k]) => k.charAt(0).toUpperCase() + k.slice(1)).join(', ');
167
+ if (levels.redux) text += (text ? ' + ' : '') + 'Redux';
168
+ btn.textContent = text + ' ▾';
169
+ }
170
+
171
+ // Console is fed via IPC (network-event handled in IPC section above)
172
+
173
+ // ─── Toast Notifications ─────────────────────────────────────────────────────
174
+ let _toastContainer = null;
175
+ const _activeToasts = {};
176
+
177
+ function getToastsEnabled() {
178
+ try { return localStorage.getItem('rn-debug-toasts') !== 'false'; } catch { return true; }
179
+ }
180
+ function setToastsEnabled(v) {
181
+ try { localStorage.setItem('rn-debug-toasts', v ? 'true' : 'false'); } catch {}
182
+ }
183
+
184
+ function showToast(message, type, targetPanel) {
185
+ if (!getToastsEnabled()) return;
186
+ if (!_toastContainer) {
187
+ _toastContainer = document.createElement('div');
188
+ _toastContainer.id = 'toastContainer';
189
+ _toastContainer.className = 'toast-container';
190
+ document.body.appendChild(_toastContainer);
191
+ }
192
+ // Don't show toast if user is already on the target panel
193
+ if (targetPanel && state.activePanel === targetPanel) return;
194
+
195
+ // Deduplicate: if same message already showing, increment count
196
+ const key = `${type}:${message}`;
197
+ if (_activeToasts[key] && _activeToasts[key].el.parentNode) {
198
+ const existing = _activeToasts[key];
199
+ existing.count++;
200
+ const msgEl = existing.el.querySelector('.toast-msg');
201
+ if (msgEl) msgEl.textContent = `${message} (${existing.count})`;
202
+ // Reset auto-remove timer
203
+ clearTimeout(existing.timer);
204
+ existing.timer = setTimeout(() => {
205
+ if (existing.el.parentNode) existing.el.remove();
206
+ delete _activeToasts[key];
207
+ }, 5000);
208
+ return;
209
+ }
210
+
211
+ const toast = document.createElement('div');
212
+ toast.className = `toast toast-${type || 'info'}`;
213
+ toast.innerHTML = `<span class="toast-msg">${esc(message)}</span>`;
214
+ if (targetPanel) {
215
+ const btn = document.createElement('span');
216
+ btn.className = 'toast-action';
217
+ btn.textContent = 'View';
218
+ btn.addEventListener('click', () => { switchPanel(targetPanel); toast.remove(); delete _activeToasts[key]; });
219
+ toast.appendChild(btn);
220
+ }
221
+ const close = document.createElement('span');
222
+ close.className = 'toast-close';
223
+ close.textContent = '✕';
224
+ close.addEventListener('click', () => { toast.remove(); delete _activeToasts[key]; });
225
+ toast.appendChild(close);
226
+
227
+ _toastContainer.appendChild(toast);
228
+ const timer = setTimeout(() => {
229
+ if (toast.parentNode) toast.remove();
230
+ delete _activeToasts[key];
231
+ }, 5000);
232
+ _activeToasts[key] = { el: toast, count: 1, timer };
233
+ // Keep max 3 toasts
234
+ const toasts = _toastContainer.querySelectorAll('.toast');
235
+ if (toasts.length > 3) { toasts[0].remove(); }
236
+ }
237
+
238
+ // ─── Batched console append (fixes re-render performance) ────────────────────
239
+ let _consolePending = [];
240
+ let _consoleRAF = null;
241
+
242
+ let _lastLogMsg = '';
243
+ let _lastLogRow = null;
244
+ let _lastLogCount = 1;
245
+
246
+ const MAX_CONSOLE_LOGS = 5000;
247
+
248
+ function addConsoleLog(event) {
249
+ state.console.logs.push(event);
250
+ // Cap in-memory logs to prevent memory leak
251
+ if (state.console.logs.length > MAX_CONSOLE_LOGS) {
252
+ state.console.logs = state.console.logs.slice(-MAX_CONSOLE_LOGS);
253
+ }
254
+ _consolePending.push(event);
255
+
256
+ // Batch DOM updates via rAF — only one paint per frame
257
+ if (!_consoleRAF) {
258
+ _consoleRAF = requestAnimationFrame(flushConsoleBatch);
259
+ }
260
+ }
261
+
262
+ function flushConsoleBatch() {
263
+ _consoleRAF = null;
264
+ const batch = _consolePending;
265
+ _consolePending = [];
266
+ if (!batch.length) return;
267
+
268
+ $('cBadge').textContent = state.console.logs.length;
269
+
270
+ const list = $('consoleList');
271
+ const empty = $('consoleEmpty');
272
+ if (!list) return;
273
+
274
+ const { levelFilters, searchFilter } = state.console;
275
+ const frag = document.createDocumentFragment();
276
+ let added = 0;
277
+
278
+ batch.forEach(l => {
279
+ // Redux logs use showRedux flag; regular logs use levelFilters
280
+ if (l.level === 'redux') {
281
+ if (!state.console.showRedux) return;
282
+ } else if (levelFilters && !levelFilters[l.level]) return;
283
+ if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return;
284
+
285
+ // Group consecutive identical messages
286
+ const msgKey = `${l.level}:${l.message || ''}`;
287
+ if (msgKey === _lastLogMsg && _lastLogRow && _lastLogRow.parentNode) {
288
+ _lastLogCount++;
289
+ let badge = _lastLogRow.querySelector('.log-group-badge');
290
+ if (!badge) {
291
+ badge = document.createElement('span');
292
+ badge.className = 'log-group-badge';
293
+ _lastLogRow.insertBefore(badge, _lastLogRow.firstChild);
294
+ }
295
+ badge.textContent = _lastLogCount;
296
+ return; // Don't add a new row
297
+ }
298
+
299
+ _lastLogMsg = msgKey;
300
+ _lastLogCount = 1;
301
+ const row = buildLogRow(l);
302
+ _lastLogRow = row;
303
+ frag.appendChild(row);
304
+ added++;
305
+ });
306
+
307
+ if (added > 0) {
308
+ // Hide empty state as soon as we have visible rows
309
+ if (empty) empty.style.display = 'none';
310
+ // Auto-scroll only if user is already near the bottom (within 150px)
311
+ const wasAtBottom = (list.scrollHeight - list.scrollTop - list.clientHeight) < 150;
312
+ list.appendChild(frag);
313
+ // Keep DOM size manageable — remove oldest rows
314
+ const rows = list.querySelectorAll('.log-row');
315
+ const MAX_DOM_ROWS = 2000;
316
+ if (rows.length > MAX_DOM_ROWS) {
317
+ const toRemove = rows.length - MAX_DOM_ROWS;
318
+ for (let i = 0; i < toRemove; i++) rows[i].remove();
319
+ }
320
+ if (wasAtBottom) list.scrollTop = list.scrollHeight;
321
+ }
322
+ }
323
+
324
+ // NOTE: console-event IPC listener is registered in init.js (not here)
325
+ // to keep all IPC registrations in one place and avoid duplicate listener issues.
326
+
327
+ // ─── Shared Object Tree Renderer (Chrome DevTools-like) ──────────────────────
328
+ // Builds interactive, collapsible DOM nodes for objects/arrays.
329
+
330
+ // Collect all entries for an object: own data properties + prototype getter values.
331
+ // Getter-derived keys use the clean name (e.g. "deliveryId") and skip backing
332
+ // fields (e.g. "_deliveryId") so the log output mirrors the model's public API.
333
+ function collectEntries(val) {
334
+ if (Array.isArray(val)) return val.map((v, i) => [i, v]);
335
+
336
+ const result = {};
337
+ const getterKeys = new Set();
338
+
339
+ // 1. Walk prototype chain and invoke getters
340
+ let proto = Object.getPrototypeOf(val);
341
+ while (proto && proto !== Object.prototype) {
342
+ const descs = Object.getOwnPropertyDescriptors(proto);
343
+ for (const [k, desc] of Object.entries(descs)) {
344
+ if (k === 'constructor') continue;
345
+ if (desc.get && !(k in result)) {
346
+ try { result[k] = desc.get.call(val); } catch { /* skip broken getters */ }
347
+ getterKeys.add(k);
348
+ }
349
+ }
350
+ proto = Object.getPrototypeOf(proto);
351
+ }
352
+
353
+ // 2. Add own data properties, but skip backing fields whose getter is present.
354
+ // Convention: getter "foo" backs "_foo"; if "foo" was collected, skip "_foo".
355
+ const ownKeys = Object.keys(val);
356
+ for (const k of ownKeys) {
357
+ const clean = k.startsWith('_') ? k.slice(1) : null;
358
+ if (clean && getterKeys.has(clean)) continue; // skip _backing field
359
+ if (!(k in result)) result[k] = val[k];
360
+ }
361
+
362
+ return Object.entries(result);
363
+ }
364
+
365
+ function objPreview(val, maxLen) {
366
+ maxLen = maxLen || 80;
367
+ if (val === null) return 'null';
368
+ if (val === undefined) return 'undefined';
369
+ if (Array.isArray(val)) {
370
+ if (val.length === 0) return '[]';
371
+ const items = [];
372
+ let len = 2; // [ ]
373
+ for (let i = 0; i < val.length && len < maxLen; i++) {
374
+ const s = primitivePreview(val[i]);
375
+ len += s.length + 2;
376
+ items.push(s);
377
+ }
378
+ const suffix = items.length < val.length ? ', ...' : '';
379
+ return `(${val.length}) [${items.join(', ')}${suffix}]`;
380
+ }
381
+ if (typeof val === 'object') {
382
+ const entries = collectEntries(val);
383
+ if (entries.length === 0) return '{}';
384
+ const items = [];
385
+ let len = 2;
386
+ for (let i = 0; i < entries.length && len < maxLen; i++) {
387
+ const s = `${entries[i][0]}: ${primitivePreview(entries[i][1])}`;
388
+ len += s.length + 2;
389
+ items.push(s);
390
+ }
391
+ const suffix = items.length < entries.length ? ', ...' : '';
392
+ return `{${items.join(', ')}${suffix}}`;
393
+ }
394
+ return primitivePreview(val);
395
+ }
396
+
397
+ function primitivePreview(val) {
398
+ if (val === null) return 'null';
399
+ if (val === undefined) return 'undefined';
400
+ if (typeof val === 'string') return val.length > 50 ? `"${val.slice(0,50)}..."` : `"${val}"`;
401
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
402
+ if (Array.isArray(val)) return `Array(${val.length})`;
403
+ if (typeof val === 'object') return `{...}`;
404
+ return String(val);
405
+ }
406
+
407
+ function createTreeNode(key, val, startCollapsed) {
408
+ const isArray = Array.isArray(val);
409
+ const isObj = val !== null && typeof val === 'object';
410
+
411
+ if (!isObj) {
412
+ // Primitive leaf
413
+ const row = document.createElement('div');
414
+ row.className = 'ov-leaf';
415
+ if (key !== null) {
416
+ const k = document.createElement('span');
417
+ k.className = 'ov-key';
418
+ k.textContent = isNaN(key) ? `${key}: ` : `${key}: `;
419
+ row.appendChild(k);
420
+ }
421
+ row.appendChild(createPrimitiveSpan(val));
422
+ return row;
423
+ }
424
+
425
+ // Collapsible object/array
426
+ const container = document.createElement('div');
427
+ container.className = 'ov-node';
428
+
429
+ const header = document.createElement('div');
430
+ header.className = 'ov-header';
431
+
432
+ const arrow = document.createElement('span');
433
+ arrow.className = 'ov-arrow';
434
+ arrow.textContent = '\u25B6'; // ▶
435
+ header.appendChild(arrow);
436
+
437
+ if (key !== null) {
438
+ const k = document.createElement('span');
439
+ k.className = 'ov-key';
440
+ k.textContent = `${key}: `;
441
+ header.appendChild(k);
442
+ }
443
+
444
+ const preview = document.createElement('span');
445
+ preview.className = 'ov-preview';
446
+ preview.textContent = objPreview(val);
447
+ header.appendChild(preview);
448
+
449
+ container.appendChild(header);
450
+
451
+ const children = document.createElement('div');
452
+ children.className = 'ov-children';
453
+ children.style.display = 'none';
454
+
455
+ let populated = false;
456
+
457
+ function populateChildren() {
458
+ if (populated) return;
459
+ populated = true;
460
+ const entries = collectEntries(val);
461
+ entries.forEach(([k, v]) => {
462
+ children.appendChild(createTreeNode(k, v, true));
463
+ });
464
+ // For arrays show length, for objects show prototype hint
465
+ if (isArray) {
466
+ const lenNode = document.createElement('div');
467
+ lenNode.className = 'ov-leaf ov-meta';
468
+ lenNode.textContent = `length: ${val.length}`;
469
+ children.appendChild(lenNode);
470
+ }
471
+ }
472
+
473
+ let expanded = !startCollapsed;
474
+ if (expanded) {
475
+ populateChildren();
476
+ children.style.display = 'block';
477
+ arrow.textContent = '\u25BC'; // ▼
478
+ arrow.classList.add('expanded');
479
+ preview.style.display = 'none';
480
+ }
481
+
482
+ header.addEventListener('click', (e) => {
483
+ e.stopPropagation();
484
+ expanded = !expanded;
485
+ if (expanded) {
486
+ populateChildren();
487
+ children.style.display = 'block';
488
+ arrow.textContent = '\u25BC';
489
+ arrow.classList.add('expanded');
490
+ preview.style.display = 'none';
491
+ } else {
492
+ children.style.display = 'none';
493
+ arrow.textContent = '\u25B6';
494
+ arrow.classList.remove('expanded');
495
+ preview.style.display = '';
496
+ }
497
+ });
498
+
499
+ container.appendChild(children);
500
+ return container;
501
+ }
502
+
503
+ function _safeStr(val) {
504
+ if (val === null) return 'null';
505
+ if (val === undefined) return 'undefined';
506
+ if (typeof val === 'string') return val;
507
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
508
+ try { return JSON.stringify(val, null, 2); } catch { return String(val); }
509
+ }
510
+
511
+ function createPrimitiveSpan(val) {
512
+ const s = document.createElement('span');
513
+ if (val === null) { s.className = 'ov-null'; s.textContent = 'null'; }
514
+ else if (val === undefined) { s.className = 'ov-undef'; s.textContent = 'undefined'; }
515
+ else if (typeof val === 'string') { s.className = 'ov-str'; s.textContent = `"${val}"`; }
516
+ else if (typeof val === 'number') { s.className = 'ov-num'; s.textContent = String(val); }
517
+ else if (typeof val === 'boolean') { s.className = 'ov-bool'; s.textContent = String(val); }
518
+ else { s.textContent = _safeStr(val); }
519
+ return s;
520
+ }
521
+
522
+ // Parse a structured arg from the SDK (or fall back to raw message string)
523
+ function renderConsoleArg(arg) {
524
+ if (!arg || typeof arg !== 'object' || !arg.t) {
525
+ // Backward compat: raw string
526
+ const s = document.createElement('span');
527
+ s.className = 'ov-str';
528
+ s.textContent = _safeStr(arg);
529
+ return s;
530
+ }
531
+ const { t, v } = arg;
532
+ if (t === 'string') {
533
+ const s = document.createElement('span');
534
+ s.className = 'log-text';
535
+ s.textContent = v;
536
+ return s;
537
+ }
538
+ if (t === 'number') { return createPrimitiveSpan(v); }
539
+ if (t === 'boolean') { return createPrimitiveSpan(v); }
540
+ if (t === 'null') { return createPrimitiveSpan(null); }
541
+ if (t === 'undefined') { return createPrimitiveSpan(undefined); }
542
+ if (t === 'object' || t === 'array') {
543
+ return createTreeNode(null, v, false);
544
+ }
545
+ const s = document.createElement('span');
546
+ s.textContent = _safeStr(v);
547
+ return s;
548
+ }
549
+
550
+ // Build the body of a console log row. If structured args exist, render each;
551
+ // otherwise fall back to the flat message string and try to detect JSON in it.
552
+ function buildLogBody(logEntry) {
553
+ const container = document.createElement('div');
554
+ container.className = 'log-body';
555
+
556
+ if (logEntry.args && Array.isArray(logEntry.args) && logEntry.args.length > 0) {
557
+ // Structured args from updated SDK
558
+ logEntry.args.forEach((arg, i) => {
559
+ if (i > 0) container.appendChild(document.createTextNode(' '));
560
+ container.appendChild(renderConsoleArg(arg));
561
+ });
562
+ } else if (logEntry.message != null) {
563
+ // Legacy / flat message — try to parse JSON objects out of it
564
+ const msg = String(logEntry.message);
565
+ // Try parsing the whole message as JSON
566
+ try {
567
+ const parsed = JSON.parse(msg);
568
+ if (typeof parsed === 'object' && parsed !== null) {
569
+ container.appendChild(createTreeNode(null, parsed, false));
570
+ return container;
571
+ }
572
+ } catch {}
573
+
574
+ // Otherwise render as text, but look for embedded JSON blocks
575
+ // If it looks like it contains JSON, try to pretty-render inline
576
+ const jsonRe = /(\{[\s\S]*\}|\[[\s\S]*\])/;
577
+ const match = msg.match(jsonRe);
578
+ if (match && match[0].length > 2) {
579
+ try {
580
+ const parsed = JSON.parse(match[0]);
581
+ // There's text before/after
582
+ const before = msg.slice(0, match.index);
583
+ const after = msg.slice(match.index + match[0].length);
584
+ if (before) container.appendChild(document.createTextNode(before));
585
+ container.appendChild(createTreeNode(null, parsed, false));
586
+ if (after) container.appendChild(document.createTextNode(after));
587
+ return container;
588
+ } catch {}
589
+ }
590
+
591
+ // Plain text
592
+ const span = document.createElement('span');
593
+ span.className = 'log-text';
594
+ span.textContent = msg;
595
+ container.appendChild(span);
596
+ }
597
+
598
+ return container;
599
+ }
600
+
601
+ function buildLogRow(l) {
602
+ const div = document.createElement('div');
603
+ div.className = `log-row entry ${l.level}`;
604
+
605
+ const timeSpan = document.createElement('span');
606
+ timeSpan.className = 'log-time';
607
+ timeSpan.textContent = ts(l.ts);
608
+ div.appendChild(timeSpan);
609
+
610
+ const lvlSpan = document.createElement('span');
611
+ lvlSpan.className = `lvl-badge lvl-${l.level}`;
612
+ lvlSpan.textContent = l.level;
613
+ div.appendChild(lvlSpan);
614
+
615
+ // Arrow (inline, not inside body-wrap)
616
+ const arrow = document.createElement('span');
617
+ arrow.className = 'log-arrow';
618
+ arrow.textContent = '\u25B6';
619
+ div.appendChild(arrow);
620
+
621
+ // Body wrapper
622
+ const bodyWrap = document.createElement('div');
623
+ bodyWrap.className = 'log-body-wrap';
624
+
625
+ // Single-line preview: message text + caller
626
+ const preview = document.createElement('div');
627
+ preview.className = 'log-preview';
628
+ const msgText = (l.message || '').replace(/\n/g, ' ').slice(0, 200);
629
+ const previewText = document.createElement('span');
630
+ previewText.textContent = msgText + ((l.message || '').length > 200 ? '...' : '');
631
+ preview.appendChild(previewText);
632
+ bodyWrap.appendChild(preview);
633
+
634
+ // Full content (hidden by default)
635
+ const full = document.createElement('div');
636
+ full.className = 'log-full';
637
+ full.style.display = 'none';
638
+ full.appendChild(buildLogBody(l));
639
+ bodyWrap.appendChild(full);
640
+
641
+ let expanded = false;
642
+ // Only toggle on click, NOT on text selection drag
643
+ let _mouseDownPos = null;
644
+ bodyWrap.addEventListener('mousedown', (e) => {
645
+ _mouseDownPos = { x: e.clientX, y: e.clientY };
646
+ });
647
+ bodyWrap.addEventListener('click', (e) => {
648
+ // Don't toggle if user is selecting text (dragged mouse)
649
+ if (_mouseDownPos) {
650
+ const dx = Math.abs(e.clientX - _mouseDownPos.x);
651
+ const dy = Math.abs(e.clientY - _mouseDownPos.y);
652
+ if (dx > 3 || dy > 3) return; // user dragged to select
653
+ }
654
+ // Don't toggle if there's an active text selection
655
+ const sel = window.getSelection();
656
+ if (sel && sel.toString().length > 0) return;
657
+ // Don't toggle if clicking inside object tree expander
658
+ if (e.target.closest('.ov-header')) return;
659
+ expanded = !expanded;
660
+ if (expanded) {
661
+ preview.style.display = 'none';
662
+ full.style.display = 'block';
663
+ arrow.textContent = '\u25BC';
664
+ arrow.classList.add('expanded');
665
+ } else {
666
+ preview.style.display = '';
667
+ full.style.display = 'none';
668
+ arrow.textContent = '\u25B6';
669
+ arrow.classList.remove('expanded');
670
+ }
671
+ });
672
+
673
+ // Right-click → copy options
674
+ div.addEventListener('contextmenu', (e) => {
675
+ e.preventDefault();
676
+ const items = [];
677
+
678
+ // Copy selected text
679
+ const sel = window.getSelection();
680
+ if (sel && sel.toString().length > 0) {
681
+ items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
682
+ }
683
+
684
+ // Copy full log message
685
+ items.push({ label: 'Copy Message', action: () => {
686
+ navigator.clipboard.writeText(l.message || '');
687
+ }});
688
+
689
+ // Copy as JSON (if structured args exist)
690
+ if (l.args && l.args.length > 0) {
691
+ items.push({ label: 'Copy as JSON', action: () => {
692
+ const json = l.args.map(a => {
693
+ if (a.t === 'object' || a.t === 'array') return JSON.stringify(a.v, null, 2);
694
+ return String(a.v);
695
+ }).join(' ');
696
+ navigator.clipboard.writeText(json);
697
+ }});
698
+ }
699
+
700
+ // Copy caller location
701
+ if (l.caller) {
702
+ items.push({ label: 'Copy Caller', action: () => navigator.clipboard.writeText(l.caller) });
703
+ }
704
+
705
+ showContextMenu(e, items);
706
+ });
707
+
708
+ div.appendChild(bodyWrap);
709
+ return div;
710
+ }
711
+
712
+ // ─── Shared Context Menu ─────────────────────────────────────────────────────
713
+ function showContextMenu(e, items) {
714
+ document.querySelectorAll('.ctx-menu').forEach(el => el.remove());
715
+ const menu = document.createElement('div');
716
+ menu.className = 'ctx-menu';
717
+ items.forEach(({ label, action }) => {
718
+ if (label === '—' || !action) {
719
+ const sep = document.createElement('div');
720
+ sep.className = 'ctx-sep';
721
+ menu.appendChild(sep);
722
+ return;
723
+ }
724
+ const item = document.createElement('div');
725
+ item.className = 'ctx-item';
726
+ item.textContent = label;
727
+ item.addEventListener('click', () => { action(); menu.remove(); });
728
+ menu.appendChild(item);
729
+ });
730
+ menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
731
+ menu.style.top = Math.min(e.clientY, window.innerHeight - items.length * 32 - 10) + 'px';
732
+ document.body.appendChild(menu);
733
+ setTimeout(() => {
734
+ const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close); } };
735
+ document.addEventListener('click', close);
736
+ }, 0);
737
+ }
738
+
739
+ // Full re-render — only used on filter/level change, NOT on every incoming log
740
+ function renderConsole() {
741
+ const list = $('consoleList');
742
+ const empty = $('consoleEmpty');
743
+ if (!list) return;
744
+
745
+ const { levelFilters, searchFilter } = state.console;
746
+ const visible = state.console.logs.filter(l => {
747
+ // Redux logs use showRedux flag; regular logs use levelFilters
748
+ if (l.level === 'redux') {
749
+ if (!state.console.showRedux) return false;
750
+ } else if (levelFilters && !levelFilters[l.level]) return false;
751
+ if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return false;
752
+ return true;
753
+ });
754
+
755
+ list.querySelectorAll('.log-row').forEach(e => e.remove());
756
+ if (!empty) { /* guard */ }
757
+ else if (visible.length > 0) {
758
+ empty.style.display = 'none';
759
+ } else if (state.console.logs.length > 0) {
760
+ const lbl = empty.querySelector('.label');
761
+ const hint = empty.querySelector('.hint');
762
+ if (lbl) lbl.textContent = 'No matching logs';
763
+ if (hint) hint.textContent = 'Adjust level filters or clear search to see logs';
764
+ empty.style.display = 'flex';
765
+ } else {
766
+ const lbl = empty.querySelector('.label');
767
+ const hint = empty.querySelector('.hint');
768
+ if (lbl) lbl.textContent = 'No logs yet';
769
+ if (hint) hint.textContent = 'Logs will appear here automatically';
770
+ empty.style.display = 'flex';
771
+ }
772
+
773
+ // Render only the last N visible rows for performance
774
+ const MAX_RENDER = 5000;
775
+ const toRender = visible.length > MAX_RENDER ? visible.slice(-MAX_RENDER) : visible;
776
+ if (visible.length > MAX_RENDER) {
777
+ const info = document.createElement('div');
778
+ info.className = 'log-row';
779
+ info.style.cssText = 'color:var(--text-dim);font-size:10px;padding:6px 14px;text-align:center;font-style:italic';
780
+ info.textContent = `${visible.length - MAX_RENDER} older logs hidden for performance`;
781
+ list.appendChild(info);
782
+ }
783
+
784
+ const frag = document.createDocumentFragment();
785
+ toRender.forEach(l => frag.appendChild(buildLogRow(l)));
786
+ list.appendChild(frag);
787
+ list.scrollTop = list.scrollHeight;
788
+ }