reactoradar 1.6.5 → 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.
Files changed (40) hide show
  1. package/AGENTS.md +341 -0
  2. package/app.js +22 -4448
  3. package/index.html +12 -0
  4. package/init.js +187 -0
  5. package/main.js +1 -0
  6. package/package.json +4 -2
  7. package/panels/console.js +788 -0
  8. package/panels/ga4.js +328 -0
  9. package/panels/native.js +256 -0
  10. package/panels/network.js +968 -0
  11. package/{src/renderer/panels → panels}/performance.js +51 -14
  12. package/panels/react.js +21 -0
  13. package/panels/redux.js +438 -0
  14. package/panels/settings.js +832 -0
  15. package/panels/sources.js +282 -0
  16. package/{src/renderer/panels → panels}/storage.js +77 -26
  17. package/styles.css +40 -7
  18. package/src/main/main.js +0 -396
  19. package/src/main/preload.js +0 -28
  20. package/src/renderer/app.js +0 -221
  21. package/src/renderer/components/object-tree.js +0 -245
  22. package/src/renderer/index.html +0 -111
  23. package/src/renderer/panels/console.js +0 -248
  24. package/src/renderer/panels/memory.js +0 -60
  25. package/src/renderer/panels/network.js +0 -559
  26. package/src/renderer/panels/react.js +0 -31
  27. package/src/renderer/panels/redux.js +0 -159
  28. package/src/renderer/panels/settings.js +0 -93
  29. package/src/renderer/panels/sources.js +0 -189
  30. package/src/renderer/state.js +0 -132
  31. package/src/renderer/styles/components.css +0 -145
  32. package/src/renderer/styles/console.css +0 -73
  33. package/src/renderer/styles/main.css +0 -229
  34. package/src/renderer/styles/network.css +0 -242
  35. package/src/renderer/styles/performance.css +0 -45
  36. package/src/renderer/styles/redux.css +0 -77
  37. package/src/renderer/styles/settings.css +0 -63
  38. package/src/renderer/styles/sources.css +0 -48
  39. package/src/renderer/styles/storage.css +0 -28
  40. package/src/renderer/styles/theme-light.css +0 -57
@@ -0,0 +1,832 @@
1
+ // ─── Settings Panel ────────────────────────────────────────────────────────
2
+
3
+ // ─── Theme helpers ───────────────────────────────────────────────────────────
4
+ function getStoredTheme() {
5
+ try { return localStorage.getItem('rn-debug-theme') || 'dark'; } catch { return 'dark'; }
6
+ }
7
+ function setStoredTheme(t) {
8
+ try { localStorage.setItem('rn-debug-theme', t); } catch {}
9
+ }
10
+ function getStoredFontSize() {
11
+ try { return parseInt(localStorage.getItem('rn-debug-fontsize')) || 12; } catch { return 12; }
12
+ }
13
+ function setStoredFontSize(s) {
14
+ try { localStorage.setItem('rn-debug-fontsize', String(s)); } catch {}
15
+ }
16
+
17
+ const FONT_FAMILIES = [
18
+ { label: 'SF Mono', value: "'SFMono-Regular', 'SF Mono', monospace" },
19
+ { label: 'Menlo', value: "Menlo, monospace" },
20
+ { label: 'Monaco', value: "Monaco, monospace" },
21
+ { label: 'Courier New', value: "'Courier New', Courier, monospace" },
22
+ { label: 'System Mono', value: "monospace" },
23
+ ];
24
+ function getStoredFontFamily() {
25
+ try {
26
+ const saved = localStorage.getItem('rn-debug-fontfamily');
27
+ // Reset if saved value was a removed font
28
+ if (saved && !FONT_FAMILIES.some(f => f.value === saved)) return FONT_FAMILIES[0].value;
29
+ return saved || FONT_FAMILIES[0].value;
30
+ } catch { return FONT_FAMILIES[0].value; }
31
+ }
32
+ function setStoredFontFamily(f) {
33
+ try { localStorage.setItem('rn-debug-fontfamily', f); } catch {}
34
+ }
35
+ function applyFontFamily(family) {
36
+ document.body.style.fontFamily = family;
37
+ }
38
+
39
+ // ─── Hidden URLs (Network tab) ───────────────────────────────────────────────
40
+ function getHiddenURLs() {
41
+ try { return JSON.parse(localStorage.getItem('rn-debug-hidden-urls') || '[]'); } catch { return []; }
42
+ }
43
+ function setHiddenURLs(list) {
44
+ try { localStorage.setItem('rn-debug-hidden-urls', JSON.stringify(list)); } catch {}
45
+ }
46
+ function addHiddenURL(url) {
47
+ // Extract the base URL (without query params) as the pattern
48
+ const pattern = url.split('?')[0];
49
+ const list = getHiddenURLs();
50
+ if (!list.includes(pattern)) {
51
+ list.push(pattern);
52
+ setHiddenURLs(list);
53
+ }
54
+ _updateHiddenBadge();
55
+ }
56
+ function removeHiddenURL(pattern) {
57
+ const list = getHiddenURLs().filter(u => u !== pattern);
58
+ setHiddenURLs(list);
59
+ _updateHiddenBadge();
60
+ }
61
+ function isURLHidden(url) {
62
+ const hidden = getHiddenURLs();
63
+ if (!hidden.length) return false;
64
+ const base = url.split('?')[0];
65
+ return hidden.some(pattern => base === pattern || base.startsWith(pattern));
66
+ }
67
+ function _updateHiddenBadge() {
68
+ const btn = $('netHiddenBtn');
69
+ if (!btn) return;
70
+ const count = getHiddenURLs().length;
71
+ btn.textContent = count > 0 ? `Hidden (${count})` : 'Hidden';
72
+ btn.style.display = count > 0 ? '' : 'none';
73
+ }
74
+
75
+ // ─── Tab Visibility ──────────────────────────────────────────────────────────
76
+ const TAB_CONFIG = [
77
+ { id: 'console', label: 'Console', icon: '🖥', essential: true },
78
+ { id: 'network', label: 'Network', icon: '📡', essential: true },
79
+ { id: 'redux', label: 'Redux', icon: '🔲', essential: false },
80
+ { id: 'ga4', label: 'GA4 Events', icon: '📊', essential: false },
81
+ { id: 'storage', label: 'AsyncStorage', icon: '💾', essential: false },
82
+ { id: 'memory', label: 'Memory', icon: '🧠', essential: false, defaultHidden: true },
83
+ { id: 'performance', label: 'Performance', icon: '⚡', essential: false, defaultHidden: true },
84
+ { id: 'react', label: 'React Tree', icon: '⚛️', essential: false },
85
+ { id: 'native', label: 'Native Logs', icon: '📱', essential: false, defaultHidden: true },
86
+ ];
87
+ function getTabVisibility() {
88
+ try {
89
+ const saved = JSON.parse(localStorage.getItem('rn-debug-tab-visibility') || '{}');
90
+ const result = {};
91
+ TAB_CONFIG.forEach(t => { result[t.id] = saved[t.id] !== undefined ? saved[t.id] : !t.defaultHidden; });
92
+ return result;
93
+ } catch {
94
+ const result = {};
95
+ TAB_CONFIG.forEach(t => { result[t.id] = !t.defaultHidden; });
96
+ return result;
97
+ }
98
+ }
99
+ function setTabVisibility(vis) {
100
+ try { localStorage.setItem('rn-debug-tab-visibility', JSON.stringify(vis)); } catch {}
101
+ }
102
+ function getTabOrder() {
103
+ try {
104
+ const saved = JSON.parse(localStorage.getItem('rn-debug-tab-order') || '[]');
105
+ if (saved.length) {
106
+ // Merge: keep saved order, append any new tabs not in saved list
107
+ const allIds = TAB_CONFIG.map(t => t.id);
108
+ const merged = saved.filter(id => allIds.includes(id));
109
+ allIds.forEach(id => { if (!merged.includes(id)) merged.push(id); });
110
+ return merged;
111
+ }
112
+ } catch {}
113
+ return TAB_CONFIG.map(t => t.id);
114
+ }
115
+ function setTabOrder(order) {
116
+ try { localStorage.setItem('rn-debug-tab-order', JSON.stringify(order)); } catch {}
117
+ }
118
+ function applyTabVisibility() {
119
+ const vis = getTabVisibility();
120
+ const order = getTabOrder();
121
+ const nav = $('sidebar');
122
+ if (!nav) return;
123
+ // Reorder nav buttons according to saved order + hide disabled ones
124
+ // Settings button always stays last
125
+ const settingsBtn = nav.querySelector('.nav-btn[data-panel="settings"]');
126
+ const spacer = nav.querySelector('.nav-spacer');
127
+ const anchor = spacer || settingsBtn; // insert before spacer or settings
128
+ order.forEach(tabId => {
129
+ const btn = nav.querySelector(`.nav-btn[data-panel="${tabId}"]`);
130
+ if (btn) {
131
+ btn.style.display = vis[tabId] ? '' : 'none';
132
+ nav.insertBefore(btn, anchor);
133
+ }
134
+ });
135
+ // If active panel is now hidden, switch to first visible
136
+ if (!vis[state.activePanel]) {
137
+ const first = order.find(id => vis[id]);
138
+ if (first) switchPanel(first);
139
+ }
140
+ }
141
+ function isTabEnabled(tabId) {
142
+ return getTabVisibility()[tabId] !== false;
143
+ }
144
+
145
+ function _buildTabVisGrid() {
146
+ const container = $('tabVisibilityGrid');
147
+ if (!container) return;
148
+ container.innerHTML = '';
149
+ const vis = getTabVisibility();
150
+ const order = getTabOrder();
151
+ let dragSrc = null;
152
+
153
+ order.forEach(tabId => {
154
+ const t = TAB_CONFIG.find(c => c.id === tabId);
155
+ if (!t) return;
156
+
157
+ const item = document.createElement('div');
158
+ item.className = `tab-vis-item ${vis[t.id] ? 'active' : 'inactive'}`;
159
+ item.dataset.tab = t.id;
160
+ item.draggable = true;
161
+
162
+ // Drag handle
163
+ const drag = document.createElement('span');
164
+ drag.className = 'tab-vis-drag';
165
+ drag.textContent = '⠿';
166
+ item.appendChild(drag);
167
+
168
+ // Checkbox
169
+ const check = document.createElement('input');
170
+ check.type = 'checkbox';
171
+ check.className = 'tab-vis-check';
172
+ check.checked = vis[t.id];
173
+ if (t.essential) check.disabled = true;
174
+ check.addEventListener('change', () => {
175
+ const v = getTabVisibility();
176
+ v[t.id] = check.checked;
177
+ setTabVisibility(v);
178
+ applyTabVisibility();
179
+ item.classList.toggle('active', check.checked);
180
+ item.classList.toggle('inactive', !check.checked);
181
+ });
182
+ item.appendChild(check);
183
+
184
+ // Icon + label
185
+ const icon = document.createElement('span');
186
+ icon.className = 'tab-vis-icon';
187
+ icon.textContent = t.icon;
188
+ item.appendChild(icon);
189
+
190
+ const label = document.createElement('span');
191
+ label.className = 'tab-vis-label';
192
+ label.textContent = t.label;
193
+ item.appendChild(label);
194
+
195
+ if (t.essential) {
196
+ const req = document.createElement('span');
197
+ req.className = 'tab-vis-required';
198
+ req.textContent = 'Required';
199
+ item.appendChild(req);
200
+ }
201
+
202
+ // Drag events
203
+ item.addEventListener('dragstart', (e) => {
204
+ dragSrc = item;
205
+ item.classList.add('dragging');
206
+ e.dataTransfer.effectAllowed = 'move';
207
+ });
208
+ item.addEventListener('dragend', () => {
209
+ item.classList.remove('dragging');
210
+ container.querySelectorAll('.tab-vis-item').forEach(el => el.classList.remove('drag-over'));
211
+ dragSrc = null;
212
+ });
213
+ item.addEventListener('dragover', (e) => {
214
+ e.preventDefault();
215
+ e.dataTransfer.dropEffect = 'move';
216
+ if (dragSrc && dragSrc !== item) item.classList.add('drag-over');
217
+ });
218
+ item.addEventListener('dragleave', () => {
219
+ item.classList.remove('drag-over');
220
+ });
221
+ item.addEventListener('drop', (e) => {
222
+ e.preventDefault();
223
+ item.classList.remove('drag-over');
224
+ if (!dragSrc || dragSrc === item) return;
225
+ // Reorder: move dragSrc before or after this item
226
+ const items = [...container.querySelectorAll('.tab-vis-item')];
227
+ const fromIdx = items.indexOf(dragSrc);
228
+ const toIdx = items.indexOf(item);
229
+ if (fromIdx < toIdx) {
230
+ container.insertBefore(dragSrc, item.nextSibling);
231
+ } else {
232
+ container.insertBefore(dragSrc, item);
233
+ }
234
+ // Save new order
235
+ const newOrder = [...container.querySelectorAll('.tab-vis-item')].map(el => el.dataset.tab);
236
+ setTabOrder(newOrder);
237
+ applyTabVisibility();
238
+ });
239
+
240
+ container.appendChild(item);
241
+ });
242
+ }
243
+
244
+ function getStoredAppName() {
245
+ try { return localStorage.getItem('rn-debug-appname') || 'ReactoRadar'; } catch { return 'ReactoRadar'; }
246
+ }
247
+ function setStoredAppName(n) {
248
+ try { localStorage.setItem('rn-debug-appname', n); } catch {}
249
+ }
250
+ function getStoredMetroPort() {
251
+ try { return parseInt(localStorage.getItem('rn-debug-metro-port')) || 8081; } catch { return 8081; }
252
+ }
253
+ function setStoredMetroPort(p) {
254
+ try { localStorage.setItem('rn-debug-metro-port', String(p)); } catch {}
255
+ }
256
+ function applyAppName(name) {
257
+ const logo = document.querySelector('.logo');
258
+ if (logo) {
259
+ // Split name — first part normal, last word in accent span
260
+ const words = name.split(/(?=[A-Z])/);
261
+ if (words.length >= 2) {
262
+ logo.innerHTML = words.slice(0, -1).join('') + '<span>' + words[words.length - 1] + '</span>';
263
+ } else {
264
+ logo.textContent = name;
265
+ }
266
+ }
267
+ document.title = name;
268
+ }
269
+
270
+ function applyTheme(theme) {
271
+ document.documentElement.setAttribute('data-theme', theme);
272
+ // Tell main process (light themes need light nativeTheme for window chrome)
273
+ const isLight = ['light', 'solarized-light'].includes(theme);
274
+ window.electronAPI?.setTheme(isLight ? 'light' : 'dark');
275
+ }
276
+
277
+ function applyFontSize(size) {
278
+ document.documentElement.style.setProperty('--app-font-size', size + 'px');
279
+ document.body.style.fontSize = size + 'px';
280
+ // Inject/update a <style> tag so ALL current and future elements get the size
281
+ let styleEl = document.getElementById('dynamic-font-size');
282
+ if (!styleEl) {
283
+ styleEl = document.createElement('style');
284
+ styleEl.id = 'dynamic-font-size';
285
+ document.head.appendChild(styleEl);
286
+ }
287
+ styleEl.textContent = `
288
+ .log-preview, .log-body, .log-text, .log-caller-inline,
289
+ .net-cell, .net-cell-name, .net-type, .net-initiator, .net-size, .net-time, .net-status,
290
+ .detail-content, .kv-val, .kv-key,
291
+ .rdx-type, .rdx-entry-detail, .rdx-store-key-label,
292
+ .storage-value-body, .storage-key-row,
293
+ .sources-code, .source-line-code,
294
+ .ov-leaf, .ov-key, .ov-preview, .ov-str, .ov-num, .ov-bool, .ov-null, .ov-undef,
295
+ .perf-meter-label,
296
+ .settings-label, .settings-hint {
297
+ font-size: ${size}px !important;
298
+ }
299
+ `;
300
+ const display = $('fontSizeDisplay');
301
+ if (display) display.textContent = size + 'px';
302
+ }
303
+
304
+ function initSettingsPanel() {
305
+ const panel = $('panel-settings');
306
+ const current = getStoredTheme();
307
+ const currentSize = getStoredFontSize();
308
+ panel.innerHTML = `
309
+ <div class="panel-toolbar">
310
+ <span class="panel-label">Settings</span>
311
+ </div>
312
+ <div class="scroll-area">
313
+ <div class="settings-two-col">
314
+ <div class="settings-col-left">
315
+ <div class="settings-section">
316
+ <div class="settings-section-title">Appearance</div>
317
+ <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
318
+ <div>
319
+ <div class="settings-label">Theme</div>
320
+ <div class="settings-hint">Choose a color theme</div>
321
+ </div>
322
+ <div class="theme-grid" id="themeSwitcher"></div>
323
+ </div>
324
+ <div class="settings-row">
325
+ <div>
326
+ <div class="settings-label">Font Size</div>
327
+ <div class="settings-hint">Adjust text size</div>
328
+ </div>
329
+ <div class="font-size-control">
330
+ <button class="font-size-btn" id="fontSizeDown">A-</button>
331
+ <span class="font-size-display" id="fontSizeDisplay">${currentSize}px</span>
332
+ <button class="font-size-btn" id="fontSizeUp">A+</button>
333
+ </div>
334
+ </div>
335
+ <div class="settings-row">
336
+ <div>
337
+ <div class="settings-label">Font Family</div>
338
+ </div>
339
+ <select id="fontFamilySelect" class="net-throttle-select" style="width:150px">
340
+ ${FONT_FAMILIES.map(f => `<option value="${esc(f.value)}" ${f.value === getStoredFontFamily() ? 'selected' : ''}>${esc(f.label)}</option>`).join('')}
341
+ </select>
342
+ </div>
343
+ <div class="settings-row">
344
+ <div>
345
+ <div class="settings-label">App Name</div>
346
+ </div>
347
+ <div style="display:flex;align-items:center;gap:6px">
348
+ <input id="appNameInput" class="net-search-input" style="width:120px;text-align:center" value="${getStoredAppName()}" />
349
+ <button class="font-size-btn" id="appNameReset" title="Reset">Reset</button>
350
+ </div>
351
+ </div>
352
+ <div class="settings-row">
353
+ <div>
354
+ <div class="settings-label">Toast Notifications</div>
355
+ <div class="settings-hint">Show alerts for API errors and slow requests</div>
356
+ </div>
357
+ <label class="toggle-label" for="toastToggle">
358
+ <input type="checkbox" id="toastToggle" class="toggle-input" ${getToastsEnabled() ? 'checked' : ''} />
359
+ <span class="toggle-slider"></span>
360
+ </label>
361
+ </div>
362
+ </div>
363
+ <div class="settings-section">
364
+ <div class="settings-section-title">Connection</div>
365
+ <div class="settings-row">
366
+ <div>
367
+ <div class="settings-label">Bridge Ports</div>
368
+ <div class="settings-hint">Redux :9090 · Storage :9091 · Network :9092</div>
369
+ </div>
370
+ </div>
371
+ <div class="settings-row">
372
+ <div>
373
+ <div class="settings-label">Metro Port</div>
374
+ </div>
375
+ <input id="metroPortInput" type="number" class="net-search-input" style="width:70px;text-align:center" value="${getStoredMetroPort()}" />
376
+ </div>
377
+ </div>
378
+ <div class="settings-section">
379
+ <div class="settings-section-title">About</div>
380
+ <div class="settings-about">
381
+ <div class="about-name" id="aboutAppName">${getStoredAppName()}</div>
382
+ <div class="about-version" id="aboutVersion">v${state._appVersion || '...'}</div>
383
+ <div class="about-desc">Standalone macOS debugger for React Native.<br/>Supports Hermes, New Arch, and RN 0.74+.</div>
384
+ <div class="about-links" style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
385
+ <span class="about-link" id="linkGithub">GitHub</span>
386
+ <span class="about-link" id="linkDocs">Docs</span>
387
+ <span class="about-link" id="linkLinkedIn">LinkedIn</span>
388
+ </div>
389
+ <div style="margin-top:12px;text-align:center">
390
+ <button class="support-btn" id="linkSupport" title="Support ReactoRadar development">☕ Support this project</button>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ </div>
395
+ <div class="settings-col-right">
396
+ <div class="settings-section">
397
+ <div class="settings-section-title">Panels</div>
398
+ <div class="settings-hint" style="margin-bottom:8px">Show/hide tabs and drag to reorder. Disabled tabs save memory.</div>
399
+ <div class="tab-visibility-grid" id="tabVisibilityGrid"></div>
400
+ </div>
401
+ <div class="settings-section">
402
+ <div class="settings-section-title">Keyboard Shortcuts</div>
403
+ <div class="settings-shortcut-grid">
404
+ <span class="sc-key">⌘K</span><span class="sc-label">Clear All</span>
405
+ <span class="sc-key">⌘D</span><span class="sc-label">JS Debugger</span>
406
+ <span class="sc-key">⌘R</span><span class="sc-label">React DevTools</span>
407
+ <span class="sc-key">⌘⇧T</span><span class="sc-label">Toggle Theme</span>
408
+ <span class="sc-key">⌘F</span><span class="sc-label">Find</span>
409
+ <span class="sc-key">⌘1–9</span><span class="sc-label">Switch Panels</span>
410
+ <span class="sc-key">⌘+/−</span><span class="sc-label">Zoom</span>
411
+ </div>
412
+ </div>
413
+ <div class="settings-section">
414
+ <div class="settings-section-title">Quick Start</div>
415
+ <div class="settings-hint" style="line-height:1.8;font-size:11px">
416
+ <b style="color:var(--text)">1.</b> <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar setup</code><br/>
417
+ <b style="color:var(--text)">2.</b> <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar</code> or open app<br/>
418
+ <b style="color:var(--text)">3.</b> <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx react-native start</code><br/>
419
+ <b style="color:var(--text)">4.</b> Console, Network, Redux auto-connect<br/>
420
+ <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
421
+ </div>
422
+ </div>
423
+ <div class="settings-section">
424
+ <div class="settings-section-title">Version History</div>
425
+ <div class="settings-hint" style="margin-bottom:4px">Roll back to a previous version if you notice issues.</div>
426
+ <div class="settings-hint rollback-steps" id="rollbackSteps" style="margin-bottom:10px;line-height:1.8;font-size:10px">
427
+ <b style="color:var(--text)">How to roll back:</b><br/>
428
+ <span id="rollbackDmgSteps" style="display:none">
429
+ <b style="color:var(--text)">1.</b> Click <b>Download</b> on the version you want<br/>
430
+ <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/>
431
+ <b style="color:var(--text)">3.</b> Drag the app to Applications (replace existing)<br/>
432
+ <b style="color:var(--text)">4.</b> Relaunch ReactoRadar
433
+ </span>
434
+ <span id="rollbackNpmSteps" style="display:none">
435
+ <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/>
436
+ <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/>
437
+ <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
438
+ </span>
439
+ </div>
440
+ <div id="versionHistoryList" class="version-history-list">
441
+ <div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Loading versions...</div>
442
+ </div>
443
+ </div>
444
+ </div>
445
+ </div>
446
+ </div>`;
447
+
448
+ // Build theme cards
449
+ const themes = [
450
+ { id: 'dark', name: 'Dark', colors: ['#0d0e11','#4facff','#3dd68c','#ff5e72'] },
451
+ { id: 'light', name: 'Light', colors: ['#f5f6f8','#0969da','#1a7f37','#cf222e'] },
452
+ { id: 'monokai', name: 'Monokai', colors: ['#272822','#66d9ef','#a6e22e','#f92672'] },
453
+ { id: 'dracula', name: 'Dracula', colors: ['#282a36','#8be9fd','#50fa7b','#ff5555'] },
454
+ { id: 'solarized-dark', name: 'Solarized Dark', colors: ['#002b36','#268bd2','#859900','#dc322f'] },
455
+ { id: 'solarized-light', name: 'Solarized Light', colors: ['#fdf6e3','#268bd2','#859900','#dc322f'] },
456
+ { id: 'nord', name: 'Nord', colors: ['#2e3440','#88c0d0','#a3be8c','#bf616a'] },
457
+ { id: 'github-dark', name: 'GitHub Dark', colors: ['#0d1117','#58a6ff','#3fb950','#f85149'] },
458
+ { id: 'one-dark', name: 'One Dark', colors: ['#282c34','#61afef','#98c379','#e06c75'] },
459
+ ];
460
+ // Tab visibility + drag reorder
461
+ _buildTabVisGrid();
462
+
463
+ const grid = $('themeSwitcher');
464
+ themes.forEach(t => {
465
+ const btn = document.createElement('button');
466
+ btn.className = 'theme-card' + (current === t.id ? ' active' : '');
467
+ btn.dataset.theme = t.id;
468
+ btn.innerHTML = '<div class="theme-preview" style="background:' + t.colors[0] + '">' +
469
+ '<span style="background:' + t.colors[1] + '"></span>' +
470
+ '<span style="background:' + t.colors[2] + '"></span>' +
471
+ '<span style="background:' + t.colors[3] + '"></span>' +
472
+ '</div><div class="theme-name">' + t.name + '</div>';
473
+ grid.appendChild(btn);
474
+ });
475
+
476
+ // Theme switcher
477
+ $('themeSwitcher').addEventListener('click', (e) => {
478
+ const btn = e.target.closest('.theme-card');
479
+ if (!btn) return;
480
+ const theme = btn.dataset.theme;
481
+ document.querySelectorAll('#themeSwitcher .theme-card').forEach(b => b.classList.remove('active'));
482
+ btn.classList.add('active');
483
+ setStoredTheme(theme);
484
+ applyTheme(theme);
485
+ });
486
+
487
+ // About links
488
+ $('linkGithub')?.addEventListener('click', () => {
489
+ window.electronAPI?.openExternal('https://github.com/sharanagouda/reactoradar');
490
+ });
491
+ $('linkDocs')?.addEventListener('click', () => {
492
+ window.electronAPI?.openExternal('https://github.com/sharanagouda/reactoradar#readme');
493
+ });
494
+ $('linkLinkedIn')?.addEventListener('click', () => {
495
+ window.electronAPI?.openExternal('https://www.linkedin.com/in/sharanagoudamk/');
496
+ });
497
+ $('linkSupport')?.addEventListener('click', () => {
498
+ window.electronAPI?.openExternal('https://razorpay.me/@reactoradar');
499
+ });
500
+
501
+ // App name
502
+ $('appNameInput').addEventListener('change', (e) => {
503
+ const name = e.target.value.trim() || 'ReactoRadar';
504
+ setStoredAppName(name);
505
+ applyAppName(name);
506
+ });
507
+ $('appNameReset').addEventListener('click', () => {
508
+ setStoredAppName('ReactoRadar');
509
+ $('appNameInput').value = 'ReactoRadar';
510
+ applyAppName('ReactoRadar');
511
+ });
512
+
513
+ // Metro Port
514
+ $('metroPortInput')?.addEventListener('change', (e) => {
515
+ let port = parseInt(e.target.value.trim());
516
+ if (isNaN(port) || port < 1024 || port > 65535) port = 8081;
517
+ e.target.value = port;
518
+ setStoredMetroPort(port);
519
+ window.electronAPI?.setMetroPort(port);
520
+ });
521
+
522
+ // Font size controls
523
+ $('fontSizeDown').addEventListener('click', () => {
524
+ let size = getStoredFontSize();
525
+ size = Math.max(8, size - 1);
526
+ setStoredFontSize(size);
527
+ applyFontSize(size);
528
+ });
529
+ $('fontSizeUp').addEventListener('click', () => {
530
+ let size = getStoredFontSize();
531
+ size = Math.min(20, size + 1);
532
+ setStoredFontSize(size);
533
+ applyFontSize(size);
534
+ });
535
+
536
+ // Font family
537
+ $('fontFamilySelect')?.addEventListener('change', (e) => {
538
+ const family = e.target.value;
539
+ setStoredFontFamily(family);
540
+ applyFontFamily(family);
541
+ });
542
+
543
+ // Toast toggle
544
+ $('toastToggle')?.addEventListener('change', (e) => {
545
+ setToastsEnabled(e.target.checked);
546
+ });
547
+
548
+ // Apply update banner if update info arrived before settings panel was created
549
+ _applyUpdateBanner();
550
+
551
+ // Fetch and render version history for rollback
552
+ _loadVersionHistory();
553
+ }
554
+
555
+ function _loadVersionHistory() {
556
+ const container = $('versionHistoryList');
557
+ if (!container) return;
558
+ if (!window.electronAPI || typeof window.electronAPI.fetchReleases !== 'function') {
559
+ container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Version history not available.</div>';
560
+ return;
561
+ }
562
+
563
+ // Show appropriate rollback steps based on install type
564
+ const isPackaged = !!state._isPackaged;
565
+ const dmgSteps = $('rollbackDmgSteps');
566
+ const npmSteps = $('rollbackNpmSteps');
567
+ if (dmgSteps) dmgSteps.style.display = isPackaged ? '' : 'none';
568
+ if (npmSteps) npmSteps.style.display = isPackaged ? 'none' : '';
569
+
570
+ window.electronAPI.fetchReleases().then(releases => {
571
+ if (!Array.isArray(releases) || releases.length === 0) {
572
+ container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">Could not load versions.</div>';
573
+ return;
574
+ }
575
+
576
+ const currentVersion = state._appVersion || '';
577
+ container.innerHTML = '';
578
+
579
+ releases.forEach(r => {
580
+ if (!r || !r.version) return; // skip malformed entries
581
+
582
+ const isCurrent = r.version === currentVersion;
583
+ const row = document.createElement('div');
584
+ row.className = 'version-row' + (isCurrent ? ' version-current' : '');
585
+
586
+ // Safe date formatting
587
+ let dateStr = '';
588
+ if (r.date) {
589
+ try {
590
+ const d = new Date(r.date);
591
+ if (!isNaN(d.getTime())) {
592
+ dateStr = d.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' });
593
+ }
594
+ } catch { /* skip bad date */ }
595
+ }
596
+
597
+ // Build action buttons based on install type
598
+ let actionHtml = '';
599
+ if (isCurrent) {
600
+ actionHtml = '<span class="version-installed">Installed</span>';
601
+ } else if (isPackaged) {
602
+ // Show download dropdown with .dmg and .zip links
603
+ actionHtml = '<div class="version-dl-wrap"><button class="version-install-btn" title="Download this version">Download ▾</button></div>';
604
+ } else {
605
+ actionHtml = `<button class="version-npm-btn" title="Copy npm install command">npx @${esc(r.version)}</button>`;
606
+ }
607
+
608
+ row.innerHTML = `
609
+ <div class="version-info">
610
+ <span class="version-tag">v${esc(r.version)}${r.prerelease ? ' <span class="version-pre">pre</span>' : ''}${isCurrent ? ' <span class="version-badge">current</span>' : ''}</span>
611
+ <span class="version-date">${esc(dateStr)}</span>
612
+ </div>
613
+ <div class="version-actions">
614
+ ${actionHtml}
615
+ <button class="version-notes-btn" title="View release notes">Notes</button>
616
+ </div>`;
617
+
618
+ // DMG/ZIP download dropdown
619
+ const dlWrap = row.querySelector('.version-dl-wrap');
620
+ const installBtn = row.querySelector('.version-install-btn');
621
+ if (installBtn && dlWrap) {
622
+ installBtn.addEventListener('click', (e) => {
623
+ e.stopPropagation();
624
+ // Remove any existing dropdown
625
+ document.querySelectorAll('.version-dl-menu').forEach(m => m.remove());
626
+
627
+ const menu = document.createElement('div');
628
+ menu.className = 'version-dl-menu';
629
+
630
+ // .dmg link
631
+ if (r.dmgUrl) {
632
+ const dmgItem = document.createElement('div');
633
+ dmgItem.className = 'version-dl-item';
634
+ dmgItem.innerHTML = `<span class="version-dl-icon">💿</span><div><div class="version-dl-name">.dmg Installer</div><div class="version-dl-hint">macOS installer (Apple Silicon)</div></div>`;
635
+ dmgItem.addEventListener('click', () => { window.electronAPI.openExternal(r.dmgUrl); menu.remove(); });
636
+ menu.appendChild(dmgItem);
637
+ }
638
+
639
+ // .zip link
640
+ if (r.zipUrl) {
641
+ const zipItem = document.createElement('div');
642
+ zipItem.className = 'version-dl-item';
643
+ zipItem.innerHTML = `<span class="version-dl-icon">📦</span><div><div class="version-dl-name">.zip Archive</div><div class="version-dl-hint">Portable zip archive</div></div>`;
644
+ zipItem.addEventListener('click', () => { window.electronAPI.openExternal(r.zipUrl); menu.remove(); });
645
+ menu.appendChild(zipItem);
646
+ }
647
+
648
+ // GitHub release page fallback
649
+ if (r.htmlUrl) {
650
+ const ghItem = document.createElement('div');
651
+ ghItem.className = 'version-dl-item';
652
+ ghItem.innerHTML = `<span class="version-dl-icon">🔗</span><div><div class="version-dl-name">GitHub Release</div><div class="version-dl-hint">View all assets on GitHub</div></div>`;
653
+ ghItem.addEventListener('click', () => { window.electronAPI.openExternal(r.htmlUrl); menu.remove(); });
654
+ menu.appendChild(ghItem);
655
+ }
656
+
657
+ // No assets fallback
658
+ if (!r.dmgUrl && !r.zipUrl && !r.htmlUrl) {
659
+ menu.innerHTML = '<div style="padding:8px 12px;color:var(--text-dim);font-size:10px">No downloads available</div>';
660
+ }
661
+
662
+ dlWrap.appendChild(menu);
663
+
664
+ // Close on outside click
665
+ setTimeout(() => {
666
+ const close = (ev) => { if (!menu.contains(ev.target) && ev.target !== installBtn) { menu.remove(); document.removeEventListener('click', close); } };
667
+ document.addEventListener('click', close);
668
+ }, 0);
669
+ });
670
+ }
671
+
672
+ // NPM copy button — copies the npx command to clipboard
673
+ const npmBtn = row.querySelector('.version-npm-btn');
674
+ if (npmBtn) {
675
+ npmBtn.addEventListener('click', () => {
676
+ const cmd = `npx reactoradar@${r.version}`;
677
+ navigator.clipboard.writeText(cmd).then(() => {
678
+ const orig = npmBtn.textContent;
679
+ npmBtn.textContent = 'Copied!';
680
+ npmBtn.style.color = 'var(--green)';
681
+ setTimeout(() => { npmBtn.textContent = orig; npmBtn.style.color = ''; }, 2000);
682
+ }).catch(() => {});
683
+ });
684
+ }
685
+
686
+ // Notes button — show changelog in modal
687
+ const notesBtn = row.querySelector('.version-notes-btn');
688
+ if (notesBtn) {
689
+ notesBtn.addEventListener('click', () => {
690
+ if (r.version && typeof _showChangelog === 'function') {
691
+ _showChangelog(r.version);
692
+ }
693
+ });
694
+ }
695
+
696
+ container.appendChild(row);
697
+ });
698
+
699
+ // If no rows were rendered (all entries were malformed)
700
+ if (container.children.length === 0) {
701
+ container.innerHTML = '<div style="color:var(--text-dim);font-size:11px;padding:12px;text-align:center">No versions found.</div>';
702
+ }
703
+ }).catch(() => {
704
+ if (container) {
705
+ 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>';
706
+ }
707
+ });
708
+ }
709
+
710
+ // ─── Update Banner ───────────────────────────────────────────────────────────
711
+ // Reusable — called from IPC handler AND from initSettingsPanel
712
+ function _applyUpdateBanner() {
713
+ const info = state._updateAvailable;
714
+ if (!info) return;
715
+ const { current, latest, autoUpdate } = info;
716
+ const downloaded = state._updateDownloaded;
717
+ const targetVersion = downloaded || latest;
718
+
719
+ const el = $('aboutVersion');
720
+ if (el) {
721
+ if (downloaded) {
722
+ el.innerHTML = `v${current} <span style="color:var(--green);font-size:10px;margin-left:6px">v${downloaded} ready to install</span>`;
723
+ } else {
724
+ el.innerHTML = `v${current} <span style="color:var(--green);font-size:10px;margin-left:6px">v${latest} available</span>`;
725
+ }
726
+ }
727
+
728
+ // Remove old buttons if state changed
729
+ const oldBtn = $('updateBtn');
730
+ if (oldBtn && downloaded && !oldBtn.dataset.isRestart) oldBtn.parentElement?.remove();
731
+ const oldChangelog = $('changelogBtn');
732
+ if (oldChangelog && downloaded && !oldChangelog.dataset.updated) oldChangelog.remove();
733
+
734
+ const aboutEl = document.querySelector('.settings-about');
735
+ if (!aboutEl) return;
736
+
737
+ // Add "What's new?" link
738
+ if (!$('changelogBtn')) {
739
+ const link = document.createElement('div');
740
+ link.style.cssText = 'margin-top:6px;text-align:center';
741
+ 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>`;
742
+ aboutEl.appendChild(link);
743
+ $('changelogBtn')?.addEventListener('click', () => _showChangelog(targetVersion));
744
+ }
745
+
746
+ // Add update button
747
+ if (!$('updateBtn')) {
748
+ const btn = document.createElement('div');
749
+ btn.style.cssText = 'margin-top:8px;text-align:center';
750
+ if (downloaded) {
751
+ 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>';
752
+ aboutEl.appendChild(btn);
753
+ $('updateBtn')?.addEventListener('click', () => window.electronAPI?.installUpdate());
754
+ } else if (autoUpdate) {
755
+ btn.innerHTML = '<button id="updateBtn" class="tb-btn" style="font-size:11px;padding:6px 16px;opacity:0.7" disabled>Downloading v' + latest + '...</button>';
756
+ aboutEl.appendChild(btn);
757
+ } else {
758
+ btn.innerHTML = '<button id="updateBtn" class="tb-btn primary" style="font-size:11px;padding:6px 16px">Download v' + latest + '</button>';
759
+ aboutEl.appendChild(btn);
760
+ $('updateBtn')?.addEventListener('click', () => window.electronAPI?.openExternal('https://github.com/sharanagouda/reactoradar/releases'));
761
+ }
762
+ }
763
+ }
764
+
765
+ async function _showChangelog(version) {
766
+ if (!version || typeof version !== 'string') return;
767
+
768
+ // Remove existing modal
769
+ $('changelogModal')?.remove();
770
+
771
+ const safeVersion = esc(version);
772
+ const modal = document.createElement('div');
773
+ modal.id = 'changelogModal';
774
+ modal.className = 'changelog-modal-overlay';
775
+ modal.innerHTML = `
776
+ <div class="changelog-modal">
777
+ <div class="changelog-header">
778
+ <span class="changelog-title">What's New in v${safeVersion}</span>
779
+ <button class="changelog-close" id="changelogClose">&times;</button>
780
+ </div>
781
+ <div class="changelog-body" id="changelogBody">
782
+ <div style="color:var(--text-dim);padding:20px;text-align:center">Loading release notes...</div>
783
+ </div>
784
+ </div>`;
785
+ document.body.appendChild(modal);
786
+
787
+ // Close handlers
788
+ $('changelogClose')?.addEventListener('click', () => modal.remove());
789
+ modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
790
+
791
+ // Fetch changelog
792
+ try {
793
+ const notes = await window.electronAPI?.fetchChangelog(version);
794
+ const body = $('changelogBody');
795
+ if (!body) return;
796
+ if (!notes || typeof notes !== 'string') {
797
+ body.innerHTML = '<div style="color:var(--text-dim);padding:20px;text-align:center">No release notes available.</div>';
798
+ return;
799
+ }
800
+ if (body && notes) {
801
+ // Simple markdown-like rendering
802
+ body.innerHTML = notes
803
+ .replace(/^### (.+)$/gm, '<h3 style="color:var(--accent);font-size:12px;font-weight:700;margin:12px 0 6px">$1</h3>')
804
+ .replace(/^## (.+)$/gm, '<h2 style="color:var(--text);font-size:14px;font-weight:700;margin:16px 0 8px">$1</h2>')
805
+ .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>')
806
+ .replace(/^- (.+)$/gm, '<div style="margin:3px 0;font-size:11px;line-height:1.6;color:var(--text-mid)">• $1</div>')
807
+ .replace(/`([^`]+)`/g, '<code style="background:var(--bg3);padding:1px 4px;border-radius:3px;color:var(--accent);font-size:10px">$1</code>')
808
+ // Convert markdown links [text](url) to clickable links
809
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a class="changelog-link" href="$2">$1</a>')
810
+ // Convert bare URLs to clickable links (skip already wrapped in href="...")
811
+ .replace(/(?<!href="|">)(https?:\/\/[^\s<)"]+)/g, '<a class="changelog-link" href="$1">$1</a>')
812
+ // Make .dmg and .zip filenames clickable — link to GitHub release assets
813
+ .replace(/(ReactoRadar-[\d.]+-arm64\.dmg)(?!\s*<\/a>)/g,
814
+ `<a class="changelog-link" href="https://github.com/sharanagouda/reactoradar/releases/download/v${esc(version)}/$1">$1</a>`)
815
+ .replace(/(ReactoRadar-[\d.]+-arm64-mac\.zip)(?!\s*<\/a>)/g,
816
+ `<a class="changelog-link" href="https://github.com/sharanagouda/reactoradar/releases/download/v${esc(version)}/$1">$1</a>`)
817
+ .replace(/\n\n/g, '<br/>');
818
+
819
+ // Make all links open externally (not inside Electron)
820
+ body.querySelectorAll('a.changelog-link').forEach(a => {
821
+ a.addEventListener('click', (e) => {
822
+ e.preventDefault();
823
+ const url = a.getAttribute('href');
824
+ if (url) window.electronAPI?.openExternal(url);
825
+ });
826
+ });
827
+ }
828
+ } catch {
829
+ const body = $('changelogBody');
830
+ if (body) body.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Could not fetch release notes</div>';
831
+ }
832
+ }