Version not found. Please check the version and try again.
reactoradar 1.2.3
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.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/app.js +2450 -0
- package/assets/icon.svg +54 -0
- package/bin/cli.js +79 -0
- package/bin/open-debugger.sh +9 -0
- package/bin/setup.js +473 -0
- package/index.html +82 -0
- package/main.js +528 -0
- package/package.json +76 -0
- package/preload.js +31 -0
- package/sdk/RNDebugSDK.js +540 -0
- package/src/main/main.js +396 -0
- package/src/main/preload.js +28 -0
- package/src/renderer/app.js +221 -0
- package/src/renderer/components/object-tree.js +245 -0
- package/src/renderer/index.html +111 -0
- package/src/renderer/panels/console.js +248 -0
- package/src/renderer/panels/memory.js +60 -0
- package/src/renderer/panels/network.js +559 -0
- package/src/renderer/panels/performance.js +144 -0
- package/src/renderer/panels/react.js +31 -0
- package/src/renderer/panels/redux.js +159 -0
- package/src/renderer/panels/settings.js +93 -0
- package/src/renderer/panels/sources.js +189 -0
- package/src/renderer/panels/storage.js +134 -0
- package/src/renderer/state.js +132 -0
- package/src/renderer/styles/components.css +145 -0
- package/src/renderer/styles/console.css +73 -0
- package/src/renderer/styles/main.css +229 -0
- package/src/renderer/styles/network.css +242 -0
- package/src/renderer/styles/performance.css +45 -0
- package/src/renderer/styles/redux.css +77 -0
- package/src/renderer/styles/settings.css +63 -0
- package/src/renderer/styles/sources.css +48 -0
- package/src/renderer/styles/storage.css +28 -0
- package/src/renderer/styles/theme-light.css +57 -0
- package/styles.css +1308 -0
package/app.js
ADDED
|
@@ -0,0 +1,2450 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
4
|
+
const state = {
|
|
5
|
+
filter: '',
|
|
6
|
+
activePanel: 'console',
|
|
7
|
+
ports: {},
|
|
8
|
+
|
|
9
|
+
console: { logs: [], levelFilter: 'all', searchFilter: '', stackTraceEnabled: false },
|
|
10
|
+
|
|
11
|
+
network: {
|
|
12
|
+
requests: {},
|
|
13
|
+
order: [],
|
|
14
|
+
statusFilter: 'all',
|
|
15
|
+
typeFilter: 'all',
|
|
16
|
+
searchFilter: '',
|
|
17
|
+
throttle: 'none',
|
|
18
|
+
enabled: true,
|
|
19
|
+
selectedId: null,
|
|
20
|
+
sortCol: 'time',
|
|
21
|
+
sortDir: 'desc',
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
redux: {
|
|
25
|
+
actions: [],
|
|
26
|
+
states: [],
|
|
27
|
+
selected: -1,
|
|
28
|
+
searchFilter: '',
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
storage: {
|
|
32
|
+
entries: {}, // key → value string
|
|
33
|
+
keys: [], // ordered keys
|
|
34
|
+
selected: null,
|
|
35
|
+
searchFilter: '',
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Device connection tracking
|
|
39
|
+
connections: { redux: false, network: false, storage: false, reactDT: false },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
43
|
+
const $ = id => document.getElementById(id);
|
|
44
|
+
const esc = s => s == null ? '' : String(s)
|
|
45
|
+
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
46
|
+
const ts = ms => new Date(ms).toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
function pretty(val) {
|
|
50
|
+
if (val == null) return '';
|
|
51
|
+
if (typeof val === 'string') { try { return JSON.stringify(JSON.parse(val),null,2); } catch{} return val; }
|
|
52
|
+
return JSON.stringify(val, null, 2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function syntaxHighlight(json) {
|
|
56
|
+
return json
|
|
57
|
+
.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, m => {
|
|
58
|
+
if (/^"/.test(m)) return /:$/.test(m) ? `<span class="json-key">${m}</span>` : `<span class="json-str">${m}</span>`;
|
|
59
|
+
if (/true|false/.test(m)) return `<span class="json-bool">${m}</span>`;
|
|
60
|
+
if (/null/.test(m)) return `<span class="json-null">${m}</span>`;
|
|
61
|
+
return `<span class="json-num">${m}</span>`;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderJSON(val) {
|
|
66
|
+
try {
|
|
67
|
+
const str = typeof val === 'string' ? val : JSON.stringify(val, null, 2);
|
|
68
|
+
return syntaxHighlight(esc(str));
|
|
69
|
+
} catch { return esc(String(val)); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tryURL(url) { try { return new URL(url); } catch { return null; } }
|
|
73
|
+
|
|
74
|
+
// Extract short caller display from the SDK's caller string.
|
|
75
|
+
// SDK now sends: "HomeScreen.tsx:42 (HomeScreen)" or "ProductDetails" or "file.tsx:10"
|
|
76
|
+
function extractCallerShort(caller) {
|
|
77
|
+
if (!caller) return '';
|
|
78
|
+
// Already short format from SDK — just clean up
|
|
79
|
+
const trimmed = caller.replace(/^\s*at\s+/, '').trim();
|
|
80
|
+
// If it's just a function name (no file), return as-is
|
|
81
|
+
if (!trimmed.includes(':') && !trimmed.includes('/')) return trimmed;
|
|
82
|
+
// If it's "file.tsx:42 (FuncName)", return "file.tsx:42"
|
|
83
|
+
const m = trimmed.match(/^([^/\\\s]+\.[jt]sx?:\d+)/);
|
|
84
|
+
if (m) return m[1];
|
|
85
|
+
// Fallback
|
|
86
|
+
return trimmed.length > 40 ? trimmed.slice(-40) : trimmed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function highlight(html, term) {
|
|
90
|
+
if (!term) return html;
|
|
91
|
+
const re = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi');
|
|
92
|
+
return html.replace(re, '<mark>$1</mark>');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Navigation ───────────────────────────────────────────────────────────────
|
|
96
|
+
document.querySelectorAll('.nav-btn').forEach(btn => {
|
|
97
|
+
btn.addEventListener('click', () => {
|
|
98
|
+
const panel = btn.dataset.panel;
|
|
99
|
+
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
|
100
|
+
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
101
|
+
btn.classList.add('active');
|
|
102
|
+
$(`panel-${panel}`).classList.add('active');
|
|
103
|
+
state.activePanel = panel;
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Global filter removed — each panel has its own search input
|
|
108
|
+
|
|
109
|
+
// ─── Clear (active tab only) ──────────────────────────────────────────────────
|
|
110
|
+
$('btnClear').addEventListener('click', clearActiveTab);
|
|
111
|
+
|
|
112
|
+
function clearActiveTab() {
|
|
113
|
+
switch (state.activePanel) {
|
|
114
|
+
case 'console':
|
|
115
|
+
state.console.logs = [];
|
|
116
|
+
_consolePending = [];
|
|
117
|
+
$('cBadge').textContent = '0';
|
|
118
|
+
renderConsole();
|
|
119
|
+
break;
|
|
120
|
+
case 'network':
|
|
121
|
+
state.network.requests = {};
|
|
122
|
+
state.network.order = [];
|
|
123
|
+
state.network.selectedId = null;
|
|
124
|
+
closeNetDetail();
|
|
125
|
+
$('nBadge').textContent = '0';
|
|
126
|
+
renderNetwork();
|
|
127
|
+
break;
|
|
128
|
+
case 'redux':
|
|
129
|
+
state.redux.actions = [];
|
|
130
|
+
state.redux.states = [];
|
|
131
|
+
state.redux.selected = -1;
|
|
132
|
+
$('rBadge').textContent = '0';
|
|
133
|
+
renderRedux();
|
|
134
|
+
break;
|
|
135
|
+
case 'storage':
|
|
136
|
+
state.storage.entries = {};
|
|
137
|
+
state.storage.keys = [];
|
|
138
|
+
state.storage.selected = null;
|
|
139
|
+
$('sBadge').textContent = '0';
|
|
140
|
+
renderStorage();
|
|
141
|
+
break;
|
|
142
|
+
case 'performance':
|
|
143
|
+
perfState.fps = [];
|
|
144
|
+
perfState.jsThread = [];
|
|
145
|
+
perfState.uiThread = [];
|
|
146
|
+
perfState.data = [];
|
|
147
|
+
const perfFPS = $('perfFPS'); if (perfFPS) perfFPS.textContent = '—';
|
|
148
|
+
const perfJS = $('perfJS'); if (perfJS) perfJS.textContent = '—';
|
|
149
|
+
const perfUI = $('perfUI'); if (perfUI) perfUI.textContent = '—';
|
|
150
|
+
clearPerfCanvas('perfFPSCanvas');
|
|
151
|
+
clearPerfCanvas('perfJSCanvas');
|
|
152
|
+
clearPerfCanvas('perfUICanvas');
|
|
153
|
+
break;
|
|
154
|
+
case 'memory':
|
|
155
|
+
const memHU = $('memHeapUsed'); if (memHU) memHU.textContent = '—';
|
|
156
|
+
const memHT = $('memHeapTotal'); if (memHT) memHT.textContent = '—';
|
|
157
|
+
const memN = $('memNative'); if (memN) memN.textContent = '—';
|
|
158
|
+
break;
|
|
159
|
+
default:
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Clear all (used by IPC clear-all-ui from menu Cmd+K)
|
|
165
|
+
function clearAll() {
|
|
166
|
+
state.console.logs = [];
|
|
167
|
+
_consolePending = [];
|
|
168
|
+
state.network.requests = {};
|
|
169
|
+
state.network.order = [];
|
|
170
|
+
state.network.selectedId = null;
|
|
171
|
+
closeNetDetail();
|
|
172
|
+
state.redux.actions = [];
|
|
173
|
+
state.redux.states = [];
|
|
174
|
+
state.redux.selected = -1;
|
|
175
|
+
state.storage.entries = {};
|
|
176
|
+
state.storage.keys = [];
|
|
177
|
+
state.storage.selected = null;
|
|
178
|
+
$('cBadge').textContent = '0';
|
|
179
|
+
$('nBadge').textContent = '0';
|
|
180
|
+
$('rBadge').textContent = '0';
|
|
181
|
+
$('sBadge').textContent = '0';
|
|
182
|
+
renderConsole();
|
|
183
|
+
renderNetwork();
|
|
184
|
+
renderRedux();
|
|
185
|
+
renderStorage();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── CDP Button ───────────────────────────────────────────────────────────────
|
|
189
|
+
$('btnCDP').addEventListener('click', () => {
|
|
190
|
+
// Tell main process to open the CDP DevTools window with the best available target
|
|
191
|
+
window.electronAPI?.openCDPTarget(null); // null = use latest known target
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
// IPC from Main
|
|
196
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
if (window.electronAPI) {
|
|
198
|
+
window.electronAPI.on('ports', ports => { state.ports = ports; });
|
|
199
|
+
|
|
200
|
+
window.electronAPI.on('cdp-targets', targets => {
|
|
201
|
+
const hasCDP = targets?.length > 0;
|
|
202
|
+
$('btnCDP').textContent = hasCDP
|
|
203
|
+
? `JS Debugger (${targets.length}) ↗`
|
|
204
|
+
: 'JS Debugger ↗';
|
|
205
|
+
$('btnCDP').style.opacity = hasCDP ? '1' : '0.5';
|
|
206
|
+
if (hasCDP) {
|
|
207
|
+
$('btnCDP').onclick = () => window.electronAPI.openCDPTarget(targets[0].webSocketDebuggerUrl);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
window.electronAPI.on('redux-event', handleReduxEvent);
|
|
213
|
+
window.electronAPI.on('network-event', handleNetworkEvent);
|
|
214
|
+
window.electronAPI.on('storage-event', handleStorageEvent);
|
|
215
|
+
|
|
216
|
+
window.electronAPI.on('perf-event', event => {
|
|
217
|
+
handlePerfEvent(event);
|
|
218
|
+
handleMemoryEvent(event);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
window.electronAPI.on('redux-connected', on => { updateDeviceBanner('redux', on); });
|
|
222
|
+
window.electronAPI.on('network-connected', on => { updateDeviceBanner('network', on); });
|
|
223
|
+
window.electronAPI.on('storage-connected', on => { updateDeviceBanner('storage', on); });
|
|
224
|
+
window.electronAPI.on('react-dt-status', on => { updateDeviceBanner('reactDT', on); });
|
|
225
|
+
|
|
226
|
+
window.electronAPI.on('clear-all-ui', clearAll);
|
|
227
|
+
|
|
228
|
+
window.electronAPI.on('update-available', ({ current, latest }) => {
|
|
229
|
+
const banner = document.createElement('div');
|
|
230
|
+
banner.className = 'update-banner';
|
|
231
|
+
banner.innerHTML = `New version <b>v${latest}</b> available (current: v${current}).
|
|
232
|
+
<a class="update-link" id="updateLink">Download update</a>
|
|
233
|
+
<span class="update-dismiss" id="updateDismiss">×</span>`;
|
|
234
|
+
document.getElementById('app').prepend(banner);
|
|
235
|
+
$('updateLink')?.addEventListener('click', () => {
|
|
236
|
+
window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger/releases');
|
|
237
|
+
});
|
|
238
|
+
$('updateDismiss')?.addEventListener('click', () => banner.remove());
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
window.electronAPI.on('trigger-open-cdp', () => {
|
|
242
|
+
window.electronAPI?.openCDPTarget(null);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Theme toggle from menu shortcut (Cmd+Shift+T)
|
|
246
|
+
window.electronAPI.on('theme-changed', theme => {
|
|
247
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
248
|
+
setStoredTheme(theme);
|
|
249
|
+
document.querySelectorAll('#themeSwitcher .theme-card')
|
|
250
|
+
.forEach(b => b.classList.toggle('active', b.dataset.theme === theme));
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Device Connection Status (inline in titlebar) ───────────────────────────
|
|
255
|
+
function updateDeviceBanner(service, connected) {
|
|
256
|
+
state.connections[service] = connected;
|
|
257
|
+
const el = $('deviceStatus');
|
|
258
|
+
const text = $('deviceText');
|
|
259
|
+
if (!el || !text) return;
|
|
260
|
+
|
|
261
|
+
const any = state.connections.redux || state.connections.network || state.connections.storage || state.connections.reactDT;
|
|
262
|
+
|
|
263
|
+
if (any) {
|
|
264
|
+
el.className = 'device-status connected';
|
|
265
|
+
text.textContent = 'Device connected';
|
|
266
|
+
} else {
|
|
267
|
+
el.className = 'device-status waiting';
|
|
268
|
+
text.textContent = 'Waiting for device...';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// CONSOLE PANEL
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
function initConsolePanel() {
|
|
276
|
+
const panel = $('panel-console');
|
|
277
|
+
panel.innerHTML = `
|
|
278
|
+
<div class="panel-toolbar">
|
|
279
|
+
<span class="panel-label">Console</span>
|
|
280
|
+
<span class="badge" id="cBadge">0</span>
|
|
281
|
+
<div class="tab-row" style="margin-left:12px">
|
|
282
|
+
<button class="tab active" onclick="setConsoleLevel('all',this)">All</button>
|
|
283
|
+
<button class="tab" onclick="setConsoleLevel('log',this)">Log</button>
|
|
284
|
+
<button class="tab" onclick="setConsoleLevel('info',this)">Info</button>
|
|
285
|
+
<button class="tab" onclick="setConsoleLevel('warn',this)">Warn</button>
|
|
286
|
+
<button class="tab" onclick="setConsoleLevel('error',this)">Error</button>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="ml-auto" style="display:flex;align-items:center;gap:8px">
|
|
289
|
+
<input id="consoleSearch" class="net-search-input" placeholder="Filter logs..." />
|
|
290
|
+
<label class="toggle-label" for="stackTraceToggle" title="Capture stack trace (caller file:line) — disabled by default for performance">
|
|
291
|
+
<span class="toggle-text" id="stackTraceText">Stack OFF</span>
|
|
292
|
+
<input type="checkbox" id="stackTraceToggle" class="toggle-input" />
|
|
293
|
+
<span class="toggle-slider"></span>
|
|
294
|
+
</label>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div class="scroll-area" id="consoleList">
|
|
298
|
+
<div class="empty-state" id="consoleEmpty">
|
|
299
|
+
<div class="icon">⬛</div>
|
|
300
|
+
<div class="label">No logs yet</div>
|
|
301
|
+
<div class="hint">Add RNDebugSDK.js to your app</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>`;
|
|
304
|
+
|
|
305
|
+
$('consoleSearch').addEventListener('input', (e) => {
|
|
306
|
+
state.console.searchFilter = e.target.value.toLowerCase().trim();
|
|
307
|
+
renderConsole();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
$('stackTraceToggle').addEventListener('change', (e) => {
|
|
311
|
+
const enabled = e.target.checked;
|
|
312
|
+
state.console.stackTraceEnabled = enabled;
|
|
313
|
+
$('stackTraceText').textContent = enabled ? 'Stack ON' : 'Stack OFF';
|
|
314
|
+
// Tell the SDK to enable/disable stack capture
|
|
315
|
+
window.electronAPI?.setStackTraceCapture(enabled);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
window.setConsoleLevel = (level, btn) => {
|
|
319
|
+
state.console.levelFilter = level;
|
|
320
|
+
document.querySelectorAll('#panel-console .tab').forEach(b => b.classList.remove('active'));
|
|
321
|
+
btn.classList.add('active');
|
|
322
|
+
renderConsole();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Console is fed via IPC (network-event handled in IPC section above)
|
|
326
|
+
|
|
327
|
+
// ─── Batched console append (fixes re-render performance) ────────────────────
|
|
328
|
+
let _consolePending = [];
|
|
329
|
+
let _consoleRAF = null;
|
|
330
|
+
|
|
331
|
+
function addConsoleLog(event) {
|
|
332
|
+
state.console.logs.push(event);
|
|
333
|
+
_consolePending.push(event);
|
|
334
|
+
// Batch DOM updates via rAF — only one paint per frame
|
|
335
|
+
if (!_consoleRAF) {
|
|
336
|
+
_consoleRAF = requestAnimationFrame(flushConsoleBatch);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function flushConsoleBatch() {
|
|
341
|
+
_consoleRAF = null;
|
|
342
|
+
const batch = _consolePending;
|
|
343
|
+
_consolePending = [];
|
|
344
|
+
if (!batch.length) return;
|
|
345
|
+
|
|
346
|
+
$('cBadge').textContent = state.console.logs.length;
|
|
347
|
+
|
|
348
|
+
const list = $('consoleList');
|
|
349
|
+
const empty = $('consoleEmpty');
|
|
350
|
+
if (!list) return;
|
|
351
|
+
|
|
352
|
+
const { levelFilter, searchFilter } = state.console;
|
|
353
|
+
const frag = document.createDocumentFragment();
|
|
354
|
+
let added = 0;
|
|
355
|
+
|
|
356
|
+
batch.forEach(l => {
|
|
357
|
+
if (levelFilter !== 'all' && l.level !== levelFilter) return;
|
|
358
|
+
if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return;
|
|
359
|
+
frag.appendChild(buildLogRow(l));
|
|
360
|
+
added++;
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (added > 0) {
|
|
364
|
+
empty.style.display = 'none';
|
|
365
|
+
list.appendChild(frag);
|
|
366
|
+
// Keep DOM size manageable — remove oldest rows if over 500
|
|
367
|
+
const rows = list.querySelectorAll('.log-row');
|
|
368
|
+
const MAX_DOM_ROWS = 500;
|
|
369
|
+
if (rows.length > MAX_DOM_ROWS) {
|
|
370
|
+
const toRemove = rows.length - MAX_DOM_ROWS;
|
|
371
|
+
for (let i = 0; i < toRemove; i++) rows[i].remove();
|
|
372
|
+
}
|
|
373
|
+
list.scrollTop = list.scrollHeight;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
window.electronAPI?.on('console-event', addConsoleLog);
|
|
378
|
+
|
|
379
|
+
// ─── Object Tree Renderer (Chrome DevTools-like) ─────────────────────────────
|
|
380
|
+
// Builds interactive, collapsible DOM nodes for objects/arrays.
|
|
381
|
+
|
|
382
|
+
function objPreview(val, maxLen) {
|
|
383
|
+
maxLen = maxLen || 80;
|
|
384
|
+
if (val === null) return 'null';
|
|
385
|
+
if (val === undefined) return 'undefined';
|
|
386
|
+
if (Array.isArray(val)) {
|
|
387
|
+
if (val.length === 0) return '[]';
|
|
388
|
+
const items = [];
|
|
389
|
+
let len = 2; // [ ]
|
|
390
|
+
for (let i = 0; i < val.length && len < maxLen; i++) {
|
|
391
|
+
const s = primitivePreview(val[i]);
|
|
392
|
+
len += s.length + 2;
|
|
393
|
+
items.push(s);
|
|
394
|
+
}
|
|
395
|
+
const suffix = items.length < val.length ? ', ...' : '';
|
|
396
|
+
return `(${val.length}) [${items.join(', ')}${suffix}]`;
|
|
397
|
+
}
|
|
398
|
+
if (typeof val === 'object') {
|
|
399
|
+
const keys = Object.keys(val);
|
|
400
|
+
if (keys.length === 0) return '{}';
|
|
401
|
+
const items = [];
|
|
402
|
+
let len = 2;
|
|
403
|
+
for (let i = 0; i < keys.length && len < maxLen; i++) {
|
|
404
|
+
const s = `${keys[i]}: ${primitivePreview(val[keys[i]])}`;
|
|
405
|
+
len += s.length + 2;
|
|
406
|
+
items.push(s);
|
|
407
|
+
}
|
|
408
|
+
const suffix = items.length < keys.length ? ', ...' : '';
|
|
409
|
+
return `{${items.join(', ')}${suffix}}`;
|
|
410
|
+
}
|
|
411
|
+
return primitivePreview(val);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function primitivePreview(val) {
|
|
415
|
+
if (val === null) return 'null';
|
|
416
|
+
if (val === undefined) return 'undefined';
|
|
417
|
+
if (typeof val === 'string') return val.length > 50 ? `"${val.slice(0,50)}..."` : `"${val}"`;
|
|
418
|
+
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
|
419
|
+
if (Array.isArray(val)) return `Array(${val.length})`;
|
|
420
|
+
if (typeof val === 'object') return `{...}`;
|
|
421
|
+
return String(val);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function createTreeNode(key, val, startCollapsed) {
|
|
425
|
+
const isArray = Array.isArray(val);
|
|
426
|
+
const isObj = val !== null && typeof val === 'object';
|
|
427
|
+
|
|
428
|
+
if (!isObj) {
|
|
429
|
+
// Primitive leaf
|
|
430
|
+
const row = document.createElement('div');
|
|
431
|
+
row.className = 'ov-leaf';
|
|
432
|
+
if (key !== null) {
|
|
433
|
+
const k = document.createElement('span');
|
|
434
|
+
k.className = 'ov-key';
|
|
435
|
+
k.textContent = isNaN(key) ? `${key}: ` : `${key}: `;
|
|
436
|
+
row.appendChild(k);
|
|
437
|
+
}
|
|
438
|
+
row.appendChild(createPrimitiveSpan(val));
|
|
439
|
+
return row;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Collapsible object/array
|
|
443
|
+
const container = document.createElement('div');
|
|
444
|
+
container.className = 'ov-node';
|
|
445
|
+
|
|
446
|
+
const header = document.createElement('div');
|
|
447
|
+
header.className = 'ov-header';
|
|
448
|
+
|
|
449
|
+
const arrow = document.createElement('span');
|
|
450
|
+
arrow.className = 'ov-arrow';
|
|
451
|
+
arrow.textContent = '\u25B6'; // ▶
|
|
452
|
+
header.appendChild(arrow);
|
|
453
|
+
|
|
454
|
+
if (key !== null) {
|
|
455
|
+
const k = document.createElement('span');
|
|
456
|
+
k.className = 'ov-key';
|
|
457
|
+
k.textContent = `${key}: `;
|
|
458
|
+
header.appendChild(k);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const preview = document.createElement('span');
|
|
462
|
+
preview.className = 'ov-preview';
|
|
463
|
+
preview.textContent = objPreview(val);
|
|
464
|
+
header.appendChild(preview);
|
|
465
|
+
|
|
466
|
+
container.appendChild(header);
|
|
467
|
+
|
|
468
|
+
const children = document.createElement('div');
|
|
469
|
+
children.className = 'ov-children';
|
|
470
|
+
children.style.display = 'none';
|
|
471
|
+
|
|
472
|
+
let populated = false;
|
|
473
|
+
|
|
474
|
+
function populateChildren() {
|
|
475
|
+
if (populated) return;
|
|
476
|
+
populated = true;
|
|
477
|
+
const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val);
|
|
478
|
+
entries.forEach(([k, v]) => {
|
|
479
|
+
children.appendChild(createTreeNode(k, v, true));
|
|
480
|
+
});
|
|
481
|
+
// For arrays show length, for objects show prototype hint
|
|
482
|
+
if (isArray) {
|
|
483
|
+
const lenNode = document.createElement('div');
|
|
484
|
+
lenNode.className = 'ov-leaf ov-meta';
|
|
485
|
+
lenNode.textContent = `length: ${val.length}`;
|
|
486
|
+
children.appendChild(lenNode);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let expanded = !startCollapsed;
|
|
491
|
+
if (expanded) {
|
|
492
|
+
populateChildren();
|
|
493
|
+
children.style.display = 'block';
|
|
494
|
+
arrow.textContent = '\u25BC'; // ▼
|
|
495
|
+
arrow.classList.add('expanded');
|
|
496
|
+
preview.style.display = 'none';
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
header.addEventListener('click', (e) => {
|
|
500
|
+
e.stopPropagation();
|
|
501
|
+
expanded = !expanded;
|
|
502
|
+
if (expanded) {
|
|
503
|
+
populateChildren();
|
|
504
|
+
children.style.display = 'block';
|
|
505
|
+
arrow.textContent = '\u25BC';
|
|
506
|
+
arrow.classList.add('expanded');
|
|
507
|
+
preview.style.display = 'none';
|
|
508
|
+
} else {
|
|
509
|
+
children.style.display = 'none';
|
|
510
|
+
arrow.textContent = '\u25B6';
|
|
511
|
+
arrow.classList.remove('expanded');
|
|
512
|
+
preview.style.display = '';
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
container.appendChild(children);
|
|
517
|
+
return container;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function createPrimitiveSpan(val) {
|
|
521
|
+
const s = document.createElement('span');
|
|
522
|
+
if (val === null) { s.className = 'ov-null'; s.textContent = 'null'; }
|
|
523
|
+
else if (val === undefined) { s.className = 'ov-undef'; s.textContent = 'undefined'; }
|
|
524
|
+
else if (typeof val === 'string') { s.className = 'ov-str'; s.textContent = `"${val}"`; }
|
|
525
|
+
else if (typeof val === 'number') { s.className = 'ov-num'; s.textContent = String(val); }
|
|
526
|
+
else if (typeof val === 'boolean') { s.className = 'ov-bool'; s.textContent = String(val); }
|
|
527
|
+
else { s.textContent = String(val); }
|
|
528
|
+
return s;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Parse a structured arg from the SDK (or fall back to raw message string)
|
|
532
|
+
function renderConsoleArg(arg) {
|
|
533
|
+
if (!arg || typeof arg !== 'object' || !arg.t) {
|
|
534
|
+
// Backward compat: raw string
|
|
535
|
+
const s = document.createElement('span');
|
|
536
|
+
s.className = 'ov-str';
|
|
537
|
+
s.textContent = String(arg);
|
|
538
|
+
return s;
|
|
539
|
+
}
|
|
540
|
+
const { t, v } = arg;
|
|
541
|
+
if (t === 'string') {
|
|
542
|
+
const s = document.createElement('span');
|
|
543
|
+
s.className = 'log-text';
|
|
544
|
+
s.textContent = v;
|
|
545
|
+
return s;
|
|
546
|
+
}
|
|
547
|
+
if (t === 'number') { return createPrimitiveSpan(v); }
|
|
548
|
+
if (t === 'boolean') { return createPrimitiveSpan(v); }
|
|
549
|
+
if (t === 'null') { return createPrimitiveSpan(null); }
|
|
550
|
+
if (t === 'undefined') { return createPrimitiveSpan(undefined); }
|
|
551
|
+
if (t === 'object' || t === 'array') {
|
|
552
|
+
return createTreeNode(null, v, false);
|
|
553
|
+
}
|
|
554
|
+
const s = document.createElement('span');
|
|
555
|
+
s.textContent = String(v);
|
|
556
|
+
return s;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Build the body of a console log row. If structured args exist, render each;
|
|
560
|
+
// otherwise fall back to the flat message string and try to detect JSON in it.
|
|
561
|
+
function buildLogBody(logEntry) {
|
|
562
|
+
const container = document.createElement('div');
|
|
563
|
+
container.className = 'log-body';
|
|
564
|
+
|
|
565
|
+
if (logEntry.args && Array.isArray(logEntry.args) && logEntry.args.length > 0) {
|
|
566
|
+
// Structured args from updated SDK
|
|
567
|
+
logEntry.args.forEach((arg, i) => {
|
|
568
|
+
if (i > 0) container.appendChild(document.createTextNode(' '));
|
|
569
|
+
container.appendChild(renderConsoleArg(arg));
|
|
570
|
+
});
|
|
571
|
+
} else if (logEntry.message != null) {
|
|
572
|
+
// Legacy / flat message — try to parse JSON objects out of it
|
|
573
|
+
const msg = String(logEntry.message);
|
|
574
|
+
// Try parsing the whole message as JSON
|
|
575
|
+
try {
|
|
576
|
+
const parsed = JSON.parse(msg);
|
|
577
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
578
|
+
container.appendChild(createTreeNode(null, parsed, false));
|
|
579
|
+
return container;
|
|
580
|
+
}
|
|
581
|
+
} catch {}
|
|
582
|
+
|
|
583
|
+
// Otherwise render as text, but look for embedded JSON blocks
|
|
584
|
+
// If it looks like it contains JSON, try to pretty-render inline
|
|
585
|
+
const jsonRe = /(\{[\s\S]*\}|\[[\s\S]*\])/;
|
|
586
|
+
const match = msg.match(jsonRe);
|
|
587
|
+
if (match && match[0].length > 2) {
|
|
588
|
+
try {
|
|
589
|
+
const parsed = JSON.parse(match[0]);
|
|
590
|
+
// There's text before/after
|
|
591
|
+
const before = msg.slice(0, match.index);
|
|
592
|
+
const after = msg.slice(match.index + match[0].length);
|
|
593
|
+
if (before) container.appendChild(document.createTextNode(before));
|
|
594
|
+
container.appendChild(createTreeNode(null, parsed, false));
|
|
595
|
+
if (after) container.appendChild(document.createTextNode(after));
|
|
596
|
+
return container;
|
|
597
|
+
} catch {}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Plain text
|
|
601
|
+
const span = document.createElement('span');
|
|
602
|
+
span.className = 'log-text';
|
|
603
|
+
span.textContent = msg;
|
|
604
|
+
container.appendChild(span);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return container;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function buildLogRow(l) {
|
|
611
|
+
const div = document.createElement('div');
|
|
612
|
+
div.className = `log-row entry ${l.level}`;
|
|
613
|
+
|
|
614
|
+
const timeSpan = document.createElement('span');
|
|
615
|
+
timeSpan.className = 'log-time';
|
|
616
|
+
timeSpan.textContent = ts(l.ts);
|
|
617
|
+
div.appendChild(timeSpan);
|
|
618
|
+
|
|
619
|
+
const lvlSpan = document.createElement('span');
|
|
620
|
+
lvlSpan.className = `lvl-badge lvl-${l.level}`;
|
|
621
|
+
lvlSpan.textContent = l.level;
|
|
622
|
+
div.appendChild(lvlSpan);
|
|
623
|
+
|
|
624
|
+
// Body wrapper with preview (collapsed) and full (expanded)
|
|
625
|
+
const bodyWrap = document.createElement('div');
|
|
626
|
+
bodyWrap.className = 'log-body-wrap';
|
|
627
|
+
|
|
628
|
+
// Single-line preview with caller at end
|
|
629
|
+
const preview = document.createElement('div');
|
|
630
|
+
preview.className = 'log-preview';
|
|
631
|
+
const msgText = (l.message || '').replace(/\n/g, ' ').slice(0, 200);
|
|
632
|
+
const previewText = document.createElement('span');
|
|
633
|
+
previewText.textContent = msgText + ((l.message || '').length > 200 ? '...' : '');
|
|
634
|
+
preview.appendChild(previewText);
|
|
635
|
+
if (l.caller) {
|
|
636
|
+
// Extract short filename:line from caller like "at Component (file.js:42:10)"
|
|
637
|
+
const callerShort = extractCallerShort(l.caller);
|
|
638
|
+
if (callerShort) {
|
|
639
|
+
const callerTag = document.createElement('span');
|
|
640
|
+
callerTag.className = 'log-caller-inline';
|
|
641
|
+
callerTag.textContent = callerShort;
|
|
642
|
+
preview.appendChild(callerTag);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
bodyWrap.appendChild(preview);
|
|
646
|
+
|
|
647
|
+
// Full content (hidden by default)
|
|
648
|
+
const full = document.createElement('div');
|
|
649
|
+
full.className = 'log-full';
|
|
650
|
+
full.style.display = 'none';
|
|
651
|
+
full.appendChild(buildLogBody(l));
|
|
652
|
+
if (l.caller) {
|
|
653
|
+
const callerSpan = document.createElement('span');
|
|
654
|
+
callerSpan.className = 'log-caller';
|
|
655
|
+
callerSpan.textContent = l.caller;
|
|
656
|
+
full.appendChild(callerSpan);
|
|
657
|
+
}
|
|
658
|
+
bodyWrap.appendChild(full);
|
|
659
|
+
|
|
660
|
+
// Expand/collapse arrow
|
|
661
|
+
const arrow = document.createElement('span');
|
|
662
|
+
arrow.className = 'log-arrow';
|
|
663
|
+
arrow.textContent = '\u25B6';
|
|
664
|
+
bodyWrap.prepend(arrow);
|
|
665
|
+
|
|
666
|
+
let expanded = false;
|
|
667
|
+
// Only toggle on click, NOT on text selection drag
|
|
668
|
+
let _mouseDownPos = null;
|
|
669
|
+
bodyWrap.addEventListener('mousedown', (e) => {
|
|
670
|
+
_mouseDownPos = { x: e.clientX, y: e.clientY };
|
|
671
|
+
});
|
|
672
|
+
bodyWrap.addEventListener('click', (e) => {
|
|
673
|
+
// Don't toggle if user is selecting text (dragged mouse)
|
|
674
|
+
if (_mouseDownPos) {
|
|
675
|
+
const dx = Math.abs(e.clientX - _mouseDownPos.x);
|
|
676
|
+
const dy = Math.abs(e.clientY - _mouseDownPos.y);
|
|
677
|
+
if (dx > 3 || dy > 3) return; // user dragged to select
|
|
678
|
+
}
|
|
679
|
+
// Don't toggle if there's an active text selection
|
|
680
|
+
const sel = window.getSelection();
|
|
681
|
+
if (sel && sel.toString().length > 0) return;
|
|
682
|
+
// Don't toggle if clicking inside object tree expander
|
|
683
|
+
if (e.target.closest('.ov-header')) return;
|
|
684
|
+
expanded = !expanded;
|
|
685
|
+
if (expanded) {
|
|
686
|
+
preview.style.display = 'none';
|
|
687
|
+
full.style.display = 'block';
|
|
688
|
+
arrow.textContent = '\u25BC';
|
|
689
|
+
arrow.classList.add('expanded');
|
|
690
|
+
} else {
|
|
691
|
+
preview.style.display = '';
|
|
692
|
+
full.style.display = 'none';
|
|
693
|
+
arrow.textContent = '\u25B6';
|
|
694
|
+
arrow.classList.remove('expanded');
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// Right-click → copy options
|
|
699
|
+
div.addEventListener('contextmenu', (e) => {
|
|
700
|
+
e.preventDefault();
|
|
701
|
+
const items = [];
|
|
702
|
+
|
|
703
|
+
// Copy selected text
|
|
704
|
+
const sel = window.getSelection();
|
|
705
|
+
if (sel && sel.toString().length > 0) {
|
|
706
|
+
items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Copy full log message
|
|
710
|
+
items.push({ label: 'Copy Message', action: () => {
|
|
711
|
+
navigator.clipboard.writeText(l.message || '');
|
|
712
|
+
}});
|
|
713
|
+
|
|
714
|
+
// Copy as JSON (if structured args exist)
|
|
715
|
+
if (l.args && l.args.length > 0) {
|
|
716
|
+
items.push({ label: 'Copy as JSON', action: () => {
|
|
717
|
+
const json = l.args.map(a => {
|
|
718
|
+
if (a.t === 'object' || a.t === 'array') return JSON.stringify(a.v, null, 2);
|
|
719
|
+
return String(a.v);
|
|
720
|
+
}).join(' ');
|
|
721
|
+
navigator.clipboard.writeText(json);
|
|
722
|
+
}});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Copy caller location
|
|
726
|
+
if (l.caller) {
|
|
727
|
+
items.push({ label: 'Copy Caller', action: () => navigator.clipboard.writeText(l.caller) });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
showContextMenu(e, items);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
div.appendChild(bodyWrap);
|
|
734
|
+
return div;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ─── Shared context menu helper ──────────────────────────────────────────────
|
|
738
|
+
function showContextMenu(e, items) {
|
|
739
|
+
document.querySelectorAll('.ctx-menu').forEach(el => el.remove());
|
|
740
|
+
const menu = document.createElement('div');
|
|
741
|
+
menu.className = 'ctx-menu';
|
|
742
|
+
items.forEach(({ label, action }) => {
|
|
743
|
+
const item = document.createElement('div');
|
|
744
|
+
item.className = 'ctx-item';
|
|
745
|
+
item.textContent = label;
|
|
746
|
+
item.addEventListener('click', () => { action(); menu.remove(); });
|
|
747
|
+
menu.appendChild(item);
|
|
748
|
+
});
|
|
749
|
+
menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
|
|
750
|
+
menu.style.top = Math.min(e.clientY, window.innerHeight - items.length * 32 - 10) + 'px';
|
|
751
|
+
document.body.appendChild(menu);
|
|
752
|
+
setTimeout(() => {
|
|
753
|
+
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close); } };
|
|
754
|
+
document.addEventListener('click', close);
|
|
755
|
+
}, 0);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Full re-render — only used on filter/level change, NOT on every incoming log
|
|
759
|
+
function renderConsole() {
|
|
760
|
+
const list = $('consoleList');
|
|
761
|
+
const empty = $('consoleEmpty');
|
|
762
|
+
if (!list) return;
|
|
763
|
+
|
|
764
|
+
const { levelFilter, searchFilter } = state.console;
|
|
765
|
+
const visible = state.console.logs.filter(l => {
|
|
766
|
+
if (levelFilter !== 'all' && l.level !== levelFilter) return false;
|
|
767
|
+
if (searchFilter && !l.message?.toLowerCase().includes(searchFilter)) return false;
|
|
768
|
+
return true;
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
list.querySelectorAll('.log-row').forEach(e => e.remove());
|
|
772
|
+
empty.style.display = visible.length ? 'none' : 'flex';
|
|
773
|
+
|
|
774
|
+
// Render only the last 500 visible rows for performance
|
|
775
|
+
const MAX_RENDER = 500;
|
|
776
|
+
const toRender = visible.length > MAX_RENDER ? visible.slice(-MAX_RENDER) : visible;
|
|
777
|
+
if (visible.length > MAX_RENDER) {
|
|
778
|
+
const info = document.createElement('div');
|
|
779
|
+
info.className = 'log-row';
|
|
780
|
+
info.style.cssText = 'color:var(--text-dim);font-size:10px;padding:6px 14px;text-align:center;font-style:italic';
|
|
781
|
+
info.textContent = `${visible.length - MAX_RENDER} older logs hidden for performance`;
|
|
782
|
+
list.appendChild(info);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const frag = document.createDocumentFragment();
|
|
786
|
+
toRender.forEach(l => frag.appendChild(buildLogRow(l)));
|
|
787
|
+
list.appendChild(frag);
|
|
788
|
+
list.scrollTop = list.scrollHeight;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
792
|
+
// NETWORK PANEL (Chrome DevTools-style)
|
|
793
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
794
|
+
const NET_COLS = [
|
|
795
|
+
{ key: 'name', label: 'Name', width: 260, min: 100 },
|
|
796
|
+
{ key: 'status', label: 'Status', width: 60, min: 40 },
|
|
797
|
+
{ key: 'type', label: 'Type', width: 70, min: 40 },
|
|
798
|
+
{ key: 'initiator', label: 'Initiator', width: 90, min: 50 },
|
|
799
|
+
{ key: 'size', label: 'Size', width: 70, min: 40 },
|
|
800
|
+
{ key: 'time', label: 'Time', width: 70, min: 40 },
|
|
801
|
+
{ key: 'waterfall', label: 'Waterfall', width: 120, min: 60 },
|
|
802
|
+
];
|
|
803
|
+
|
|
804
|
+
function initNetworkPanel() {
|
|
805
|
+
const panel = $('panel-network');
|
|
806
|
+
panel.innerHTML = `
|
|
807
|
+
<div class="panel-toolbar">
|
|
808
|
+
<span class="panel-label">Network</span>
|
|
809
|
+
<span class="badge" id="nBadge">0</span>
|
|
810
|
+
<div class="ml-auto" style="display:flex;align-items:center;gap:6px">
|
|
811
|
+
<label class="toggle-label" for="netToggle">
|
|
812
|
+
<span class="toggle-text" id="netToggleText">Capture ON</span>
|
|
813
|
+
<input type="checkbox" id="netToggle" class="toggle-input" checked />
|
|
814
|
+
<span class="toggle-slider"></span>
|
|
815
|
+
</label>
|
|
816
|
+
</div>
|
|
817
|
+
</div>
|
|
818
|
+
<div class="net-filter-bar" id="netFilterBar">
|
|
819
|
+
<input id="netSearchInput" class="net-search-input" placeholder="Filter URLs..." />
|
|
820
|
+
<div class="net-type-filters" id="netTypeFilters">
|
|
821
|
+
<button class="net-type-btn active" data-type="all">All</button>
|
|
822
|
+
<button class="net-type-btn" data-type="fetch">Fetch/XHR</button>
|
|
823
|
+
<button class="net-type-btn" data-type="js">JS</button>
|
|
824
|
+
<button class="net-type-btn" data-type="css">CSS</button>
|
|
825
|
+
<button class="net-type-btn" data-type="img">Img</button>
|
|
826
|
+
<button class="net-type-btn" data-type="media">Media</button>
|
|
827
|
+
<button class="net-type-btn" data-type="font">Font</button>
|
|
828
|
+
<button class="net-type-btn" data-type="doc">Doc</button>
|
|
829
|
+
<button class="net-type-btn" data-type="ws">WS</button>
|
|
830
|
+
</div>
|
|
831
|
+
<div class="net-throttle" id="netThrottle">
|
|
832
|
+
<select id="netThrottleSelect" class="net-throttle-select">
|
|
833
|
+
<option value="none">No throttling</option>
|
|
834
|
+
<option value="fast3g">Fast 3G</option>
|
|
835
|
+
<option value="slow3g">Slow 3G</option>
|
|
836
|
+
<option value="offline">Offline</option>
|
|
837
|
+
</select>
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
<div class="net-layout">
|
|
841
|
+
<div class="net-table-wrap" id="netTableWrap">
|
|
842
|
+
<div class="net-header" id="netHeader"></div>
|
|
843
|
+
<div class="net-rows" id="netRows">
|
|
844
|
+
<div class="empty-state" id="networkEmpty">
|
|
845
|
+
<div class="icon">📡</div>
|
|
846
|
+
<div class="label">No requests yet</div>
|
|
847
|
+
<div class="hint">API calls will appear here automatically</div>
|
|
848
|
+
</div>
|
|
849
|
+
</div>
|
|
850
|
+
</div>
|
|
851
|
+
<div class="net-detail-pane" id="netDetailPane">
|
|
852
|
+
<div class="net-detail-bar">
|
|
853
|
+
<div class="detail-tabs" id="netDetailTabs"></div>
|
|
854
|
+
<button class="detail-close" id="netDetailClose" title="Close">×</button>
|
|
855
|
+
</div>
|
|
856
|
+
<div class="detail-content" id="netDetailContent"></div>
|
|
857
|
+
</div>
|
|
858
|
+
</div>`;
|
|
859
|
+
|
|
860
|
+
$('netToggle').addEventListener('change', (e) => {
|
|
861
|
+
state.network.enabled = e.target.checked;
|
|
862
|
+
$('netToggleText').textContent = e.target.checked ? 'Capture ON' : 'Capture OFF';
|
|
863
|
+
window.electronAPI?.setNetworkCapture(e.target.checked);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// Network search input
|
|
867
|
+
$('netSearchInput').addEventListener('input', (e) => {
|
|
868
|
+
state.network.searchFilter = e.target.value.toLowerCase().trim();
|
|
869
|
+
renderNetwork();
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// Type filter buttons
|
|
873
|
+
$('netTypeFilters').addEventListener('click', (e) => {
|
|
874
|
+
const btn = e.target.closest('.net-type-btn');
|
|
875
|
+
if (!btn) return;
|
|
876
|
+
$('netTypeFilters').querySelectorAll('.net-type-btn').forEach(b => b.classList.remove('active'));
|
|
877
|
+
btn.classList.add('active');
|
|
878
|
+
state.network.typeFilter = btn.dataset.type;
|
|
879
|
+
renderNetwork();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// Throttle select
|
|
883
|
+
$('netThrottleSelect').addEventListener('change', (e) => {
|
|
884
|
+
state.network.throttle = e.target.value;
|
|
885
|
+
// Send throttle config to the RN app
|
|
886
|
+
window.electronAPI?.setNetworkThrottle(state.network.throttle);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Close detail button
|
|
890
|
+
$('netDetailClose').addEventListener('click', closeNetDetail);
|
|
891
|
+
|
|
892
|
+
buildNetHeader();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ─── Column header with sort icons + full-height resize handles ──────────────
|
|
896
|
+
function buildNetHeader() {
|
|
897
|
+
const header = $('netHeader');
|
|
898
|
+
header.innerHTML = '';
|
|
899
|
+
NET_COLS.forEach((col, i) => {
|
|
900
|
+
const cell = document.createElement('div');
|
|
901
|
+
cell.className = 'net-hcell';
|
|
902
|
+
cell.style.width = col.width + 'px';
|
|
903
|
+
cell.dataset.col = col.key;
|
|
904
|
+
|
|
905
|
+
const label = document.createElement('span');
|
|
906
|
+
label.className = 'net-hcell-label';
|
|
907
|
+
label.textContent = col.label;
|
|
908
|
+
cell.appendChild(label);
|
|
909
|
+
|
|
910
|
+
if (col.key !== 'waterfall') {
|
|
911
|
+
const sortIcon = document.createElement('span');
|
|
912
|
+
sortIcon.className = 'net-sort-icon';
|
|
913
|
+
if (state.network.sortCol === col.key) {
|
|
914
|
+
sortIcon.textContent = state.network.sortDir === 'asc' ? ' \u25B2' : ' \u25BC';
|
|
915
|
+
sortIcon.classList.add('active');
|
|
916
|
+
}
|
|
917
|
+
cell.appendChild(sortIcon);
|
|
918
|
+
cell.addEventListener('click', (e) => {
|
|
919
|
+
if (e.target.closest('.net-hcell-resize')) return;
|
|
920
|
+
if (state.network.sortCol === col.key) {
|
|
921
|
+
state.network.sortDir = state.network.sortDir === 'asc' ? 'desc' : 'asc';
|
|
922
|
+
} else {
|
|
923
|
+
state.network.sortCol = col.key;
|
|
924
|
+
state.network.sortDir = col.key === 'name' ? 'asc' : 'desc';
|
|
925
|
+
}
|
|
926
|
+
buildNetHeader();
|
|
927
|
+
renderNetwork();
|
|
928
|
+
});
|
|
929
|
+
cell.style.cursor = 'pointer';
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Resize handle in header
|
|
933
|
+
if (i < NET_COLS.length - 1) {
|
|
934
|
+
const handle = document.createElement('div');
|
|
935
|
+
handle.className = 'net-hcell-resize';
|
|
936
|
+
handle.addEventListener('mousedown', (e) => startColResize(e, col));
|
|
937
|
+
cell.appendChild(handle);
|
|
938
|
+
}
|
|
939
|
+
header.appendChild(cell);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
// Build full-height resize overlay lines
|
|
943
|
+
buildResizeOverlays();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function buildResizeOverlays() {
|
|
947
|
+
// Remove old overlays
|
|
948
|
+
document.querySelectorAll('.net-resize-overlay').forEach(e => e.remove());
|
|
949
|
+
const tableWrap = $('netTableWrap');
|
|
950
|
+
if (!tableWrap) return;
|
|
951
|
+
// Make the table wrap position:relative for overlay positioning
|
|
952
|
+
tableWrap.style.position = 'relative';
|
|
953
|
+
|
|
954
|
+
let leftOffset = 0;
|
|
955
|
+
NET_COLS.forEach((col, i) => {
|
|
956
|
+
leftOffset += col.width;
|
|
957
|
+
if (i >= NET_COLS.length - 1) return; // no handle after last column
|
|
958
|
+
|
|
959
|
+
const overlay = document.createElement('div');
|
|
960
|
+
overlay.className = 'net-resize-overlay';
|
|
961
|
+
overlay.style.left = (leftOffset - 3) + 'px';
|
|
962
|
+
overlay.addEventListener('mousedown', (e) => startColResize(e, col));
|
|
963
|
+
tableWrap.appendChild(overlay);
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function startColResize(e, col) {
|
|
968
|
+
e.preventDefault();
|
|
969
|
+
e.stopPropagation();
|
|
970
|
+
const startX = e.clientX;
|
|
971
|
+
const startW = col.width;
|
|
972
|
+
|
|
973
|
+
// Add visual feedback
|
|
974
|
+
document.body.style.cursor = 'col-resize';
|
|
975
|
+
document.body.style.userSelect = 'none';
|
|
976
|
+
|
|
977
|
+
function onMove(ev) {
|
|
978
|
+
const delta = ev.clientX - startX;
|
|
979
|
+
col.width = Math.max(col.min, startW + delta);
|
|
980
|
+
// Update header + all data cells for this column
|
|
981
|
+
document.querySelectorAll(`.net-cell[data-col="${col.key}"], .net-hcell[data-col="${col.key}"]`)
|
|
982
|
+
.forEach(el => el.style.width = col.width + 'px');
|
|
983
|
+
// Keep detail pane aligned with Name column
|
|
984
|
+
if (col.key === 'name' && state.network.selectedId) {
|
|
985
|
+
const pane = $('netDetailPane');
|
|
986
|
+
if (pane) pane.style.left = (col.width + 1) + 'px';
|
|
987
|
+
}
|
|
988
|
+
// Reposition overlays
|
|
989
|
+
buildResizeOverlays();
|
|
990
|
+
}
|
|
991
|
+
function onUp() {
|
|
992
|
+
document.body.style.cursor = '';
|
|
993
|
+
document.body.style.userSelect = '';
|
|
994
|
+
document.removeEventListener('mousemove', onMove);
|
|
995
|
+
document.removeEventListener('mouseup', onUp);
|
|
996
|
+
}
|
|
997
|
+
document.addEventListener('mousemove', onMove);
|
|
998
|
+
document.addEventListener('mouseup', onUp);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ─── Network type matching ──────────────────────────────────────────────────
|
|
1002
|
+
function matchNetType(r, type) {
|
|
1003
|
+
const ct = (r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '').toLowerCase();
|
|
1004
|
+
const url = (r.url || '').toLowerCase();
|
|
1005
|
+
switch (type) {
|
|
1006
|
+
case 'fetch': return true; // All XHR/fetch requests pass
|
|
1007
|
+
case 'js': return ct.includes('javascript') || url.endsWith('.js') || url.endsWith('.bundle');
|
|
1008
|
+
case 'css': return ct.includes('css') || url.endsWith('.css');
|
|
1009
|
+
case 'img': return ct.includes('image') || /\.(png|jpg|jpeg|gif|svg|webp|ico)(\?|$)/i.test(url);
|
|
1010
|
+
case 'media': return ct.includes('video') || ct.includes('audio') || /\.(mp4|mp3|wav|webm)(\?|$)/i.test(url);
|
|
1011
|
+
case 'font': return ct.includes('font') || /\.(woff2?|ttf|otf|eot)(\?|$)/i.test(url);
|
|
1012
|
+
case 'doc': return ct.includes('html') || ct.includes('xml');
|
|
1013
|
+
case 'ws': return url.startsWith('ws://') || url.startsWith('wss://');
|
|
1014
|
+
default: return true;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
let _netRAF = null;
|
|
1019
|
+
|
|
1020
|
+
function handleNetworkEvent(event) {
|
|
1021
|
+
if (event.type === 'console') { addConsoleLog(event); return; }
|
|
1022
|
+
if (event.type !== 'network') return;
|
|
1023
|
+
if (!state.network.enabled) return;
|
|
1024
|
+
|
|
1025
|
+
const { id, phase } = event;
|
|
1026
|
+
if (phase === 'request') {
|
|
1027
|
+
state.network.requests[id] = { ...event, _tab: 'headers' };
|
|
1028
|
+
if (!state.network.order.includes(id)) state.network.order.push(id);
|
|
1029
|
+
$('nBadge').textContent = state.network.order.length;
|
|
1030
|
+
} else {
|
|
1031
|
+
Object.assign(state.network.requests[id] || (state.network.requests[id] = {}), event);
|
|
1032
|
+
}
|
|
1033
|
+
if (!_netRAF) {
|
|
1034
|
+
_netRAF = requestAnimationFrame(() => {
|
|
1035
|
+
_netRAF = null;
|
|
1036
|
+
renderNetwork();
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ─── Sort network IDs ───────────────────────────────────────────────────────
|
|
1042
|
+
function sortNetworkIds(ids) {
|
|
1043
|
+
const { sortCol, sortDir } = state.network;
|
|
1044
|
+
const reqs = state.network.requests;
|
|
1045
|
+
const sorted = [...ids].sort((a, b) => {
|
|
1046
|
+
const ra = reqs[a], rb = reqs[b];
|
|
1047
|
+
if (!ra || !rb) return 0;
|
|
1048
|
+
let va, vb;
|
|
1049
|
+
switch (sortCol) {
|
|
1050
|
+
case 'name':
|
|
1051
|
+
va = (ra.url || '').toLowerCase(); vb = (rb.url || '').toLowerCase();
|
|
1052
|
+
return va < vb ? -1 : va > vb ? 1 : 0;
|
|
1053
|
+
case 'status':
|
|
1054
|
+
va = ra.status || 0; vb = rb.status || 0;
|
|
1055
|
+
return va - vb;
|
|
1056
|
+
case 'type':
|
|
1057
|
+
va = (ra.responseHeaders?.['content-type'] || '').toLowerCase();
|
|
1058
|
+
vb = (rb.responseHeaders?.['content-type'] || '').toLowerCase();
|
|
1059
|
+
return va < vb ? -1 : va > vb ? 1 : 0;
|
|
1060
|
+
case 'size':
|
|
1061
|
+
// Use cached size or estimate — avoid JSON.stringify in sort comparator
|
|
1062
|
+
va = ra._cachedSize ?? (ra._cachedSize = typeof ra.responseBody === 'string' ? ra.responseBody.length : (ra.responseBody != null ? 100 : 0));
|
|
1063
|
+
vb = rb._cachedSize ?? (rb._cachedSize = typeof rb.responseBody === 'string' ? rb.responseBody.length : (rb.responseBody != null ? 100 : 0));
|
|
1064
|
+
return va - vb;
|
|
1065
|
+
case 'time':
|
|
1066
|
+
default:
|
|
1067
|
+
va = ra.ts || 0; vb = rb.ts || 0;
|
|
1068
|
+
return va - vb;
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
if (sortDir === 'desc') sorted.reverse();
|
|
1072
|
+
return sorted;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ─── Render network rows ────────────────────────────────────────────────────
|
|
1076
|
+
function renderNetwork() {
|
|
1077
|
+
const rows = $('netRows');
|
|
1078
|
+
const empty = $('networkEmpty');
|
|
1079
|
+
if (!rows) return;
|
|
1080
|
+
|
|
1081
|
+
const { statusFilter, typeFilter, searchFilter } = state.network;
|
|
1082
|
+
const visible = state.network.order.filter(id => {
|
|
1083
|
+
const r = state.network.requests[id];
|
|
1084
|
+
if (!r) return false;
|
|
1085
|
+
if (statusFilter === '2xx' && !(r.status >= 200 && r.status < 300)) return false;
|
|
1086
|
+
if (statusFilter === 'errors' && !(r.phase === 'error' || r.status >= 400)) return false;
|
|
1087
|
+
if (searchFilter && !r.url?.toLowerCase().includes(searchFilter)) return false;
|
|
1088
|
+
if (typeFilter !== 'all' && !matchNetType(r, typeFilter)) return false;
|
|
1089
|
+
return true;
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
// Sort: apply current sort, default = newest first
|
|
1093
|
+
const sortedVisible = sortNetworkIds(visible);
|
|
1094
|
+
|
|
1095
|
+
empty.style.display = sortedVisible.length ? 'none' : 'flex';
|
|
1096
|
+
rows.querySelectorAll('.net-row').forEach(e => e.remove());
|
|
1097
|
+
|
|
1098
|
+
// Waterfall scale: find min/max timestamps
|
|
1099
|
+
let wfMin = Infinity, wfMax = 0;
|
|
1100
|
+
sortedVisible.forEach(id => {
|
|
1101
|
+
const r = state.network.requests[id];
|
|
1102
|
+
if (r.ts) { wfMin = Math.min(wfMin, r.ts); wfMax = Math.max(wfMax, r.ts + (r.duration || 0)); }
|
|
1103
|
+
});
|
|
1104
|
+
const wfRange = Math.max(wfMax - wfMin, 1);
|
|
1105
|
+
|
|
1106
|
+
// Render max 300 rows for performance
|
|
1107
|
+
const MAX_NET_ROWS = 300;
|
|
1108
|
+
const toRender = sortedVisible.length > MAX_NET_ROWS ? sortedVisible.slice(0, MAX_NET_ROWS) : sortedVisible;
|
|
1109
|
+
|
|
1110
|
+
const frag = document.createDocumentFragment();
|
|
1111
|
+
if (sortedVisible.length > MAX_NET_ROWS) {
|
|
1112
|
+
const info = document.createElement('div');
|
|
1113
|
+
info.className = 'net-row';
|
|
1114
|
+
info.style.cssText = 'color:var(--text-dim);font-size:10px;padding:6px 14px;justify-content:center;font-style:italic';
|
|
1115
|
+
info.textContent = `Showing ${MAX_NET_ROWS} of ${sortedVisible.length} requests`;
|
|
1116
|
+
frag.appendChild(info);
|
|
1117
|
+
}
|
|
1118
|
+
toRender.forEach(id => {
|
|
1119
|
+
const r = state.network.requests[id];
|
|
1120
|
+
frag.appendChild(buildNetRow(r, wfMin, wfRange));
|
|
1121
|
+
});
|
|
1122
|
+
rows.appendChild(frag);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function buildNetRow(r, wfMin, wfRange) {
|
|
1126
|
+
const row = document.createElement('div');
|
|
1127
|
+
row.className = 'net-row' + (r.id === state.network.selectedId ? ' selected' : '') + (r.phase === 'error' ? ' error' : '');
|
|
1128
|
+
row.dataset.id = r.id;
|
|
1129
|
+
|
|
1130
|
+
const urlObj = tryURL(r.url);
|
|
1131
|
+
const pathname = urlObj ? urlObj.pathname : r.url || '';
|
|
1132
|
+
const filename = pathname.split('/').filter(Boolean).pop() || pathname;
|
|
1133
|
+
const host = urlObj ? urlObj.host : '';
|
|
1134
|
+
|
|
1135
|
+
// Name — show method + full path (expands with column)
|
|
1136
|
+
const nameCell = document.createElement('div');
|
|
1137
|
+
nameCell.className = 'net-cell net-cell-name';
|
|
1138
|
+
nameCell.dataset.col = 'name';
|
|
1139
|
+
nameCell.style.width = NET_COLS[0].width + 'px';
|
|
1140
|
+
const method = r.method || '?';
|
|
1141
|
+
const mClass = ['GET','POST','PUT','PATCH','DELETE'].includes(method) ? `m-${method}` : 'm-other';
|
|
1142
|
+
const fullPath = urlObj ? urlObj.pathname + urlObj.search : r.url || '';
|
|
1143
|
+
nameCell.innerHTML = `<span class="method-badge ${mClass}">${method}</span> <span class="net-path" title="${esc(r.url)}">${esc(fullPath)}</span><span class="net-host">${esc(host)}</span>`;
|
|
1144
|
+
row.appendChild(nameCell);
|
|
1145
|
+
|
|
1146
|
+
// Status
|
|
1147
|
+
const statusCell = document.createElement('div');
|
|
1148
|
+
statusCell.className = 'net-cell net-status';
|
|
1149
|
+
statusCell.dataset.col = 'status';
|
|
1150
|
+
statusCell.style.width = NET_COLS[1].width + 'px';
|
|
1151
|
+
let statusStr = '...', sCls = 's-pending';
|
|
1152
|
+
if (r.phase === 'error') { statusStr = 'ERR'; sCls = 's-err'; }
|
|
1153
|
+
else if (r.status) { statusStr = String(r.status); sCls = `s-${Math.floor(r.status/100)}`; }
|
|
1154
|
+
statusCell.className += ` ${sCls}`;
|
|
1155
|
+
statusCell.textContent = statusStr;
|
|
1156
|
+
row.appendChild(statusCell);
|
|
1157
|
+
|
|
1158
|
+
// Type (content-type from response headers)
|
|
1159
|
+
const typeCell = document.createElement('div');
|
|
1160
|
+
typeCell.className = 'net-cell net-type';
|
|
1161
|
+
typeCell.dataset.col = 'type';
|
|
1162
|
+
typeCell.style.width = NET_COLS[2].width + 'px';
|
|
1163
|
+
const ct = r.responseHeaders?.['content-type'] || r.responseHeaders?.['Content-Type'] || '';
|
|
1164
|
+
typeCell.textContent = ct.split(';')[0].replace('application/', '').replace('text/', '') || '—';
|
|
1165
|
+
row.appendChild(typeCell);
|
|
1166
|
+
|
|
1167
|
+
// Initiator
|
|
1168
|
+
const initCell = document.createElement('div');
|
|
1169
|
+
initCell.className = 'net-cell net-initiator';
|
|
1170
|
+
initCell.dataset.col = 'initiator';
|
|
1171
|
+
initCell.style.width = NET_COLS[3].width + 'px';
|
|
1172
|
+
initCell.textContent = r.initiator || 'xhr';
|
|
1173
|
+
row.appendChild(initCell);
|
|
1174
|
+
|
|
1175
|
+
// Size
|
|
1176
|
+
const sizeCell = document.createElement('div');
|
|
1177
|
+
sizeCell.className = 'net-cell net-size';
|
|
1178
|
+
sizeCell.dataset.col = 'size';
|
|
1179
|
+
sizeCell.style.width = NET_COLS[4].width + 'px';
|
|
1180
|
+
const bodyStr = typeof r.responseBody === 'string' ? r.responseBody : (r.responseBody != null ? JSON.stringify(r.responseBody) : '');
|
|
1181
|
+
sizeCell.textContent = bodyStr.length > 0 ? formatSize(bodyStr.length) : '—';
|
|
1182
|
+
row.appendChild(sizeCell);
|
|
1183
|
+
|
|
1184
|
+
// Time
|
|
1185
|
+
const timeCell = document.createElement('div');
|
|
1186
|
+
timeCell.className = 'net-cell net-time' + ((r.duration || 0) > 1500 ? ' slow' : '');
|
|
1187
|
+
timeCell.dataset.col = 'time';
|
|
1188
|
+
timeCell.style.width = NET_COLS[5].width + 'px';
|
|
1189
|
+
timeCell.textContent = r.duration != null ? (r.duration > 999 ? `${(r.duration/1000).toFixed(1)}s` : `${r.duration}ms`) : '...';
|
|
1190
|
+
row.appendChild(timeCell);
|
|
1191
|
+
|
|
1192
|
+
// Waterfall
|
|
1193
|
+
const wfCell = document.createElement('div');
|
|
1194
|
+
wfCell.className = 'net-cell net-waterfall';
|
|
1195
|
+
wfCell.dataset.col = 'waterfall';
|
|
1196
|
+
wfCell.style.width = NET_COLS[6].width + 'px';
|
|
1197
|
+
if (r.ts) {
|
|
1198
|
+
const left = ((r.ts - wfMin) / wfRange) * 100;
|
|
1199
|
+
const width = Math.max(2, ((r.duration || 50) / wfRange) * 100);
|
|
1200
|
+
let barCls = 'pending';
|
|
1201
|
+
if (r.phase === 'error') barCls = 'err';
|
|
1202
|
+
else if (r.status) barCls = `s${Math.floor(r.status/100)}`;
|
|
1203
|
+
wfCell.innerHTML = `<div class="wf-bar ${barCls}" style="left:${left}%;width:${width}%"></div>`;
|
|
1204
|
+
}
|
|
1205
|
+
row.appendChild(wfCell);
|
|
1206
|
+
|
|
1207
|
+
// Click to select and show detail
|
|
1208
|
+
row.addEventListener('click', () => selectNetRequest(r.id));
|
|
1209
|
+
|
|
1210
|
+
// Right-click for context menu (copy as cURL)
|
|
1211
|
+
row.addEventListener('contextmenu', (e) => {
|
|
1212
|
+
e.preventDefault();
|
|
1213
|
+
showNetContextMenu(e, r);
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
return row;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ─── Select request → overlay detail pane over Status/Type/etc columns ───────
|
|
1220
|
+
function selectNetRequest(id) {
|
|
1221
|
+
state.network.selectedId = id;
|
|
1222
|
+
const r = state.network.requests[id];
|
|
1223
|
+
if (!r) return;
|
|
1224
|
+
|
|
1225
|
+
// Highlight selected row
|
|
1226
|
+
document.querySelectorAll('#netRows .net-row').forEach(el =>
|
|
1227
|
+
el.classList.toggle('selected', el.dataset.id === id)
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
// Position detail pane to overlay everything after the Name column
|
|
1231
|
+
const pane = $('netDetailPane');
|
|
1232
|
+
const nameColWidth = NET_COLS[0].width;
|
|
1233
|
+
pane.style.left = (nameColWidth + 1) + 'px'; // +1 for the border
|
|
1234
|
+
pane.classList.add('open');
|
|
1235
|
+
r._tab = r._tab || 'headers';
|
|
1236
|
+
renderNetDetailTabs(r);
|
|
1237
|
+
renderNetDetailContent(r);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function closeNetDetail() {
|
|
1241
|
+
state.network.selectedId = null;
|
|
1242
|
+
const pane = $('netDetailPane');
|
|
1243
|
+
if (pane) pane.classList.remove('open');
|
|
1244
|
+
document.querySelectorAll('#netRows .net-row').forEach(el =>
|
|
1245
|
+
el.classList.remove('selected')
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function renderNetDetailTabs(r) {
|
|
1250
|
+
const tabs = $('netDetailTabs');
|
|
1251
|
+
tabs.innerHTML = '';
|
|
1252
|
+
['Headers', 'Request', 'Preview', 'Response'].forEach(label => {
|
|
1253
|
+
const key = label.toLowerCase();
|
|
1254
|
+
const btn = document.createElement('button');
|
|
1255
|
+
btn.className = 'detail-tab' + (r._tab === key ? ' active' : '');
|
|
1256
|
+
btn.textContent = label;
|
|
1257
|
+
btn.addEventListener('click', () => {
|
|
1258
|
+
r._tab = key;
|
|
1259
|
+
tabs.querySelectorAll('.detail-tab').forEach(b => b.classList.remove('active'));
|
|
1260
|
+
btn.classList.add('active');
|
|
1261
|
+
renderNetDetailContent(r);
|
|
1262
|
+
});
|
|
1263
|
+
tabs.appendChild(btn);
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function renderNetDetailContent(r) {
|
|
1268
|
+
const body = $('netDetailContent');
|
|
1269
|
+
if (!body) return;
|
|
1270
|
+
const tab = r._tab || 'headers';
|
|
1271
|
+
|
|
1272
|
+
if (tab === 'headers') {
|
|
1273
|
+
const rqH = r.requestHeaders || {};
|
|
1274
|
+
const rsH = r.responseHeaders || {};
|
|
1275
|
+
const renderH = (title, h) => {
|
|
1276
|
+
const keys = Object.keys(h);
|
|
1277
|
+
if (!keys.length) return `<div class="section-label">${title}</div><span style="color:var(--text-dim)">none</span>`;
|
|
1278
|
+
return `<div class="section-label">${title}</div><div class="kv-grid">${keys.map(k => {
|
|
1279
|
+
let val = h[k];
|
|
1280
|
+
if (val && typeof val === 'object') { try { val = JSON.stringify(val); } catch { val = String(val); } }
|
|
1281
|
+
return `<span class="kv-key">${esc(k)}</span><span class="kv-val">${esc(val)}</span>`;
|
|
1282
|
+
}).join('')}</div>`;
|
|
1283
|
+
};
|
|
1284
|
+
body.innerHTML = `<div class="section-label" style="margin-top:0">General</div>
|
|
1285
|
+
<div class="kv-grid">
|
|
1286
|
+
<span class="kv-key">Request URL</span><span class="kv-val">${esc(r.url)}</span>
|
|
1287
|
+
<span class="kv-key">Method</span><span class="kv-val">${esc(r.method)}</span>
|
|
1288
|
+
<span class="kv-key">Status</span><span class="kv-val ${r.status ? 's-' + Math.floor(r.status/100) : 's-pending'}">${r.status || 'Pending'} ${r.statusText || ''}</span>
|
|
1289
|
+
</div>
|
|
1290
|
+
${renderH('Response Headers', rsH)}
|
|
1291
|
+
${renderH('Request Headers', rqH)}`;
|
|
1292
|
+
} else if (tab === 'request') {
|
|
1293
|
+
if (!r.requestBody) {
|
|
1294
|
+
body.innerHTML = '<span style="color:var(--text-dim)">No request body</span>';
|
|
1295
|
+
} else {
|
|
1296
|
+
body.innerHTML = '';
|
|
1297
|
+
let reqData = r.requestBody;
|
|
1298
|
+
if (typeof reqData === 'string') {
|
|
1299
|
+
try { reqData = JSON.parse(reqData); } catch {}
|
|
1300
|
+
}
|
|
1301
|
+
if (reqData && typeof reqData === 'object') {
|
|
1302
|
+
body.appendChild(createTreeNode(null, reqData, false));
|
|
1303
|
+
body.addEventListener('contextmenu', (e) => {
|
|
1304
|
+
e.preventDefault();
|
|
1305
|
+
showPreviewCopyMenu(e, reqData);
|
|
1306
|
+
});
|
|
1307
|
+
} else {
|
|
1308
|
+
body.innerHTML = renderJSON(r.requestBody);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
} else if (tab === 'preview') {
|
|
1312
|
+
if (r.phase === 'error') { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
|
|
1313
|
+
if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
|
|
1314
|
+
// Render as collapsible JSON tree with right-click copy
|
|
1315
|
+
const val = r.responseBody;
|
|
1316
|
+
let treeData = val;
|
|
1317
|
+
if (typeof val === 'string') {
|
|
1318
|
+
try { treeData = JSON.parse(val); } catch { body.textContent = val; return; }
|
|
1319
|
+
}
|
|
1320
|
+
if (treeData && typeof treeData === 'object') {
|
|
1321
|
+
body.innerHTML = '';
|
|
1322
|
+
body.appendChild(createTreeNode(null, treeData, false));
|
|
1323
|
+
// Right-click on preview to copy the whole object or clicked node value
|
|
1324
|
+
body.addEventListener('contextmenu', (e) => {
|
|
1325
|
+
e.preventDefault();
|
|
1326
|
+
showPreviewCopyMenu(e, treeData);
|
|
1327
|
+
});
|
|
1328
|
+
} else {
|
|
1329
|
+
body.innerHTML = '<span style="color:var(--text-dim)">No preview available</span>';
|
|
1330
|
+
}
|
|
1331
|
+
} else if (tab === 'response') {
|
|
1332
|
+
if (r.phase === 'error') { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
|
|
1333
|
+
if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
|
|
1334
|
+
body.innerHTML = renderJSON(r.responseBody);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ─── Network context menus ──────────────────────────────────────────────────
|
|
1339
|
+
function showNetContextMenu(e, r) {
|
|
1340
|
+
const items = [
|
|
1341
|
+
{ label: 'Copy as cURL', action: () => navigator.clipboard.writeText(buildCurlCommand(r)) },
|
|
1342
|
+
{ label: 'Copy URL', action: () => navigator.clipboard.writeText(r.url || '') },
|
|
1343
|
+
];
|
|
1344
|
+
if (r.responseBody) {
|
|
1345
|
+
items.push({ label: 'Copy Response', action: () => {
|
|
1346
|
+
const text = typeof r.responseBody === 'string' ? r.responseBody : JSON.stringify(r.responseBody, null, 2);
|
|
1347
|
+
navigator.clipboard.writeText(text);
|
|
1348
|
+
}});
|
|
1349
|
+
}
|
|
1350
|
+
showContextMenu(e, items);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function showPreviewCopyMenu(e, fullData) {
|
|
1354
|
+
const items = [
|
|
1355
|
+
{ label: 'Copy Object', action: () => navigator.clipboard.writeText(JSON.stringify(fullData, null, 2)) },
|
|
1356
|
+
];
|
|
1357
|
+
const sel = window.getSelection();
|
|
1358
|
+
if (sel && sel.toString().length > 0) {
|
|
1359
|
+
items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
|
|
1360
|
+
}
|
|
1361
|
+
const keyEl = e.target.closest('.ov-key');
|
|
1362
|
+
const leafEl = e.target.closest('.ov-leaf');
|
|
1363
|
+
if (keyEl || leafEl) {
|
|
1364
|
+
items.push({ label: 'Copy Value', action: () => navigator.clipboard.writeText((leafEl || keyEl.parentElement).textContent) });
|
|
1365
|
+
}
|
|
1366
|
+
showContextMenu(e, items);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function buildCurlCommand(r) {
|
|
1370
|
+
let cmd = `curl '${r.url}'`;
|
|
1371
|
+
if (r.method && r.method !== 'GET') cmd += ` -X ${r.method}`;
|
|
1372
|
+
const headers = r.requestHeaders || {};
|
|
1373
|
+
Object.entries(headers).forEach(([k, v]) => {
|
|
1374
|
+
cmd += ` \\\n -H '${k}: ${v}'`;
|
|
1375
|
+
});
|
|
1376
|
+
if (r.requestBody) {
|
|
1377
|
+
const body = typeof r.requestBody === 'string' ? r.requestBody : JSON.stringify(r.requestBody);
|
|
1378
|
+
cmd += ` \\\n --data-raw '${body.replace(/'/g, "'\\''")}'`;
|
|
1379
|
+
}
|
|
1380
|
+
return cmd;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1384
|
+
// REDUX PANEL
|
|
1385
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1386
|
+
function initReduxPanel() {
|
|
1387
|
+
const panel = $('panel-redux');
|
|
1388
|
+
panel.innerHTML = `
|
|
1389
|
+
<div class="panel-toolbar">
|
|
1390
|
+
<span class="panel-label">Redux</span>
|
|
1391
|
+
<span class="badge" id="rBadge">0</span>
|
|
1392
|
+
<div class="ml-auto" style="display:flex;align-items:center;gap:8px">
|
|
1393
|
+
<input id="reduxSearch" class="net-search-input" placeholder="Filter actions..." />
|
|
1394
|
+
<div class="time-travel-bar" style="border:none;padding:0;margin:0">
|
|
1395
|
+
<button class="tt-btn" onclick="reduxJumpTo(state.redux.selected-1)">◀</button>
|
|
1396
|
+
<span class="tt-label" id="ttLabel">—/—</span>
|
|
1397
|
+
<button class="tt-btn" onclick="reduxJumpTo(state.redux.selected+1)">▶</button>
|
|
1398
|
+
</div>
|
|
1399
|
+
</div>
|
|
1400
|
+
</div>
|
|
1401
|
+
<div class="scroll-area" id="reduxContent">
|
|
1402
|
+
<div class="empty-state" id="reduxEmpty">
|
|
1403
|
+
<div class="icon">🔲</div>
|
|
1404
|
+
<div class="label">No actions dispatched</div>
|
|
1405
|
+
<div class="hint">Connect Redux store to RNDebugSDK</div>
|
|
1406
|
+
</div>
|
|
1407
|
+
</div>`;
|
|
1408
|
+
|
|
1409
|
+
$('reduxSearch').addEventListener('input', (e) => {
|
|
1410
|
+
state.redux.searchFilter = e.target.value.toLowerCase().trim();
|
|
1411
|
+
renderRedux();
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
window.reduxJumpTo = idx => {
|
|
1416
|
+
const { actions } = state.redux;
|
|
1417
|
+
if (!actions.length) return;
|
|
1418
|
+
idx = Math.max(0, Math.min(actions.length - 1, idx));
|
|
1419
|
+
state.redux.selected = idx;
|
|
1420
|
+
renderRedux();
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
// Fast deep equality check for Redux state comparison
|
|
1424
|
+
function _deepEqual(a, b) {
|
|
1425
|
+
if (a === b) return true;
|
|
1426
|
+
if (a == null || b == null) return false;
|
|
1427
|
+
if (typeof a !== typeof b) return false;
|
|
1428
|
+
if (typeof a !== 'object') return false;
|
|
1429
|
+
try {
|
|
1430
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1431
|
+
} catch { return false; }
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function handleReduxEvent(event) {
|
|
1435
|
+
if (event.type !== 'redux') return;
|
|
1436
|
+
const { action, nextState } = event;
|
|
1437
|
+
const idx = state.redux.actions.length;
|
|
1438
|
+
|
|
1439
|
+
const prevState = state.redux.states.length > 0 ? state.redux.states[state.redux.states.length - 1] : null;
|
|
1440
|
+
const changedKeys = [];
|
|
1441
|
+
if (prevState && nextState && typeof prevState === 'object' && typeof nextState === 'object') {
|
|
1442
|
+
const allKeys = new Set([...Object.keys(prevState), ...Object.keys(nextState)]);
|
|
1443
|
+
allKeys.forEach(k => { if (!_deepEqual(prevState[k], nextState[k])) changedKeys.push(k); });
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
state.redux.actions.push({ type: action?.type || '?', payload: action, ts: event.ts, index: idx, changedKeys });
|
|
1447
|
+
state.redux.states.push(nextState);
|
|
1448
|
+
state.redux.selected = idx;
|
|
1449
|
+
$('rBadge').textContent = state.redux.actions.length;
|
|
1450
|
+
renderRedux();
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function renderRedux() {
|
|
1454
|
+
const content = $('reduxContent');
|
|
1455
|
+
const empty = $('reduxEmpty');
|
|
1456
|
+
if (!content) return;
|
|
1457
|
+
|
|
1458
|
+
const { actions, states, selected, searchFilter } = state.redux;
|
|
1459
|
+
const visible = searchFilter ? actions.filter(a => a.type.toLowerCase().includes(searchFilter)) : actions;
|
|
1460
|
+
|
|
1461
|
+
empty.style.display = visible.length ? 'none' : 'flex';
|
|
1462
|
+
content.querySelectorAll('.rdx-entry').forEach(e => e.remove());
|
|
1463
|
+
if (!visible.length) return;
|
|
1464
|
+
|
|
1465
|
+
const ttLabel = $('ttLabel');
|
|
1466
|
+
if (ttLabel) ttLabel.textContent = `${selected + 1}/${actions.length}`;
|
|
1467
|
+
|
|
1468
|
+
const frag = document.createDocumentFragment();
|
|
1469
|
+
visible.forEach(a => {
|
|
1470
|
+
const isSelected = a.index === selected;
|
|
1471
|
+
const isPrev = a.index === selected - 1;
|
|
1472
|
+
const isNext = a.index === selected + 1;
|
|
1473
|
+
|
|
1474
|
+
const entry = document.createElement('div');
|
|
1475
|
+
entry.className = 'rdx-entry' + (isSelected ? ' selected' : '') + (isPrev ? ' is-prev' : '') + (isNext ? ' is-next' : '');
|
|
1476
|
+
|
|
1477
|
+
// Row header — always visible
|
|
1478
|
+
const header = document.createElement('div');
|
|
1479
|
+
header.className = 'rdx-entry-header';
|
|
1480
|
+
const changesBadge = a.changedKeys?.length ? `<span class="rdx-changes">${a.changedKeys.length}</span>` : '';
|
|
1481
|
+
const roleTag = isPrev ? '<span class="rdx-role prev">PREV</span>' : isNext ? '<span class="rdx-role next">NEXT</span>' : isSelected ? '<span class="rdx-role current">CURRENT</span>' : '';
|
|
1482
|
+
header.innerHTML = `<span class="rdx-index">#${a.index}</span>${roleTag}<span class="rdx-type">${esc(a.type)}</span>${changesBadge}<span class="rdx-time">${ts(a.ts)}</span>`;
|
|
1483
|
+
header.addEventListener('click', () => { state.redux.selected = a.index; renderRedux(); });
|
|
1484
|
+
entry.appendChild(header);
|
|
1485
|
+
|
|
1486
|
+
// Expanded detail for selected / prev / next
|
|
1487
|
+
if (isSelected || isPrev || isNext) {
|
|
1488
|
+
const detail = document.createElement('div');
|
|
1489
|
+
detail.className = 'rdx-entry-detail';
|
|
1490
|
+
|
|
1491
|
+
// Changed keys badges
|
|
1492
|
+
if (a.changedKeys?.length > 0) {
|
|
1493
|
+
const keysEl = document.createElement('div');
|
|
1494
|
+
keysEl.className = 'redux-changed-keys';
|
|
1495
|
+
keysEl.innerHTML = `<span class="redux-changed-label">Changed:</span> ${a.changedKeys.map(k =>
|
|
1496
|
+
`<span class="redux-changed-key">${esc(k)}</span>`).join(' ')}`;
|
|
1497
|
+
detail.appendChild(keysEl);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Payload
|
|
1501
|
+
if (a.payload) {
|
|
1502
|
+
const pLabel = document.createElement('div');
|
|
1503
|
+
pLabel.className = 'redux-section-title';
|
|
1504
|
+
pLabel.textContent = 'Payload';
|
|
1505
|
+
detail.appendChild(pLabel);
|
|
1506
|
+
detail.appendChild(createTreeNode(null, a.payload, !isSelected));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// Store changes (only for selected)
|
|
1510
|
+
if (isSelected) {
|
|
1511
|
+
const prevS = a.index > 0 ? states[a.index - 1] : null;
|
|
1512
|
+
const currS = states[a.index];
|
|
1513
|
+
if (currS && typeof currS === 'object' && a.changedKeys?.length > 0) {
|
|
1514
|
+
const sLabel = document.createElement('div');
|
|
1515
|
+
sLabel.className = 'redux-section-title';
|
|
1516
|
+
sLabel.textContent = 'Store Changes';
|
|
1517
|
+
detail.appendChild(sLabel);
|
|
1518
|
+
|
|
1519
|
+
a.changedKeys.forEach(key => {
|
|
1520
|
+
const keyWrap = document.createElement('div');
|
|
1521
|
+
keyWrap.className = 'rdx-store-diff';
|
|
1522
|
+
const kLabel = document.createElement('div');
|
|
1523
|
+
kLabel.className = 'rdx-store-key-label';
|
|
1524
|
+
kLabel.textContent = key;
|
|
1525
|
+
keyWrap.appendChild(kLabel);
|
|
1526
|
+
|
|
1527
|
+
if (prevS && prevS[key] !== undefined) {
|
|
1528
|
+
const prevRow = document.createElement('div');
|
|
1529
|
+
prevRow.className = 'rdx-diff-row removed';
|
|
1530
|
+
prevRow.innerHTML = '<span class="rdx-diff-sign">-</span>';
|
|
1531
|
+
prevRow.appendChild(createTreeNode(null, prevS[key], true));
|
|
1532
|
+
keyWrap.appendChild(prevRow);
|
|
1533
|
+
}
|
|
1534
|
+
if (currS[key] !== undefined) {
|
|
1535
|
+
const newRow = document.createElement('div');
|
|
1536
|
+
newRow.className = 'rdx-diff-row added';
|
|
1537
|
+
newRow.innerHTML = '<span class="rdx-diff-sign">+</span>';
|
|
1538
|
+
newRow.appendChild(createTreeNode(null, currS[key], true));
|
|
1539
|
+
keyWrap.appendChild(newRow);
|
|
1540
|
+
}
|
|
1541
|
+
detail.appendChild(keyWrap);
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
entry.appendChild(detail);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
frag.appendChild(entry);
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
content.appendChild(frag);
|
|
1553
|
+
const selEl = content.querySelector('.rdx-entry.selected');
|
|
1554
|
+
if (selEl) selEl.scrollIntoView({ block: 'nearest' });
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1558
|
+
// ASYNC STORAGE PANEL
|
|
1559
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1560
|
+
function initStoragePanel() {
|
|
1561
|
+
const panel = $('panel-storage');
|
|
1562
|
+
panel.innerHTML = `
|
|
1563
|
+
<div class="panel-toolbar">
|
|
1564
|
+
<span class="panel-label">AsyncStorage</span>
|
|
1565
|
+
<span class="badge" id="sBadge">0</span>
|
|
1566
|
+
<div class="ml-auto">
|
|
1567
|
+
<input id="storageSearch" class="net-search-input" placeholder="Filter keys..." />
|
|
1568
|
+
</div>
|
|
1569
|
+
</div>
|
|
1570
|
+
<div class="storage-layout">
|
|
1571
|
+
<div class="storage-keys">
|
|
1572
|
+
<div class="panel-toolbar" style="height:32px">
|
|
1573
|
+
<span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px">Keys</span>
|
|
1574
|
+
</div>
|
|
1575
|
+
<div class="scroll-area storage-keys-list" id="storageKeyList">
|
|
1576
|
+
<div class="empty-state" id="storageEmpty">
|
|
1577
|
+
<div class="icon">💾</div>
|
|
1578
|
+
<div class="label">No storage data</div>
|
|
1579
|
+
<div class="hint">Add storage plugin to RNDebugPlugin</div>
|
|
1580
|
+
</div>
|
|
1581
|
+
</div>
|
|
1582
|
+
</div>
|
|
1583
|
+
<div class="storage-value-view">
|
|
1584
|
+
<div class="storage-value-toolbar">
|
|
1585
|
+
<span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px">Value</span>
|
|
1586
|
+
<span id="storageSelectedKey" style="font-size:11px;color:var(--accent);margin-left:8px"></span>
|
|
1587
|
+
</div>
|
|
1588
|
+
<div class="storage-value-body" id="storageValueBody">
|
|
1589
|
+
<span style="color:var(--text-dim)">Select a key to view its value</span>
|
|
1590
|
+
</div>
|
|
1591
|
+
</div>
|
|
1592
|
+
</div>`;
|
|
1593
|
+
|
|
1594
|
+
$('storageSearch').addEventListener('input', (e) => {
|
|
1595
|
+
state.storage.searchFilter = e.target.value.toLowerCase().trim();
|
|
1596
|
+
renderStorage();
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
let _storageRAF = null;
|
|
1601
|
+
|
|
1602
|
+
function handleStorageEvent(event) {
|
|
1603
|
+
if (event.type !== 'storage') return;
|
|
1604
|
+
const { key, value, action } = event;
|
|
1605
|
+
if (action === 'set' || action === 'snapshot') {
|
|
1606
|
+
if (action === 'snapshot' && typeof key === 'object') {
|
|
1607
|
+
// Skip if data hasn't changed
|
|
1608
|
+
const newKeys = Object.keys(key).slice().sort().join(',');
|
|
1609
|
+
const oldKeys = state.storage.keys.slice().sort().join(',');
|
|
1610
|
+
if (newKeys === oldKeys) {
|
|
1611
|
+
// Check if values changed
|
|
1612
|
+
let same = true;
|
|
1613
|
+
for (const [k, v] of Object.entries(key)) {
|
|
1614
|
+
if (state.storage.entries[k] !== v) { same = false; break; }
|
|
1615
|
+
}
|
|
1616
|
+
if (same) return; // No changes, skip re-render
|
|
1617
|
+
}
|
|
1618
|
+
Object.entries(key).forEach(([k, v]) => {
|
|
1619
|
+
state.storage.entries[k] = v;
|
|
1620
|
+
if (!state.storage.keys.includes(k)) state.storage.keys.push(k);
|
|
1621
|
+
});
|
|
1622
|
+
} else {
|
|
1623
|
+
if (state.storage.entries[key] === value) return; // No change
|
|
1624
|
+
state.storage.entries[key] = value;
|
|
1625
|
+
if (!state.storage.keys.includes(key)) state.storage.keys.push(key);
|
|
1626
|
+
}
|
|
1627
|
+
} else if (action === 'remove') {
|
|
1628
|
+
if (!(key in state.storage.entries)) return; // Already removed
|
|
1629
|
+
delete state.storage.entries[key];
|
|
1630
|
+
state.storage.keys = state.storage.keys.filter(k => k !== key);
|
|
1631
|
+
if (state.storage.selected === key) state.storage.selected = null;
|
|
1632
|
+
}
|
|
1633
|
+
$('sBadge').textContent = state.storage.keys.length;
|
|
1634
|
+
// Debounce render via rAF
|
|
1635
|
+
if (!_storageRAF) {
|
|
1636
|
+
_storageRAF = requestAnimationFrame(() => {
|
|
1637
|
+
_storageRAF = null;
|
|
1638
|
+
renderStorage();
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function renderStorage() {
|
|
1644
|
+
const list = $('storageKeyList');
|
|
1645
|
+
const empty = $('storageEmpty');
|
|
1646
|
+
if (!list) return;
|
|
1647
|
+
|
|
1648
|
+
const { searchFilter } = state.storage;
|
|
1649
|
+
const visible = state.storage.keys.filter(k =>
|
|
1650
|
+
!searchFilter || k.toLowerCase().includes(searchFilter)
|
|
1651
|
+
);
|
|
1652
|
+
|
|
1653
|
+
empty.style.display = visible.length ? 'none' : 'flex';
|
|
1654
|
+
list.querySelectorAll('.storage-key-row').forEach(e => e.remove());
|
|
1655
|
+
|
|
1656
|
+
const frag = document.createDocumentFragment();
|
|
1657
|
+
visible.forEach(k => {
|
|
1658
|
+
const div = document.createElement('div');
|
|
1659
|
+
const val = state.storage.entries[k] || '';
|
|
1660
|
+
div.className = 'storage-key-row entry' + (k === state.storage.selected ? ' selected' : '');
|
|
1661
|
+
div.innerHTML = `
|
|
1662
|
+
<span class="key-name">${highlight(esc(k), searchFilter)}</span>
|
|
1663
|
+
<span class="key-size">${formatSize(val.length)}</span>`;
|
|
1664
|
+
div.onclick = () => { state.storage.selected = k; renderStorage(); renderStorageValue(); };
|
|
1665
|
+
frag.appendChild(div);
|
|
1666
|
+
});
|
|
1667
|
+
list.appendChild(frag);
|
|
1668
|
+
renderStorageValue();
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function renderStorageValue() {
|
|
1672
|
+
const body = $('storageValueBody');
|
|
1673
|
+
const keyLabel = $('storageSelectedKey');
|
|
1674
|
+
if (!body) return;
|
|
1675
|
+
const { selected, entries } = state.storage;
|
|
1676
|
+
if (!selected) {
|
|
1677
|
+
body.innerHTML = '<span style="color:var(--text-dim)">Select a key</span>';
|
|
1678
|
+
if (keyLabel) keyLabel.textContent = '';
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
if (keyLabel) keyLabel.textContent = selected;
|
|
1682
|
+
body.innerHTML = renderJSON(entries[selected]);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function formatSize(bytes) {
|
|
1686
|
+
if (bytes < 1024) return `${bytes}b`;
|
|
1687
|
+
return `${(bytes/1024).toFixed(1)}kb`;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1691
|
+
// REACT TREE PANEL
|
|
1692
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1693
|
+
function initReactPanel() {
|
|
1694
|
+
const panel = $('panel-react');
|
|
1695
|
+
panel.innerHTML = `
|
|
1696
|
+
<div class="panel-toolbar">
|
|
1697
|
+
<span class="panel-label">React Tree</span>
|
|
1698
|
+
</div>
|
|
1699
|
+
<div class="react-panel-inner">
|
|
1700
|
+
<div class="react-connect-hint" id="reactHint">
|
|
1701
|
+
<div class="icon" style="font-size:40px;opacity:.2">⚛️</div>
|
|
1702
|
+
<div class="label">React DevTools</div>
|
|
1703
|
+
<div class="hint">Launches as a separate window connected to your app</div>
|
|
1704
|
+
<div class="hint">React Native auto-connects on port <code>8097</code> in dev mode</div>
|
|
1705
|
+
<button class="btn-launch" id="btnReactDT">Open React DevTools ↗</button>
|
|
1706
|
+
</div>
|
|
1707
|
+
</div>`;
|
|
1708
|
+
|
|
1709
|
+
$('btnReactDT').addEventListener('click', () => {
|
|
1710
|
+
window.electronAPI?.openReactDevTools();
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1715
|
+
// SETTINGS PANEL
|
|
1716
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1717
|
+
function getStoredTheme() {
|
|
1718
|
+
try { return localStorage.getItem('rn-debug-theme') || 'dark'; } catch { return 'dark'; }
|
|
1719
|
+
}
|
|
1720
|
+
function setStoredTheme(t) {
|
|
1721
|
+
try { localStorage.setItem('rn-debug-theme', t); } catch {}
|
|
1722
|
+
}
|
|
1723
|
+
function getStoredFontSize() {
|
|
1724
|
+
try { return parseInt(localStorage.getItem('rn-debug-fontsize')) || 12; } catch { return 12; }
|
|
1725
|
+
}
|
|
1726
|
+
function setStoredFontSize(s) {
|
|
1727
|
+
try { localStorage.setItem('rn-debug-fontsize', String(s)); } catch {}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function getStoredAppName() {
|
|
1731
|
+
try { return localStorage.getItem('rn-debug-appname') || 'ReactoRadar'; } catch { return 'ReactoRadar'; }
|
|
1732
|
+
}
|
|
1733
|
+
function setStoredAppName(n) {
|
|
1734
|
+
try { localStorage.setItem('rn-debug-appname', n); } catch {}
|
|
1735
|
+
}
|
|
1736
|
+
function applyAppName(name) {
|
|
1737
|
+
const logo = document.querySelector('.logo');
|
|
1738
|
+
if (logo) {
|
|
1739
|
+
// Split name — first part normal, last word in accent span
|
|
1740
|
+
const words = name.split(/(?=[A-Z])/);
|
|
1741
|
+
if (words.length >= 2) {
|
|
1742
|
+
logo.innerHTML = words.slice(0, -1).join('') + '<span>' + words[words.length - 1] + '</span>';
|
|
1743
|
+
} else {
|
|
1744
|
+
logo.textContent = name;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
document.title = name;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function applyTheme(theme) {
|
|
1751
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
1752
|
+
// Tell main process (light themes need light nativeTheme for window chrome)
|
|
1753
|
+
const isLight = ['light', 'solarized-light'].includes(theme);
|
|
1754
|
+
window.electronAPI?.setTheme(isLight ? 'light' : 'dark');
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function applyFontSize(size) {
|
|
1758
|
+
document.documentElement.style.setProperty('--app-font-size', size + 'px');
|
|
1759
|
+
document.body.style.fontSize = size + 'px';
|
|
1760
|
+
// Inject/update a <style> tag so ALL current and future elements get the size
|
|
1761
|
+
let styleEl = document.getElementById('dynamic-font-size');
|
|
1762
|
+
if (!styleEl) {
|
|
1763
|
+
styleEl = document.createElement('style');
|
|
1764
|
+
styleEl.id = 'dynamic-font-size';
|
|
1765
|
+
document.head.appendChild(styleEl);
|
|
1766
|
+
}
|
|
1767
|
+
styleEl.textContent = `
|
|
1768
|
+
.log-preview, .log-body, .log-text, .log-caller-inline,
|
|
1769
|
+
.net-cell, .net-cell-name, .net-type, .net-initiator, .net-size, .net-time, .net-status,
|
|
1770
|
+
.detail-content, .kv-val, .kv-key,
|
|
1771
|
+
.rdx-type, .rdx-entry-detail, .rdx-store-key-label,
|
|
1772
|
+
.storage-value-body, .storage-key-row,
|
|
1773
|
+
.sources-code, .source-line-code,
|
|
1774
|
+
.ov-leaf, .ov-key, .ov-preview, .ov-str, .ov-num, .ov-bool, .ov-null, .ov-undef,
|
|
1775
|
+
.perf-meter-label,
|
|
1776
|
+
.settings-label, .settings-hint {
|
|
1777
|
+
font-size: ${size}px !important;
|
|
1778
|
+
}
|
|
1779
|
+
`;
|
|
1780
|
+
const display = $('fontSizeDisplay');
|
|
1781
|
+
if (display) display.textContent = size + 'px';
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function initSettingsPanel() {
|
|
1785
|
+
const panel = $('panel-settings');
|
|
1786
|
+
const current = getStoredTheme();
|
|
1787
|
+
const currentSize = getStoredFontSize();
|
|
1788
|
+
panel.innerHTML = `
|
|
1789
|
+
<div class="panel-toolbar">
|
|
1790
|
+
<span class="panel-label">Settings</span>
|
|
1791
|
+
</div>
|
|
1792
|
+
<div class="scroll-area">
|
|
1793
|
+
<div class="settings-content">
|
|
1794
|
+
<div class="settings-section">
|
|
1795
|
+
<div class="settings-section-title">Appearance</div>
|
|
1796
|
+
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
|
|
1797
|
+
<div>
|
|
1798
|
+
<div class="settings-label">Theme</div>
|
|
1799
|
+
<div class="settings-hint">Choose a color theme for the debugger</div>
|
|
1800
|
+
</div>
|
|
1801
|
+
<div class="theme-grid" id="themeSwitcher"></div>
|
|
1802
|
+
</div>
|
|
1803
|
+
<div class="settings-row">
|
|
1804
|
+
<div>
|
|
1805
|
+
<div class="settings-label">Font Size</div>
|
|
1806
|
+
<div class="settings-hint">Adjust text size across all panels</div>
|
|
1807
|
+
</div>
|
|
1808
|
+
<div class="font-size-control">
|
|
1809
|
+
<button class="font-size-btn" id="fontSizeDown">A-</button>
|
|
1810
|
+
<span class="font-size-display" id="fontSizeDisplay">${currentSize}px</span>
|
|
1811
|
+
<button class="font-size-btn" id="fontSizeUp">A+</button>
|
|
1812
|
+
</div>
|
|
1813
|
+
</div>
|
|
1814
|
+
<div class="settings-row">
|
|
1815
|
+
<div>
|
|
1816
|
+
<div class="settings-label">App Name</div>
|
|
1817
|
+
<div class="settings-hint">Customize the app title (visible in titlebar)</div>
|
|
1818
|
+
</div>
|
|
1819
|
+
<div style="display:flex;align-items:center;gap:6px">
|
|
1820
|
+
<input id="appNameInput" class="net-search-input" style="width:140px;text-align:center" value="${getStoredAppName()}" />
|
|
1821
|
+
<button class="font-size-btn" id="appNameReset" title="Reset to default">Reset</button>
|
|
1822
|
+
</div>
|
|
1823
|
+
</div>
|
|
1824
|
+
</div>
|
|
1825
|
+
<div class="settings-section">
|
|
1826
|
+
<div class="settings-section-title">Connection</div>
|
|
1827
|
+
<div class="settings-row">
|
|
1828
|
+
<div>
|
|
1829
|
+
<div class="settings-label">Bridge Ports</div>
|
|
1830
|
+
<div class="settings-hint">Redux :9090 · Storage :9091 · Network :9092 · React DT :8097</div>
|
|
1831
|
+
</div>
|
|
1832
|
+
</div>
|
|
1833
|
+
<div class="settings-row">
|
|
1834
|
+
<div>
|
|
1835
|
+
<div class="settings-label">Metro Bundler</div>
|
|
1836
|
+
<div class="settings-hint">CDP target discovery on :8081</div>
|
|
1837
|
+
</div>
|
|
1838
|
+
</div>
|
|
1839
|
+
</div>
|
|
1840
|
+
<div class="settings-section">
|
|
1841
|
+
<div class="settings-section-title">Keyboard Shortcuts</div>
|
|
1842
|
+
<div class="settings-row">
|
|
1843
|
+
<div class="settings-label">Clear Active Tab</div>
|
|
1844
|
+
<div class="settings-hint" style="font-size:11px;color:var(--text-mid)">Clear button</div>
|
|
1845
|
+
</div>
|
|
1846
|
+
<div class="settings-row">
|
|
1847
|
+
<div class="settings-label">Clear All</div>
|
|
1848
|
+
<div class="settings-hint" style="font-size:11px;color:var(--text-mid)">⌘K</div>
|
|
1849
|
+
</div>
|
|
1850
|
+
<div class="settings-row">
|
|
1851
|
+
<div class="settings-label">Open JS Debugger</div>
|
|
1852
|
+
<div class="settings-hint" style="font-size:11px;color:var(--text-mid)">⌘D</div>
|
|
1853
|
+
</div>
|
|
1854
|
+
<div class="settings-row">
|
|
1855
|
+
<div class="settings-label">Open React DevTools</div>
|
|
1856
|
+
<div class="settings-hint" style="font-size:11px;color:var(--text-mid)">⌘R</div>
|
|
1857
|
+
</div>
|
|
1858
|
+
<div class="settings-row">
|
|
1859
|
+
<div class="settings-label">Toggle Theme</div>
|
|
1860
|
+
<div class="settings-hint" style="font-size:11px;color:var(--text-mid)">⌘⇧T</div>
|
|
1861
|
+
</div>
|
|
1862
|
+
<div class="settings-row">
|
|
1863
|
+
<div class="settings-label">Zoom In / Out</div>
|
|
1864
|
+
<div class="settings-hint" style="font-size:11px;color:var(--text-mid)">⌘+ / ⌘-</div>
|
|
1865
|
+
</div>
|
|
1866
|
+
</div>
|
|
1867
|
+
<div class="settings-section">
|
|
1868
|
+
<div class="settings-section-title">How to Use</div>
|
|
1869
|
+
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:8px">
|
|
1870
|
+
<div class="settings-hint" style="line-height:1.8">
|
|
1871
|
+
<b style="color:var(--text)">1. Setup</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar setup</code> from your RN project<br/>
|
|
1872
|
+
<b style="color:var(--text)">2. Start</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar</code> or open ReactoRadar.app<br/>
|
|
1873
|
+
<b style="color:var(--text)">3. Run your app</b> — <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx react-native start --reset-cache</code><br/>
|
|
1874
|
+
<b style="color:var(--text)">4. Debug</b> — Console, Network, Redux data flows automatically<br/>
|
|
1875
|
+
<b style="color:var(--text)">5. Remove</b> — Run <code style="color:var(--accent);background:var(--bg3);padding:1px 5px;border-radius:3px">npx reactoradar remove</code> to clean uninstall
|
|
1876
|
+
</div>
|
|
1877
|
+
</div>
|
|
1878
|
+
</div>
|
|
1879
|
+
<div class="settings-section">
|
|
1880
|
+
<div class="settings-section-title">About</div>
|
|
1881
|
+
<div class="settings-about">
|
|
1882
|
+
<div class="about-name" id="aboutAppName">${getStoredAppName()}</div>
|
|
1883
|
+
<div class="about-version">v1.2.0</div>
|
|
1884
|
+
<div class="about-desc">A standalone macOS debugger for React Native apps.<br/>Supports Hermes, New Architecture, and React Native 0.74+.</div>
|
|
1885
|
+
<div class="about-links" style="display:flex;gap:16px;justify-content:center">
|
|
1886
|
+
<span class="about-link" id="linkGithub">GitHub</span>
|
|
1887
|
+
<span class="about-link" id="linkDocs">Documentation</span>
|
|
1888
|
+
</div>
|
|
1889
|
+
</div>
|
|
1890
|
+
</div>
|
|
1891
|
+
</div>
|
|
1892
|
+
</div>`;
|
|
1893
|
+
|
|
1894
|
+
// Build theme cards
|
|
1895
|
+
const themes = [
|
|
1896
|
+
{ id: 'dark', name: 'Dark', colors: ['#0d0e11','#4facff','#3dd68c','#ff5e72'] },
|
|
1897
|
+
{ id: 'light', name: 'Light', colors: ['#f5f6f8','#0969da','#1a7f37','#cf222e'] },
|
|
1898
|
+
{ id: 'monokai', name: 'Monokai', colors: ['#272822','#66d9ef','#a6e22e','#f92672'] },
|
|
1899
|
+
{ id: 'dracula', name: 'Dracula', colors: ['#282a36','#8be9fd','#50fa7b','#ff5555'] },
|
|
1900
|
+
{ id: 'solarized-dark', name: 'Solarized Dark', colors: ['#002b36','#268bd2','#859900','#dc322f'] },
|
|
1901
|
+
{ id: 'solarized-light', name: 'Solarized Light', colors: ['#fdf6e3','#268bd2','#859900','#dc322f'] },
|
|
1902
|
+
{ id: 'nord', name: 'Nord', colors: ['#2e3440','#88c0d0','#a3be8c','#bf616a'] },
|
|
1903
|
+
{ id: 'github-dark', name: 'GitHub Dark', colors: ['#0d1117','#58a6ff','#3fb950','#f85149'] },
|
|
1904
|
+
{ id: 'one-dark', name: 'One Dark', colors: ['#282c34','#61afef','#98c379','#e06c75'] },
|
|
1905
|
+
];
|
|
1906
|
+
const grid = $('themeSwitcher');
|
|
1907
|
+
themes.forEach(t => {
|
|
1908
|
+
const btn = document.createElement('button');
|
|
1909
|
+
btn.className = 'theme-card' + (current === t.id ? ' active' : '');
|
|
1910
|
+
btn.dataset.theme = t.id;
|
|
1911
|
+
btn.innerHTML = '<div class="theme-preview" style="background:' + t.colors[0] + '">' +
|
|
1912
|
+
'<span style="background:' + t.colors[1] + '"></span>' +
|
|
1913
|
+
'<span style="background:' + t.colors[2] + '"></span>' +
|
|
1914
|
+
'<span style="background:' + t.colors[3] + '"></span>' +
|
|
1915
|
+
'</div><div class="theme-name">' + t.name + '</div>';
|
|
1916
|
+
grid.appendChild(btn);
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
// Theme switcher
|
|
1920
|
+
$('themeSwitcher').addEventListener('click', (e) => {
|
|
1921
|
+
const btn = e.target.closest('.theme-card');
|
|
1922
|
+
if (!btn) return;
|
|
1923
|
+
const theme = btn.dataset.theme;
|
|
1924
|
+
document.querySelectorAll('#themeSwitcher .theme-card').forEach(b => b.classList.remove('active'));
|
|
1925
|
+
btn.classList.add('active');
|
|
1926
|
+
setStoredTheme(theme);
|
|
1927
|
+
applyTheme(theme);
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
// About links
|
|
1931
|
+
$('linkGithub')?.addEventListener('click', () => {
|
|
1932
|
+
window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger');
|
|
1933
|
+
});
|
|
1934
|
+
$('linkDocs')?.addEventListener('click', () => {
|
|
1935
|
+
window.electronAPI?.openExternal('https://github.com/sharanagouda/react-native-debugger#readme');
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
// App name
|
|
1939
|
+
$('appNameInput').addEventListener('change', (e) => {
|
|
1940
|
+
const name = e.target.value.trim() || 'ReactoRadar';
|
|
1941
|
+
setStoredAppName(name);
|
|
1942
|
+
applyAppName(name);
|
|
1943
|
+
});
|
|
1944
|
+
$('appNameReset').addEventListener('click', () => {
|
|
1945
|
+
setStoredAppName('ReactoRadar');
|
|
1946
|
+
$('appNameInput').value = 'ReactoRadar';
|
|
1947
|
+
applyAppName('ReactoRadar');
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
// Font size controls
|
|
1951
|
+
$('fontSizeDown').addEventListener('click', () => {
|
|
1952
|
+
let size = getStoredFontSize();
|
|
1953
|
+
size = Math.max(8, size - 1);
|
|
1954
|
+
setStoredFontSize(size);
|
|
1955
|
+
applyFontSize(size);
|
|
1956
|
+
});
|
|
1957
|
+
$('fontSizeUp').addEventListener('click', () => {
|
|
1958
|
+
let size = getStoredFontSize();
|
|
1959
|
+
size = Math.min(20, size + 1);
|
|
1960
|
+
setStoredFontSize(size);
|
|
1961
|
+
applyFontSize(size);
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// Apply saved theme + font size + app name on load
|
|
1966
|
+
applyTheme(getStoredTheme());
|
|
1967
|
+
applyFontSize(getStoredFontSize());
|
|
1968
|
+
applyAppName(getStoredAppName());
|
|
1969
|
+
|
|
1970
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1971
|
+
// SOURCES PANEL — CDP-based file browser + breakpoints
|
|
1972
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1973
|
+
function initSourcesPanel() {
|
|
1974
|
+
const panel = $('panel-sources');
|
|
1975
|
+
panel.innerHTML = `
|
|
1976
|
+
<div class="panel-toolbar">
|
|
1977
|
+
<span class="panel-label">Sources</span>
|
|
1978
|
+
<div class="ml-auto" style="display:flex;gap:6px">
|
|
1979
|
+
<button class="tb-btn" id="btnOpenSourcesExt" title="Open in separate DevTools window">Breakpoints ↗</button>
|
|
1980
|
+
</div>
|
|
1981
|
+
</div>
|
|
1982
|
+
<div class="sources-layout">
|
|
1983
|
+
<div class="sources-sidebar" id="sourcesSidebar">
|
|
1984
|
+
<div class="panel-toolbar" style="height:32px">
|
|
1985
|
+
<input id="sourcesSearch" class="net-search-input" style="width:100%" placeholder="Search files..." />
|
|
1986
|
+
</div>
|
|
1987
|
+
<div class="scroll-area sources-file-list" id="sourcesFileList">
|
|
1988
|
+
<div class="empty-state" id="sourcesEmpty">
|
|
1989
|
+
<div class="icon" style="font-size:28px;opacity:.2"></></div>
|
|
1990
|
+
<div class="label">Waiting for Metro...</div>
|
|
1991
|
+
<div class="hint">Source files will load when Metro is running</div>
|
|
1992
|
+
</div>
|
|
1993
|
+
</div>
|
|
1994
|
+
</div>
|
|
1995
|
+
<div class="sources-editor" id="sourcesEditor">
|
|
1996
|
+
<div class="panel-toolbar" style="height:32px">
|
|
1997
|
+
<span id="sourcesFileName" style="font-size:10px;color:var(--accent)"></span>
|
|
1998
|
+
<span id="sourcesLineInfo" style="font-size:10px;color:var(--text-dim);margin-left:auto"></span>
|
|
1999
|
+
</div>
|
|
2000
|
+
<div class="scroll-area sources-code" id="sourcesCode">
|
|
2001
|
+
<span style="color:var(--text-dim);padding:20px;display:block">Select a file to view its source</span>
|
|
2002
|
+
</div>
|
|
2003
|
+
</div>
|
|
2004
|
+
</div>`;
|
|
2005
|
+
|
|
2006
|
+
// Open JS Debugger for breakpoints
|
|
2007
|
+
$('btnOpenSourcesExt').addEventListener('click', () => {
|
|
2008
|
+
window.electronAPI?.openCDPTarget(null);
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
// Search filter for file tree
|
|
2012
|
+
$('sourcesSearch').addEventListener('input', (e) => {
|
|
2013
|
+
const term = e.target.value.toLowerCase().trim();
|
|
2014
|
+
document.querySelectorAll('#sourcesFileList .src-tree-file').forEach(row => {
|
|
2015
|
+
const filepath = row.dataset.file || '';
|
|
2016
|
+
const match = !term || filepath.toLowerCase().includes(term);
|
|
2017
|
+
row.style.display = match ? '' : 'none';
|
|
2018
|
+
});
|
|
2019
|
+
// Show/hide folder nodes based on whether they have visible children
|
|
2020
|
+
document.querySelectorAll('#sourcesFileList .src-tree-folder').forEach(folder => {
|
|
2021
|
+
const visibleFiles = folder.querySelectorAll('.src-tree-file:not([style*="display: none"])');
|
|
2022
|
+
folder.style.display = (!term || visibleFiles.length > 0) ? '' : 'none';
|
|
2023
|
+
// Auto-expand folders when searching
|
|
2024
|
+
if (term && visibleFiles.length > 0) {
|
|
2025
|
+
const children = folder.querySelector('.src-tree-children');
|
|
2026
|
+
const arrow = folder.querySelector('.src-tree-arrow');
|
|
2027
|
+
if (children) children.style.display = 'block';
|
|
2028
|
+
if (arrow) { arrow.textContent = '\u25BC'; arrow.classList.add('expanded'); }
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
// Fetch the source map / bundle modules list from Metro
|
|
2034
|
+
fetchSourceFileList();
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
async function fetchSourceFileList() {
|
|
2038
|
+
if (!window.electronAPI?.getSourceFileList) {
|
|
2039
|
+
console.log('[Sources] electronAPI.getSourceFileList not available, retrying...');
|
|
2040
|
+
setTimeout(fetchSourceFileList, 5000);
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
try {
|
|
2044
|
+
console.log('[Sources] Fetching file list from Metro...');
|
|
2045
|
+
const result = await window.electronAPI.getSourceFileList();
|
|
2046
|
+
console.log('[Sources] Got result:', result?.files?.length, 'files, root:', result?.root?.slice(-30));
|
|
2047
|
+
if (result?.files && result.files.length > 0) {
|
|
2048
|
+
state._sourcesRoot = result.root;
|
|
2049
|
+
// Limit to 500 files max to avoid DOM overload
|
|
2050
|
+
const files = result.files.length > 500 ? result.files.slice(0, 500) : result.files;
|
|
2051
|
+
renderSourceFileList(files);
|
|
2052
|
+
console.log('[Sources] Rendered', files.length, 'files');
|
|
2053
|
+
} else {
|
|
2054
|
+
console.log('[Sources] No files, retrying in 5s...');
|
|
2055
|
+
setTimeout(fetchSourceFileList, 5000);
|
|
2056
|
+
}
|
|
2057
|
+
} catch (e) {
|
|
2058
|
+
console.log('[Sources] Error:', e?.message || e);
|
|
2059
|
+
setTimeout(fetchSourceFileList, 5000);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function renderSourceFileList(files) {
|
|
2064
|
+
const list = $('sourcesFileList');
|
|
2065
|
+
const empty = $('sourcesEmpty');
|
|
2066
|
+
if (!list) return;
|
|
2067
|
+
if (!files.length) return;
|
|
2068
|
+
if (empty) empty.style.display = 'none';
|
|
2069
|
+
list.querySelectorAll('.src-tree-node').forEach(e => e.remove());
|
|
2070
|
+
|
|
2071
|
+
// Build folder tree from file paths
|
|
2072
|
+
const tree = {};
|
|
2073
|
+
files.forEach(filepath => {
|
|
2074
|
+
const parts = filepath.split('/').filter(Boolean);
|
|
2075
|
+
let node = tree;
|
|
2076
|
+
parts.forEach((part, i) => {
|
|
2077
|
+
if (i === parts.length - 1) {
|
|
2078
|
+
// File leaf
|
|
2079
|
+
node[part] = filepath; // string = file
|
|
2080
|
+
} else {
|
|
2081
|
+
// Folder
|
|
2082
|
+
if (!node[part] || typeof node[part] === 'string') node[part] = {};
|
|
2083
|
+
node = node[part];
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
// Render tree recursively
|
|
2089
|
+
const frag = document.createDocumentFragment();
|
|
2090
|
+
|
|
2091
|
+
// Project folders first, node_modules last
|
|
2092
|
+
const topKeys = Object.keys(tree).sort((a, b) => {
|
|
2093
|
+
if (a === 'node_modules') return 1;
|
|
2094
|
+
if (b === 'node_modules') return -1;
|
|
2095
|
+
return a.localeCompare(b);
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
topKeys.forEach(key => {
|
|
2099
|
+
frag.appendChild(buildSourceTreeNode(key, tree[key], 0));
|
|
2100
|
+
});
|
|
2101
|
+
list.appendChild(frag);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function buildSourceTreeNode(name, value, depth) {
|
|
2105
|
+
if (typeof value === 'string') {
|
|
2106
|
+
// File leaf
|
|
2107
|
+
const row = document.createElement('div');
|
|
2108
|
+
row.className = 'src-tree-node src-tree-file';
|
|
2109
|
+
row.dataset.file = value;
|
|
2110
|
+
row.style.paddingLeft = (12 + depth * 16) + 'px';
|
|
2111
|
+
const isNM = value.includes('node_modules');
|
|
2112
|
+
const ext = name.split('.').pop();
|
|
2113
|
+
const iconColor = ext === 'tsx' || ext === 'ts' ? '#3178c6'
|
|
2114
|
+
: ext === 'jsx' || ext === 'js' ? '#f0db4f'
|
|
2115
|
+
: ext === 'json' ? '#a0a0a0'
|
|
2116
|
+
: ext === 'css' ? '#264de4'
|
|
2117
|
+
: 'var(--text-dim)';
|
|
2118
|
+
row.innerHTML = `<span class="src-file-icon" style="color:${iconColor}">●</span><span class="src-file-name" style="color:${isNM ? 'var(--text-dim)' : 'var(--text-bright)'}">${esc(name)}</span>`;
|
|
2119
|
+
row.addEventListener('click', () => {
|
|
2120
|
+
const fileList = $('sourcesFileList');
|
|
2121
|
+
fileList.querySelectorAll('.src-tree-file').forEach(el => el.classList.remove('selected'));
|
|
2122
|
+
row.classList.add('selected');
|
|
2123
|
+
loadSourceFile(value);
|
|
2124
|
+
});
|
|
2125
|
+
// Search filter support
|
|
2126
|
+
const searchInput = $('sourcesSearch');
|
|
2127
|
+
if (searchInput && searchInput.value) {
|
|
2128
|
+
const term = searchInput.value.toLowerCase();
|
|
2129
|
+
if (!name.toLowerCase().includes(term) && !value.toLowerCase().includes(term)) {
|
|
2130
|
+
row.style.display = 'none';
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return row;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// Folder node
|
|
2137
|
+
const container = document.createElement('div');
|
|
2138
|
+
container.className = 'src-tree-node src-tree-folder';
|
|
2139
|
+
|
|
2140
|
+
const header = document.createElement('div');
|
|
2141
|
+
header.className = 'src-tree-folder-header';
|
|
2142
|
+
header.style.paddingLeft = (8 + depth * 16) + 'px';
|
|
2143
|
+
|
|
2144
|
+
const arrow = document.createElement('span');
|
|
2145
|
+
arrow.className = 'src-tree-arrow';
|
|
2146
|
+
arrow.textContent = '\u25B6';
|
|
2147
|
+
|
|
2148
|
+
const folderName = document.createElement('span');
|
|
2149
|
+
folderName.className = 'src-folder-name';
|
|
2150
|
+
const isNM = name === 'node_modules';
|
|
2151
|
+
folderName.style.color = isNM ? 'var(--text-dim)' : 'var(--text)';
|
|
2152
|
+
folderName.textContent = name;
|
|
2153
|
+
|
|
2154
|
+
header.appendChild(arrow);
|
|
2155
|
+
header.appendChild(folderName);
|
|
2156
|
+
container.appendChild(header);
|
|
2157
|
+
|
|
2158
|
+
const children = document.createElement('div');
|
|
2159
|
+
children.className = 'src-tree-children';
|
|
2160
|
+
// Start all folders collapsed
|
|
2161
|
+
children.style.display = 'none';
|
|
2162
|
+
|
|
2163
|
+
// Sort: folders first, then files
|
|
2164
|
+
const entries = Object.entries(value).sort((a, b) => {
|
|
2165
|
+
const aIsFolder = typeof a[1] === 'object';
|
|
2166
|
+
const bIsFolder = typeof b[1] === 'object';
|
|
2167
|
+
if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1;
|
|
2168
|
+
return a[0].localeCompare(b[0]);
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
let populated = false;
|
|
2172
|
+
function populate() {
|
|
2173
|
+
if (populated) return;
|
|
2174
|
+
populated = true;
|
|
2175
|
+
entries.forEach(([childName, childValue]) => {
|
|
2176
|
+
children.appendChild(buildSourceTreeNode(childName, childValue, depth + 1));
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
if (!startCollapsed) populate();
|
|
2181
|
+
|
|
2182
|
+
header.addEventListener('click', () => {
|
|
2183
|
+
const isOpen = children.style.display !== 'none';
|
|
2184
|
+
if (!isOpen) {
|
|
2185
|
+
populate();
|
|
2186
|
+
children.style.display = 'block';
|
|
2187
|
+
arrow.textContent = '\u25BC';
|
|
2188
|
+
arrow.classList.add('expanded');
|
|
2189
|
+
} else {
|
|
2190
|
+
children.style.display = 'none';
|
|
2191
|
+
arrow.textContent = '\u25B6';
|
|
2192
|
+
arrow.classList.remove('expanded');
|
|
2193
|
+
}
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
container.appendChild(children);
|
|
2197
|
+
return container;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
async function loadSourceFile(filepath) {
|
|
2201
|
+
const codeEl = $('sourcesCode');
|
|
2202
|
+
const nameEl = $('sourcesFileName');
|
|
2203
|
+
const lineEl = $('sourcesLineInfo');
|
|
2204
|
+
if (!codeEl) return;
|
|
2205
|
+
if (nameEl) nameEl.textContent = filepath.split('/').pop();
|
|
2206
|
+
if (lineEl) lineEl.textContent = filepath;
|
|
2207
|
+
codeEl.innerHTML = '<span style="color:var(--text-dim)">Loading...</span>';
|
|
2208
|
+
|
|
2209
|
+
let source = null;
|
|
2210
|
+
const root = state._sourcesRoot || '';
|
|
2211
|
+
const fullPath = root ? `${root}/${filepath}` : filepath;
|
|
2212
|
+
|
|
2213
|
+
// Strategy 1: Read from disk via IPC (most reliable)
|
|
2214
|
+
if (window.electronAPI?.readSourceFile) {
|
|
2215
|
+
source = await window.electronAPI.readSourceFile(fullPath);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// Strategy 2: Fetch from Metro
|
|
2219
|
+
if (!source) {
|
|
2220
|
+
try {
|
|
2221
|
+
const resp = await fetch(`http://localhost:8081/${filepath}?platform=ios&dev=true`);
|
|
2222
|
+
if (resp.ok) source = await resp.text();
|
|
2223
|
+
} catch {}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
if (!source) {
|
|
2227
|
+
codeEl.innerHTML = `<span style="color:var(--text-dim);padding:20px;display:block">Could not load: ${esc(filepath)}</span>`;
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// Render with line numbers
|
|
2232
|
+
const lines = source.split('\n');
|
|
2233
|
+
if (lineEl) lineEl.textContent = `${filepath} (${lines.length} lines)`;
|
|
2234
|
+
codeEl.innerHTML = '';
|
|
2235
|
+
const pre = document.createElement('pre');
|
|
2236
|
+
pre.className = 'source-pre';
|
|
2237
|
+
lines.forEach((line, i) => {
|
|
2238
|
+
const lineDiv = document.createElement('div');
|
|
2239
|
+
lineDiv.className = 'source-line';
|
|
2240
|
+
lineDiv.innerHTML = `<span class="source-line-num">${i + 1}</span><span class="source-line-code">${syntaxHighlight(esc(line))}</span>`;
|
|
2241
|
+
pre.appendChild(lineDiv);
|
|
2242
|
+
});
|
|
2243
|
+
codeEl.appendChild(pre);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// Called from cdp-targets IPC handler (no longer opens external window)
|
|
2247
|
+
|
|
2248
|
+
// Called from cdp-targets IPC handler (shared, no duplicate registration)
|
|
2249
|
+
// Sources panel uses Metro source map for file tree — CDP targets are only
|
|
2250
|
+
// used for the "Breakpoints" button, not for the file list.
|
|
2251
|
+
function updateSourcesPanel(targets) {
|
|
2252
|
+
// No-op: file list is populated by fetchSourceFileList from Metro source map
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2256
|
+
// PERFORMANCE PANEL — FPS, render timing, JS thread
|
|
2257
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2258
|
+
const perfState = { fps: [], jsThread: [], uiThread: [], recording: false, data: [] };
|
|
2259
|
+
|
|
2260
|
+
function initPerformancePanel() {
|
|
2261
|
+
const panel = $('panel-performance');
|
|
2262
|
+
panel.innerHTML = `
|
|
2263
|
+
<div class="panel-toolbar">
|
|
2264
|
+
<span class="panel-label">Performance</span>
|
|
2265
|
+
<div class="ml-auto" style="display:flex;gap:6px">
|
|
2266
|
+
<button class="tb-btn" id="btnPerfRecord">Record</button>
|
|
2267
|
+
<button class="tb-btn" id="btnPerfClear">Clear</button>
|
|
2268
|
+
</div>
|
|
2269
|
+
</div>
|
|
2270
|
+
<div class="perf-layout">
|
|
2271
|
+
<div class="perf-meters">
|
|
2272
|
+
<div class="perf-meter">
|
|
2273
|
+
<div class="perf-meter-label">FPS</div>
|
|
2274
|
+
<div class="perf-meter-value" id="perfFPS">—</div>
|
|
2275
|
+
<canvas class="perf-canvas" id="perfFPSCanvas" width="200" height="60"></canvas>
|
|
2276
|
+
</div>
|
|
2277
|
+
<div class="perf-meter">
|
|
2278
|
+
<div class="perf-meter-label">JS Thread</div>
|
|
2279
|
+
<div class="perf-meter-value" id="perfJS">—</div>
|
|
2280
|
+
<canvas class="perf-canvas" id="perfJSCanvas" width="200" height="60"></canvas>
|
|
2281
|
+
</div>
|
|
2282
|
+
<div class="perf-meter">
|
|
2283
|
+
<div class="perf-meter-label">UI Thread</div>
|
|
2284
|
+
<div class="perf-meter-value" id="perfUI">—</div>
|
|
2285
|
+
<canvas class="perf-canvas" id="perfUICanvas" width="200" height="60"></canvas>
|
|
2286
|
+
</div>
|
|
2287
|
+
</div>
|
|
2288
|
+
<div class="scroll-area perf-timeline" id="perfTimeline">
|
|
2289
|
+
<div class="empty-state" id="perfEmpty">
|
|
2290
|
+
<div class="icon" style="font-size:28px;opacity:.2">📊</div>
|
|
2291
|
+
<div class="label">No performance data</div>
|
|
2292
|
+
<div class="hint">Click "Record" to start capturing performance metrics</div>
|
|
2293
|
+
<div class="hint">The SDK sends FPS + thread usage automatically when connected</div>
|
|
2294
|
+
</div>
|
|
2295
|
+
</div>
|
|
2296
|
+
</div>`;
|
|
2297
|
+
|
|
2298
|
+
$('btnPerfRecord').addEventListener('click', () => {
|
|
2299
|
+
perfState.recording = !perfState.recording;
|
|
2300
|
+
$('btnPerfRecord').textContent = perfState.recording ? 'Stop' : 'Record';
|
|
2301
|
+
$('btnPerfRecord').classList.toggle('primary', perfState.recording);
|
|
2302
|
+
if (perfState.recording) {
|
|
2303
|
+
// Tell SDK to start sending perf data
|
|
2304
|
+
window.electronAPI?.setNetworkCapture(true); // reuse channel
|
|
2305
|
+
}
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
$('btnPerfClear').addEventListener('click', () => {
|
|
2309
|
+
perfState.fps = [];
|
|
2310
|
+
perfState.jsThread = [];
|
|
2311
|
+
perfState.uiThread = [];
|
|
2312
|
+
perfState.data = [];
|
|
2313
|
+
$('perfFPS').textContent = '—';
|
|
2314
|
+
$('perfJS').textContent = '—';
|
|
2315
|
+
$('perfUI').textContent = '—';
|
|
2316
|
+
clearPerfCanvas('perfFPSCanvas');
|
|
2317
|
+
clearPerfCanvas('perfJSCanvas');
|
|
2318
|
+
clearPerfCanvas('perfUICanvas');
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
function clearPerfCanvas(id) {
|
|
2323
|
+
const canvas = $(id);
|
|
2324
|
+
if (!canvas) return;
|
|
2325
|
+
const ctx = canvas.getContext('2d');
|
|
2326
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function drawPerfGraph(canvasId, data, maxVal, color) {
|
|
2330
|
+
const canvas = $(canvasId);
|
|
2331
|
+
if (!canvas || !data.length) return;
|
|
2332
|
+
const ctx = canvas.getContext('2d');
|
|
2333
|
+
const w = canvas.width, h = canvas.height;
|
|
2334
|
+
ctx.clearRect(0, 0, w, h);
|
|
2335
|
+
|
|
2336
|
+
// Grid lines
|
|
2337
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
|
2338
|
+
ctx.lineWidth = 1;
|
|
2339
|
+
for (let y = 0; y < h; y += h/4) {
|
|
2340
|
+
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
// Data line
|
|
2344
|
+
ctx.strokeStyle = color;
|
|
2345
|
+
ctx.lineWidth = 1.5;
|
|
2346
|
+
ctx.beginPath();
|
|
2347
|
+
const step = w / Math.max(data.length - 1, 1);
|
|
2348
|
+
data.forEach((v, i) => {
|
|
2349
|
+
const x = i * step;
|
|
2350
|
+
const y = h - (v / maxVal) * h;
|
|
2351
|
+
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
2352
|
+
});
|
|
2353
|
+
ctx.stroke();
|
|
2354
|
+
|
|
2355
|
+
// Fill under
|
|
2356
|
+
ctx.lineTo(w, h);
|
|
2357
|
+
ctx.lineTo(0, h);
|
|
2358
|
+
ctx.closePath();
|
|
2359
|
+
ctx.fillStyle = color.replace('1)', '0.1)');
|
|
2360
|
+
ctx.fill();
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
// Handle performance events from SDK (always updates meters, graphs only when recording)
|
|
2364
|
+
function handlePerfEvent(event) {
|
|
2365
|
+
if (event.fps != null) {
|
|
2366
|
+
perfState.fps.push(event.fps);
|
|
2367
|
+
if (perfState.fps.length > 100) perfState.fps.shift();
|
|
2368
|
+
const fpsEl = $('perfFPS');
|
|
2369
|
+
if (fpsEl) fpsEl.textContent = event.fps + ' fps';
|
|
2370
|
+
drawPerfGraph('perfFPSCanvas', perfState.fps, 60, 'rgba(61,214,140,1)');
|
|
2371
|
+
}
|
|
2372
|
+
if (event.jsThread != null) {
|
|
2373
|
+
perfState.jsThread.push(event.jsThread);
|
|
2374
|
+
if (perfState.jsThread.length > 100) perfState.jsThread.shift();
|
|
2375
|
+
const jsEl = $('perfJS');
|
|
2376
|
+
if (jsEl) jsEl.textContent = event.jsThread.toFixed(1) + 'ms';
|
|
2377
|
+
drawPerfGraph('perfJSCanvas', perfState.jsThread, 32, 'rgba(79,172,255,1)');
|
|
2378
|
+
}
|
|
2379
|
+
if (event.uiThread != null) {
|
|
2380
|
+
perfState.uiThread.push(event.uiThread);
|
|
2381
|
+
if (perfState.uiThread.length > 100) perfState.uiThread.shift();
|
|
2382
|
+
const uiEl = $('perfUI');
|
|
2383
|
+
if (uiEl) uiEl.textContent = event.uiThread.toFixed(1) + 'ms';
|
|
2384
|
+
drawPerfGraph('perfUICanvas', perfState.uiThread, 32, 'rgba(155,127,255,1)');
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2389
|
+
// MEMORY PANEL — Heap snapshot summary via Hermes CDP
|
|
2390
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2391
|
+
function initMemoryPanel() {
|
|
2392
|
+
const panel = $('panel-memory');
|
|
2393
|
+
panel.innerHTML = `
|
|
2394
|
+
<div class="panel-toolbar">
|
|
2395
|
+
<span class="panel-label">Memory</span>
|
|
2396
|
+
<div class="ml-auto" style="display:flex;gap:6px">
|
|
2397
|
+
<button class="tb-btn primary" id="btnHeapSnapshot">Take Heap Snapshot</button>
|
|
2398
|
+
</div>
|
|
2399
|
+
</div>
|
|
2400
|
+
<div class="memory-layout">
|
|
2401
|
+
<div class="perf-meters" style="padding:14px">
|
|
2402
|
+
<div class="perf-meter">
|
|
2403
|
+
<div class="perf-meter-label">JS Heap Used</div>
|
|
2404
|
+
<div class="perf-meter-value" id="memHeapUsed">—</div>
|
|
2405
|
+
</div>
|
|
2406
|
+
<div class="perf-meter">
|
|
2407
|
+
<div class="perf-meter-label">JS Heap Total</div>
|
|
2408
|
+
<div class="perf-meter-value" id="memHeapTotal">—</div>
|
|
2409
|
+
</div>
|
|
2410
|
+
<div class="perf-meter">
|
|
2411
|
+
<div class="perf-meter-label">Native Memory</div>
|
|
2412
|
+
<div class="perf-meter-value" id="memNative">—</div>
|
|
2413
|
+
</div>
|
|
2414
|
+
</div>
|
|
2415
|
+
<div class="scroll-area" id="memoryContent">
|
|
2416
|
+
<div class="empty-state" id="memoryEmpty">
|
|
2417
|
+
<div class="icon" style="font-size:28px;opacity:.2">🧠</div>
|
|
2418
|
+
<div class="label">No memory data</div>
|
|
2419
|
+
<div class="hint">Click "Take Heap Snapshot" to capture memory usage</div>
|
|
2420
|
+
<div class="hint">Requires Hermes CDP connection (press Cmd+D first)</div>
|
|
2421
|
+
</div>
|
|
2422
|
+
</div>
|
|
2423
|
+
</div>`;
|
|
2424
|
+
|
|
2425
|
+
$('btnHeapSnapshot').addEventListener('click', () => {
|
|
2426
|
+
// Request heap snapshot via CDP - this opens the DevTools window
|
|
2427
|
+
// which has built-in Memory profiler
|
|
2428
|
+
window.electronAPI?.openCDPTarget(null);
|
|
2429
|
+
});
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// Handle memory events from SDK
|
|
2433
|
+
function handleMemoryEvent(event) {
|
|
2434
|
+
const hu = $('memHeapUsed'), ht = $('memHeapTotal'), mn = $('memNative');
|
|
2435
|
+
if (event.heapUsed != null && hu) hu.textContent = formatSize(event.heapUsed);
|
|
2436
|
+
if (event.heapTotal != null && ht) ht.textContent = formatSize(event.heapTotal);
|
|
2437
|
+
if (event.native != null && mn) mn.textContent = formatSize(event.native);
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2441
|
+
// INIT
|
|
2442
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2443
|
+
initConsolePanel();
|
|
2444
|
+
initNetworkPanel();
|
|
2445
|
+
initPerformancePanel();
|
|
2446
|
+
initMemoryPanel();
|
|
2447
|
+
initReduxPanel();
|
|
2448
|
+
initStoragePanel();
|
|
2449
|
+
initReactPanel();
|
|
2450
|
+
initSettingsPanel();
|