blob: 1460dc36d26076d89ad14f3fe8bac6e6446ca794 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import * as Diff2Html from "diff2html";
2
3/**
4 * Class to handle diff and commit viewing functionality in the timeline UI.
5 */
6export 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}