reactoradar 1.6.6 → 1.6.8

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.
@@ -0,0 +1,188 @@
1
+ // ─── Performance + Memory Panels ───────────────────────────────────────────
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // PERFORMANCE PANEL — FPS, render timing, JS thread
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ const perfState = { fps: [], jsThread: [], uiThread: [], recording: false, data: [] };
6
+
7
+ function initPerformancePanel() {
8
+ const panel = $('panel-performance');
9
+ if (!panel) return;
10
+ panel.innerHTML = `
11
+ <div class="panel-toolbar">
12
+ <span class="panel-label">Performance</span>
13
+ <div class="ml-auto" style="display:flex;gap:6px">
14
+ <button class="tb-btn" id="btnPerfRecord">Record</button>
15
+ <button class="tb-btn" id="btnPerfClear">Clear</button>
16
+ </div>
17
+ </div>
18
+ <div class="perf-layout">
19
+ <div class="perf-meters">
20
+ <div class="perf-meter">
21
+ <div class="perf-meter-label">FPS</div>
22
+ <div class="perf-meter-value" id="perfFPS">—</div>
23
+ <canvas class="perf-canvas" id="perfFPSCanvas" width="200" height="60"></canvas>
24
+ </div>
25
+ <div class="perf-meter">
26
+ <div class="perf-meter-label">JS Thread</div>
27
+ <div class="perf-meter-value" id="perfJS">—</div>
28
+ <canvas class="perf-canvas" id="perfJSCanvas" width="200" height="60"></canvas>
29
+ </div>
30
+ <div class="perf-meter">
31
+ <div class="perf-meter-label">UI Thread</div>
32
+ <div class="perf-meter-value" id="perfUI">—</div>
33
+ <canvas class="perf-canvas" id="perfUICanvas" width="200" height="60"></canvas>
34
+ </div>
35
+ </div>
36
+ <div class="scroll-area perf-timeline" id="perfTimeline">
37
+ <div class="empty-state" id="perfEmpty">
38
+ <div class="icon" style="font-size:28px;opacity:.2">📊</div>
39
+ <div class="label">No performance data</div>
40
+ <div class="hint">Click "Record" to start capturing performance metrics</div>
41
+ <div class="hint">The SDK sends FPS + thread usage automatically when connected</div>
42
+ </div>
43
+ </div>
44
+ </div>`;
45
+
46
+ $('btnPerfRecord').addEventListener('click', () => {
47
+ perfState.recording = !perfState.recording;
48
+ $('btnPerfRecord').textContent = perfState.recording ? 'Stop' : 'Record';
49
+ $('btnPerfRecord').classList.toggle('primary', perfState.recording);
50
+ if (perfState.recording) {
51
+ // Tell SDK to start sending perf data
52
+ window.electronAPI?.setNetworkCapture(true); // reuse channel
53
+ }
54
+ });
55
+
56
+ $('btnPerfClear').addEventListener('click', () => {
57
+ perfState.fps = [];
58
+ perfState.jsThread = [];
59
+ perfState.uiThread = [];
60
+ perfState.data = [];
61
+ $('perfFPS').textContent = '—';
62
+ $('perfJS').textContent = '—';
63
+ $('perfUI').textContent = '—';
64
+ clearPerfCanvas('perfFPSCanvas');
65
+ clearPerfCanvas('perfJSCanvas');
66
+ clearPerfCanvas('perfUICanvas');
67
+ });
68
+ }
69
+
70
+ function clearPerfCanvas(id) {
71
+ const canvas = $(id);
72
+ if (!canvas) return;
73
+ const ctx = canvas.getContext('2d');
74
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
75
+ }
76
+
77
+ function drawPerfGraph(canvasId, data, maxVal, color) {
78
+ const canvas = $(canvasId);
79
+ if (!canvas || !data.length) return;
80
+ const ctx = canvas.getContext('2d');
81
+ const w = canvas.width, h = canvas.height;
82
+ ctx.clearRect(0, 0, w, h);
83
+
84
+ // Grid lines
85
+ ctx.strokeStyle = 'rgba(255,255,255,0.05)';
86
+ ctx.lineWidth = 1;
87
+ for (let y = 0; y < h; y += h/4) {
88
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
89
+ }
90
+
91
+ // Data line
92
+ ctx.strokeStyle = color;
93
+ ctx.lineWidth = 1.5;
94
+ ctx.beginPath();
95
+ const step = w / Math.max(data.length - 1, 1);
96
+ data.forEach((v, i) => {
97
+ const x = i * step;
98
+ const y = h - (v / maxVal) * h;
99
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
100
+ });
101
+ ctx.stroke();
102
+
103
+ // Fill under
104
+ ctx.lineTo(w, h);
105
+ ctx.lineTo(0, h);
106
+ ctx.closePath();
107
+ ctx.fillStyle = color.replace('1)', '0.1)');
108
+ ctx.fill();
109
+ }
110
+
111
+ // Handle performance events from SDK (always updates meters, graphs only when recording)
112
+ function handlePerfEvent(event) {
113
+ if (!isTabEnabled('performance') && !isTabEnabled('memory')) return;
114
+ if (event.fps != null) {
115
+ perfState.fps.push(event.fps);
116
+ if (perfState.fps.length > 100) perfState.fps.shift();
117
+ const fpsEl = $('perfFPS');
118
+ if (fpsEl) fpsEl.textContent = event.fps + ' fps';
119
+ drawPerfGraph('perfFPSCanvas', perfState.fps, 60, 'rgba(61,214,140,1)');
120
+ }
121
+ if (event.jsThread != null) {
122
+ perfState.jsThread.push(event.jsThread);
123
+ if (perfState.jsThread.length > 100) perfState.jsThread.shift();
124
+ const jsEl = $('perfJS');
125
+ if (jsEl) jsEl.textContent = event.jsThread.toFixed(1) + 'ms';
126
+ drawPerfGraph('perfJSCanvas', perfState.jsThread, 32, 'rgba(79,172,255,1)');
127
+ }
128
+ if (event.uiThread != null) {
129
+ perfState.uiThread.push(event.uiThread);
130
+ if (perfState.uiThread.length > 100) perfState.uiThread.shift();
131
+ const uiEl = $('perfUI');
132
+ if (uiEl) uiEl.textContent = event.uiThread.toFixed(1) + 'ms';
133
+ drawPerfGraph('perfUICanvas', perfState.uiThread, 32, 'rgba(155,127,255,1)');
134
+ }
135
+ }
136
+
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+ // MEMORY PANEL — Heap snapshot summary via Hermes CDP
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+ function initMemoryPanel() {
141
+ const panel = $('panel-memory');
142
+ if (!panel) return;
143
+ panel.innerHTML = `
144
+ <div class="panel-toolbar">
145
+ <span class="panel-label">Memory</span>
146
+ <div class="ml-auto" style="display:flex;gap:6px">
147
+ <button class="tb-btn primary" id="btnHeapSnapshot">Take Heap Snapshot</button>
148
+ </div>
149
+ </div>
150
+ <div class="memory-layout">
151
+ <div class="perf-meters" style="padding:14px">
152
+ <div class="perf-meter">
153
+ <div class="perf-meter-label">JS Heap Used</div>
154
+ <div class="perf-meter-value" id="memHeapUsed">—</div>
155
+ </div>
156
+ <div class="perf-meter">
157
+ <div class="perf-meter-label">JS Heap Total</div>
158
+ <div class="perf-meter-value" id="memHeapTotal">—</div>
159
+ </div>
160
+ <div class="perf-meter">
161
+ <div class="perf-meter-label">Native Memory</div>
162
+ <div class="perf-meter-value" id="memNative">—</div>
163
+ </div>
164
+ </div>
165
+ <div class="scroll-area" id="memoryContent">
166
+ <div class="empty-state" id="memoryEmpty">
167
+ <div class="icon" style="font-size:28px;opacity:.2">🧠</div>
168
+ <div class="label">No memory data</div>
169
+ <div class="hint">Click "Take Heap Snapshot" to capture memory usage</div>
170
+ <div class="hint">Requires Hermes CDP connection (press Cmd+D first)</div>
171
+ </div>
172
+ </div>
173
+ </div>`;
174
+
175
+ $('btnHeapSnapshot').addEventListener('click', () => {
176
+ // Request heap snapshot via CDP - this opens the DevTools window
177
+ // which has built-in Memory profiler
178
+ window.electronAPI?.openCDPTarget(null);
179
+ });
180
+ }
181
+
182
+ // Handle memory events from SDK
183
+ function handleMemoryEvent(event) {
184
+ const hu = $('memHeapUsed'), ht = $('memHeapTotal'), mn = $('memNative');
185
+ if (event.heapUsed != null && hu) hu.textContent = formatSize(event.heapUsed);
186
+ if (event.heapTotal != null && ht) ht.textContent = formatSize(event.heapTotal);
187
+ if (event.native != null && mn) mn.textContent = formatSize(event.native);
188
+ }
@@ -0,0 +1,23 @@
1
+ // ─── React Tree Panel ──────────────────────────────────────────────────────
2
+ function initReactPanel() {
3
+ const panel = $('panel-react');
4
+ if (!panel) return;
5
+ panel.innerHTML = `
6
+ <div class="panel-toolbar">
7
+ <span class="panel-label">React Tree</span>
8
+ </div>
9
+ <div class="react-panel-inner">
10
+ <div class="react-connect-hint" id="reactHint">
11
+ <div class="icon" style="font-size:40px;opacity:.2">⚛️</div>
12
+ <div class="label">React DevTools</div>
13
+ <div class="hint">Opens as a separate window connected to your app via port 8097</div>
14
+ <div class="hint" style="margin-top:8px;color:var(--yellow)">Note: The RN inspector overlay won't work while React DevTools is connected. Close the DevTools window to use the built-in inspector.</div>
15
+ <button class="btn-launch" id="btnReactDT" style="margin-top:12px">Open React DevTools ↗</button>
16
+ </div>
17
+ </div>`;
18
+
19
+ $('btnReactDT').addEventListener('click', () => {
20
+ window.electronAPI?.openReactDevTools();
21
+ });
22
+ }
23
+
@@ -0,0 +1,441 @@
1
+ // ─── Redux Panel ───────────────────────────────────────────────────────────
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // REDUX PANEL
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ function initReduxPanel() {
6
+ const panel = $('panel-redux');
7
+ if (!panel) return;
8
+ panel.innerHTML = `
9
+ <div class="panel-toolbar">
10
+ <span class="panel-label">Redux</span>
11
+ <span class="badge" id="rBadge">0</span>
12
+ <input id="reduxSearch" class="net-search-input" style="margin-left:12px" placeholder="Filter actions..." />
13
+ <div class="ml-auto" style="display:flex;align-items:center;gap:8px">
14
+ <button class="panel-clear-btn" id="reduxClear" title="Clear redux">Clear</button>
15
+ <button class="panel-clear-btn" id="reduxSort" title="Toggle sort order">Time ▲</button>
16
+ <div class="time-travel-bar" style="border:none;padding:0;margin:0">
17
+ <button class="tt-btn" onclick="reduxJumpTo(state.redux.selected-1)">◀</button>
18
+ <span class="tt-label" id="ttLabel">—/—</span>
19
+ <button class="tt-btn" onclick="reduxJumpTo(state.redux.selected+1)">▶</button>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ <div class="scroll-area" id="reduxContent">
24
+ <div class="empty-state" id="reduxEmpty">
25
+ <div class="icon">🔲</div>
26
+ <div class="label">No actions dispatched</div>
27
+ <div class="hint">Connect Redux store to RNDebugSDK</div>
28
+ </div>
29
+ </div>`;
30
+
31
+ $('reduxSearch').addEventListener('input', (e) => {
32
+ state.redux.searchFilter = e.target.value.toLowerCase().trim();
33
+ renderRedux();
34
+ });
35
+
36
+ $('reduxClear').addEventListener('click', () => {
37
+ state.redux.actions = [];
38
+ state.redux.states = [];
39
+ state.redux.selected = -1;
40
+ $('rBadge').textContent = '0';
41
+ renderRedux();
42
+ });
43
+
44
+ $('reduxSort').addEventListener('click', () => {
45
+ state.redux.sortDir = state.redux.sortDir === 'desc' ? 'asc' : 'desc';
46
+ $('reduxSort').textContent = state.redux.sortDir === 'desc' ? 'Time \u25BC' : 'Time \u25B2';
47
+ renderRedux();
48
+ });
49
+ }
50
+
51
+ window.reduxJumpTo = idx => {
52
+ const { actions } = state.redux;
53
+ if (!actions.length) return;
54
+ idx = Math.max(0, Math.min(actions.length - 1, idx));
55
+ state.redux.selected = idx;
56
+ renderRedux();
57
+ };
58
+
59
+ // Fast deep equality check for Redux state comparison
60
+ function _deepEqual(a, b) {
61
+ if (a === b) return true;
62
+ if (a == null || b == null) return false;
63
+ if (typeof a !== typeof b) return false;
64
+ if (typeof a !== 'object') return false;
65
+ try {
66
+ return JSON.stringify(a) === JSON.stringify(b);
67
+ } catch { return false; }
68
+ }
69
+
70
+ // Find leaf-level changes between two values (for Redux store diff)
71
+ function _findLeafChanges(oldVal, newVal, basePath, maxDepth) {
72
+ const changes = [];
73
+ if (maxDepth === undefined) maxDepth = 5;
74
+
75
+ function walk(a, b, path, depth) {
76
+ if (depth > maxDepth) {
77
+ if (!_deepEqual(a, b)) changes.push({ path, oldVal: a, newVal: b });
78
+ return;
79
+ }
80
+ if (a === b) return;
81
+ if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) {
82
+ changes.push({ path, oldVal: a, newVal: b });
83
+ return;
84
+ }
85
+ const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
86
+ allKeys.forEach(k => {
87
+ if (!_deepEqual(a[k], b[k])) {
88
+ const childPath = path ? `${path}.${k}` : k;
89
+ if (a[k] != null && b[k] != null && typeof a[k] === 'object' && typeof b[k] === 'object' && !Array.isArray(a[k])) {
90
+ walk(a[k], b[k], childPath, depth + 1);
91
+ } else {
92
+ changes.push({ path: childPath, oldVal: a[k], newVal: b[k] });
93
+ }
94
+ }
95
+ });
96
+ }
97
+
98
+ walk(oldVal, newVal, '', 0);
99
+ return changes;
100
+ }
101
+
102
+ // Create a tree node with changed paths highlighted in a different color
103
+ function _createHighlightedTree(key, val, changedPaths, currentPath, isOld) {
104
+ const isArray = Array.isArray(val);
105
+ const isObj = val !== null && typeof val === 'object';
106
+ const myPath = key !== null ? (currentPath ? `${currentPath}.${key}` : String(key)) : currentPath;
107
+ const isChanged = changedPaths.has(myPath);
108
+
109
+ if (!isObj) {
110
+ // Leaf value
111
+ const row = document.createElement('div');
112
+ row.className = 'ov-leaf' + (isChanged ? ' rdx-highlight' : '');
113
+ if (isChanged) row.style.cssText = isOld
114
+ ? 'background:rgba(255,94,114,.12);border-radius:3px;padding:1px 4px;'
115
+ : 'background:rgba(61,214,140,.12);border-radius:3px;padding:1px 4px;';
116
+ if (key !== null) {
117
+ const k = document.createElement('span');
118
+ k.className = 'ov-key';
119
+ k.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : '';
120
+ k.textContent = `${key}: `;
121
+ row.appendChild(k);
122
+ }
123
+ const v = document.createElement('span');
124
+ v.className = 'ov-prim';
125
+ if (isChanged) v.style.fontWeight = '700';
126
+ if (val === null) { v.textContent = 'null'; v.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : 'var(--text-dim)'; }
127
+ else if (typeof val === 'string') { v.textContent = `"${val}"`; v.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : 'var(--green)'; }
128
+ else if (typeof val === 'number') { v.textContent = String(val); v.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : 'var(--accent2)'; }
129
+ else if (typeof val === 'boolean') { v.textContent = String(val); v.style.color = isChanged ? (isOld ? 'var(--red)' : 'var(--green)') : 'var(--accent2)'; }
130
+ else { v.textContent = _safeStr(val); }
131
+ row.appendChild(v);
132
+ return row;
133
+ }
134
+
135
+ // Object/Array — check if any descendants changed
136
+ const hasChangedDescendant = [...changedPaths].some(p => p === myPath || p.startsWith(myPath ? myPath + '.' : ''));
137
+ const container = document.createElement('div');
138
+ container.className = 'ov-node';
139
+
140
+ const header = document.createElement('div');
141
+ header.className = 'ov-header';
142
+
143
+ const arrow = document.createElement('span');
144
+ arrow.className = 'ov-arrow';
145
+ arrow.textContent = '\u25B6';
146
+ header.appendChild(arrow);
147
+
148
+ if (key !== null) {
149
+ const k = document.createElement('span');
150
+ k.className = 'ov-key';
151
+ if (hasChangedDescendant) k.style.color = isOld ? 'var(--red)' : 'var(--green)';
152
+ k.textContent = `${key}: `;
153
+ header.appendChild(k);
154
+ }
155
+
156
+ const preview = document.createElement('span');
157
+ preview.className = 'ov-preview';
158
+ preview.textContent = isArray ? `Array(${val.length})` : `{${Object.keys(val).length} keys}`;
159
+ header.appendChild(preview);
160
+
161
+ container.appendChild(header);
162
+
163
+ const children = document.createElement('div');
164
+ children.className = 'ov-children';
165
+ // Always start collapsed — user expands what they need
166
+ children.style.display = 'none';
167
+
168
+ let populated = false;
169
+ function populate() {
170
+ if (populated) return;
171
+ populated = true;
172
+ const entries = isArray ? val.map((v, i) => [i, v]) : Object.entries(val);
173
+ entries.forEach(([k, v]) => {
174
+ children.appendChild(_createHighlightedTree(k, v, changedPaths, myPath, isOld));
175
+ });
176
+ }
177
+
178
+ header.addEventListener('click', (e) => {
179
+ e.stopPropagation();
180
+ const open = children.style.display !== 'none';
181
+ children.style.display = open ? 'none' : 'block';
182
+ arrow.textContent = open ? '\u25B6' : '\u25BC';
183
+ if (!open) populate();
184
+ });
185
+
186
+ container.appendChild(children);
187
+ return container;
188
+ }
189
+
190
+ function handleReduxEvent(event) {
191
+ if (event.type !== 'redux') return;
192
+ // Skip processing if Redux tab is disabled (saves memory)
193
+ if (!isTabEnabled('redux')) return;
194
+ const { action, nextState } = event;
195
+ const idx = state.redux.actions.length;
196
+
197
+ const prevState = state.redux.states.length > 0 ? state.redux.states[state.redux.states.length - 1] : null;
198
+ const changedKeys = [];
199
+ if (prevState && nextState && typeof prevState === 'object' && typeof nextState === 'object') {
200
+ const allKeys = new Set([...Object.keys(prevState), ...Object.keys(nextState)]);
201
+ allKeys.forEach(k => { if (!_deepEqual(prevState[k], nextState[k])) changedKeys.push(k); });
202
+ }
203
+
204
+ const actionEntry = { type: action?.type || '?', payload: action, ts: event.ts, index: idx, changedKeys };
205
+ state.redux.actions.push(actionEntry);
206
+ state.redux.states.push(nextState);
207
+ // Cap Redux history to prevent memory leak (full state stored per action)
208
+ const MAX_REDUX_HISTORY = 500;
209
+ if (state.redux.actions.length > MAX_REDUX_HISTORY) {
210
+ const trim = state.redux.actions.length - MAX_REDUX_HISTORY;
211
+ state.redux.actions.splice(0, trim);
212
+ state.redux.states.splice(0, trim);
213
+ // Re-index remaining actions
214
+ state.redux.actions.forEach((a, i) => a.index = i);
215
+ if (state.redux.selected >= 0) state.redux.selected = Math.max(0, state.redux.selected - trim);
216
+ }
217
+ // Don't auto-select — keep all collapsed until user clicks
218
+ $('rBadge').textContent = state.redux.actions.length;
219
+ renderRedux();
220
+
221
+ // Always add Redux actions to console logs — visibility controlled by showRedux filter
222
+ {
223
+ const msg = `[Redux] ${actionEntry.type}` + (changedKeys.length ? ` (changed: ${changedKeys.join(', ')})` : '');
224
+ addConsoleLog({
225
+ level: 'redux',
226
+ message: msg,
227
+ args: [{ t: 'string', v: `[Redux] ${actionEntry.type}` }, { t: 'object', v: action }],
228
+ ts: event.ts,
229
+ _isRedux: true,
230
+ });
231
+ }
232
+ }
233
+
234
+ // Assign a consistent color to each Redux action category (e.g. ANALYTICS, CART, USER)
235
+ const _reduxCatColors = {};
236
+ const _reduxColorPalette = [
237
+ 'var(--accent)', // blue
238
+ 'var(--green)', // green
239
+ 'var(--orange)', // orange
240
+ 'var(--accent2)', // purple
241
+ '#e06c75', // coral
242
+ '#56b6c2', // teal
243
+ '#c678dd', // magenta
244
+ '#d19a66', // gold
245
+ '#98c379', // lime
246
+ '#e5c07b', // yellow
247
+ ];
248
+ let _reduxColorIdx = 0;
249
+ function _reduxCategoryColor(category) {
250
+ if (!_reduxCatColors[category]) {
251
+ _reduxCatColors[category] = _reduxColorPalette[_reduxColorIdx % _reduxColorPalette.length];
252
+ _reduxColorIdx++;
253
+ }
254
+ return _reduxCatColors[category];
255
+ }
256
+
257
+ function renderRedux() {
258
+ const content = $('reduxContent');
259
+ const empty = $('reduxEmpty');
260
+ if (!content) return;
261
+
262
+ const { actions, states, selected, searchFilter, sortDir } = state.redux;
263
+ let visible = searchFilter ? actions.filter(a => a.type.toLowerCase().includes(searchFilter)) : [...actions];
264
+ if (sortDir === 'desc') visible = [...visible].reverse();
265
+
266
+ empty.style.display = visible.length ? 'none' : 'flex';
267
+ content.querySelectorAll('.rdx-entry').forEach(e => e.remove());
268
+ if (!visible.length) return;
269
+
270
+ const ttLabel = $('ttLabel');
271
+ if (ttLabel) ttLabel.textContent = selected >= 0 ? `${selected + 1}/${actions.length}` : `—/${actions.length}`;
272
+
273
+ const frag = document.createDocumentFragment();
274
+ visible.forEach(a => {
275
+ const isSelected = a.index === selected;
276
+
277
+ const entry = document.createElement('div');
278
+ entry.className = 'rdx-entry' + (isSelected ? ' selected' : '');
279
+
280
+ // Row header — always visible
281
+ const header = document.createElement('div');
282
+ header.className = 'rdx-entry-header';
283
+ const changesBadge = a.changedKeys?.length ? `<span class="rdx-changes">${a.changedKeys.length} changed</span>` : '';
284
+ // Color-code action type by category prefix (e.g. ANALYTICS/, CART/, USER/)
285
+ const typeParts = a.type.split('/');
286
+ let typeHtml;
287
+ if (typeParts.length >= 2) {
288
+ const catColor = _reduxCategoryColor(typeParts[0]);
289
+ typeHtml = `<span class="rdx-type-cat" style="color:${catColor}">${esc(typeParts[0])}/</span><span class="rdx-type-name">${esc(typeParts.slice(1).join('/'))}</span>`;
290
+ } else {
291
+ typeHtml = `<span class="rdx-type">${esc(a.type)}</span>`;
292
+ }
293
+ header.innerHTML = `<span class="rdx-index">#${a.index}</span>${typeHtml}<span class="rdx-header-right">${changesBadge}<span class="rdx-time">${ts(a.ts)}</span></span>`;
294
+ // Toggle: click to expand, click again to collapse
295
+ header.addEventListener('click', () => {
296
+ state.redux.selected = isSelected ? -1 : a.index;
297
+ renderRedux();
298
+ });
299
+ // Right-click to copy action type
300
+ header.addEventListener('contextmenu', (e) => {
301
+ e.preventDefault();
302
+ e.stopPropagation();
303
+ showContextMenu(e, [
304
+ { label: 'Copy Action Type', action: () => navigator.clipboard.writeText(a.type) },
305
+ { label: 'Copy Action Payload', action: () => navigator.clipboard.writeText(JSON.stringify(a.payload, null, 2)) },
306
+ ]);
307
+ });
308
+ // Allow text selection on the action type
309
+ header.style.userSelect = 'text';
310
+ entry.appendChild(header);
311
+
312
+ // Expanded detail — only for explicitly selected action
313
+ if (isSelected) {
314
+ const detail = document.createElement('div');
315
+ detail.className = 'rdx-entry-detail';
316
+
317
+ // Close button
318
+ const closeBtn = document.createElement('button');
319
+ closeBtn.className = 'rdx-close-btn';
320
+ closeBtn.textContent = '✕';
321
+ closeBtn.title = 'Close';
322
+ closeBtn.addEventListener('click', (e) => {
323
+ e.stopPropagation();
324
+ state.redux.selected = -1;
325
+ renderRedux();
326
+ });
327
+ detail.appendChild(closeBtn);
328
+
329
+ // Changed keys badges
330
+ if (a.changedKeys?.length > 0) {
331
+ const keysEl = document.createElement('div');
332
+ keysEl.className = 'redux-changed-keys';
333
+ keysEl.innerHTML = `<span class="redux-changed-label">Changed:</span> ${a.changedKeys.map(k =>
334
+ `<span class="redux-changed-key">${esc(k)}</span>`).join(' ')}`;
335
+ detail.appendChild(keysEl);
336
+ }
337
+
338
+ // Payload
339
+ if (a.payload) {
340
+ const pLabel = document.createElement('div');
341
+ pLabel.className = 'redux-section-title';
342
+ pLabel.textContent = 'Action Payload';
343
+ detail.appendChild(pLabel);
344
+ detail.appendChild(createTreeNode(null, a.payload, false));
345
+ }
346
+
347
+ // Store changes — two-column layout: Previous | Current
348
+ const prevS = a.index > 0 ? states[a.index - 1] : null;
349
+ const currS = states[a.index];
350
+ if (currS && typeof currS === 'object' && a.changedKeys?.length > 0) {
351
+ a.changedKeys.forEach(key => {
352
+ const keyWrap = document.createElement('div');
353
+ keyWrap.className = 'rdx-store-diff';
354
+
355
+ const kLabel = document.createElement('div');
356
+ kLabel.className = 'rdx-store-key-label';
357
+ kLabel.textContent = key;
358
+ keyWrap.appendChild(kLabel);
359
+
360
+ const oldVal = prevS ? prevS[key] : undefined;
361
+ const newVal = currS[key];
362
+
363
+ // Find which sub-keys changed (for highlighting)
364
+ const changedPaths = new Set();
365
+ _findLeafChanges(oldVal, newVal, '').forEach(c => changedPaths.add(c.path));
366
+
367
+ // Two-column grid: Previous | Current
368
+ const grid = document.createElement('div');
369
+ grid.className = 'rdx-diff-grid';
370
+
371
+ // Previous column
372
+ const prevCol = document.createElement('div');
373
+ prevCol.className = 'rdx-diff-col prev';
374
+ const prevLabel = document.createElement('div');
375
+ prevLabel.className = 'rdx-state-label prev';
376
+ prevLabel.textContent = '- Previous';
377
+ prevCol.appendChild(prevLabel);
378
+ if (oldVal !== undefined) {
379
+ prevCol.appendChild(_createHighlightedTree(null, oldVal, changedPaths, '', true));
380
+ } else {
381
+ const na = document.createElement('span');
382
+ na.style.cssText = 'color:var(--text-dim);font-size:10px;font-style:italic';
383
+ na.textContent = 'undefined';
384
+ prevCol.appendChild(na);
385
+ }
386
+ grid.appendChild(prevCol);
387
+
388
+ // Current column
389
+ const currCol = document.createElement('div');
390
+ currCol.className = 'rdx-diff-col curr';
391
+ const currLabel = document.createElement('div');
392
+ currLabel.className = 'rdx-state-label curr';
393
+ currLabel.textContent = '+ Current';
394
+ currCol.appendChild(currLabel);
395
+ if (newVal !== undefined) {
396
+ currCol.appendChild(_createHighlightedTree(null, newVal, changedPaths, '', false));
397
+ } else {
398
+ const na = document.createElement('span');
399
+ na.style.cssText = 'color:var(--text-dim);font-size:10px;font-style:italic';
400
+ na.textContent = 'undefined';
401
+ currCol.appendChild(na);
402
+ }
403
+ grid.appendChild(currCol);
404
+
405
+ // Right-click to copy on each column
406
+ prevCol.addEventListener('contextmenu', (e) => {
407
+ e.preventDefault(); e.stopPropagation();
408
+ showContextMenu(e, [
409
+ { label: 'Copy Previous Value', action: () => navigator.clipboard.writeText(JSON.stringify(oldVal, null, 2)) },
410
+ { label: 'Copy Current Value', action: () => navigator.clipboard.writeText(JSON.stringify(newVal, null, 2)) },
411
+ { label: `Copy "${key}" key`, action: () => navigator.clipboard.writeText(key) },
412
+ ]);
413
+ });
414
+ currCol.addEventListener('contextmenu', (e) => {
415
+ e.preventDefault(); e.stopPropagation();
416
+ showContextMenu(e, [
417
+ { label: 'Copy Current Value', action: () => navigator.clipboard.writeText(JSON.stringify(newVal, null, 2)) },
418
+ { label: 'Copy Previous Value', action: () => navigator.clipboard.writeText(JSON.stringify(oldVal, null, 2)) },
419
+ { label: `Copy "${key}" key`, action: () => navigator.clipboard.writeText(key) },
420
+ ]);
421
+ });
422
+
423
+ keyWrap.appendChild(grid);
424
+ detail.appendChild(keyWrap);
425
+ });
426
+ }
427
+
428
+ entry.appendChild(detail);
429
+ }
430
+
431
+ frag.appendChild(entry);
432
+ });
433
+
434
+ content.appendChild(frag);
435
+ // Scroll selected entry into view
436
+ const selEl = content.querySelector('.rdx-entry.selected');
437
+ if (selEl) {
438
+ selEl.scrollIntoView({ block: 'nearest', behavior: 'auto' });
439
+ }
440
+ }
441
+