blob: d08b520b9cc365652dec71b3aa4f40444810b8a5 [file] [log] [blame]
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";
}
});
// Intercept clicks on anchor links within the diff to prevent browser navigation
this.interceptAnchorClicks(diff2htmlContent);
// 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();
}
// Add keydown event listener to handle Escape key
if (commentInput) {
commentInput.addEventListener("keydown", (event) => {
// If Escape key is pressed
if (event.key === "Escape") {
// If the comment input is empty, dismiss the comment box
if (commentInput.value.trim() === "") {
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();
}
/**
* Intercept clicks on anchor links within the diff to prevent default browser navigation
* and instead scroll to the target element without changing URL
*
* @param container The container element containing diff content
*/
private interceptAnchorClicks(container: HTMLElement): void {
const anchors = container.querySelectorAll('a[href^="#"]');
anchors.forEach((anchor) => {
anchor.addEventListener("click", (event) => {
event.preventDefault();
// Extract the target ID from the href
const href = (anchor as HTMLAnchorElement).getAttribute("href");
if (!href || !href.startsWith("#")) return;
const targetId = href.substring(1);
const targetElement = container.querySelector(`[id="${targetId}"]`);
if (targetElement) {
// Scroll the target element into view
targetElement.scrollIntoView({ behavior: "smooth" });
}
});
});
}
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;
}
}