@in-the-loop-labs/pair-review 2.6.2 → 2.7.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/bin/git-diff-lines +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
- package/public/css/pr.css +201 -0
- package/public/index.html +168 -3
- package/public/js/components/AIPanel.js +16 -2
- package/public/js/components/ChatPanel.js +41 -6
- package/public/js/components/ConfirmDialog.js +21 -2
- package/public/js/components/CouncilProgressModal.js +13 -0
- package/public/js/components/DiffOptionsDropdown.js +410 -23
- package/public/js/components/SuggestionNavigator.js +12 -5
- package/public/js/components/TabTitle.js +96 -0
- package/public/js/components/Toast.js +6 -0
- package/public/js/index.js +648 -43
- package/public/js/local.js +569 -76
- package/public/js/modules/analysis-history.js +3 -2
- package/public/js/modules/comment-manager.js +5 -0
- package/public/js/modules/comment-minimizer.js +304 -0
- package/public/js/pr.js +82 -6
- package/public/local.html +14 -0
- package/public/pr.html +3 -0
- package/src/ai/analyzer.js +22 -16
- package/src/ai/cursor-agent-provider.js +21 -12
- package/src/chat/prompt-builder.js +3 -3
- package/src/config.js +2 -0
- package/src/database.js +590 -39
- package/src/git/base-branch.js +173 -0
- package/src/git/sha-abbrev.js +35 -0
- package/src/git/worktree.js +3 -2
- package/src/github/client.js +32 -1
- package/src/hooks/hook-runner.js +100 -0
- package/src/hooks/payloads.js +212 -0
- package/src/local-review.js +468 -129
- package/src/local-scope.js +58 -0
- package/src/main.js +57 -6
- package/src/routes/analyses.js +73 -10
- package/src/routes/chat.js +33 -0
- package/src/routes/config.js +1 -0
- package/src/routes/github-collections.js +2 -2
- package/src/routes/local.js +734 -68
- package/src/routes/mcp.js +20 -10
- package/src/routes/pr.js +92 -14
- package/src/routes/setup.js +1 -0
- package/src/routes/worktrees.js +212 -148
- package/src/server.js +30 -0
- package/src/setup/local-setup.js +46 -5
- package/src/setup/pr-setup.js +28 -5
- package/src/utils/diff-file-list.js +1 -1
package/public/js/index.js
CHANGED
|
@@ -66,6 +66,12 @@
|
|
|
66
66
|
// Close on Escape key
|
|
67
67
|
document.addEventListener('keydown', function (e) {
|
|
68
68
|
if (e.key === 'Escape') {
|
|
69
|
+
// Exit selection mode first (higher priority)
|
|
70
|
+
if (activeSelection && activeSelection.active) {
|
|
71
|
+
activeSelection.exit();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Then try closing help modal
|
|
69
75
|
const overlay = document.getElementById('help-modal-overlay');
|
|
70
76
|
if (overlay.classList.contains('visible')) {
|
|
71
77
|
closeHelpModal();
|
|
@@ -229,8 +235,9 @@
|
|
|
229
235
|
const link = '/local/' + session.id;
|
|
230
236
|
const relativeTime = formatRelativeTime(session.updated_at);
|
|
231
237
|
const pathDisplay = session.local_path || '';
|
|
238
|
+
const abbrevLen = session.sha_abbrev_length || 7;
|
|
232
239
|
const sha = session.local_head_sha
|
|
233
|
-
? session.local_head_sha.substring(0,
|
|
240
|
+
? session.local_head_sha.substring(0, abbrevLen)
|
|
234
241
|
: '';
|
|
235
242
|
const hasName = !!session.name;
|
|
236
243
|
const nameDisplay = hasName ? escapeHtml(session.name) : '<em>Untitled</em>';
|
|
@@ -328,7 +335,7 @@
|
|
|
328
335
|
: '<td class="col-author">' + authorDisplay + '</td>';
|
|
329
336
|
|
|
330
337
|
return '' +
|
|
331
|
-
'<tr class="collection-pr-row" data-pr-url="' + escapeHtml(prUrl) + '">' +
|
|
338
|
+
'<tr class="collection-pr-row" data-pr-url="' + escapeHtml(prUrl) + '" data-owner="' + escapeHtml(pr.owner) + '" data-repo="' + escapeHtml(pr.repo) + '" data-number="' + pr.number + '">' +
|
|
332
339
|
'<td class="col-repo">' + escapeHtml(repoFull) + '</td>' +
|
|
333
340
|
'<td class="col-pr"><span class="collection-pr-number">#' + pr.number + '</span></td>' +
|
|
334
341
|
'<td class="col-title" title="' + escapeHtml(pr.title || '') + '">' + escapeHtml(pr.title || '') + '</td>' +
|
|
@@ -345,6 +352,9 @@
|
|
|
345
352
|
* @param {string} collection - The collection name ('review-requests' or 'my-prs')
|
|
346
353
|
*/
|
|
347
354
|
function renderCollectionTable(container, state, collection) {
|
|
355
|
+
var sel = collection === 'review-requests' ? reviewRequestsSelection : myPrsSelection;
|
|
356
|
+
sel.exit();
|
|
357
|
+
|
|
348
358
|
var fetchedAtId = collection === 'review-requests' ? 'review-requests-fetched-at' : 'my-prs-fetched-at';
|
|
349
359
|
var fetchedAtEl = document.getElementById(fetchedAtId);
|
|
350
360
|
if (fetchedAtEl) {
|
|
@@ -374,6 +384,8 @@
|
|
|
374
384
|
|
|
375
385
|
var authorTh = collection === 'my-prs' ? '' : '<th>Author</th>';
|
|
376
386
|
|
|
387
|
+
var tbodyId = collection === 'review-requests' ? 'review-requests-tbody' : 'my-prs-tbody';
|
|
388
|
+
|
|
377
389
|
container.innerHTML =
|
|
378
390
|
'<table class="recent-reviews-table">' +
|
|
379
391
|
'<thead>' +
|
|
@@ -386,7 +398,7 @@
|
|
|
386
398
|
'<th>Actions</th>' +
|
|
387
399
|
'</tr>' +
|
|
388
400
|
'</thead>' +
|
|
389
|
-
'<tbody>' +
|
|
401
|
+
'<tbody id="' + tbodyId + '">' +
|
|
390
402
|
state.prs.map(function (pr) { return renderCollectionPrRow(pr, collection); }).join('') +
|
|
391
403
|
'</tbody>' +
|
|
392
404
|
'</table>';
|
|
@@ -486,6 +498,8 @@
|
|
|
486
498
|
* Fetch and display local review sessions (initial load).
|
|
487
499
|
*/
|
|
488
500
|
async function loadLocalReviews() {
|
|
501
|
+
localSelection.exit();
|
|
502
|
+
|
|
489
503
|
const container = document.getElementById('local-reviews-container');
|
|
490
504
|
if (!container) return;
|
|
491
505
|
|
|
@@ -577,6 +591,7 @@
|
|
|
577
591
|
}
|
|
578
592
|
|
|
579
593
|
tbody.insertAdjacentHTML('beforeend', data.sessions.map(renderLocalReviewRow).join(''));
|
|
594
|
+
if (localSelection.active) localSelection.onRowsAdded();
|
|
580
595
|
|
|
581
596
|
localReviewsPagination.lastTimestamp = data.sessions[data.sessions.length - 1].updated_at;
|
|
582
597
|
localReviewsPagination.hasMore = !!data.hasMore;
|
|
@@ -734,34 +749,34 @@
|
|
|
734
749
|
|
|
735
750
|
/**
|
|
736
751
|
* Render a single recent review table row
|
|
737
|
-
* @param {Object}
|
|
752
|
+
* @param {Object} review - Review data
|
|
738
753
|
* @returns {string} HTML string for the table row
|
|
739
754
|
*/
|
|
740
|
-
function renderRecentReviewRow(
|
|
741
|
-
const parts =
|
|
755
|
+
function renderRecentReviewRow(review) {
|
|
756
|
+
const parts = review.repository.split('/');
|
|
742
757
|
const owner = parts[0];
|
|
743
758
|
const repo = parts[1];
|
|
744
|
-
const link = '/pr/' + owner + '/' + repo + '/' +
|
|
759
|
+
const link = '/pr/' + owner + '/' + repo + '/' + review.pr_number;
|
|
745
760
|
const settingsLink = '/repo-settings.html?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo);
|
|
746
|
-
const relativeTime = formatRelativeTime(
|
|
761
|
+
const relativeTime = formatRelativeTime(review.last_accessed_at);
|
|
747
762
|
|
|
748
|
-
const authorDisplay =
|
|
749
|
-
? '<a href="https://github.com/' + encodeURIComponent(
|
|
763
|
+
const authorDisplay = review.author
|
|
764
|
+
? '<a href="https://github.com/' + encodeURIComponent(review.author) + '" target="_blank" rel="noopener">' + escapeHtml(review.author) + '</a>'
|
|
750
765
|
: '';
|
|
751
766
|
|
|
752
767
|
return '' +
|
|
753
|
-
'<tr>' +
|
|
754
|
-
'<td class="col-repo">' + escapeHtml(
|
|
755
|
-
'<td class="col-pr"><a href="' + link + '">#' +
|
|
756
|
-
'<td class="col-title" title="' + escapeHtml(
|
|
768
|
+
'<tr data-review-id="' + review.id + '">' +
|
|
769
|
+
'<td class="col-repo">' + escapeHtml(review.repository) + '</td>' +
|
|
770
|
+
'<td class="col-pr"><a href="' + link + '">#' + review.pr_number + '</a></td>' +
|
|
771
|
+
'<td class="col-title" title="' + escapeHtml(review.pr_title) + '">' + escapeHtml(review.pr_title) + '</td>' +
|
|
757
772
|
'<td class="col-author">' + authorDisplay + '</td>' +
|
|
758
773
|
'<td class="col-time">' + relativeTime + '</td>' +
|
|
759
774
|
'<td class="col-actions">' +
|
|
760
|
-
'<a href="https://github.com/' + escapeHtml(
|
|
775
|
+
'<a href="https://github.com/' + escapeHtml(review.repository) + '/pull/' + review.pr_number + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on GitHub">' +
|
|
761
776
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>' +
|
|
762
777
|
'</a>' +
|
|
763
|
-
(window.__pairReview?.enableGraphite &&
|
|
764
|
-
? '<a href="' + escapeHtml(window.__pairReview.toGraphiteUrl(
|
|
778
|
+
(window.__pairReview?.enableGraphite && review.html_url
|
|
779
|
+
? '<a href="' + escapeHtml(window.__pairReview.toGraphiteUrl(review.html_url)) + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on Graphite">' +
|
|
765
780
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.7932,1.3079L3.101,3.101l-1.7932,6.6921,4.899,4.899,6.6921-1.7931,1.7932-6.6921L9.7932,1.3079Zm1.0936,11.6921H5.1133l-2.8867-5L5.1133,3h5.7735l2.8867,5-2.8867,5Z"/><polygon points="11.3504 4.6496 6.7737 3.4232 3.4232 6.7737 4.6496 11.3504 9.2263 12.5768 12.5768 9.2263 11.3504 4.6496"/></svg>' +
|
|
766
781
|
'</a>'
|
|
767
782
|
: '') +
|
|
@@ -771,11 +786,11 @@
|
|
|
771
786
|
'</svg>' +
|
|
772
787
|
'</a>' +
|
|
773
788
|
'<button' +
|
|
774
|
-
' class="btn-delete-
|
|
775
|
-
' data-
|
|
776
|
-
' data-repository="' + escapeHtml(
|
|
777
|
-
' data-pr-number="' +
|
|
778
|
-
' title="Delete
|
|
789
|
+
' class="btn-delete-review"' +
|
|
790
|
+
' data-review-id="' + review.id + '"' +
|
|
791
|
+
' data-repository="' + escapeHtml(review.repository) + '"' +
|
|
792
|
+
' data-pr-number="' + review.pr_number + '"' +
|
|
793
|
+
' title="Delete review"' +
|
|
779
794
|
'>' +
|
|
780
795
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
|
|
781
796
|
'<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>' +
|
|
@@ -786,11 +801,11 @@
|
|
|
786
801
|
}
|
|
787
802
|
|
|
788
803
|
/**
|
|
789
|
-
* Show inline delete confirmation for a PR
|
|
804
|
+
* Show inline delete confirmation for a PR review row
|
|
790
805
|
* @param {HTMLElement} button - The delete button element
|
|
791
806
|
*/
|
|
792
|
-
function
|
|
793
|
-
const
|
|
807
|
+
function showDeleteReviewConfirm(button) {
|
|
808
|
+
const reviewId = button.dataset.reviewId;
|
|
794
809
|
const repository = button.dataset.repository;
|
|
795
810
|
const prNumber = button.dataset.prNumber;
|
|
796
811
|
const row = button.closest('tr');
|
|
@@ -808,8 +823,8 @@
|
|
|
808
823
|
row.innerHTML =
|
|
809
824
|
'<td colspan="' + colCount + '">' +
|
|
810
825
|
'<div class="delete-confirm-inner">' +
|
|
811
|
-
'<span>Delete
|
|
812
|
-
'<button class="btn-confirm-yes" data-
|
|
826
|
+
'<span>Delete review for ' + escapeHtml(repository) + ' #' + escapeHtml(String(prNumber)) + '?</span>' +
|
|
827
|
+
'<button class="btn-confirm-yes" data-review-id="' + reviewId + '">Delete</button>' +
|
|
813
828
|
'<button class="btn-confirm-no">Cancel</button>' +
|
|
814
829
|
'</div>' +
|
|
815
830
|
'</td>';
|
|
@@ -817,20 +832,20 @@
|
|
|
817
832
|
// Wire up buttons
|
|
818
833
|
row.querySelector('.btn-confirm-yes').addEventListener('click', async function () {
|
|
819
834
|
try {
|
|
820
|
-
const response = await fetch('/api/worktrees/' +
|
|
835
|
+
const response = await fetch('/api/worktrees/' + reviewId, {
|
|
821
836
|
method: 'DELETE'
|
|
822
837
|
});
|
|
823
838
|
|
|
824
839
|
if (!response.ok) {
|
|
825
840
|
const data = await response.json().catch(function () { return {}; });
|
|
826
|
-
throw new Error(data.error || 'Failed to delete
|
|
841
|
+
throw new Error(data.error || 'Failed to delete review');
|
|
827
842
|
}
|
|
828
843
|
|
|
829
844
|
// Reload the recent reviews list
|
|
830
845
|
await loadRecentReviews();
|
|
831
846
|
|
|
832
847
|
} catch (error) {
|
|
833
|
-
console.error('Error deleting
|
|
848
|
+
console.error('Error deleting review:', error);
|
|
834
849
|
// Restore row on failure
|
|
835
850
|
row.classList.remove('delete-confirm-row');
|
|
836
851
|
row.innerHTML = originalHTML;
|
|
@@ -847,7 +862,7 @@
|
|
|
847
862
|
const recentReviewsPagination = {
|
|
848
863
|
/** ISO timestamp of the last loaded item (cursor for next fetch) */
|
|
849
864
|
lastTimestamp: null,
|
|
850
|
-
/** Number of
|
|
865
|
+
/** Number of reviews to fetch per page */
|
|
851
866
|
pageSize: 10,
|
|
852
867
|
/** Whether the server has indicated more results exist */
|
|
853
868
|
hasMore: false
|
|
@@ -858,6 +873,8 @@
|
|
|
858
873
|
* Resets pagination state and renders the full table from scratch.
|
|
859
874
|
*/
|
|
860
875
|
async function loadRecentReviews() {
|
|
876
|
+
prSelection.exit();
|
|
877
|
+
|
|
861
878
|
const container = document.getElementById('recent-reviews-container');
|
|
862
879
|
const section = document.getElementById('recent-reviews-section');
|
|
863
880
|
// Reset pagination state
|
|
@@ -873,7 +890,7 @@
|
|
|
873
890
|
|
|
874
891
|
const data = await response.json();
|
|
875
892
|
|
|
876
|
-
if (!data.success || !data.
|
|
893
|
+
if (!data.success || !data.reviews || data.reviews.length === 0) {
|
|
877
894
|
// Show friendly empty state
|
|
878
895
|
container.innerHTML =
|
|
879
896
|
'<div class="recent-reviews-empty">' +
|
|
@@ -886,7 +903,7 @@
|
|
|
886
903
|
}
|
|
887
904
|
|
|
888
905
|
// Update pagination state - track the cursor for the next page
|
|
889
|
-
recentReviewsPagination.lastTimestamp = data.
|
|
906
|
+
recentReviewsPagination.lastTimestamp = data.reviews[data.reviews.length - 1].last_accessed_at;
|
|
890
907
|
recentReviewsPagination.hasMore = !!data.hasMore;
|
|
891
908
|
|
|
892
909
|
// Render the table of recent reviews
|
|
@@ -903,7 +920,7 @@
|
|
|
903
920
|
'</tr>' +
|
|
904
921
|
'</thead>' +
|
|
905
922
|
'<tbody id="recent-reviews-tbody">' +
|
|
906
|
-
data.
|
|
923
|
+
data.reviews.map(renderRecentReviewRow).join('') +
|
|
907
924
|
'</tbody>' +
|
|
908
925
|
'</table>' +
|
|
909
926
|
renderShowMoreButton(data.hasMore);
|
|
@@ -937,7 +954,7 @@
|
|
|
937
954
|
}
|
|
938
955
|
|
|
939
956
|
/**
|
|
940
|
-
* Load the next page of
|
|
957
|
+
* Load the next page of reviews and append them to the existing table.
|
|
941
958
|
* Called when the "Show more" button is clicked.
|
|
942
959
|
*/
|
|
943
960
|
async function loadMoreReviews() {
|
|
@@ -963,7 +980,7 @@
|
|
|
963
980
|
// Guard against stale response if the table was refreshed (e.g. by a delete) while loading
|
|
964
981
|
if (!document.contains(btn)) return;
|
|
965
982
|
|
|
966
|
-
if (!data.success || !data.
|
|
983
|
+
if (!data.success || !data.reviews || data.reviews.length === 0) {
|
|
967
984
|
// No more results - remove the button
|
|
968
985
|
const showMoreContainer = document.getElementById('show-more-container');
|
|
969
986
|
if (showMoreContainer) showMoreContainer.remove();
|
|
@@ -972,10 +989,11 @@
|
|
|
972
989
|
}
|
|
973
990
|
|
|
974
991
|
// Append new rows to the existing table body
|
|
975
|
-
tbody.insertAdjacentHTML('beforeend', data.
|
|
992
|
+
tbody.insertAdjacentHTML('beforeend', data.reviews.map(renderRecentReviewRow).join(''));
|
|
993
|
+
if (prSelection.active) prSelection.onRowsAdded();
|
|
976
994
|
|
|
977
995
|
// Update pagination state - advance the cursor
|
|
978
|
-
recentReviewsPagination.lastTimestamp = data.
|
|
996
|
+
recentReviewsPagination.lastTimestamp = data.reviews[data.reviews.length - 1].last_accessed_at;
|
|
979
997
|
recentReviewsPagination.hasMore = !!data.hasMore;
|
|
980
998
|
|
|
981
999
|
// Update or remove the "Show more" button
|
|
@@ -1119,6 +1137,7 @@
|
|
|
1119
1137
|
const chatProviders = config.chat_providers || [];
|
|
1120
1138
|
window.__pairReview.chatProviders = chatProviders;
|
|
1121
1139
|
window.__pairReview.enableGraphite = config.enable_graphite === true;
|
|
1140
|
+
window.__pairReview.chatSpinner = config.chat_spinner || 'dots';
|
|
1122
1141
|
|
|
1123
1142
|
// Set chat feature state based on config and provider availability
|
|
1124
1143
|
let chatState = 'disabled';
|
|
@@ -1139,16 +1158,528 @@
|
|
|
1139
1158
|
}
|
|
1140
1159
|
}
|
|
1141
1160
|
|
|
1161
|
+
// ─── Selection Mode ──────────────────────────────────────────────────────
|
|
1162
|
+
|
|
1163
|
+
/** Currently active SelectionMode instance (only one tab at a time) */
|
|
1164
|
+
var activeSelection = null;
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* SelectionMode manages checkbox-based selection for a single tab's table.
|
|
1168
|
+
*
|
|
1169
|
+
* @param {Object} config
|
|
1170
|
+
* @param {string} config.tabId - Tab pane element ID (e.g. 'pr-tab')
|
|
1171
|
+
* @param {string} config.containerId - Table container ID (e.g. 'recent-reviews-container')
|
|
1172
|
+
* @param {string} config.tbodyId - Table body ID (e.g. 'recent-reviews-tbody')
|
|
1173
|
+
* @param {string} config.rowIdAttr - data attribute name on <tr> for the row's ID (e.g. 'reviewId' reads tr.dataset.reviewId)
|
|
1174
|
+
* @param {Array} config.actions - [{ label: string, className: string, handler: function(selectedIds, selectionInstance) }]
|
|
1175
|
+
*/
|
|
1176
|
+
function SelectionMode(config) {
|
|
1177
|
+
this.config = config;
|
|
1178
|
+
this.active = false;
|
|
1179
|
+
this.selectedIds = new Set();
|
|
1180
|
+
this._actionBar = null;
|
|
1181
|
+
this._toggleBtn = null;
|
|
1182
|
+
this._confirming = false;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
SelectionMode.prototype.enter = function () {
|
|
1186
|
+
if (this.active) return;
|
|
1187
|
+
this.active = true;
|
|
1188
|
+
this.selectedIds.clear();
|
|
1189
|
+
this._confirming = false;
|
|
1190
|
+
|
|
1191
|
+
// Deactivate any other active selection
|
|
1192
|
+
if (activeSelection && activeSelection !== this) {
|
|
1193
|
+
activeSelection.exit();
|
|
1194
|
+
}
|
|
1195
|
+
activeSelection = this;
|
|
1196
|
+
|
|
1197
|
+
var container = document.getElementById(this.config.containerId);
|
|
1198
|
+
if (container) container.classList.add('selection-mode');
|
|
1199
|
+
|
|
1200
|
+
// Hide the Select button, show inline action controls
|
|
1201
|
+
if (this._toggleBtn) {
|
|
1202
|
+
this._toggleBtn.style.display = 'none';
|
|
1203
|
+
}
|
|
1204
|
+
this._ensureInlineActions();
|
|
1205
|
+
this._showInlineActions();
|
|
1206
|
+
|
|
1207
|
+
this._injectCheckboxes();
|
|
1208
|
+
this._updateInlineActions();
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
SelectionMode.prototype.exit = function () {
|
|
1212
|
+
if (!this.active) return;
|
|
1213
|
+
this.active = false;
|
|
1214
|
+
this.selectedIds.clear();
|
|
1215
|
+
this._confirming = false;
|
|
1216
|
+
|
|
1217
|
+
if (activeSelection === this) activeSelection = null;
|
|
1218
|
+
|
|
1219
|
+
var container = document.getElementById(this.config.containerId);
|
|
1220
|
+
if (container) container.classList.remove('selection-mode');
|
|
1221
|
+
|
|
1222
|
+
// Show Select button, hide inline action controls
|
|
1223
|
+
if (this._toggleBtn) {
|
|
1224
|
+
this._toggleBtn.style.display = '';
|
|
1225
|
+
}
|
|
1226
|
+
this._hideInlineActions();
|
|
1227
|
+
|
|
1228
|
+
this._removeCheckboxes();
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
SelectionMode.prototype.toggle = function () {
|
|
1232
|
+
if (this.active) {
|
|
1233
|
+
this.exit();
|
|
1234
|
+
} else {
|
|
1235
|
+
this.enter();
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
SelectionMode.prototype._getTable = function () {
|
|
1240
|
+
var container = document.getElementById(this.config.containerId);
|
|
1241
|
+
return container ? container.querySelector('table') : null;
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
SelectionMode.prototype._getTbody = function () {
|
|
1245
|
+
return document.getElementById(this.config.tbodyId);
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
SelectionMode.prototype._injectCheckboxes = function () {
|
|
1249
|
+
var table = this._getTable();
|
|
1250
|
+
if (!table) return;
|
|
1251
|
+
|
|
1252
|
+
// Add select-all checkbox to thead
|
|
1253
|
+
var thead = table.querySelector('thead tr');
|
|
1254
|
+
if (thead) {
|
|
1255
|
+
var th = document.createElement('th');
|
|
1256
|
+
th.className = 'col-select';
|
|
1257
|
+
th.innerHTML = '<input type="checkbox" class="select-all-checkbox" title="Select all">';
|
|
1258
|
+
thead.insertBefore(th, thead.firstChild);
|
|
1259
|
+
|
|
1260
|
+
var self = this;
|
|
1261
|
+
th.querySelector('input').addEventListener('change', function () {
|
|
1262
|
+
self._handleSelectAll(this.checked);
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Add checkboxes to all existing rows
|
|
1267
|
+
var tbody = this._getTbody();
|
|
1268
|
+
if (tbody) {
|
|
1269
|
+
var rows = tbody.querySelectorAll('tr');
|
|
1270
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1271
|
+
this._injectCheckboxIntoRow(rows[i]);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
SelectionMode.prototype._injectCheckboxIntoRow = function (tr) {
|
|
1277
|
+
// Skip rows that already have a checkbox (e.g. delete-confirm rows)
|
|
1278
|
+
if (tr.querySelector('.col-select')) return;
|
|
1279
|
+
// Skip delete confirmation rows
|
|
1280
|
+
if (tr.classList.contains('delete-confirm-row')) return;
|
|
1281
|
+
|
|
1282
|
+
var rowId = tr.dataset[this.config.rowIdAttr];
|
|
1283
|
+
var td = document.createElement('td');
|
|
1284
|
+
td.className = 'col-select';
|
|
1285
|
+
td.innerHTML = '<input type="checkbox" data-select-id="' + (rowId || '') + '">';
|
|
1286
|
+
tr.insertBefore(td, tr.firstChild);
|
|
1287
|
+
|
|
1288
|
+
var self = this;
|
|
1289
|
+
td.querySelector('input').addEventListener('change', function () {
|
|
1290
|
+
self._handleRowCheckbox(this.dataset.selectId, this.checked, tr);
|
|
1291
|
+
});
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
SelectionMode.prototype._removeCheckboxes = function () {
|
|
1295
|
+
var table = this._getTable();
|
|
1296
|
+
if (!table) return;
|
|
1297
|
+
|
|
1298
|
+
// Remove all .col-select cells
|
|
1299
|
+
var cells = table.querySelectorAll('.col-select');
|
|
1300
|
+
for (var i = 0; i < cells.length; i++) {
|
|
1301
|
+
cells[i].remove();
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Remove selected class from all rows
|
|
1305
|
+
var rows = table.querySelectorAll('tr.bulk-selected');
|
|
1306
|
+
for (var j = 0; j < rows.length; j++) {
|
|
1307
|
+
rows[j].classList.remove('bulk-selected');
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
SelectionMode.prototype._handleSelectAll = function (checked) {
|
|
1312
|
+
var tbody = this._getTbody();
|
|
1313
|
+
if (!tbody) return;
|
|
1314
|
+
|
|
1315
|
+
var checkboxes = tbody.querySelectorAll('.col-select input[type="checkbox"]');
|
|
1316
|
+
for (var i = 0; i < checkboxes.length; i++) {
|
|
1317
|
+
var cb = checkboxes[i];
|
|
1318
|
+
cb.checked = checked;
|
|
1319
|
+
var id = cb.dataset.selectId;
|
|
1320
|
+
var row = cb.closest('tr');
|
|
1321
|
+
if (checked) {
|
|
1322
|
+
if (id) this.selectedIds.add(id);
|
|
1323
|
+
if (row) row.classList.add('bulk-selected');
|
|
1324
|
+
} else {
|
|
1325
|
+
this.selectedIds.delete(id);
|
|
1326
|
+
if (row) row.classList.remove('bulk-selected');
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
this._updateInlineActions();
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
SelectionMode.prototype._handleRowCheckbox = function (id, checked, tr) {
|
|
1333
|
+
if (checked) {
|
|
1334
|
+
if (id) this.selectedIds.add(id);
|
|
1335
|
+
tr.classList.add('bulk-selected');
|
|
1336
|
+
} else {
|
|
1337
|
+
this.selectedIds.delete(id);
|
|
1338
|
+
tr.classList.remove('bulk-selected');
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Update select-all checkbox state
|
|
1342
|
+
var table = this._getTable();
|
|
1343
|
+
if (table) {
|
|
1344
|
+
var selectAllCb = table.querySelector('.select-all-checkbox');
|
|
1345
|
+
if (selectAllCb) {
|
|
1346
|
+
var tbody = this._getTbody();
|
|
1347
|
+
var total = tbody ? tbody.querySelectorAll('.col-select input[type="checkbox"]').length : 0;
|
|
1348
|
+
selectAllCb.checked = total > 0 && this.selectedIds.size === total;
|
|
1349
|
+
selectAllCb.indeterminate = !selectAllCb.checked && this.selectedIds.size > 0;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
this._updateInlineActions();
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
SelectionMode.prototype.onRowsAdded = function () {
|
|
1357
|
+
if (!this.active) return;
|
|
1358
|
+
var tbody = this._getTbody();
|
|
1359
|
+
if (!tbody) return;
|
|
1360
|
+
|
|
1361
|
+
var rows = tbody.querySelectorAll('tr');
|
|
1362
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1363
|
+
if (!rows[i].querySelector('.col-select')) {
|
|
1364
|
+
this._injectCheckboxIntoRow(rows[i]);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Uncheck select-all since new rows are not selected
|
|
1369
|
+
var table = this._getTable();
|
|
1370
|
+
if (table) {
|
|
1371
|
+
var selectAllCb = table.querySelector('.select-all-checkbox');
|
|
1372
|
+
if (selectAllCb) {
|
|
1373
|
+
selectAllCb.checked = false;
|
|
1374
|
+
selectAllCb.indeterminate = this.selectedIds.size > 0;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Build the inline action controls (action buttons + count + cancel) and
|
|
1381
|
+
* insert them next to the Select toggle button. Created once, then
|
|
1382
|
+
* shown/hidden on enter/exit.
|
|
1383
|
+
*/
|
|
1384
|
+
SelectionMode.prototype._ensureInlineActions = function () {
|
|
1385
|
+
if (this._inlineEl) return;
|
|
1386
|
+
if (!this._toggleBtn) return;
|
|
1387
|
+
|
|
1388
|
+
var wrapper = document.createElement('span');
|
|
1389
|
+
wrapper.className = 'bulk-inline-actions';
|
|
1390
|
+
|
|
1391
|
+
// Count label
|
|
1392
|
+
var countSpan = document.createElement('span');
|
|
1393
|
+
countSpan.className = 'bulk-action-count';
|
|
1394
|
+
wrapper.appendChild(countSpan);
|
|
1395
|
+
|
|
1396
|
+
// Action buttons (disabled by default — enabled when selection count > 0)
|
|
1397
|
+
var buttonsSpan = document.createElement('span');
|
|
1398
|
+
buttonsSpan.className = 'bulk-action-buttons';
|
|
1399
|
+
var self = this;
|
|
1400
|
+
this._actionBtns = [];
|
|
1401
|
+
for (var i = 0; i < this.config.actions.length; i++) {
|
|
1402
|
+
var action = this.config.actions[i];
|
|
1403
|
+
var btn = document.createElement('button');
|
|
1404
|
+
btn.className = action.className;
|
|
1405
|
+
btn.textContent = action.label;
|
|
1406
|
+
btn.disabled = true;
|
|
1407
|
+
btn.addEventListener('click', (function (act) {
|
|
1408
|
+
return function () {
|
|
1409
|
+
act.handler(new Set(self.selectedIds), self);
|
|
1410
|
+
};
|
|
1411
|
+
})(action));
|
|
1412
|
+
buttonsSpan.appendChild(btn);
|
|
1413
|
+
this._actionBtns.push(btn);
|
|
1414
|
+
}
|
|
1415
|
+
wrapper.appendChild(buttonsSpan);
|
|
1416
|
+
|
|
1417
|
+
// Confirm buttons (hidden by default, shown when confirming)
|
|
1418
|
+
var confirmSpan = document.createElement('span');
|
|
1419
|
+
confirmSpan.className = 'bulk-confirm-buttons';
|
|
1420
|
+
var confirmYes = document.createElement('button');
|
|
1421
|
+
confirmYes.className = 'btn-bulk-delete';
|
|
1422
|
+
confirmYes.textContent = 'Confirm';
|
|
1423
|
+
var confirmNo = document.createElement('button');
|
|
1424
|
+
confirmNo.className = 'btn-bulk-cancel';
|
|
1425
|
+
confirmNo.textContent = 'Cancel';
|
|
1426
|
+
confirmSpan.appendChild(confirmYes);
|
|
1427
|
+
confirmSpan.appendChild(confirmNo);
|
|
1428
|
+
wrapper.appendChild(confirmSpan);
|
|
1429
|
+
|
|
1430
|
+
// Cancel button (exits selection mode)
|
|
1431
|
+
var cancelBtn = document.createElement('button');
|
|
1432
|
+
cancelBtn.className = 'btn-bulk-cancel';
|
|
1433
|
+
cancelBtn.textContent = 'Cancel';
|
|
1434
|
+
cancelBtn.addEventListener('click', function () {
|
|
1435
|
+
self.exit();
|
|
1436
|
+
});
|
|
1437
|
+
wrapper.appendChild(cancelBtn);
|
|
1438
|
+
|
|
1439
|
+
// Insert after the toggle button
|
|
1440
|
+
this._toggleBtn.parentNode.insertBefore(wrapper, this._toggleBtn.nextSibling);
|
|
1441
|
+
this._inlineEl = wrapper;
|
|
1442
|
+
this._countEl = countSpan;
|
|
1443
|
+
this._confirmYes = confirmYes;
|
|
1444
|
+
this._confirmNo = confirmNo;
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
SelectionMode.prototype._showInlineActions = function () {
|
|
1448
|
+
if (this._inlineEl) this._inlineEl.style.display = '';
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
SelectionMode.prototype._hideInlineActions = function () {
|
|
1452
|
+
if (this._inlineEl) {
|
|
1453
|
+
this._inlineEl.style.display = 'none';
|
|
1454
|
+
this._inlineEl.classList.remove('confirming');
|
|
1455
|
+
this._confirming = false;
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
SelectionMode.prototype._updateInlineActions = function () {
|
|
1460
|
+
if (!this._inlineEl) return;
|
|
1461
|
+
|
|
1462
|
+
var count = this.selectedIds.size;
|
|
1463
|
+
|
|
1464
|
+
// Update count label
|
|
1465
|
+
// Clear count text when not confirming (only used for confirm message)
|
|
1466
|
+
if (this._countEl && !this._confirming) {
|
|
1467
|
+
this._countEl.textContent = '';
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Enable/disable action buttons
|
|
1471
|
+
for (var i = 0; i < this._actionBtns.length; i++) {
|
|
1472
|
+
this._actionBtns[i].disabled = count === 0;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Exit confirming state if count drops to 0
|
|
1476
|
+
if (count === 0 && this._confirming) {
|
|
1477
|
+
this._inlineEl.classList.remove('confirming');
|
|
1478
|
+
this._confirming = false;
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Show confirmation state in the inline action controls.
|
|
1484
|
+
* @param {string} message - Confirmation message (e.g. "Delete 3 reviews?")
|
|
1485
|
+
* @param {Function} onConfirm - Called when user confirms
|
|
1486
|
+
*/
|
|
1487
|
+
SelectionMode.prototype.showConfirm = function (message, onConfirm) {
|
|
1488
|
+
if (!this._inlineEl) return;
|
|
1489
|
+
|
|
1490
|
+
this._confirming = true;
|
|
1491
|
+
if (this._countEl) this._countEl.textContent = message;
|
|
1492
|
+
this._inlineEl.classList.add('confirming');
|
|
1493
|
+
|
|
1494
|
+
var self = this;
|
|
1495
|
+
|
|
1496
|
+
// Wire up confirm/cancel buttons (replace nodes to avoid stacking listeners)
|
|
1497
|
+
var newConfirmYes = this._confirmYes.cloneNode(true);
|
|
1498
|
+
this._confirmYes.parentNode.replaceChild(newConfirmYes, this._confirmYes);
|
|
1499
|
+
this._confirmYes = newConfirmYes;
|
|
1500
|
+
|
|
1501
|
+
var newConfirmNo = this._confirmNo.cloneNode(true);
|
|
1502
|
+
this._confirmNo.parentNode.replaceChild(newConfirmNo, this._confirmNo);
|
|
1503
|
+
this._confirmNo = newConfirmNo;
|
|
1504
|
+
|
|
1505
|
+
newConfirmYes.addEventListener('click', function () {
|
|
1506
|
+
self._inlineEl.classList.remove('confirming');
|
|
1507
|
+
self._confirming = false;
|
|
1508
|
+
onConfirm();
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
newConfirmNo.addEventListener('click', function () {
|
|
1512
|
+
self._inlineEl.classList.remove('confirming');
|
|
1513
|
+
self._confirming = false;
|
|
1514
|
+
self._updateInlineActions();
|
|
1515
|
+
});
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
// ─── Selection Mode Instances & Handlers ────────────────────────────────────
|
|
1519
|
+
|
|
1520
|
+
var prSelection = new SelectionMode({
|
|
1521
|
+
tabId: 'pr-tab',
|
|
1522
|
+
containerId: 'recent-reviews-container',
|
|
1523
|
+
tbodyId: 'recent-reviews-tbody',
|
|
1524
|
+
rowIdAttr: 'reviewId',
|
|
1525
|
+
actions: [
|
|
1526
|
+
{ label: 'Delete', className: 'btn-bulk-delete', handler: handleBulkDeletePR }
|
|
1527
|
+
]
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
var localSelection = new SelectionMode({
|
|
1531
|
+
tabId: 'local-tab',
|
|
1532
|
+
containerId: 'local-reviews-container',
|
|
1533
|
+
tbodyId: 'local-reviews-tbody',
|
|
1534
|
+
rowIdAttr: 'sessionId',
|
|
1535
|
+
actions: [
|
|
1536
|
+
{ label: 'Delete', className: 'btn-bulk-delete', handler: handleBulkDeleteLocal }
|
|
1537
|
+
]
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
var reviewRequestsSelection = new SelectionMode({
|
|
1541
|
+
tabId: 'review-requests-tab',
|
|
1542
|
+
containerId: 'review-requests-container',
|
|
1543
|
+
tbodyId: 'review-requests-tbody',
|
|
1544
|
+
rowIdAttr: 'prUrl',
|
|
1545
|
+
actions: [
|
|
1546
|
+
{ label: 'Open', className: 'btn-bulk-open', handler: handleBulkOpen },
|
|
1547
|
+
{ label: 'Analyze', className: 'btn-bulk-analyze', handler: handleBulkAnalyze }
|
|
1548
|
+
]
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
var myPrsSelection = new SelectionMode({
|
|
1552
|
+
tabId: 'my-prs-tab',
|
|
1553
|
+
containerId: 'my-prs-container',
|
|
1554
|
+
tbodyId: 'my-prs-tbody',
|
|
1555
|
+
rowIdAttr: 'prUrl',
|
|
1556
|
+
actions: [
|
|
1557
|
+
{ label: 'Open', className: 'btn-bulk-open', handler: handleBulkOpen },
|
|
1558
|
+
{ label: 'Analyze', className: 'btn-bulk-analyze', handler: handleBulkAnalyze }
|
|
1559
|
+
]
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
async function handleBulkDeletePR(selectedIds, selectionInstance) {
|
|
1563
|
+
var count = selectedIds.size;
|
|
1564
|
+
selectionInstance.showConfirm('Delete ' + count + ' review' + (count === 1 ? '' : 's') + '?', async function () {
|
|
1565
|
+
try {
|
|
1566
|
+
var response = await fetch('/api/worktrees/bulk-delete', {
|
|
1567
|
+
method: 'POST',
|
|
1568
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1569
|
+
body: JSON.stringify({ ids: Array.from(selectedIds).map(Number) })
|
|
1570
|
+
});
|
|
1571
|
+
var data = await response.json();
|
|
1572
|
+
if (!response.ok) throw new Error(data.error || 'Bulk delete failed');
|
|
1573
|
+
|
|
1574
|
+
if (data.failed > 0) {
|
|
1575
|
+
if (window.toast) window.toast.error(data.failed + ' of ' + count + ' failed to delete');
|
|
1576
|
+
} else {
|
|
1577
|
+
if (window.toast) window.toast.success('Deleted ' + data.deleted + ' review' + (data.deleted === 1 ? '' : 's'));
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
selectionInstance.exit();
|
|
1581
|
+
await loadRecentReviews();
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
console.error('Bulk delete PR error:', error);
|
|
1584
|
+
if (window.toast) window.toast.error('Bulk delete failed: ' + error.message);
|
|
1585
|
+
selectionInstance.exit();
|
|
1586
|
+
await loadRecentReviews();
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
async function handleBulkDeleteLocal(selectedIds, selectionInstance) {
|
|
1592
|
+
var count = selectedIds.size;
|
|
1593
|
+
selectionInstance.showConfirm('Delete ' + count + ' session' + (count === 1 ? '' : 's') + '?', async function () {
|
|
1594
|
+
try {
|
|
1595
|
+
var response = await fetch('/api/local/sessions/bulk-delete', {
|
|
1596
|
+
method: 'POST',
|
|
1597
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1598
|
+
body: JSON.stringify({ ids: Array.from(selectedIds).map(Number) })
|
|
1599
|
+
});
|
|
1600
|
+
var data = await response.json();
|
|
1601
|
+
if (!response.ok) throw new Error(data.error || 'Bulk delete failed');
|
|
1602
|
+
|
|
1603
|
+
if (data.failed > 0) {
|
|
1604
|
+
if (window.toast) window.toast.error(data.failed + ' of ' + count + ' failed to delete');
|
|
1605
|
+
} else {
|
|
1606
|
+
if (window.toast) window.toast.success('Deleted ' + data.deleted + ' session' + (data.deleted === 1 ? '' : 's'));
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
selectionInstance.exit();
|
|
1610
|
+
await loadLocalReviews();
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
console.error('Bulk delete local error:', error);
|
|
1613
|
+
if (window.toast) window.toast.error('Bulk delete failed: ' + error.message);
|
|
1614
|
+
selectionInstance.exit();
|
|
1615
|
+
await loadLocalReviews();
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* Build pair-review URLs from selected collection rows.
|
|
1622
|
+
* @param {Set} selectedIds - PR URLs (data-pr-url values)
|
|
1623
|
+
* @param {string} tbodyId - tbody element ID
|
|
1624
|
+
* @param {string} [query] - optional query string (e.g. '?analyze=true')
|
|
1625
|
+
* @returns {string[]} array of pair-review URLs
|
|
1626
|
+
*/
|
|
1627
|
+
function buildReviewUrls(selectedIds, tbodyId, query) {
|
|
1628
|
+
var tbody = document.getElementById(tbodyId);
|
|
1629
|
+
if (!tbody) return [];
|
|
1630
|
+
var urls = [];
|
|
1631
|
+
selectedIds.forEach(function (prUrl) {
|
|
1632
|
+
var row = tbody.querySelector('tr[data-pr-url="' + CSS.escape(prUrl) + '"]');
|
|
1633
|
+
if (!row) return;
|
|
1634
|
+
var owner = row.dataset.owner;
|
|
1635
|
+
var repo = row.dataset.repo;
|
|
1636
|
+
var number = row.dataset.number;
|
|
1637
|
+
if (owner && repo && number) {
|
|
1638
|
+
urls.push('/pr/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/' + number + (query || ''));
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
return urls;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Open multiple review URLs via the server-side /api/bulk-open endpoint.
|
|
1646
|
+
* The server uses the OS `open` command to launch each URL in the default
|
|
1647
|
+
* browser, bypassing popup blockers entirely.
|
|
1648
|
+
*/
|
|
1649
|
+
function bulkOpenUrls(urls) {
|
|
1650
|
+
if (urls.length === 0) return;
|
|
1651
|
+
fetch('/api/bulk-open', {
|
|
1652
|
+
method: 'POST',
|
|
1653
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1654
|
+
body: JSON.stringify({ urls: urls })
|
|
1655
|
+
}).catch(function (err) {
|
|
1656
|
+
console.error('Bulk open failed:', err);
|
|
1657
|
+
if (window.toast) window.toast.error('Failed to open reviews');
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function handleBulkOpen(selectedIds, selectionInstance) {
|
|
1662
|
+
var urls = buildReviewUrls(selectedIds, selectionInstance.config.tbodyId);
|
|
1663
|
+
selectionInstance.exit();
|
|
1664
|
+
bulkOpenUrls(urls);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function handleBulkAnalyze(selectedIds, selectionInstance) {
|
|
1668
|
+
var urls = buildReviewUrls(selectedIds, selectionInstance.config.tbodyId, '?analyze=true');
|
|
1669
|
+
selectionInstance.exit();
|
|
1670
|
+
bulkOpenUrls(urls);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1142
1673
|
// ─── Event Delegation ───────────────────────────────────────────────────────
|
|
1143
1674
|
|
|
1144
1675
|
// Event delegation for buttons, show-more, tab switching
|
|
1145
1676
|
document.addEventListener('click', function (event) {
|
|
1146
|
-
// Delete
|
|
1147
|
-
const deleteBtn = event.target.closest('.btn-delete-
|
|
1677
|
+
// Delete review (PR mode)
|
|
1678
|
+
const deleteBtn = event.target.closest('.btn-delete-review');
|
|
1148
1679
|
if (deleteBtn) {
|
|
1149
1680
|
event.preventDefault();
|
|
1150
1681
|
event.stopPropagation();
|
|
1151
|
-
|
|
1682
|
+
showDeleteReviewConfirm(deleteBtn);
|
|
1152
1683
|
return;
|
|
1153
1684
|
}
|
|
1154
1685
|
|
|
@@ -1176,9 +1707,30 @@
|
|
|
1176
1707
|
return;
|
|
1177
1708
|
}
|
|
1178
1709
|
|
|
1179
|
-
//
|
|
1710
|
+
// Select toggle button
|
|
1711
|
+
var selectToggle = event.target.closest('.btn-select-toggle');
|
|
1712
|
+
if (selectToggle) {
|
|
1713
|
+
event.preventDefault();
|
|
1714
|
+
var tabId = selectToggle.dataset.selectionTab;
|
|
1715
|
+
var instances = { 'pr-tab': prSelection, 'local-tab': localSelection, 'review-requests-tab': reviewRequestsSelection, 'my-prs-tab': myPrsSelection };
|
|
1716
|
+
var instance = instances[tabId];
|
|
1717
|
+
if (instance) instance.toggle();
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// Click on a collection PR row — toggle checkbox in selection mode, else start review
|
|
1180
1722
|
var collectionRow = event.target.closest('.collection-pr-row');
|
|
1181
|
-
if (collectionRow && !event.target.closest('a')) {
|
|
1723
|
+
if (collectionRow && !event.target.closest('a') && !event.target.closest('.col-select')) {
|
|
1724
|
+
// If in selection mode, toggle the row's checkbox
|
|
1725
|
+
if (activeSelection && activeSelection.active && collectionRow.closest('.selection-mode')) {
|
|
1726
|
+
var cb = collectionRow.querySelector('.col-select input[type="checkbox"]');
|
|
1727
|
+
if (cb) {
|
|
1728
|
+
cb.checked = !cb.checked;
|
|
1729
|
+
cb.dispatchEvent(new Event('change'));
|
|
1730
|
+
}
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1182
1734
|
var prUrl = collectionRow.dataset.prUrl;
|
|
1183
1735
|
if (prUrl) {
|
|
1184
1736
|
// Switch to PR tab to show loading state (do NOT persist to
|
|
@@ -1222,6 +1774,8 @@
|
|
|
1222
1774
|
if (unifiedTabBtn) {
|
|
1223
1775
|
const tabBar = document.getElementById('unified-tab-bar');
|
|
1224
1776
|
switchTab(tabBar, unifiedTabBtn, async function (tabId) {
|
|
1777
|
+
// Exit any active selection mode when switching tabs
|
|
1778
|
+
if (activeSelection) activeSelection.exit();
|
|
1225
1779
|
// Persist tab choice
|
|
1226
1780
|
localStorage.setItem(TAB_STORAGE_KEY, tabId);
|
|
1227
1781
|
// Lazy-load local reviews on first switch
|
|
@@ -1304,6 +1858,57 @@
|
|
|
1304
1858
|
// Note: No explicit Enter keypress handlers are needed here.
|
|
1305
1859
|
// Both inputs are inside <form> elements, so pressing Enter
|
|
1306
1860
|
// natively triggers form submission.
|
|
1861
|
+
|
|
1862
|
+
// ─── Create Select toggle buttons for each tab ──────────────────────────
|
|
1863
|
+
|
|
1864
|
+
function createSelectButton(tabId) {
|
|
1865
|
+
var btn = document.createElement('button');
|
|
1866
|
+
btn.className = 'btn-select-toggle';
|
|
1867
|
+
btn.type = 'button';
|
|
1868
|
+
btn.textContent = 'Select';
|
|
1869
|
+
btn.dataset.selectionTab = tabId;
|
|
1870
|
+
return btn;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// PR tab: insert header between form and container
|
|
1874
|
+
var prTab = document.getElementById('pr-tab');
|
|
1875
|
+
if (prTab) {
|
|
1876
|
+
var prContainer = document.getElementById('recent-reviews-container');
|
|
1877
|
+
var prHeader = document.createElement('div');
|
|
1878
|
+
prHeader.className = 'select-mode-header visible';
|
|
1879
|
+
var prBtn = createSelectButton('pr-tab');
|
|
1880
|
+
prSelection._toggleBtn = prBtn;
|
|
1881
|
+
prHeader.appendChild(prBtn);
|
|
1882
|
+
prTab.insertBefore(prHeader, prContainer);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// Local tab: insert header between form and container
|
|
1886
|
+
var localTab = document.getElementById('local-tab');
|
|
1887
|
+
if (localTab) {
|
|
1888
|
+
var localContainer = document.getElementById('local-reviews-container');
|
|
1889
|
+
var localHeader = document.createElement('div');
|
|
1890
|
+
localHeader.className = 'select-mode-header visible';
|
|
1891
|
+
var localBtn = createSelectButton('local-tab');
|
|
1892
|
+
localSelection._toggleBtn = localBtn;
|
|
1893
|
+
localHeader.appendChild(localBtn);
|
|
1894
|
+
localTab.insertBefore(localHeader, localContainer);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Review Requests tab: add to existing header
|
|
1898
|
+
var rrHeader = document.querySelector('#review-requests-tab .tab-pane-header');
|
|
1899
|
+
if (rrHeader) {
|
|
1900
|
+
var rrBtn = createSelectButton('review-requests-tab');
|
|
1901
|
+
reviewRequestsSelection._toggleBtn = rrBtn;
|
|
1902
|
+
rrHeader.insertBefore(rrBtn, rrHeader.firstChild);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// My PRs tab: add to existing header
|
|
1906
|
+
var mpHeader = document.querySelector('#my-prs-tab .tab-pane-header');
|
|
1907
|
+
if (mpHeader) {
|
|
1908
|
+
var mpBtn = createSelectButton('my-prs-tab');
|
|
1909
|
+
myPrsSelection._toggleBtn = mpBtn;
|
|
1910
|
+
mpHeader.insertBefore(mpBtn, mpHeader.firstChild);
|
|
1911
|
+
}
|
|
1307
1912
|
});
|
|
1308
1913
|
|
|
1309
1914
|
// ─── bfcache Restoration ───────────────────────────────────────────────────
|