| import { css, html, LitElement, unsafeCSS } from "lit"; |
| import { customElement, property, state } from "lit/decorators.js"; |
| import * as Diff2Html from "diff2html"; |
| |
| @customElement("sketch-diff-view") |
| export class SketchDiffView extends LitElement { |
| // Current commit hash being viewed |
| @property({ type: String }) |
| commitHash: string = ""; |
| |
| // Selected line in the diff for commenting |
| @state() |
| private selectedDiffLine: string | null = null; |
| |
| // The clicked button element used for positioning the comment box |
| @state() |
| private clickedElement: HTMLElement | null = null; |
| |
| // View format (side-by-side or line-by-line) |
| @state() |
| private viewFormat: "side-by-side" | "line-by-line" = "side-by-side"; |
| |
| static styles = css` |
| .diff-view { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| height: 100%; |
| } |
| |
| .diff-container { |
| height: 100%; |
| overflow: auto; |
| flex: 1; |
| padding: 0 1rem; |
| } |
| |
| #diff-view-controls { |
| display: flex; |
| justify-content: flex-end; |
| padding: 10px; |
| background: #f8f8f8; |
| border-bottom: 1px solid #eee; |
| } |
| |
| .diff-view-format { |
| display: flex; |
| gap: 10px; |
| } |
| |
| .diff-view-format label { |
| cursor: pointer; |
| } |
| |
| .diff2html-content { |
| font-family: var(--monospace-font); |
| position: relative; |
| } |
| |
| /* Comment box styles */ |
| .diff-comment-box { |
| position: absolute; |
| width: 400px; |
| background-color: white; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
| padding: 16px; |
| z-index: 1000; |
| margin-top: 10px; |
| } |
| |
| .diff-comment-box h3 { |
| margin-top: 0; |
| margin-bottom: 10px; |
| font-size: 16px; |
| } |
| |
| .selected-line { |
| margin-bottom: 10px; |
| font-size: 14px; |
| } |
| |
| .selected-line pre { |
| padding: 6px; |
| background: #f5f5f5; |
| border: 1px solid #eee; |
| border-radius: 3px; |
| margin: 5px 0; |
| max-height: 100px; |
| overflow: auto; |
| font-family: var(--monospace-font); |
| font-size: 13px; |
| white-space: pre-wrap; |
| } |
| |
| #diffCommentInput { |
| width: 100%; |
| height: 100px; |
| padding: 8px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| resize: vertical; |
| font-family: inherit; |
| margin-bottom: 10px; |
| } |
| |
| .diff-comment-buttons { |
| display: flex; |
| justify-content: flex-end; |
| gap: 8px; |
| } |
| |
| .diff-comment-buttons button { |
| padding: 6px 12px; |
| border-radius: 4px; |
| border: 1px solid #ddd; |
| background: white; |
| cursor: pointer; |
| } |
| |
| .diff-comment-buttons button:hover { |
| background: #f5f5f5; |
| } |
| |
| .diff-comment-buttons button#submitDiffComment { |
| background: #1a73e8; |
| color: white; |
| border-color: #1a73e8; |
| } |
| |
| .diff-comment-buttons button#submitDiffComment:hover { |
| background: #1967d2; |
| } |
| |
| /* Styles for the comment button on diff lines */ |
| .d2h-gutter-comment-button { |
| position: absolute; |
| right: 0; |
| top: 50%; |
| transform: translateY(-50%); |
| visibility: hidden; |
| background: rgba(255, 255, 255, 0.8); |
| border-radius: 50%; |
| width: 16px; |
| height: 16px; |
| line-height: 13px; |
| text-align: center; |
| font-size: 14px; |
| cursor: pointer; |
| color: #666; |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); |
| } |
| |
| tr:hover .d2h-gutter-comment-button { |
| visibility: visible; |
| } |
| |
| .d2h-gutter-comment-button:hover { |
| background: white; |
| color: #333; |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); |
| } |
| `; |
| |
| constructor() { |
| super(); |
| } |
| |
| // See https://lit.dev/docs/components/lifecycle/ |
| connectedCallback() { |
| super.connectedCallback(); |
| |
| // Load the diff2html CSS if needed |
| this.loadDiff2HtmlCSS(); |
| } |
| |
| // Load diff2html CSS into the shadow DOM |
| private async loadDiff2HtmlCSS() { |
| try { |
| // Check if diff2html styles are already loaded |
| const styleId = "diff2html-styles"; |
| if (this.shadowRoot?.getElementById(styleId)) { |
| return; // Already loaded |
| } |
| |
| // Fetch the diff2html CSS |
| const response = await fetch("static/diff2html.min.css"); |
| |
| if (!response.ok) { |
| console.error( |
| `Failed to load diff2html CSS: ${response.status} ${response.statusText}` |
| ); |
| return; |
| } |
| |
| const cssText = await response.text(); |
| |
| // Create a style element and append to shadow DOM |
| const style = document.createElement("style"); |
| style.id = styleId; |
| style.textContent = cssText; |
| this.shadowRoot?.appendChild(style); |
| |
| console.log("diff2html CSS loaded into shadow DOM"); |
| } catch (error) { |
| console.error("Error loading diff2html CSS:", error); |
| } |
| } |
| |
| // See https://lit.dev/docs/components/lifecycle/ |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| // Method called to load diff content |
| async loadDiffContent() { |
| // Wait for the component to be rendered |
| await this.updateComplete; |
| |
| const diff2htmlContent = |
| this.shadowRoot?.getElementById("diff2htmlContent"); |
| if (!diff2htmlContent) return; |
| |
| try { |
| // Build the diff URL - include commit hash if specified |
| const diffUrl = this.commitHash |
| ? `diff?commit=${this.commitHash}` |
| : "diff"; |
| |
| if (this.commitHash) { |
| diff2htmlContent.innerHTML = `Loading diff for commit <strong>${this.commitHash}</strong>...`; |
| } else { |
| diff2htmlContent.innerHTML = "Loading diff..."; |
| } |
| |
| // Fetch the diff from the server |
| const response = await fetch(diffUrl); |
| |
| if (!response.ok) { |
| throw new Error( |
| `Server returned ${response.status}: ${response.statusText}` |
| ); |
| } |
| |
| const diffText = await response.text(); |
| |
| if (!diffText || diffText.trim() === "") { |
| diff2htmlContent.innerHTML = |
| "<span style='color: #666; font-style: italic;'>No changes detected since conversation started.</span>"; |
| return; |
| } |
| |
| // Render the diff using diff2html |
| const diffHtml = Diff2Html.html(diffText, { |
| outputFormat: this.viewFormat, |
| drawFileList: true, |
| matching: "lines", |
| renderNothingWhenEmpty: false, |
| colorScheme: "light" as any, // Force light mode to match the rest of the UI |
| }); |
| |
| // Insert the generated HTML |
| diff2htmlContent.innerHTML = diffHtml; |
| |
| // Add CSS styles to ensure we don't have double scrollbars |
| const d2hFiles = diff2htmlContent.querySelectorAll(".d2h-file-wrapper"); |
| d2hFiles.forEach((file) => { |
| const contentElem = file.querySelector(".d2h-files-diff"); |
| if (contentElem) { |
| // Remove internal scrollbar - the outer container will handle scrolling |
| (contentElem as HTMLElement).style.overflow = "visible"; |
| (contentElem as HTMLElement).style.maxHeight = "none"; |
| } |
| }); |
| |
| // Add click event handlers to each code line for commenting |
| this.setupDiffLineComments(); |
| } catch (error) { |
| console.error("Error loading diff2html content:", error); |
| const errorMessage = |
| error instanceof Error ? error.message : "Unknown error"; |
| diff2htmlContent.innerHTML = `<span style='color: #dc3545;'>Error loading enhanced diff: ${errorMessage}</span>`; |
| } |
| } |
| |
| // Handle view format changes |
| private handleViewFormatChange(event: Event) { |
| const input = event.target as HTMLInputElement; |
| if (input.checked) { |
| this.viewFormat = input.value as "side-by-side" | "line-by-line"; |
| this.loadDiffContent(); |
| } |
| } |
| |
| /** |
| * Setup handlers for diff code lines to enable commenting |
| */ |
| private setupDiffLineComments(): void { |
| const diff2htmlContent = |
| this.shadowRoot?.getElementById("diff2htmlContent"); |
| if (!diff2htmlContent) return; |
| |
| // Add plus buttons to each code line |
| this.addCommentButtonsToCodeLines(); |
| |
| // Use event delegation for handling clicks on plus buttons |
| diff2htmlContent.addEventListener("click", (event) => { |
| const target = event.target as HTMLElement; |
| |
| // Only respond to clicks on the plus button |
| if (target.classList.contains("d2h-gutter-comment-button")) { |
| // Find the parent row first |
| const row = target.closest("tr"); |
| if (!row) return; |
| |
| // Then find the code line in that row |
| const codeLine = |
| row.querySelector(".d2h-code-side-line") || |
| row.querySelector(".d2h-code-line"); |
| if (!codeLine) return; |
| |
| // Get the line text content |
| const lineContent = codeLine.querySelector(".d2h-code-line-ctn"); |
| if (!lineContent) return; |
| |
| const lineText = lineContent.textContent?.trim() || ""; |
| |
| // Get file name to add context |
| const fileHeader = codeLine |
| .closest(".d2h-file-wrapper") |
| ?.querySelector(".d2h-file-name"); |
| const fileName = fileHeader |
| ? fileHeader.textContent?.trim() |
| : "Unknown file"; |
| |
| // Get line number if available |
| const lineNumElem = codeLine |
| .closest("tr") |
| ?.querySelector(".d2h-code-side-linenumber"); |
| const lineNum = lineNumElem ? lineNumElem.textContent?.trim() : ""; |
| const lineInfo = lineNum ? `Line ${lineNum}: ` : ""; |
| |
| // Format the line for the comment box with file context and line number |
| const formattedLine = `${fileName} ${lineInfo}${lineText}`; |
| |
| console.log("Comment button clicked for line: ", formattedLine); |
| |
| // Open the comment box with this line and store the clicked element for positioning |
| this.clickedElement = target; |
| this.openDiffCommentBox(formattedLine); |
| |
| // Prevent event from bubbling up |
| event.stopPropagation(); |
| } |
| }); |
| } |
| |
| /** |
| * Add plus buttons to each table row in the diff for commenting |
| */ |
| private addCommentButtonsToCodeLines(): void { |
| const diff2htmlContent = |
| this.shadowRoot?.getElementById("diff2htmlContent"); |
| if (!diff2htmlContent) return; |
| |
| // Target code lines first, then find their parent rows |
| const codeLines = diff2htmlContent.querySelectorAll( |
| ".d2h-code-side-line, .d2h-code-line" |
| ); |
| |
| // Create a Set to store unique rows to avoid duplicates |
| const rowsSet = new Set<HTMLElement>(); |
| |
| // Get all rows that contain code lines |
| codeLines.forEach((line) => { |
| const row = line.closest("tr"); |
| if (row) rowsSet.add(row as HTMLElement); |
| }); |
| |
| // Convert Set back to array for processing |
| const codeRows = Array.from(rowsSet); |
| |
| codeRows.forEach((row) => { |
| const rowElem = row as HTMLElement; |
| |
| // Skip info lines without actual code (e.g., "file added") |
| if (rowElem.querySelector(".d2h-info")) { |
| return; |
| } |
| |
| // Find the code line number element (first TD in the row) |
| const lineNumberCell = rowElem.querySelector( |
| ".d2h-code-side-linenumber, .d2h-code-linenumber" |
| ); |
| |
| if (!lineNumberCell) return; |
| |
| // Create the plus button |
| const plusButton = document.createElement("span"); |
| plusButton.className = "d2h-gutter-comment-button"; |
| plusButton.innerHTML = "+"; |
| plusButton.title = "Add a comment on this line"; |
| |
| // Add button to the line number cell for proper positioning |
| (lineNumberCell as HTMLElement).style.position = "relative"; // Ensure positioning context |
| lineNumberCell.appendChild(plusButton); |
| }); |
| } |
| |
| /** |
| * Open the comment box for a selected diff line |
| */ |
| private openDiffCommentBox(lineText: string): void { |
| // Make sure the comment box div exists |
| const commentBoxId = "diffCommentBox"; |
| let commentBox = this.shadowRoot?.getElementById(commentBoxId); |
| |
| // If it doesn't exist, create it |
| if (!commentBox) { |
| commentBox = document.createElement("div"); |
| commentBox.id = commentBoxId; |
| commentBox.className = "diff-comment-box"; |
| |
| // Create the comment box contents |
| commentBox.innerHTML = ` |
| <h3>Add a comment</h3> |
| <div class="selected-line"> |
| Line: |
| <pre id="selectedLine"></pre> |
| </div> |
| <textarea |
| id="diffCommentInput" |
| placeholder="Enter your comment about this line..." |
| ></textarea> |
| <div class="diff-comment-buttons"> |
| <button id="cancelDiffComment">Cancel</button> |
| <button id="submitDiffComment">Add Comment</button> |
| </div> |
| `; |
| |
| // Append the comment box to the diff container to ensure proper positioning |
| const diffContainer = this.shadowRoot?.querySelector(".diff-container"); |
| if (diffContainer) { |
| diffContainer.appendChild(commentBox); |
| } else { |
| this.shadowRoot?.appendChild(commentBox); |
| } |
| } |
| |
| // Store the selected line |
| this.selectedDiffLine = lineText; |
| |
| // Display the line in the comment box |
| const selectedLine = this.shadowRoot?.getElementById("selectedLine"); |
| if (selectedLine) { |
| selectedLine.textContent = lineText; |
| } |
| |
| // Reset the comment input |
| const commentInput = this.shadowRoot?.getElementById( |
| "diffCommentInput" |
| ) as HTMLTextAreaElement; |
| if (commentInput) { |
| commentInput.value = ""; |
| } |
| |
| // Show the comment box and position it below the clicked line |
| if (commentBox && this.clickedElement) { |
| // Get the row that contains the clicked button |
| const row = this.clickedElement.closest("tr"); |
| if (row) { |
| // Get the position of the row |
| const rowRect = row.getBoundingClientRect(); |
| const diffContainerRect = this.shadowRoot?.querySelector(".diff-container")?.getBoundingClientRect(); |
| |
| if (diffContainerRect) { |
| // Position the comment box below the row |
| const topPosition = rowRect.bottom - diffContainerRect.top + this.shadowRoot!.querySelector(".diff-container")!.scrollTop; |
| const leftPosition = rowRect.left - diffContainerRect.left; |
| |
| commentBox.style.top = `${topPosition}px`; |
| commentBox.style.left = `${leftPosition}px`; |
| commentBox.style.display = "block"; |
| } |
| } else { |
| // Fallback if we can't find the row |
| commentBox.style.display = "block"; |
| } |
| } else if (commentBox) { |
| // Fallback if we don't have clickedElement |
| commentBox.style.display = "block"; |
| } |
| |
| // Add event listeners for submit and cancel buttons |
| const submitButton = this.shadowRoot?.getElementById("submitDiffComment"); |
| if (submitButton) { |
| submitButton.onclick = () => this.submitDiffComment(); |
| } |
| |
| const cancelButton = this.shadowRoot?.getElementById("cancelDiffComment"); |
| if (cancelButton) { |
| cancelButton.onclick = () => this.closeDiffCommentBox(); |
| } |
| |
| // Focus on the comment input |
| if (commentInput) { |
| commentInput.focus(); |
| } |
| } |
| |
| /** |
| * Close the diff comment box without submitting |
| */ |
| private closeDiffCommentBox(): void { |
| const commentBox = this.shadowRoot?.getElementById("diffCommentBox"); |
| if (commentBox) { |
| commentBox.style.display = "none"; |
| } |
| this.selectedDiffLine = null; |
| this.clickedElement = null; |
| } |
| |
| /** |
| * Submit a comment on a diff line |
| */ |
| private submitDiffComment(): void { |
| const commentInput = this.shadowRoot?.getElementById( |
| "diffCommentInput" |
| ) as HTMLTextAreaElement; |
| |
| if (!commentInput) return; |
| |
| const comment = commentInput.value.trim(); |
| |
| // Validate inputs |
| if (!this.selectedDiffLine || !comment) { |
| alert("Please select a line and enter a comment."); |
| return; |
| } |
| |
| // Format the comment in a readable way |
| const formattedComment = `\`\`\`\n${this.selectedDiffLine}\n\`\`\`\n\n${comment}`; |
| |
| // Dispatch a custom event with the formatted comment |
| const event = new CustomEvent("diff-comment", { |
| detail: { comment: formattedComment }, |
| bubbles: true, |
| composed: true, |
| }); |
| this.dispatchEvent(event); |
| |
| // Close only the comment box but keep the diff view open |
| this.closeDiffCommentBox(); |
| } |
| |
| // Clear the current state |
| public clearState(): void { |
| this.commitHash = ""; |
| } |
| |
| // Show diff for a specific commit |
| public showCommitDiff(commitHash: string): void { |
| // Store the commit hash |
| this.commitHash = commitHash; |
| // Load the diff content |
| this.loadDiffContent(); |
| } |
| |
| render() { |
| return html` |
| <div class="diff-view"> |
| <div class="diff-container"> |
| <div id="diff-view-controls"> |
| <div class="diff-view-format"> |
| <label> |
| <input |
| type="radio" |
| name="diffViewFormat" |
| value="side-by-side" |
| ?checked=${this.viewFormat === "side-by-side"} |
| @change=${this.handleViewFormatChange} |
| /> |
| Side-by-side |
| </label> |
| <label> |
| <input |
| type="radio" |
| name="diffViewFormat" |
| value="line-by-line" |
| ?checked=${this.viewFormat === "line-by-line"} |
| @change=${this.handleViewFormatChange} |
| /> |
| Line-by-line |
| </label> |
| </div> |
| </div> |
| <div id="diff2htmlContent" class="diff2html-content"></div> |
| </div> |
| </div> |
| `; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| "sketch-diff-view": SketchDiffView; |
| } |
| } |