@in-the-loop-labs/pair-review 3.1.4 → 3.2.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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +894 -1
- package/public/js/components/CouncilProgressModal.js +8 -0
- package/public/js/components/NotificationDropdown.js +257 -0
- package/public/js/components/StackAnalysisDialog.js +313 -0
- package/public/js/components/StackProgressModal.js +475 -0
- package/public/js/components/StatusIndicator.js +1 -0
- package/public/js/pr.js +420 -2
- package/public/js/utils/notification-sounds.js +62 -0
- package/public/local.html +10 -0
- package/public/pr.html +12 -0
- package/public/setup.html +4 -0
- package/src/git/base-branch.js +1 -51
- package/src/git/worktree-lock.js +88 -0
- package/src/git/worktree.js +64 -0
- package/src/github/stack-walker.js +196 -0
- package/src/routes/local.js +12 -8
- package/src/routes/pr.js +139 -26
- package/src/routes/sound.js +49 -0
- package/src/routes/stack-analysis.js +886 -0
- package/src/server.js +4 -0
- package/src/setup/stack-setup.js +77 -0
|
@@ -55,6 +55,7 @@ class CouncilProgressModal {
|
|
|
55
55
|
this.councilConfig = councilConfig;
|
|
56
56
|
this.isVisible = true;
|
|
57
57
|
this._voiceStates = {};
|
|
58
|
+
this._completionHandled = false;
|
|
58
59
|
|
|
59
60
|
// Detect rendering mode
|
|
60
61
|
const configType = options.configType || (councilConfig ? 'advanced' : 'single');
|
|
@@ -903,6 +904,13 @@ class CouncilProgressModal {
|
|
|
903
904
|
// ---------------------------------------------------------------------------
|
|
904
905
|
|
|
905
906
|
_handleCompletion(status) {
|
|
907
|
+
// Guard against duplicate invocations (e.g. WebSocket retry + reconnect fetch race)
|
|
908
|
+
if (this._completionHandled) return;
|
|
909
|
+
this._completionHandled = true;
|
|
910
|
+
|
|
911
|
+
// Play notification sound before any async work so it fires promptly
|
|
912
|
+
if (window.notificationSounds) window.notificationSounds.playIfEnabled('analysis');
|
|
913
|
+
|
|
906
914
|
if (this._renderMode === 'council') {
|
|
907
915
|
// Voice-centric: mark all voice-level children as complete
|
|
908
916
|
const vcEls = this.modal.querySelectorAll('[data-vc-voice][data-vc-level]');
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* NotificationDropdown - Bell-icon popover for notification sound preferences.
|
|
4
|
+
*
|
|
5
|
+
* Anchors a small dropdown below the bell button with checkbox toggles that
|
|
6
|
+
* control which events trigger a notification chime. Supports configurable
|
|
7
|
+
* event types (e.g. 'analysis', 'setup') and a "Test sound" link.
|
|
8
|
+
*
|
|
9
|
+
* Follows the same popover pattern as DiffOptionsDropdown (fixed positioning
|
|
10
|
+
* via getBoundingClientRect, click-outside and Escape to dismiss,
|
|
11
|
+
* opacity+transform animation).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* const dropdown = new NotificationDropdown(
|
|
15
|
+
* document.getElementById('notification-btn'),
|
|
16
|
+
* { events: ['analysis', 'setup'] }
|
|
17
|
+
* );
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const EVENT_LABELS = {
|
|
21
|
+
'analysis': 'Analysis complete',
|
|
22
|
+
'setup': 'Setup complete'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
class NotificationDropdown {
|
|
26
|
+
/**
|
|
27
|
+
* @param {HTMLElement} buttonElement - The bell icon button already in the DOM
|
|
28
|
+
* @param {Object} options
|
|
29
|
+
* @param {string[]} options.events - Event type strings to show toggles for
|
|
30
|
+
*/
|
|
31
|
+
constructor(buttonElement, { events }) {
|
|
32
|
+
this._btn = buttonElement;
|
|
33
|
+
this._events = events || [];
|
|
34
|
+
|
|
35
|
+
this._popoverEl = null;
|
|
36
|
+
this._checkboxes = {};
|
|
37
|
+
this._visible = false;
|
|
38
|
+
this._outsideClickHandler = null;
|
|
39
|
+
this._escapeHandler = null;
|
|
40
|
+
|
|
41
|
+
this._renderPopover();
|
|
42
|
+
this._syncButtonActive();
|
|
43
|
+
|
|
44
|
+
// Toggle popover on button click
|
|
45
|
+
this._btnClickHandler = (e) => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
if (this._visible) {
|
|
48
|
+
this._hide();
|
|
49
|
+
} else {
|
|
50
|
+
this._show();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
this._btn.addEventListener('click', this._btnClickHandler);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Public API
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/** Remove all DOM elements and event listeners. Safe to call multiple times. */
|
|
61
|
+
destroy() {
|
|
62
|
+
this._hide();
|
|
63
|
+
if (this._popoverEl) {
|
|
64
|
+
this._popoverEl.remove();
|
|
65
|
+
this._popoverEl = null;
|
|
66
|
+
}
|
|
67
|
+
if (this._btn && this._btnClickHandler) {
|
|
68
|
+
this._btn.removeEventListener('click', this._btnClickHandler);
|
|
69
|
+
this._btnClickHandler = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// DOM construction
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
_renderPopover() {
|
|
78
|
+
const popover = document.createElement('div');
|
|
79
|
+
popover.className = 'notification-popover';
|
|
80
|
+
// Start hidden (opacity 0, shifted up)
|
|
81
|
+
popover.style.opacity = '0';
|
|
82
|
+
popover.style.transform = 'translateY(-4px)';
|
|
83
|
+
popover.style.pointerEvents = 'none';
|
|
84
|
+
popover.style.position = 'fixed';
|
|
85
|
+
popover.style.zIndex = '1100';
|
|
86
|
+
popover.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
|
|
87
|
+
|
|
88
|
+
// --- Event checkboxes ---
|
|
89
|
+
this._events.forEach((eventType) => {
|
|
90
|
+
const labelText = EVENT_LABELS[eventType] || eventType;
|
|
91
|
+
const enabled = window.notificationSounds
|
|
92
|
+
? window.notificationSounds.isEnabled(eventType)
|
|
93
|
+
: false;
|
|
94
|
+
|
|
95
|
+
const label = this._createCheckboxLabel(labelText, enabled);
|
|
96
|
+
const checkbox = label.querySelector('input');
|
|
97
|
+
popover.appendChild(label);
|
|
98
|
+
|
|
99
|
+
this._checkboxes[eventType] = checkbox;
|
|
100
|
+
|
|
101
|
+
checkbox.addEventListener('change', () => {
|
|
102
|
+
if (window.notificationSounds) {
|
|
103
|
+
window.notificationSounds.setEnabled(eventType, checkbox.checked);
|
|
104
|
+
}
|
|
105
|
+
this._syncButtonActive();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- Divider ---
|
|
110
|
+
const divider = document.createElement('div');
|
|
111
|
+
divider.style.height = '1px';
|
|
112
|
+
divider.style.background = 'var(--color-border-primary, #d0d7de)';
|
|
113
|
+
divider.style.margin = '0';
|
|
114
|
+
popover.appendChild(divider);
|
|
115
|
+
|
|
116
|
+
// --- Test sound link ---
|
|
117
|
+
const testLink = document.createElement('div');
|
|
118
|
+
testLink.textContent = 'Test sound';
|
|
119
|
+
testLink.style.padding = '8px 12px';
|
|
120
|
+
testLink.style.fontSize = '0.8125rem';
|
|
121
|
+
testLink.style.color = 'var(--color-accent-primary)';
|
|
122
|
+
testLink.style.cursor = 'pointer';
|
|
123
|
+
testLink.style.textDecoration = 'none';
|
|
124
|
+
testLink.style.userSelect = 'none';
|
|
125
|
+
|
|
126
|
+
testLink.addEventListener('mouseenter', () => {
|
|
127
|
+
testLink.style.textDecoration = 'underline';
|
|
128
|
+
});
|
|
129
|
+
testLink.addEventListener('mouseleave', () => {
|
|
130
|
+
testLink.style.textDecoration = 'none';
|
|
131
|
+
});
|
|
132
|
+
testLink.addEventListener('click', (e) => {
|
|
133
|
+
e.stopPropagation();
|
|
134
|
+
if (window.notificationSounds) {
|
|
135
|
+
window.notificationSounds.playChime();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
popover.appendChild(testLink);
|
|
140
|
+
|
|
141
|
+
document.body.appendChild(popover);
|
|
142
|
+
this._popoverEl = popover;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create a label element wrapping a checkbox.
|
|
147
|
+
* @param {string} text - Label text
|
|
148
|
+
* @param {boolean} checked - Initial checked state
|
|
149
|
+
* @returns {HTMLLabelElement}
|
|
150
|
+
*/
|
|
151
|
+
_createCheckboxLabel(text, checked) {
|
|
152
|
+
const label = document.createElement('label');
|
|
153
|
+
label.style.display = 'flex';
|
|
154
|
+
label.style.alignItems = 'center';
|
|
155
|
+
label.style.gap = '8px';
|
|
156
|
+
label.style.cursor = 'pointer';
|
|
157
|
+
label.style.fontSize = '0.8125rem';
|
|
158
|
+
label.style.whiteSpace = 'nowrap';
|
|
159
|
+
label.style.padding = '8px 12px';
|
|
160
|
+
label.style.color = 'var(--color-text-primary, #24292f)';
|
|
161
|
+
label.style.userSelect = 'none';
|
|
162
|
+
|
|
163
|
+
const checkbox = document.createElement('input');
|
|
164
|
+
checkbox.type = 'checkbox';
|
|
165
|
+
checkbox.checked = checked;
|
|
166
|
+
checkbox.style.margin = '0';
|
|
167
|
+
checkbox.style.cursor = 'pointer';
|
|
168
|
+
|
|
169
|
+
label.appendChild(checkbox);
|
|
170
|
+
label.appendChild(document.createTextNode(text));
|
|
171
|
+
return label;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Show / Hide (mirrors DiffOptionsDropdown pattern)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
_show() {
|
|
179
|
+
if (!this._popoverEl || !this._btn) return;
|
|
180
|
+
|
|
181
|
+
// Position below the button
|
|
182
|
+
const rect = this._btn.getBoundingClientRect();
|
|
183
|
+
this._popoverEl.style.top = `${rect.bottom + 4}px`;
|
|
184
|
+
this._popoverEl.style.left = `${rect.left + rect.width / 2}px`;
|
|
185
|
+
this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
|
|
186
|
+
|
|
187
|
+
// Make visible
|
|
188
|
+
this._popoverEl.style.opacity = '1';
|
|
189
|
+
this._popoverEl.style.pointerEvents = 'auto';
|
|
190
|
+
this._visible = true;
|
|
191
|
+
|
|
192
|
+
// Animate into final position
|
|
193
|
+
requestAnimationFrame(() => {
|
|
194
|
+
if (this._popoverEl) {
|
|
195
|
+
this._popoverEl.style.transform = 'translateX(-50%) translateY(0)';
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Click-outside-to-close
|
|
200
|
+
this._outsideClickHandler = (e) => {
|
|
201
|
+
if (!this._popoverEl.contains(e.target) && !this._btn.contains(e.target)) {
|
|
202
|
+
this._hide();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
document.addEventListener('click', this._outsideClickHandler, true);
|
|
206
|
+
|
|
207
|
+
// Escape to dismiss
|
|
208
|
+
this._escapeHandler = (e) => {
|
|
209
|
+
if (e.key === 'Escape') {
|
|
210
|
+
this._hide();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
document.addEventListener('keydown', this._escapeHandler, true);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_hide() {
|
|
217
|
+
if (!this._popoverEl) return;
|
|
218
|
+
|
|
219
|
+
this._popoverEl.style.opacity = '0';
|
|
220
|
+
this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
|
|
221
|
+
this._popoverEl.style.pointerEvents = 'none';
|
|
222
|
+
this._visible = false;
|
|
223
|
+
|
|
224
|
+
if (this._outsideClickHandler) {
|
|
225
|
+
document.removeEventListener('click', this._outsideClickHandler, true);
|
|
226
|
+
this._outsideClickHandler = null;
|
|
227
|
+
}
|
|
228
|
+
if (this._escapeHandler) {
|
|
229
|
+
document.removeEventListener('keydown', this._escapeHandler, true);
|
|
230
|
+
this._escapeHandler = null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Helpers
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/** Swap bell icon visibility when any notification is enabled. */
|
|
239
|
+
_syncButtonActive() {
|
|
240
|
+
if (!this._btn) return;
|
|
241
|
+
const anyEnabled = this._events.some((eventType) => {
|
|
242
|
+
return window.notificationSounds
|
|
243
|
+
? window.notificationSounds.isEnabled(eventType)
|
|
244
|
+
: false;
|
|
245
|
+
});
|
|
246
|
+
const onIcon = this._btn.querySelector('.bell-icon-on');
|
|
247
|
+
const offIcon = this._btn.querySelector('.bell-icon-off');
|
|
248
|
+
if (onIcon) onIcon.style.display = anyEnabled ? '' : 'none';
|
|
249
|
+
if (offIcon) offIcon.style.display = anyEnabled ? 'none' : '';
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
window.NotificationDropdown = NotificationDropdown;
|
|
254
|
+
|
|
255
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
256
|
+
module.exports = { NotificationDropdown };
|
|
257
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Stack Analysis Dialog Component
|
|
4
|
+
* Modal showing stack PRs with checkboxes for selection before analysis.
|
|
5
|
+
* Returns a Promise resolving to { selectedPRNumbers } or null if cancelled.
|
|
6
|
+
*/
|
|
7
|
+
class StackAnalysisDialog {
|
|
8
|
+
constructor(container) {
|
|
9
|
+
this.container = container || document.body;
|
|
10
|
+
this.overlay = null;
|
|
11
|
+
this._resolve = null;
|
|
12
|
+
this._stackData = null;
|
|
13
|
+
this._currentPRNumber = null;
|
|
14
|
+
|
|
15
|
+
// Bind methods
|
|
16
|
+
this.handleKeydown = this.handleKeydown.bind(this);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Open the dialog, fetch stack info, and let the user select PRs.
|
|
21
|
+
* @param {string} owner - Repository owner
|
|
22
|
+
* @param {string} repo - Repository name
|
|
23
|
+
* @param {number} number - Current PR number
|
|
24
|
+
* @returns {Promise<{selectedPRNumbers: number[]}|null>} Selected PR numbers or null if cancelled
|
|
25
|
+
*/
|
|
26
|
+
open(owner, repo, number) {
|
|
27
|
+
this._currentPRNumber = number;
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
this._resolve = resolve;
|
|
31
|
+
this._createOverlay();
|
|
32
|
+
this._showLoading();
|
|
33
|
+
this._attachListeners();
|
|
34
|
+
this._fetchStackInfo(owner, repo, number);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// DOM creation
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
_createOverlay() {
|
|
43
|
+
// Remove any existing overlay
|
|
44
|
+
this._removeOverlay();
|
|
45
|
+
|
|
46
|
+
const overlay = document.createElement('div');
|
|
47
|
+
overlay.className = 'stack-dialog-overlay';
|
|
48
|
+
|
|
49
|
+
overlay.innerHTML = `
|
|
50
|
+
<div class="stack-dialog-backdrop" data-action="cancel"></div>
|
|
51
|
+
<div class="stack-dialog">
|
|
52
|
+
<div class="stack-dialog-header">
|
|
53
|
+
<h3>Analyze Stack</h3>
|
|
54
|
+
<button class="modal-close-btn" data-action="cancel" title="Close">
|
|
55
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
56
|
+
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
|
|
57
|
+
</svg>
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="stack-dialog-body">
|
|
61
|
+
<!-- Populated dynamically -->
|
|
62
|
+
</div>
|
|
63
|
+
<div class="stack-dialog-footer">
|
|
64
|
+
<button class="btn btn-secondary" data-action="cancel">Cancel</button>
|
|
65
|
+
<button class="btn btn-primary stack-dialog-submit" data-action="submit" disabled>Configure & Analyze</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
this.container.appendChild(overlay);
|
|
71
|
+
this.overlay = overlay;
|
|
72
|
+
|
|
73
|
+
// Event delegation for clicks
|
|
74
|
+
overlay.addEventListener('click', (e) => {
|
|
75
|
+
const action = e.target.closest('[data-action]')?.dataset.action;
|
|
76
|
+
if (action === 'cancel') {
|
|
77
|
+
this._cancel();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (action === 'submit') {
|
|
81
|
+
this._submit();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (action === 'select-all') {
|
|
85
|
+
this._setAllChecked(true);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (action === 'select-none') {
|
|
89
|
+
this._setAllChecked(false);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Checkbox change via clicking the row
|
|
94
|
+
const checkbox = e.target.closest('.stack-dialog-pr-checkbox');
|
|
95
|
+
if (checkbox) {
|
|
96
|
+
// Let the native checkbox toggle, then update button state
|
|
97
|
+
setTimeout(() => this._updateSubmitButton(), 0);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Handle checkbox changes (covers keyboard toggle too)
|
|
102
|
+
overlay.addEventListener('change', () => {
|
|
103
|
+
this._updateSubmitButton();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_showLoading() {
|
|
108
|
+
const body = this.overlay?.querySelector('.stack-dialog-body');
|
|
109
|
+
if (!body) return;
|
|
110
|
+
|
|
111
|
+
body.innerHTML = `
|
|
112
|
+
<div class="stack-dialog-loading">
|
|
113
|
+
<span class="council-spinner"></span>
|
|
114
|
+
<span>Loading stack info...</span>
|
|
115
|
+
</div>
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_showError(message) {
|
|
120
|
+
const body = this.overlay?.querySelector('.stack-dialog-body');
|
|
121
|
+
if (!body) return;
|
|
122
|
+
|
|
123
|
+
body.innerHTML = `
|
|
124
|
+
<div class="stack-dialog-error">
|
|
125
|
+
<p>${this._escapeHtml(message)}</p>
|
|
126
|
+
</div>
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_renderPRList(stack) {
|
|
131
|
+
const body = this.overlay?.querySelector('.stack-dialog-body');
|
|
132
|
+
if (!body) return;
|
|
133
|
+
|
|
134
|
+
// Filter to non-trunk entries with PR numbers
|
|
135
|
+
const prs = stack.filter(entry => !entry.isTrunk && entry.prNumber);
|
|
136
|
+
if (prs.length === 0) {
|
|
137
|
+
this._showError('No PRs found in this stack.');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this._stackData = prs;
|
|
142
|
+
|
|
143
|
+
// Display PRs in reverse order: tip of stack (top) first, base (bottom) last
|
|
144
|
+
const displayPRs = [...prs].reverse();
|
|
145
|
+
|
|
146
|
+
let html = `
|
|
147
|
+
<div class="stack-dialog-controls">
|
|
148
|
+
<button class="btn btn-sm btn-secondary" data-action="select-all">Select All</button>
|
|
149
|
+
<button class="btn btn-sm btn-secondary" data-action="select-none">Select None</button>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="stack-dialog-pr-list">
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
for (const pr of displayPRs) {
|
|
155
|
+
const isCurrent = pr.prNumber === this._currentPRNumber;
|
|
156
|
+
const currentClass = isCurrent ? ' stack-dialog-pr-current' : '';
|
|
157
|
+
const currentBadge = isCurrent ? ' <span class="stack-dialog-current-badge">\u2605</span>' : '';
|
|
158
|
+
const analysisBadge = pr.hasAnalysis
|
|
159
|
+
? ' <span class="stack-dialog-analyzed-badge" title="Has existing analysis">\u2022 analyzed</span>'
|
|
160
|
+
: '';
|
|
161
|
+
|
|
162
|
+
html += `
|
|
163
|
+
<label class="stack-dialog-pr-item${currentClass}">
|
|
164
|
+
<input type="checkbox" class="stack-dialog-pr-checkbox" data-pr-number="${pr.prNumber}" checked />
|
|
165
|
+
<span class="stack-dialog-pr-number">#${pr.prNumber}</span>
|
|
166
|
+
<div class="stack-dialog-pr-info">
|
|
167
|
+
<span class="stack-dialog-pr-title-row">
|
|
168
|
+
${currentBadge}
|
|
169
|
+
<span class="stack-dialog-pr-title">${this._escapeHtml(pr.title || pr.branch || '')}</span>
|
|
170
|
+
${analysisBadge}
|
|
171
|
+
</span>
|
|
172
|
+
<span class="stack-dialog-pr-branch"><svg class="stack-dialog-branch-icon" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Z"/></svg><span class="stack-dialog-branch-name">${this._escapeHtml(pr.branch || '')}</span></span>
|
|
173
|
+
</div>
|
|
174
|
+
</label>
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
html += `
|
|
179
|
+
</div>
|
|
180
|
+
<div class="stack-dialog-note">
|
|
181
|
+
<span class="stack-dialog-note-info" title="Each PR gets its own worktree (created automatically if needed). All selected PRs are analyzed in parallel.">
|
|
182
|
+
<svg class="stack-dialog-info-icon" width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
183
|
+
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>
|
|
184
|
+
</svg>
|
|
185
|
+
Each PR gets its own worktree (created automatically if needed). All selected PRs are analyzed in parallel.
|
|
186
|
+
</span>
|
|
187
|
+
</div>
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
body.innerHTML = html;
|
|
191
|
+
|
|
192
|
+
// Enable submit button
|
|
193
|
+
this._updateSubmitButton();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Fetch
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
async _fetchStackInfo(owner, repo, number) {
|
|
201
|
+
try {
|
|
202
|
+
const response = await fetch(`/api/pr/${owner}/${repo}/${number}/stack-info`);
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
const data = await response.json().catch(() => ({}));
|
|
205
|
+
throw new Error(data.error || `Failed to fetch stack info (${response.status})`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const data = await response.json();
|
|
209
|
+
this._renderPRList(data.stack || []);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('Error fetching stack info:', error);
|
|
212
|
+
this._showError(error.message || 'Failed to load stack information.');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Actions
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
_getSelectedPRNumbers() {
|
|
221
|
+
if (!this.overlay) return [];
|
|
222
|
+
const checkboxes = this.overlay.querySelectorAll('.stack-dialog-pr-checkbox:checked');
|
|
223
|
+
return Array.from(checkboxes).map(cb => Number(cb.dataset.prNumber));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_setAllChecked(checked) {
|
|
227
|
+
if (!this.overlay) return;
|
|
228
|
+
const checkboxes = this.overlay.querySelectorAll('.stack-dialog-pr-checkbox');
|
|
229
|
+
checkboxes.forEach(cb => { cb.checked = checked; });
|
|
230
|
+
this._updateSubmitButton();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_updateSubmitButton() {
|
|
234
|
+
const submitBtn = this.overlay?.querySelector('.stack-dialog-submit');
|
|
235
|
+
if (!submitBtn) return;
|
|
236
|
+
const selected = this._getSelectedPRNumbers();
|
|
237
|
+
submitBtn.disabled = selected.length === 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_submit() {
|
|
241
|
+
const selectedPRNumbers = this._getSelectedPRNumbers();
|
|
242
|
+
if (selectedPRNumbers.length === 0) return;
|
|
243
|
+
|
|
244
|
+
// Build prList with titles for the progress modal
|
|
245
|
+
const selectedSet = new Set(selectedPRNumbers);
|
|
246
|
+
const prList = (this._stackData || [])
|
|
247
|
+
.filter(pr => selectedSet.has(pr.prNumber))
|
|
248
|
+
.map(pr => ({ prNumber: pr.prNumber, title: pr.title || pr.branch || '' }));
|
|
249
|
+
|
|
250
|
+
this._cleanup();
|
|
251
|
+
if (this._resolve) {
|
|
252
|
+
this._resolve({ selectedPRNumbers, prList });
|
|
253
|
+
this._resolve = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_cancel() {
|
|
258
|
+
this._cleanup();
|
|
259
|
+
if (this._resolve) {
|
|
260
|
+
this._resolve(null);
|
|
261
|
+
this._resolve = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Listeners & cleanup
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
_attachListeners() {
|
|
270
|
+
document.addEventListener('keydown', this.handleKeydown);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
handleKeydown(e) {
|
|
274
|
+
if (e.key === 'Escape') {
|
|
275
|
+
this._cancel();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_cleanup() {
|
|
280
|
+
document.removeEventListener('keydown', this.handleKeydown);
|
|
281
|
+
this._removeOverlay();
|
|
282
|
+
this._stackData = null;
|
|
283
|
+
this._currentPRNumber = null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_removeOverlay() {
|
|
287
|
+
if (this.overlay) {
|
|
288
|
+
this.overlay.remove();
|
|
289
|
+
this.overlay = null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Utilities
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
_escapeHtml(str) {
|
|
298
|
+
if (!str) return '';
|
|
299
|
+
const div = document.createElement('div');
|
|
300
|
+
div.textContent = str;
|
|
301
|
+
return div.innerHTML;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Export for use in other modules
|
|
306
|
+
if (typeof window !== 'undefined') {
|
|
307
|
+
window.StackAnalysisDialog = StackAnalysisDialog;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Export for Node.js/test environments
|
|
311
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
312
|
+
module.exports = { StackAnalysisDialog };
|
|
313
|
+
}
|