| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame^] | 1 | import * as Diff2Html from "diff2html"; |
| 2 | |
| 3 | /** |
| 4 | * Class to handle diff and commit viewing functionality in the timeline UI. |
| 5 | */ |
| 6 | export class DiffViewer { |
| 7 | // Current commit hash being viewed |
| 8 | private currentCommitHash: string = ""; |
| 9 | // Selected line in the diff for commenting |
| 10 | private selectedDiffLine: string | null = null; |
| 11 | // Current view mode (needed for integration with TimelineManager) |
| 12 | private viewMode: string = "chat"; |
| 13 | |
| 14 | /** |
| 15 | * Constructor for DiffViewer |
| 16 | */ |
| 17 | constructor() {} |
| 18 | |
| 19 | /** |
| 20 | * Sets the current view mode |
| 21 | * @param mode The current view mode |
| 22 | */ |
| 23 | public setViewMode(mode: string): void { |
| 24 | this.viewMode = mode; |
| 25 | } |
| 26 | |
| 27 | /** |
| 28 | * Gets the current commit hash |
| 29 | * @returns The current commit hash |
| 30 | */ |
| 31 | public getCurrentCommitHash(): string { |
| 32 | return this.currentCommitHash; |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Sets the current commit hash |
| 37 | * @param hash The commit hash to set |
| 38 | */ |
| 39 | public setCurrentCommitHash(hash: string): void { |
| 40 | this.currentCommitHash = hash; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Clears the current commit hash |
| 45 | */ |
| 46 | public clearCurrentCommitHash(): void { |
| 47 | this.currentCommitHash = ""; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Loads diff content and renders it using diff2html |
| 52 | * @param commitHash Optional commit hash to load diff for |
| 53 | */ |
| 54 | public async loadDiff2HtmlContent(commitHash?: string): Promise<void> { |
| 55 | const diff2htmlContent = document.getElementById("diff2htmlContent"); |
| 56 | const container = document.querySelector(".timeline-container"); |
| 57 | if (!diff2htmlContent || !container) return; |
| 58 | |
| 59 | try { |
| 60 | // Show loading state |
| 61 | diff2htmlContent.innerHTML = "Loading enhanced diff..."; |
| 62 | |
| 63 | // Add classes to container to allow full-width rendering |
| 64 | container.classList.add("diff2-active"); |
| 65 | container.classList.add("diff-active"); |
| 66 | |
| 67 | // Use currentCommitHash if provided or passed from parameter |
| 68 | const hash = commitHash || this.currentCommitHash; |
| 69 | |
| 70 | // Build the diff URL - include commit hash if specified |
| 71 | const diffUrl = hash ? `diff?commit=${hash}` : "diff"; |
| 72 | |
| 73 | // Fetch the diff from the server |
| 74 | const response = await fetch(diffUrl); |
| 75 | |
| 76 | if (!response.ok) { |
| 77 | throw new Error( |
| 78 | `Server returned ${response.status}: ${response.statusText}`, |
| 79 | ); |
| 80 | } |
| 81 | |
| 82 | const diffText = await response.text(); |
| 83 | |
| 84 | if (!diffText || diffText.trim() === "") { |
| 85 | diff2htmlContent.innerHTML = |
| 86 | "<span style='color: #666; font-style: italic;'>No changes detected since conversation started.</span>"; |
| 87 | return; |
| 88 | } |
| 89 | |
| 90 | // Get the selected view format |
| 91 | const formatRadios = document.getElementsByName("diffViewFormat") as NodeListOf<HTMLInputElement>; |
| 92 | let outputFormat = "side-by-side"; // default |
| 93 | |
| 94 | // Convert NodeListOf to Array to ensure [Symbol.iterator]() is available |
| 95 | Array.from(formatRadios).forEach(radio => { |
| 96 | if (radio.checked) { |
| 97 | outputFormat = radio.value as "side-by-side" | "line-by-line"; |
| 98 | } |
| 99 | }) |
| 100 | |
| 101 | // Render the diff using diff2html |
| 102 | const diffHtml = Diff2Html.html(diffText, { |
| 103 | outputFormat: outputFormat as "side-by-side" | "line-by-line", |
| 104 | drawFileList: true, |
| 105 | matching: "lines", |
| 106 | // Make sure no unnecessary scrollbars in the nested containers |
| 107 | renderNothingWhenEmpty: false, |
| 108 | colorScheme: "light" as any, // Force light mode to match the rest of the UI |
| 109 | }); |
| 110 | |
| 111 | // Insert the generated HTML |
| 112 | diff2htmlContent.innerHTML = diffHtml; |
| 113 | |
| 114 | // Add CSS styles to ensure we don't have double scrollbars |
| 115 | const d2hFiles = diff2htmlContent.querySelectorAll(".d2h-file-wrapper"); |
| 116 | d2hFiles.forEach((file) => { |
| 117 | const contentElem = file.querySelector(".d2h-files-diff"); |
| 118 | if (contentElem) { |
| 119 | // Remove internal scrollbar - the outer container will handle scrolling |
| 120 | (contentElem as HTMLElement).style.overflow = "visible"; |
| 121 | (contentElem as HTMLElement).style.maxHeight = "none"; |
| 122 | } |
| 123 | }); |
| 124 | |
| 125 | // Add click event handlers to each code line for commenting |
| 126 | this.setupDiff2LineComments(); |
| 127 | |
| 128 | // Setup event listeners for diff view format radio buttons |
| 129 | this.setupDiffViewFormatListeners(); |
| 130 | } catch (error) { |
| 131 | console.error("Error loading diff2html content:", error); |
| 132 | const errorMessage = |
| 133 | error instanceof Error ? error.message : "Unknown error"; |
| 134 | diff2htmlContent.innerHTML = `<span style='color: #dc3545;'>Error loading enhanced diff: ${errorMessage}</span>`; |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * Setup event listeners for diff view format radio buttons |
| 140 | */ |
| 141 | private setupDiffViewFormatListeners(): void { |
| 142 | const formatRadios = document.getElementsByName("diffViewFormat") as NodeListOf<HTMLInputElement>; |
| 143 | |
| 144 | // Convert NodeListOf to Array to ensure [Symbol.iterator]() is available |
| 145 | Array.from(formatRadios).forEach(radio => { |
| 146 | radio.addEventListener("change", () => { |
| 147 | // Reload the diff with the new format when radio selection changes |
| 148 | this.loadDiff2HtmlContent(this.currentCommitHash); |
| 149 | }); |
| 150 | }) |
| 151 | } |
| 152 | |
| 153 | /** |
| 154 | * Setup handlers for diff2 code lines to enable commenting |
| 155 | */ |
| 156 | private setupDiff2LineComments(): void { |
| 157 | const diff2htmlContent = document.getElementById("diff2htmlContent"); |
| 158 | if (!diff2htmlContent) return; |
| 159 | |
| 160 | console.log("Setting up diff2 line comments"); |
| 161 | |
| 162 | // Add plus buttons to each code line |
| 163 | this.addCommentButtonsToCodeLines(); |
| 164 | |
| 165 | // Use event delegation for handling clicks on plus buttons |
| 166 | diff2htmlContent.addEventListener("click", (event) => { |
| 167 | const target = event.target as HTMLElement; |
| 168 | |
| 169 | // Only respond to clicks on the plus button |
| 170 | if (target.classList.contains("d2h-gutter-comment-button")) { |
| 171 | // Find the parent row first |
| 172 | const row = target.closest("tr"); |
| 173 | if (!row) return; |
| 174 | |
| 175 | // Then find the code line in that row |
| 176 | const codeLine = row.querySelector(".d2h-code-side-line") || row.querySelector(".d2h-code-line"); |
| 177 | if (!codeLine) return; |
| 178 | |
| 179 | // Get the line text content |
| 180 | const lineContent = codeLine.querySelector(".d2h-code-line-ctn"); |
| 181 | if (!lineContent) return; |
| 182 | |
| 183 | const lineText = lineContent.textContent?.trim() || ""; |
| 184 | |
| 185 | // Get file name to add context |
| 186 | const fileHeader = codeLine |
| 187 | .closest(".d2h-file-wrapper") |
| 188 | ?.querySelector(".d2h-file-name"); |
| 189 | const fileName = fileHeader |
| 190 | ? fileHeader.textContent?.trim() |
| 191 | : "Unknown file"; |
| 192 | |
| 193 | // Get line number if available |
| 194 | const lineNumElem = codeLine |
| 195 | .closest("tr") |
| 196 | ?.querySelector(".d2h-code-side-linenumber"); |
| 197 | const lineNum = lineNumElem ? lineNumElem.textContent?.trim() : ""; |
| 198 | const lineInfo = lineNum ? `Line ${lineNum}: ` : ""; |
| 199 | |
| 200 | // Format the line for the comment box with file context and line number |
| 201 | const formattedLine = `${fileName} ${lineInfo}${lineText}`; |
| 202 | |
| 203 | console.log("Comment button clicked for line: ", formattedLine); |
| 204 | |
| 205 | // Open the comment box with this line |
| 206 | this.openDiffCommentBox(formattedLine, 0); |
| 207 | |
| 208 | // Prevent event from bubbling up |
| 209 | event.stopPropagation(); |
| 210 | } |
| 211 | }); |
| 212 | |
| 213 | // Handle text selection |
| 214 | let isSelecting = false; |
| 215 | |
| 216 | diff2htmlContent.addEventListener("mousedown", () => { |
| 217 | isSelecting = false; |
| 218 | }); |
| 219 | |
| 220 | diff2htmlContent.addEventListener("mousemove", (event) => { |
| 221 | // If mouse is moving with button pressed, user is selecting text |
| 222 | if (event.buttons === 1) { // Primary button (usually left) is pressed |
| 223 | isSelecting = true; |
| 224 | } |
| 225 | }); |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Add plus buttons to each table row in the diff for commenting |
| 230 | */ |
| 231 | private addCommentButtonsToCodeLines(): void { |
| 232 | const diff2htmlContent = document.getElementById("diff2htmlContent"); |
| 233 | if (!diff2htmlContent) return; |
| 234 | |
| 235 | // Target code lines first, then find their parent rows |
| 236 | const codeLines = diff2htmlContent.querySelectorAll( |
| 237 | ".d2h-code-side-line, .d2h-code-line" |
| 238 | ); |
| 239 | |
| 240 | // Create a Set to store unique rows to avoid duplicates |
| 241 | const rowsSet = new Set<HTMLElement>(); |
| 242 | |
| 243 | // Get all rows that contain code lines |
| 244 | codeLines.forEach(line => { |
| 245 | const row = line.closest('tr'); |
| 246 | if (row) rowsSet.add(row as HTMLElement); |
| 247 | }); |
| 248 | |
| 249 | // Convert Set back to array for processing |
| 250 | const codeRows = Array.from(rowsSet); |
| 251 | |
| 252 | codeRows.forEach((row) => { |
| 253 | const rowElem = row as HTMLElement; |
| 254 | |
| 255 | // Skip info lines without actual code (e.g., "file added") |
| 256 | if (rowElem.querySelector(".d2h-info")) { |
| 257 | return; |
| 258 | } |
| 259 | |
| 260 | // Find the code line number element (first TD in the row) |
| 261 | const lineNumberCell = rowElem.querySelector( |
| 262 | ".d2h-code-side-linenumber, .d2h-code-linenumber" |
| 263 | ); |
| 264 | |
| 265 | if (!lineNumberCell) return; |
| 266 | |
| 267 | // Create the plus button |
| 268 | const plusButton = document.createElement("span"); |
| 269 | plusButton.className = "d2h-gutter-comment-button"; |
| 270 | plusButton.innerHTML = "+"; |
| 271 | plusButton.title = "Add a comment on this line"; |
| 272 | |
| 273 | // Add button to the line number cell for proper positioning |
| 274 | (lineNumberCell as HTMLElement).style.position = "relative"; // Ensure positioning context |
| 275 | lineNumberCell.appendChild(plusButton); |
| 276 | }); |
| 277 | } |
| 278 | |
| 279 | /** |
| 280 | * Open the comment box for a selected diff line |
| 281 | */ |
| 282 | private openDiffCommentBox(lineText: string, _lineNumber: number): void { |
| 283 | const commentBox = document.getElementById("diffCommentBox"); |
| 284 | const selectedLine = document.getElementById("selectedLine"); |
| 285 | const commentInput = document.getElementById( |
| 286 | "diffCommentInput", |
| 287 | ) as HTMLTextAreaElement; |
| 288 | |
| 289 | if (!commentBox || !selectedLine || !commentInput) return; |
| 290 | |
| 291 | // Store the selected line |
| 292 | this.selectedDiffLine = lineText; |
| 293 | |
| 294 | // Display the line in the comment box |
| 295 | selectedLine.textContent = lineText; |
| 296 | |
| 297 | // Reset the comment input |
| 298 | commentInput.value = ""; |
| 299 | |
| 300 | // Show the comment box |
| 301 | commentBox.style.display = "block"; |
| 302 | |
| 303 | // Focus on the comment input |
| 304 | commentInput.focus(); |
| 305 | |
| 306 | // Add event listeners for submit and cancel buttons |
| 307 | const submitButton = document.getElementById("submitDiffComment"); |
| 308 | if (submitButton) { |
| 309 | submitButton.onclick = () => this.submitDiffComment(); |
| 310 | } |
| 311 | |
| 312 | const cancelButton = document.getElementById("cancelDiffComment"); |
| 313 | if (cancelButton) { |
| 314 | cancelButton.onclick = () => this.closeDiffCommentBox(); |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | /** |
| 319 | * Close the diff comment box without submitting |
| 320 | */ |
| 321 | private closeDiffCommentBox(): void { |
| 322 | const commentBox = document.getElementById("diffCommentBox"); |
| 323 | if (commentBox) { |
| 324 | commentBox.style.display = "none"; |
| 325 | } |
| 326 | this.selectedDiffLine = null; |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * Submit a comment on a diff line |
| 331 | */ |
| 332 | private submitDiffComment(): void { |
| 333 | const commentInput = document.getElementById( |
| 334 | "diffCommentInput", |
| 335 | ) as HTMLTextAreaElement; |
| 336 | const chatInput = document.getElementById( |
| 337 | "chatInput", |
| 338 | ) as HTMLTextAreaElement; |
| 339 | |
| 340 | if (!commentInput || !chatInput) return; |
| 341 | |
| 342 | const comment = commentInput.value.trim(); |
| 343 | |
| 344 | // Validate inputs |
| 345 | if (!this.selectedDiffLine || !comment) { |
| 346 | alert("Please select a line and enter a comment."); |
| 347 | return; |
| 348 | } |
| 349 | |
| 350 | // Format the comment in a readable way |
| 351 | const formattedComment = `\`\`\`\n${this.selectedDiffLine}\n\`\`\`\n\n${comment}`; |
| 352 | |
| 353 | // Append the formatted comment to the chat textarea |
| 354 | if (chatInput.value.trim() !== "") { |
| 355 | chatInput.value += "\n\n"; // Add two line breaks before the new comment |
| 356 | } |
| 357 | chatInput.value += formattedComment; |
| 358 | chatInput.focus(); |
| 359 | |
| 360 | // Close only the comment box but keep the diff view open |
| 361 | this.closeDiffCommentBox(); |
| 362 | } |
| 363 | |
| 364 | /** |
| 365 | * Show diff for a specific commit |
| 366 | * @param commitHash The commit hash to show diff for |
| 367 | * @param toggleViewModeCallback Callback to toggle view mode to diff |
| 368 | */ |
| 369 | public showCommitDiff(commitHash: string, toggleViewModeCallback: (mode: string) => void): void { |
| 370 | // Store the commit hash |
| 371 | this.currentCommitHash = commitHash; |
| 372 | |
| 373 | // Switch to diff2 view (side-by-side) |
| 374 | toggleViewModeCallback("diff2"); |
| 375 | } |
| 376 | |
| 377 | /** |
| 378 | * Clean up resources when component is destroyed |
| 379 | */ |
| 380 | public dispose(): void { |
| 381 | // Clean up any resources or event listeners here |
| 382 | // Currently there are no specific resources to clean up |
| 383 | } |
| 384 | } |