@in-the-loop-labs/pair-review 3.5.1 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +603 -6
- package/public/index.html +90 -0
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/TourBar.js +248 -0
- package/public/js/index.js +298 -25
- package/public/js/local.js +6 -0
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/tour-renderer.js +725 -0
- package/public/js/pr.js +1276 -2
- package/public/js/utils/modal-detection.js +77 -0
- package/public/local.html +17 -0
- package/public/pr.html +17 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +114 -0
- package/src/database.js +282 -1
- package/src/local-review.js +189 -169
- package/src/routes/config.js +16 -1
- package/src/routes/context-files.js +2 -29
- package/src/routes/github-collections.js +168 -90
- package/src/routes/local.js +311 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +259 -4
- package/src/routes/reviews.js +145 -29
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
package/public/index.html
CHANGED
|
@@ -960,6 +960,65 @@
|
|
|
960
960
|
animation: spin 0.8s linear infinite;
|
|
961
961
|
}
|
|
962
962
|
|
|
963
|
+
/* Team Review Requests: filter-by-team control */
|
|
964
|
+
.team-filter-group {
|
|
965
|
+
display: flex;
|
|
966
|
+
align-items: center;
|
|
967
|
+
gap: 8px;
|
|
968
|
+
/* Push the filter to the left; fetched-at + refresh stay right. */
|
|
969
|
+
margin-right: auto;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
.team-filter {
|
|
973
|
+
display: flex;
|
|
974
|
+
align-items: center;
|
|
975
|
+
gap: 4px;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
.team-filter-input {
|
|
979
|
+
width: 160px;
|
|
980
|
+
height: 32px;
|
|
981
|
+
padding: 0 8px;
|
|
982
|
+
font-size: 13px;
|
|
983
|
+
background: var(--color-bg-primary);
|
|
984
|
+
border: 1px solid var(--color-border-primary);
|
|
985
|
+
border-radius: var(--radius-md);
|
|
986
|
+
color: var(--color-text-primary);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.team-filter-input:focus {
|
|
990
|
+
outline: none;
|
|
991
|
+
border-color: var(--ai-primary);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
.team-filter-input.invalid {
|
|
995
|
+
border-color: var(--color-danger);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.team-filter-apply,
|
|
999
|
+
.team-filter-clear {
|
|
1000
|
+
height: 32px;
|
|
1001
|
+
padding: 0 10px;
|
|
1002
|
+
font-size: 13px;
|
|
1003
|
+
background: transparent;
|
|
1004
|
+
border: 1px solid var(--color-border-primary);
|
|
1005
|
+
border-radius: var(--radius-md);
|
|
1006
|
+
color: var(--color-text-tertiary);
|
|
1007
|
+
cursor: pointer;
|
|
1008
|
+
transition: all var(--transition-fast);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.team-filter-apply:hover,
|
|
1012
|
+
.team-filter-clear:hover {
|
|
1013
|
+
background: var(--color-bg-secondary);
|
|
1014
|
+
color: var(--color-text-primary);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
.team-filter-hint {
|
|
1018
|
+
font-size: 12px;
|
|
1019
|
+
color: var(--color-danger);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
963
1022
|
.collection-pr-row {
|
|
964
1023
|
cursor: pointer;
|
|
965
1024
|
}
|
|
@@ -1343,6 +1402,7 @@
|
|
|
1343
1402
|
<div class="tab-bar" id="unified-tab-bar">
|
|
1344
1403
|
<button class="tab-btn active" data-tab="pr-tab" type="button">Pull Requests</button>
|
|
1345
1404
|
<button class="tab-btn" data-tab="review-requests-tab" type="button">My Review Requests</button>
|
|
1405
|
+
<button class="tab-btn" data-tab="team-reviews-tab" type="button">Team Review Requests</button>
|
|
1346
1406
|
<button class="tab-btn" data-tab="my-prs-tab" type="button">My PRs</button>
|
|
1347
1407
|
<span class="tab-divider"></span>
|
|
1348
1408
|
<button class="tab-btn" data-tab="local-tab" type="button">Local Reviews</button>
|
|
@@ -1392,6 +1452,36 @@
|
|
|
1392
1452
|
</div>
|
|
1393
1453
|
</div>
|
|
1394
1454
|
|
|
1455
|
+
<!-- Team Review Requests Tab -->
|
|
1456
|
+
<div class="tab-pane" id="team-reviews-tab">
|
|
1457
|
+
<div class="tab-pane-header">
|
|
1458
|
+
<div class="team-filter-group">
|
|
1459
|
+
<form class="team-filter" id="team-reviews-filter">
|
|
1460
|
+
<input
|
|
1461
|
+
type="text"
|
|
1462
|
+
class="team-filter-input"
|
|
1463
|
+
id="team-reviews-team-input"
|
|
1464
|
+
placeholder="org/team"
|
|
1465
|
+
autocomplete="off"
|
|
1466
|
+
spellcheck="false"
|
|
1467
|
+
aria-label="Filter by team (org/team)"
|
|
1468
|
+
aria-describedby="team-reviews-filter-hint"
|
|
1469
|
+
>
|
|
1470
|
+
<button type="submit" class="team-filter-apply" id="team-reviews-filter-apply" title="Filter by this team">Filter</button>
|
|
1471
|
+
<button type="button" class="team-filter-clear" id="team-reviews-filter-clear" title="Show all teams" hidden>Clear</button>
|
|
1472
|
+
</form>
|
|
1473
|
+
<span class="team-filter-hint" id="team-reviews-filter-hint" role="alert" hidden>Use the form org/team.</span>
|
|
1474
|
+
</div>
|
|
1475
|
+
<span class="fetched-at-label" id="team-reviews-fetched-at"></span>
|
|
1476
|
+
<button class="btn-refresh" id="refresh-team-reviews" title="Refresh from GitHub">
|
|
1477
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
|
|
1478
|
+
</button>
|
|
1479
|
+
</div>
|
|
1480
|
+
<div id="team-reviews-container" class="recent-reviews-loading">
|
|
1481
|
+
Loading...
|
|
1482
|
+
</div>
|
|
1483
|
+
</div>
|
|
1484
|
+
|
|
1395
1485
|
<!-- My PRs Tab -->
|
|
1396
1486
|
<div class="tab-pane" id="my-prs-tab">
|
|
1397
1487
|
<div class="tab-pane-header">
|
|
@@ -1275,13 +1275,15 @@ class ChatPanel {
|
|
|
1275
1275
|
// attached as tabs are created/restored.
|
|
1276
1276
|
this._ensureSubscriptions();
|
|
1277
1277
|
|
|
1278
|
-
// Recognise thread context (external systems)
|
|
1279
|
-
// comment / file when deciding whether to open with
|
|
1278
|
+
// Recognise thread context (external systems) and tour context alongside
|
|
1279
|
+
// suggestion / user comment / file when deciding whether to open with
|
|
1280
|
+
// explicit context.
|
|
1280
1281
|
const hasExplicitContext = !!(
|
|
1281
1282
|
options.suggestionContext ||
|
|
1282
1283
|
options.commentContext ||
|
|
1283
1284
|
options.threadContext ||
|
|
1284
|
-
options.fileContext
|
|
1285
|
+
options.fileContext ||
|
|
1286
|
+
options.tourContext
|
|
1285
1287
|
);
|
|
1286
1288
|
|
|
1287
1289
|
// First-time open behaviour:
|
|
@@ -1355,6 +1357,15 @@ class ChatPanel {
|
|
|
1355
1357
|
this._sendFileContextMessage(options.fileContext);
|
|
1356
1358
|
this._contextSource = 'file';
|
|
1357
1359
|
this._contextItemId = null;
|
|
1360
|
+
} else if (options.tourContext) {
|
|
1361
|
+
// If opening with tour-stop context, inject it as a context card.
|
|
1362
|
+
// Awaited because tour stops on context files (outside the PR diff)
|
|
1363
|
+
// need an async file-content fetch to populate the snippet.
|
|
1364
|
+
await this._sendTourContextMessage(options.tourContext);
|
|
1365
|
+
this._contextSource = 'tour';
|
|
1366
|
+
this._contextItemId = options.tourContext.stopIndex != null
|
|
1367
|
+
? String(options.tourContext.stopIndex)
|
|
1368
|
+
: null;
|
|
1358
1369
|
}
|
|
1359
1370
|
|
|
1360
1371
|
// Gate input when reviewId is not yet available (PanelGroup auto-restore race)
|
|
@@ -2789,6 +2800,155 @@ class ChatPanel {
|
|
|
2789
2800
|
this._addFileContextCard(contextData, { removable: true });
|
|
2790
2801
|
}
|
|
2791
2802
|
|
|
2803
|
+
/**
|
|
2804
|
+
* Store pending context and render a compact context card for a tour stop.
|
|
2805
|
+
* Called when the user clicks "Chat about" on a tour-stop annotation.
|
|
2806
|
+
* The context is NOT sent to the agent immediately — it is prepended to
|
|
2807
|
+
* the next user message so the agent receives question + context together.
|
|
2808
|
+
*
|
|
2809
|
+
* Async because tour stops on context files (files NOT in the PR diff) need
|
|
2810
|
+
* to fetch a code snippet via the file-content API so the agent receives a
|
|
2811
|
+
* meaningful snippet — same shape as the diff-hunk enrichment used for stops
|
|
2812
|
+
* inside the diff.
|
|
2813
|
+
*
|
|
2814
|
+
* @param {Object} ctx - Tour stop context
|
|
2815
|
+
* {stopIndex, totalStops, title, description, file, line_start, line_end, side}
|
|
2816
|
+
* @returns {Promise<void>}
|
|
2817
|
+
*/
|
|
2818
|
+
async _sendTourContextMessage(ctx) {
|
|
2819
|
+
const tab = this._getActiveTab();
|
|
2820
|
+
if (!tab) return;
|
|
2821
|
+
|
|
2822
|
+
// Remove empty state if present
|
|
2823
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
2824
|
+
if (emptyState) emptyState.remove();
|
|
2825
|
+
|
|
2826
|
+
const stopLabel = (typeof ctx.stopIndex === 'number' && typeof ctx.totalStops === 'number')
|
|
2827
|
+
? `Stop ${ctx.stopIndex + 1} of ${ctx.totalStops}`
|
|
2828
|
+
: 'Tour stop';
|
|
2829
|
+
|
|
2830
|
+
// Store structured context data for DB persistence (session resumption).
|
|
2831
|
+
const contextData = {
|
|
2832
|
+
type: 'tour stop',
|
|
2833
|
+
title: ctx.title || stopLabel,
|
|
2834
|
+
file: ctx.file || null,
|
|
2835
|
+
line_start: ctx.line_start || null,
|
|
2836
|
+
line_end: ctx.line_end || null,
|
|
2837
|
+
side: ctx.side || null,
|
|
2838
|
+
body: ctx.description || null,
|
|
2839
|
+
stopIndex: typeof ctx.stopIndex === 'number' ? ctx.stopIndex : null,
|
|
2840
|
+
totalStops: typeof ctx.totalStops === 'number' ? ctx.totalStops : null
|
|
2841
|
+
};
|
|
2842
|
+
tab.pendingContextData.push(contextData);
|
|
2843
|
+
|
|
2844
|
+
// Build the plain-text context for the agent.
|
|
2845
|
+
const lines = [`The user wants to discuss this tour stop (${stopLabel}):`];
|
|
2846
|
+
if (contextData.title) {
|
|
2847
|
+
lines.push(`- Title: ${contextData.title}`);
|
|
2848
|
+
}
|
|
2849
|
+
if (contextData.file) {
|
|
2850
|
+
let fileLine = `- File: ${contextData.file}`;
|
|
2851
|
+
if (contextData.line_start) {
|
|
2852
|
+
fileLine += ` (line ${contextData.line_start}${contextData.line_end && contextData.line_end !== contextData.line_start ? '-' + contextData.line_end : ''})`;
|
|
2853
|
+
}
|
|
2854
|
+
lines.push(fileLine);
|
|
2855
|
+
}
|
|
2856
|
+
if (contextData.body) {
|
|
2857
|
+
lines.push(`- Description: ${contextData.body}`);
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// Enrich with code snippet.
|
|
2861
|
+
//
|
|
2862
|
+
// Primary path: pull from the in-memory PR diff via DiffContext. This is
|
|
2863
|
+
// synchronous and matches the shape used by suggestion/comment context.
|
|
2864
|
+
//
|
|
2865
|
+
// Fallback path: tour-renderer.prepareStop auto-adds context files (files
|
|
2866
|
+
// outside the PR diff) via prManager.ensureContextFile. Those files have
|
|
2867
|
+
// no entry in filePatches, so the lookup misses — but the agent benefits
|
|
2868
|
+
// most from a snippet in exactly that case (file isn't visible in the
|
|
2869
|
+
// diff). Fetch the file content and slice [line_start-5, line_end+5].
|
|
2870
|
+
//
|
|
2871
|
+
// Both paths render as a fenced code block so the agent sees a consistent
|
|
2872
|
+
// shape. A failed fetch logs a warning and falls through to no snippet.
|
|
2873
|
+
const patch = window.prManager?.filePatches?.get(contextData.file);
|
|
2874
|
+
let snippet = null;
|
|
2875
|
+
if (patch && window.DiffContext && contextData.line_start) {
|
|
2876
|
+
const hunk = window.DiffContext.extractHunkForLines(
|
|
2877
|
+
patch,
|
|
2878
|
+
contextData.line_start,
|
|
2879
|
+
contextData.line_end || contextData.line_start,
|
|
2880
|
+
contextData.side
|
|
2881
|
+
);
|
|
2882
|
+
if (hunk) {
|
|
2883
|
+
snippet = `- Diff hunk:\n\`\`\`\n${hunk}\n\`\`\``;
|
|
2884
|
+
}
|
|
2885
|
+
} else if (!patch && contextData.file && contextData.line_start) {
|
|
2886
|
+
const sliced = await this._fetchContextFileSnippet(
|
|
2887
|
+
contextData.file,
|
|
2888
|
+
contextData.line_start,
|
|
2889
|
+
contextData.line_end || contextData.line_start
|
|
2890
|
+
);
|
|
2891
|
+
if (sliced) {
|
|
2892
|
+
snippet = `- File snippet:\n\`\`\`\n${sliced}\n\`\`\``;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
if (snippet) {
|
|
2896
|
+
lines.push(snippet);
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
tab.pendingContext.push(lines.join('\n'));
|
|
2900
|
+
|
|
2901
|
+
// Render the compact context card in the UI (reuses suggestion card shape).
|
|
2902
|
+
this._addContextCard(contextData, { removable: true });
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* Fetch a small slice of file content for tour stops on context files
|
|
2907
|
+
* (files outside the PR diff). Returns the slice with line numbers prefixed
|
|
2908
|
+
* so the agent can correlate with the stop's line range, or null on failure.
|
|
2909
|
+
*
|
|
2910
|
+
* @param {string} file - File path (will be URI-encoded)
|
|
2911
|
+
* @param {number} lineStart - 1-based first line of the stop range
|
|
2912
|
+
* @param {number} lineEnd - 1-based last line of the stop range (inclusive)
|
|
2913
|
+
* @param {number} [padding=5] - Extra lines to include on each side
|
|
2914
|
+
* @returns {Promise<string|null>}
|
|
2915
|
+
*/
|
|
2916
|
+
async _fetchContextFileSnippet(file, lineStart, lineEnd, padding = 5) {
|
|
2917
|
+
const reviewId = this.reviewId || window.prManager?.currentPR?.id;
|
|
2918
|
+
if (!reviewId || !file || !lineStart) return null;
|
|
2919
|
+
|
|
2920
|
+
try {
|
|
2921
|
+
const resp = await fetch(
|
|
2922
|
+
`/api/reviews/${reviewId}/file-content/${encodeURIComponent(file)}`
|
|
2923
|
+
);
|
|
2924
|
+
if (!resp || !resp.ok) {
|
|
2925
|
+
console.warn(
|
|
2926
|
+
'[ChatPanel] context-file snippet fetch failed',
|
|
2927
|
+
{ file, status: resp && resp.status }
|
|
2928
|
+
);
|
|
2929
|
+
return null;
|
|
2930
|
+
}
|
|
2931
|
+
const data = await resp.json();
|
|
2932
|
+
const allLines = Array.isArray(data?.lines) ? data.lines : null;
|
|
2933
|
+
if (!allLines || allLines.length === 0) return null;
|
|
2934
|
+
|
|
2935
|
+
// Clamp to file bounds; convert to 0-based slice indices.
|
|
2936
|
+
const startIdx = Math.max(0, lineStart - 1 - padding);
|
|
2937
|
+
const endIdx = Math.min(allLines.length, lineEnd + padding);
|
|
2938
|
+
if (endIdx <= startIdx) return null;
|
|
2939
|
+
|
|
2940
|
+
const out = [];
|
|
2941
|
+
const pad = String(endIdx).length;
|
|
2942
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
2943
|
+
out.push(`${String(i + 1).padStart(pad, ' ')}: ${allLines[i]}`);
|
|
2944
|
+
}
|
|
2945
|
+
return out.join('\n');
|
|
2946
|
+
} catch (err) {
|
|
2947
|
+
console.warn('[ChatPanel] context-file snippet fetch threw', { file, err });
|
|
2948
|
+
return null;
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2792
2952
|
/**
|
|
2793
2953
|
* Add an analysis run as context for the chat conversation.
|
|
2794
2954
|
* Fetches run metadata from the backend and creates a removable context card
|
|
@@ -299,45 +299,29 @@ class KeyboardShortcuts {
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
/**
|
|
302
|
-
* Check if a modal is currently open (excluding our help overlay)
|
|
302
|
+
* Check if a modal is currently open (excluding our help overlay).
|
|
303
|
+
* Delegates to the shared ModalDetection utility so the selector list
|
|
304
|
+
* and visibility check stay in sync with PRManager's tour handler.
|
|
303
305
|
* @returns {boolean} True if a modal is open
|
|
304
306
|
*/
|
|
305
307
|
isModalOpen() {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
'.modal-overlay:not(#keyboard-shortcuts-help)',
|
|
309
|
-
'.review-modal-overlay',
|
|
310
|
-
'.preview-modal-overlay',
|
|
311
|
-
'.confirm-dialog-overlay',
|
|
312
|
-
'.analysis-config-overlay',
|
|
313
|
-
'.ai-summary-modal-overlay',
|
|
314
|
-
'[role="dialog"]:not(#keyboard-shortcuts-help)'
|
|
315
|
-
];
|
|
316
|
-
|
|
317
|
-
for (const selector of modalSelectors) {
|
|
318
|
-
const modal = document.querySelector(selector);
|
|
319
|
-
if (modal && this.isElementVisible(modal)) {
|
|
320
|
-
return true;
|
|
321
|
-
}
|
|
308
|
+
if (window.ModalDetection && typeof window.ModalDetection.isModalOpen === 'function') {
|
|
309
|
+
return window.ModalDetection.isModalOpen();
|
|
322
310
|
}
|
|
323
|
-
|
|
324
311
|
return false;
|
|
325
312
|
}
|
|
326
313
|
|
|
327
314
|
/**
|
|
328
|
-
* Check if an element is visible
|
|
315
|
+
* Check if an element is visible. Delegates to the shared
|
|
316
|
+
* ModalDetection utility.
|
|
329
317
|
* @param {HTMLElement} element - The element to check
|
|
330
318
|
* @returns {boolean} True if element is visible
|
|
331
319
|
*/
|
|
332
320
|
isElementVisible(element) {
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
const style = window.getComputedStyle(element);
|
|
336
|
-
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
337
|
-
return false;
|
|
321
|
+
if (window.ModalDetection && typeof window.ModalDetection.isElementVisible === 'function') {
|
|
322
|
+
return window.ModalDetection.isElementVisible(element);
|
|
338
323
|
}
|
|
339
|
-
|
|
340
|
-
return true;
|
|
324
|
+
return Boolean(element);
|
|
341
325
|
}
|
|
342
326
|
|
|
343
327
|
/**
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* TourBar - Sticky top bar shown while a guided tour is active.
|
|
4
|
+
*
|
|
5
|
+
* Displays "Stop N of M" plus Prev/Next/Exit controls. On completion the
|
|
6
|
+
* Prev/Next/Exit chrome swaps for Restart/Close. The bar consumes callbacks
|
|
7
|
+
* from PRManager; it never reaches back into application state.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle:
|
|
10
|
+
* const bar = new TourBar({ onPrev, onNext, onExit, onRestart });
|
|
11
|
+
* bar.mount(parent); // prepends to parent (defaults to document.body)
|
|
12
|
+
* bar.setStops(stops); // initial render
|
|
13
|
+
* bar.setActiveIndex(0); // pre-tour state
|
|
14
|
+
* bar.setCompleted(true); // toggles to Restart / Close chrome
|
|
15
|
+
* bar.unmount();
|
|
16
|
+
*
|
|
17
|
+
* The bar uses `position: sticky` so it visually pins to the top of its
|
|
18
|
+
* scrolling parent. Pass the diff-view scroll container as `parent` so the
|
|
19
|
+
* bar spans the diff width (and not the file-tree sidebar).
|
|
20
|
+
*
|
|
21
|
+
* Testability: instantiable in jsdom; CommonJS export at the bottom.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Octicon SVG path data. `milestone` brands the bar overall; `location`
|
|
25
|
+
// marks the per-stop navigation chrome (mirrors the inline stop marker).
|
|
26
|
+
// Module-scoped names are prefixed so they don't collide with the same
|
|
27
|
+
// constants in tour-renderer.js when both are loaded as plain <script> tags
|
|
28
|
+
// into the shared global scope.
|
|
29
|
+
const TOUR_BAR_MILESTONE_PATH = 'M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.144 1.13.406l2.501 2.071a1.75 1.75 0 0 1 0 2.696l-2.5 2.07a1.75 1.75 0 0 1-1.131.407H8.5v5.6a.75.75 0 0 1-1.5 0V10.65H3.75A1.75 1.75 0 0 1 2 8.9V4.75C2 3.784 2.784 3 3.75 3H7V.75A.75.75 0 0 1 7.75 0Zm-4 4.5a.25.25 0 0 0-.25.25V8.9c0 .138.112.25.25.25h8.384a.25.25 0 0 0 .16-.058l2.5-2.07a.25.25 0 0 0 0-.386l-2.5-2.07a.25.25 0 0 0-.16-.058H3.75Z';
|
|
30
|
+
const TOUR_BAR_LOCATION_PATH = 'm12.596 11.596-3.535 3.536a1.5 1.5 0 0 1-2.122 0l-3.535-3.536a6.5 6.5 0 1 1 9.192-9.193 6.5 6.5 0 0 1 0 9.193Zm-1.06-8.132v-.001a5 5 0 1 0-7.072 7.072L8 14.07l3.536-3.534a5 5 0 0 0 0-7.072ZM8 9a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 9Z';
|
|
31
|
+
|
|
32
|
+
function tourBarSvgIcon(pathData) {
|
|
33
|
+
return `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="${pathData}"/></svg>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class TourBar {
|
|
37
|
+
/**
|
|
38
|
+
* @param {Object} callbacks
|
|
39
|
+
* @param {Function} [callbacks.onPrev] - Called when the user clicks Prev.
|
|
40
|
+
* @param {Function} [callbacks.onNext] - Called when the user clicks Next.
|
|
41
|
+
* @param {Function} [callbacks.onExit] - Called when the user clicks Exit.
|
|
42
|
+
* @param {Function} [callbacks.onRestart] - Called when the user clicks Restart (completion state).
|
|
43
|
+
*/
|
|
44
|
+
constructor({ onPrev, onNext, onExit, onRestart } = {}) {
|
|
45
|
+
this._onPrev = onPrev || (() => {});
|
|
46
|
+
this._onNext = onNext || (() => {});
|
|
47
|
+
this._onExit = onExit || (() => {});
|
|
48
|
+
this._onRestart = onRestart || (() => {});
|
|
49
|
+
|
|
50
|
+
this._stops = [];
|
|
51
|
+
this._activeIndex = -1;
|
|
52
|
+
this._completed = false;
|
|
53
|
+
|
|
54
|
+
this._root = null;
|
|
55
|
+
this._progressEl = null;
|
|
56
|
+
this._navEl = null;
|
|
57
|
+
this._prevBtn = null;
|
|
58
|
+
this._nextBtn = null;
|
|
59
|
+
this._exitBtn = null;
|
|
60
|
+
this._restartBtn = null;
|
|
61
|
+
this._closeBtn = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Prepend the bar to `parent` (defaults to `document.body`). Idempotent.
|
|
66
|
+
* The bar's CSS uses `position: sticky`, so it pins to the top of the
|
|
67
|
+
* nearest scrollable ancestor — pass that scroll container as `parent`
|
|
68
|
+
* to scope the bar to a region rather than the viewport.
|
|
69
|
+
* @param {HTMLElement} [parent=document.body]
|
|
70
|
+
* @returns {TourBar}
|
|
71
|
+
*/
|
|
72
|
+
mount(parent) {
|
|
73
|
+
if (this._root && this._root.isConnected) return this;
|
|
74
|
+
const target = parent || document.body;
|
|
75
|
+
|
|
76
|
+
const root = document.createElement('div');
|
|
77
|
+
root.className = 'tour-bar';
|
|
78
|
+
root.setAttribute('role', 'toolbar');
|
|
79
|
+
root.setAttribute('aria-label', 'Guided tour controls');
|
|
80
|
+
|
|
81
|
+
const brand = document.createElement('div');
|
|
82
|
+
brand.className = 'tour-bar__brand';
|
|
83
|
+
brand.innerHTML = `${tourBarSvgIcon(TOUR_BAR_MILESTONE_PATH)}<span>Tour</span>`;
|
|
84
|
+
|
|
85
|
+
const progress = document.createElement('div');
|
|
86
|
+
progress.className = 'tour-bar__progress';
|
|
87
|
+
progress.textContent = '';
|
|
88
|
+
|
|
89
|
+
const nav = document.createElement('div');
|
|
90
|
+
nav.className = 'tour-bar__nav';
|
|
91
|
+
|
|
92
|
+
// Prev / Next / Exit (default chrome)
|
|
93
|
+
const prevBtn = document.createElement('button');
|
|
94
|
+
prevBtn.type = 'button';
|
|
95
|
+
prevBtn.className = 'tour-bar__prev';
|
|
96
|
+
prevBtn.innerHTML = `${tourBarSvgIcon(TOUR_BAR_LOCATION_PATH)}<span>Prev</span>`;
|
|
97
|
+
prevBtn.addEventListener('click', () => this._onPrev());
|
|
98
|
+
|
|
99
|
+
const nextBtn = document.createElement('button');
|
|
100
|
+
nextBtn.type = 'button';
|
|
101
|
+
nextBtn.className = 'tour-bar__next';
|
|
102
|
+
nextBtn.innerHTML = `<span>Next</span>${tourBarSvgIcon(TOUR_BAR_LOCATION_PATH)}`;
|
|
103
|
+
nextBtn.addEventListener('click', () => this._onNext());
|
|
104
|
+
|
|
105
|
+
const exitBtn = document.createElement('button');
|
|
106
|
+
exitBtn.type = 'button';
|
|
107
|
+
exitBtn.className = 'tour-bar__exit';
|
|
108
|
+
exitBtn.textContent = 'Exit';
|
|
109
|
+
exitBtn.addEventListener('click', () => this._onExit());
|
|
110
|
+
|
|
111
|
+
// Completion chrome (created up front, toggled by setCompleted)
|
|
112
|
+
const restartBtn = document.createElement('button');
|
|
113
|
+
restartBtn.type = 'button';
|
|
114
|
+
restartBtn.className = 'tour-bar__restart';
|
|
115
|
+
restartBtn.textContent = 'Restart';
|
|
116
|
+
restartBtn.style.display = 'none';
|
|
117
|
+
restartBtn.addEventListener('click', () => this._onRestart());
|
|
118
|
+
|
|
119
|
+
const closeBtn = document.createElement('button');
|
|
120
|
+
closeBtn.type = 'button';
|
|
121
|
+
closeBtn.className = 'tour-bar__close';
|
|
122
|
+
closeBtn.textContent = 'Close';
|
|
123
|
+
closeBtn.style.display = 'none';
|
|
124
|
+
closeBtn.addEventListener('click', () => this._onExit());
|
|
125
|
+
|
|
126
|
+
nav.appendChild(prevBtn);
|
|
127
|
+
nav.appendChild(nextBtn);
|
|
128
|
+
nav.appendChild(exitBtn);
|
|
129
|
+
nav.appendChild(restartBtn);
|
|
130
|
+
nav.appendChild(closeBtn);
|
|
131
|
+
|
|
132
|
+
root.appendChild(brand);
|
|
133
|
+
root.appendChild(progress);
|
|
134
|
+
root.appendChild(nav);
|
|
135
|
+
|
|
136
|
+
// Prepend so the bar becomes the first child — it pins above the
|
|
137
|
+
// diff-toolbar (which sits at top:0 in the same scroll container).
|
|
138
|
+
target.prepend(root);
|
|
139
|
+
|
|
140
|
+
this._root = root;
|
|
141
|
+
this._progressEl = progress;
|
|
142
|
+
this._navEl = nav;
|
|
143
|
+
this._prevBtn = prevBtn;
|
|
144
|
+
this._nextBtn = nextBtn;
|
|
145
|
+
this._exitBtn = exitBtn;
|
|
146
|
+
this._restartBtn = restartBtn;
|
|
147
|
+
this._closeBtn = closeBtn;
|
|
148
|
+
|
|
149
|
+
// Initial paint reflects whatever state was set before mount.
|
|
150
|
+
this._render();
|
|
151
|
+
return this;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Remove the bar from the DOM. Safe to call repeatedly.
|
|
156
|
+
*/
|
|
157
|
+
unmount() {
|
|
158
|
+
if (this._root && this._root.isConnected) {
|
|
159
|
+
this._root.remove();
|
|
160
|
+
}
|
|
161
|
+
this._root = null;
|
|
162
|
+
this._progressEl = null;
|
|
163
|
+
this._navEl = null;
|
|
164
|
+
this._prevBtn = null;
|
|
165
|
+
this._nextBtn = null;
|
|
166
|
+
this._exitBtn = null;
|
|
167
|
+
this._restartBtn = null;
|
|
168
|
+
this._closeBtn = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {Array<Object>} stops - The full ordered list of stops.
|
|
173
|
+
*/
|
|
174
|
+
setStops(stops) {
|
|
175
|
+
this._stops = Array.isArray(stops) ? stops : [];
|
|
176
|
+
this._render();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {number} index - The currently-active stop (0-based) or -1 for none.
|
|
181
|
+
*/
|
|
182
|
+
setActiveIndex(index) {
|
|
183
|
+
this._activeIndex = typeof index === 'number' ? index : -1;
|
|
184
|
+
this._render();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {boolean} isCompleted - Swap to Restart/Close chrome when true.
|
|
189
|
+
*/
|
|
190
|
+
setCompleted(isCompleted) {
|
|
191
|
+
this._completed = isCompleted === true;
|
|
192
|
+
this._render();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- private ------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
_render() {
|
|
198
|
+
if (!this._root) return;
|
|
199
|
+
const total = this._stops.length;
|
|
200
|
+
const visibleIndex = this._activeIndex + 1; // 1-based for display
|
|
201
|
+
|
|
202
|
+
if (this._progressEl) {
|
|
203
|
+
if (total === 0) {
|
|
204
|
+
this._progressEl.textContent = '';
|
|
205
|
+
} else if (this._completed) {
|
|
206
|
+
this._progressEl.textContent = `Tour complete (${total} stop${total === 1 ? '' : 's'})`;
|
|
207
|
+
} else if (this._activeIndex < 0) {
|
|
208
|
+
this._progressEl.textContent = `${total} stop${total === 1 ? '' : 's'}`;
|
|
209
|
+
} else {
|
|
210
|
+
this._progressEl.textContent = `Stop ${visibleIndex} of ${total}`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (this._completed) {
|
|
215
|
+
if (this._prevBtn) this._prevBtn.style.display = 'none';
|
|
216
|
+
if (this._nextBtn) this._nextBtn.style.display = 'none';
|
|
217
|
+
if (this._exitBtn) this._exitBtn.style.display = 'none';
|
|
218
|
+
if (this._restartBtn) this._restartBtn.style.display = '';
|
|
219
|
+
if (this._closeBtn) this._closeBtn.style.display = '';
|
|
220
|
+
} else {
|
|
221
|
+
if (this._prevBtn) this._prevBtn.style.display = '';
|
|
222
|
+
if (this._nextBtn) this._nextBtn.style.display = '';
|
|
223
|
+
if (this._exitBtn) this._exitBtn.style.display = '';
|
|
224
|
+
if (this._restartBtn) this._restartBtn.style.display = 'none';
|
|
225
|
+
if (this._closeBtn) this._closeBtn.style.display = 'none';
|
|
226
|
+
|
|
227
|
+
// Disable Prev at first stop; disable Next at last (no auto-completion
|
|
228
|
+
// happens in the bar — the orchestrator advances past the end to set
|
|
229
|
+
// completed state).
|
|
230
|
+
if (this._prevBtn) {
|
|
231
|
+
this._prevBtn.disabled = this._activeIndex <= 0;
|
|
232
|
+
}
|
|
233
|
+
if (this._nextBtn) {
|
|
234
|
+
// Next remains enabled on the last stop so the user can advance
|
|
235
|
+
// into completion state.
|
|
236
|
+
this._nextBtn.disabled = total === 0;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (typeof window !== 'undefined') {
|
|
243
|
+
window.TourBar = TourBar;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
247
|
+
module.exports = { TourBar };
|
|
248
|
+
}
|