@in-the-loop-labs/pair-review 1.0.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.
Files changed (91) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +371 -0
  3. package/bin/git-diff-lines +146 -0
  4. package/bin/pair-review.js +49 -0
  5. package/package.json +71 -0
  6. package/public/css/ai-summary-modal.css +183 -0
  7. package/public/css/pr.css +8698 -0
  8. package/public/css/repo-settings.css +891 -0
  9. package/public/css/styles.css +479 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +1104 -0
  12. package/public/js/components/AIPanel.js +1639 -0
  13. package/public/js/components/AISummaryModal.js +278 -0
  14. package/public/js/components/AnalysisConfigModal.js +684 -0
  15. package/public/js/components/ConfirmDialog.js +227 -0
  16. package/public/js/components/PreviewModal.js +344 -0
  17. package/public/js/components/ProgressModal.js +678 -0
  18. package/public/js/components/ReviewModal.js +531 -0
  19. package/public/js/components/SplitButton.js +382 -0
  20. package/public/js/components/StatusIndicator.js +265 -0
  21. package/public/js/components/SuggestionNavigator.js +489 -0
  22. package/public/js/components/Toast.js +166 -0
  23. package/public/js/local.js +1580 -0
  24. package/public/js/modules/analysis-history.js +940 -0
  25. package/public/js/modules/comment-manager.js +643 -0
  26. package/public/js/modules/diff-renderer.js +585 -0
  27. package/public/js/modules/file-comment-manager.js +1242 -0
  28. package/public/js/modules/gap-coordinates.js +190 -0
  29. package/public/js/modules/hunk-parser.js +358 -0
  30. package/public/js/modules/line-tracker.js +386 -0
  31. package/public/js/modules/panel-resizer.js +228 -0
  32. package/public/js/modules/storage-cleanup.js +36 -0
  33. package/public/js/modules/suggestion-manager.js +692 -0
  34. package/public/js/pr.js +3503 -0
  35. package/public/js/repo-settings.js +691 -0
  36. package/public/js/utils/file-order.js +87 -0
  37. package/public/js/utils/markdown.js +97 -0
  38. package/public/js/utils/suggestion-ui.js +55 -0
  39. package/public/js/utils/tier-icons.js +25 -0
  40. package/public/local.html +460 -0
  41. package/public/pr.html +329 -0
  42. package/public/repo-settings.html +243 -0
  43. package/src/ai/analyzer.js +2592 -0
  44. package/src/ai/claude-cli.js +153 -0
  45. package/src/ai/claude-provider.js +261 -0
  46. package/src/ai/codex-provider.js +361 -0
  47. package/src/ai/copilot-provider.js +345 -0
  48. package/src/ai/gemini-provider.js +375 -0
  49. package/src/ai/index.js +47 -0
  50. package/src/ai/prompts/baseline/_meta.json +14 -0
  51. package/src/ai/prompts/baseline/level1/balanced.js +239 -0
  52. package/src/ai/prompts/baseline/level1/fast.js +194 -0
  53. package/src/ai/prompts/baseline/level1/thorough.js +319 -0
  54. package/src/ai/prompts/baseline/level2/balanced.js +248 -0
  55. package/src/ai/prompts/baseline/level2/fast.js +201 -0
  56. package/src/ai/prompts/baseline/level2/thorough.js +367 -0
  57. package/src/ai/prompts/baseline/level3/balanced.js +280 -0
  58. package/src/ai/prompts/baseline/level3/fast.js +220 -0
  59. package/src/ai/prompts/baseline/level3/thorough.js +459 -0
  60. package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
  61. package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
  62. package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
  63. package/src/ai/prompts/config.js +52 -0
  64. package/src/ai/prompts/index.js +267 -0
  65. package/src/ai/prompts/shared/diff-instructions.js +50 -0
  66. package/src/ai/prompts/shared/output-schema.js +179 -0
  67. package/src/ai/prompts/shared/valid-files.js +37 -0
  68. package/src/ai/provider.js +260 -0
  69. package/src/config.js +139 -0
  70. package/src/database.js +2284 -0
  71. package/src/git/gitattributes.js +207 -0
  72. package/src/git/worktree.js +688 -0
  73. package/src/github/client.js +893 -0
  74. package/src/github/parser.js +247 -0
  75. package/src/local-review.js +691 -0
  76. package/src/main.js +987 -0
  77. package/src/routes/analysis.js +897 -0
  78. package/src/routes/comments.js +534 -0
  79. package/src/routes/config.js +250 -0
  80. package/src/routes/local.js +1728 -0
  81. package/src/routes/pr.js +1164 -0
  82. package/src/routes/shared.js +218 -0
  83. package/src/routes/worktrees.js +500 -0
  84. package/src/server.js +295 -0
  85. package/src/utils/diff-annotator.js +414 -0
  86. package/src/utils/instructions.js +33 -0
  87. package/src/utils/json-extractor.js +107 -0
  88. package/src/utils/line-validation.js +183 -0
  89. package/src/utils/logger.js +142 -0
  90. package/src/utils/paths.js +161 -0
  91. package/src/utils/stats-calculator.js +86 -0
@@ -0,0 +1,386 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * LineTracker - Line number mapping, range selection, and highlighting
4
+ * Handles line number extraction, range selection for multi-line comments,
5
+ * and line highlighting for the diff view.
6
+ */
7
+
8
+ class LineTracker {
9
+ constructor() {
10
+ // Line range selection state
11
+ this.rangeSelectionStart = null;
12
+ this.rangeSelectionEnd = null;
13
+ this.isDraggingRange = false;
14
+ this.dragStartLine = null;
15
+ this.dragEndLine = null;
16
+ this.potentialDragStart = null;
17
+ // Global mouseup handler reference for cleanup
18
+ this.handleGlobalMouseUp = null;
19
+ }
20
+
21
+ /**
22
+ * Get the line number from a diff row
23
+ * Handles both added/context lines (new line numbers) and deleted lines (old line numbers).
24
+ *
25
+ * When side is specified:
26
+ * - 'LEFT': Returns the OLD line number (for deleted lines or context lines in OLD coordinate system)
27
+ * - 'RIGHT': Returns the NEW line number (for added lines or context lines in NEW coordinate system)
28
+ *
29
+ * When side is not specified (default behavior):
30
+ * - Returns dataset.lineNumber which is the primary line number for the row's type
31
+ * - For deleted lines: returns oldNumber
32
+ * - For added/context lines: returns newNumber
33
+ *
34
+ * Priority order:
35
+ * 1. dataset.lineNumber (or oldLineNumber/newLineNumber based on side)
36
+ * 2. .line-num2: new line numbers for added/context lines
37
+ * 3. .line-num1: old line numbers for deleted lines
38
+ * 4. Nested selectors as fallback
39
+ * @param {Element} row - Table row element
40
+ * @param {string} [side] - Optional side ('LEFT' or 'RIGHT') to get specific coordinate system
41
+ * @returns {number|null} The line number or null if not found
42
+ */
43
+ getLineNumber(row, side) {
44
+ // If a specific side is requested, use the appropriate line number
45
+ if (side === 'LEFT') {
46
+ // LEFT side: use old line number
47
+ // Check data-old-line-number first (available on deleted lines and context lines)
48
+ if (row.dataset?.oldLineNumber) {
49
+ const oldNum = parseInt(row.dataset.oldLineNumber);
50
+ if (!isNaN(oldNum)) return oldNum;
51
+ }
52
+ // Fallback to .line-num1 span
53
+ const lineNum1 = row.querySelector('.line-num1')?.textContent?.trim();
54
+ if (lineNum1) return parseInt(lineNum1);
55
+ return null;
56
+ }
57
+
58
+ if (side === 'RIGHT') {
59
+ // RIGHT side: use new line number
60
+ // Check data-new-line-number first (available on added lines and context lines)
61
+ if (row.dataset?.newLineNumber) {
62
+ const newNum = parseInt(row.dataset.newLineNumber);
63
+ if (!isNaN(newNum)) return newNum;
64
+ }
65
+ // Check data-line-number as fallback (for added/context lines, this is the new number)
66
+ if (row.dataset?.lineNumber && row.dataset?.side === 'RIGHT') {
67
+ const datasetNum = parseInt(row.dataset.lineNumber);
68
+ if (!isNaN(datasetNum)) return datasetNum;
69
+ }
70
+ // Fallback to .line-num2 span
71
+ const lineNum2 = row.querySelector('.line-num2')?.textContent?.trim();
72
+ if (lineNum2) return parseInt(lineNum2);
73
+ return null;
74
+ }
75
+
76
+ // Default behavior (no side specified): return the row's primary line number
77
+ // Primary: use dataset.lineNumber if available (set during renderDiffLine)
78
+ // This correctly handles both deleted lines (uses oldNumber) and added/context lines (uses newNumber)
79
+ if (row.dataset?.lineNumber) {
80
+ const datasetNum = parseInt(row.dataset.lineNumber);
81
+ if (!isNaN(datasetNum)) return datasetNum;
82
+ }
83
+
84
+ // Fallback: check span elements
85
+ // For added/context lines, check .line-num2 (new line number)
86
+ let lineNum = row.querySelector('.line-num2')?.textContent?.trim();
87
+ if (lineNum) return parseInt(lineNum);
88
+
89
+ // For deleted lines, check .line-num1 (old line number)
90
+ lineNum = row.querySelector('.line-num1')?.textContent?.trim();
91
+ if (lineNum) return parseInt(lineNum);
92
+
93
+ // Alternative: .line-num-new
94
+ lineNum = row.querySelector('.line-num-new')?.textContent?.trim();
95
+ if (lineNum) return parseInt(lineNum);
96
+
97
+ // Nested: inside .d2h-code-linenumber container
98
+ const lineNumCell = row.querySelector('.d2h-code-linenumber');
99
+ if (lineNumCell) {
100
+ const lineNum2 = lineNumCell.querySelector('.line-num2');
101
+ if (lineNum2) {
102
+ lineNum = lineNum2.textContent?.trim();
103
+ if (lineNum) return parseInt(lineNum);
104
+ }
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Build a set of visible line numbers for a file element
112
+ * This is more efficient than checking each line individually when processing multiple suggestions
113
+ * @param {Element} fileElement - The file wrapper element
114
+ * @returns {Set<number>} Set of visible line numbers
115
+ */
116
+ buildVisibleLinesSet(fileElement) {
117
+ const visibleLines = new Set();
118
+ const lineRows = fileElement.querySelectorAll('tr');
119
+
120
+ for (const row of lineRows) {
121
+ const lineNum = this.getLineNumber(row);
122
+ if (lineNum !== null) {
123
+ visibleLines.add(lineNum);
124
+ }
125
+ }
126
+
127
+ return visibleLines;
128
+ }
129
+
130
+ /**
131
+ * Start line range selection
132
+ * @param {HTMLElement} row - The starting row
133
+ * @param {number} lineNumber - The line number
134
+ * @param {string} fileName - The file name
135
+ * @param {string} side - The side ('LEFT' or 'RIGHT')
136
+ */
137
+ startRangeSelection(row, lineNumber, fileName, side = 'RIGHT') {
138
+ // Clear any existing selection
139
+ this.clearRangeSelection();
140
+
141
+ // Set start of range (including side for GitHub API)
142
+ this.rangeSelectionStart = {
143
+ row: row,
144
+ lineNumber: lineNumber,
145
+ fileName: fileName,
146
+ side: side
147
+ };
148
+
149
+ // Add visual indicator
150
+ row.classList.add('line-range-start');
151
+ }
152
+
153
+ /**
154
+ * Complete line range selection
155
+ * @param {HTMLElement} endRow - The ending row
156
+ * @param {number} endLineNumber - The ending line number
157
+ * @param {string} fileName - The file name
158
+ * @param {Function} showCommentFormCallback - Callback to show comment form
159
+ */
160
+ completeRangeSelection(endRow, endLineNumber, fileName, showCommentFormCallback) {
161
+ if (!this.rangeSelectionStart) return;
162
+
163
+ // Ensure we're in the same file
164
+ if (this.rangeSelectionStart.fileName !== fileName) {
165
+ alert('Cannot select range across different files');
166
+ this.clearRangeSelection();
167
+ return;
168
+ }
169
+
170
+ const startLine = this.rangeSelectionStart.lineNumber;
171
+ const endLine = endLineNumber;
172
+
173
+ // Ensure start is before end
174
+ const minLine = Math.min(startLine, endLine);
175
+ const maxLine = Math.max(startLine, endLine);
176
+
177
+ // Highlight all rows in range (pass side to avoid highlighting both deleted and added lines with same line number)
178
+ const side = this.rangeSelectionStart.side;
179
+ this.highlightLineRange(this.rangeSelectionStart.row, endRow, fileName, minLine, maxLine, side);
180
+
181
+ // Store end of range
182
+ this.rangeSelectionEnd = {
183
+ row: endRow,
184
+ lineNumber: endLineNumber,
185
+ fileName: fileName
186
+ };
187
+
188
+ // Get diff position from the end row (GitHub uses position at end of range)
189
+ const diffPosition = endRow.dataset.diffPosition;
190
+
191
+ if (showCommentFormCallback) {
192
+ showCommentFormCallback(endRow, minLine, fileName, diffPosition, maxLine, side || 'RIGHT');
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Highlight all lines in a range
198
+ * @param {HTMLElement} startRow - The starting row element
199
+ * @param {HTMLElement} endRow - The ending row element
200
+ * @param {string} fileName - The file name
201
+ * @param {number} minLine - The minimum line number
202
+ * @param {number} maxLine - The maximum line number
203
+ * @param {string} side - The side of the diff ('LEFT' for deleted lines, 'RIGHT' for added/context)
204
+ */
205
+ highlightLineRange(startRow, endRow, fileName, minLine, maxLine, side) {
206
+ // Find all rows in the file between minLine and maxLine
207
+ const fileWrapper = startRow.closest('.d2h-file-wrapper');
208
+ if (!fileWrapper) return;
209
+
210
+ const allRows = fileWrapper.querySelectorAll('tr[data-line-number]');
211
+
212
+ allRows.forEach(row => {
213
+ const lineNum = parseInt(row.dataset.lineNumber);
214
+ const rowSide = row.dataset.side || 'RIGHT';
215
+ // Match by line number range, file name, and side
216
+ // This prevents deleted lines (LEFT) from matching added/context lines (RIGHT) with same line number
217
+ if (lineNum >= minLine && lineNum <= maxLine &&
218
+ row.dataset.fileName === fileName &&
219
+ rowSide === side) {
220
+ row.classList.add('line-range-selected');
221
+ }
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Clear line range selection
227
+ */
228
+ clearRangeSelection() {
229
+ // Remove all selection highlights
230
+ document.querySelectorAll('.line-range-start, .line-range-selected').forEach(row => {
231
+ row.classList.remove('line-range-start', 'line-range-selected');
232
+ });
233
+
234
+ // Clean up global listener if it exists
235
+ if (this.handleGlobalMouseUp) {
236
+ document.removeEventListener('mouseup', this.handleGlobalMouseUp);
237
+ this.handleGlobalMouseUp = null;
238
+ }
239
+
240
+ // Clear state
241
+ this.rangeSelectionStart = null;
242
+ this.rangeSelectionEnd = null;
243
+ this.isDraggingRange = false;
244
+ this.dragStartLine = null;
245
+ this.dragEndLine = null;
246
+ this.potentialDragStart = null;
247
+ }
248
+
249
+ /**
250
+ * Start drag selection
251
+ * @param {HTMLElement} row - The starting row
252
+ * @param {number} lineNumber - The line number
253
+ * @param {string} fileName - The file name
254
+ * @param {string} side - The side ('LEFT' or 'RIGHT')
255
+ */
256
+ startDragSelection(row, lineNumber, fileName, side = 'RIGHT') {
257
+ // Clear any existing selection and ensure cleanup
258
+ this.clearRangeSelection();
259
+
260
+ // Set dragging state
261
+ this.isDraggingRange = true;
262
+ this.dragStartLine = lineNumber;
263
+ this.dragEndLine = lineNumber;
264
+
265
+ // Set start of range, including side for GitHub API
266
+ this.rangeSelectionStart = {
267
+ row: row,
268
+ lineNumber: lineNumber,
269
+ fileName: fileName,
270
+ side: side
271
+ };
272
+
273
+ // Add visual indicator
274
+ row.classList.add('line-range-selected');
275
+
276
+ // Add global mouse up handler to catch mouseup outside of line numbers
277
+ // Store as bound function for reliable cleanup
278
+ this.handleGlobalMouseUp = (e) => {
279
+ if (this.isDraggingRange) {
280
+ this.completeDragSelection(row, this.dragEndLine || lineNumber, fileName);
281
+ }
282
+ };
283
+ document.addEventListener('mouseup', this.handleGlobalMouseUp);
284
+ }
285
+
286
+ /**
287
+ * Update drag selection as mouse moves
288
+ * @param {HTMLElement} row - The current row
289
+ * @param {number} lineNumber - The current line number
290
+ * @param {string} fileName - The file name
291
+ */
292
+ updateDragSelection(row, lineNumber, fileName) {
293
+ if (!this.isDraggingRange || !this.rangeSelectionStart) return;
294
+
295
+ // Ensure we're in the same file
296
+ if (this.rangeSelectionStart.fileName !== fileName) return;
297
+
298
+ // Update end line
299
+ this.dragEndLine = lineNumber;
300
+
301
+ // Update end of range
302
+ this.rangeSelectionEnd = {
303
+ row: row,
304
+ lineNumber: lineNumber,
305
+ fileName: fileName
306
+ };
307
+
308
+ // Clear existing highlights
309
+ document.querySelectorAll('.line-range-selected').forEach(r => {
310
+ r.classList.remove('line-range-selected');
311
+ });
312
+
313
+ // Highlight all rows in range (pass side to avoid highlighting both deleted and added lines with same line number)
314
+ const minLine = Math.min(this.dragStartLine, lineNumber);
315
+ const maxLine = Math.max(this.dragStartLine, lineNumber);
316
+ const side = this.rangeSelectionStart.side;
317
+ this.highlightLineRange(this.rangeSelectionStart.row, row, fileName, minLine, maxLine, side);
318
+ }
319
+
320
+ /**
321
+ * Complete drag selection
322
+ * @param {HTMLElement} row - The ending row
323
+ * @param {number} lineNumber - The ending line number
324
+ * @param {string} fileName - The file name
325
+ */
326
+ completeDragSelection(row, lineNumber, fileName) {
327
+ if (!this.isDraggingRange) return;
328
+
329
+ try {
330
+ // Update end of range
331
+ this.rangeSelectionEnd = {
332
+ row: row,
333
+ lineNumber: lineNumber,
334
+ fileName: fileName
335
+ };
336
+
337
+ // If we have a valid range (more than one line), keep selection
338
+ const minLine = Math.min(this.dragStartLine, this.dragEndLine);
339
+ const maxLine = Math.max(this.dragStartLine, this.dragEndLine);
340
+
341
+ if (minLine === maxLine) {
342
+ // Single line - clear selection
343
+ this.clearRangeSelection();
344
+ } else {
345
+ // Multi-line - keep selection for user to click + button
346
+ // The selection stays highlighted until they click a comment button or clear it
347
+ }
348
+ } finally {
349
+ // Always clean up the global listener and dragging state
350
+ if (this.handleGlobalMouseUp) {
351
+ document.removeEventListener('mouseup', this.handleGlobalMouseUp);
352
+ this.handleGlobalMouseUp = null;
353
+ }
354
+ this.isDraggingRange = false;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Check if there is an active range selection
360
+ * @returns {boolean} True if there is an active range selection
361
+ */
362
+ hasActiveSelection() {
363
+ return this.rangeSelectionStart !== null && this.rangeSelectionEnd !== null;
364
+ }
365
+
366
+ /**
367
+ * Get the current selection range
368
+ * @returns {{ start: number, end: number, fileName: string, side: string }|null}
369
+ */
370
+ getSelectionRange() {
371
+ if (!this.hasActiveSelection()) return null;
372
+
373
+ const minLine = Math.min(this.rangeSelectionStart.lineNumber, this.rangeSelectionEnd.lineNumber);
374
+ const maxLine = Math.max(this.rangeSelectionStart.lineNumber, this.rangeSelectionEnd.lineNumber);
375
+
376
+ return {
377
+ start: minLine,
378
+ end: maxLine,
379
+ fileName: this.rangeSelectionStart.fileName,
380
+ side: this.rangeSelectionStart.side
381
+ };
382
+ }
383
+ }
384
+
385
+ // Make LineTracker available globally
386
+ window.LineTracker = LineTracker;
@@ -0,0 +1,228 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Panel Resizer Module
4
+ * Handles drag-to-resize functionality for the sidebar and AI panel
5
+ */
6
+
7
+ window.PanelResizer = (function() {
8
+ 'use strict';
9
+
10
+ // Configuration
11
+ const CONFIG = {
12
+ sidebar: {
13
+ min: 150,
14
+ max: 400,
15
+ default: 260,
16
+ storageKey: 'sidebar-width',
17
+ cssVar: '--sidebar-width'
18
+ },
19
+ 'ai-panel': {
20
+ min: 200,
21
+ max: 600,
22
+ default: 320,
23
+ storageKey: 'ai-panel-width',
24
+ cssVar: '--ai-panel-width'
25
+ }
26
+ };
27
+
28
+ // State
29
+ let isDragging = false;
30
+ let currentPanel = null;
31
+ let startX = 0;
32
+ let startWidth = 0;
33
+
34
+ /**
35
+ * Initialize the panel resizer
36
+ */
37
+ function init() {
38
+ // Apply saved widths on load
39
+ applySavedWidths();
40
+
41
+ // Set up event listeners for resize handles
42
+ const handles = document.querySelectorAll('.resize-handle');
43
+ handles.forEach(handle => {
44
+ handle.addEventListener('mousedown', onMouseDown);
45
+ });
46
+
47
+ // Global mouse events for drag
48
+ document.addEventListener('mousemove', onMouseMove);
49
+ document.addEventListener('mouseup', onMouseUp);
50
+ }
51
+
52
+ /**
53
+ * Apply saved widths from localStorage
54
+ */
55
+ function applySavedWidths() {
56
+ Object.keys(CONFIG).forEach(panelName => {
57
+ const config = CONFIG[panelName];
58
+ const savedWidth = localStorage.getItem(config.storageKey);
59
+
60
+ if (savedWidth) {
61
+ const width = parseInt(savedWidth, 10);
62
+ if (width >= config.min && width <= config.max) {
63
+ document.documentElement.style.setProperty(config.cssVar, `${width}px`);
64
+ }
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Get the current width of a panel
71
+ * @param {string} panelName - 'sidebar' or 'ai-panel'
72
+ * @returns {number} Current width in pixels
73
+ */
74
+ function getPanelWidth(panelName) {
75
+ const config = CONFIG[panelName];
76
+ if (!config) return 0;
77
+
78
+ const cssValue = getComputedStyle(document.documentElement)
79
+ .getPropertyValue(config.cssVar)
80
+ .trim();
81
+
82
+ return parseInt(cssValue, 10) || config.default;
83
+ }
84
+
85
+ /**
86
+ * Set the width of a panel
87
+ * @param {string} panelName - 'sidebar' or 'ai-panel'
88
+ * @param {number} width - Width in pixels
89
+ * @param {boolean} save - Whether to save to localStorage
90
+ */
91
+ function setPanelWidth(panelName, width, save = true) {
92
+ const config = CONFIG[panelName];
93
+ if (!config) return;
94
+
95
+ // Clamp to min/max
96
+ const clampedWidth = Math.max(config.min, Math.min(config.max, width));
97
+
98
+ // Apply to CSS
99
+ document.documentElement.style.setProperty(config.cssVar, `${clampedWidth}px`);
100
+
101
+ // Save to localStorage
102
+ if (save) {
103
+ localStorage.setItem(config.storageKey, clampedWidth.toString());
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Handle mousedown on resize handle
109
+ * @param {MouseEvent} e
110
+ */
111
+ function onMouseDown(e) {
112
+ const handle = e.target.closest('.resize-handle');
113
+ if (!handle) return;
114
+
115
+ const panelName = handle.dataset.panel;
116
+ const panelEl = panelName === 'sidebar'
117
+ ? document.getElementById('files-sidebar')
118
+ : document.getElementById('ai-panel');
119
+
120
+ // Don't allow resize if panel is collapsed
121
+ if (panelEl && panelEl.classList.contains('collapsed')) {
122
+ return;
123
+ }
124
+
125
+ isDragging = true;
126
+ currentPanel = panelName;
127
+ startX = e.clientX;
128
+ startWidth = getPanelWidth(panelName);
129
+
130
+ // Add visual feedback
131
+ handle.classList.add('dragging');
132
+ document.body.classList.add('resizing');
133
+
134
+ e.preventDefault();
135
+ }
136
+
137
+ /**
138
+ * Handle mousemove during drag
139
+ * @param {MouseEvent} e
140
+ */
141
+ function onMouseMove(e) {
142
+ if (!isDragging || !currentPanel) return;
143
+
144
+ const config = CONFIG[currentPanel];
145
+ if (!config) return;
146
+
147
+ // Calculate delta based on panel position
148
+ // For sidebar (left panel): moving right increases width
149
+ // For ai-panel (right panel): moving left increases width
150
+ let delta;
151
+ if (currentPanel === 'sidebar') {
152
+ delta = e.clientX - startX;
153
+ } else {
154
+ delta = startX - e.clientX;
155
+ }
156
+
157
+ const newWidth = startWidth + delta;
158
+ setPanelWidth(currentPanel, newWidth, false); // Don't save during drag
159
+ }
160
+
161
+ /**
162
+ * Handle mouseup to end drag
163
+ * @param {MouseEvent} e
164
+ */
165
+ function onMouseUp(e) {
166
+ if (!isDragging) return;
167
+
168
+ // Save final width
169
+ if (currentPanel) {
170
+ const finalWidth = getPanelWidth(currentPanel);
171
+ const config = CONFIG[currentPanel];
172
+ if (config) {
173
+ localStorage.setItem(config.storageKey, finalWidth.toString());
174
+ }
175
+ }
176
+
177
+ // Clean up
178
+ const handle = document.querySelector('.resize-handle.dragging');
179
+ if (handle) {
180
+ handle.classList.remove('dragging');
181
+ }
182
+ document.body.classList.remove('resizing');
183
+
184
+ isDragging = false;
185
+ currentPanel = null;
186
+ startX = 0;
187
+ startWidth = 0;
188
+ }
189
+
190
+ /**
191
+ * Get saved width for a panel (useful for AIPanel integration)
192
+ * @param {string} panelName - 'sidebar' or 'ai-panel'
193
+ * @returns {number|null} Saved width or null if not saved
194
+ */
195
+ function getSavedWidth(panelName) {
196
+ const config = CONFIG[panelName];
197
+ if (!config) return null;
198
+
199
+ const saved = localStorage.getItem(config.storageKey);
200
+ if (saved) {
201
+ const width = parseInt(saved, 10);
202
+ if (width >= config.min && width <= config.max) {
203
+ return width;
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Get default width for a panel
211
+ * @param {string} panelName - 'sidebar' or 'ai-panel'
212
+ * @returns {number} Default width
213
+ */
214
+ function getDefaultWidth(panelName) {
215
+ const config = CONFIG[panelName];
216
+ return config ? config.default : 0;
217
+ }
218
+
219
+ // Public API
220
+ return {
221
+ init,
222
+ getPanelWidth,
223
+ setPanelWidth,
224
+ getSavedWidth,
225
+ getDefaultWidth,
226
+ applySavedWidths
227
+ };
228
+ })();
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Storage Cleanup Module
4
+ * Cleans up legacy localStorage keys from older versions of the app.
5
+ * This prevents stale/orphaned data from causing confusion across sessions.
6
+ *
7
+ * Used by both PR mode (pr.js) and Local mode (local.js) on startup.
8
+ */
9
+
10
+ /**
11
+ * Remove legacy localStorage keys that are no longer used.
12
+ * This runs on every page load to clean up stale data.
13
+ */
14
+ function cleanupLegacyLocalStorage() {
15
+ const legacyKeys = [
16
+ 'pair-review-session-state', // Old session state format
17
+ 'reviewPanelSegment', // Unscoped version (now uses reviewPanelSegment_${key})
18
+ 'pair-review-preferences', // Old preferences format
19
+ 'pairReviewSidebarCollapsed', // Duplicate of file-sidebar-collapsed
20
+ 'pairReviewTheme', // Duplicate of theme
21
+ 'settingsReferrer', // Unscoped version (now uses settingsReferrer:${repo})
22
+ ];
23
+
24
+ // Remove known legacy keys
25
+ legacyKeys.forEach(key => {
26
+ if (localStorage.getItem(key) !== null) {
27
+ localStorage.removeItem(key);
28
+ console.log(`[cleanup] Removed legacy localStorage key: ${key}`);
29
+ }
30
+ });
31
+ }
32
+
33
+ // Export for use in other modules
34
+ if (typeof window !== 'undefined') {
35
+ window.cleanupLegacyLocalStorage = cleanupLegacyLocalStorage;
36
+ }