reactoradar 1.6.6 → 1.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/panels/ga4.js ADDED
@@ -0,0 +1,328 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // GA4 EVENT INSPECTOR — extracted from app.js
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ const ga4State = { events: [], selected: -1, searchFilter: '', sortDir: 'desc' };
5
+
6
+ function initGA4Panel() {
7
+ const panel = $('panel-ga4');
8
+ panel.innerHTML = `
9
+ <div class="panel-toolbar">
10
+ <span class="panel-label">GA4 Events</span>
11
+ <span class="badge" id="ga4Badge">0</span>
12
+ <input id="ga4Search" class="net-search-input" style="margin-left:12px" placeholder="Filter events..." />
13
+ <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
14
+ <label class="toggle-label" for="ga4ColorToggle" style="font-size:10px;gap:4px">
15
+ <span style="color:var(--text-dim)">Colors</span>
16
+ <input type="checkbox" id="ga4ColorToggle" class="toggle-input" ${getGA4ColorsEnabled() ? 'checked' : ''} />
17
+ <span class="toggle-slider"></span>
18
+ </label>
19
+ <button class="panel-clear-btn" id="ga4Clear" title="Clear GA4 events">Clear</button>
20
+ </div>
21
+ </div>
22
+ <div class="ga4-layout">
23
+ <div class="ga4-list-pane">
24
+ <div class="ga4-list-header">
25
+ <span class="ga4-hcell ga4-sort-btn" id="ga4SortBtn" style="width:90px;cursor:pointer" title="Click to toggle sort order">Time <span id="ga4SortIcon">\u25BC</span></span>
26
+ <span class="ga4-hcell" style="flex:1">Event</span>
27
+ </div>
28
+ <div class="scroll-area" id="ga4List">
29
+ <div class="empty-state" id="ga4Empty">
30
+ <div class="icon" style="font-size:28px;opacity:.2">📊</div>
31
+ <div class="label">No GA4 events yet</div>
32
+ <div class="hint">Events from @react-native-firebase/analytics will appear here</div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ <div class="ga4-resize-handle" id="ga4ResizeHandle"></div>
37
+ <div class="ga4-detail-pane" id="ga4DetailPane">
38
+ <div class="ga4-detail-header">EVENT DETAIL</div>
39
+ <div class="scroll-area ga4-detail-content" id="ga4Detail">
40
+ <span style="color:var(--text-dim);padding:16px;display:block">Click an event to inspect</span>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ <div class="ga4-summary" id="ga4Summary">
45
+ <span class="ga4-summary-label">Total: 0</span>
46
+ </div>`;
47
+
48
+ $('ga4Search').addEventListener('input', (e) => {
49
+ ga4State.searchFilter = e.target.value.toLowerCase().trim();
50
+ renderGA4List();
51
+ renderGA4Summary(); // update active chip highlight
52
+ });
53
+
54
+ $('ga4ColorToggle')?.addEventListener('change', (e) => {
55
+ setGA4ColorsEnabled(e.target.checked);
56
+ renderGA4List();
57
+ renderGA4Summary();
58
+ });
59
+
60
+ $('ga4Clear').addEventListener('click', () => {
61
+ ga4State.events = [];
62
+ ga4State.selected = -1;
63
+ ga4State.searchFilter = '';
64
+ const search = $('ga4Search');
65
+ if (search) search.value = '';
66
+ $('ga4Badge').textContent = '0';
67
+ renderGA4List();
68
+ renderGA4Summary();
69
+ // Clear detail pane
70
+ const detail = $('ga4Detail');
71
+ if (detail) detail.innerHTML = '<div class="ga4-detail-empty" style="color:var(--text-dim);padding:20px;text-align:center;font-size:11px">Select an event to view details</div>';
72
+ });
73
+
74
+ $('ga4SortBtn').addEventListener('click', () => {
75
+ ga4State.sortDir = ga4State.sortDir === 'desc' ? 'asc' : 'desc';
76
+ $('ga4SortIcon').textContent = ga4State.sortDir === 'desc' ? '\u25BC' : '\u25B2';
77
+ renderGA4List();
78
+ });
79
+
80
+ // Resizable divider between list and detail
81
+ const resizeHandle = $('ga4ResizeHandle');
82
+ const detailPane = $('ga4DetailPane');
83
+ resizeHandle.addEventListener('mousedown', (e) => {
84
+ e.preventDefault();
85
+ const startX = e.clientX;
86
+ const startWidth = detailPane.offsetWidth;
87
+ document.body.style.cursor = 'col-resize';
88
+ document.body.style.userSelect = 'none';
89
+ function onMove(ev) {
90
+ const delta = startX - ev.clientX;
91
+ detailPane.style.width = Math.max(200, Math.min(window.innerWidth * 0.8, startWidth + delta)) + 'px';
92
+ }
93
+ function onUp() {
94
+ document.body.style.cursor = '';
95
+ document.body.style.userSelect = '';
96
+ document.removeEventListener('mousemove', onMove);
97
+ document.removeEventListener('mouseup', onUp);
98
+ }
99
+ document.addEventListener('mousemove', onMove);
100
+ document.addEventListener('mouseup', onUp);
101
+ });
102
+ }
103
+
104
+ function handleGA4Event(event) {
105
+ if (!isTabEnabled('ga4')) return;
106
+ ga4State.events.push({
107
+ name: event.name || '?',
108
+ params: event.params || {},
109
+ tag: event.tag || 'GA4',
110
+ source: event.source || '',
111
+ ts: event.ts || Date.now(),
112
+ index: ga4State.events.length,
113
+ });
114
+ $('ga4Badge').textContent = ga4State.events.length;
115
+
116
+ // Append to list (batched via rAF)
117
+ if (!ga4State._raf) {
118
+ ga4State._raf = requestAnimationFrame(() => {
119
+ ga4State._raf = null;
120
+ renderGA4List();
121
+ renderGA4Summary();
122
+ });
123
+ }
124
+ }
125
+
126
+ // Assign consistent color to each GA4 event name
127
+ const _ga4EventColors = {};
128
+ const _ga4ColorPalette = [
129
+ '#4facff', // blue
130
+ '#3dd68c', // green
131
+ '#ff813f', // orange
132
+ '#c678dd', // purple
133
+ '#e06c75', // coral
134
+ '#56b6c2', // teal
135
+ '#d19a66', // gold
136
+ '#98c379', // lime
137
+ '#e5c07b', // yellow
138
+ '#ff5e72', // red
139
+ '#61afef', // light blue
140
+ '#be5046', // rust
141
+ ];
142
+ let _ga4ColorIdx = 0;
143
+ function _ga4EventColor(name) {
144
+ if (!getGA4ColorsEnabled()) return ''; // empty = inherit default text color
145
+ if (!_ga4EventColors[name]) {
146
+ _ga4EventColors[name] = _ga4ColorPalette[_ga4ColorIdx % _ga4ColorPalette.length];
147
+ _ga4ColorIdx++;
148
+ }
149
+ return _ga4EventColors[name];
150
+ }
151
+ function getGA4ColorsEnabled() {
152
+ try { return localStorage.getItem('rn-debug-ga4-colors') === 'true'; } catch { return false; }
153
+ }
154
+ function setGA4ColorsEnabled(v) {
155
+ try { localStorage.setItem('rn-debug-ga4-colors', v ? 'true' : 'false'); } catch {}
156
+ }
157
+
158
+ function renderGA4List() {
159
+ const list = $('ga4List');
160
+ const empty = $('ga4Empty');
161
+ if (!list) return;
162
+
163
+ const { searchFilter, sortDir } = ga4State;
164
+ let visible = ga4State.events.filter(e =>
165
+ !searchFilter || e.name.toLowerCase().includes(searchFilter)
166
+ );
167
+
168
+ // Sort: newest first (desc) or oldest first (asc)
169
+ if (sortDir === 'desc') {
170
+ visible = [...visible].reverse();
171
+ }
172
+
173
+ empty.style.display = visible.length ? 'none' : 'flex';
174
+ list.querySelectorAll('.ga4-row').forEach(e => e.remove());
175
+
176
+ // Cap at 500 rows
177
+ const MAX = 500;
178
+ const toRender = visible.length > MAX ? visible.slice(0, MAX) : visible;
179
+
180
+ const frag = document.createDocumentFragment();
181
+ toRender.forEach(e => {
182
+ const row = document.createElement('div');
183
+ row.className = 'ga4-row' + (e.index === ga4State.selected ? ' selected' : '');
184
+
185
+ const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
186
+
187
+ const evtColor = _ga4EventColor(e.name);
188
+ const colorStyle = evtColor ? `color:${evtColor}` : '';
189
+ row.innerHTML = `
190
+ <span class="ga4-cell ga4-time">${time}</span>
191
+ <span class="ga4-cell ga4-name" style="${colorStyle}">${esc(e.name)}</span>`;
192
+
193
+ row.addEventListener('click', () => {
194
+ ga4State.selected = e.index;
195
+ list.querySelectorAll('.ga4-row').forEach(r => r.classList.remove('selected'));
196
+ row.classList.add('selected');
197
+ renderGA4Detail(e);
198
+ });
199
+
200
+ // Right-click to copy
201
+ row.addEventListener('contextmenu', (ev) => {
202
+ ev.preventDefault();
203
+ showContextMenu(ev, [
204
+ { label: 'Copy Event Name', action: () => navigator.clipboard.writeText(e.name) },
205
+ { label: 'Copy as JSON', action: () => navigator.clipboard.writeText(JSON.stringify({ event: e.name, params: e.params }, null, 2)) },
206
+ ]);
207
+ });
208
+
209
+ frag.appendChild(row);
210
+ });
211
+ list.appendChild(frag);
212
+ }
213
+
214
+ function renderGA4Detail(e) {
215
+ let detail = $('ga4Detail');
216
+ if (!detail) return;
217
+
218
+ const time = new Date(e.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
219
+
220
+ // Clone-replace to remove stale event listeners
221
+ const fresh = detail.cloneNode(false);
222
+ detail.parentNode.replaceChild(fresh, detail);
223
+ detail = fresh;
224
+
225
+ // Header info
226
+ const header = document.createElement('div');
227
+ header.className = 'ga4-detail-info';
228
+ header.innerHTML = `
229
+ <div class="ga4-detail-row"><span class="ga4-detail-key">Event Name</span><span class="ga4-detail-val" style="${_ga4EventColor(e.name) ? 'color:' + _ga4EventColor(e.name) + ';' : ''}font-weight:600;font-size:1.1em">${esc(e.name)}</span></div>
230
+ <div class="ga4-detail-row"><span class="ga4-detail-key">Timestamp</span><span class="ga4-detail-val">${time}</span></div>
231
+ `;
232
+ detail.appendChild(header);
233
+
234
+ // Separator
235
+ const sep = document.createElement('div');
236
+ sep.className = 'ga4-detail-sep';
237
+ detail.appendChild(sep);
238
+
239
+ // Parameters as key-value list with collapsible objects
240
+ if (e.params && typeof e.params === 'object') {
241
+ const keys = Object.keys(e.params).sort();
242
+ keys.forEach(key => {
243
+ const val = e.params[key];
244
+ const row = document.createElement('div');
245
+ row.className = 'ga4-param-row';
246
+
247
+ const keyEl = document.createElement('span');
248
+ keyEl.className = 'ga4-param-key';
249
+ keyEl.textContent = key;
250
+ row.appendChild(keyEl);
251
+
252
+ if (val && typeof val === 'object') {
253
+ // Collapsible object tree
254
+ const treeWrap = document.createElement('span');
255
+ treeWrap.className = 'ga4-param-val';
256
+ treeWrap.appendChild(createTreeNode(null, val, true));
257
+ row.appendChild(treeWrap);
258
+ } else {
259
+ const valEl = document.createElement('span');
260
+ valEl.className = 'ga4-param-val';
261
+ valEl.textContent = val === null ? 'null' : val === undefined ? 'undefined' : JSON.stringify(val);
262
+ if (typeof val === 'string') valEl.style.color = 'var(--green)';
263
+ else if (typeof val === 'number') valEl.style.color = 'var(--orange)';
264
+ else if (typeof val === 'boolean') valEl.style.color = 'var(--accent2)';
265
+ row.appendChild(valEl);
266
+ }
267
+
268
+ detail.appendChild(row);
269
+ });
270
+ }
271
+
272
+ // Right-click on detail
273
+ detail.addEventListener('contextmenu', (ev) => {
274
+ ev.preventDefault();
275
+ showContextMenu(ev, [
276
+ { label: 'Copy All Parameters', action: () => navigator.clipboard.writeText(JSON.stringify(e.params, null, 2)) },
277
+ { label: 'Copy Event JSON', action: () => navigator.clipboard.writeText(JSON.stringify({ event: e.name, params: e.params, timestamp: e.ts }, null, 2)) },
278
+ ]);
279
+ });
280
+ }
281
+
282
+ function renderGA4Summary() {
283
+ const summary = $('ga4Summary');
284
+ if (!summary) return;
285
+
286
+ const counts = {};
287
+ ga4State.events.forEach(e => {
288
+ counts[e.name] = (counts[e.name] || 0) + 1;
289
+ });
290
+
291
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
292
+
293
+ summary.innerHTML = '';
294
+
295
+ const totalLabel = document.createElement('span');
296
+ totalLabel.className = 'ga4-summary-label';
297
+ totalLabel.textContent = `Total: ${ga4State.events.length}`;
298
+ summary.appendChild(totalLabel);
299
+
300
+ sorted.forEach(([name, count]) => {
301
+ const chip = document.createElement('span');
302
+ const isActive = ga4State.searchFilter === name.toLowerCase();
303
+ const chipColor = _ga4EventColor(name);
304
+ chip.className = 'ga4-summary-chip' + (isActive ? ' active' : '');
305
+ if (chipColor) {
306
+ chip.style.borderColor = chipColor;
307
+ if (isActive) chip.style.background = chipColor + '22';
308
+ chip.innerHTML = `<b style="color:${chipColor}">${esc(name)}</b><span class="chip-count">${count}</span>`;
309
+ } else {
310
+ chip.innerHTML = `<b>${esc(name)}</b><span class="chip-count">${count}</span>`;
311
+ }
312
+ chip.addEventListener('click', () => {
313
+ const search = $('ga4Search');
314
+ if (isActive) {
315
+ // Clear filter
316
+ ga4State.searchFilter = '';
317
+ if (search) search.value = '';
318
+ } else {
319
+ // Set filter to this event name
320
+ ga4State.searchFilter = name.toLowerCase();
321
+ if (search) search.value = name;
322
+ }
323
+ renderGA4List();
324
+ renderGA4Summary();
325
+ });
326
+ summary.appendChild(chip);
327
+ });
328
+ }
@@ -0,0 +1,256 @@
1
+ // ─── Native Logs Panel ─────────────────────────────────────────────────────
2
+ const _nativeState = { logs: [], connected: false, platform: null, levelFilter: 'all', searchFilter: '' };
3
+ const MAX_NATIVE_LOGS = 2000;
4
+
5
+ function initNativeLogsPanel() {
6
+ const panel = $('panel-native');
7
+ if (!panel) return;
8
+ panel.innerHTML = `
9
+ <div class="panel-toolbar">
10
+ <span class="panel-label">Native Logs</span>
11
+ <span class="badge" id="nativeBadge">0</span>
12
+ <div class="ml-auto" style="display:flex;align-items:center;gap:6px">
13
+ <span class="native-status" id="nativeStatus">Detecting...</span>
14
+ <button class="panel-clear-btn" id="nativeClear">Clear</button>
15
+ </div>
16
+ </div>
17
+ <div class="native-connect-panel" id="nativeConnectPanel">
18
+ <div class="native-hero">
19
+ <div style="font-size:36px;opacity:0.15;margin-bottom:12px">📱</div>
20
+ <div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:6px">Native Logs</div>
21
+ <div style="font-size:11px;color:var(--text-dim);max-width:420px;line-height:1.7;margin-bottom:20px">
22
+ Stream native crash logs, errors, and warnings directly in ReactoRadar.<br/>
23
+ No need to open Android Studio or Xcode.
24
+ </div>
25
+ <div class="native-platform-cards">
26
+ <div class="native-card" id="nativeCardAndroid">
27
+ <div class="native-card-icon">🤖</div>
28
+ <div class="native-card-title">Android</div>
29
+ <div class="native-card-hint">Requires: <code>adb</code> in PATH (Android SDK)</div>
30
+ <div class="native-card-prereq">
31
+ <div class="native-prereq-step"><b>Prerequisites:</b></div>
32
+ <div class="native-prereq-step">1. Enable <b>Developer Options</b> on device<br/><span style="color:var(--text-dim);font-size:9px">Settings → About Phone → Tap Build Number 7 times</span></div>
33
+ <div class="native-prereq-step">2. Enable <b>USB Debugging</b><br/><span style="color:var(--text-dim);font-size:9px">Settings → Developer Options → USB Debugging → ON</span></div>
34
+ <div class="native-prereq-step">3. Connect device via USB and accept the prompt</div>
35
+ <div class="native-prereq-step">4. Verify: run <code>adb devices</code> in terminal</div>
36
+ </div>
37
+ <div id="nativeAndroidStatus" class="native-detect-status"></div>
38
+ <button class="native-connect-btn" id="nativeConnectAndroid">Connect Android</button>
39
+ </div>
40
+ <div class="native-card" id="nativeCardIOS">
41
+ <div class="native-card-icon">🍎</div>
42
+ <div class="native-card-title">iOS</div>
43
+ <div class="native-card-hint">Simulator or USB device</div>
44
+ <div class="native-card-prereq">
45
+ <div class="native-prereq-step"><b>Simulator:</b></div>
46
+ <div class="native-prereq-step">Requires Xcode Command Line Tools<br/><code>xcode-select --install</code></div>
47
+ <div class="native-prereq-step" style="margin-top:6px"><b>Real Device (USB):</b></div>
48
+ <div class="native-prereq-step">1. Install: <code>brew install libimobiledevice</code></div>
49
+ <div class="native-prereq-step">2. Connect device, tap <b>Trust</b> on the prompt</div>
50
+ <div class="native-prereq-step">3. Verify: <code>idevice_id -l</code> shows device UDID</div>
51
+ </div>
52
+ <div id="nativeIOSStatus" class="native-detect-status"></div>
53
+ <div style="display:flex;gap:6px;margin-top:8px">
54
+ <button class="native-connect-btn" id="nativeConnectIOSSim">Simulator</button>
55
+ <button class="native-connect-btn" id="nativeConnectIOSDevice">USB Device</button>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ <div class="native-logs-area" id="nativeLogsArea" style="display:none">
62
+ <div class="native-filter-bar">
63
+ <input id="nativeSearch" class="net-search-input" placeholder="Filter logs..." />
64
+ <div class="native-level-filters" id="nativeLevelFilters">
65
+ <button class="net-status-btn active" data-level="all">All</button>
66
+ <button class="net-status-btn" data-level="fatal">Fatal</button>
67
+ <button class="net-status-btn" data-level="error">Error</button>
68
+ <button class="net-status-btn" data-level="warn">Warn</button>
69
+ <button class="net-status-btn" data-level="info">Info</button>
70
+ <button class="net-status-btn" data-level="debug">Debug</button>
71
+ </div>
72
+ <div style="margin-left:auto;display:flex;gap:6px;align-items:center">
73
+ <button class="panel-clear-btn" id="nativeLogsClear">Clear</button>
74
+ <button class="panel-clear-btn" id="nativeDisconnect" style="color:var(--red)">Disconnect</button>
75
+ </div>
76
+ </div>
77
+ <div class="native-log-list" id="nativeLogList"></div>
78
+ </div>`;
79
+
80
+ // Connect buttons — auto-enable tab when user clicks connect
81
+ function _enableNativeTab() {
82
+ const vis = getTabVisibility();
83
+ if (!vis['native']) {
84
+ vis['native'] = true;
85
+ setTabVisibility(vis);
86
+ applyTabVisibility();
87
+ }
88
+ }
89
+ $('nativeConnectAndroid')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('android'); });
90
+ $('nativeConnectIOSSim')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('ios-sim'); });
91
+ $('nativeConnectIOSDevice')?.addEventListener('click', () => { _enableNativeTab(); window.electronAPI?.startNativeLogs('ios-device'); });
92
+ $('nativeDisconnect')?.addEventListener('click', () => window.electronAPI?.stopNativeLogs());
93
+
94
+ // Clear buttons (toolbar + logs area)
95
+ $('nativeClear')?.addEventListener('click', _clearNativeLogs);
96
+ $('nativeLogsClear')?.addEventListener('click', _clearNativeLogs);
97
+
98
+ // Level filter
99
+ $('nativeLevelFilters')?.addEventListener('click', (e) => {
100
+ const btn = e.target.closest('.net-status-btn');
101
+ if (!btn) return;
102
+ $('nativeLevelFilters').querySelectorAll('.net-status-btn').forEach(b => b.classList.remove('active'));
103
+ btn.classList.add('active');
104
+ _nativeState.levelFilter = btn.dataset.level;
105
+ _renderNativeLogs();
106
+ });
107
+
108
+ // Search
109
+ $('nativeSearch')?.addEventListener('input', (e) => {
110
+ _nativeState.searchFilter = e.target.value.toLowerCase().trim();
111
+ _renderNativeLogs();
112
+ });
113
+
114
+ // IPC: receive native logs
115
+ window.electronAPI?.on('native-log', (log) => {
116
+ if (!isTabEnabled('native')) return;
117
+ _nativeState.logs.push(log);
118
+ if (_nativeState.logs.length > MAX_NATIVE_LOGS) {
119
+ _nativeState.logs = _nativeState.logs.slice(-MAX_NATIVE_LOGS);
120
+ }
121
+ $('nativeBadge').textContent = _nativeState.logs.length;
122
+ _appendNativeLog(log);
123
+ });
124
+
125
+ // IPC: connection status
126
+ window.electronAPI?.on('native-status', (status) => {
127
+ _nativeState.connected = status.connected;
128
+ _nativeState.platform = status.platform || null;
129
+ const statusEl = $('nativeStatus');
130
+ const connectPanel = $('nativeConnectPanel');
131
+ const logsArea = $('nativeLogsArea');
132
+
133
+ if (status.connected) {
134
+ if (statusEl) { statusEl.textContent = `Connected (${status.platform})`; statusEl.style.color = 'var(--green)'; }
135
+ if (connectPanel) connectPanel.style.display = 'none';
136
+ if (logsArea) logsArea.style.display = 'flex';
137
+ } else {
138
+ if (statusEl) {
139
+ statusEl.textContent = status.error || 'Not connected';
140
+ statusEl.style.color = status.error ? 'var(--red)' : 'var(--text-dim)';
141
+ }
142
+ if (connectPanel) connectPanel.style.display = 'flex';
143
+ if (logsArea) logsArea.style.display = 'none';
144
+ }
145
+ });
146
+
147
+ // Auto-detect platform and auto-connect
148
+ _autoDetectNative();
149
+ }
150
+
151
+ function _clearNativeLogs() {
152
+ _nativeState.logs = [];
153
+ if ($('nativeBadge')) $('nativeBadge').textContent = '0';
154
+ const list = $('nativeLogList');
155
+ if (list) list.innerHTML = '';
156
+ }
157
+
158
+ async function _autoDetectNative() {
159
+ const statusEl = $('nativeStatus');
160
+ try {
161
+ const result = await window.electronAPI?.detectNativePlatform();
162
+ if (!result) { if (statusEl) { statusEl.textContent = 'Detection unavailable'; statusEl.style.color = 'var(--text-dim)'; } return; }
163
+
164
+ // Update card statuses
165
+ const androidStatus = $('nativeAndroidStatus');
166
+ const iosStatus = $('nativeIOSStatus');
167
+ if (androidStatus) {
168
+ if (result.android) { androidStatus.innerHTML = '<span style="color:var(--green)">Device detected</span>'; }
169
+ else if (result.adbPath) { androidStatus.innerHTML = '<span style="color:var(--orange)">adb found — no device connected</span>'; }
170
+ else { androidStatus.innerHTML = '<span style="color:var(--text-dim)">adb not found</span>'; }
171
+ }
172
+ if (iosStatus) {
173
+ const parts = [];
174
+ if (result.iosSim) parts.push('<span style="color:var(--green)">Simulator running</span>');
175
+ if (result.iosDevice) parts.push('<span style="color:var(--green)">USB device detected</span>');
176
+ if (!parts.length) parts.push('<span style="color:var(--text-dim)">No device detected</span>');
177
+ iosStatus.innerHTML = parts.join(' · ');
178
+ }
179
+
180
+ // Show detection result — user clicks Connect to start
181
+ if (result.android || result.iosSim || result.iosDevice) {
182
+ const detected = [result.android ? 'Android' : '', result.iosSim ? 'iOS Sim' : '', result.iosDevice ? 'iOS Device' : ''].filter(Boolean).join(', ');
183
+ if (statusEl) { statusEl.textContent = `Detected: ${detected} — click Connect to start`; statusEl.style.color = 'var(--accent)'; }
184
+ } else {
185
+ if (statusEl) { statusEl.textContent = 'No device detected'; statusEl.style.color = 'var(--text-dim)'; }
186
+ }
187
+ } catch {
188
+ if (statusEl) { statusEl.textContent = 'Detection failed'; statusEl.style.color = 'var(--text-dim)'; }
189
+ }
190
+ }
191
+
192
+ function _appendNativeLog(log) {
193
+ const list = $('nativeLogList');
194
+ if (!list) return;
195
+
196
+ // Check filters
197
+ if (_nativeState.levelFilter !== 'all' && log.level !== _nativeState.levelFilter) return;
198
+ if (_nativeState.searchFilter && !log.message?.toLowerCase().includes(_nativeState.searchFilter) && !log.tag?.toLowerCase().includes(_nativeState.searchFilter)) return;
199
+
200
+ const isExpandable = log.level === 'error' || log.level === 'fatal' || (log.message || '').length > 200;
201
+ const row = document.createElement('div');
202
+ row.className = `native-log-row native-${log.level || 'info'}`;
203
+
204
+ const time = log.time || new Date(log.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
205
+
206
+ // Header line (always visible)
207
+ const header = document.createElement('div');
208
+ header.className = 'native-log-header';
209
+ header.innerHTML = `<span class="native-log-time">${esc(time)}</span>`
210
+ + `<span class="native-log-level">${esc((log.level || 'info').toUpperCase())}</span>`
211
+ + (log.tag ? `<span class="native-log-tag">${esc(log.tag)}</span>` : '')
212
+ + `<span class="native-log-preview">${esc((log.message || '').split('\\n')[0].slice(0, 200))}</span>`;
213
+ row.appendChild(header);
214
+
215
+ // Expandable full message (for errors and long messages)
216
+ if (isExpandable) {
217
+ const fullMsg = document.createElement('div');
218
+ fullMsg.className = 'native-log-full';
219
+ fullMsg.style.display = 'none';
220
+ fullMsg.textContent = log.message || '';
221
+ row.appendChild(fullMsg);
222
+
223
+ header.style.cursor = 'pointer';
224
+ header.addEventListener('click', () => {
225
+ const open = fullMsg.style.display !== 'none';
226
+ fullMsg.style.display = open ? 'none' : 'block';
227
+ row.classList.toggle('expanded', !open);
228
+ });
229
+ }
230
+
231
+ // Right-click to copy
232
+ row.addEventListener('contextmenu', (e) => {
233
+ e.preventDefault();
234
+ showContextMenu(e, [
235
+ { label: 'Copy Message', action: () => navigator.clipboard.writeText(log.message || '') },
236
+ { label: 'Copy Raw Line', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
237
+ ...(log.tag ? [{ label: `Copy Tag (${log.tag})`, action: () => navigator.clipboard.writeText(log.tag) }] : []),
238
+ ]);
239
+ });
240
+
241
+ list.appendChild(row);
242
+
243
+ // Cap DOM rows
244
+ while (list.children.length > 1000) list.firstChild.remove();
245
+
246
+ // Auto-scroll if near bottom
247
+ const atBottom = (list.scrollHeight - list.scrollTop - list.clientHeight) < 150;
248
+ if (atBottom) list.scrollTop = list.scrollHeight;
249
+ }
250
+
251
+ function _renderNativeLogs() {
252
+ const list = $('nativeLogList');
253
+ if (!list) return;
254
+ list.innerHTML = '';
255
+ _nativeState.logs.forEach(log => _appendNativeLog(log));
256
+ }