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