| /* eslint-disable @typescript-eslint/no-explicit-any */ |
| import { html } from "lit"; |
| import { customElement, property, state } from "lit/decorators.js"; |
| import { SketchTailwindElement } from "./sketch-tailwind-element.js"; |
| import "./sketch-monaco-view"; |
| import "./sketch-diff-range-picker"; |
| import "./sketch-diff-empty-view"; |
| import { GitDiffFile, GitDataService } from "./git-data-service"; |
| import { DiffRange } from "./sketch-diff-range-picker"; |
| |
| /** |
| * A component that displays diffs using Monaco editor with range and file pickers |
| */ |
| @customElement("sketch-diff2-view") |
| export class SketchDiff2View extends SketchTailwindElement { |
| /** |
| * Handles comment events from the Monaco editor and forwards them to the chat input |
| * using the same event format as the original diff view for consistency. |
| */ |
| private handleMonacoComment(event: CustomEvent) { |
| try { |
| // Validate incoming data |
| if (!event.detail || !event.detail.formattedComment) { |
| console.error("Invalid comment data received"); |
| return; |
| } |
| |
| // Create and dispatch event using the standardized format |
| const commentEvent = new CustomEvent("diff-comment", { |
| detail: { comment: event.detail.formattedComment }, |
| bubbles: true, |
| composed: true, |
| }); |
| |
| this.dispatchEvent(commentEvent); |
| } catch (error) { |
| console.error("Error handling Monaco comment:", error); |
| } |
| } |
| |
| /** |
| * Handle height change events from the Monaco editor |
| */ |
| private handleMonacoHeightChange(event: CustomEvent) { |
| try { |
| // Get the monaco view that emitted the event |
| const monacoView = event.target as HTMLElement; |
| if (!monacoView) return; |
| |
| // Find the parent Monaco editor container (now using Tailwind classes) |
| const fileDiffEditor = monacoView.closest( |
| ".flex.flex-col.w-full.min-h-\\[200px\\].flex-1", |
| ) as HTMLElement; |
| if (!fileDiffEditor) return; |
| |
| // Get the new height from the event |
| const newHeight = event.detail.height; |
| |
| // Only update if the height actually changed to avoid unnecessary layout |
| const currentHeight = fileDiffEditor.style.height; |
| const newHeightStr = `${newHeight}px`; |
| |
| if (currentHeight !== newHeightStr) { |
| // Update the container height to match monaco's height |
| fileDiffEditor.style.height = newHeightStr; |
| |
| // Remove any previous min-height constraint that might interfere |
| fileDiffEditor.style.minHeight = "auto"; |
| |
| // IMPORTANT: Tell Monaco to relayout after its container size changed |
| // Monaco has automaticLayout: false, so it won't detect container changes |
| setTimeout(() => { |
| const monacoComponent = monacoView as any; |
| if (monacoComponent && monacoComponent.editor) { |
| // Force layout with explicit dimensions to ensure Monaco fills the space |
| const editorWidth = fileDiffEditor.offsetWidth; |
| monacoComponent.editor.layout({ |
| width: editorWidth, |
| height: newHeight, |
| }); |
| } |
| }, 0); |
| } |
| } catch (error) { |
| console.error("Error handling Monaco height change:", error); |
| } |
| } |
| |
| /** |
| * Handle save events from the Monaco editor |
| */ |
| private async handleMonacoSave(event: CustomEvent) { |
| try { |
| // Validate incoming data |
| if ( |
| !event.detail || |
| !event.detail.path || |
| event.detail.content === undefined |
| ) { |
| console.error("Invalid save data received"); |
| return; |
| } |
| |
| const { path, content } = event.detail; |
| |
| // Get Monaco view component |
| const monacoView = this.querySelector("sketch-monaco-view"); |
| if (!monacoView) { |
| console.error("Monaco view not found"); |
| return; |
| } |
| |
| try { |
| await this.gitService?.saveFileContent(path, content); |
| console.log(`File saved: ${path}`); |
| (monacoView as any).notifySaveComplete(true); |
| } catch (error) { |
| console.error( |
| `Error saving file: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| (monacoView as any).notifySaveComplete(false); |
| } |
| } catch (error) { |
| console.error("Error handling save:", error); |
| } |
| } |
| @property({ type: String }) |
| initialCommit: string = ""; |
| |
| // The commit to show - used when showing a specific commit from timeline |
| @property({ type: String }) |
| commit: string = ""; |
| |
| @property({ type: String }) |
| selectedFilePath: string = ""; |
| |
| @state() |
| private files: GitDiffFile[] = []; |
| |
| @state() |
| private currentRange: DiffRange = { type: "range", from: "", to: "HEAD" }; |
| |
| @state() |
| private fileContents: Map< |
| string, |
| { original: string; modified: string; editable: boolean } |
| > = new Map(); |
| |
| @state() |
| private fileExpandStates: Map<string, boolean> = new Map(); |
| |
| @state() |
| private loading: boolean = false; |
| |
| @state() |
| private error: string | null = null; |
| |
| @state() |
| private selectedFile: string = ""; // Empty string means "All files" |
| |
| @state() |
| private viewMode: "all" | "single" = "all"; |
| |
| @state() |
| private untrackedFiles: string[] = []; |
| |
| @state() |
| private showUntrackedPopup: boolean = false; |
| |
| @property({ attribute: false, type: Object }) |
| gitService!: GitDataService; |
| |
| // The gitService must be passed from parent to ensure proper dependency injection |
| |
| constructor() { |
| super(); |
| console.log("SketchDiff2View initialized"); |
| |
| // Fix for monaco-aria-container positioning and hide scrollbars globally |
| // Add a global style to ensure proper positioning of aria containers and hide scrollbars |
| const styleElement = document.createElement("style"); |
| styleElement.textContent = ` |
| .monaco-aria-container { |
| position: absolute !important; |
| top: 0 !important; |
| left: 0 !important; |
| width: 1px !important; |
| height: 1px !important; |
| overflow: hidden !important; |
| clip: rect(1px, 1px, 1px, 1px) !important; |
| white-space: nowrap !important; |
| margin: 0 !important; |
| padding: 0 !important; |
| border: 0 !important; |
| z-index: -1 !important; |
| } |
| |
| /* Aggressively hide all Monaco scrollbar elements */ |
| .monaco-editor .scrollbar, |
| .monaco-editor .scroll-decoration, |
| .monaco-editor .invisible.scrollbar, |
| .monaco-editor .slider, |
| .monaco-editor .vertical.scrollbar, |
| .monaco-editor .horizontal.scrollbar, |
| .monaco-diff-editor .scrollbar, |
| .monaco-diff-editor .scroll-decoration, |
| .monaco-diff-editor .invisible.scrollbar, |
| .monaco-diff-editor .slider, |
| .monaco-diff-editor .vertical.scrollbar, |
| .monaco-diff-editor .horizontal.scrollbar { |
| display: none !important; |
| visibility: hidden !important; |
| width: 0 !important; |
| height: 0 !important; |
| opacity: 0 !important; |
| } |
| |
| /* Target the specific scrollbar classes that Monaco uses */ |
| .monaco-scrollable-element > .scrollbar, |
| .monaco-scrollable-element > .scroll-decoration, |
| .monaco-scrollable-element .slider { |
| display: none !important; |
| visibility: hidden !important; |
| width: 0 !important; |
| height: 0 !important; |
| } |
| |
| /* Remove scrollbar space/padding from content area */ |
| .monaco-editor .monaco-scrollable-element, |
| .monaco-diff-editor .monaco-scrollable-element { |
| padding-right: 0 !important; |
| padding-bottom: 0 !important; |
| margin-right: 0 !important; |
| margin-bottom: 0 !important; |
| } |
| |
| /* Ensure the diff content takes full width without scrollbar space */ |
| .monaco-diff-editor .editor.modified, |
| .monaco-diff-editor .editor.original { |
| margin-right: 0 !important; |
| padding-right: 0 !important; |
| } |
| `; |
| document.head.appendChild(styleElement); |
| } |
| |
| // Override createRenderRoot to apply host styles for proper sizing while still using light DOM |
| createRenderRoot() { |
| // Use light DOM like SketchTailwindElement but still apply host styles |
| const style = document.createElement("style"); |
| style.textContent = ` |
| sketch-diff2-view { |
| height: -webkit-fill-available; |
| } |
| `; |
| |
| // Add the style to the document head if not already present |
| if (!document.head.querySelector("style[data-sketch-diff2-view]")) { |
| style.setAttribute("data-sketch-diff2-view", ""); |
| document.head.appendChild(style); |
| } |
| |
| return this; |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| // Initialize with default range and load data |
| // Get base commit if not set |
| if ( |
| this.currentRange.type === "range" && |
| !("from" in this.currentRange && this.currentRange.from) |
| ) { |
| this.gitService |
| .getBaseCommitRef() |
| .then((baseRef) => { |
| this.currentRange = { type: "range", from: baseRef, to: "HEAD" }; |
| this.loadDiffData(); |
| }) |
| .catch((error) => { |
| console.error("Error getting base commit ref:", error); |
| // Use default range |
| this.loadDiffData(); |
| }); |
| } else { |
| this.loadDiffData(); |
| } |
| |
| // Add click listener to close popup when clicking outside |
| document.addEventListener("click", this.handleDocumentClick.bind(this)); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| document.removeEventListener("click", this.handleDocumentClick.bind(this)); |
| } |
| |
| handleDocumentClick(event: Event) { |
| if (this.showUntrackedPopup) { |
| const target = event.target as HTMLElement; |
| // Check if click is outside the popup and button |
| if (!target.closest(".relative")) { |
| this.showUntrackedPopup = false; |
| } |
| } |
| } |
| |
| // Toggle hideUnchangedRegions setting for a specific file |
| private toggleFileExpansion(filePath: string) { |
| const currentState = this.fileExpandStates.get(filePath) ?? false; |
| const newState = !currentState; |
| this.fileExpandStates.set(filePath, newState); |
| |
| // Apply to the specific Monaco view component for this file |
| const monacoView = this.querySelector( |
| `sketch-monaco-view[data-file-path="${filePath}"]`, |
| ); |
| if (monacoView) { |
| (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged" |
| } |
| |
| // Force a re-render to update the button state |
| this.requestUpdate(); |
| } |
| |
| render() { |
| return html` |
| <div class="px-4 py-2 border-b border-gray-300 bg-gray-100 flex-shrink-0"> |
| <div class="flex flex-col gap-3"> |
| <div class="w-full flex items-center gap-3"> |
| <sketch-diff-range-picker |
| class="flex-1 min-w-[400px]" |
| .gitService="${this.gitService}" |
| @range-change="${this.handleRangeChange}" |
| ></sketch-diff-range-picker> |
| ${this.renderUntrackedFilesNotification()} |
| <div class="flex-1"></div> |
| ${this.renderFileSelector()} |
| </div> |
| </div> |
| </div> |
| |
| <div class="flex-1 overflow-auto flex flex-col min-h-0 relative h-full"> |
| ${this.renderDiffContent()} |
| </div> |
| `; |
| } |
| |
| renderFileSelector() { |
| const fileCount = this.files.length; |
| |
| return html` |
| <div class="flex items-center gap-2"> |
| <select |
| class="min-w-[200px] px-3 py-2 border border-gray-400 rounded bg-white text-sm cursor-pointer focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-200 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed" |
| .value="${this.selectedFile}" |
| @change="${this.handleFileSelection}" |
| ?disabled="${fileCount === 0}" |
| > |
| <option value="">All files (${fileCount})</option> |
| ${this.files.map( |
| (file) => html` |
| <option value="${file.path}"> |
| ${this.getFileDisplayName(file)} |
| </option> |
| `, |
| )} |
| </select> |
| ${this.selectedFile ? this.renderSingleFileExpandButton() : ""} |
| </div> |
| `; |
| } |
| |
| renderUntrackedFilesNotification() { |
| if (!this.untrackedFiles || this.untrackedFiles.length === 0) { |
| return ""; |
| } |
| |
| const fileCount = this.untrackedFiles.length; |
| const fileCountText = |
| fileCount === 1 ? "1 untracked file" : `${fileCount} untracked files`; |
| |
| return html` |
| <div class="relative"> |
| <button |
| class="flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-100 text-gray-700 border border-gray-300 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" |
| @click="${this.toggleUntrackedFilesPopup}" |
| type="button" |
| > |
| ${fileCount} untracked |
| <svg |
| class="w-4 h-4" |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| stroke-width="2" |
| d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" |
| /> |
| </svg> |
| </button> |
| |
| ${this.showUntrackedPopup |
| ? html` |
| <div |
| class="absolute top-full left-0 mt-2 w-80 bg-white border border-gray-300 rounded-lg shadow-lg z-50" |
| > |
| <div class="p-4"> |
| <div class="flex items-start gap-3 mb-3"> |
| <svg |
| class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| stroke-width="2" |
| d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" |
| /> |
| </svg> |
| <div class="flex-1"> |
| <div class="font-medium text-gray-900 mb-1"> |
| ${fileCountText} |
| </div> |
| <div class="text-sm text-gray-600 mb-3"> |
| These files are not tracked by git. They will be lost if the session ends now. The agent typically does not add files to git until it is ready for feedback. |
| </div> |
| </div> |
| </div> |
| |
| <div class="max-h-32 overflow-y-auto"> |
| <div class="text-sm text-gray-700"> |
| ${this.untrackedFiles.map( |
| (file) => html` |
| <div |
| class="py-1 px-2 hover:bg-gray-100 rounded font-mono text-xs" |
| > |
| ${file} |
| </div> |
| `, |
| )} |
| </div> |
| </div> |
| </div> |
| </div> |
| ` |
| : ""} |
| </div> |
| `; |
| } |
| |
| renderDiffContent() { |
| if (this.loading) { |
| return html`<div class="flex items-center justify-center h-full"> |
| Loading diff... |
| </div>`; |
| } |
| |
| if (this.error) { |
| return html`<div class="text-red-600 p-4">${this.error}</div>`; |
| } |
| |
| if (this.files.length === 0) { |
| return html`<sketch-diff-empty-view></sketch-diff-empty-view>`; |
| } |
| |
| // Render single file view if a specific file is selected |
| if (this.selectedFile && this.viewMode === "single") { |
| return this.renderSingleFileView(); |
| } |
| |
| // Render multi-file view |
| return html` |
| <div class="flex flex-col w-full min-h-full"> |
| ${this.files.map((file, index) => this.renderFileDiff(file, index))} |
| </div> |
| `; |
| } |
| |
| /** |
| * Load diff data for the current range |
| */ |
| async loadDiffData() { |
| this.loading = true; |
| this.error = null; |
| |
| try { |
| // Initialize files as empty array if undefined |
| if (!this.files) { |
| this.files = []; |
| } |
| |
| // Load diff data for the range |
| this.files = await this.gitService.getDiff( |
| this.currentRange.from, |
| this.currentRange.to, |
| ); |
| |
| // Ensure files is always an array, even when API returns null |
| if (!this.files) { |
| this.files = []; |
| } |
| |
| // Load untracked files for notification |
| try { |
| this.untrackedFiles = await this.gitService.getUntrackedFiles(); |
| } catch (error) { |
| console.error("Error loading untracked files:", error); |
| this.untrackedFiles = []; |
| } |
| |
| // Load content for all files |
| if (this.files.length > 0) { |
| // Initialize expand states for new files (default to collapsed) |
| this.files.forEach((file) => { |
| if (!this.fileExpandStates.has(file.path)) { |
| this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions) |
| } |
| }); |
| await this.loadAllFileContents(); |
| } else { |
| // No files to display - reset the view to initial state |
| this.selectedFilePath = ""; |
| this.selectedFile = ""; |
| this.viewMode = "all"; |
| this.fileContents.clear(); |
| this.fileExpandStates.clear(); |
| } |
| } catch (error) { |
| console.error("Error loading diff data:", error); |
| this.error = `Error loading diff data: ${error.message}`; |
| // Ensure files is an empty array when an error occurs |
| this.files = []; |
| // Reset the view to initial state |
| this.selectedFilePath = ""; |
| this.selectedFile = ""; |
| this.viewMode = "all"; |
| this.fileContents.clear(); |
| this.fileExpandStates.clear(); |
| } finally { |
| this.loading = false; |
| } |
| } |
| |
| /** |
| * Load content for all files in the diff |
| */ |
| async loadAllFileContents() { |
| this.loading = true; |
| this.error = null; |
| this.fileContents.clear(); |
| |
| try { |
| let isUnstagedChanges = false; |
| |
| // Determine the commits to compare based on the current range |
| const _fromCommit = this.currentRange.from; |
| const toCommit = this.currentRange.to; |
| // Check if this is an unstaged changes view |
| isUnstagedChanges = toCommit === ""; |
| |
| // Load content for all files |
| const promises = this.files.map(async (file) => { |
| try { |
| let originalCode = ""; |
| let modifiedCode = ""; |
| let editable = isUnstagedChanges; |
| |
| // Load the original code based on file status |
| if (file.status !== "A") { |
| // For modified, renamed, or deleted files: load original content |
| originalCode = await this.gitService.getFileContent( |
| file.old_hash || "", |
| ); |
| } |
| |
| // For modified code, always use working copy when editable |
| if (editable) { |
| try { |
| // Always use working copy when editable, regardless of diff status |
| modifiedCode = await this.gitService.getWorkingCopyContent( |
| file.path, |
| ); |
| } catch (error) { |
| if (file.status === "D") { |
| // For deleted files, silently use empty content |
| console.warn( |
| `Could not get working copy for deleted file ${file.path}, using empty content`, |
| ); |
| modifiedCode = ""; |
| } else { |
| // For any other file status, propagate the error |
| console.error( |
| `Failed to get working copy for ${file.path}:`, |
| error, |
| ); |
| throw error; |
| } |
| } |
| } else { |
| // For non-editable view, use git content based on file status |
| if (file.status === "D") { |
| // Deleted file: empty modified |
| modifiedCode = ""; |
| } else { |
| // Added/modified/renamed: use the content from git |
| modifiedCode = await this.gitService.getFileContent( |
| file.new_hash || "", |
| ); |
| } |
| } |
| |
| // Don't make deleted files editable |
| if (file.status === "D") { |
| editable = false; |
| } |
| |
| this.fileContents.set(file.path, { |
| original: originalCode, |
| modified: modifiedCode, |
| editable, |
| }); |
| } catch (error) { |
| console.error(`Error loading content for file ${file.path}:`, error); |
| // Store empty content for failed files to prevent blocking |
| this.fileContents.set(file.path, { |
| original: "", |
| modified: "", |
| editable: false, |
| }); |
| } |
| }); |
| |
| await Promise.all(promises); |
| } catch (error) { |
| console.error("Error loading file contents:", error); |
| this.error = `Error loading file contents: ${error.message}`; |
| } finally { |
| this.loading = false; |
| } |
| } |
| |
| /** |
| * Handle range change event from the range picker |
| */ |
| handleRangeChange(event: CustomEvent) { |
| const { range } = event.detail; |
| console.log("Range changed:", range); |
| this.currentRange = range; |
| |
| // Load diff data for the new range |
| this.loadDiffData(); |
| } |
| |
| /** |
| * Render a single file diff section |
| */ |
| renderFileDiff(file: GitDiffFile, index: number) { |
| const content = this.fileContents.get(file.path); |
| if (!content) { |
| return html` |
| <div |
| class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0" |
| > |
| <div |
| class="bg-gray-100 border-b border-gray-300 px-4 py-2 font-medium text-sm text-gray-800 sticky top-0 z-10 shadow-sm flex justify-between items-center" |
| > |
| ${this.renderFileHeader(file)} |
| </div> |
| <div class="flex items-center justify-center h-full"> |
| Loading ${file.path}... |
| </div> |
| </div> |
| `; |
| } |
| |
| return html` |
| <div |
| class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0" |
| > |
| <div |
| class="bg-gray-100 border-b border-gray-300 px-4 py-2 font-medium text-sm text-gray-800 sticky top-0 z-10 shadow-sm flex justify-between items-center" |
| > |
| ${this.renderFileHeader(file)} |
| </div> |
| <div class="flex flex-col w-full min-h-[200px] flex-1"> |
| <sketch-monaco-view |
| class="flex flex-col w-full min-h-[200px] flex-1" |
| .originalCode="${content.original}" |
| .modifiedCode="${content.modified}" |
| .originalFilename="${file.path}" |
| .modifiedFilename="${file.path}" |
| ?readOnly="${!content.editable}" |
| ?editable-right="${content.editable}" |
| @monaco-comment="${this.handleMonacoComment}" |
| @monaco-save="${this.handleMonacoSave}" |
| @monaco-height-changed="${this.handleMonacoHeightChange}" |
| data-file-index="${index}" |
| data-file-path="${file.path}" |
| ></sketch-monaco-view> |
| </div> |
| </div> |
| `; |
| } |
| |
| /** |
| * Render file header with status and path info |
| */ |
| renderFileHeader(file: GitDiffFile) { |
| const statusClasses = this.getFileStatusTailwindClasses(file.status); |
| const statusText = this.getFileStatusText(file.status); |
| const changesInfo = this.getChangesInfo(file); |
| const pathInfo = this.getPathInfo(file); |
| |
| const isExpanded = this.fileExpandStates.get(file.path) ?? false; |
| |
| return html` |
| <div class="flex items-center gap-2"> |
| <span |
| class="inline-block px-1.5 py-0.5 rounded text-xs font-bold mr-2 ${statusClasses}" |
| > |
| ${statusText} |
| </span> |
| <span class="font-mono font-normal text-gray-600">${pathInfo}</span> |
| ${changesInfo |
| ? html`<span class="ml-2 text-xs text-gray-600">${changesInfo}</span>` |
| : ""} |
| </div> |
| <div class="flex items-center"> |
| <button |
| class="bg-transparent border border-gray-300 rounded px-2 py-1 text-sm cursor-pointer whitespace-nowrap transition-colors duration-200 flex items-center justify-center min-w-8 min-h-8 hover:bg-gray-200" |
| @click="${() => this.toggleFileExpansion(file.path)}" |
| title="${isExpanded |
| ? "Collapse: Hide unchanged regions to focus on changes" |
| : "Expand: Show all lines including unchanged regions"}" |
| > |
| ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()} |
| </button> |
| </div> |
| `; |
| } |
| |
| /** |
| * Get Tailwind CSS classes for file status |
| */ |
| getFileStatusTailwindClasses(status: string): string { |
| switch (status.toUpperCase()) { |
| case "A": |
| return "bg-green-100 text-green-800"; |
| case "M": |
| return "bg-yellow-100 text-yellow-800"; |
| case "D": |
| return "bg-red-100 text-red-800"; |
| case "R": |
| case "C": |
| default: |
| if (status.toUpperCase().startsWith("R")) { |
| return "bg-cyan-100 text-cyan-800"; |
| } |
| if (status.toUpperCase().startsWith("C")) { |
| return "bg-indigo-100 text-indigo-800"; |
| } |
| return "bg-yellow-100 text-yellow-800"; |
| } |
| } |
| |
| /** |
| * Get display text for file status |
| */ |
| getFileStatusText(status: string): string { |
| switch (status.toUpperCase()) { |
| case "A": |
| return "Added"; |
| case "M": |
| return "Modified"; |
| case "D": |
| return "Deleted"; |
| case "R": |
| case "C": |
| default: |
| if (status.toUpperCase().startsWith("R")) { |
| return "Renamed"; |
| } |
| if (status.toUpperCase().startsWith("C")) { |
| return "Copied"; |
| } |
| return "Modified"; |
| } |
| } |
| |
| /** |
| * Get changes information (+/-) for display |
| */ |
| getChangesInfo(file: GitDiffFile): string { |
| const additions = file.additions || 0; |
| const deletions = file.deletions || 0; |
| |
| if (additions === 0 && deletions === 0) { |
| return ""; |
| } |
| |
| const parts = []; |
| if (additions > 0) { |
| parts.push(`+${additions}`); |
| } |
| if (deletions > 0) { |
| parts.push(`-${deletions}`); |
| } |
| |
| return `(${parts.join(", ")})`; |
| } |
| |
| /** |
| * Get path information for display, handling renames |
| */ |
| getPathInfo(file: GitDiffFile): string { |
| if (file.old_path && file.old_path !== "") { |
| // For renames, show old_path → new_path |
| return `${file.old_path} → ${file.path}`; |
| } |
| // For regular files, just show the path |
| return file.path; |
| } |
| |
| /** |
| * Render expand all icon (dotted line with arrows pointing away) |
| */ |
| renderExpandAllIcon() { |
| return html` |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> |
| <!-- Dotted line in the middle --> |
| <line |
| x1="2" |
| y1="8" |
| x2="14" |
| y2="8" |
| stroke="currentColor" |
| stroke-width="1" |
| stroke-dasharray="2,1" |
| /> |
| <!-- Large arrow pointing up --> |
| <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" /> |
| <!-- Large arrow pointing down --> |
| <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" /> |
| </svg> |
| `; |
| } |
| |
| /** |
| * Render collapse icon (arrows pointing towards dotted line) |
| */ |
| renderCollapseIcon() { |
| return html` |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> |
| <!-- Dotted line in the middle --> |
| <line |
| x1="2" |
| y1="8" |
| x2="14" |
| y2="8" |
| stroke="currentColor" |
| stroke-width="1" |
| stroke-dasharray="2,1" |
| /> |
| <!-- Large arrow pointing down towards line --> |
| <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" /> |
| <!-- Large arrow pointing up towards line --> |
| <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" /> |
| </svg> |
| `; |
| } |
| |
| /** |
| * Handle file selection change from the dropdown |
| */ |
| handleFileSelection(event: Event) { |
| const selectElement = event.target as HTMLSelectElement; |
| const selectedValue = selectElement.value; |
| |
| this.selectedFile = selectedValue; |
| this.viewMode = selectedValue ? "single" : "all"; |
| |
| // Force re-render |
| this.requestUpdate(); |
| } |
| |
| toggleUntrackedFilesPopup() { |
| this.showUntrackedPopup = !this.showUntrackedPopup; |
| } |
| |
| /** |
| * Get display name for file in the selector |
| */ |
| getFileDisplayName(file: GitDiffFile): string { |
| const status = this.getFileStatusText(file.status); |
| const pathInfo = this.getPathInfo(file); |
| return `${status}: ${pathInfo}`; |
| } |
| |
| /** |
| * Render expand/collapse button for single file view in header |
| */ |
| renderSingleFileExpandButton() { |
| if (!this.selectedFile) return ""; |
| |
| const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false; |
| |
| return html` |
| <button |
| class="bg-transparent border border-gray-300 rounded px-1.5 py-1.5 text-sm cursor-pointer whitespace-nowrap transition-colors duration-200 flex items-center justify-center min-w-8 min-h-8 hover:bg-gray-200" |
| @click="${() => this.toggleFileExpansion(this.selectedFile)}" |
| title="${isExpanded |
| ? "Collapse: Hide unchanged regions to focus on changes" |
| : "Expand: Show all lines including unchanged regions"}" |
| > |
| ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()} |
| </button> |
| `; |
| } |
| |
| /** |
| * Render single file view with full-screen Monaco editor |
| */ |
| renderSingleFileView() { |
| const selectedFileData = this.files.find( |
| (f) => f.path === this.selectedFile, |
| ); |
| if (!selectedFileData) { |
| return html`<div class="text-red-600 p-4">Selected file not found</div>`; |
| } |
| |
| const content = this.fileContents.get(this.selectedFile); |
| if (!content) { |
| return html`<div class="flex items-center justify-center h-full"> |
| Loading ${this.selectedFile}... |
| </div>`; |
| } |
| |
| return html` |
| <div class="flex-1 flex flex-col h-full min-h-0"> |
| <sketch-monaco-view |
| class="flex-1 w-full h-full min-h-0" |
| .originalCode="${content.original}" |
| .modifiedCode="${content.modified}" |
| .originalFilename="${selectedFileData.path}" |
| .modifiedFilename="${selectedFileData.path}" |
| ?readOnly="${!content.editable}" |
| ?editable-right="${content.editable}" |
| @monaco-comment="${this.handleMonacoComment}" |
| @monaco-save="${this.handleMonacoSave}" |
| data-file-path="${selectedFileData.path}" |
| ></sketch-monaco-view> |
| </div> |
| `; |
| } |
| |
| /** |
| * Refresh the diff view by reloading commits and diff data |
| * |
| * This is called when the Monaco diff tab is activated to ensure: |
| * 1. Branch information from git/recentlog is current (branches can change frequently) |
| * 2. The diff content is synchronized with the latest repository state |
| * 3. Users always see up-to-date information without manual refresh |
| */ |
| refreshDiffView() { |
| // First refresh the range picker to get updated branch information |
| const rangePicker = this.querySelector("sketch-diff-range-picker"); |
| if (rangePicker) { |
| (rangePicker as any).loadCommits(); |
| } |
| |
| if (this.commit) { |
| // Convert single commit to range (commit^ to commit) |
| this.currentRange = { |
| type: "range", |
| from: `${this.commit}^`, |
| to: this.commit, |
| }; |
| } |
| |
| // Then reload diff data based on the current range |
| this.loadDiffData(); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| "sketch-diff2-view": SketchDiff2View; |
| } |
| } |