Initial commit
diff --git a/loop/webui/src/timeline/diffviewer.ts b/loop/webui/src/timeline/diffviewer.ts
new file mode 100644
index 0000000..1460dc3
--- /dev/null
+++ b/loop/webui/src/timeline/diffviewer.ts
@@ -0,0 +1,384 @@
+import * as Diff2Html from "diff2html";
+
+/**
+ * Class to handle diff and commit viewing functionality in the timeline UI.
+ */
+export class DiffViewer {
+ // Current commit hash being viewed
+ private currentCommitHash: string = "";
+ // Selected line in the diff for commenting
+ private selectedDiffLine: string | null = null;
+ // Current view mode (needed for integration with TimelineManager)
+ private viewMode: string = "chat";
+
+ /**
+ * Constructor for DiffViewer
+ */
+ constructor() {}
+
+ /**
+ * Sets the current view mode
+ * @param mode The current view mode
+ */
+ public setViewMode(mode: string): void {
+ this.viewMode = mode;
+ }
+
+ /**
+ * Gets the current commit hash
+ * @returns The current commit hash
+ */
+ public getCurrentCommitHash(): string {
+ return this.currentCommitHash;
+ }
+
+ /**
+ * Sets the current commit hash
+ * @param hash The commit hash to set
+ */
+ public setCurrentCommitHash(hash: string): void {
+ this.currentCommitHash = hash;
+ }
+
+ /**
+ * Clears the current commit hash
+ */
+ public clearCurrentCommitHash(): void {
+ this.currentCommitHash = "";
+ }
+
+ /**
+ * Loads diff content and renders it using diff2html
+ * @param commitHash Optional commit hash to load diff for
+ */
+ public async loadDiff2HtmlContent(commitHash?: string): Promise<void> {
+ const diff2htmlContent = document.getElementById("diff2htmlContent");
+ const container = document.querySelector(".timeline-container");
+ if (!diff2htmlContent || !container) return;
+
+ try {
+ // Show loading state
+ diff2htmlContent.innerHTML = "Loading enhanced diff...";
+
+ // Add classes to container to allow full-width rendering
+ container.classList.add("diff2-active");
+ container.classList.add("diff-active");
+
+ // Use currentCommitHash if provided or passed from parameter
+ const hash = commitHash || this.currentCommitHash;
+
+ // Build the diff URL - include commit hash if specified
+ const diffUrl = hash ? `diff?commit=${hash}` : "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;
+ }
+
+ // Get the selected view format
+ const formatRadios = document.getElementsByName("diffViewFormat") as NodeListOf<HTMLInputElement>;
+ let outputFormat = "side-by-side"; // default
+
+ // Convert NodeListOf to Array to ensure [Symbol.iterator]() is available
+ Array.from(formatRadios).forEach(radio => {
+ if (radio.checked) {
+ outputFormat = radio.value as "side-by-side" | "line-by-line";
+ }
+ })
+
+ // Render the diff using diff2html
+ const diffHtml = Diff2Html.html(diffText, {
+ outputFormat: outputFormat as "side-by-side" | "line-by-line",
+ drawFileList: true,
+ matching: "lines",
+ // Make sure no unnecessary scrollbars in the nested containers
+ 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.setupDiff2LineComments();
+
+ // Setup event listeners for diff view format radio buttons
+ this.setupDiffViewFormatListeners();
+ } 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>`;
+ }
+ }
+
+ /**
+ * Setup event listeners for diff view format radio buttons
+ */
+ private setupDiffViewFormatListeners(): void {
+ const formatRadios = document.getElementsByName("diffViewFormat") as NodeListOf<HTMLInputElement>;
+
+ // Convert NodeListOf to Array to ensure [Symbol.iterator]() is available
+ Array.from(formatRadios).forEach(radio => {
+ radio.addEventListener("change", () => {
+ // Reload the diff with the new format when radio selection changes
+ this.loadDiff2HtmlContent(this.currentCommitHash);
+ });
+ })
+ }
+
+ /**
+ * Setup handlers for diff2 code lines to enable commenting
+ */
+ private setupDiff2LineComments(): void {
+ const diff2htmlContent = document.getElementById("diff2htmlContent");
+ if (!diff2htmlContent) return;
+
+ console.log("Setting up diff2 line comments");
+
+ // 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
+ this.openDiffCommentBox(formattedLine, 0);
+
+ // Prevent event from bubbling up
+ event.stopPropagation();
+ }
+ });
+
+ // Handle text selection
+ let isSelecting = false;
+
+ diff2htmlContent.addEventListener("mousedown", () => {
+ isSelecting = false;
+ });
+
+ diff2htmlContent.addEventListener("mousemove", (event) => {
+ // If mouse is moving with button pressed, user is selecting text
+ if (event.buttons === 1) { // Primary button (usually left) is pressed
+ isSelecting = true;
+ }
+ });
+ }
+
+ /**
+ * Add plus buttons to each table row in the diff for commenting
+ */
+ private addCommentButtonsToCodeLines(): void {
+ const diff2htmlContent = document.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, _lineNumber: number): void {
+ const commentBox = document.getElementById("diffCommentBox");
+ const selectedLine = document.getElementById("selectedLine");
+ const commentInput = document.getElementById(
+ "diffCommentInput",
+ ) as HTMLTextAreaElement;
+
+ if (!commentBox || !selectedLine || !commentInput) return;
+
+ // Store the selected line
+ this.selectedDiffLine = lineText;
+
+ // Display the line in the comment box
+ selectedLine.textContent = lineText;
+
+ // Reset the comment input
+ commentInput.value = "";
+
+ // Show the comment box
+ commentBox.style.display = "block";
+
+ // Focus on the comment input
+ commentInput.focus();
+
+ // Add event listeners for submit and cancel buttons
+ const submitButton = document.getElementById("submitDiffComment");
+ if (submitButton) {
+ submitButton.onclick = () => this.submitDiffComment();
+ }
+
+ const cancelButton = document.getElementById("cancelDiffComment");
+ if (cancelButton) {
+ cancelButton.onclick = () => this.closeDiffCommentBox();
+ }
+ }
+
+ /**
+ * Close the diff comment box without submitting
+ */
+ private closeDiffCommentBox(): void {
+ const commentBox = document.getElementById("diffCommentBox");
+ if (commentBox) {
+ commentBox.style.display = "none";
+ }
+ this.selectedDiffLine = null;
+ }
+
+ /**
+ * Submit a comment on a diff line
+ */
+ private submitDiffComment(): void {
+ const commentInput = document.getElementById(
+ "diffCommentInput",
+ ) as HTMLTextAreaElement;
+ const chatInput = document.getElementById(
+ "chatInput",
+ ) as HTMLTextAreaElement;
+
+ if (!commentInput || !chatInput) 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}`;
+
+ // Append the formatted comment to the chat textarea
+ if (chatInput.value.trim() !== "") {
+ chatInput.value += "\n\n"; // Add two line breaks before the new comment
+ }
+ chatInput.value += formattedComment;
+ chatInput.focus();
+
+ // Close only the comment box but keep the diff view open
+ this.closeDiffCommentBox();
+ }
+
+ /**
+ * Show diff for a specific commit
+ * @param commitHash The commit hash to show diff for
+ * @param toggleViewModeCallback Callback to toggle view mode to diff
+ */
+ public showCommitDiff(commitHash: string, toggleViewModeCallback: (mode: string) => void): void {
+ // Store the commit hash
+ this.currentCommitHash = commitHash;
+
+ // Switch to diff2 view (side-by-side)
+ toggleViewModeCallback("diff2");
+ }
+
+ /**
+ * Clean up resources when component is destroyed
+ */
+ public dispose(): void {
+ // Clean up any resources or event listeners here
+ // Currently there are no specific resources to clean up
+ }
+}