@in-the-loop-labs/pair-review 1.3.2 → 1.4.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/README.md +67 -38
- 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/index.html +270 -623
- package/public/js/index.js +1071 -0
- package/public/js/local.js +80 -0
- package/public/js/modules/analysis-history.js +5 -1
- package/public/local.html +45 -2
- package/src/ai/claude-provider.js +12 -7
- package/src/ai/codex-provider.js +9 -7
- package/src/ai/cursor-agent-provider.js +9 -6
- package/src/ai/gemini-provider.js +9 -7
- package/src/ai/index.js +1 -0
- package/src/ai/opencode-provider.js +9 -7
- package/src/ai/pi-provider.js +859 -0
- package/src/ai/provider.js +32 -8
- package/src/ai/stream-parser.js +171 -2
- package/src/config.js +1 -1
- package/src/database.js +170 -40
- package/src/local-review.js +9 -0
- package/src/routes/local.js +390 -41
- package/src/utils/json-extractor.js +129 -39
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Index Page Manager
|
|
4
|
+
*
|
|
5
|
+
* Handles the main index page functionality including:
|
|
6
|
+
* - Theme management (light/dark toggle)
|
|
7
|
+
* - Help modal
|
|
8
|
+
* - PR review start flow
|
|
9
|
+
* - Local review start flow
|
|
10
|
+
* - Recent reviews listing with pagination
|
|
11
|
+
* - Local review sessions listing with pagination and deletion
|
|
12
|
+
* - Tab switching (PR / Local)
|
|
13
|
+
*/
|
|
14
|
+
(function () {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
// ─── Theme Management ───────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function initTheme() {
|
|
20
|
+
const savedTheme = localStorage.getItem('theme');
|
|
21
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
22
|
+
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
|
|
23
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toggleTheme() {
|
|
27
|
+
const currentTheme = document.documentElement.getAttribute('data-theme');
|
|
28
|
+
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
29
|
+
document.documentElement.setAttribute('data-theme', newTheme);
|
|
30
|
+
localStorage.setItem('theme', newTheme);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Initialize theme on page load
|
|
34
|
+
initTheme();
|
|
35
|
+
|
|
36
|
+
// Set up theme toggle button
|
|
37
|
+
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
|
38
|
+
|
|
39
|
+
// ─── Help Modal ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function openHelpModal() {
|
|
42
|
+
const overlay = document.getElementById('help-modal-overlay');
|
|
43
|
+
overlay.classList.add('visible');
|
|
44
|
+
document.body.style.overflow = 'hidden';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function closeHelpModal() {
|
|
48
|
+
const overlay = document.getElementById('help-modal-overlay');
|
|
49
|
+
overlay.classList.remove('visible');
|
|
50
|
+
document.body.style.overflow = '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Set up help button
|
|
54
|
+
document.getElementById('help-btn').addEventListener('click', openHelpModal);
|
|
55
|
+
|
|
56
|
+
// Set up close button
|
|
57
|
+
document.getElementById('help-modal-close').addEventListener('click', closeHelpModal);
|
|
58
|
+
|
|
59
|
+
// Close on overlay click (but not modal click)
|
|
60
|
+
document.getElementById('help-modal-overlay').addEventListener('click', function (e) {
|
|
61
|
+
if (e.target === this) {
|
|
62
|
+
closeHelpModal();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Close on Escape key
|
|
67
|
+
document.addEventListener('keydown', function (e) {
|
|
68
|
+
if (e.key === 'Escape') {
|
|
69
|
+
const overlay = document.getElementById('help-modal-overlay');
|
|
70
|
+
if (overlay.classList.contains('visible')) {
|
|
71
|
+
closeHelpModal();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Listen for system theme changes
|
|
77
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
|
78
|
+
if (!localStorage.getItem('theme')) {
|
|
79
|
+
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── Shared Utilities ───────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** localStorage key for persisting the active tab */
|
|
86
|
+
const TAB_STORAGE_KEY = 'pair-review-active-tab';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a relative time string from a date
|
|
90
|
+
* @param {string} dateString - ISO date string
|
|
91
|
+
* @returns {string} Human-readable relative time
|
|
92
|
+
*/
|
|
93
|
+
function formatRelativeTime(dateString) {
|
|
94
|
+
const date = new Date(dateString);
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const diffMs = now - date;
|
|
97
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
98
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
99
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
100
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
101
|
+
|
|
102
|
+
if (diffSecs < 60) {
|
|
103
|
+
return 'Just now';
|
|
104
|
+
} else if (diffMins < 60) {
|
|
105
|
+
return diffMins + ' minute' + (diffMins !== 1 ? 's' : '') + ' ago';
|
|
106
|
+
} else if (diffHours < 24) {
|
|
107
|
+
return diffHours + ' hour' + (diffHours !== 1 ? 's' : '') + ' ago';
|
|
108
|
+
} else if (diffDays < 7) {
|
|
109
|
+
return diffDays + ' day' + (diffDays !== 1 ? 's' : '') + ' ago';
|
|
110
|
+
} else if (diffDays < 30) {
|
|
111
|
+
const weeks = Math.floor(diffDays / 7);
|
|
112
|
+
return weeks + ' week' + (weeks !== 1 ? 's' : '') + ' ago';
|
|
113
|
+
} else {
|
|
114
|
+
const months = Math.floor(diffDays / 30);
|
|
115
|
+
return months + ' month' + (months !== 1 ? 's' : '') + ' ago';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Escape HTML special characters
|
|
121
|
+
* @param {string} text - Text to escape
|
|
122
|
+
* @returns {string} Escaped text
|
|
123
|
+
*/
|
|
124
|
+
function escapeHtml(text) {
|
|
125
|
+
const div = document.createElement('div');
|
|
126
|
+
div.textContent = text || '';
|
|
127
|
+
return div.innerHTML;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Set loading state for a tab's form
|
|
132
|
+
* @param {string} tab - 'pr' or 'local'
|
|
133
|
+
* @param {boolean} loading - Whether to show loading state
|
|
134
|
+
* @param {string} [text] - Optional loading text
|
|
135
|
+
*/
|
|
136
|
+
function setFormLoading(tab, loading, text) {
|
|
137
|
+
const ids = tab === 'pr'
|
|
138
|
+
? { input: 'pr-url-input', btn: 'start-review-btn', loadingEl: 'start-review-loading-pr', loadingText: 'start-review-loading-text-pr', errorEl: 'start-review-error-pr', btnLabel: 'Start Review' }
|
|
139
|
+
: { input: 'local-path-input', btn: 'start-local-btn', loadingEl: 'start-review-loading-local', loadingText: 'start-review-loading-text-local', errorEl: 'start-review-error-local', btnLabel: 'Review Local' };
|
|
140
|
+
|
|
141
|
+
const inputEl = document.getElementById(ids.input);
|
|
142
|
+
const btnEl = document.getElementById(ids.btn);
|
|
143
|
+
const loadingEl = document.getElementById(ids.loadingEl);
|
|
144
|
+
const loadingTextEl = document.getElementById(ids.loadingText);
|
|
145
|
+
const errorEl = document.getElementById(ids.errorEl);
|
|
146
|
+
|
|
147
|
+
if (loading) {
|
|
148
|
+
if (inputEl) inputEl.disabled = true;
|
|
149
|
+
if (btnEl) { btnEl.disabled = true; btnEl.textContent = 'Starting...'; }
|
|
150
|
+
if (loadingEl) loadingEl.classList.add('visible');
|
|
151
|
+
if (loadingTextEl && text) loadingTextEl.textContent = text;
|
|
152
|
+
if (errorEl) errorEl.classList.remove('visible', 'info');
|
|
153
|
+
} else {
|
|
154
|
+
if (inputEl) inputEl.disabled = false;
|
|
155
|
+
if (btnEl) { btnEl.disabled = false; btnEl.textContent = ids.btnLabel; }
|
|
156
|
+
if (loadingEl) loadingEl.classList.remove('visible');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Show error message for a specific tab's form
|
|
162
|
+
* @param {string} tab - 'pr' or 'local'
|
|
163
|
+
* @param {string} message - Error message to display
|
|
164
|
+
*/
|
|
165
|
+
function showError(tab, message) {
|
|
166
|
+
const elId = tab === 'pr' ? 'start-review-error-pr' : 'start-review-error-local';
|
|
167
|
+
const errorEl = document.getElementById(elId);
|
|
168
|
+
if (errorEl) {
|
|
169
|
+
errorEl.textContent = message;
|
|
170
|
+
errorEl.classList.remove('info');
|
|
171
|
+
errorEl.classList.add('visible');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Show an informational (non-error) message to the user.
|
|
177
|
+
* Uses the same element as showError but with neutral info styling.
|
|
178
|
+
* @param {string} tab - 'pr' or 'local'
|
|
179
|
+
* @param {string} message - Informational message to display
|
|
180
|
+
*/
|
|
181
|
+
function showInfo(tab, message) {
|
|
182
|
+
const elId = tab === 'pr' ? 'start-review-error-pr' : 'start-review-error-local';
|
|
183
|
+
const errorEl = document.getElementById(elId);
|
|
184
|
+
if (errorEl) {
|
|
185
|
+
errorEl.textContent = message;
|
|
186
|
+
errorEl.classList.add('visible', 'info');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Tab Switching ──────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generic tab switching handler
|
|
194
|
+
* Activates the clicked tab and its corresponding pane
|
|
195
|
+
* @param {HTMLElement} tabBar - The tab bar container
|
|
196
|
+
* @param {HTMLElement} clickedBtn - The clicked tab button
|
|
197
|
+
* @param {Function} [onActivate] - Optional callback when a tab is activated
|
|
198
|
+
*/
|
|
199
|
+
function switchTab(tabBar, clickedBtn, onActivate) {
|
|
200
|
+
const tabId = clickedBtn.dataset.tab;
|
|
201
|
+
if (!tabId) return;
|
|
202
|
+
|
|
203
|
+
// Deactivate all tabs in this bar
|
|
204
|
+
tabBar.querySelectorAll('.tab-btn').forEach(function (btn) { btn.classList.remove('active'); });
|
|
205
|
+
clickedBtn.classList.add('active');
|
|
206
|
+
|
|
207
|
+
// Find the parent container that holds the tab panes
|
|
208
|
+
const container = tabBar.closest('.recent-reviews-section');
|
|
209
|
+
if (!container) return;
|
|
210
|
+
|
|
211
|
+
// Hide all panes, show the target
|
|
212
|
+
container.querySelectorAll('.tab-pane').forEach(function (pane) { pane.classList.remove('active'); });
|
|
213
|
+
const targetPane = container.querySelector('#' + tabId);
|
|
214
|
+
if (targetPane) {
|
|
215
|
+
targetPane.classList.add('active');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (onActivate) onActivate(tabId);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Local Reviews ──────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Render a single local review session table row
|
|
225
|
+
* @param {Object} session - Session data
|
|
226
|
+
* @returns {string} HTML string for the table row
|
|
227
|
+
*/
|
|
228
|
+
function renderLocalReviewRow(session) {
|
|
229
|
+
const link = '/local/' + session.id;
|
|
230
|
+
const relativeTime = formatRelativeTime(session.updated_at);
|
|
231
|
+
const pathDisplay = session.local_path || '';
|
|
232
|
+
const sha = session.local_head_sha
|
|
233
|
+
? session.local_head_sha.substring(0, 7)
|
|
234
|
+
: '';
|
|
235
|
+
const hasName = !!session.name;
|
|
236
|
+
const nameDisplay = hasName ? escapeHtml(session.name) : '<em>Untitled</em>';
|
|
237
|
+
|
|
238
|
+
// Build repo settings link if repository looks like owner/repo
|
|
239
|
+
var settingsHtml = '';
|
|
240
|
+
if (session.repository && session.repository.includes('/')) {
|
|
241
|
+
var repoParts = session.repository.split('/');
|
|
242
|
+
var settingsLink = '/repo-settings.html?owner=' + encodeURIComponent(repoParts[0]) + '&repo=' + encodeURIComponent(repoParts[1]);
|
|
243
|
+
settingsHtml =
|
|
244
|
+
'<a href="' + settingsLink + '" class="btn-repo-settings" title="Repository settings">' +
|
|
245
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
|
|
246
|
+
'<path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>' +
|
|
247
|
+
'</svg>' +
|
|
248
|
+
'</a>';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return '' +
|
|
252
|
+
'<tr data-session-id="' + session.id + '">' +
|
|
253
|
+
'<td class="col-local-name" title="' + escapeHtml(session.name || 'Untitled') + '"><a href="' + link + '">' + nameDisplay + '</a></td>' +
|
|
254
|
+
'<td class="col-local-sha" title="' + escapeHtml(session.local_head_sha || '') + '">' + escapeHtml(sha) + '</td>' +
|
|
255
|
+
'<td class="col-local-path" title="' + escapeHtml(pathDisplay) + '">' + escapeHtml(pathDisplay) + '</td>' +
|
|
256
|
+
'<td class="col-repo">' + escapeHtml(session.repository || '') + '</td>' +
|
|
257
|
+
'<td class="col-time">' + relativeTime + '</td>' +
|
|
258
|
+
'<td class="col-actions">' +
|
|
259
|
+
settingsHtml +
|
|
260
|
+
'<button' +
|
|
261
|
+
' class="btn-delete-session"' +
|
|
262
|
+
' data-session-id="' + session.id + '"' +
|
|
263
|
+
' data-session-path="' + escapeHtml(pathDisplay) + '"' +
|
|
264
|
+
' title="Delete session"' +
|
|
265
|
+
'>' +
|
|
266
|
+
'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">' +
|
|
267
|
+
'<path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19a1.75 1.75 0 001.741-1.575l.66-6.6a.75.75 0 00-1.492-.15l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>' +
|
|
268
|
+
'</svg>' +
|
|
269
|
+
'</button>' +
|
|
270
|
+
'</td>' +
|
|
271
|
+
'</tr>';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Pagination state for the local reviews list */
|
|
275
|
+
const localReviewsPagination = {
|
|
276
|
+
lastTimestamp: null,
|
|
277
|
+
pageSize: 10,
|
|
278
|
+
hasMore: false,
|
|
279
|
+
loaded: false
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Fetch and display local review sessions (initial load).
|
|
284
|
+
*/
|
|
285
|
+
async function loadLocalReviews() {
|
|
286
|
+
const container = document.getElementById('local-reviews-container');
|
|
287
|
+
if (!container) return;
|
|
288
|
+
|
|
289
|
+
// Reset pagination
|
|
290
|
+
localReviewsPagination.lastTimestamp = null;
|
|
291
|
+
localReviewsPagination.hasMore = false;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const response = await fetch('/api/local/sessions?limit=' + localReviewsPagination.pageSize);
|
|
295
|
+
if (!response.ok) throw new Error('Failed to fetch local sessions');
|
|
296
|
+
|
|
297
|
+
const data = await response.json();
|
|
298
|
+
|
|
299
|
+
if (!data.success || !data.sessions || data.sessions.length === 0) {
|
|
300
|
+
container.innerHTML =
|
|
301
|
+
'<div class="recent-reviews-empty">' +
|
|
302
|
+
'<p>No local review sessions yet. Enter a directory path above or run <code>pair-review --local</code> from the CLI.</p>' +
|
|
303
|
+
'</div>';
|
|
304
|
+
container.classList.remove('recent-reviews-loading');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Update pagination
|
|
309
|
+
localReviewsPagination.lastTimestamp = data.sessions[data.sessions.length - 1].updated_at;
|
|
310
|
+
localReviewsPagination.hasMore = !!data.hasMore;
|
|
311
|
+
|
|
312
|
+
container.innerHTML =
|
|
313
|
+
'<table class="recent-reviews-table local-table">' +
|
|
314
|
+
'<thead>' +
|
|
315
|
+
'<tr>' +
|
|
316
|
+
'<th>Name</th>' +
|
|
317
|
+
'<th>Head</th>' +
|
|
318
|
+
'<th>Path</th>' +
|
|
319
|
+
'<th>Repo</th>' +
|
|
320
|
+
'<th>Last Updated</th>' +
|
|
321
|
+
'<th></th>' +
|
|
322
|
+
'</tr>' +
|
|
323
|
+
'</thead>' +
|
|
324
|
+
'<tbody id="local-reviews-tbody">' +
|
|
325
|
+
data.sessions.map(renderLocalReviewRow).join('') +
|
|
326
|
+
'</tbody>' +
|
|
327
|
+
'</table>' +
|
|
328
|
+
(data.hasMore
|
|
329
|
+
? '<div class="show-more-container" id="local-show-more-container">' +
|
|
330
|
+
'<button class="btn-show-more" id="btn-local-show-more" type="button">' +
|
|
331
|
+
'<span class="btn-show-more-text">Show more</span>' +
|
|
332
|
+
'<span class="spinner"></span>' +
|
|
333
|
+
'</button>' +
|
|
334
|
+
'</div>'
|
|
335
|
+
: '');
|
|
336
|
+
container.classList.remove('recent-reviews-loading');
|
|
337
|
+
localReviewsPagination.loaded = true;
|
|
338
|
+
|
|
339
|
+
} catch (error) {
|
|
340
|
+
console.error('Error loading local reviews:', error);
|
|
341
|
+
container.innerHTML =
|
|
342
|
+
'<div class="recent-reviews-empty">' +
|
|
343
|
+
'<p>Failed to load local reviews.</p>' +
|
|
344
|
+
'</div>';
|
|
345
|
+
container.classList.remove('recent-reviews-loading');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Load more local review sessions (pagination)
|
|
351
|
+
*/
|
|
352
|
+
async function loadMoreLocalReviews() {
|
|
353
|
+
const btn = document.getElementById('btn-local-show-more');
|
|
354
|
+
const tbody = document.getElementById('local-reviews-tbody');
|
|
355
|
+
if (!btn || !tbody) return;
|
|
356
|
+
|
|
357
|
+
btn.classList.add('loading');
|
|
358
|
+
btn.disabled = true;
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const params = new URLSearchParams({ limit: localReviewsPagination.pageSize });
|
|
362
|
+
if (localReviewsPagination.lastTimestamp) params.set('before', localReviewsPagination.lastTimestamp);
|
|
363
|
+
const response = await fetch('/api/local/sessions?' + params);
|
|
364
|
+
if (!response.ok) throw new Error('Failed to fetch more sessions');
|
|
365
|
+
|
|
366
|
+
const data = await response.json();
|
|
367
|
+
if (!document.contains(btn)) return;
|
|
368
|
+
|
|
369
|
+
if (!data.success || !data.sessions || data.sessions.length === 0) {
|
|
370
|
+
const showMoreContainer = document.getElementById('local-show-more-container');
|
|
371
|
+
if (showMoreContainer) showMoreContainer.remove();
|
|
372
|
+
localReviewsPagination.hasMore = false;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
tbody.insertAdjacentHTML('beforeend', data.sessions.map(renderLocalReviewRow).join(''));
|
|
377
|
+
|
|
378
|
+
localReviewsPagination.lastTimestamp = data.sessions[data.sessions.length - 1].updated_at;
|
|
379
|
+
localReviewsPagination.hasMore = !!data.hasMore;
|
|
380
|
+
|
|
381
|
+
if (!data.hasMore) {
|
|
382
|
+
const container = document.getElementById('local-show-more-container');
|
|
383
|
+
if (container) container.remove();
|
|
384
|
+
} else {
|
|
385
|
+
btn.classList.remove('loading');
|
|
386
|
+
btn.disabled = false;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error('Error loading more local reviews:', error);
|
|
391
|
+
btn.classList.remove('loading');
|
|
392
|
+
btn.disabled = false;
|
|
393
|
+
const textEl = btn.querySelector('.btn-show-more-text');
|
|
394
|
+
if (textEl) {
|
|
395
|
+
textEl.textContent = 'Failed to load \u2014 click to retry';
|
|
396
|
+
setTimeout(function () { textEl.textContent = 'Show more'; }, 4000);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Show inline delete confirmation for a local session row
|
|
403
|
+
* @param {HTMLElement} button - The delete button element
|
|
404
|
+
*/
|
|
405
|
+
function showDeleteSessionConfirm(button) {
|
|
406
|
+
const sessionId = button.dataset.sessionId;
|
|
407
|
+
const sessionPath = button.dataset.sessionPath || '';
|
|
408
|
+
const row = button.closest('tr');
|
|
409
|
+
if (!row) return;
|
|
410
|
+
|
|
411
|
+
// If already showing confirmation, do nothing
|
|
412
|
+
if (row.classList.contains('delete-confirm-row')) return;
|
|
413
|
+
|
|
414
|
+
// Remember original HTML so we can restore on cancel.
|
|
415
|
+
// Note: restoring innerHTML is safe because all click handlers use event delegation.
|
|
416
|
+
const originalHTML = row.innerHTML;
|
|
417
|
+
const colCount = row.children.length;
|
|
418
|
+
|
|
419
|
+
row.classList.add('delete-confirm-row');
|
|
420
|
+
row.innerHTML =
|
|
421
|
+
'<td colspan="' + colCount + '">' +
|
|
422
|
+
'<div class="delete-confirm-inner">' +
|
|
423
|
+
'<span>Delete session for ' + escapeHtml(sessionPath) + '?</span>' +
|
|
424
|
+
'<button class="btn-confirm-yes" data-session-id="' + sessionId + '">Delete</button>' +
|
|
425
|
+
'<button class="btn-confirm-no">Cancel</button>' +
|
|
426
|
+
'</div>' +
|
|
427
|
+
'</td>';
|
|
428
|
+
|
|
429
|
+
// Wire up buttons
|
|
430
|
+
row.querySelector('.btn-confirm-yes').addEventListener('click', async function () {
|
|
431
|
+
try {
|
|
432
|
+
const response = await fetch('/api/local/sessions/' + sessionId, {
|
|
433
|
+
method: 'DELETE'
|
|
434
|
+
});
|
|
435
|
+
if (!response.ok) {
|
|
436
|
+
const respData = await response.json().catch(function () { return {}; });
|
|
437
|
+
throw new Error(respData.error || 'Failed to delete session');
|
|
438
|
+
}
|
|
439
|
+
// Remove the row from DOM
|
|
440
|
+
row.remove();
|
|
441
|
+
// If no more rows, reload to show empty state
|
|
442
|
+
const tbody = document.getElementById('local-reviews-tbody');
|
|
443
|
+
if (tbody && tbody.children.length === 0) {
|
|
444
|
+
await loadLocalReviews();
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
console.error('Error deleting local session:', error);
|
|
448
|
+
// Restore row on failure
|
|
449
|
+
row.classList.remove('delete-confirm-row');
|
|
450
|
+
row.innerHTML = originalHTML;
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
row.querySelector('.btn-confirm-no').addEventListener('click', function () {
|
|
455
|
+
row.classList.remove('delete-confirm-row');
|
|
456
|
+
row.innerHTML = originalHTML;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ─── Local Review Start ─────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Handle start local review form submission
|
|
464
|
+
* @param {Event} event - Form submit event
|
|
465
|
+
*/
|
|
466
|
+
async function handleStartLocal(event) {
|
|
467
|
+
event.preventDefault();
|
|
468
|
+
|
|
469
|
+
const input = document.getElementById('local-path-input');
|
|
470
|
+
const pathValue = input.value.trim();
|
|
471
|
+
|
|
472
|
+
// Clear previous errors/info messages
|
|
473
|
+
const errorEl = document.getElementById('start-review-error-local');
|
|
474
|
+
if (errorEl) errorEl.classList.remove('visible', 'info');
|
|
475
|
+
|
|
476
|
+
if (!pathValue) {
|
|
477
|
+
showError('local', 'Please enter a directory path');
|
|
478
|
+
input.focus();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
setFormLoading('local', true, 'Starting local review...');
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const response = await fetch('/api/local/start', {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: { 'Content-Type': 'application/json' },
|
|
488
|
+
body: JSON.stringify({ path: pathValue })
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const data = await response.json();
|
|
492
|
+
|
|
493
|
+
if (!response.ok) {
|
|
494
|
+
throw new Error(data.error || 'Failed to start local review');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
setFormLoading('local', true, 'Redirecting to review...');
|
|
498
|
+
window.location.href = data.reviewUrl;
|
|
499
|
+
|
|
500
|
+
} catch (error) {
|
|
501
|
+
console.error('Error starting local review:', error);
|
|
502
|
+
setFormLoading('local', false);
|
|
503
|
+
showError('local', error.message || 'An unexpected error occurred. Please try again.');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ─── Browse Directory ──────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Handle Browse button click — open native OS directory picker via backend API
|
|
511
|
+
*/
|
|
512
|
+
async function handleBrowseLocal() {
|
|
513
|
+
const browseBtn = document.getElementById('browse-local-btn');
|
|
514
|
+
const input = document.getElementById('local-path-input');
|
|
515
|
+
if (!browseBtn || !input) return;
|
|
516
|
+
|
|
517
|
+
// Disable button while dialog is open
|
|
518
|
+
browseBtn.disabled = true;
|
|
519
|
+
browseBtn.textContent = 'Browsing...';
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const response = await fetch('/api/local/browse', {
|
|
523
|
+
method: 'POST',
|
|
524
|
+
headers: { 'Content-Type': 'application/json' }
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const data = await response.json();
|
|
528
|
+
|
|
529
|
+
if (!response.ok) {
|
|
530
|
+
showError('local', data.error || 'Failed to open directory picker');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!data.cancelled && data.path) {
|
|
535
|
+
input.value = data.path;
|
|
536
|
+
input.focus();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
} catch (error) {
|
|
540
|
+
console.error('Error browsing for directory:', error);
|
|
541
|
+
showError('local', 'Failed to open directory picker');
|
|
542
|
+
} finally {
|
|
543
|
+
browseBtn.disabled = false;
|
|
544
|
+
browseBtn.textContent = 'Browse';
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ─── PR Reviews ─────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Render a single recent review table row
|
|
552
|
+
* @param {Object} worktree - Worktree data
|
|
553
|
+
* @returns {string} HTML string for the table row
|
|
554
|
+
*/
|
|
555
|
+
function renderRecentReviewRow(worktree) {
|
|
556
|
+
const parts = worktree.repository.split('/');
|
|
557
|
+
const owner = parts[0];
|
|
558
|
+
const repo = parts[1];
|
|
559
|
+
const link = '/pr/' + owner + '/' + repo + '/' + worktree.pr_number;
|
|
560
|
+
const settingsLink = '/repo-settings.html?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo);
|
|
561
|
+
const relativeTime = formatRelativeTime(worktree.last_accessed_at);
|
|
562
|
+
|
|
563
|
+
const authorDisplay = worktree.author
|
|
564
|
+
? '<a href="https://github.com/' + encodeURIComponent(worktree.author) + '" target="_blank" rel="noopener">' + escapeHtml(worktree.author) + '</a>'
|
|
565
|
+
: '';
|
|
566
|
+
|
|
567
|
+
return '' +
|
|
568
|
+
'<tr>' +
|
|
569
|
+
'<td class="col-repo">' + escapeHtml(worktree.repository) + '</td>' +
|
|
570
|
+
'<td class="col-pr"><a href="' + link + '">#' + worktree.pr_number + '</a></td>' +
|
|
571
|
+
'<td class="col-title" title="' + escapeHtml(worktree.pr_title) + '">' + escapeHtml(worktree.pr_title) + '</td>' +
|
|
572
|
+
'<td class="col-author">' + authorDisplay + '</td>' +
|
|
573
|
+
'<td class="col-time">' + relativeTime + '</td>' +
|
|
574
|
+
'<td class="col-actions">' +
|
|
575
|
+
'<a href="' + settingsLink + '" class="btn-repo-settings" title="Repository settings">' +
|
|
576
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
|
|
577
|
+
'<path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>' +
|
|
578
|
+
'</svg>' +
|
|
579
|
+
'</a>' +
|
|
580
|
+
'<button' +
|
|
581
|
+
' class="btn-delete-worktree"' +
|
|
582
|
+
' data-worktree-id="' + worktree.id + '"' +
|
|
583
|
+
' data-repository="' + escapeHtml(worktree.repository) + '"' +
|
|
584
|
+
' data-pr-number="' + worktree.pr_number + '"' +
|
|
585
|
+
' title="Delete worktree"' +
|
|
586
|
+
'>' +
|
|
587
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
|
|
588
|
+
'<path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19a1.75 1.75 0 001.741-1.575l.66-6.6a.75.75 0 00-1.492-.15l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>' +
|
|
589
|
+
'</svg>' +
|
|
590
|
+
'</button>' +
|
|
591
|
+
'</td>' +
|
|
592
|
+
'</tr>';
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Show inline delete confirmation for a PR worktree row
|
|
597
|
+
* @param {HTMLElement} button - The delete button element
|
|
598
|
+
*/
|
|
599
|
+
function showDeleteWorktreeConfirm(button) {
|
|
600
|
+
const worktreeId = button.dataset.worktreeId;
|
|
601
|
+
const repository = button.dataset.repository;
|
|
602
|
+
const prNumber = button.dataset.prNumber;
|
|
603
|
+
const row = button.closest('tr');
|
|
604
|
+
if (!row) return;
|
|
605
|
+
|
|
606
|
+
// If already showing confirmation, do nothing
|
|
607
|
+
if (row.classList.contains('delete-confirm-row')) return;
|
|
608
|
+
|
|
609
|
+
// Remember original HTML so we can restore on cancel.
|
|
610
|
+
// Note: restoring innerHTML is safe because all click handlers use event delegation.
|
|
611
|
+
const originalHTML = row.innerHTML;
|
|
612
|
+
const colCount = row.children.length;
|
|
613
|
+
|
|
614
|
+
row.classList.add('delete-confirm-row');
|
|
615
|
+
row.innerHTML =
|
|
616
|
+
'<td colspan="' + colCount + '">' +
|
|
617
|
+
'<div class="delete-confirm-inner">' +
|
|
618
|
+
'<span>Delete worktree for ' + escapeHtml(repository) + ' #' + escapeHtml(String(prNumber)) + '?</span>' +
|
|
619
|
+
'<button class="btn-confirm-yes" data-worktree-id="' + worktreeId + '">Delete</button>' +
|
|
620
|
+
'<button class="btn-confirm-no">Cancel</button>' +
|
|
621
|
+
'</div>' +
|
|
622
|
+
'</td>';
|
|
623
|
+
|
|
624
|
+
// Wire up buttons
|
|
625
|
+
row.querySelector('.btn-confirm-yes').addEventListener('click', async function () {
|
|
626
|
+
try {
|
|
627
|
+
const response = await fetch('/api/worktrees/' + worktreeId, {
|
|
628
|
+
method: 'DELETE'
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
if (!response.ok) {
|
|
632
|
+
const data = await response.json().catch(function () { return {}; });
|
|
633
|
+
throw new Error(data.error || 'Failed to delete worktree');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Reload the recent reviews list
|
|
637
|
+
await loadRecentReviews();
|
|
638
|
+
|
|
639
|
+
} catch (error) {
|
|
640
|
+
console.error('Error deleting worktree:', error);
|
|
641
|
+
// Restore row on failure
|
|
642
|
+
row.classList.remove('delete-confirm-row');
|
|
643
|
+
row.innerHTML = originalHTML;
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
row.querySelector('.btn-confirm-no').addEventListener('click', function () {
|
|
648
|
+
row.classList.remove('delete-confirm-row');
|
|
649
|
+
row.innerHTML = originalHTML;
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Pagination state for the recent reviews list */
|
|
654
|
+
const recentReviewsPagination = {
|
|
655
|
+
/** ISO timestamp of the last loaded item (cursor for next fetch) */
|
|
656
|
+
lastTimestamp: null,
|
|
657
|
+
/** Number of worktrees to fetch per page */
|
|
658
|
+
pageSize: 10,
|
|
659
|
+
/** Whether the server has indicated more results exist */
|
|
660
|
+
hasMore: false
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Fetch and display recent reviews (initial load).
|
|
665
|
+
* Resets pagination state and renders the full table from scratch.
|
|
666
|
+
*/
|
|
667
|
+
async function loadRecentReviews() {
|
|
668
|
+
const container = document.getElementById('recent-reviews-container');
|
|
669
|
+
const section = document.getElementById('recent-reviews-section');
|
|
670
|
+
const usageInfo = document.getElementById('usage-info');
|
|
671
|
+
|
|
672
|
+
// Reset pagination state
|
|
673
|
+
recentReviewsPagination.lastTimestamp = null;
|
|
674
|
+
recentReviewsPagination.hasMore = false;
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
const response = await fetch('/api/worktrees/recent?limit=' + recentReviewsPagination.pageSize);
|
|
678
|
+
|
|
679
|
+
if (!response.ok) {
|
|
680
|
+
throw new Error('Failed to fetch recent reviews');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const data = await response.json();
|
|
684
|
+
|
|
685
|
+
if (!data.success || !data.worktrees || data.worktrees.length === 0) {
|
|
686
|
+
// Show friendly empty state with usage info
|
|
687
|
+
container.innerHTML =
|
|
688
|
+
'<div class="recent-reviews-empty">' +
|
|
689
|
+
'<p>No PR reviews yet. Paste a PR URL above to get started.</p>' +
|
|
690
|
+
'</div>';
|
|
691
|
+
container.classList.remove('recent-reviews-loading');
|
|
692
|
+
// Show usage info when no reviews exist
|
|
693
|
+
if (usageInfo) usageInfo.classList.remove('loading-hidden');
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Update pagination state - track the cursor for the next page
|
|
698
|
+
recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
|
|
699
|
+
recentReviewsPagination.hasMore = !!data.hasMore;
|
|
700
|
+
|
|
701
|
+
// Render the table of recent reviews
|
|
702
|
+
const html =
|
|
703
|
+
'<table class="recent-reviews-table">' +
|
|
704
|
+
'<thead>' +
|
|
705
|
+
'<tr>' +
|
|
706
|
+
'<th>Repository</th>' +
|
|
707
|
+
'<th>PR</th>' +
|
|
708
|
+
'<th>Title</th>' +
|
|
709
|
+
'<th>Author</th>' +
|
|
710
|
+
'<th>Last Opened</th>' +
|
|
711
|
+
'<th>Actions</th>' +
|
|
712
|
+
'</tr>' +
|
|
713
|
+
'</thead>' +
|
|
714
|
+
'<tbody id="recent-reviews-tbody">' +
|
|
715
|
+
data.worktrees.map(renderRecentReviewRow).join('') +
|
|
716
|
+
'</tbody>' +
|
|
717
|
+
'</table>' +
|
|
718
|
+
renderShowMoreButton(data.hasMore);
|
|
719
|
+
container.innerHTML = html;
|
|
720
|
+
container.classList.remove('recent-reviews-loading');
|
|
721
|
+
|
|
722
|
+
} catch (error) {
|
|
723
|
+
console.error('Error loading recent reviews:', error);
|
|
724
|
+
// Hide the section on error, show usage info as fallback
|
|
725
|
+
section.style.display = 'none';
|
|
726
|
+
if (usageInfo) usageInfo.classList.remove('loading-hidden');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Render the "Show more" button HTML.
|
|
732
|
+
* @param {boolean} hasMore - Whether more results are available
|
|
733
|
+
* @returns {string} HTML string for the show-more container
|
|
734
|
+
*/
|
|
735
|
+
function renderShowMoreButton(hasMore) {
|
|
736
|
+
if (!hasMore) return '';
|
|
737
|
+
return '' +
|
|
738
|
+
'<div class="show-more-container" id="show-more-container">' +
|
|
739
|
+
'<button class="btn-show-more" id="btn-show-more" type="button">' +
|
|
740
|
+
'<span class="btn-show-more-text">Show more</span>' +
|
|
741
|
+
'<span class="spinner"></span>' +
|
|
742
|
+
'</button>' +
|
|
743
|
+
'</div>';
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Load the next page of worktrees and append them to the existing table.
|
|
748
|
+
* Called when the "Show more" button is clicked.
|
|
749
|
+
*/
|
|
750
|
+
async function loadMoreReviews() {
|
|
751
|
+
const btn = document.getElementById('btn-show-more');
|
|
752
|
+
const tbody = document.getElementById('recent-reviews-tbody');
|
|
753
|
+
if (!btn || !tbody) return;
|
|
754
|
+
|
|
755
|
+
// Show loading state on the button
|
|
756
|
+
btn.classList.add('loading');
|
|
757
|
+
btn.disabled = true;
|
|
758
|
+
|
|
759
|
+
try {
|
|
760
|
+
const params = new URLSearchParams({ limit: recentReviewsPagination.pageSize });
|
|
761
|
+
if (recentReviewsPagination.lastTimestamp) params.set('before', recentReviewsPagination.lastTimestamp);
|
|
762
|
+
const response = await fetch('/api/worktrees/recent?' + params);
|
|
763
|
+
|
|
764
|
+
if (!response.ok) {
|
|
765
|
+
throw new Error('Failed to fetch more reviews');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const data = await response.json();
|
|
769
|
+
|
|
770
|
+
// Guard against stale response if the table was refreshed (e.g. by a delete) while loading
|
|
771
|
+
if (!document.contains(btn)) return;
|
|
772
|
+
|
|
773
|
+
if (!data.success || !data.worktrees || data.worktrees.length === 0) {
|
|
774
|
+
// No more results - remove the button
|
|
775
|
+
const showMoreContainer = document.getElementById('show-more-container');
|
|
776
|
+
if (showMoreContainer) showMoreContainer.remove();
|
|
777
|
+
recentReviewsPagination.hasMore = false;
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Append new rows to the existing table body
|
|
782
|
+
tbody.insertAdjacentHTML('beforeend', data.worktrees.map(renderRecentReviewRow).join(''));
|
|
783
|
+
|
|
784
|
+
// Update pagination state - advance the cursor
|
|
785
|
+
recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
|
|
786
|
+
recentReviewsPagination.hasMore = !!data.hasMore;
|
|
787
|
+
|
|
788
|
+
// Update or remove the "Show more" button
|
|
789
|
+
if (!data.hasMore) {
|
|
790
|
+
const container = document.getElementById('show-more-container');
|
|
791
|
+
if (container) container.remove();
|
|
792
|
+
} else {
|
|
793
|
+
// Reset button state
|
|
794
|
+
btn.classList.remove('loading');
|
|
795
|
+
btn.disabled = false;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
} catch (error) {
|
|
799
|
+
console.error('Error loading more reviews:', error);
|
|
800
|
+
// Reset button state and show error so the user knows what happened
|
|
801
|
+
btn.classList.remove('loading');
|
|
802
|
+
btn.disabled = false;
|
|
803
|
+
const textEl = btn.querySelector('.btn-show-more-text');
|
|
804
|
+
if (textEl) {
|
|
805
|
+
textEl.textContent = 'Failed to load \u2014 click to retry';
|
|
806
|
+
// Restore original text after a brief delay so the user sees the error
|
|
807
|
+
setTimeout(function () { textEl.textContent = 'Show more'; }, 4000);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ─── PR Start Flow ──────────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Parse a PR URL using the backend API
|
|
816
|
+
* Supports GitHub and Graphite URLs (with or without protocol)
|
|
817
|
+
* @param {string} url - The PR URL to parse
|
|
818
|
+
* @returns {Promise<Object|null>} { owner, repo, prNumber } or null if invalid
|
|
819
|
+
*/
|
|
820
|
+
async function parsePRUrl(url) {
|
|
821
|
+
if (!url || typeof url !== 'string') {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
const response = await fetch('/api/parse-pr-url', {
|
|
827
|
+
method: 'POST',
|
|
828
|
+
headers: {
|
|
829
|
+
'Content-Type': 'application/json'
|
|
830
|
+
},
|
|
831
|
+
body: JSON.stringify({ url: url.trim() })
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const data = await response.json();
|
|
835
|
+
|
|
836
|
+
if (data.valid) {
|
|
837
|
+
return {
|
|
838
|
+
owner: data.owner,
|
|
839
|
+
repo: data.repo,
|
|
840
|
+
prNumber: data.prNumber
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return null;
|
|
845
|
+
} catch (e) {
|
|
846
|
+
console.error('Error parsing PR URL:', e);
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Handle start review form submission
|
|
853
|
+
* @param {Event} event - Form submit event
|
|
854
|
+
*/
|
|
855
|
+
async function handleStartReview(event) {
|
|
856
|
+
event.preventDefault();
|
|
857
|
+
|
|
858
|
+
const input = document.getElementById('pr-url-input');
|
|
859
|
+
const url = input.value.trim();
|
|
860
|
+
|
|
861
|
+
// Clear previous errors
|
|
862
|
+
const errorEl = document.getElementById('start-review-error-pr');
|
|
863
|
+
if (errorEl) errorEl.classList.remove('visible');
|
|
864
|
+
|
|
865
|
+
// Validate input
|
|
866
|
+
if (!url) {
|
|
867
|
+
showError('pr', 'Please enter a GitHub PR URL');
|
|
868
|
+
input.focus();
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Show loading state while parsing
|
|
873
|
+
setFormLoading('pr', true, 'Validating PR URL...');
|
|
874
|
+
|
|
875
|
+
// Parse the URL using the backend API
|
|
876
|
+
const parsed = await parsePRUrl(url);
|
|
877
|
+
if (!parsed) {
|
|
878
|
+
setFormLoading('pr', false);
|
|
879
|
+
showError('pr', 'Invalid PR URL. Please enter a GitHub or Graphite PR URL (e.g., https://github.com/owner/repo/pull/123)');
|
|
880
|
+
input.focus();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Update loading state
|
|
885
|
+
setFormLoading('pr', true, 'Fetching PR data from GitHub...');
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
// Call the API to create the worktree
|
|
889
|
+
const response = await fetch('/api/worktrees/create', {
|
|
890
|
+
method: 'POST',
|
|
891
|
+
headers: {
|
|
892
|
+
'Content-Type': 'application/json'
|
|
893
|
+
},
|
|
894
|
+
body: JSON.stringify({
|
|
895
|
+
owner: parsed.owner,
|
|
896
|
+
repo: parsed.repo,
|
|
897
|
+
prNumber: parsed.prNumber
|
|
898
|
+
})
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
const data = await response.json();
|
|
902
|
+
|
|
903
|
+
if (!response.ok) {
|
|
904
|
+
throw new Error(data.error || 'Failed to create worktree');
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (!data.success) {
|
|
908
|
+
throw new Error(data.error || 'Failed to create worktree');
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Update loading text before redirect
|
|
912
|
+
setFormLoading('pr', true, 'Redirecting to review...');
|
|
913
|
+
|
|
914
|
+
// Redirect to the review page
|
|
915
|
+
window.location.href = data.reviewUrl;
|
|
916
|
+
|
|
917
|
+
} catch (error) {
|
|
918
|
+
console.error('Error starting review:', error);
|
|
919
|
+
setFormLoading('pr', false);
|
|
920
|
+
showError('pr', error.message || 'An unexpected error occurred. Please try again.');
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ─── Config & Command Examples ──────────────────────────────────────────────
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Update command examples based on whether running via npx or installed
|
|
928
|
+
* @param {boolean} isNpx - True if running via npx
|
|
929
|
+
*/
|
|
930
|
+
function updateCommandExamples(isNpx) {
|
|
931
|
+
const baseCmd = isNpx ? 'npx @in-the-loop-labs/pair-review' : 'pair-review';
|
|
932
|
+
const cmdExamples = document.querySelectorAll('.cmd-example');
|
|
933
|
+
cmdExamples.forEach(function (el) {
|
|
934
|
+
const args = el.dataset.args || '';
|
|
935
|
+
el.textContent = args ? baseCmd + ' ' + args : baseCmd;
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Fetch config from server and update UI accordingly
|
|
941
|
+
*/
|
|
942
|
+
async function loadConfigAndUpdateUI() {
|
|
943
|
+
try {
|
|
944
|
+
const response = await fetch('/api/config');
|
|
945
|
+
if (response.ok) {
|
|
946
|
+
const config = await response.json();
|
|
947
|
+
updateCommandExamples(config.is_running_via_npx);
|
|
948
|
+
} else {
|
|
949
|
+
// Fallback: assume installed (shorter command)
|
|
950
|
+
updateCommandExamples(false);
|
|
951
|
+
}
|
|
952
|
+
} catch (error) {
|
|
953
|
+
console.error('Error loading config:', error);
|
|
954
|
+
// Fallback: assume installed (shorter command)
|
|
955
|
+
updateCommandExamples(false);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ─── Event Delegation ───────────────────────────────────────────────────────
|
|
960
|
+
|
|
961
|
+
// Event delegation for buttons, show-more, tab switching
|
|
962
|
+
document.addEventListener('click', function (event) {
|
|
963
|
+
// Delete worktree (PR mode)
|
|
964
|
+
const deleteBtn = event.target.closest('.btn-delete-worktree');
|
|
965
|
+
if (deleteBtn) {
|
|
966
|
+
event.preventDefault();
|
|
967
|
+
event.stopPropagation();
|
|
968
|
+
showDeleteWorktreeConfirm(deleteBtn);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Delete local session
|
|
973
|
+
const deleteSessionBtn = event.target.closest('.btn-delete-session');
|
|
974
|
+
if (deleteSessionBtn) {
|
|
975
|
+
event.preventDefault();
|
|
976
|
+
event.stopPropagation();
|
|
977
|
+
showDeleteSessionConfirm(deleteSessionBtn);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Show more (PR reviews)
|
|
982
|
+
const showMoreBtn = event.target.closest('#btn-show-more');
|
|
983
|
+
if (showMoreBtn) {
|
|
984
|
+
event.preventDefault();
|
|
985
|
+
loadMoreReviews();
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Show more (local reviews)
|
|
990
|
+
const localShowMoreBtn = event.target.closest('#btn-local-show-more');
|
|
991
|
+
if (localShowMoreBtn) {
|
|
992
|
+
event.preventDefault();
|
|
993
|
+
loadMoreLocalReviews();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Unified tab switching
|
|
998
|
+
const unifiedTabBtn = event.target.closest('#unified-tab-bar .tab-btn');
|
|
999
|
+
if (unifiedTabBtn) {
|
|
1000
|
+
const tabBar = document.getElementById('unified-tab-bar');
|
|
1001
|
+
switchTab(tabBar, unifiedTabBtn, function (tabId) {
|
|
1002
|
+
// Persist tab choice
|
|
1003
|
+
localStorage.setItem(TAB_STORAGE_KEY, tabId);
|
|
1004
|
+
// Lazy-load local reviews on first switch
|
|
1005
|
+
if (tabId === 'local-tab' && !localReviewsPagination.loaded) {
|
|
1006
|
+
loadLocalReviews();
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// ─── DOMContentLoaded Initialization ────────────────────────────────────────
|
|
1014
|
+
|
|
1015
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
1016
|
+
// Load config and update command examples based on npx detection
|
|
1017
|
+
loadConfigAndUpdateUI().then(function () {
|
|
1018
|
+
// Sync help content to usage-info section AFTER command examples are updated
|
|
1019
|
+
const helpContent = document.querySelector('.help-modal-content');
|
|
1020
|
+
const usageInfo = document.getElementById('usage-info');
|
|
1021
|
+
if (helpContent && usageInfo) {
|
|
1022
|
+
usageInfo.innerHTML = '';
|
|
1023
|
+
Array.from(helpContent.childNodes).forEach(function (node) {
|
|
1024
|
+
usageInfo.appendChild(node.cloneNode(true));
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// Restore saved tab from localStorage (default: 'pr-tab')
|
|
1030
|
+
const savedTab = localStorage.getItem(TAB_STORAGE_KEY) || 'pr-tab';
|
|
1031
|
+
const tabBar = document.getElementById('unified-tab-bar');
|
|
1032
|
+
if (tabBar) {
|
|
1033
|
+
const targetBtn = tabBar.querySelector('[data-tab="' + savedTab + '"]');
|
|
1034
|
+
if (targetBtn && savedTab !== 'pr-tab') {
|
|
1035
|
+
// Switch to saved tab (pr-tab is already active by default in HTML)
|
|
1036
|
+
switchTab(tabBar, targetBtn);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Always load PR reviews (they show on initial load)
|
|
1041
|
+
loadRecentReviews();
|
|
1042
|
+
|
|
1043
|
+
// If local tab is active, load local reviews immediately
|
|
1044
|
+
if (savedTab === 'local-tab') {
|
|
1045
|
+
loadLocalReviews();
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Set up start review form handler
|
|
1049
|
+
const form = document.getElementById('start-review-form');
|
|
1050
|
+
if (form) {
|
|
1051
|
+
form.addEventListener('submit', handleStartReview);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Set up local review form handler
|
|
1055
|
+
const localForm = document.getElementById('start-local-form');
|
|
1056
|
+
if (localForm) {
|
|
1057
|
+
localForm.addEventListener('submit', handleStartLocal);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Set up browse button handler
|
|
1061
|
+
const browseBtn = document.getElementById('browse-local-btn');
|
|
1062
|
+
if (browseBtn) {
|
|
1063
|
+
browseBtn.addEventListener('click', handleBrowseLocal);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Note: No explicit Enter keypress handlers are needed here.
|
|
1067
|
+
// Both inputs are inside <form> elements, so pressing Enter
|
|
1068
|
+
// natively triggers form submission.
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
})();
|