reactoradar 1.6.3 → 1.6.5

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.
Files changed (5) hide show
  1. package/app.js +520 -45
  2. package/main.js +100 -9
  3. package/package.json +1 -1
  4. package/preload.js +3 -1
  5. package/styles.css +111 -0
package/app.js CHANGED
@@ -194,23 +194,37 @@ function clearActiveTab() {
194
194
  const memHT = $('memHeapTotal'); if (memHT) memHT.textContent = '—';
195
195
  const memN = $('memNative'); if (memN) memN.textContent = '—';
196
196
  break;
197
+ case 'native':
198
+ _nativeState.logs = [];
199
+ if ($('nativeBadge')) $('nativeBadge').textContent = '0';
200
+ const nativeList = $('nativeLogList');
201
+ if (nativeList) nativeList.innerHTML = '';
202
+ break;
197
203
  default:
198
204
  break;
199
205
  }
200
206
  }
201
207
 
202
- // Clear all (used by IPC clear-all-ui from menu Cmd+K)
208
+ // Clear all (used by IPC clear-all-ui from menu Cmd+K, and on device disconnect)
203
209
  function clearAll() {
210
+ // Cancel pending render batches
211
+ if (_consoleRAF) { cancelAnimationFrame(_consoleRAF); _consoleRAF = null; }
212
+ if (_netRAF) { cancelAnimationFrame(_netRAF); _netRAF = null; }
213
+ if (_storageRAF) { cancelAnimationFrame(_storageRAF); _storageRAF = null; }
214
+ // Console
204
215
  state.console.logs = [];
205
216
  _consolePending = [];
206
217
  _lastLogMsg = ''; _lastLogRow = null; _lastLogCount = 1;
218
+ // Network
207
219
  state.network.requests = {};
208
220
  state.network.order = [];
209
221
  state.network.selectedId = null;
210
222
  closeNetDetail();
223
+ // Redux
211
224
  state.redux.actions = [];
212
225
  state.redux.states = [];
213
226
  state.redux.selected = -1;
227
+ // Storage
214
228
  state.storage.entries = {};
215
229
  state.storage.keys = [];
216
230
  state.storage.selected = null;
@@ -226,6 +240,21 @@ function clearAll() {
226
240
  _nativeState.logs = [];
227
241
  const nativeList = $('nativeLogList');
228
242
  if (nativeList) nativeList.innerHTML = '';
243
+ // Performance
244
+ perfState.fps = [];
245
+ perfState.jsThread = [];
246
+ perfState.uiThread = [];
247
+ perfState.data = [];
248
+ const perfFPS = $('perfFPS'); if (perfFPS) perfFPS.textContent = '—';
249
+ const perfJS = $('perfJS'); if (perfJS) perfJS.textContent = '—';
250
+ const perfUI = $('perfUI'); if (perfUI) perfUI.textContent = '—';
251
+ clearPerfCanvas('perfFPSCanvas');
252
+ clearPerfCanvas('perfJSCanvas');
253
+ clearPerfCanvas('perfUICanvas');
254
+ // Memory
255
+ const memHU = $('memHeapUsed'); if (memHU) memHU.textContent = '—';
256
+ const memHT = $('memHeapTotal'); if (memHT) memHT.textContent = '—';
257
+ const memN = $('memNative'); if (memN) memN.textContent = '—';
229
258
  // Badges
230
259
  $('cBadge').textContent = '0';
231
260
  $('nBadge').textContent = '0';
@@ -241,6 +270,42 @@ function clearAll() {
241
270
  if (typeof renderGA4List === 'function') { renderGA4List(); renderGA4Summary(); }
242
271
  }
243
272
 
273
+ // Free heavy in-memory data without clearing the visible UI.
274
+ // Called on device disconnect and app quit to reduce memory footprint
275
+ // while keeping logs/network/redux visible for inspection.
276
+ function freeMemory() {
277
+ // Drop response/request bodies from network requests (biggest memory hog)
278
+ for (const id of state.network.order) {
279
+ const r = state.network.requests[id];
280
+ if (r) { r.responseBody = null; r.requestBody = null; }
281
+ }
282
+ // Trim console logs to a small tail (keep last 200 for reference)
283
+ if (state.console.logs.length > 200) {
284
+ state.console.logs = state.console.logs.slice(-200);
285
+ }
286
+ // Drop full Redux state snapshots (keep action metadata)
287
+ state.redux.states = [];
288
+ // Drop storage values (keep keys for reference)
289
+ for (const k in state.storage.entries) {
290
+ state.storage.entries[k] = null;
291
+ }
292
+ // Trim GA4 events
293
+ if (ga4State.events.length > 200) {
294
+ ga4State.events = ga4State.events.slice(-200);
295
+ }
296
+ // Trim native logs
297
+ if (_nativeState.logs.length > 200) {
298
+ _nativeState.logs = _nativeState.logs.slice(-200);
299
+ }
300
+ // Drop performance timeline data
301
+ perfState.data = [];
302
+ perfState.fps = [];
303
+ perfState.jsThread = [];
304
+ perfState.uiThread = [];
305
+ // Flush pending console batch
306
+ _consolePending = [];
307
+ }
308
+
244
309
  // ─── CDP Button ───────────────────────────────────────────────────────────────
245
310
  $('btnCDP')?.addEventListener('click', () => {
246
311
  // Tell main process to open the CDP DevTools window with the best available target
@@ -294,15 +359,38 @@ if (window.electronAPI) {
294
359
  handleMemoryEvent(event);
295
360
  });
296
361
 
297
- window.electronAPI.on('redux-connected', on => { updateDeviceBanner('redux', on); });
298
- window.electronAPI.on('network-connected', on => { updateDeviceBanner('network', on); });
299
- window.electronAPI.on('storage-connected', on => { updateDeviceBanner('storage', on); });
300
- window.electronAPI.on('react-dt-status', on => { updateDeviceBanner('reactDT', on); });
301
-
302
362
  window.electronAPI.on('clear-all-ui', clearAll);
303
363
 
364
+ // When all device bridges disconnect, release heavy memory but keep logs visible.
365
+ // Debounced to avoid data loss during hot reloads or flaky connections.
366
+ let _disconnectTimer = null;
367
+ window.electronAPI.on('device-all-disconnected', () => {
368
+ clearTimeout(_disconnectTimer);
369
+ _disconnectTimer = setTimeout(() => {
370
+ console.log('[App] All devices disconnected — freeing memory');
371
+ freeMemory();
372
+ }, 3000);
373
+ });
374
+ // Cancel pending free if a device reconnects
375
+ const _cancelDisconnectTimer = () => { clearTimeout(_disconnectTimer); _disconnectTimer = null; };
376
+ window.electronAPI.on('redux-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('redux', on); });
377
+ window.electronAPI.on('network-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('network', on); });
378
+ window.electronAPI.on('storage-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('storage', on); });
379
+ window.electronAPI.on('react-dt-status', on => { updateDeviceBanner('reactDT', on); });
380
+
304
381
  // Cmd+F — focus the search input for the active panel
305
382
  function _handleFind() {
383
+ // If network detail is open, focus the detail search
384
+ if (state.activePanel === 'network' && state.network.selectedId) {
385
+ const wrap = $('detailSearchWrap');
386
+ const input = $('detailSearchInput');
387
+ if (wrap && input) {
388
+ wrap.style.display = 'flex';
389
+ input.focus();
390
+ input.select();
391
+ return;
392
+ }
393
+ }
306
394
  const searchMap = {
307
395
  console: 'consoleSearch',
308
396
  network: 'netSearchInput',
@@ -330,8 +418,9 @@ if (window.electronAPI) {
330
418
  }
331
419
  });
332
420
 
333
- window.electronAPI.on('app-version', (version) => {
421
+ window.electronAPI.on('app-version', (version, isPackaged) => {
334
422
  state._appVersion = version;
423
+ state._isPackaged = !!isPackaged;
335
424
  // Update anywhere the version is displayed
336
425
  document.querySelectorAll('#aboutVersion').forEach(el => el.textContent = 'v' + version);
337
426
  });
@@ -366,6 +455,7 @@ function _applyUpdateBanner() {
366
455
  if (!info) return;
367
456
  const { current, latest, autoUpdate } = info;
368
457
  const downloaded = state._updateDownloaded;
458
+ const targetVersion = downloaded || latest;
369
459
 
370
460
  const el = $('aboutVersion');
371
461
  if (el) {
@@ -376,39 +466,94 @@ function _applyUpdateBanner() {
376
466
  }
377
467
  }
378
468
 
379
- // Remove old button if state changed
469
+ // Remove old buttons if state changed
380
470
  const oldBtn = $('updateBtn');
381
471
  if (oldBtn && downloaded && !oldBtn.dataset.isRestart) oldBtn.parentElement?.remove();
472
+ const oldChangelog = $('changelogBtn');
473
+ if (oldChangelog && downloaded && !oldChangelog.dataset.updated) oldChangelog.remove();
474
+
475
+ const aboutEl = document.querySelector('.settings-about');
476
+ if (!aboutEl) return;
477
+
478
+ // Add "What's new?" link
479
+ if (!$('changelogBtn')) {
480
+ const link = document.createElement('div');
481
+ link.style.cssText = 'margin-top:6px;text-align:center';
482
+ link.innerHTML = `<span id="changelogBtn" class="about-link" style="font-size:10px;cursor:pointer" data-updated="${downloaded ? '1' : ''}">What's new in v${targetVersion}?</span>`;
483
+ aboutEl.appendChild(link);
484
+ $('changelogBtn')?.addEventListener('click', () => _showChangelog(targetVersion));
485
+ }
382
486
 
383
- // Add update button in settings if not already there
487
+ // Add update button
384
488
  if (!$('updateBtn')) {
385
- const aboutEl = document.querySelector('.settings-about');
386
- if (aboutEl) {
387
- const btn = document.createElement('div');
388
- btn.style.cssText = 'margin-top:10px';
389
- if (downloaded) {
390
- // Update is downloaded — show "Restart & Update"
391
- btn.innerHTML = '<button id="updateBtn" data-is-restart="1" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Restart & Update to v' + downloaded + '</button>';
392
- aboutEl.appendChild(btn);
393
- $('updateBtn')?.addEventListener('click', () => {
394
- window.electronAPI?.installUpdate();
395
- });
396
- } else if (autoUpdate) {
397
- // Auto-update in progress — show downloading status
398
- btn.innerHTML = '<button id="updateBtn" class="tb-btn" style="font-size:11px;padding:6px 16px;opacity:0.7" disabled>Downloading v' + latest + '...</button>';
399
- aboutEl.appendChild(btn);
400
- } else {
401
- // npx/manual — show download link
402
- btn.innerHTML = '<button id="updateBtn" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Download v' + latest + '</button>';
403
- aboutEl.appendChild(btn);
404
- $('updateBtn')?.addEventListener('click', () => {
405
- window.electronAPI?.openExternal('https://github.com/sharanagouda/reactoradar/releases');
406
- });
407
- }
489
+ const btn = document.createElement('div');
490
+ btn.style.cssText = 'margin-top:8px;text-align:center';
491
+ if (downloaded) {
492
+ btn.innerHTML = '<button id="updateBtn" data-is-restart="1" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Restart & Update to v' + downloaded + '</button>';
493
+ aboutEl.appendChild(btn);
494
+ $('updateBtn')?.addEventListener('click', () => window.electronAPI?.installUpdate());
495
+ } else if (autoUpdate) {
496
+ btn.innerHTML = '<button id="updateBtn" class="tb-btn" style="font-size:11px;padding:6px 16px;opacity:0.7" disabled>Downloading v' + latest + '...</button>';
497
+ aboutEl.appendChild(btn);
498
+ } else {
499
+ btn.innerHTML = '<button id="updateBtn" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Download v' + latest + '</button>';
500
+ aboutEl.appendChild(btn);
501
+ $('updateBtn')?.addEventListener('click', () => window.electronAPI?.openExternal('https://github.com/sharanagouda/reactoradar/releases'));
408
502
  }
409
503
  }
410
504
  }
411
505
 
506
+ async function _showChangelog(version) {
507
+ if (!version || typeof version !== 'string') return;
508
+
509
+ // Remove existing modal
510
+ $('changelogModal')?.remove();
511
+
512
+ const safeVersion = esc(version);
513
+ const modal = document.createElement('div');
514
+ modal.id = 'changelogModal';
515
+ modal.className = 'changelog-modal-overlay';
516
+ modal.innerHTML = `
517
+ <div class="changelog-modal">
518
+ <div class="changelog-header">
519
+ <span class="changelog-title">What's New in v${safeVersion}</span>
520
+ <button class="changelog-close" id="changelogClose">&times;</button>
521
+ </div>
522
+ <div class="changelog-body" id="changelogBody">
523
+ <div style="color:var(--text-dim);padding:20px;text-align:center">Loading release notes...</div>
524
+ </div>
525
+ </div>`;
526
+ document.body.appendChild(modal);
527
+
528
+ // Close handlers
529
+ $('changelogClose')?.addEventListener('click', () => modal.remove());
530
+ modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
531
+
532
+ // Fetch changelog
533
+ try {
534
+ const notes = await window.electronAPI?.fetchChangelog(version);
535
+ const body = $('changelogBody');
536
+ if (!body) return;
537
+ if (!notes || typeof notes !== 'string') {
538
+ body.innerHTML = '<div style="color:var(--text-dim);padding:20px;text-align:center">No release notes available.</div>';
539
+ return;
540
+ }
541
+ if (body && notes) {
542
+ // Simple markdown-like rendering
543
+ body.innerHTML = notes
544
+ .replace(/^### (.+)$/gm, '<h3 style="color:var(--accent);font-size:12px;font-weight:700;margin:12px 0 6px">$1</h3>')
545
+ .replace(/^## (.+)$/gm, '<h2 style="color:var(--text);font-size:14px;font-weight:700;margin:16px 0 8px">$1</h2>')
546
+ .replace(/^- \*\*(.+?)\*\*(.*)$/gm, '<div style="margin:3px 0;font-size:11px;line-height:1.6"><b style="color:var(--text)">$1</b><span style="color:var(--text-dim)">$2</span></div>')
547
+ .replace(/^- (.+)$/gm, '<div style="margin:3px 0;font-size:11px;line-height:1.6;color:var(--text-mid)">• $1</div>')
548
+ .replace(/`([^`]+)`/g, '<code style="background:var(--bg3);padding:1px 4px;border-radius:3px;color:var(--accent);font-size:10px">$1</code>')
549
+ .replace(/\n\n/g, '<br/>');
550
+ }
551
+ } catch {
552
+ const body = $('changelogBody');
553
+ if (body) body.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Could not fetch release notes</div>';
554
+ }
555
+ }
556
+
412
557
  function updateDeviceBanner(service, connected) {
413
558
  state.connections[service] = connected;
414
559
  const el = $('deviceStatus');
@@ -755,6 +900,41 @@ window.electronAPI?.on('console-event', addConsoleLog);
755
900
  // ─── Object Tree Renderer (Chrome DevTools-like) ─────────────────────────────
756
901
  // Builds interactive, collapsible DOM nodes for objects/arrays.
757
902
 
903
+ // Collect all entries for an object: own data properties + prototype getter values.
904
+ // Getter-derived keys use the clean name (e.g. "deliveryId") and skip backing
905
+ // fields (e.g. "_deliveryId") so the log output mirrors the model's public API.
906
+ function collectEntries(val) {
907
+ if (Array.isArray(val)) return val.map((v, i) => [i, v]);
908
+
909
+ const result = {};
910
+ const getterKeys = new Set();
911
+
912
+ // 1. Walk prototype chain and invoke getters
913
+ let proto = Object.getPrototypeOf(val);
914
+ while (proto && proto !== Object.prototype) {
915
+ const descs = Object.getOwnPropertyDescriptors(proto);
916
+ for (const [k, desc] of Object.entries(descs)) {
917
+ if (k === 'constructor') continue;
918
+ if (desc.get && !(k in result)) {
919
+ try { result[k] = desc.get.call(val); } catch { /* skip broken getters */ }
920
+ getterKeys.add(k);
921
+ }
922
+ }
923
+ proto = Object.getPrototypeOf(proto);
924
+ }
925
+
926
+ // 2. Add own data properties, but skip backing fields whose getter is present.
927
+ // Convention: getter "foo" backs "_foo"; if "foo" was collected, skip "_foo".
928
+ const ownKeys = Object.keys(val);
929
+ for (const k of ownKeys) {
930
+ const clean = k.startsWith('_') ? k.slice(1) : null;
931
+ if (clean && getterKeys.has(clean)) continue; // skip _backing field
932
+ if (!(k in result)) result[k] = val[k];
933
+ }
934
+
935
+ return Object.entries(result);
936
+ }
937
+
758
938
  function objPreview(val, maxLen) {
759
939
  maxLen = maxLen || 80;
760
940
  if (val === null) return 'null';
@@ -772,16 +952,16 @@ function objPreview(val, maxLen) {
772
952
  return `(${val.length}) [${items.join(', ')}${suffix}]`;
773
953
  }
774
954
  if (typeof val === 'object') {
775
- const keys = Object.keys(val);
776
- if (keys.length === 0) return '{}';
955
+ const entries = collectEntries(val);
956
+ if (entries.length === 0) return '{}';
777
957
  const items = [];
778
958
  let len = 2;
779
- for (let i = 0; i < keys.length && len < maxLen; i++) {
780
- const s = `${keys[i]}: ${primitivePreview(val[keys[i]])}`;
959
+ for (let i = 0; i < entries.length && len < maxLen; i++) {
960
+ const s = `${entries[i][0]}: ${primitivePreview(entries[i][1])}`;
781
961
  len += s.length + 2;
782
962
  items.push(s);
783
963
  }
784
- const suffix = items.length < keys.length ? ', ...' : '';
964
+ const suffix = items.length < entries.length ? ', ...' : '';
785
965
  return `{${items.join(', ')}${suffix}}`;
786
966
  }
787
967
  return primitivePreview(val);
@@ -850,7 +1030,7 @@ function createTreeNode(key, val, startCollapsed) {
850
1030
  function populateChildren() {
851
1031
  if (populated) return;
852
1032
  populated = true;
853
- const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val);
1033
+ const entries = collectEntries(val);
854
1034
  entries.forEach(([k, v]) => {
855
1035
  children.appendChild(createTreeNode(k, v, true));
856
1036
  });
@@ -1255,6 +1435,13 @@ function initNetworkPanel() {
1255
1435
  <div class="net-detail-pane" id="netDetailPane">
1256
1436
  <div class="net-detail-bar">
1257
1437
  <div class="detail-tabs" id="netDetailTabs"></div>
1438
+ <div class="detail-search-wrap" id="detailSearchWrap" style="display:none">
1439
+ <input id="detailSearchInput" class="detail-search-input" placeholder="Search key or value..." />
1440
+ <span id="detailSearchCount" class="detail-search-count"></span>
1441
+ <button class="detail-search-nav" id="detailSearchPrev" title="Previous">&#9650;</button>
1442
+ <button class="detail-search-nav" id="detailSearchNext" title="Next">&#9660;</button>
1443
+ <button class="detail-search-close" id="detailSearchClose" title="Close search">&times;</button>
1444
+ </div>
1258
1445
  <button class="detail-close" id="netDetailClose" title="Close">&times;</button>
1259
1446
  </div>
1260
1447
  <div class="detail-content" id="netDetailContent"></div>
@@ -1408,6 +1595,121 @@ function initNetworkPanel() {
1408
1595
  // Close detail button
1409
1596
  $('netDetailClose').addEventListener('click', closeNetDetail);
1410
1597
 
1598
+ // Detail panel search
1599
+ let _detailSearchMatches = [];
1600
+ let _detailSearchIdx = -1;
1601
+
1602
+ function _detailSearch() {
1603
+ const term = $('detailSearchInput')?.value?.trim().toLowerCase();
1604
+ const body = $('netDetailContent');
1605
+ if (!body || !term) { _detailClearSearch(); return; }
1606
+
1607
+ // Remove old highlights
1608
+ body.querySelectorAll('.detail-search-hl').forEach(el => {
1609
+ const parent = el.parentNode;
1610
+ parent.replaceChild(document.createTextNode(el.textContent), el);
1611
+ parent.normalize();
1612
+ });
1613
+
1614
+ _detailSearchMatches = [];
1615
+ _detailSearchIdx = -1;
1616
+
1617
+ // Walk all text nodes and highlight matches
1618
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null);
1619
+ const textNodes = [];
1620
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
1621
+
1622
+ textNodes.forEach(node => {
1623
+ const text = node.textContent;
1624
+ const lower = text.toLowerCase();
1625
+ if (!lower.includes(term)) return;
1626
+
1627
+ const frag = document.createDocumentFragment();
1628
+ let lastIdx = 0;
1629
+ let idx;
1630
+ while ((idx = lower.indexOf(term, lastIdx)) !== -1) {
1631
+ if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
1632
+ const hl = document.createElement('span');
1633
+ hl.className = 'detail-search-hl';
1634
+ hl.textContent = text.slice(idx, idx + term.length);
1635
+ _detailSearchMatches.push(hl);
1636
+ frag.appendChild(hl);
1637
+ lastIdx = idx + term.length;
1638
+ }
1639
+ if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
1640
+ node.parentNode.replaceChild(frag, node);
1641
+ });
1642
+
1643
+ // Update count
1644
+ const countEl = $('detailSearchCount');
1645
+ if (countEl) countEl.textContent = _detailSearchMatches.length ? `${_detailSearchMatches.length} found` : 'No match';
1646
+
1647
+ // Navigate to first match
1648
+ if (_detailSearchMatches.length) _detailNavTo(0);
1649
+ }
1650
+
1651
+ function _detailNavTo(idx) {
1652
+ // Remove active highlight from previous
1653
+ if (_detailSearchIdx >= 0 && _detailSearchMatches[_detailSearchIdx]) {
1654
+ _detailSearchMatches[_detailSearchIdx].classList.remove('active');
1655
+ }
1656
+ _detailSearchIdx = idx;
1657
+ const el = _detailSearchMatches[idx];
1658
+ if (!el) return;
1659
+ el.classList.add('active');
1660
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
1661
+ // Update count
1662
+ const countEl = $('detailSearchCount');
1663
+ if (countEl) countEl.textContent = `${idx + 1}/${_detailSearchMatches.length}`;
1664
+ }
1665
+
1666
+ function _detailClearSearch() {
1667
+ const body = $('netDetailContent');
1668
+ if (body) {
1669
+ body.querySelectorAll('.detail-search-hl').forEach(el => {
1670
+ const parent = el.parentNode;
1671
+ parent.replaceChild(document.createTextNode(el.textContent), el);
1672
+ parent.normalize();
1673
+ });
1674
+ }
1675
+ _detailSearchMatches = [];
1676
+ _detailSearchIdx = -1;
1677
+ const countEl = $('detailSearchCount');
1678
+ if (countEl) countEl.textContent = '';
1679
+ }
1680
+
1681
+ $('detailSearchInput')?.addEventListener('input', () => {
1682
+ clearTimeout($('detailSearchInput')._debounce);
1683
+ $('detailSearchInput')._debounce = setTimeout(_detailSearch, 200);
1684
+ });
1685
+ $('detailSearchInput')?.addEventListener('keydown', (e) => {
1686
+ if (e.key === 'Enter') {
1687
+ e.preventDefault();
1688
+ if (!_detailSearchMatches.length) return;
1689
+ const next = e.shiftKey
1690
+ ? (_detailSearchIdx - 1 + _detailSearchMatches.length) % _detailSearchMatches.length
1691
+ : (_detailSearchIdx + 1) % _detailSearchMatches.length;
1692
+ _detailNavTo(next);
1693
+ }
1694
+ if (e.key === 'Escape') {
1695
+ _detailClearSearch();
1696
+ $('detailSearchWrap').style.display = 'none';
1697
+ }
1698
+ });
1699
+ $('detailSearchNext')?.addEventListener('click', () => {
1700
+ if (!_detailSearchMatches.length) return;
1701
+ _detailNavTo((_detailSearchIdx + 1) % _detailSearchMatches.length);
1702
+ });
1703
+ $('detailSearchPrev')?.addEventListener('click', () => {
1704
+ if (!_detailSearchMatches.length) return;
1705
+ _detailNavTo((_detailSearchIdx - 1 + _detailSearchMatches.length) % _detailSearchMatches.length);
1706
+ });
1707
+ $('detailSearchClose')?.addEventListener('click', () => {
1708
+ _detailClearSearch();
1709
+ $('detailSearchInput').value = '';
1710
+ $('detailSearchWrap').style.display = 'none';
1711
+ });
1712
+
1411
1713
  buildNetHeader();
1412
1714
  }
1413
1715
 
@@ -1828,14 +2130,38 @@ function closeNetDetail() {
1828
2130
  );
1829
2131
  }
1830
2132
 
2133
+ function _estimateSize(val) {
2134
+ if (val == null) return 0;
2135
+ if (typeof val === 'string') return val.length;
2136
+ try { return JSON.stringify(val).length; } catch { return 0; }
2137
+ }
2138
+
2139
+ function _formatBytes(bytes) {
2140
+ if (bytes < 1024) return `${bytes}B`;
2141
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`;
2142
+ return `${(bytes / 1048576).toFixed(1)}MB`;
2143
+ }
2144
+
1831
2145
  function renderNetDetailTabs(r) {
1832
2146
  const tabs = $('netDetailTabs');
1833
2147
  tabs.innerHTML = '';
1834
- ['Headers', 'Request', 'Preview', 'Response'].forEach(label => {
1835
- const key = label.toLowerCase();
2148
+
2149
+ const tabDefs = [
2150
+ { label: 'Headers', key: 'headers' },
2151
+ { label: 'Request', key: 'request', sizeFrom: 'requestBody' },
2152
+ { label: 'Preview', key: 'preview', sizeFrom: 'responseBody' },
2153
+ { label: 'Response', key: 'response', sizeFrom: 'responseBody' },
2154
+ ];
2155
+
2156
+ tabDefs.forEach(({ label, key, sizeFrom }) => {
1836
2157
  const btn = document.createElement('button');
1837
2158
  btn.className = 'detail-tab' + (r._tab === key ? ' active' : '');
1838
- btn.textContent = label;
2159
+ let text = label;
2160
+ if (sizeFrom && r[sizeFrom]) {
2161
+ const size = _estimateSize(r[sizeFrom]);
2162
+ if (size > 0) text += ` (${_formatBytes(size)})`;
2163
+ }
2164
+ btn.textContent = text;
1839
2165
  btn.addEventListener('click', () => {
1840
2166
  r._tab = key;
1841
2167
  tabs.querySelectorAll('.detail-tab').forEach(b => b.classList.remove('active'));
@@ -1844,6 +2170,12 @@ function renderNetDetailTabs(r) {
1844
2170
  });
1845
2171
  tabs.appendChild(btn);
1846
2172
  });
2173
+
2174
+ // Show search box for Preview/Response tabs
2175
+ const searchWrap = $('detailSearchWrap');
2176
+ if (searchWrap) {
2177
+ searchWrap.style.display = (r._tab === 'preview' || r._tab === 'response' || r._tab === 'headers') ? 'flex' : 'none';
2178
+ }
1847
2179
  }
1848
2180
 
1849
2181
  function renderNetDetailContent(r) {
@@ -3036,10 +3368,18 @@ function initNativeLogsPanel() {
3036
3368
  <div class="native-log-list" id="nativeLogList"></div>
3037
3369
  </div>`;
3038
3370
 
3039
- // Connect buttons
3040
- $('nativeConnectAndroid')?.addEventListener('click', () => window.electronAPI?.startNativeLogs('android'));
3041
- $('nativeConnectIOSSim')?.addEventListener('click', () => window.electronAPI?.startNativeLogs('ios-sim'));
3042
- $('nativeConnectIOSDevice')?.addEventListener('click', () => window.electronAPI?.startNativeLogs('ios-device'));
3371
+ // Connect buttons — auto-enable tab when user clicks connect
3372
+ function _enableNativeTab() {
3373
+ const vis = getTabVisibility();
3374
+ if (!vis['native']) {
3375
+ vis['native'] = true;
3376
+ setTabVisibility(vis);
3377
+ applyTabVisibility();
3378
+ }
3379
+ }
3380
+ $('nativeConnectAndroid')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('android'); });
3381
+ $('nativeConnectIOSSim')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('ios-sim'); });
3382
+ $('nativeConnectIOSDevice')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('ios-device'); });
3043
3383
  $('nativeDisconnect')?.addEventListener('click', () => window.electronAPI?.stopNativeLogs());
3044
3384
 
3045
3385
  // Clear buttons (toolbar + logs area)
@@ -3649,6 +3989,27 @@ function initSettingsPanel() {
3649
3989
  <b style="color:var(--text)">5.</b> <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar remove</code> to uninstall
3650
3990
  </div>
3651
3991
  </div>
3992
+ <div class="settings-section">
3993
+ <div class="settings-section-title">Version History</div>
3994
+ <div class="settings-hint" style="margin-bottom:4px">Roll back to a previous version if you notice issues.</div>
3995
+ <div class="settings-hint rollback-steps" id="rollbackSteps" style="margin-bottom:10px;line-height:1.8;font-size:10px">
3996
+ <b style="color:var(--text)">How to roll back:</b><br/>
3997
+ <span id="rollbackDmgSteps" style="display:none">
3998
+ <b style="color:var(--text)">1.</b> Click <b>Download</b> on the version you want<br/>
3999
+ <b style="color:var(--text)">2.</b> Open the downloaded <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">.dmg</code> file<br/>
4000
+ <b style="color:var(--text)">3.</b> Drag the app to Applications (replace existing)<br/>
4001
+ <b style="color:var(--text)">4.</b> Relaunch ReactoRadar
4002
+ </span>
4003
+ <span id="rollbackNpmSteps" style="display:none">
4004
+ <b style="color:var(--text)">1.</b> Run <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">npx reactoradar@&lt;version&gt;</code> e.g. <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">npx reactoradar@1.6.4</code><br/>
4005
+ <b style="color:var(--text)">2.</b> Or pin globally: <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">npm i -g reactoradar@1.6.4</code><br/>
4006
+ <b style="color:var(--text)">3.</b> Run <code style="color:var(--accent);background:var(--bg3);padding:1px 4px;border-radius:3px">reactoradar</code> to launch
4007
+ </span>
4008
+ </div>
4009
+ <div id="versionHistoryList" class="version-history-list">
4010
+ <div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Loading versions...</div>
4011
+ </div>
4012
+ </div>
3652
4013
  </div>
3653
4014
  </div>
3654
4015
  </div>`;
@@ -3755,6 +4116,120 @@ function initSettingsPanel() {
3755
4116
 
3756
4117
  // Apply update banner if update info arrived before settings panel was created
3757
4118
  _applyUpdateBanner();
4119
+
4120
+ // Fetch and render version history for rollback
4121
+ _loadVersionHistory();
4122
+ }
4123
+
4124
+ function _loadVersionHistory() {
4125
+ const container = $('versionHistoryList');
4126
+ if (!container) return;
4127
+ if (!window.electronAPI || typeof window.electronAPI.fetchReleases !== 'function') {
4128
+ container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Version history not available.</div>';
4129
+ return;
4130
+ }
4131
+
4132
+ // Show appropriate rollback steps based on install type
4133
+ const isPackaged = !!state._isPackaged;
4134
+ const dmgSteps = $('rollbackDmgSteps');
4135
+ const npmSteps = $('rollbackNpmSteps');
4136
+ if (dmgSteps) dmgSteps.style.display = isPackaged ? '' : 'none';
4137
+ if (npmSteps) npmSteps.style.display = isPackaged ? 'none' : '';
4138
+
4139
+ window.electronAPI.fetchReleases().then(releases => {
4140
+ if (!Array.isArray(releases) || releases.length === 0) {
4141
+ container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Could not load versions.</div>';
4142
+ return;
4143
+ }
4144
+
4145
+ const currentVersion = state._appVersion || '';
4146
+ container.innerHTML = '';
4147
+
4148
+ releases.forEach(r => {
4149
+ if (!r || !r.version) return; // skip malformed entries
4150
+
4151
+ const isCurrent = r.version === currentVersion;
4152
+ const row = document.createElement('div');
4153
+ row.className = 'version-row' + (isCurrent ? ' version-current' : '');
4154
+
4155
+ // Safe date formatting
4156
+ let dateStr = '';
4157
+ if (r.date) {
4158
+ try {
4159
+ const d = new Date(r.date);
4160
+ if (!isNaN(d.getTime())) {
4161
+ dateStr = d.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' });
4162
+ }
4163
+ } catch { /* skip bad date */ }
4164
+ }
4165
+
4166
+ // Build action button based on install type
4167
+ let actionHtml = '';
4168
+ if (isCurrent) {
4169
+ actionHtml = '<span class="version-installed">Installed</span>';
4170
+ } else if (isPackaged) {
4171
+ actionHtml = '<button class="version-install-btn" title="Download .dmg for this version">Download</button>';
4172
+ } else {
4173
+ actionHtml = `<button class="version-npm-btn" title="Copy npm install command">npx @${esc(r.version)}</button>`;
4174
+ }
4175
+
4176
+ row.innerHTML = `
4177
+ <div class="version-info">
4178
+ <span class="version-tag">v${esc(r.version)}${r.prerelease ? ' <span class="version-pre">pre</span>' : ''}${isCurrent ? ' <span class="version-badge">current</span>' : ''}</span>
4179
+ <span class="version-date">${esc(dateStr)}</span>
4180
+ </div>
4181
+ <div class="version-actions">
4182
+ ${actionHtml}
4183
+ <button class="version-notes-btn" title="View release notes">Notes</button>
4184
+ </div>`;
4185
+
4186
+ // DMG download button — opens the .dmg asset or release page
4187
+ const installBtn = row.querySelector('.version-install-btn');
4188
+ if (installBtn) {
4189
+ installBtn.addEventListener('click', () => {
4190
+ const url = r.dmgUrl || r.htmlUrl || '';
4191
+ if (url) {
4192
+ window.electronAPI.openExternal(url);
4193
+ }
4194
+ });
4195
+ }
4196
+
4197
+ // NPM copy button — copies the npx command to clipboard
4198
+ const npmBtn = row.querySelector('.version-npm-btn');
4199
+ if (npmBtn) {
4200
+ npmBtn.addEventListener('click', () => {
4201
+ const cmd = `npx reactoradar@${r.version}`;
4202
+ navigator.clipboard.writeText(cmd).then(() => {
4203
+ const orig = npmBtn.textContent;
4204
+ npmBtn.textContent = 'Copied!';
4205
+ npmBtn.style.color = 'var(--green)';
4206
+ setTimeout(() => { npmBtn.textContent = orig; npmBtn.style.color = ''; }, 2000);
4207
+ }).catch(() => {});
4208
+ });
4209
+ }
4210
+
4211
+ // Notes button — show changelog in modal
4212
+ const notesBtn = row.querySelector('.version-notes-btn');
4213
+ if (notesBtn) {
4214
+ notesBtn.addEventListener('click', () => {
4215
+ if (r.version && typeof _showChangelog === 'function') {
4216
+ _showChangelog(r.version);
4217
+ }
4218
+ });
4219
+ }
4220
+
4221
+ container.appendChild(row);
4222
+ });
4223
+
4224
+ // If no rows were rendered (all entries were malformed)
4225
+ if (container.children.length === 0) {
4226
+ container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">No versions found.</div>';
4227
+ }
4228
+ }).catch(() => {
4229
+ if (container) {
4230
+ container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Could not load versions. Check your internet connection.</div>';
4231
+ }
4232
+ });
3758
4233
  }
3759
4234
 
3760
4235
  // ─── Memory Monitor ──────────────────────────────────────────────────────────
package/main.js CHANGED
@@ -35,6 +35,7 @@ function _send(channel, ...args) {
35
35
  let reduxClients = new Set();
36
36
  let storageClients = new Set();
37
37
  let networkClients = new Set();
38
+ const _bridgeServers = []; // track bridge WSS instances for cleanup on quit
38
39
 
39
40
  // ─── Set dock icon ASAP (before app ready) ──────────────────────────────────
40
41
  const _appIcon = nativeImage.createFromPath(path.join(__dirname, 'ReactoRadar.png'));
@@ -74,15 +75,16 @@ if (gotLock) app.whenReady().then(async () => {
74
75
 
75
76
  await createMainWindow();
76
77
 
77
- // Send version to renderer — try package.json, fallback to app.getVersion()
78
+ // Send version + install type to renderer — try package.json, fallback to app.getVersion()
78
79
  let appVersion;
79
80
  try { appVersion = require('./package.json').version; } catch { appVersion = app.getVersion(); }
81
+ const isPackaged = app.isPackaged;
80
82
  // Send multiple times to ensure renderer catches it (covers race conditions)
81
83
  mainWindow.webContents.on('did-finish-load', () => {
82
84
  // Send immediately + retries
83
- _send('app-version', appVersion);
85
+ _send('app-version', appVersion, isPackaged);
84
86
  [500, 2000, 5000].forEach(delay => {
85
- setTimeout(() => _send('app-version', appVersion), delay);
87
+ setTimeout(() => _send('app-version', appVersion, isPackaged), delay);
86
88
  });
87
89
  });
88
90
 
@@ -102,12 +104,27 @@ app.on('window-all-closed', () => {
102
104
 
103
105
  app.on('before-quit', () => {
104
106
  _forceQuit = true;
107
+ // Free renderer memory before shutdown (logs are not cleared — user may still see them briefly)
108
+ _send('device-all-disconnected');
109
+ // Close CDP DevTools window if open
110
+ if (devtoolsWindow && !devtoolsWindow.isDestroyed()) {
111
+ devtoolsWindow.destroy();
112
+ devtoolsWindow = null;
113
+ }
105
114
  // Close all WS servers gracefully
106
115
  if (reactDTServer) {
107
116
  reactDTServer.close();
108
117
  reactDTClients.forEach(ws => ws.close());
109
118
  reactDTClients.clear();
110
119
  }
120
+ // Close bridge servers and disconnect all clients
121
+ _bridgeServers.forEach(wss => {
122
+ wss.clients.forEach(ws => ws.close());
123
+ wss.close();
124
+ });
125
+ reduxClients.clear();
126
+ storageClients.clear();
127
+ networkClients.clear();
111
128
  });
112
129
 
113
130
  app.on('activate', () => {
@@ -336,6 +353,10 @@ function startReactDevToolsServer() {
336
353
  });
337
354
  });
338
355
 
356
+ ws.on('error', (err) => {
357
+ console.warn(`[ReactDT] Client error:`, err.message);
358
+ });
359
+
339
360
  ws.on('close', () => {
340
361
  reactDTClients.delete(ws);
341
362
  console.log(`[ReactDT] Client disconnected (total: ${reactDTClients.size})`);
@@ -380,6 +401,7 @@ function startBridgeServers() {
380
401
  function startBridge(port, name, clients, onEvent) {
381
402
  try {
382
403
  const wss = new WebSocketServer({ port });
404
+ _bridgeServers.push(wss);
383
405
  wss.on('error', (err) => {
384
406
  if (err.code === 'EADDRINUSE') {
385
407
  console.error(`[${name}] Port ${port} is already in use — another ReactoRadar or debugger may be running.`);
@@ -404,10 +426,19 @@ function startBridge(port, name, clients, onEvent) {
404
426
  }
405
427
  });
406
428
 
429
+ ws.on('error', (err) => {
430
+ console.warn(`[${name}] Client error:`, err.message);
431
+ });
432
+
407
433
  ws.on('close', () => {
408
434
  clients.delete(ws);
409
435
  if (clients.size === 0) {
410
436
  _send(`${name}-connected`, false);
437
+ // When every bridge has zero clients, tell the renderer to clear old data
438
+ if (reduxClients.size === 0 && storageClients.size === 0 && networkClients.size === 0) {
439
+ console.log('[Bridge] All device connections closed — sending clear signal');
440
+ _send('device-all-disconnected');
441
+ }
411
442
  }
412
443
  });
413
444
  });
@@ -588,6 +619,62 @@ function setupIPC() {
588
619
  }
589
620
  });
590
621
 
622
+ ipcMain.handle('fetch-changelog', async (_, version) => {
623
+ if (!version || typeof version !== 'string' || !/^[\d]+\.[\d]+\.[\d]+/.test(version)) {
624
+ return 'Invalid version.';
625
+ }
626
+ return new Promise((resolve) => {
627
+ https.get(`https://api.github.com/repos/sharanagouda/reactoradar/releases/tags/v${version}`, {
628
+ headers: { 'User-Agent': 'ReactoRadar', 'Accept': 'application/vnd.github.v3+json' }
629
+ }, (res) => {
630
+ let data = '';
631
+ res.on('data', d => data += d);
632
+ res.on('end', () => {
633
+ try { resolve(JSON.parse(data).body || 'No release notes available.'); }
634
+ catch { resolve('Could not fetch release notes.'); }
635
+ });
636
+ }).on('error', () => resolve('Could not connect to GitHub.'));
637
+ });
638
+ });
639
+
640
+ // Fetch all releases for version history / rollback
641
+ ipcMain.handle('fetch-releases', async () => {
642
+ return new Promise((resolve) => {
643
+ https.get('https://api.github.com/repos/sharanagouda/reactoradar/releases?per_page=20', {
644
+ headers: { 'User-Agent': 'ReactoRadar', 'Accept': 'application/vnd.github.v3+json' }
645
+ }, (res) => {
646
+ let data = '';
647
+ res.on('data', d => data += d);
648
+ res.on('end', () => {
649
+ try {
650
+ const releases = JSON.parse(data);
651
+ if (!Array.isArray(releases)) { resolve([]); return; }
652
+ const mapped = [];
653
+ for (const r of releases) {
654
+ if (!r || typeof r !== 'object') continue;
655
+ const tag = r.tag_name || '';
656
+ const version = tag.replace(/^v/, '');
657
+ if (!version) continue; // skip entries with no version
658
+ const assets = Array.isArray(r.assets) ? r.assets : [];
659
+ mapped.push({
660
+ version,
661
+ tag,
662
+ name: r.name || tag || version,
663
+ date: r.published_at || null,
664
+ prerelease: !!r.prerelease,
665
+ body: r.body || '',
666
+ dmgUrl: (assets.find(a => a && a.name && a.name.endsWith('.dmg')) || {}).browser_download_url || '',
667
+ zipUrl: (assets.find(a => a && a.name && a.name.endsWith('.zip')) || {}).browser_download_url || '',
668
+ htmlUrl: r.html_url || '',
669
+ });
670
+ }
671
+ resolve(mapped);
672
+ } catch { resolve([]); }
673
+ });
674
+ }).on('error', () => resolve([]));
675
+ });
676
+ });
677
+
591
678
  ipcMain.on('install-update', () => {
592
679
  if (autoUpdater) {
593
680
  autoUpdater.quitAndInstall(false, true);
@@ -639,10 +726,9 @@ function setupIPC() {
639
726
  });
640
727
 
641
728
  ipcMain.on('start-native-logs', (_, platform) => {
642
- // Kill existing process group
729
+ // Kill existing process
643
730
  if (_nativeLogProcess) {
644
- try { process.kill(-_nativeLogProcess.pid); } catch {}
645
- try { _nativeLogProcess.kill(); } catch {}
731
+ try { _nativeLogProcess.kill('SIGTERM'); } catch {}
646
732
  _nativeLogProcess = null;
647
733
  }
648
734
 
@@ -650,9 +736,9 @@ function setupIPC() {
650
736
  let cmd, args;
651
737
 
652
738
  if (platform === 'android') {
653
- // adb logcat — filter for app-relevant logs
739
+ // adb logcat — show only new logs from now (not historical buffer)
654
740
  cmd = 'adb';
655
- args = ['logcat', '-v', 'threadtime', '*:W']; // Warnings and above
741
+ args = ['logcat', '-v', 'threadtime', '-T', '1', '*:W']; // -T 1 = last 1 line then real-time
656
742
  } else if (platform === 'ios-sim') {
657
743
  // xcrun simctl for iOS Simulator — use syslog style for parseable output
658
744
  cmd = 'xcrun';
@@ -667,9 +753,10 @@ function setupIPC() {
667
753
  }
668
754
 
669
755
  try {
670
- _nativeLogProcess = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: true });
756
+ _nativeLogProcess = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
671
757
 
672
758
  _send('native-status', { connected: true, platform });
759
+ console.log(`[NativeLogs] Started ${cmd} ${args.join(' ')} (pid: ${_nativeLogProcess.pid})`);
673
760
 
674
761
  let buffer = '';
675
762
  _nativeLogProcess.stdout.on('data', (chunk) => {
@@ -688,6 +775,10 @@ function setupIPC() {
688
775
  if (text) _send('native-log', { level: 'error', message: text, source: 'stderr', ts: Date.now() });
689
776
  });
690
777
 
778
+ // Guard against stream errors (broken pipe, etc.)
779
+ _nativeLogProcess.stdout.on('error', () => {});
780
+ _nativeLogProcess.stderr.on('error', () => {});
781
+
691
782
  _nativeLogProcess.on('close', (code) => {
692
783
  _nativeLogProcess = null;
693
784
  _send('native-status', { connected: false, error: code ? `Process exited with code ${code}` : 'Disconnected' });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reactoradar",
3
3
  "productName": "ReactoRadar",
4
- "version": "1.6.3",
4
+ "version": "1.6.5",
5
5
  "description": "macOS debugger for React Native — Console, Sources, Network, Performance, Memory, Redux, AsyncStorage, React tree. Supports RN 0.74+ with Hermes and New Architecture.",
6
6
  "main": "main.js",
7
7
  "bin": {
package/preload.js CHANGED
@@ -11,7 +11,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
11
11
  'ports', 'cdp-targets', 'redux-event', 'storage-event', 'network-event',
12
12
  'console-event', 'perf-event', 'ga4-event', 'redux-connected', 'storage-connected', 'network-connected',
13
13
  'react-dt-status', 'trigger-open-cdp', 'clear-all-ui', 'theme-changed', 'update-available', 'update-downloaded', 'app-version', 'focus-search',
14
- 'native-log', 'native-status',
14
+ 'native-log', 'native-status', 'device-all-disconnected',
15
15
  ];
16
16
  if (allowed.includes(channel)) {
17
17
  ipcRenderer.removeAllListeners(channel);
@@ -35,4 +35,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
35
35
  startNativeLogs: (platform) => ipcRenderer.send('start-native-logs', platform),
36
36
  stopNativeLogs: () => ipcRenderer.send('stop-native-logs'),
37
37
  detectNativePlatform: () => ipcRenderer.invoke('detect-native-platform'),
38
+ fetchChangelog: (version) => ipcRenderer.invoke('fetch-changelog', version),
39
+ fetchReleases: () => ipcRenderer.invoke('fetch-releases'),
38
40
  });
package/styles.css CHANGED
@@ -1320,6 +1320,96 @@ mark { background: rgba(79,172,255,.2); color: var(--accent); border-radius: 2px
1320
1320
  text-decoration: underline;
1321
1321
  }
1322
1322
 
1323
+ /* Version History */
1324
+ .version-history-list {
1325
+ max-height: 300px;
1326
+ overflow-y: auto;
1327
+ border: 1px solid var(--border);
1328
+ border-radius: 6px;
1329
+ background: var(--bg2);
1330
+ }
1331
+ .version-history-list::-webkit-scrollbar { width: 3px; }
1332
+ .version-history-list::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
1333
+ .version-row {
1334
+ display: flex;
1335
+ align-items: center;
1336
+ justify-content: space-between;
1337
+ padding: 8px 12px;
1338
+ border-bottom: 1px solid var(--border);
1339
+ font-size: 11px;
1340
+ transition: background 0.15s;
1341
+ }
1342
+ .version-row:last-child { border-bottom: none; }
1343
+ .version-row:hover { background: var(--bg3); }
1344
+ .version-row.version-current { background: color-mix(in srgb, var(--accent) 8%, transparent); }
1345
+ .version-info { display: flex; flex-direction: column; gap: 2px; }
1346
+ .version-tag { color: var(--text); font-weight: 600; font-size: 12px; }
1347
+ .version-date { color: var(--text-dim); font-size: 10px; }
1348
+ .version-badge {
1349
+ display: inline-block;
1350
+ background: var(--green);
1351
+ color: #000;
1352
+ font-size: 9px;
1353
+ font-weight: 700;
1354
+ padding: 1px 5px;
1355
+ border-radius: 3px;
1356
+ margin-left: 6px;
1357
+ vertical-align: middle;
1358
+ text-transform: uppercase;
1359
+ }
1360
+ .version-pre {
1361
+ display: inline-block;
1362
+ background: var(--yellow);
1363
+ color: #000;
1364
+ font-size: 9px;
1365
+ font-weight: 700;
1366
+ padding: 1px 5px;
1367
+ border-radius: 3px;
1368
+ margin-left: 4px;
1369
+ vertical-align: middle;
1370
+ }
1371
+ .version-actions { display: flex; gap: 6px; align-items: center; }
1372
+ .version-installed {
1373
+ font-size: 10px;
1374
+ color: var(--green);
1375
+ font-weight: 600;
1376
+ }
1377
+ .version-install-btn {
1378
+ background: var(--accent);
1379
+ color: #fff;
1380
+ border: none;
1381
+ border-radius: 4px;
1382
+ padding: 3px 10px;
1383
+ font-size: 10px;
1384
+ font-weight: 600;
1385
+ cursor: pointer;
1386
+ transition: opacity 0.15s;
1387
+ }
1388
+ .version-install-btn:hover { opacity: 0.85; }
1389
+ .version-notes-btn {
1390
+ background: transparent;
1391
+ color: var(--text-mid);
1392
+ border: 1px solid var(--border);
1393
+ border-radius: 4px;
1394
+ padding: 3px 8px;
1395
+ font-size: 10px;
1396
+ cursor: pointer;
1397
+ transition: color 0.15s, border-color 0.15s;
1398
+ }
1399
+ .version-npm-btn {
1400
+ background: transparent;
1401
+ color: var(--accent);
1402
+ border: 1px solid var(--accent);
1403
+ border-radius: 4px;
1404
+ padding: 3px 8px;
1405
+ font-size: 10px;
1406
+ font-family: var(--font-mono, monospace);
1407
+ cursor: pointer;
1408
+ transition: background 0.15s, color 0.15s;
1409
+ }
1410
+ .version-npm-btn:hover { background: var(--accent); color: #fff; }
1411
+ .version-notes-btn:hover { color: var(--accent); border-color: var(--accent); }
1412
+
1323
1413
  /* ─────────────────────────────────────────────────────────────────────────────
1324
1414
  SOURCES PANEL
1325
1415
  ───────────────────────────────────────────────────────────────────────────── */
@@ -1682,6 +1772,27 @@ mark { background: rgba(79,172,255,.2); color: var(--accent); border-radius: 2px
1682
1772
  .native-fatal { background: rgba(255,94,114,.08); }
1683
1773
  .native-fatal .native-log-msg { color: var(--red); font-weight: 700; }
1684
1774
 
1775
+ /* ── Detail Panel Search ───────────────────────────────────────────────────── */
1776
+ .detail-search-wrap { display: flex; align-items: center; gap: 4px; margin-left: auto; padding: 0 6px; }
1777
+ .detail-search-input { width: 150px; font-size: 10px; padding: 3px 6px; border: 1px solid var(--border); background: var(--bg1); color: var(--text); border-radius: 3px; outline: none; }
1778
+ .detail-search-input:focus { border-color: var(--accent); }
1779
+ .detail-search-count { font-size: 9px; color: var(--text-dim); white-space: nowrap; min-width: 45px; }
1780
+ .detail-search-nav { background: transparent; border: 1px solid var(--border); color: var(--text-dim); font-size: 9px; width: 18px; height: 18px; border-radius: 3px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
1781
+ .detail-search-nav:hover { background: var(--bg3); color: var(--text); }
1782
+ .detail-search-close { background: transparent; border: none; color: var(--text-dim); font-size: 12px; cursor: pointer; padding: 0 2px; }
1783
+ .detail-search-close:hover { color: var(--text); }
1784
+ .detail-search-hl { background: rgba(255,213,79,.3); border-radius: 2px; padding: 0 1px; }
1785
+ .detail-search-hl.active { background: rgba(255,213,79,.7); outline: 1px solid rgba(255,213,79,.9); }
1786
+
1787
+ /* ── Changelog Modal ───────────────────────────────────────────────────────── */
1788
+ .changelog-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 9999; display: flex; align-items: center; justify-content: center; }
1789
+ .changelog-modal { background: var(--bg1); border: 1px solid var(--border); border-radius: 10px; width: 520px; max-width: 90vw; max-height: 70vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,.4); }
1790
+ .changelog-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); }
1791
+ .changelog-title { font-size: 13px; font-weight: 700; color: var(--text); }
1792
+ .changelog-close { background: transparent; border: none; color: var(--text-dim); font-size: 18px; cursor: pointer; padding: 0 4px; }
1793
+ .changelog-close:hover { color: var(--text); }
1794
+ .changelog-body { flex: 1; overflow-y: auto; padding: 16px; font-size: 11px; line-height: 1.6; color: var(--text-mid); }
1795
+
1685
1796
  /* ── Support Button ────────────────────────────────────────────────────────── */
1686
1797
  .support-btn { background: linear-gradient(135deg, #ff813f, #ff5e72); color: #fff; border: none; padding: 8px 20px; border-radius: 8px; font-size: 12px; font-weight: 700; cursor: pointer; transition: all 0.15s; letter-spacing: 0.3px; }
1687
1798
  .support-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(255,94,114,.3); }