@scenetest/vite-plugin 0.11.0 → 0.13.0
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/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/panels/observer/audio.d.ts +81 -0
- package/dist/panels/observer/audio.d.ts.map +1 -0
- package/dist/panels/observer/audio.js +296 -0
- package/dist/panels/observer/audio.js.map +1 -0
- package/dist/panels/observer/auto.d.ts +10 -0
- package/dist/panels/observer/auto.d.ts.map +1 -0
- package/dist/panels/observer/auto.js +11 -0
- package/dist/panels/observer/auto.js.map +1 -0
- package/dist/panels/observer/fs-viewer.d.ts +20 -0
- package/dist/panels/observer/fs-viewer.d.ts.map +1 -0
- package/dist/panels/observer/fs-viewer.js +536 -0
- package/dist/panels/observer/fs-viewer.js.map +1 -0
- package/dist/panels/observer/fullscreen.d.ts +24 -0
- package/dist/panels/observer/fullscreen.d.ts.map +1 -0
- package/dist/panels/observer/fullscreen.js +701 -0
- package/dist/panels/observer/fullscreen.js.map +1 -0
- package/dist/panels/observer/history.d.ts +41 -0
- package/dist/panels/observer/history.d.ts.map +1 -0
- package/dist/panels/observer/history.js +307 -0
- package/dist/panels/observer/history.js.map +1 -0
- package/dist/panels/observer/index.d.ts +33 -0
- package/dist/panels/observer/index.d.ts.map +1 -0
- package/dist/panels/observer/index.js +128 -0
- package/dist/panels/observer/index.js.map +1 -0
- package/dist/panels/observer/panel.d.ts +12 -0
- package/dist/panels/observer/panel.d.ts.map +1 -0
- package/dist/panels/observer/panel.js +461 -0
- package/dist/panels/observer/panel.js.map +1 -0
- package/dist/panels/observer/render.d.ts +109 -0
- package/dist/panels/observer/render.d.ts.map +1 -0
- package/dist/panels/observer/render.js +760 -0
- package/dist/panels/observer/render.js.map +1 -0
- package/dist/panels/observer/state.d.ts +57 -0
- package/dist/panels/observer/state.d.ts.map +1 -0
- package/dist/panels/observer/state.js +187 -0
- package/dist/panels/observer/state.js.map +1 -0
- package/dist/panels/observer/styles.d.ts +6 -0
- package/dist/panels/observer/styles.d.ts.map +1 -0
- package/dist/panels/observer/styles.js +1706 -0
- package/dist/panels/observer/styles.js.map +1 -0
- package/dist/panels/observer/types.d.ts +102 -0
- package/dist/panels/observer/types.d.ts.map +1 -0
- package/dist/panels/observer/types.js +5 -0
- package/dist/panels/observer/types.js.map +1 -0
- package/dist/panels/observer/utils.d.ts +45 -0
- package/dist/panels/observer/utils.d.ts.map +1 -0
- package/dist/panels/observer/utils.js +101 -0
- package/dist/panels/observer/utils.js.map +1 -0
- package/dist/panels/recorder/auto.d.ts +10 -0
- package/dist/panels/recorder/auto.d.ts.map +1 -0
- package/dist/panels/recorder/auto.js +11 -0
- package/dist/panels/recorder/auto.js.map +1 -0
- package/dist/panels/recorder/capture.d.ts +18 -0
- package/dist/panels/recorder/capture.d.ts.map +1 -0
- package/dist/panels/recorder/capture.js +218 -0
- package/dist/panels/recorder/capture.js.map +1 -0
- package/dist/panels/recorder/index.d.ts +41 -0
- package/dist/panels/recorder/index.d.ts.map +1 -0
- package/dist/panels/recorder/index.js +208 -0
- package/dist/panels/recorder/index.js.map +1 -0
- package/dist/panels/recorder/panel.d.ts +55 -0
- package/dist/panels/recorder/panel.d.ts.map +1 -0
- package/dist/panels/recorder/panel.js +284 -0
- package/dist/panels/recorder/panel.js.map +1 -0
- package/dist/panels/recorder/reverse-selector.d.ts +31 -0
- package/dist/panels/recorder/reverse-selector.d.ts.map +1 -0
- package/dist/panels/recorder/reverse-selector.js +116 -0
- package/dist/panels/recorder/reverse-selector.js.map +1 -0
- package/dist/panels/recorder/styles.d.ts +5 -0
- package/dist/panels/recorder/styles.d.ts.map +1 -0
- package/dist/panels/recorder/styles.js +300 -0
- package/dist/panels/recorder/styles.js.map +1 -0
- package/dist/panels/recorder/types.d.ts +51 -0
- package/dist/panels/recorder/types.d.ts.map +1 -0
- package/dist/panels/recorder/types.js +15 -0
- package/dist/panels/recorder/types.js.map +1 -0
- package/dist/strip.d.ts.map +1 -1
- package/dist/strip.js +6 -3
- package/dist/strip.js.map +1 -1
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +5 -2
- package/dist/transform.js.map +1 -1
- package/package.json +8 -6
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering functions for the dev panel
|
|
3
|
+
*
|
|
4
|
+
* SECURITY NOTE: These functions use data attributes instead of inline onclick
|
|
5
|
+
* handlers to prevent XSS. Event listeners are attached via delegation in
|
|
6
|
+
* attachEventListeners() after rendering.
|
|
7
|
+
*/
|
|
8
|
+
import { getHistoryStats, formatHistorySummary, computeFlakyStats, computeResolutionStats, formatResolutionSummary, formatFlakyStatus } from './history.js';
|
|
9
|
+
import { escapeHtml, escapeHtmlAttr, formatContext, formatLocation, formatLocationShort, formatTime, getGroupStats } from './utils.js';
|
|
10
|
+
import { getNoteInfo } from './audio.js';
|
|
11
|
+
import { fileTreeCollapsed, FILE_TREE_GLOW_MS } from './state.js';
|
|
12
|
+
/**
|
|
13
|
+
* Render a single assertion item for the main panel
|
|
14
|
+
* Click opens fullscreen view, no history shown (history only in fullscreen)
|
|
15
|
+
*/
|
|
16
|
+
export function renderPanelItem(a, groupId) {
|
|
17
|
+
const titleAttr = a.context
|
|
18
|
+
? escapeHtml(formatContext(a.context))
|
|
19
|
+
: a.location
|
|
20
|
+
? escapeHtml(formatLocation(a.location))
|
|
21
|
+
: '';
|
|
22
|
+
const noteInfo = getNoteInfo(a.description);
|
|
23
|
+
const isServer = !!a.assertionId;
|
|
24
|
+
return `
|
|
25
|
+
<div class="scenetest-item ${a.result ? 'pass' : 'fail'}${isServer ? ' server' : ''}"
|
|
26
|
+
data-action="openFullscreenToGroup"
|
|
27
|
+
data-group-id="${groupId}"
|
|
28
|
+
title="${titleAttr}">
|
|
29
|
+
<span class="scenetest-icon">${a.result ? '\u2713' : '\u2717'}</span>
|
|
30
|
+
<div class="scenetest-content">
|
|
31
|
+
<div class="scenetest-desc${a.type === 'fail' && a.result ? ' negated' : ''}">${isServer ? '<span class="scenetest-server-badge">server</span>' : ''}${escapeHtml(a.description)}</div>
|
|
32
|
+
${a.location ? `<div class="scenetest-location">${escapeHtml(formatLocation(a.location))}</div>` : ''}
|
|
33
|
+
</div>
|
|
34
|
+
<span class="scenetest-note ${a.result ? 'pass' : 'fail'}" title="Musical note for this assertion">\u266A${noteInfo.noteName}</span>
|
|
35
|
+
</div>
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Render a single assertion item for the main panel (ungrouped, with time)
|
|
40
|
+
*/
|
|
41
|
+
export function renderPanelItemWithTime(a) {
|
|
42
|
+
const histStats = getHistoryStats(a.description, a._index ?? 0);
|
|
43
|
+
const histSummary = formatHistorySummary(histStats);
|
|
44
|
+
const locJson = a.location ? escapeHtmlAttr(JSON.stringify(a.location)) : 'null';
|
|
45
|
+
const titleAttr = a.context
|
|
46
|
+
? escapeHtml(formatContext(a.context))
|
|
47
|
+
: a.location
|
|
48
|
+
? escapeHtml(formatLocation(a.location))
|
|
49
|
+
: '';
|
|
50
|
+
return `
|
|
51
|
+
<div class="scenetest-item ${a.result ? 'pass' : 'fail'}"
|
|
52
|
+
data-action="openInEditor"
|
|
53
|
+
data-location="${locJson}"
|
|
54
|
+
title="${titleAttr}">
|
|
55
|
+
<span class="scenetest-icon">${a.result ? '\u2713' : '\u2717'}</span>
|
|
56
|
+
<div class="scenetest-content">
|
|
57
|
+
<div class="scenetest-desc${a.type === 'fail' && a.result ? ' negated' : ''}">${escapeHtml(a.description)}</div>
|
|
58
|
+
${a.location ? `<div class="scenetest-location">${escapeHtml(formatLocation(a.location))}</div>` : ''}
|
|
59
|
+
${histSummary ? `<div class="scenetest-history">${histSummary}</div>` : ''}
|
|
60
|
+
</div>
|
|
61
|
+
<span class="scenetest-time">${formatTime(a.timestamp)}</span>
|
|
62
|
+
</div>
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Render a group of assertions for the main panel
|
|
67
|
+
*/
|
|
68
|
+
export function renderPanelGroup(g) {
|
|
69
|
+
const stats = getGroupStats(g.items);
|
|
70
|
+
return `
|
|
71
|
+
<div class="scenetest-group${g.collapsed ? ' collapsed' : ''}" data-group-id="${g.id}">
|
|
72
|
+
<div class="scenetest-group-header" data-action="toggleCollapsed">
|
|
73
|
+
<div class="scenetest-group-summary">
|
|
74
|
+
<span class="scenetest-group-time">${formatTime(g.timestamp)}</span>
|
|
75
|
+
<div class="scenetest-group-stats">
|
|
76
|
+
<span class="scenetest-group-stat pass">\u2713${stats.passCount}</span>
|
|
77
|
+
<span class="scenetest-group-stat ${stats.failCount > 0 ? 'fail' : 'zero'}">\u2717${stats.failCount}</span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<span class="scenetest-group-toggle">\u25BC</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="scenetest-group-items">
|
|
83
|
+
${g.items.map(a => renderPanelItem(a, g.id)).join('')}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Render a single assertion item for fullscreen view
|
|
90
|
+
*/
|
|
91
|
+
export function renderFullscreenItem(a) {
|
|
92
|
+
const histStats = getHistoryStats(a.description, a._index ?? 0);
|
|
93
|
+
const histSummary = formatHistorySummary(histStats);
|
|
94
|
+
const locJson = a.location ? escapeHtmlAttr(JSON.stringify(a.location)) : 'null';
|
|
95
|
+
const noteInfo = getNoteInfo(a.description);
|
|
96
|
+
const isServer = !!a.assertionId;
|
|
97
|
+
return `
|
|
98
|
+
<div class="item ${a.result ? 'pass' : 'fail'}${isServer ? ' server' : ''}">
|
|
99
|
+
<span class="icon">${a.result ? '\u2713' : '\u2717'}</span>
|
|
100
|
+
<div class="content">
|
|
101
|
+
<div class="desc${a.type === 'fail' && a.result ? ' negated' : ''}" data-action="showSequence" data-key="${escapeHtmlAttr(JSON.stringify(a.description))}">${isServer ? '<span class="server-badge">server</span>' : ''}${escapeHtml(a.description)}</div>
|
|
102
|
+
${a.location ? `<div class="location" data-action="openInEditorFullscreen" data-location="${locJson}">${escapeHtml(formatLocation(a.location))}</div>` : ''}
|
|
103
|
+
${histSummary ? `<div class="history">${histSummary}</div>` : ''}
|
|
104
|
+
${a.context ? `<div class="context">${escapeHtml(formatContext(a.context))}</div>` : ''}
|
|
105
|
+
${a.stack && !a.context ? `<div class="stack">${escapeHtml(a.stack.split('\n').slice(0, 3).join('\n'))}</div>` : ''}
|
|
106
|
+
</div>
|
|
107
|
+
<span class="note-badge ${a.result ? 'pass' : 'fail'}" title="Musical note: ${noteInfo.noteName}">\u266A${noteInfo.noteName}</span>
|
|
108
|
+
</div>
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Render a group of assertions for fullscreen view
|
|
113
|
+
*/
|
|
114
|
+
export function renderFullscreenGroup(g) {
|
|
115
|
+
const stats = getGroupStats(g.items);
|
|
116
|
+
return `
|
|
117
|
+
<div class="group" data-group-id="${g.id}">
|
|
118
|
+
<div class="group-header" data-action="toggleCollapsed">
|
|
119
|
+
<div class="group-info">
|
|
120
|
+
<span class="group-time">${formatTime(g.timestamp)}</span>
|
|
121
|
+
<div class="group-stats">
|
|
122
|
+
${stats.passCount > 0 ? `<span class="group-stat pass">\u2713 ${stats.passCount}</span>` : ''}
|
|
123
|
+
${stats.failCount > 0 ? `<span class="group-stat fail">\u2717 ${stats.failCount}</span>` : ''}
|
|
124
|
+
</div>
|
|
125
|
+
<span style="color: #6a6a8a">${g.items.length} assertion${g.items.length === 1 ? '' : 's'}</span>
|
|
126
|
+
</div>
|
|
127
|
+
<span class="group-toggle">\u25BC</span>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="group-items">
|
|
130
|
+
${g.items.map(a => renderFullscreenItem(a)).join('')}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Render a location row for the "by location" view
|
|
137
|
+
* Shows one row per unique code location with status dots
|
|
138
|
+
*/
|
|
139
|
+
export function renderLocationRow(group) {
|
|
140
|
+
const passCount = group.entries.filter(e => e.result).length;
|
|
141
|
+
const failCount = group.entries.filter(e => !e.result).length;
|
|
142
|
+
const total = group.entries.length;
|
|
143
|
+
const keyJson = escapeHtmlAttr(JSON.stringify(group.key));
|
|
144
|
+
const locJson = escapeHtmlAttr(JSON.stringify(group.location));
|
|
145
|
+
const noteInfo = getNoteInfo(group.description);
|
|
146
|
+
// Generate status dots (most recent 10 runs) with ✗ marker for failures
|
|
147
|
+
const recentEntries = group.entries.slice(-10);
|
|
148
|
+
const dots = recentEntries
|
|
149
|
+
.map(e => {
|
|
150
|
+
if (e.result) {
|
|
151
|
+
return `<span class="status-dot pass" title="${formatTime(e.timestamp)}"></span>`;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
return `<span class="status-dot fail" title="${formatTime(e.timestamp)}"><span class="dot-x">\u2717</span></span>`;
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
.join('');
|
|
158
|
+
// Determine overall status class and icon
|
|
159
|
+
const hasAnyFails = failCount > 0;
|
|
160
|
+
const lastFailed = !group.lastResult;
|
|
161
|
+
const statusClass = lastFailed ? 'last-fail' : hasAnyFails ? 'has-fails' : 'all-pass';
|
|
162
|
+
// Note badge class based on current state
|
|
163
|
+
const noteBadgeClass = lastFailed ? 'fail' : hasAnyFails ? 'warn' : 'pass';
|
|
164
|
+
// Current state icon - prominent indicator of current status
|
|
165
|
+
const stateIcon = lastFailed
|
|
166
|
+
? '<span class="state-icon fail">\u2717</span>'
|
|
167
|
+
: hasAnyFails
|
|
168
|
+
? '<span class="state-icon warn">\u26A0</span>'
|
|
169
|
+
: '<span class="state-icon pass">\u2713</span>';
|
|
170
|
+
// Compute flaky and resolution stats
|
|
171
|
+
const flakyStats = computeFlakyStats(group.description);
|
|
172
|
+
const resolutionStats = computeResolutionStats(group.description);
|
|
173
|
+
// Build flaky info line
|
|
174
|
+
let flakyInfo = '';
|
|
175
|
+
if (flakyStats && flakyStats.isCurrentlyFailing) {
|
|
176
|
+
// Currently failing - show failure rate
|
|
177
|
+
flakyInfo = formatFlakyStatus(flakyStats);
|
|
178
|
+
}
|
|
179
|
+
else if (resolutionStats && resolutionStats.resolvedFailures.length > 0) {
|
|
180
|
+
// Has resolved failures - show resolution summary
|
|
181
|
+
flakyInfo = formatResolutionSummary(resolutionStats);
|
|
182
|
+
}
|
|
183
|
+
return `
|
|
184
|
+
<div class="location-row ${statusClass}" data-action="showSequence" data-key="${keyJson}" data-location-key="${keyJson}" data-description="${escapeHtmlAttr(group.description)}" data-last-result="${group.lastResult}">
|
|
185
|
+
${stateIcon}
|
|
186
|
+
<div class="location-main">
|
|
187
|
+
<div class="location-info">
|
|
188
|
+
<span class="location-file">${escapeHtml(formatLocationShort(group.location))}</span>
|
|
189
|
+
<span class="location-desc">${escapeHtml(group.description)}</span>
|
|
190
|
+
${flakyInfo ? `<span class="flaky-info ${lastFailed ? 'failing' : 'resolved'}">${escapeHtml(flakyInfo)}</span>` : ''}
|
|
191
|
+
</div>
|
|
192
|
+
<div class="location-stats">
|
|
193
|
+
<div class="status-dots">${dots}</div>
|
|
194
|
+
<span class="location-count">${total} run${total === 1 ? '' : 's'}</span>
|
|
195
|
+
<div class="location-summary">
|
|
196
|
+
${passCount > 0 ? `<span class="stat pass">\u2713${passCount}</span>` : ''}
|
|
197
|
+
${failCount > 0 ? `<span class="stat fail">\u2717${failCount}</span>` : ''}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="location-actions">
|
|
202
|
+
<span class="note-badge clickable ${noteBadgeClass}" data-description="${escapeHtml(group.description)}" data-result="${group.lastResult}" title="Click to hear this note">\u266A${noteInfo.noteName}</span>
|
|
203
|
+
<button class="loc-btn" data-action="openInEditorFullscreen" data-location="${locJson}" title="Open in editor">\u270E</button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Render a piano roll visualization showing notes over time (chronological grid)
|
|
210
|
+
* X axis = time (chords), Y axis = notes (pitch)
|
|
211
|
+
*/
|
|
212
|
+
export function renderPianoRoll(locations, allAssertions) {
|
|
213
|
+
if (locations.length === 0 || allAssertions.length === 0)
|
|
214
|
+
return '';
|
|
215
|
+
// Group assertions by timestamp (within 50ms threshold)
|
|
216
|
+
const GROUP_THRESHOLD = 50;
|
|
217
|
+
const chords = [];
|
|
218
|
+
const sortedAssertions = [...allAssertions].sort((a, b) => a.timestamp - b.timestamp);
|
|
219
|
+
sortedAssertions.forEach(assertion => {
|
|
220
|
+
const lastChord = chords[chords.length - 1];
|
|
221
|
+
if (lastChord && assertion.timestamp - lastChord.timestamp < GROUP_THRESHOLD) {
|
|
222
|
+
lastChord.assertions.push(assertion);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
chords.push({ timestamp: assertion.timestamp, assertions: [assertion] });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
// Get unique notes sorted by pitch
|
|
229
|
+
const noteMap = new Map();
|
|
230
|
+
locations.forEach(loc => {
|
|
231
|
+
const noteInfo = getNoteInfo(loc.description);
|
|
232
|
+
const failCount = loc.entries.filter(e => !e.result).length;
|
|
233
|
+
noteMap.set(loc.description, {
|
|
234
|
+
noteInfo,
|
|
235
|
+
description: loc.description,
|
|
236
|
+
hasAnyFails: failCount > 0,
|
|
237
|
+
lastResult: loc.lastResult,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
const notes = Array.from(noteMap.values()).sort((a, b) => a.noteInfo.noteIndex - b.noteInfo.noteIndex);
|
|
241
|
+
// Limit to last N chords to fit reasonably
|
|
242
|
+
const maxChords = 30;
|
|
243
|
+
const displayChords = chords.slice(-maxChords);
|
|
244
|
+
// Build the grid
|
|
245
|
+
const rows = notes.map(note => {
|
|
246
|
+
const statusClass = !note.lastResult ? 'fail' : note.hasAnyFails ? 'warn' : 'pass';
|
|
247
|
+
const cells = displayChords.map((chord, colIdx) => {
|
|
248
|
+
const match = chord.assertions.find(a => a.description === note.description);
|
|
249
|
+
if (match) {
|
|
250
|
+
const cellClass = match.result ? 'pass' : 'fail';
|
|
251
|
+
return `<span class="piano-cell ${cellClass}" data-col="${colIdx}"></span>`;
|
|
252
|
+
}
|
|
253
|
+
return `<span class="piano-cell empty" data-col="${colIdx}"></span>`;
|
|
254
|
+
}).join('');
|
|
255
|
+
return `
|
|
256
|
+
<div class="piano-row ${statusClass}" data-description="${escapeHtmlAttr(note.description)}" data-result="${note.lastResult}">
|
|
257
|
+
<span class="piano-note">${note.noteInfo.noteName}</span>
|
|
258
|
+
<div class="piano-cells">${cells}</div>
|
|
259
|
+
</div>
|
|
260
|
+
`;
|
|
261
|
+
}).join('');
|
|
262
|
+
// Build chord data for click handlers (JSON encoded)
|
|
263
|
+
const chordData = displayChords.map(chord => ({
|
|
264
|
+
timestamp: chord.timestamp,
|
|
265
|
+
notes: chord.assertions.map(a => ({
|
|
266
|
+
description: a.description,
|
|
267
|
+
result: a.result,
|
|
268
|
+
})),
|
|
269
|
+
}));
|
|
270
|
+
return `
|
|
271
|
+
<div class="piano-roll" data-chords="${escapeHtmlAttr(JSON.stringify(chordData))}">
|
|
272
|
+
<div class="piano-roll-header">
|
|
273
|
+
<span class="piano-roll-title">\uD83C\uDFB9 Piano Roll</span>
|
|
274
|
+
<span class="piano-roll-hint">Click a column to hear that chord</span>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="piano-grid">
|
|
277
|
+
${rows}
|
|
278
|
+
</div>
|
|
279
|
+
<div class="piano-timeline">
|
|
280
|
+
<span class="piano-note"></span>
|
|
281
|
+
<div class="piano-time-markers">
|
|
282
|
+
${displayChords.map((_, i) => `<span class="piano-time-marker" data-col="${i}"></span>`).join('')}
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Render a sequence entry for the "sequence" view
|
|
290
|
+
* Shows a single run of an assertion at a specific location
|
|
291
|
+
* isFirst/isLast are used to show timeline labels
|
|
292
|
+
*/
|
|
293
|
+
export function renderSequenceEntry(entry, _location, isFirst = false, isLast = false) {
|
|
294
|
+
const noteInfo = getNoteInfo(entry.description);
|
|
295
|
+
return `
|
|
296
|
+
<div class="sequence-entry ${entry.result ? 'pass' : 'fail'}" data-timestamp="${entry.timestamp}">
|
|
297
|
+
<div class="timeline-track">
|
|
298
|
+
<div class="timeline-line ${isFirst ? 'first' : ''} ${isLast ? 'last' : ''}"></div>
|
|
299
|
+
<div class="timeline-dot ${entry.result ? 'pass' : 'fail'}">
|
|
300
|
+
${entry.result ? '' : '<span class="dot-x">\u2717</span>'}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="content">
|
|
304
|
+
<div class="sequence-time chord-trigger" data-timestamp="${entry.timestamp}" title="Hover to hear this chord">
|
|
305
|
+
${formatTime(entry.timestamp)}
|
|
306
|
+
<span class="chord-icon">\uD83C\uDFB5</span>
|
|
307
|
+
</div>
|
|
308
|
+
<div class="desc">${escapeHtml(entry.description)}</div>
|
|
309
|
+
${entry.context ? `<div class="context">${escapeHtml(formatContext(entry.context))}</div>` : ''}
|
|
310
|
+
</div>
|
|
311
|
+
<span class="note-badge ${entry.result ? 'pass' : 'fail'}" title="Musical note: ${noteInfo.noteName}">\u266A${noteInfo.noteName}</span>
|
|
312
|
+
</div>
|
|
313
|
+
`;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Render a chord tooltip showing all assertions that fired together
|
|
317
|
+
*/
|
|
318
|
+
export function renderChordTooltip(assertions) {
|
|
319
|
+
if (assertions.length === 0)
|
|
320
|
+
return '';
|
|
321
|
+
const passes = assertions.filter(a => a.result).length;
|
|
322
|
+
const fails = assertions.length - passes;
|
|
323
|
+
return `
|
|
324
|
+
<div class="chord-tooltip">
|
|
325
|
+
<div class="chord-header">
|
|
326
|
+
<span class="chord-title">\uD83C\uDFB5 Chord (${assertions.length} note${assertions.length === 1 ? '' : 's'})</span>
|
|
327
|
+
<span class="chord-stats">
|
|
328
|
+
${passes > 0 ? `<span class="pass">\u2713${passes}</span>` : ''}
|
|
329
|
+
${fails > 0 ? `<span class="fail">\u2717${fails}</span>` : ''}
|
|
330
|
+
</span>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="chord-notes">
|
|
333
|
+
${assertions.map(a => {
|
|
334
|
+
const noteInfo = getNoteInfo(a.description);
|
|
335
|
+
return `
|
|
336
|
+
<div class="chord-note ${a.result ? 'pass' : 'fail'}">
|
|
337
|
+
<span class="note-indicator">\u266A${noteInfo.noteName}</span>
|
|
338
|
+
<span class="note-desc">${escapeHtml(a.description)}</span>
|
|
339
|
+
<span class="note-status">${a.result ? '\u2713' : '\u2717'}</span>
|
|
340
|
+
</div>
|
|
341
|
+
`;
|
|
342
|
+
}).join('')}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
`;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Render the sequence view header showing the location being tracked
|
|
349
|
+
*/
|
|
350
|
+
export function renderSequenceHeader(group) {
|
|
351
|
+
const locJson = escapeHtmlAttr(JSON.stringify(group.location));
|
|
352
|
+
const passCount = group.entries.filter(e => e.result).length;
|
|
353
|
+
const failCount = group.entries.filter(e => !e.result).length;
|
|
354
|
+
const noteInfo = getNoteInfo(group.description);
|
|
355
|
+
// Determine status: flaky (has both), fail (only fails), or pass
|
|
356
|
+
const isFlaky = passCount > 0 && failCount > 0;
|
|
357
|
+
const lastFailed = !group.lastResult;
|
|
358
|
+
const statusClass = lastFailed ? 'fail' : isFlaky ? 'warn' : 'pass';
|
|
359
|
+
// State icon like in location rows
|
|
360
|
+
const stateIcon = lastFailed
|
|
361
|
+
? '<span class="state-icon fail">\u2717</span>'
|
|
362
|
+
: isFlaky
|
|
363
|
+
? '<span class="state-icon warn">\u26A0</span>'
|
|
364
|
+
: '<span class="state-icon pass">\u2713</span>';
|
|
365
|
+
// Compute flaky and resolution stats
|
|
366
|
+
const flakyStats = computeFlakyStats(group.description);
|
|
367
|
+
const resolutionStats = computeResolutionStats(group.description);
|
|
368
|
+
// Build detailed flaky info lines
|
|
369
|
+
let flakyStatusLine = '';
|
|
370
|
+
let resolutionLine = '';
|
|
371
|
+
if (flakyStats && flakyStats.isCurrentlyFailing) {
|
|
372
|
+
flakyStatusLine = formatFlakyStatus(flakyStats);
|
|
373
|
+
}
|
|
374
|
+
if (resolutionStats && resolutionStats.resolvedFailures.length > 0) {
|
|
375
|
+
resolutionLine = formatResolutionSummary(resolutionStats);
|
|
376
|
+
}
|
|
377
|
+
return `
|
|
378
|
+
<div class="sequence-header ${statusClass}">
|
|
379
|
+
${stateIcon}
|
|
380
|
+
<div class="sequence-info">
|
|
381
|
+
<div class="sequence-location">
|
|
382
|
+
<span class="sequence-file" data-action="openInEditorFullscreen" data-location="${locJson}">${escapeHtml(formatLocation(group.location))}</span>
|
|
383
|
+
</div>
|
|
384
|
+
<div class="sequence-desc">${escapeHtml(group.description)}</div>
|
|
385
|
+
<div class="sequence-summary">
|
|
386
|
+
<span class="sequence-total">${group.entries.length} run${group.entries.length === 1 ? '' : 's'}</span>
|
|
387
|
+
<div class="sequence-stats">
|
|
388
|
+
${passCount > 0 ? `<span class="stat pass">\u2713 ${passCount}</span>` : ''}
|
|
389
|
+
${failCount > 0 ? `<span class="stat fail">\u2717 ${failCount}</span>` : ''}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
${flakyStatusLine ? `<div class="flaky-status">${escapeHtml(flakyStatusLine)}</div>` : ''}
|
|
393
|
+
${resolutionLine ? `<div class="resolution-info">${escapeHtml(resolutionLine)}</div>` : ''}
|
|
394
|
+
</div>
|
|
395
|
+
<span class="note-badge clickable ${statusClass}" data-description="${escapeHtml(group.description)}" data-result="${group.lastResult}" title="Click to hear this note">\u266A${noteInfo.noteName}</span>
|
|
396
|
+
</div>
|
|
397
|
+
`;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Render the back button for sequence view
|
|
401
|
+
*/
|
|
402
|
+
export function renderBackButton(fromView = 'byLocation') {
|
|
403
|
+
const label = fromView === 'grouped' ? 'Back to timeline' : 'Back to all locations';
|
|
404
|
+
return `<div class="back-btn" data-action="backToLocationView">\u2190 ${label}</div>`;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Shorten a file path for display: strip everything up to and including /src/,
|
|
408
|
+
* or everything before the last three segments.
|
|
409
|
+
*/
|
|
410
|
+
function shortenPath(file) {
|
|
411
|
+
const srcIdx = file.indexOf('/src/');
|
|
412
|
+
if (srcIdx !== -1)
|
|
413
|
+
return file.slice(srcIdx + 1); // "src/..."
|
|
414
|
+
// Fallback: last 3 path segments
|
|
415
|
+
const parts = file.split('/');
|
|
416
|
+
return parts.slice(Math.max(0, parts.length - 3)).join('/');
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Build a file tree from location groups.
|
|
420
|
+
*
|
|
421
|
+
* Groups assertions by their `location.file`, constructs a trie from the path
|
|
422
|
+
* segments, and compacts single-child directory chains (e.g. "src/components").
|
|
423
|
+
*/
|
|
424
|
+
export function buildFileTree(locations) {
|
|
425
|
+
// Group by file path
|
|
426
|
+
const byFile = new Map();
|
|
427
|
+
for (const loc of locations) {
|
|
428
|
+
const file = loc.location ? shortenPath(loc.location.file) : '(unknown)';
|
|
429
|
+
let group = byFile.get(file);
|
|
430
|
+
if (!group) {
|
|
431
|
+
group = [];
|
|
432
|
+
byFile.set(file, group);
|
|
433
|
+
}
|
|
434
|
+
group.push(loc);
|
|
435
|
+
}
|
|
436
|
+
const root = { name: '', path: '', children: new Map(), assertions: [] };
|
|
437
|
+
for (const [filePath, locs] of byFile) {
|
|
438
|
+
const parts = filePath.split('/');
|
|
439
|
+
let current = root;
|
|
440
|
+
let pathSoFar = '';
|
|
441
|
+
for (let i = 0; i < parts.length; i++) {
|
|
442
|
+
const part = parts[i];
|
|
443
|
+
pathSoFar = pathSoFar ? pathSoFar + '/' + part : part;
|
|
444
|
+
let child = current.children.get(part);
|
|
445
|
+
if (!child) {
|
|
446
|
+
child = { name: part, path: pathSoFar, children: new Map(), assertions: [] };
|
|
447
|
+
current.children.set(part, child);
|
|
448
|
+
}
|
|
449
|
+
current = child;
|
|
450
|
+
}
|
|
451
|
+
// Leaf node: attach assertions
|
|
452
|
+
current.assertions = locs;
|
|
453
|
+
}
|
|
454
|
+
// Convert trie to FileTreeNode array, compacting single-child dirs
|
|
455
|
+
function convert(trie) {
|
|
456
|
+
const isFile = trie.children.size === 0;
|
|
457
|
+
const children = [];
|
|
458
|
+
for (const child of trie.children.values()) {
|
|
459
|
+
children.push(convert(child));
|
|
460
|
+
}
|
|
461
|
+
// Sort: directories first (alphabetical), then files (alphabetical)
|
|
462
|
+
children.sort((a, b) => {
|
|
463
|
+
if (a.isFile !== b.isFile)
|
|
464
|
+
return a.isFile ? 1 : -1;
|
|
465
|
+
return a.name.localeCompare(b.name);
|
|
466
|
+
});
|
|
467
|
+
// Compute aggregate stats
|
|
468
|
+
let totalAssertions = 0;
|
|
469
|
+
let totalFailures = 0;
|
|
470
|
+
let lastTimestamp = 0;
|
|
471
|
+
if (isFile) {
|
|
472
|
+
for (const loc of trie.assertions) {
|
|
473
|
+
totalAssertions += loc.entries.length;
|
|
474
|
+
totalFailures += loc.entries.filter(e => !e.result).length;
|
|
475
|
+
if (loc.lastTimestamp > lastTimestamp)
|
|
476
|
+
lastTimestamp = loc.lastTimestamp;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
for (const child of children) {
|
|
481
|
+
totalAssertions += child.totalAssertions;
|
|
482
|
+
totalFailures += child.totalFailures;
|
|
483
|
+
if (child.lastTimestamp > lastTimestamp)
|
|
484
|
+
lastTimestamp = child.lastTimestamp;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const hasAnyFails = totalFailures > 0;
|
|
488
|
+
// Determine if any assertion is currently failing
|
|
489
|
+
const anyCurrentFail = isFile
|
|
490
|
+
? trie.assertions.some(a => !a.lastResult)
|
|
491
|
+
: children.some(c => c.status === 'fail');
|
|
492
|
+
const status = anyCurrentFail ? 'fail' : hasAnyFails ? 'warn' : 'pass';
|
|
493
|
+
return {
|
|
494
|
+
name: trie.name,
|
|
495
|
+
path: trie.path,
|
|
496
|
+
isFile,
|
|
497
|
+
children,
|
|
498
|
+
assertions: trie.assertions,
|
|
499
|
+
status,
|
|
500
|
+
lastTimestamp,
|
|
501
|
+
totalAssertions,
|
|
502
|
+
totalFailures,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
// Convert root's children (skip the empty root node)
|
|
506
|
+
const topLevel = [];
|
|
507
|
+
for (const child of root.children.values()) {
|
|
508
|
+
topLevel.push(convert(child));
|
|
509
|
+
}
|
|
510
|
+
// Compact: if a directory has a single child directory, merge names
|
|
511
|
+
function compact(node) {
|
|
512
|
+
if (!node.isFile && node.children.length === 1 && !node.children[0].isFile) {
|
|
513
|
+
const child = node.children[0];
|
|
514
|
+
return compact({
|
|
515
|
+
...child,
|
|
516
|
+
name: node.name + '/' + child.name,
|
|
517
|
+
// Keep the child's path since it's the full path
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
...node,
|
|
522
|
+
children: node.children.map(compact),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return topLevel.map(compact).sort((a, b) => {
|
|
526
|
+
if (a.isFile !== b.isFile)
|
|
527
|
+
return a.isFile ? 1 : -1;
|
|
528
|
+
return a.name.localeCompare(b.name);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Render the full file tree view.
|
|
533
|
+
*/
|
|
534
|
+
export function renderFileTree(tree) {
|
|
535
|
+
if (tree.length === 0)
|
|
536
|
+
return '';
|
|
537
|
+
// Compute totals for header
|
|
538
|
+
let totalFiles = 0;
|
|
539
|
+
let totalAssertions = 0;
|
|
540
|
+
function countFiles(nodes) {
|
|
541
|
+
for (const n of nodes) {
|
|
542
|
+
if (n.isFile) {
|
|
543
|
+
totalFiles++;
|
|
544
|
+
totalAssertions += n.totalAssertions;
|
|
545
|
+
}
|
|
546
|
+
else
|
|
547
|
+
countFiles(n.children);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
countFiles(tree);
|
|
551
|
+
return `
|
|
552
|
+
<div class="file-tree">
|
|
553
|
+
<div class="file-tree-header">
|
|
554
|
+
<span class="file-tree-title">\uD83D\uDCC2 File Tree</span>
|
|
555
|
+
<span class="file-tree-hint">${totalFiles} file${totalFiles === 1 ? '' : 's'}, ${totalAssertions} assertion${totalAssertions === 1 ? '' : 's'}</span>
|
|
556
|
+
</div>
|
|
557
|
+
<div class="file-tree-content">
|
|
558
|
+
${tree.map(node => renderFileTreeNode(node, 0)).join('')}
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Render a single file tree node (directory or file) recursively.
|
|
565
|
+
*/
|
|
566
|
+
function renderFileTreeNode(node, depth) {
|
|
567
|
+
const now = Date.now();
|
|
568
|
+
const isActive = now - node.lastTimestamp < FILE_TREE_GLOW_MS;
|
|
569
|
+
const isCollapsed = fileTreeCollapsed.has(node.path);
|
|
570
|
+
const pathJson = escapeHtmlAttr(JSON.stringify(node.path));
|
|
571
|
+
if (node.isFile) {
|
|
572
|
+
// File node with assertions underneath
|
|
573
|
+
const fileStatusIcon = node.status === 'fail'
|
|
574
|
+
? '<span class="ft-status-icon fail">\u2717</span>'
|
|
575
|
+
: node.status === 'warn'
|
|
576
|
+
? '<span class="ft-status-icon warn">\u26A0</span>'
|
|
577
|
+
: '<span class="ft-status-icon pass">\u2713</span>';
|
|
578
|
+
const assertionCount = node.assertions.reduce((sum, a) => sum + a.entries.length, 0);
|
|
579
|
+
return `
|
|
580
|
+
<div class="ft-node ft-file ${node.status}${isActive ? ' ft-active' : ''}${isCollapsed ? ' ft-collapsed' : ''}"
|
|
581
|
+
style="--depth: ${depth}">
|
|
582
|
+
<div class="ft-file-header" data-action="toggleFileTree" data-tree-path="${pathJson}">
|
|
583
|
+
<span class="ft-indent" style="width: ${depth * 16}px"></span>
|
|
584
|
+
<span class="ft-toggle">${isCollapsed ? '\u25B6' : '\u25BC'}</span>
|
|
585
|
+
<span class="ft-file-icon">\uD83D\uDCC4</span>
|
|
586
|
+
${fileStatusIcon}
|
|
587
|
+
<span class="ft-name">${escapeHtml(node.name)}</span>
|
|
588
|
+
<span class="ft-badge">${assertionCount}</span>
|
|
589
|
+
</div>
|
|
590
|
+
${!isCollapsed ? `
|
|
591
|
+
<div class="ft-assertions">
|
|
592
|
+
${node.assertions
|
|
593
|
+
.sort((a, b) => b.lastTimestamp - a.lastTimestamp)
|
|
594
|
+
.map(loc => renderFileTreeAssertion(loc, depth + 1))
|
|
595
|
+
.join('')}
|
|
596
|
+
</div>
|
|
597
|
+
` : ''}
|
|
598
|
+
</div>
|
|
599
|
+
`;
|
|
600
|
+
}
|
|
601
|
+
// Directory node
|
|
602
|
+
const dirStatusIcon = node.status === 'fail'
|
|
603
|
+
? '<span class="ft-status-icon fail">\u2717</span>'
|
|
604
|
+
: node.status === 'warn'
|
|
605
|
+
? '<span class="ft-status-icon warn">\u26A0</span>'
|
|
606
|
+
: '<span class="ft-status-icon pass">\u2713</span>';
|
|
607
|
+
return `
|
|
608
|
+
<div class="ft-node ft-dir ${node.status}${isActive ? ' ft-active' : ''}${isCollapsed ? ' ft-collapsed' : ''}"
|
|
609
|
+
style="--depth: ${depth}">
|
|
610
|
+
<div class="ft-dir-header" data-action="toggleFileTree" data-tree-path="${pathJson}">
|
|
611
|
+
<span class="ft-indent" style="width: ${depth * 16}px"></span>
|
|
612
|
+
<span class="ft-toggle">${isCollapsed ? '\u25B6' : '\u25BC'}</span>
|
|
613
|
+
<span class="ft-dir-icon">${isCollapsed ? '\uD83D\uDCC1' : '\uD83D\uDCC2'}</span>
|
|
614
|
+
${dirStatusIcon}
|
|
615
|
+
<span class="ft-name">${escapeHtml(node.name)}</span>
|
|
616
|
+
<span class="ft-badge">${node.totalAssertions}</span>
|
|
617
|
+
${node.totalFailures > 0 ? `<span class="ft-fail-badge">${node.totalFailures} \u2717</span>` : ''}
|
|
618
|
+
</div>
|
|
619
|
+
${!isCollapsed ? `
|
|
620
|
+
<div class="ft-children">
|
|
621
|
+
${node.children.map(child => renderFileTreeNode(child, depth + 1)).join('')}
|
|
622
|
+
</div>
|
|
623
|
+
` : ''}
|
|
624
|
+
</div>
|
|
625
|
+
`;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Render a single assertion row inside a file tree file node.
|
|
629
|
+
*/
|
|
630
|
+
function renderFileTreeAssertion(group, depth) {
|
|
631
|
+
const now = Date.now();
|
|
632
|
+
const isActive = now - group.lastTimestamp < FILE_TREE_GLOW_MS;
|
|
633
|
+
const keyJson = escapeHtmlAttr(JSON.stringify(group.key));
|
|
634
|
+
const lastFailed = !group.lastResult;
|
|
635
|
+
const hasAnyFails = group.entries.some(e => !e.result);
|
|
636
|
+
const statusClass = lastFailed ? 'fail' : hasAnyFails ? 'warn' : 'pass';
|
|
637
|
+
const total = group.entries.length;
|
|
638
|
+
const line = group.location?.line;
|
|
639
|
+
const statusIcon = lastFailed
|
|
640
|
+
? '<span class="ft-assert-icon fail">\u2717</span>'
|
|
641
|
+
: hasAnyFails
|
|
642
|
+
? '<span class="ft-assert-icon warn">\u26A0</span>'
|
|
643
|
+
: '<span class="ft-assert-icon pass">\u2713</span>';
|
|
644
|
+
// Recent status dots (last 5)
|
|
645
|
+
const recentEntries = group.entries.slice(-5);
|
|
646
|
+
const dots = recentEntries
|
|
647
|
+
.map(e => `<span class="ft-dot ${e.result ? 'pass' : 'fail'}"></span>`)
|
|
648
|
+
.join('');
|
|
649
|
+
return `
|
|
650
|
+
<div class="ft-assertion ${statusClass}${isActive ? ' ft-active' : ''}"
|
|
651
|
+
data-action="showSequence" data-key="${keyJson}"
|
|
652
|
+
style="--depth: ${depth}">
|
|
653
|
+
<span class="ft-indent" style="width: ${depth * 16}px"></span>
|
|
654
|
+
${statusIcon}
|
|
655
|
+
<span class="ft-assert-desc">${escapeHtml(group.description)}</span>
|
|
656
|
+
${line ? `<span class="ft-assert-line">:${line}</span>` : ''}
|
|
657
|
+
<span class="ft-dots">${dots}</span>
|
|
658
|
+
<span class="ft-assert-count">${total}x</span>
|
|
659
|
+
</div>
|
|
660
|
+
`;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Attach event listeners to rendered elements using event delegation.
|
|
664
|
+
* Call this after rendering HTML to a container.
|
|
665
|
+
*
|
|
666
|
+
* @param container - The container element (or document)
|
|
667
|
+
* @param handlers - Object with handler functions
|
|
668
|
+
*/
|
|
669
|
+
export function attachEventListeners(container, handlers) {
|
|
670
|
+
const root = container instanceof Document ? container.body : container;
|
|
671
|
+
root.addEventListener('click', (e) => {
|
|
672
|
+
const target = e.target;
|
|
673
|
+
// Find the element with the data-action attribute
|
|
674
|
+
const actionEl = target.closest('[data-action]');
|
|
675
|
+
if (!actionEl)
|
|
676
|
+
return;
|
|
677
|
+
const action = actionEl.dataset.action;
|
|
678
|
+
switch (action) {
|
|
679
|
+
case 'openFullscreenToGroup': {
|
|
680
|
+
const groupId = parseInt(actionEl.dataset.groupId || '', 10);
|
|
681
|
+
if (!isNaN(groupId) && handlers.openFullscreenToGroup) {
|
|
682
|
+
handlers.openFullscreenToGroup(groupId);
|
|
683
|
+
}
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
case 'openInEditor': {
|
|
687
|
+
const locStr = actionEl.dataset.location;
|
|
688
|
+
if (locStr && locStr !== 'null' && handlers.openInEditor) {
|
|
689
|
+
try {
|
|
690
|
+
const location = JSON.parse(locStr);
|
|
691
|
+
handlers.openInEditor(location);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
// Invalid JSON, ignore
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
case 'openInEditorFullscreen': {
|
|
700
|
+
e.stopPropagation();
|
|
701
|
+
const locStr = actionEl.dataset.location;
|
|
702
|
+
if (locStr && locStr !== 'null' && handlers.openInEditorFullscreen) {
|
|
703
|
+
try {
|
|
704
|
+
const location = JSON.parse(locStr);
|
|
705
|
+
handlers.openInEditorFullscreen(location);
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
// Invalid JSON, ignore
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
case 'showSequence': {
|
|
714
|
+
const key = actionEl.dataset.key;
|
|
715
|
+
if (key && handlers.showSequence) {
|
|
716
|
+
try {
|
|
717
|
+
const keyParsed = JSON.parse(key);
|
|
718
|
+
handlers.showSequence(keyParsed);
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// Invalid JSON, ignore
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case 'backToLocationView': {
|
|
727
|
+
if (handlers.backToLocationView) {
|
|
728
|
+
handlers.backToLocationView();
|
|
729
|
+
}
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
case 'toggleCollapsed': {
|
|
733
|
+
const parent = actionEl.parentElement;
|
|
734
|
+
if (parent) {
|
|
735
|
+
parent.classList.toggle('collapsed');
|
|
736
|
+
// Also update the state if a callback is provided
|
|
737
|
+
const groupId = parseInt(parent.dataset.groupId || '', 10);
|
|
738
|
+
if (!isNaN(groupId) && handlers.toggleCollapsed) {
|
|
739
|
+
handlers.toggleCollapsed(groupId);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
case 'toggleFileTree': {
|
|
745
|
+
const treePath = actionEl.dataset.treePath;
|
|
746
|
+
if (treePath && handlers.toggleFileTree) {
|
|
747
|
+
try {
|
|
748
|
+
const pathParsed = JSON.parse(treePath);
|
|
749
|
+
handlers.toggleFileTree(pathParsed);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// Invalid JSON, ignore
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=render.js.map
|