blob: 562eb1e221649c6de6674d2f0ac743f8a548d353 [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import {css, html, LitElement, unsafeCSS} from 'lit';
2import {customElement, property, state} from 'lit/decorators.js';
3import * as Diff2Html from "diff2html";
4
5@customElement('sketch-diff-view')
6export class SketchDiffView extends LitElement {
7 // Current commit hash being viewed
8 @property({type: String})
9 commitHash: string = "";
10
11 // Selected line in the diff for commenting
12 @state()
13 private selectedDiffLine: string | null = null;
14
15 // View format (side-by-side or line-by-line)
16 @state()
17 private viewFormat: "side-by-side" | "line-by-line" = "side-by-side";
18
19 static styles = css`
20 .diff-view {
21 flex: 1;
22 display: flex;
23 flex-direction: column;
24 overflow: hidden;
25 height: 100%;
26 }
27
28 .diff-container {
29 height: 100%;
30 overflow: auto;
31 flex: 1;
32 padding: 0 1rem;
33 }
34
35 #diff-view-controls {
36 display: flex;
37 justify-content: flex-end;
38 padding: 10px;
39 background: #f8f8f8;
40 border-bottom: 1px solid #eee;
41 }
42
43 .diff-view-format {
44 display: flex;
45 gap: 10px;
46 }
47
48 .diff-view-format label {
49 cursor: pointer;
50 }
51
52 .diff2html-content {
53 font-family: var(--monospace-font);
54 position: relative;
55 }
56
57 /* Comment box styles */
58 .diff-comment-box {
59 position: fixed;
60 bottom: 80px;
61 right: 20px;
62 width: 400px;
63 background-color: white;
64 border: 1px solid #ddd;
65 border-radius: 4px;
66 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
67 padding: 16px;
68 z-index: 1000;
69 }
70
71 .diff-comment-box h3 {
72 margin-top: 0;
73 margin-bottom: 10px;
74 font-size: 16px;
75 }
76
77 .selected-line {
78 margin-bottom: 10px;
79 font-size: 14px;
80 }
81
82 .selected-line pre {
83 padding: 6px;
84 background: #f5f5f5;
85 border: 1px solid #eee;
86 border-radius: 3px;
87 margin: 5px 0;
88 max-height: 100px;
89 overflow: auto;
90 font-family: var(--monospace-font);
91 font-size: 13px;
92 white-space: pre-wrap;
93 }
94
95 #diffCommentInput {
96 width: 100%;
97 height: 100px;
98 padding: 8px;
99 border: 1px solid #ddd;
100 border-radius: 4px;
101 resize: vertical;
102 font-family: inherit;
103 margin-bottom: 10px;
104 }
105
106 .diff-comment-buttons {
107 display: flex;
108 justify-content: flex-end;
109 gap: 8px;
110 }
111
112 .diff-comment-buttons button {
113 padding: 6px 12px;
114 border-radius: 4px;
115 border: 1px solid #ddd;
116 background: white;
117 cursor: pointer;
118 }
119
120 .diff-comment-buttons button:hover {
121 background: #f5f5f5;
122 }
123
124 .diff-comment-buttons button#submitDiffComment {
125 background: #1a73e8;
126 color: white;
127 border-color: #1a73e8;
128 }
129
130 .diff-comment-buttons button#submitDiffComment:hover {
131 background: #1967d2;
132 }
133
134 /* Styles for the comment button on diff lines */
135 .d2h-gutter-comment-button {
136 position: absolute;
137 right: 0;
138 top: 50%;
139 transform: translateY(-50%);
140 visibility: hidden;
141 background: rgba(255, 255, 255, 0.8);
142 border-radius: 50%;
143 width: 16px;
144 height: 16px;
145 line-height: 13px;
146 text-align: center;
147 font-size: 14px;
148 cursor: pointer;
149 color: #666;
150 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
151 }
152
153 tr:hover .d2h-gutter-comment-button {
154 visibility: visible;
155 }
156
157 .d2h-gutter-comment-button:hover {
158 background: white;
159 color: #333;
160 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
161 }
162 `;
163
164 constructor() {
165 super();
166 }
167
168 // See https://lit.dev/docs/components/lifecycle/
169 connectedCallback() {
170 super.connectedCallback();
171
172 // Load the diff2html CSS if needed
173 this.loadDiff2HtmlCSS();
174 }
175
176 // Load diff2html CSS into the shadow DOM
177 private async loadDiff2HtmlCSS() {
178 try {
179 // Check if diff2html styles are already loaded
180 const styleId = 'diff2html-styles';
181 if (this.shadowRoot?.getElementById(styleId)) {
182 return; // Already loaded
183 }
184
185 // Fetch the diff2html CSS
186 const response = await fetch('static/diff2html.min.css');
187
188 if (!response.ok) {
189 console.error(`Failed to load diff2html CSS: ${response.status} ${response.statusText}`);
190 return;
191 }
192
193 const cssText = await response.text();
194
195 // Create a style element and append to shadow DOM
196 const style = document.createElement('style');
197 style.id = styleId;
198 style.textContent = cssText;
199 this.shadowRoot?.appendChild(style);
200
201 console.log('diff2html CSS loaded into shadow DOM');
202 } catch (error) {
203 console.error('Error loading diff2html CSS:', error);
204 }
205 }
206
207 // See https://lit.dev/docs/components/lifecycle/
208 disconnectedCallback() {
209 super.disconnectedCallback();
210 }
211
212 // Method called to load diff content
213 async loadDiffContent() {
214 // Wait for the component to be rendered
215 await this.updateComplete;
216
217 const diff2htmlContent = this.shadowRoot?.getElementById("diff2htmlContent");
218 if (!diff2htmlContent) return;
219
220 try {
221 // Show loading state
222 diff2htmlContent.innerHTML = "Loading enhanced diff...";
223
224 // Build the diff URL - include commit hash if specified
225 const diffUrl = this.commitHash ? `diff?commit=${this.commitHash}` : "diff";
226
227 // Fetch the diff from the server
228 const response = await fetch(diffUrl);
229
230 if (!response.ok) {
231 throw new Error(
232 `Server returned ${response.status}: ${response.statusText}`,
233 );
234 }
235
236 const diffText = await response.text();
237
238 if (!diffText || diffText.trim() === "") {
239 diff2htmlContent.innerHTML =
240 "<span style='color: #666; font-style: italic;'>No changes detected since conversation started.</span>";
241 return;
242 }
243
244 // Render the diff using diff2html
245 const diffHtml = Diff2Html.html(diffText, {
246 outputFormat: this.viewFormat,
247 drawFileList: true,
248 matching: "lines",
249 renderNothingWhenEmpty: false,
250 colorScheme: "light" as any, // Force light mode to match the rest of the UI
251 });
252
253 // Insert the generated HTML
254 diff2htmlContent.innerHTML = diffHtml;
255
256 // Add CSS styles to ensure we don't have double scrollbars
257 const d2hFiles = diff2htmlContent.querySelectorAll(".d2h-file-wrapper");
258 d2hFiles.forEach((file) => {
259 const contentElem = file.querySelector(".d2h-files-diff");
260 if (contentElem) {
261 // Remove internal scrollbar - the outer container will handle scrolling
262 (contentElem as HTMLElement).style.overflow = "visible";
263 (contentElem as HTMLElement).style.maxHeight = "none";
264 }
265 });
266
267 // Add click event handlers to each code line for commenting
268 this.setupDiffLineComments();
269
270 } catch (error) {
271 console.error("Error loading diff2html content:", error);
272 const errorMessage =
273 error instanceof Error ? error.message : "Unknown error";
274 diff2htmlContent.innerHTML = `<span style='color: #dc3545;'>Error loading enhanced diff: ${errorMessage}</span>`;
275 }
276 }
277
278 // Handle view format changes
279 private handleViewFormatChange(event: Event) {
280 const input = event.target as HTMLInputElement;
281 if (input.checked) {
282 this.viewFormat = input.value as "side-by-side" | "line-by-line";
283 this.loadDiffContent();
284 }
285 }
286
287 /**
288 * Setup handlers for diff code lines to enable commenting
289 */
290 private setupDiffLineComments(): void {
291 const diff2htmlContent = this.shadowRoot?.getElementById("diff2htmlContent");
292 if (!diff2htmlContent) return;
293
294 console.log("Setting up diff line comments");
295
296 // Add plus buttons to each code line
297 this.addCommentButtonsToCodeLines();
298
299 // Use event delegation for handling clicks on plus buttons
300 diff2htmlContent.addEventListener("click", (event) => {
301 const target = event.target as HTMLElement;
302
303 // Only respond to clicks on the plus button
304 if (target.classList.contains("d2h-gutter-comment-button")) {
305 // Find the parent row first
306 const row = target.closest("tr");
307 if (!row) return;
308
309 // Then find the code line in that row
310 const codeLine = row.querySelector(".d2h-code-side-line") || row.querySelector(".d2h-code-line");
311 if (!codeLine) return;
312
313 // Get the line text content
314 const lineContent = codeLine.querySelector(".d2h-code-line-ctn");
315 if (!lineContent) return;
316
317 const lineText = lineContent.textContent?.trim() || "";
318
319 // Get file name to add context
320 const fileHeader = codeLine
321 .closest(".d2h-file-wrapper")
322 ?.querySelector(".d2h-file-name");
323 const fileName = fileHeader
324 ? fileHeader.textContent?.trim()
325 : "Unknown file";
326
327 // Get line number if available
328 const lineNumElem = codeLine
329 .closest("tr")
330 ?.querySelector(".d2h-code-side-linenumber");
331 const lineNum = lineNumElem ? lineNumElem.textContent?.trim() : "";
332 const lineInfo = lineNum ? `Line ${lineNum}: ` : "";
333
334 // Format the line for the comment box with file context and line number
335 const formattedLine = `${fileName} ${lineInfo}${lineText}`;
336
337 console.log("Comment button clicked for line: ", formattedLine);
338
339 // Open the comment box with this line
340 this.openDiffCommentBox(formattedLine);
341
342 // Prevent event from bubbling up
343 event.stopPropagation();
344 }
345 });
346 }
347
348 /**
349 * Add plus buttons to each table row in the diff for commenting
350 */
351 private addCommentButtonsToCodeLines(): void {
352 const diff2htmlContent = this.shadowRoot?.getElementById("diff2htmlContent");
353 if (!diff2htmlContent) return;
354
355 // Target code lines first, then find their parent rows
356 const codeLines = diff2htmlContent.querySelectorAll(
357 ".d2h-code-side-line, .d2h-code-line"
358 );
359
360 // Create a Set to store unique rows to avoid duplicates
361 const rowsSet = new Set<HTMLElement>();
362
363 // Get all rows that contain code lines
364 codeLines.forEach(line => {
365 const row = line.closest('tr');
366 if (row) rowsSet.add(row as HTMLElement);
367 });
368
369 // Convert Set back to array for processing
370 const codeRows = Array.from(rowsSet);
371
372 codeRows.forEach((row) => {
373 const rowElem = row as HTMLElement;
374
375 // Skip info lines without actual code (e.g., "file added")
376 if (rowElem.querySelector(".d2h-info")) {
377 return;
378 }
379
380 // Find the code line number element (first TD in the row)
381 const lineNumberCell = rowElem.querySelector(
382 ".d2h-code-side-linenumber, .d2h-code-linenumber"
383 );
384
385 if (!lineNumberCell) return;
386
387 // Create the plus button
388 const plusButton = document.createElement("span");
389 plusButton.className = "d2h-gutter-comment-button";
390 plusButton.innerHTML = "+";
391 plusButton.title = "Add a comment on this line";
392
393 // Add button to the line number cell for proper positioning
394 (lineNumberCell as HTMLElement).style.position = "relative"; // Ensure positioning context
395 lineNumberCell.appendChild(plusButton);
396 });
397 }
398
399 /**
400 * Open the comment box for a selected diff line
401 */
402 private openDiffCommentBox(lineText: string): void {
403 // Make sure the comment box div exists
404 const commentBoxId = "diffCommentBox";
405 let commentBox = this.shadowRoot?.getElementById(commentBoxId);
406
407 // If it doesn't exist, create it
408 if (!commentBox) {
409 commentBox = document.createElement("div");
410 commentBox.id = commentBoxId;
411 commentBox.className = "diff-comment-box";
412
413 // Create the comment box contents
414 commentBox.innerHTML = `
415 <h3>Add a comment</h3>
416 <div class="selected-line">
417 Line:
418 <pre id="selectedLine"></pre>
419 </div>
420 <textarea
421 id="diffCommentInput"
422 placeholder="Enter your comment about this line..."
423 ></textarea>
424 <div class="diff-comment-buttons">
425 <button id="cancelDiffComment">Cancel</button>
426 <button id="submitDiffComment">Add Comment</button>
427 </div>
428 `;
429
430 this.shadowRoot?.appendChild(commentBox);
431 }
432
433 // Store the selected line
434 this.selectedDiffLine = lineText;
435
436 // Display the line in the comment box
437 const selectedLine = this.shadowRoot?.getElementById("selectedLine");
438 if (selectedLine) {
439 selectedLine.textContent = lineText;
440 }
441
442 // Reset the comment input
443 const commentInput = this.shadowRoot?.getElementById(
444 "diffCommentInput"
445 ) as HTMLTextAreaElement;
446 if (commentInput) {
447 commentInput.value = "";
448 }
449
450 // Show the comment box
451 if (commentBox) {
452 commentBox.style.display = "block";
453 }
454
455 // Add event listeners for submit and cancel buttons
456 const submitButton = this.shadowRoot?.getElementById("submitDiffComment");
457 if (submitButton) {
458 submitButton.onclick = () => this.submitDiffComment();
459 }
460
461 const cancelButton = this.shadowRoot?.getElementById("cancelDiffComment");
462 if (cancelButton) {
463 cancelButton.onclick = () => this.closeDiffCommentBox();
464 }
465
466 // Focus on the comment input
467 if (commentInput) {
468 commentInput.focus();
469 }
470 }
471
472 /**
473 * Close the diff comment box without submitting
474 */
475 private closeDiffCommentBox(): void {
476 const commentBox = this.shadowRoot?.getElementById("diffCommentBox");
477 if (commentBox) {
478 commentBox.style.display = "none";
479 }
480 this.selectedDiffLine = null;
481 }
482
483 /**
484 * Submit a comment on a diff line
485 */
486 private submitDiffComment(): void {
487 const commentInput = this.shadowRoot?.getElementById(
488 "diffCommentInput"
489 ) as HTMLTextAreaElement;
490
491 if (!commentInput) return;
492
493 const comment = commentInput.value.trim();
494
495 // Validate inputs
496 if (!this.selectedDiffLine || !comment) {
497 alert("Please select a line and enter a comment.");
498 return;
499 }
500
501 // Format the comment in a readable way
502 const formattedComment = `\`\`\`\n${this.selectedDiffLine}\n\`\`\`\n\n${comment}`;
503
504 // Dispatch a custom event with the formatted comment
505 const event = new CustomEvent('diff-comment', {
506 detail: { comment: formattedComment },
507 bubbles: true,
508 composed: true
509 });
510 this.dispatchEvent(event);
511
512 // Close only the comment box but keep the diff view open
513 this.closeDiffCommentBox();
514 }
515
516 // Clear the current state
517 public clearState(): void {
518 this.commitHash = "";
519 }
520
521 // Show diff for a specific commit
522 public showCommitDiff(commitHash: string): void {
523 // Store the commit hash
524 this.commitHash = commitHash;
525 // Load the diff content
526 this.loadDiffContent();
527 }
528
529 render() {
530 return html`
531 <div class="diff-view">
532 <div class="diff-container">
533 <div id="diff-view-controls">
534 <div class="diff-view-format">
535 <label>
536 <input
537 type="radio"
538 name="diffViewFormat"
539 value="side-by-side"
540 ?checked=${this.viewFormat === "side-by-side"}
541 @change=${this.handleViewFormatChange}
542 > Side-by-side
543 </label>
544 <label>
545 <input
546 type="radio"
547 name="diffViewFormat"
548 value="line-by-line"
549 ?checked=${this.viewFormat === "line-by-line"}
550 @change=${this.handleViewFormatChange}
551 > Line-by-line
552 </label>
553 </div>
554 </div>
555 <div id="diff2htmlContent" class="diff2html-content"></div>
556 </div>
557 </div>
558 `;
559 }
560}
561
562declare global {
563 interface HTMLElementTagNameMap {
564 "sketch-diff-view": SketchDiffView;
565 }
566}