reactoradar 1.6.10 → 1.6.12

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/README.md CHANGED
@@ -53,6 +53,17 @@
53
53
  | **React** | Component tree and props inspector via `react-devtools-core` relay |
54
54
  | **Settings** | 9 color themes, font family/size, configurable panel visibility with drag-to-reorder, Metro port config, keyboard shortcuts, auto-update, support link |
55
55
 
56
+ ### What's New in v1.6.11
57
+
58
+ - **Auto-clear on reconnect** — All tabs reset automatically when the RN app relaunches (fresh session, no stale data)
59
+ - **No more `[object Object]`** — Safe string serialization everywhere in Console, Network, and Redux panels
60
+ - **SDK auto-detects platform** — Android emulator (`10.0.2.2`) and iOS simulator (`127.0.0.1`) detected at runtime. No manual HOST editing.
61
+ - **Setup auto-patches legacy `createStore`** — Detects `const middleware = []` pattern and wires `reduxMiddleware` automatically
62
+ - **SDK hardened** — BigInt/circular-safe JSON, Redux state >1MB truncated, binary response guard, reconnect backoff, console try/catch
63
+ - **Version rollback** — Settings > Version History shows all releases with download/install buttons
64
+ - **Panel-per-file architecture** — Each panel in its own file under `panels/`. Safer, easier to maintain.
65
+ - **Null guards everywhere** — All panel init functions, badge updates, and DOM access null-safe
66
+
56
67
  ### What's New in v1.6.0
57
68
 
58
69
  - **Auto-Update** — `.dmg` builds auto-download updates from GitHub Releases. Settings shows "Restart & Update" when ready.
@@ -134,7 +145,7 @@ Console, Network, Redux, GA4, AsyncStorage data flows automatically. No config n
134
145
  | Android real device (USB) | `10.0.2.2` | `adb reverse` tunnels over USB (auto-configured) |
135
146
  | iOS real device (USB/WiFi) | Mac's LAN IP | Auto-detected. Device must be on same WiFi as Mac. |
136
147
 
137
- `npx reactoradar setup` auto-detects your platform and sets the correct HOST.
148
+ The SDK auto-detects the platform at runtime. For iOS real devices, set `HOST_OVERRIDE` in `src/debug/RNDebugSDK.js` to your Mac's LAN IP. `npx reactoradar setup` handles this automatically.
138
149
 
139
150
  ### Uninstall
140
151
 
@@ -146,7 +157,7 @@ npx reactoradar remove
146
157
 
147
158
  | ReactoRadar | React Native | Engine | Architecture |
148
159
  |---|---|---|---|
149
- | v1.6+ | 0.74 — 0.81+ | Hermes | Old & New Architecture |
160
+ | v1.6.11+ | 0.74 — 0.81+ | Hermes | Old & New Architecture |
150
161
 
151
162
  ## Network Inspector
152
163
 
@@ -203,13 +214,26 @@ export const store = configureStore({
203
214
  });
204
215
  ```
205
216
 
206
- **Legacy Redux (createStore):**
217
+ **Legacy Redux (createStore with middleware array):**
218
+ ```js
219
+ const middleware = [];
220
+ // ... your existing middleware (saga, thunk, etc.)
221
+ if (__DEV__) {
222
+ try {
223
+ const { reduxMiddleware } = require('./debug/RNDebugSDK');
224
+ if (reduxMiddleware) middleware.push(reduxMiddleware);
225
+ } catch {}
226
+ }
227
+ const store = createStore(rootReducer, applyMiddleware(...middleware));
228
+ ```
229
+
230
+ **Legacy Redux (createStore without middleware):**
207
231
  ```js
208
232
  import { reduxEnhancer } from '../debug/RNDebugSDK';
209
233
  const store = createStore(reducer, __DEV__ ? reduxEnhancer : undefined);
210
234
  ```
211
235
 
212
- > **Note:** The import path is relative from your store file to `src/debug/RNDebugSDK`. Run `npx reactoradar setup` to auto-detect the correct path.
236
+ > **Note:** `npx reactoradar setup` auto-detects your store file and patches it. If it can't, follow the examples above. The import path is relative from your store file to `src/debug/RNDebugSDK`.
213
237
 
214
238
  ## Settings
215
239
 
@@ -293,7 +317,8 @@ const store = createStore(reducer, __DEV__ ? reduxEnhancer : undefined);
293
317
  | Network tab empty | Run Metro with `--reset-cache` |
294
318
  | Blank screen after long use | Click "Clear All Data" on the memory warning banner, or restart the app |
295
319
  | Redux shows "No actions dispatched" | Verify `reduxMiddleware` is wired in your store. Run `npx reactoradar setup` to auto-detect. |
296
- | Real device not connecting | Ensure HOST in `src/debug/RNDebugSDK.js` matches your Mac's LAN IP. Re-run `npx reactoradar setup`. |
320
+ | Android emulator not connecting | Run `adb reverse tcp:9090 tcp:9090 && adb reverse tcp:9091 tcp:9091 && adb reverse tcp:9092 tcp:9092`. Re-run after emulator restart. |
321
+ | Real device not connecting | Set `HOST_OVERRIDE` in `src/debug/RNDebugSDK.js` to your Mac's LAN IP. Re-run `npx reactoradar setup`. |
297
322
  | `XHRInterceptor.js` warning | Set `networking: false` in ReactotronConfig.js |
298
323
  | GA4 events not showing | Restart Metro with `--reset-cache` after setup |
299
324
  | Port conflict | Run `kill $(lsof -ti :9092)` to free the port, then restart |
package/app.js CHANGED
@@ -46,6 +46,14 @@ const esc = s => s == null ? '' : String(s)
46
46
  .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
47
47
  const ts = ms => new Date(ms).toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
48
48
 
49
+ // Safe string conversion — never returns [object Object]
50
+ function safeStr(val) {
51
+ if (val == null) return '';
52
+ if (typeof val === 'string') return val;
53
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
54
+ try { return JSON.stringify(val); } catch { return '[Complex object]'; }
55
+ }
56
+
49
57
 
50
58
  function pretty(val) {
51
59
  if (val == null) return '';
@@ -63,11 +71,28 @@ function syntaxHighlight(json) {
63
71
  });
64
72
  }
65
73
 
74
+ // Sort object keys alphabetically for display (recursive)
75
+ function _sortKeys(obj) {
76
+ if (Array.isArray(obj)) return obj.map(_sortKeys);
77
+ if (obj !== null && typeof obj === 'object') {
78
+ const sorted = {};
79
+ Object.keys(obj).sort().forEach(k => { sorted[k] = _sortKeys(obj[k]); });
80
+ return sorted;
81
+ }
82
+ return obj;
83
+ }
84
+
66
85
  function renderJSON(val) {
86
+ if (val == null) return '<span style="color:var(--text-dim)">Empty response</span>';
67
87
  try {
68
- const str = typeof val === 'string' ? val : JSON.stringify(val, null, 2);
88
+ let data = val;
89
+ // Parse string JSON so we can sort keys
90
+ if (typeof data === 'string') { try { data = JSON.parse(data); } catch { return syntaxHighlight(esc(data)); } }
91
+ const sorted = _sortKeys(data);
92
+ const str = JSON.stringify(sorted, null, 2);
93
+ if (!str || str === '{}' || str === '""') return '<span style="color:var(--text-dim)">Empty response body</span>';
69
94
  return syntaxHighlight(esc(str));
70
- } catch { return esc(typeof val === 'object' ? JSON.stringify(val) : String(val)); }
95
+ } catch { try { return esc(JSON.stringify(val)); } catch { return esc('[Unserializable data]'); } }
71
96
  }
72
97
 
73
98
  function tryURL(url) { try { return new URL(url); } catch { return null; } }
package/bin/setup.js CHANGED
@@ -383,30 +383,9 @@ ${SDK_MARKER_END}
383
383
  } else {
384
384
  log('Redux store already has RNDebugSDK wired correctly — skipping');
385
385
  }
386
- } else if (storeContent.includes('configureStore')) {
387
- // RTK configureStore
388
- // Try to add middleware to configureStore
389
- if (storeContent.includes('middleware:') || storeContent.includes('middleware :')) {
390
- warn('Redux store found at', C.bold + storeFile + C.reset, '— has custom middleware');
391
- console.log(C.dim + ' Add manually to your middleware:' + C.reset);
392
- console.log(C.dim + ` import { reduxMiddleware } from '${relSDK}';` + C.reset);
393
- console.log(C.dim + ' middleware: (getDefault) => __DEV__' + C.reset);
394
- console.log(C.dim + ' ? getDefault().concat(reduxMiddleware)' + C.reset);
395
- console.log(C.dim + ' : getDefault(),' + C.reset);
396
- } else {
397
- // Add middleware field to configureStore
398
- const patched = storeContent.replace(
399
- /(configureStore\s*\(\s*\{)/,
400
- `$1\n middleware: (getDefaultMiddleware) =>\n __DEV__\n ? getDefaultMiddleware().concat(require('${relSDK}').reduxMiddleware)\n : getDefaultMiddleware(),`
401
- );
402
- if (patched !== storeContent) {
403
- fs.writeFileSync(storePath, patched);
404
- log('Patched', C.bold + storeFile + C.reset, '— Redux middleware wired');
405
- } else {
406
- warn('Could not auto-patch', storeFile, '— wire Redux manually');
407
- }
408
- }
409
- } else if (storeContent.includes('createStore')) {
386
+ } else if (/createStore\s*\(/.test(storeContent)) {
387
+ // Legacy createStore (check this BEFORE configureStore — a file may have both
388
+ // if the user named their wrapper function "configureStore" but uses Redux's createStore inside)
410
389
  // Legacy createStore — try to auto-patch by adding reduxMiddleware to middleware array
411
390
  let patched = storeContent;
412
391
  let didPatch = false;
@@ -449,9 +428,30 @@ ${SDK_MARKER_END}
449
428
  console.log(C.dim + ' Could not auto-patch. Add manually:' + C.reset);
450
429
  console.log(C.dim + ` if (__DEV__) { try { const { reduxMiddleware } = require('${relSDK}'); middleware.push(reduxMiddleware); } catch {} }` + C.reset);
451
430
  console.log(C.dim + ' Add this BEFORE the createStore() call in your middleware setup.' + C.reset);
431
+ }
432
+ } else if (/configureStore\s*\(\s*\{/.test(storeContent)) {
433
+ // RTK configureStore({ ... }) — actual Redux Toolkit usage
434
+ if (storeContent.includes('middleware:') || storeContent.includes('middleware :')) {
435
+ warn('Redux store found at', C.bold + storeFile + C.reset, '— has custom middleware');
436
+ console.log(C.dim + ' Add manually to your middleware:' + C.reset);
437
+ console.log(C.dim + ` import { reduxMiddleware } from '${relSDK}';` + C.reset);
438
+ console.log(C.dim + ' middleware: (getDefault) => __DEV__' + C.reset);
439
+ console.log(C.dim + ' ? getDefault().concat(reduxMiddleware)' + C.reset);
440
+ console.log(C.dim + ' : getDefault(),' + C.reset);
441
+ } else {
442
+ const patched = storeContent.replace(
443
+ /(configureStore\s*\(\s*\{)/,
444
+ `$1\n middleware: (getDefaultMiddleware) =>\n __DEV__\n ? getDefaultMiddleware().concat(require('${relSDK}').reduxMiddleware)\n : getDefaultMiddleware(),`
445
+ );
446
+ if (patched !== storeContent) {
447
+ fs.writeFileSync(storePath, patched);
448
+ log('Patched', C.bold + storeFile + C.reset, '— Redux middleware wired (RTK)');
449
+ } else {
450
+ warn('Could not auto-patch', storeFile, '— wire Redux manually');
451
+ }
452
452
  }
453
- }
454
- } else {
453
+ }
454
+ } else {
455
455
  warn('Redux detected but store file not found automatically');
456
456
  console.log(C.dim + ' Add to your store setup:' + C.reset);
457
457
  console.log(C.dim + ' import { reduxMiddleware } from \'./src/debug/RNDebugSDK\';' + C.reset);
package/init.js CHANGED
@@ -47,21 +47,32 @@ if (window.electronAPI) {
47
47
 
48
48
  window.electronAPI.on('clear-all-ui', clearAll);
49
49
 
50
- // When all device bridges disconnect, release heavy memory but keep logs visible.
51
- // Debounced to avoid data loss during hot reloads or flaky connections.
50
+ // Device disconnect debounced freeMemory. Reconnect clearAll (fresh session).
52
51
  let _disconnectTimer = null;
52
+ let _wasDisconnected = false;
53
+
53
54
  window.electronAPI.on('device-all-disconnected', () => {
55
+ _wasDisconnected = true;
54
56
  clearTimeout(_disconnectTimer);
55
57
  _disconnectTimer = setTimeout(() => {
56
58
  console.log('[App] All devices disconnected — freeing memory');
57
59
  freeMemory();
58
60
  }, 3000);
59
61
  });
60
- // Cancel pending free if a device reconnects
61
- const _cancelDisconnectTimer = () => { clearTimeout(_disconnectTimer); _disconnectTimer = null; };
62
- window.electronAPI.on('redux-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('redux', on); });
63
- window.electronAPI.on('network-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('network', on); });
64
- window.electronAPI.on('storage-connected', on => { if (on) _cancelDisconnectTimer(); updateDeviceBanner('storage', on); });
62
+
63
+ const _handleReconnect = () => {
64
+ clearTimeout(_disconnectTimer);
65
+ _disconnectTimer = null;
66
+ if (_wasDisconnected) {
67
+ _wasDisconnected = false;
68
+ console.log('[App] Device reconnected — clearing old session data');
69
+ clearAll();
70
+ }
71
+ };
72
+
73
+ window.electronAPI.on('redux-connected', on => { if (on) _handleReconnect(); updateDeviceBanner('redux', on); });
74
+ window.electronAPI.on('network-connected', on => { if (on) _handleReconnect(); updateDeviceBanner('network', on); });
75
+ window.electronAPI.on('storage-connected', on => { if (on) _handleReconnect(); updateDeviceBanner('storage', on); });
65
76
  window.electronAPI.on('react-dt-status', on => { updateDeviceBanner('reactDT', on); });
66
77
 
67
78
  // Cmd+F — focus the search input for the active panel
package/main.js CHANGED
@@ -739,11 +739,15 @@ function setupIPC() {
739
739
  if (platform === 'android') {
740
740
  // adb logcat — show only new logs from now (not historical buffer)
741
741
  cmd = 'adb';
742
- args = ['logcat', '-v', 'threadtime', '-T', '1', '*:W']; // -T 1 = last 1 line then real-time
742
+ // -T 1 = last 1 line then real-time. Include Firebase tags at Verbose level for GA4 event capture.
743
+ args = ['logcat', '-v', 'threadtime', '-T', '1', 'FA:V', 'FA-SVC:V', 'FirebaseAnalytics:V', '*:W'];
743
744
  } else if (platform === 'ios-sim') {
744
- // xcrun simctl for iOS Simulator use syslog style for parseable output
745
- cmd = 'xcrun';
746
- args = ['simctl', 'spawn', 'booted', 'log', 'stream', '--style', 'syslog', '--level', 'error'];
745
+ // Use macOS unified log to capture iOS simulator logs
746
+ // processImagePath CONTAINS "CoreSimulator" filters to simulator processes only
747
+ // Captures errors + Firebase/FIRAnalytics events (requires -FIRDebugEnabled in Xcode scheme)
748
+ cmd = '/usr/bin/log';
749
+ args = ['stream', '--style', 'syslog', '--predicate',
750
+ 'processImagePath CONTAINS "CoreSimulator" AND (messageType >= error OR composedMessage CONTAINS "firebase" OR composedMessage CONTAINS "FIRAnalytics" OR composedMessage CONTAINS "Logging event" OR composedMessage CONTAINS "GoogleAnalytics" OR composedMessage CONTAINS "[GA4]")'];
747
751
  } else if (platform === 'ios-device') {
748
752
  // idevicesyslog for real iOS device
749
753
  cmd = 'idevicesyslog';
@@ -808,13 +812,50 @@ function setupIPC() {
808
812
  if (_nativeLogProcess) { try { _nativeLogProcess.kill(); } catch {} }
809
813
  });
810
814
 
815
+ // Firebase/GA tag detection for both Android and iOS
816
+ const _firebaseTags = new Set(['FA', 'FA-SVC', 'FA:Application', 'FA:Service', 'FirebaseAnalytics', 'FIRAnalytics', 'firebase', 'google.analytics', 'AnalyticsService']);
817
+ const _firebaseTagRe = /^(FA|FA-SVC|FA:Application|FA:Service|FirebaseAnalytics|FIRAnalytics|firebase|google\.analytics|AnalyticsService)/i;
818
+
819
+ function _parseFirebaseEvent(message, tag) {
820
+ if (!message) return null;
821
+ // Android FA tag patterns:
822
+ // "Logging event (FE): session_start(_s), Bundle[{...}]"
823
+ // "Setting event parameter: engagement_time_msec = 1234"
824
+ // "Screen exposed: main_screen"
825
+ let m;
826
+ if ((m = message.match(/Logging event\s*\(?(\w+)?\)?\s*:\s*(\S+?)(?:\((\w+)\))?\s*,?\s*Bundle\[\{(.*)\}\]/i))) {
827
+ const params = {};
828
+ // Parse "key=value, key=value" from Bundle
829
+ if (m[4]) m[4].split(/,\s*/).forEach(p => { const [k, v] = p.split('='); if (k) params[k.trim()] = v ? v.trim() : ''; });
830
+ return { eventName: m[2] || m[3] || 'unknown', source: m[1] || 'native', params };
831
+ }
832
+ if ((m = message.match(/Logging event\s*\(?(\w+)?\)?\s*:\s*(\S+)/i))) {
833
+ return { eventName: m[2], source: m[1] || 'native', params: {} };
834
+ }
835
+ // iOS FIRAnalytics pattern: "Logging event: origin, name, params: { ... }"
836
+ if ((m = message.match(/Logging event:\s*(\w+),\s*(\w+),\s*params:\s*(\{.*\})/i))) {
837
+ let params = {};
838
+ try { params = JSON.parse(m[3]); } catch {}
839
+ return { eventName: m[2], source: m[1] || 'native', params };
840
+ }
841
+ return null;
842
+ }
843
+
811
844
  function _parseNativeLog(line, platform) {
812
845
  if (platform === 'android') {
813
846
  // Android logcat format: "06-05 10:30:45.123 1234 5678 E TAG: message"
814
847
  const m = line.match(/^\d{2}-\d{2}\s+(\d{2}:\d{2}:\d{2})\.\d+\s+\d+\s+\d+\s+([VDIWEF])\s+([^:]+):\s*(.*)/);
815
848
  if (m) {
816
849
  const levelMap = { V: 'verbose', D: 'debug', I: 'info', W: 'warn', E: 'error', F: 'fatal' };
817
- return { ts: Date.now(), time: m[1], level: levelMap[m[2]] || 'info', tag: m[3].trim(), message: m[4], raw: line };
850
+ const tag = m[3].trim();
851
+ const parsed = { ts: Date.now(), time: m[1], level: levelMap[m[2]] || 'info', tag, message: m[4], raw: line };
852
+ // Detect Firebase/GA events
853
+ if (_firebaseTagRe.test(tag)) {
854
+ parsed.firebase = true;
855
+ const fe = _parseFirebaseEvent(m[4], tag);
856
+ if (fe) parsed.firebaseEvent = fe;
857
+ }
858
+ return parsed;
818
859
  }
819
860
  return { ts: Date.now(), level: 'info', message: line, raw: line };
820
861
  }
@@ -823,13 +864,25 @@ function setupIPC() {
823
864
  const m1 = line.match(/(\d{2}:\d{2}:\d{2})\.\d+[^\s]*\s+\S+\s+(\S+)\[\d+\].*?<(\w+)>:\s*(.*)/);
824
865
  if (m1) {
825
866
  const levelMap = { Notice: 'info', Info: 'info', Default: 'info', Debug: 'debug', Error: 'error', Fault: 'fatal' };
826
- return { ts: Date.now(), time: m1[1], level: levelMap[m1[3]] || 'info', tag: m1[2], message: m1[4], raw: line };
867
+ const parsed = { ts: Date.now(), time: m1[1], level: levelMap[m1[3]] || 'info', tag: m1[2], message: m1[4], raw: line };
868
+ if (_firebaseTagRe.test(m1[2]) || /FIRAnalytics|GoogleAnalytics|firebase/i.test(m1[4])) {
869
+ parsed.firebase = true;
870
+ const fe = _parseFirebaseEvent(m1[4], m1[2]);
871
+ if (fe) parsed.firebaseEvent = fe;
872
+ }
873
+ return parsed;
827
874
  }
828
875
  // idevicesyslog format: "Jun 5 10:30:45 iPhone MyApp(libsystem)[123] <Error>: message"
829
876
  const m2 = line.match(/\w+\s+\d+\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+(\S+?)[\[(].*?<(\w+)>:\s*(.*)/);
830
877
  if (m2) {
831
878
  const levelMap = { Notice: 'info', Info: 'info', Debug: 'debug', Warning: 'warn', Error: 'error', Critical: 'fatal' };
832
- return { ts: Date.now(), time: m2[1], level: levelMap[m2[3]] || 'info', tag: m2[2], message: m2[4], raw: line };
879
+ const parsed = { ts: Date.now(), time: m2[1], level: levelMap[m2[3]] || 'info', tag: m2[2], message: m2[4], raw: line };
880
+ if (_firebaseTagRe.test(m2[2]) || /FIRAnalytics|GoogleAnalytics|firebase/i.test(m2[4])) {
881
+ parsed.firebase = true;
882
+ const fe = _parseFirebaseEvent(m2[4], m2[2]);
883
+ if (fe) parsed.firebaseEvent = fe;
884
+ }
885
+ return parsed;
833
886
  }
834
887
  // Fallback
835
888
  const timeMatch = line.match(/(\d{2}:\d{2}:\d{2})/);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reactoradar",
3
3
  "productName": "ReactoRadar",
4
- "version": "1.6.10",
4
+ "version": "1.6.12",
5
5
  "description": "macOS debugger for React Native — Console, Sources, Network, Performance, Memory, Redux, AsyncStorage, React tree. Supports RN 0.74+ with Hermes and New Architecture.",
6
6
  "main": "main.js",
7
7
  "bin": {
package/panels/console.js CHANGED
@@ -359,7 +359,8 @@ function collectEntries(val) {
359
359
  if (!(k in result)) result[k] = val[k];
360
360
  }
361
361
 
362
- return Object.entries(result);
362
+ // Sort keys alphabetically for consistent, readable display
363
+ return Object.entries(result).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
363
364
  }
364
365
 
365
366
  function objPreview(val, maxLen) {
@@ -400,13 +401,23 @@ function primitivePreview(val) {
400
401
  if (typeof val === 'string') return val.length > 50 ? `"${val.slice(0,50)}..."` : `"${val}"`;
401
402
  if (typeof val === 'number' || typeof val === 'boolean') return String(val);
402
403
  if (Array.isArray(val)) return `Array(${val.length})`;
403
- if (typeof val === 'object') return `{...}`;
404
- return String(val);
404
+ if (typeof val === 'object') {
405
+ const keys = Object.keys(val);
406
+ if (keys.length === 0) return '{}';
407
+ if (keys.length <= 3) return `{${keys.join(', ')}}`;
408
+ return `{${keys.slice(0, 3).join(', ')}, +${keys.length - 3}}`;
409
+ }
410
+ if (typeof val === 'function') return `[Function: ${val.name || 'anonymous'}]`;
411
+ return safeStr(val);
405
412
  }
406
413
 
407
- function createTreeNode(key, val, startCollapsed) {
414
+ function createTreeNode(key, val, startCollapsed, parentPath) {
408
415
  const isArray = Array.isArray(val);
409
416
  const isObj = val !== null && typeof val === 'object';
417
+ // Build full dot-notation path for "Copy path"
418
+ const fullPath = key !== null
419
+ ? (parentPath ? (typeof key === 'number' ? `${parentPath}[${key}]` : `${parentPath}.${key}`) : String(key))
420
+ : (parentPath || '');
410
421
 
411
422
  if (!isObj) {
412
423
  // Primitive leaf
@@ -419,6 +430,16 @@ function createTreeNode(key, val, startCollapsed) {
419
430
  row.appendChild(k);
420
431
  }
421
432
  row.appendChild(createPrimitiveSpan(val));
433
+ // Right-click to copy value
434
+ row.addEventListener('contextmenu', (e) => {
435
+ e.preventDefault();
436
+ e.stopPropagation();
437
+ const items = [];
438
+ if (key !== null) items.push({ label: `Copy value of "${key}"`, action: () => navigator.clipboard.writeText(safeStr(val)) });
439
+ items.push({ label: 'Copy as JSON', action: () => { try { navigator.clipboard.writeText(JSON.stringify(val)); } catch { navigator.clipboard.writeText(safeStr(val)); } } });
440
+ if (fullPath) items.push({ label: 'Copy path', action: () => navigator.clipboard.writeText(fullPath) });
441
+ showContextMenu(e, items);
442
+ });
422
443
  return row;
423
444
  }
424
445
 
@@ -459,7 +480,7 @@ function createTreeNode(key, val, startCollapsed) {
459
480
  populated = true;
460
481
  const entries = collectEntries(val);
461
482
  entries.forEach(([k, v]) => {
462
- children.appendChild(createTreeNode(k, v, true));
483
+ children.appendChild(createTreeNode(k, v, true, fullPath));
463
484
  });
464
485
  // For arrays show length, for objects show prototype hint
465
486
  if (isArray) {
@@ -496,6 +517,26 @@ function createTreeNode(key, val, startCollapsed) {
496
517
  }
497
518
  });
498
519
 
520
+ // Right-click on any node to copy its subtree
521
+ header.addEventListener('contextmenu', (e) => {
522
+ e.preventDefault();
523
+ e.stopPropagation();
524
+ const items = [];
525
+ if (key !== null) {
526
+ items.push({ label: `Copy "${key}" value`, action: () => {
527
+ try { navigator.clipboard.writeText(JSON.stringify(val, null, 2)); } catch { navigator.clipboard.writeText(safeStr(val)); }
528
+ }});
529
+ items.push({ label: `Copy "${key}" key-value`, action: () => {
530
+ try { navigator.clipboard.writeText(JSON.stringify({ [key]: val }, null, 2)); } catch { navigator.clipboard.writeText(`${key}: ${safeStr(val)}`); }
531
+ }});
532
+ }
533
+ items.push({ label: 'Copy entire object', action: () => {
534
+ try { navigator.clipboard.writeText(JSON.stringify(val, null, 2)); } catch { navigator.clipboard.writeText(safeStr(val)); }
535
+ }});
536
+ if (fullPath) items.push({ label: 'Copy path', action: () => navigator.clipboard.writeText(fullPath) });
537
+ showContextMenu(e, items);
538
+ });
539
+
499
540
  container.appendChild(children);
500
541
  return container;
501
542
  }
@@ -505,7 +546,7 @@ function _safeStr(val) {
505
546
  if (val === undefined) return 'undefined';
506
547
  if (typeof val === 'string') return val;
507
548
  if (typeof val === 'number' || typeof val === 'boolean') return String(val);
508
- try { return JSON.stringify(val, null, 2); } catch { return String(val); }
549
+ try { return JSON.stringify(val, null, 2); } catch { return '[Complex object]'; }
509
550
  }
510
551
 
511
552
  function createPrimitiveSpan(val) {
@@ -561,7 +602,7 @@ function buildLogBody(logEntry) {
561
602
  });
562
603
  } else if (logEntry.message != null) {
563
604
  // Legacy / flat message — try to parse JSON objects out of it
564
- const msg = String(logEntry.message);
605
+ const msg = safeStr(logEntry.message);
565
606
  // Try parsing the whole message as JSON
566
607
  try {
567
608
  const parsed = JSON.parse(msg);
@@ -691,7 +732,7 @@ function buildLogRow(l) {
691
732
  items.push({ label: 'Copy as JSON', action: () => {
692
733
  const json = l.args.map(a => {
693
734
  if (a.t === 'object' || a.t === 'array') return JSON.stringify(a.v, null, 2);
694
- return String(a.v);
735
+ return safeStr(a.v);
695
736
  }).join(' ');
696
737
  navigator.clipboard.writeText(json);
697
738
  }});
package/panels/native.js CHANGED
@@ -36,6 +36,8 @@ function initNativeLogsPanel() {
36
36
  <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>
37
37
  <div class="native-prereq-step">3. Connect device via USB and accept the prompt</div>
38
38
  <div class="native-prereq-step">4. Verify: run <code>adb devices</code> in terminal</div>
39
+ <div class="native-prereq-step" style="margin-top:6px"><b>Firebase events:</b></div>
40
+ <div class="native-prereq-step"><code>adb shell setprop debug.firebase.analytics.app &lt;pkg&gt;</code></div>
39
41
  </div>
40
42
  <div id="nativeAndroidStatus" class="native-detect-status"></div>
41
43
  <button class="native-connect-btn" id="nativeConnectAndroid">Connect Android</button>
@@ -51,6 +53,8 @@ function initNativeLogsPanel() {
51
53
  <div class="native-prereq-step">1. Install: <code>brew install libimobiledevice</code></div>
52
54
  <div class="native-prereq-step">2. Connect device, tap <b>Trust</b> on the prompt</div>
53
55
  <div class="native-prereq-step">3. Verify: <code>idevice_id -l</code> shows device UDID</div>
56
+ <div class="native-prereq-step" style="margin-top:6px"><b>Firebase events:</b></div>
57
+ <div class="native-prereq-step">Add <code>-FIRDebugEnabled</code> to Xcode scheme launch arguments</div>
54
58
  </div>
55
59
  <div id="nativeIOSStatus" class="native-detect-status"></div>
56
60
  <div style="display:flex;gap:6px;margin-top:8px">
@@ -66,6 +70,7 @@ function initNativeLogsPanel() {
66
70
  <input id="nativeSearch" class="net-search-input" placeholder="Filter logs..." />
67
71
  <div class="native-level-filters" id="nativeLevelFilters">
68
72
  <button class="net-status-btn active" data-level="all">All</button>
73
+ <button class="net-status-btn" data-level="firebase" style="color:var(--yellow)">🔥 Firebase</button>
69
74
  <button class="net-status-btn" data-level="fatal">Fatal</button>
70
75
  <button class="net-status-btn" data-level="error">Error</button>
71
76
  <button class="net-status-btn" data-level="warn">Warn</button>
@@ -197,51 +202,103 @@ function _appendNativeLog(log) {
197
202
  if (!list) return;
198
203
 
199
204
  // Check filters
200
- if (_nativeState.levelFilter !== 'all' && log.level !== _nativeState.levelFilter) return;
205
+ if (_nativeState.levelFilter === 'firebase') {
206
+ if (!log.firebase) return; // Only show Firebase-tagged logs
207
+ } else if (_nativeState.levelFilter !== 'all' && log.level !== _nativeState.levelFilter) {
208
+ return;
209
+ }
201
210
  if (_nativeState.searchFilter && !log.message?.toLowerCase().includes(_nativeState.searchFilter) && !log.tag?.toLowerCase().includes(_nativeState.searchFilter)) return;
202
211
 
203
- const isExpandable = log.level === 'error' || log.level === 'fatal' || (log.message || '').length > 200;
204
- const row = document.createElement('div');
205
- row.className = `native-log-row native-${log.level || 'info'}`;
206
-
207
212
  const time = log.time || new Date(log.ts).toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
208
213
 
209
- // Header line (always visible)
210
- const header = document.createElement('div');
211
- header.className = 'native-log-header';
212
- header.innerHTML = `<span class="native-log-time">${esc(time)}</span>`
213
- + `<span class="native-log-level">${esc((log.level || 'info').toUpperCase())}</span>`
214
- + (log.tag ? `<span class="native-log-tag">${esc(log.tag)}</span>` : '')
215
- + `<span class="native-log-preview">${esc((log.message || '').split('\\n')[0].slice(0, 200))}</span>`;
216
- row.appendChild(header);
214
+ // Firebase events render GA4-style cards
215
+ if (log.firebase && log.firebaseEvent && _nativeState.levelFilter === 'firebase') {
216
+ const fe = log.firebaseEvent;
217
+ const row = document.createElement('div');
218
+ row.className = 'native-firebase-card';
219
+
220
+ const paramKeys = Object.keys(fe.params || {});
221
+ const paramHtml = paramKeys.length > 0
222
+ ? `<div class="native-firebase-params">${paramKeys.map(k =>
223
+ `<div class="native-firebase-param"><span class="native-firebase-param-key">${esc(k)}</span><span class="native-firebase-param-val">${esc(safeStr(fe.params[k]))}</span></div>`
224
+ ).join('')}</div>`
225
+ : '';
226
+
227
+ row.innerHTML = `
228
+ <div class="native-firebase-header">
229
+ <span class="native-firebase-event">${esc(fe.eventName)}</span>
230
+ <span class="native-firebase-source">${esc(fe.source)}</span>
231
+ <span class="native-firebase-time">${esc(time)}</span>
232
+ </div>
233
+ ${paramHtml}`;
217
234
 
218
- // Expandable full message (for errors and long messages)
219
- if (isExpandable) {
220
- const fullMsg = document.createElement('div');
221
- fullMsg.className = 'native-log-full';
222
- fullMsg.style.display = 'none';
223
- fullMsg.textContent = log.message || '';
224
- row.appendChild(fullMsg);
235
+ // Expand/collapse params
236
+ const header = row.querySelector('.native-firebase-header');
237
+ const params = row.querySelector('.native-firebase-params');
238
+ if (header && params) {
239
+ params.style.display = 'none';
240
+ header.style.cursor = 'pointer';
241
+ header.addEventListener('click', () => {
242
+ const open = params.style.display !== 'none';
243
+ params.style.display = open ? 'none' : 'block';
244
+ row.classList.toggle('expanded', !open);
245
+ });
246
+ }
225
247
 
226
- header.style.cursor = 'pointer';
227
- header.addEventListener('click', () => {
228
- const open = fullMsg.style.display !== 'none';
229
- fullMsg.style.display = open ? 'none' : 'block';
230
- row.classList.toggle('expanded', !open);
248
+ // Right-click
249
+ row.addEventListener('contextmenu', (e) => {
250
+ e.preventDefault();
251
+ showContextMenu(e, [
252
+ { label: `Copy Event: ${fe.eventName}`, action: () => navigator.clipboard.writeText(fe.eventName) },
253
+ { label: 'Copy Params (JSON)', action: () => navigator.clipboard.writeText(JSON.stringify(fe.params, null, 2)) },
254
+ { label: 'Copy Raw Log', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
255
+ ]);
231
256
  });
232
- }
233
257
 
234
- // Right-click to copy
235
- row.addEventListener('contextmenu', (e) => {
236
- e.preventDefault();
237
- showContextMenu(e, [
238
- { label: 'Copy Message', action: () => navigator.clipboard.writeText(log.message || '') },
239
- { label: 'Copy Raw Line', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
240
- ...(log.tag ? [{ label: `Copy Tag (${log.tag})`, action: () => navigator.clipboard.writeText(log.tag) }] : []),
241
- ]);
242
- });
258
+ list.appendChild(row);
259
+ } else {
260
+ // Standard log row — with Firebase badge if applicable
261
+ const isExpandable = log.level === 'error' || log.level === 'fatal' || (log.message || '').length > 200;
262
+ const row = document.createElement('div');
263
+ row.className = `native-log-row native-${log.level || 'info'}${log.firebase ? ' native-firebase' : ''}`;
243
264
 
244
- list.appendChild(row);
265
+ const header = document.createElement('div');
266
+ header.className = 'native-log-header';
267
+ header.innerHTML = `<span class="native-log-time">${esc(time)}</span>`
268
+ + `<span class="native-log-level">${esc((log.level || 'info').toUpperCase())}</span>`
269
+ + (log.firebase ? '<span class="native-firebase-badge">Firebase</span>' : '')
270
+ + (log.tag ? `<span class="native-log-tag">${esc(log.tag)}</span>` : '')
271
+ + `<span class="native-log-preview">${esc((log.message || '').split('\\n')[0].slice(0, 200))}</span>`;
272
+ row.appendChild(header);
273
+
274
+ if (isExpandable) {
275
+ const fullMsg = document.createElement('div');
276
+ fullMsg.className = 'native-log-full';
277
+ fullMsg.style.display = 'none';
278
+ fullMsg.textContent = log.message || '';
279
+ row.appendChild(fullMsg);
280
+
281
+ header.style.cursor = 'pointer';
282
+ header.addEventListener('click', () => {
283
+ const open = fullMsg.style.display !== 'none';
284
+ fullMsg.style.display = open ? 'none' : 'block';
285
+ row.classList.toggle('expanded', !open);
286
+ });
287
+ }
288
+
289
+ row.addEventListener('contextmenu', (e) => {
290
+ e.preventDefault();
291
+ const items = [
292
+ { label: 'Copy Message', action: () => navigator.clipboard.writeText(log.message || '') },
293
+ { label: 'Copy Raw Line', action: () => navigator.clipboard.writeText(log.raw || log.message || '') },
294
+ ];
295
+ if (log.tag) items.push({ label: `Copy Tag (${log.tag})`, action: () => navigator.clipboard.writeText(log.tag) });
296
+ if (log.firebaseEvent) items.push({ label: `Copy Event: ${log.firebaseEvent.eventName}`, action: () => navigator.clipboard.writeText(log.firebaseEvent.eventName) });
297
+ showContextMenu(e, items);
298
+ });
299
+
300
+ list.appendChild(row);
301
+ }
245
302
 
246
303
  // Cap DOM rows
247
304
  while (list.children.length > 1000) list.firstChild.remove();
package/panels/network.js CHANGED
@@ -203,13 +203,13 @@ function initNetworkPanel() {
203
203
  method: r.method || 'GET',
204
204
  url: r.url || '',
205
205
  headers: Object.entries(r.requestHeaders || {}).map(([n, v]) => ({ name: n, value: v })),
206
- postData: r.requestBody ? { mimeType: 'application/json', text: typeof r.requestBody === 'object' ? JSON.stringify(r.requestBody) : String(r.requestBody) } : undefined,
206
+ postData: r.requestBody ? { mimeType: 'application/json', text: typeof r.requestBody === 'object' ? JSON.stringify(r.requestBody) : safeStr(r.requestBody) } : undefined,
207
207
  },
208
208
  response: {
209
209
  status: r.status || 0,
210
210
  statusText: r.statusText || '',
211
211
  headers: Object.entries(r.responseHeaders || {}).map(([n, v]) => ({ name: n, value: v })),
212
- content: { size: -1, mimeType: 'application/json', text: r.responseBody ? (typeof r.responseBody === 'object' ? JSON.stringify(r.responseBody) : String(r.responseBody)) : '' },
212
+ content: { size: -1, mimeType: 'application/json', text: r.responseBody ? (typeof r.responseBody === 'object' ? JSON.stringify(r.responseBody) : safeStr(r.responseBody)) : '' },
213
213
  },
214
214
  timings: { send: 0, wait: r.duration || 0, receive: 0 },
215
215
  };
@@ -835,7 +835,7 @@ function renderNetDetailContent(r) {
835
835
  if (!keys.length) return `<div class="section-label">${title}</div><span style="color:var(--text-dim)">none</span>`;
836
836
  return `<div class="section-label">${title}</div><div class="kv-grid">${keys.map(k => {
837
837
  let val = h[k];
838
- if (val && typeof val === 'object') { try { val = JSON.stringify(val); } catch { val = String(val); } }
838
+ if (val && typeof val === 'object') { try { val = JSON.stringify(val); } catch { val = '[Complex object]'; } }
839
839
  return `<span class="kv-key">${esc(k)}</span><span class="kv-val">${esc(val)}</span>`;
840
840
  }).join('')}</div>`;
841
841
  };
@@ -870,6 +870,7 @@ function renderNetDetailContent(r) {
870
870
  const isErrStatus = _isHttpError(r);
871
871
  if (r.phase === 'error' && !r.responseBody) { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
872
872
  if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
873
+ if (r.responseBody == null || r.responseBody === '') { body.innerHTML = '<span style="color:var(--text-dim)">Empty response body</span>'; return; }
873
874
  // Render as collapsible JSON tree with right-click copy
874
875
  const val = r.responseBody;
875
876
  let treeData = val;
@@ -896,13 +897,14 @@ function renderNetDetailContent(r) {
896
897
  });
897
898
  } else {
898
899
  body.innerHTML = isErrStatus
899
- ? `<span style="color:var(--red)">${esc(String(r.responseBody))}</span>`
900
+ ? `<span style="color:var(--red)">${esc(safeStr(r.responseBody))}</span>`
900
901
  : '<span style="color:var(--text-dim)">No preview available</span>';
901
902
  }
902
903
  } else if (tab === 'response') {
903
904
  const isErrStatus = _isHttpError(r);
904
905
  if (r.phase === 'error' && !r.responseBody) { body.innerHTML = `<span style="color:var(--red)">${esc(r.error || 'Request failed')}</span>`; return; }
905
906
  if (!r.responseBody && r.phase !== 'response') { body.innerHTML = '<span style="color:var(--text-dim)">Pending...</span>'; return; }
907
+ if (r.responseBody == null || r.responseBody === '') { body.innerHTML = '<span style="color:var(--text-dim)">Empty response body</span>'; return; }
906
908
  if (isErrStatus) {
907
909
  const errBanner = document.createElement('div');
908
910
  errBanner.style.cssText = 'color:var(--red);font-weight:600;padding:4px 0 8px;font-size:11px;border-bottom:1px solid rgba(255,94,114,.15);margin-bottom:8px';
@@ -916,6 +918,28 @@ function renderNetDetailContent(r) {
916
918
  } else {
917
919
  body.innerHTML = renderJSON(r.responseBody);
918
920
  }
921
+ // Right-click to copy response object
922
+ body.addEventListener('contextmenu', (e) => {
923
+ e.preventDefault();
924
+ const sel = window.getSelection();
925
+ const items = [];
926
+ if (sel && sel.toString().length > 0) {
927
+ items.push({ label: 'Copy Selection', action: () => navigator.clipboard.writeText(sel.toString()) });
928
+ }
929
+ items.push({ label: 'Copy Response Object', action: () => {
930
+ try {
931
+ const data = typeof r.responseBody === 'string' ? JSON.parse(r.responseBody) : r.responseBody;
932
+ navigator.clipboard.writeText(JSON.stringify(_sortKeys(data), null, 2));
933
+ } catch { navigator.clipboard.writeText(safeStr(r.responseBody)); }
934
+ }});
935
+ items.push({ label: 'Copy Response (minified)', action: () => {
936
+ try {
937
+ const data = typeof r.responseBody === 'string' ? JSON.parse(r.responseBody) : r.responseBody;
938
+ navigator.clipboard.writeText(JSON.stringify(_sortKeys(data)));
939
+ } catch { navigator.clipboard.writeText(safeStr(r.responseBody)); }
940
+ }});
941
+ showContextMenu(e, items);
942
+ });
919
943
  }
920
944
  }
921
945
 
@@ -942,7 +966,7 @@ function showNetContextMenu(e, r) {
942
966
 
943
967
  function showPreviewCopyMenu(e, fullData) {
944
968
  const items = [
945
- { label: 'Copy Object', action: () => navigator.clipboard.writeText(JSON.stringify(fullData, null, 2)) },
969
+ { label: 'Copy Object', action: () => navigator.clipboard.writeText(JSON.stringify(_sortKeys(fullData), null, 2)) },
946
970
  ];
947
971
  const sel = window.getSelection();
948
972
  if (sel && sel.toString().length > 0) {
package/panels/redux.js CHANGED
@@ -82,7 +82,7 @@ function _findLeafChanges(oldVal, newVal, basePath, maxDepth) {
82
82
  changes.push({ path, oldVal: a, newVal: b });
83
83
  return;
84
84
  }
85
- const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
85
+ const allKeys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
86
86
  allKeys.forEach(k => {
87
87
  if (!_deepEqual(a[k], b[k])) {
88
88
  const childPath = path ? `${path}.${k}` : k;
@@ -169,7 +169,7 @@ function _createHighlightedTree(key, val, changedPaths, currentPath, isOld) {
169
169
  function populate() {
170
170
  if (populated) return;
171
171
  populated = true;
172
- const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val);
172
+ const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
173
173
  entries.forEach(([k, v]) => {
174
174
  children.appendChild(_createHighlightedTree(k, v, changedPaths, myPath, isOld));
175
175
  });
@@ -197,7 +197,7 @@ function handleReduxEvent(event) {
197
197
  const prevState = state.redux.states.length > 0 ? state.redux.states[state.redux.states.length - 1] : null;
198
198
  const changedKeys = [];
199
199
  if (prevState && nextState && typeof prevState === 'object' && typeof nextState === 'object') {
200
- const allKeys = new Set([...Object.keys(prevState), ...Object.keys(nextState)]);
200
+ const allKeys = [...new Set([...Object.keys(prevState), ...Object.keys(nextState)])].sort();
201
201
  allKeys.forEach(k => { if (!_deepEqual(prevState[k], nextState[k])) changedKeys.push(k); });
202
202
  }
203
203
 
package/styles.css CHANGED
@@ -1799,6 +1799,78 @@ mark { background: rgba(79,172,255,.2); color: var(--accent); border-radius: 2px
1799
1799
  .native-fatal { background: rgba(255,94,114,.08); }
1800
1800
  .native-fatal .native-log-msg { color: var(--red); font-weight: 700; }
1801
1801
 
1802
+ /* Firebase event cards (GA4-style in Native Logs) */
1803
+ .native-firebase-badge {
1804
+ display: inline-block;
1805
+ background: rgba(255,196,0,.15);
1806
+ color: #ffa500;
1807
+ font-size: 9px;
1808
+ font-weight: 700;
1809
+ padding: 1px 5px;
1810
+ border-radius: 3px;
1811
+ margin-right: 4px;
1812
+ text-transform: uppercase;
1813
+ }
1814
+ .native-firebase { border-left: 2px solid #ffa500; }
1815
+ .native-firebase-card {
1816
+ padding: 8px 12px;
1817
+ border-bottom: 1px solid var(--border);
1818
+ border-left: 3px solid #ffa500;
1819
+ transition: background 0.12s;
1820
+ }
1821
+ .native-firebase-card:hover { background: var(--bg3); }
1822
+ .native-firebase-card.expanded { background: var(--bg3); }
1823
+ .native-firebase-header {
1824
+ display: flex;
1825
+ align-items: center;
1826
+ gap: 8px;
1827
+ font-size: 11px;
1828
+ }
1829
+ .native-firebase-event {
1830
+ font-weight: 700;
1831
+ color: var(--accent);
1832
+ font-size: 12px;
1833
+ }
1834
+ .native-firebase-source {
1835
+ font-size: 9px;
1836
+ color: var(--text-dim);
1837
+ background: var(--bg3);
1838
+ padding: 1px 5px;
1839
+ border-radius: 3px;
1840
+ text-transform: uppercase;
1841
+ }
1842
+ .native-firebase-time {
1843
+ margin-left: auto;
1844
+ font-size: 10px;
1845
+ color: var(--text-dim);
1846
+ font-variant-numeric: tabular-nums;
1847
+ }
1848
+ .native-firebase-params {
1849
+ margin-top: 6px;
1850
+ padding: 6px 8px;
1851
+ background: var(--bg2);
1852
+ border-radius: 4px;
1853
+ border: 1px solid var(--border);
1854
+ }
1855
+ .native-firebase-param {
1856
+ display: flex;
1857
+ gap: 8px;
1858
+ padding: 2px 0;
1859
+ font-size: 10px;
1860
+ border-bottom: 1px solid var(--border);
1861
+ }
1862
+ .native-firebase-param:last-child { border-bottom: none; }
1863
+ .native-firebase-param-key {
1864
+ color: var(--accent);
1865
+ font-weight: 600;
1866
+ min-width: 120px;
1867
+ flex-shrink: 0;
1868
+ }
1869
+ .native-firebase-param-val {
1870
+ color: var(--text-mid);
1871
+ word-break: break-all;
1872
+ }
1873
+
1802
1874
  /* ── Detail Panel Search ───────────────────────────────────────────────────── */
1803
1875
  .detail-search-wrap { display: flex; align-items: center; gap: 4px; margin-left: auto; padding: 0 6px; }
1804
1876
  .detail-search-input { width: 150px; font-size: 10px; padding: 3px 6px; border: 1px solid var(--border); background: var(--bg2); color: var(--text); border-radius: 3px; outline: none; }