blob: f524dd5bee246bd9390dcc2496135ee5cca6a886 [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
banksean54505842025-07-03 00:18:44 +00002import { html } from "lit";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07003import { customElement, property, state } from "lit/decorators.js";
banksean54505842025-07-03 00:18:44 +00004import { SketchTailwindElement } from "./sketch-tailwind-element.js";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07005import "./sketch-monaco-view";
6import "./sketch-diff-range-picker";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07007import "./sketch-diff-empty-view";
philip.zeyliger26bc6592025-06-30 20:15:30 -07008import { GitDiffFile, GitDataService } from "./git-data-service";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07009import { DiffRange } from "./sketch-diff-range-picker";
10
11/**
12 * A component that displays diffs using Monaco editor with range and file pickers
13 */
14@customElement("sketch-diff2-view")
banksean54505842025-07-03 00:18:44 +000015export class SketchDiff2View extends SketchTailwindElement {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070016 /**
17 * Handles comment events from the Monaco editor and forwards them to the chat input
18 * using the same event format as the original diff view for consistency.
19 */
20 private handleMonacoComment(event: CustomEvent) {
21 try {
22 // Validate incoming data
23 if (!event.detail || !event.detail.formattedComment) {
Autoformatter8c463622025-05-16 21:54:17 +000024 console.error("Invalid comment data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070025 return;
26 }
Autoformatter8c463622025-05-16 21:54:17 +000027
Philip Zeyliger272a90e2025-05-16 14:49:51 -070028 // Create and dispatch event using the standardized format
Autoformatter8c463622025-05-16 21:54:17 +000029 const commentEvent = new CustomEvent("diff-comment", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070030 detail: { comment: event.detail.formattedComment },
31 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +000032 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -070033 });
Autoformatter8c463622025-05-16 21:54:17 +000034
Philip Zeyliger272a90e2025-05-16 14:49:51 -070035 this.dispatchEvent(commentEvent);
36 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +000037 console.error("Error handling Monaco comment:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -070038 }
39 }
Autoformatter8c463622025-05-16 21:54:17 +000040
Philip Zeyliger272a90e2025-05-16 14:49:51 -070041 /**
David Crawshaw26f3f342025-06-14 19:58:32 +000042 * Handle height change events from the Monaco editor
43 */
44 private handleMonacoHeightChange(event: CustomEvent) {
45 try {
46 // Get the monaco view that emitted the event
47 const monacoView = event.target as HTMLElement;
48 if (!monacoView) return;
Autoformatter9abf8032025-06-14 23:24:08 +000049
David Crawshaw255dc432025-07-06 21:58:00 +000050 // Find the parent Monaco editor container (now using Tailwind classes)
Autoformatter9abf8032025-06-14 23:24:08 +000051 const fileDiffEditor = monacoView.closest(
David Crawshaw255dc432025-07-06 21:58:00 +000052 ".flex.flex-col.w-full.min-h-\\[200px\\].flex-1",
Autoformatter9abf8032025-06-14 23:24:08 +000053 ) as HTMLElement;
David Crawshaw26f3f342025-06-14 19:58:32 +000054 if (!fileDiffEditor) return;
Autoformatter9abf8032025-06-14 23:24:08 +000055
David Crawshaw26f3f342025-06-14 19:58:32 +000056 // Get the new height from the event
57 const newHeight = event.detail.height;
Autoformatter9abf8032025-06-14 23:24:08 +000058
David Crawshaw26f3f342025-06-14 19:58:32 +000059 // Only update if the height actually changed to avoid unnecessary layout
60 const currentHeight = fileDiffEditor.style.height;
61 const newHeightStr = `${newHeight}px`;
Autoformatter9abf8032025-06-14 23:24:08 +000062
David Crawshaw26f3f342025-06-14 19:58:32 +000063 if (currentHeight !== newHeightStr) {
David Crawshaw255dc432025-07-06 21:58:00 +000064 // Update the container height to match monaco's height
David Crawshaw26f3f342025-06-14 19:58:32 +000065 fileDiffEditor.style.height = newHeightStr;
Autoformatter9abf8032025-06-14 23:24:08 +000066
David Crawshaw26f3f342025-06-14 19:58:32 +000067 // Remove any previous min-height constraint that might interfere
Autoformatter9abf8032025-06-14 23:24:08 +000068 fileDiffEditor.style.minHeight = "auto";
69
David Crawshaw26f3f342025-06-14 19:58:32 +000070 // IMPORTANT: Tell Monaco to relayout after its container size changed
71 // Monaco has automaticLayout: false, so it won't detect container changes
72 setTimeout(() => {
73 const monacoComponent = monacoView as any;
74 if (monacoComponent && monacoComponent.editor) {
75 // Force layout with explicit dimensions to ensure Monaco fills the space
76 const editorWidth = fileDiffEditor.offsetWidth;
77 monacoComponent.editor.layout({
78 width: editorWidth,
Autoformatter9abf8032025-06-14 23:24:08 +000079 height: newHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +000080 });
81 }
82 }, 0);
83 }
David Crawshaw26f3f342025-06-14 19:58:32 +000084 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +000085 console.error("Error handling Monaco height change:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +000086 }
87 }
88
89 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -070090 * Handle save events from the Monaco editor
91 */
92 private async handleMonacoSave(event: CustomEvent) {
93 try {
94 // Validate incoming data
Autoformatter8c463622025-05-16 21:54:17 +000095 if (
96 !event.detail ||
97 !event.detail.path ||
98 event.detail.content === undefined
99 ) {
100 console.error("Invalid save data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700101 return;
102 }
Autoformatter8c463622025-05-16 21:54:17 +0000103
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700104 const { path, content } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000105
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700106 // Get Monaco view component
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700107 const monacoView = this.querySelector("sketch-monaco-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700108 if (!monacoView) {
Autoformatter8c463622025-05-16 21:54:17 +0000109 console.error("Monaco view not found");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700110 return;
111 }
Autoformatter8c463622025-05-16 21:54:17 +0000112
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700113 try {
114 await this.gitService?.saveFileContent(path, content);
115 console.log(`File saved: ${path}`);
116 (monacoView as any).notifySaveComplete(true);
117 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000118 console.error(
119 `Error saving file: ${error instanceof Error ? error.message : String(error)}`,
120 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700121 (monacoView as any).notifySaveComplete(false);
122 }
123 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000124 console.error("Error handling save:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700125 }
126 }
127 @property({ type: String })
128 initialCommit: string = "";
Autoformatter8c463622025-05-16 21:54:17 +0000129
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700130 // The commit to show - used when showing a specific commit from timeline
131 @property({ type: String })
132 commit: string = "";
133
134 @property({ type: String })
135 selectedFilePath: string = "";
136
137 @state()
138 private files: GitDiffFile[] = [];
Autoformatter8c463622025-05-16 21:54:17 +0000139
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700140 @state()
Autoformatter8c463622025-05-16 21:54:17 +0000141 private currentRange: DiffRange = { type: "range", from: "", to: "HEAD" };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700142
143 @state()
Autoformatter9abf8032025-06-14 23:24:08 +0000144 private fileContents: Map<
145 string,
146 { original: string; modified: string; editable: boolean }
147 > = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700148
149 @state()
David Crawshaw26f3f342025-06-14 19:58:32 +0000150 private fileExpandStates: Map<string, boolean> = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700151
152 @state()
153 private loading: boolean = false;
154
155 @state()
156 private error: string | null = null;
157
David Crawshaw4cd01292025-06-15 18:59:13 +0000158 @state()
159 private selectedFile: string = ""; // Empty string means "All files"
160
161 @state()
162 private viewMode: "all" | "single" = "all";
163
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000164 @state()
165 private untrackedFiles: string[] = [];
166
167 @state()
168 private showUntrackedPopup: boolean = false;
169
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700170 @property({ attribute: false, type: Object })
171 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000172
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700173 // The gitService must be passed from parent to ensure proper dependency injection
174
175 constructor() {
176 super();
Autoformatter8c463622025-05-16 21:54:17 +0000177 console.log("SketchDiff2View initialized");
178
David Crawshawe2954ce2025-06-15 00:06:34 +0000179 // Fix for monaco-aria-container positioning and hide scrollbars globally
180 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000181 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700182 styleElement.textContent = `
183 .monaco-aria-container {
184 position: absolute !important;
185 top: 0 !important;
186 left: 0 !important;
187 width: 1px !important;
188 height: 1px !important;
189 overflow: hidden !important;
190 clip: rect(1px, 1px, 1px, 1px) !important;
191 white-space: nowrap !important;
192 margin: 0 !important;
193 padding: 0 !important;
194 border: 0 !important;
195 z-index: -1 !important;
196 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700197
David Crawshawe2954ce2025-06-15 00:06:34 +0000198 /* Aggressively hide all Monaco scrollbar elements */
199 .monaco-editor .scrollbar,
200 .monaco-editor .scroll-decoration,
201 .monaco-editor .invisible.scrollbar,
202 .monaco-editor .slider,
203 .monaco-editor .vertical.scrollbar,
204 .monaco-editor .horizontal.scrollbar,
205 .monaco-diff-editor .scrollbar,
206 .monaco-diff-editor .scroll-decoration,
207 .monaco-diff-editor .invisible.scrollbar,
208 .monaco-diff-editor .slider,
209 .monaco-diff-editor .vertical.scrollbar,
210 .monaco-diff-editor .horizontal.scrollbar {
211 display: none !important;
212 visibility: hidden !important;
213 width: 0 !important;
214 height: 0 !important;
215 opacity: 0 !important;
216 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700217
David Crawshawe2954ce2025-06-15 00:06:34 +0000218 /* Target the specific scrollbar classes that Monaco uses */
219 .monaco-scrollable-element > .scrollbar,
220 .monaco-scrollable-element > .scroll-decoration,
221 .monaco-scrollable-element .slider {
222 display: none !important;
223 visibility: hidden !important;
224 width: 0 !important;
225 height: 0 !important;
226 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700227
David Crawshawe2954ce2025-06-15 00:06:34 +0000228 /* Remove scrollbar space/padding from content area */
229 .monaco-editor .monaco-scrollable-element,
230 .monaco-diff-editor .monaco-scrollable-element {
231 padding-right: 0 !important;
232 padding-bottom: 0 !important;
233 margin-right: 0 !important;
234 margin-bottom: 0 !important;
235 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700236
David Crawshawe2954ce2025-06-15 00:06:34 +0000237 /* Ensure the diff content takes full width without scrollbar space */
238 .monaco-diff-editor .editor.modified,
239 .monaco-diff-editor .editor.original {
240 margin-right: 0 !important;
241 padding-right: 0 !important;
242 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700243 `;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700244 document.head.appendChild(styleElement);
245 }
246
Sean McCulloughdf234032025-07-02 20:45:29 -0700247 // Override createRenderRoot to apply host styles for proper sizing while still using light DOM
248 createRenderRoot() {
249 // Use light DOM like SketchTailwindElement but still apply host styles
250 const style = document.createElement("style");
251 style.textContent = `
252 sketch-diff2-view {
253 height: -webkit-fill-available;
254 }
255 `;
256
257 // Add the style to the document head if not already present
258 if (!document.head.querySelector("style[data-sketch-diff2-view]")) {
259 style.setAttribute("data-sketch-diff2-view", "");
260 document.head.appendChild(style);
261 }
262
263 return this;
264 }
265
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700266 connectedCallback() {
267 super.connectedCallback();
268 // Initialize with default range and load data
269 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000270 if (
271 this.currentRange.type === "range" &&
272 !("from" in this.currentRange && this.currentRange.from)
273 ) {
274 this.gitService
275 .getBaseCommitRef()
276 .then((baseRef) => {
277 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
278 this.loadDiffData();
279 })
280 .catch((error) => {
281 console.error("Error getting base commit ref:", error);
282 // Use default range
283 this.loadDiffData();
284 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700285 } else {
286 this.loadDiffData();
287 }
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000288
289 // Add click listener to close popup when clicking outside
290 document.addEventListener("click", this.handleDocumentClick.bind(this));
291 }
292
293 disconnectedCallback() {
294 super.disconnectedCallback();
295 document.removeEventListener("click", this.handleDocumentClick.bind(this));
296 }
297
298 handleDocumentClick(event: Event) {
299 if (this.showUntrackedPopup) {
300 const target = event.target as HTMLElement;
301 // Check if click is outside the popup and button
302 if (!target.closest(".relative")) {
303 this.showUntrackedPopup = false;
304 }
305 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700306 }
307
David Crawshaw26f3f342025-06-14 19:58:32 +0000308 // Toggle hideUnchangedRegions setting for a specific file
309 private toggleFileExpansion(filePath: string) {
310 const currentState = this.fileExpandStates.get(filePath) ?? false;
311 const newState = !currentState;
312 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000313
David Crawshaw26f3f342025-06-14 19:58:32 +0000314 // Apply to the specific Monaco view component for this file
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700315 const monacoView = this.querySelector(
Autoformatter9abf8032025-06-14 23:24:08 +0000316 `sketch-monaco-view[data-file-path="${filePath}"]`,
317 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700318 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000319 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700320 }
Autoformatter9abf8032025-06-14 23:24:08 +0000321
David Crawshaw26f3f342025-06-14 19:58:32 +0000322 // Force a re-render to update the button state
323 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700324 }
Autoformatter8c463622025-05-16 21:54:17 +0000325
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700326 render() {
327 return html`
David Crawshaw255dc432025-07-06 21:58:00 +0000328 <div class="px-4 py-2 border-b border-gray-300 bg-gray-100 flex-shrink-0">
329 <div class="flex flex-col gap-3">
330 <div class="w-full flex items-center gap-3">
331 <sketch-diff-range-picker
332 class="flex-1 min-w-[400px]"
333 .gitService="${this.gitService}"
334 @range-change="${this.handleRangeChange}"
335 ></sketch-diff-range-picker>
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000336 ${this.renderUntrackedFilesNotification()}
David Crawshaw255dc432025-07-06 21:58:00 +0000337 <div class="flex-1"></div>
338 ${this.renderFileSelector()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700339 </div>
340 </div>
David Crawshaw255dc432025-07-06 21:58:00 +0000341 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700342
David Crawshaw255dc432025-07-06 21:58:00 +0000343 <div class="flex-1 overflow-auto flex flex-col min-h-0 relative h-full">
344 ${this.renderDiffContent()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700345 </div>
346 `;
347 }
348
David Crawshaw4cd01292025-06-15 18:59:13 +0000349 renderFileSelector() {
David Crawshaw5c6d8292025-06-15 19:09:19 +0000350 const fileCount = this.files.length;
Autoformatter62554112025-06-15 19:23:33 +0000351
David Crawshaw4cd01292025-06-15 18:59:13 +0000352 return html`
banksean54505842025-07-03 00:18:44 +0000353 <div class="flex items-center gap-2">
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700354 <select
banksean54505842025-07-03 00:18:44 +0000355 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"
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700356 .value="${this.selectedFile}"
357 @change="${this.handleFileSelection}"
358 ?disabled="${fileCount === 0}"
359 >
360 <option value="">All files (${fileCount})</option>
361 ${this.files.map(
362 (file) => html`
363 <option value="${file.path}">
364 ${this.getFileDisplayName(file)}
365 </option>
366 `,
367 )}
368 </select>
369 ${this.selectedFile ? this.renderSingleFileExpandButton() : ""}
370 </div>
David Crawshaw4cd01292025-06-15 18:59:13 +0000371 `;
372 }
373
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000374 renderUntrackedFilesNotification() {
375 if (!this.untrackedFiles || this.untrackedFiles.length === 0) {
376 return "";
377 }
378
379 const fileCount = this.untrackedFiles.length;
380 const fileCountText =
381 fileCount === 1 ? "1 untracked file" : `${fileCount} untracked files`;
382
383 return html`
384 <div class="relative">
385 <button
386 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"
387 @click="${this.toggleUntrackedFilesPopup}"
388 type="button"
389 >
390 ${fileCount} untracked
391 <svg
392 class="w-4 h-4"
393 fill="none"
394 stroke="currentColor"
395 viewBox="0 0 24 24"
396 >
397 <path
398 stroke-linecap="round"
399 stroke-linejoin="round"
400 stroke-width="2"
401 d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
402 />
403 </svg>
404 </button>
405
406 ${this.showUntrackedPopup
407 ? html`
408 <div
409 class="absolute top-full left-0 mt-2 w-80 bg-white border border-gray-300 rounded-lg shadow-lg z-50"
410 >
411 <div class="p-4">
412 <div class="flex items-start gap-3 mb-3">
413 <svg
414 class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5"
415 fill="none"
416 stroke="currentColor"
417 viewBox="0 0 24 24"
418 >
419 <path
420 stroke-linecap="round"
421 stroke-linejoin="round"
422 stroke-width="2"
423 d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
424 />
425 </svg>
426 <div class="flex-1">
427 <div class="font-medium text-gray-900 mb-1">
428 ${fileCountText}
429 </div>
430 <div class="text-sm text-gray-600 mb-3">
Autoformatter390f8472025-07-16 20:09:45 +0000431 These files are not tracked by git. They will be lost if
432 the session ends now. The agent typically does not add
433 files to git until it is ready for feedback.
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000434 </div>
435 </div>
436 </div>
437
438 <div class="max-h-32 overflow-y-auto">
439 <div class="text-sm text-gray-700">
440 ${this.untrackedFiles.map(
441 (file) => html`
442 <div
443 class="py-1 px-2 hover:bg-gray-100 rounded font-mono text-xs"
444 >
445 ${file}
446 </div>
447 `,
448 )}
449 </div>
450 </div>
451 </div>
452 </div>
453 `
454 : ""}
455 </div>
456 `;
457 }
458
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700459 renderDiffContent() {
460 if (this.loading) {
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000461 return html`<div class="flex items-center justify-center h-full">
462 Loading diff...
463 </div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700464 }
465
466 if (this.error) {
banksean54505842025-07-03 00:18:44 +0000467 return html`<div class="text-red-600 p-4">${this.error}</div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700468 }
469
470 if (this.files.length === 0) {
471 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
472 }
Autoformatter8c463622025-05-16 21:54:17 +0000473
David Crawshaw4cd01292025-06-15 18:59:13 +0000474 // Render single file view if a specific file is selected
475 if (this.selectedFile && this.viewMode === "single") {
476 return this.renderSingleFileView();
477 }
478
479 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700480 return html`
banksean54505842025-07-03 00:18:44 +0000481 <div class="flex flex-col w-full min-h-full">
David Crawshaw26f3f342025-06-14 19:58:32 +0000482 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
483 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700484 `;
485 }
486
487 /**
488 * Load diff data for the current range
489 */
490 async loadDiffData() {
491 this.loading = true;
492 this.error = null;
493
494 try {
495 // Initialize files as empty array if undefined
496 if (!this.files) {
497 this.files = [];
498 }
499
David Crawshaw216d2fc2025-06-15 18:45:53 +0000500 // Load diff data for the range
501 this.files = await this.gitService.getDiff(
502 this.currentRange.from,
503 this.currentRange.to,
504 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700505
506 // Ensure files is always an array, even when API returns null
507 if (!this.files) {
508 this.files = [];
509 }
Autoformatter8c463622025-05-16 21:54:17 +0000510
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000511 // Load untracked files for notification
512 try {
513 this.untrackedFiles = await this.gitService.getUntrackedFiles();
514 } catch (error) {
515 console.error("Error loading untracked files:", error);
516 this.untrackedFiles = [];
517 }
518
David Crawshaw26f3f342025-06-14 19:58:32 +0000519 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700520 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000521 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000522 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000523 if (!this.fileExpandStates.has(file.path)) {
524 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
525 }
526 });
527 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700528 } else {
529 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000530 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000531 this.selectedFile = "";
532 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000533 this.fileContents.clear();
534 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700535 }
536 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000537 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700538 this.error = `Error loading diff data: ${error.message}`;
539 // Ensure files is an empty array when an error occurs
540 this.files = [];
541 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000542 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000543 this.selectedFile = "";
544 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000545 this.fileContents.clear();
546 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700547 } finally {
548 this.loading = false;
Josh Bleecher Snyderd37f7a72025-07-22 13:43:52 +0000549 // Request a re-render.
550 // Empirically, without this line, diffs are visibly slow to load.
551 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700552 }
553 }
554
555 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000556 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700557 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000558 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700559 this.loading = true;
560 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000561 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700562
563 try {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700564 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000565
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700566 // Determine the commits to compare based on the current range
philip.zeyliger26bc6592025-06-30 20:15:30 -0700567 const _fromCommit = this.currentRange.from;
568 const toCommit = this.currentRange.to;
David Crawshaw216d2fc2025-06-15 18:45:53 +0000569 // Check if this is an unstaged changes view
570 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700571
David Crawshaw26f3f342025-06-14 19:58:32 +0000572 // Load content for all files
573 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700574 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000575 let originalCode = "";
576 let modifiedCode = "";
577 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000578
David Crawshaw26f3f342025-06-14 19:58:32 +0000579 // Load the original code based on file status
580 if (file.status !== "A") {
581 // For modified, renamed, or deleted files: load original content
582 originalCode = await this.gitService.getFileContent(
583 file.old_hash || "",
584 );
585 }
586
587 // For modified code, always use working copy when editable
588 if (editable) {
589 try {
590 // Always use working copy when editable, regardless of diff status
591 modifiedCode = await this.gitService.getWorkingCopyContent(
592 file.path,
593 );
594 } catch (error) {
595 if (file.status === "D") {
596 // For deleted files, silently use empty content
597 console.warn(
598 `Could not get working copy for deleted file ${file.path}, using empty content`,
599 );
600 modifiedCode = "";
601 } else {
602 // For any other file status, propagate the error
603 console.error(
604 `Failed to get working copy for ${file.path}:`,
605 error,
606 );
607 throw error;
608 }
609 }
610 } else {
611 // For non-editable view, use git content based on file status
612 if (file.status === "D") {
613 // Deleted file: empty modified
614 modifiedCode = "";
615 } else {
616 // Added/modified/renamed: use the content from git
617 modifiedCode = await this.gitService.getFileContent(
618 file.new_hash || "",
619 );
620 }
621 }
622
623 // Don't make deleted files editable
624 if (file.status === "D") {
625 editable = false;
626 }
627
628 this.fileContents.set(file.path, {
629 original: originalCode,
630 modified: modifiedCode,
631 editable,
632 });
633 } catch (error) {
634 console.error(`Error loading content for file ${file.path}:`, error);
635 // Store empty content for failed files to prevent blocking
636 this.fileContents.set(file.path, {
637 original: "",
638 modified: "",
639 editable: false,
640 });
641 }
642 });
643
644 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700645 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000646 console.error("Error loading file contents:", error);
647 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700648 } finally {
649 this.loading = false;
650 }
651 }
652
653 /**
654 * Handle range change event from the range picker
655 */
656 handleRangeChange(event: CustomEvent) {
657 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000658 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700659 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000660
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700661 // Load diff data for the new range
662 this.loadDiffData();
663 }
664
665 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000666 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700667 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000668 renderFileDiff(file: GitDiffFile, index: number) {
669 const content = this.fileContents.get(file.path);
670 if (!content) {
671 return html`
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000672 <div
673 class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0"
674 >
675 <div
676 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"
677 >
678 ${this.renderFileHeader(file)}
679 </div>
680 <div class="flex items-center justify-center h-full">
681 Loading ${file.path}...
682 </div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000683 </div>
684 `;
685 }
686
687 return html`
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000688 <div
689 class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0"
690 >
691 <div
692 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"
693 >
694 ${this.renderFileHeader(file)}
695 </div>
David Crawshaw255dc432025-07-06 21:58:00 +0000696 <div class="flex flex-col w-full min-h-[200px] flex-1">
David Crawshaw26f3f342025-06-14 19:58:32 +0000697 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000698 class="flex flex-col w-full min-h-[200px] flex-1"
David Crawshaw26f3f342025-06-14 19:58:32 +0000699 .originalCode="${content.original}"
700 .modifiedCode="${content.modified}"
701 .originalFilename="${file.path}"
702 .modifiedFilename="${file.path}"
703 ?readOnly="${!content.editable}"
704 ?editable-right="${content.editable}"
705 @monaco-comment="${this.handleMonacoComment}"
706 @monaco-save="${this.handleMonacoSave}"
707 @monaco-height-changed="${this.handleMonacoHeightChange}"
708 data-file-index="${index}"
709 data-file-path="${file.path}"
710 ></sketch-monaco-view>
711 </div>
712 </div>
713 `;
714 }
715
716 /**
717 * Render file header with status and path info
718 */
719 renderFileHeader(file: GitDiffFile) {
banksean54505842025-07-03 00:18:44 +0000720 const statusClasses = this.getFileStatusTailwindClasses(file.status);
David Crawshaw26f3f342025-06-14 19:58:32 +0000721 const statusText = this.getFileStatusText(file.status);
722 const changesInfo = this.getChangesInfo(file);
723 const pathInfo = this.getPathInfo(file);
724
725 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000726
David Crawshaw26f3f342025-06-14 19:58:32 +0000727 return html`
banksean54505842025-07-03 00:18:44 +0000728 <div class="flex items-center gap-2">
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000729 <span
730 class="inline-block px-1.5 py-0.5 rounded text-xs font-bold mr-2 ${statusClasses}"
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000731 >
David Crawshaw255dc432025-07-06 21:58:00 +0000732 ${statusText}
733 </span>
banksean54505842025-07-03 00:18:44 +0000734 <span class="font-mono font-normal text-gray-600">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000735 ${changesInfo
banksean54505842025-07-03 00:18:44 +0000736 ? html`<span class="ml-2 text-xs text-gray-600">${changesInfo}</span>`
Autoformatter9abf8032025-06-14 23:24:08 +0000737 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000738 </div>
banksean54505842025-07-03 00:18:44 +0000739 <div class="flex items-center">
David Crawshaw26f3f342025-06-14 19:58:32 +0000740 <button
banksean54505842025-07-03 00:18:44 +0000741 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"
David Crawshaw26f3f342025-06-14 19:58:32 +0000742 @click="${() => this.toggleFileExpansion(file.path)}"
743 title="${isExpanded
744 ? "Collapse: Hide unchanged regions to focus on changes"
745 : "Expand: Show all lines including unchanged regions"}"
746 >
Autoformatter9abf8032025-06-14 23:24:08 +0000747 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000748 </button>
749 </div>
750 `;
751 }
752
753 /**
banksean54505842025-07-03 00:18:44 +0000754 * Get Tailwind CSS classes for file status
David Crawshaw26f3f342025-06-14 19:58:32 +0000755 */
banksean54505842025-07-03 00:18:44 +0000756 getFileStatusTailwindClasses(status: string): string {
David Crawshaw26f3f342025-06-14 19:58:32 +0000757 switch (status.toUpperCase()) {
758 case "A":
banksean54505842025-07-03 00:18:44 +0000759 return "bg-green-100 text-green-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000760 case "M":
banksean54505842025-07-03 00:18:44 +0000761 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000762 case "D":
banksean54505842025-07-03 00:18:44 +0000763 return "bg-red-100 text-red-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000764 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000765 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000766 default:
767 if (status.toUpperCase().startsWith("R")) {
banksean54505842025-07-03 00:18:44 +0000768 return "bg-cyan-100 text-cyan-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000769 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000770 if (status.toUpperCase().startsWith("C")) {
banksean54505842025-07-03 00:18:44 +0000771 return "bg-indigo-100 text-indigo-800";
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000772 }
banksean54505842025-07-03 00:18:44 +0000773 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000774 }
775 }
776
777 /**
778 * Get display text for file status
779 */
780 getFileStatusText(status: string): string {
781 switch (status.toUpperCase()) {
782 case "A":
783 return "Added";
784 case "M":
785 return "Modified";
786 case "D":
787 return "Deleted";
788 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000789 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000790 default:
791 if (status.toUpperCase().startsWith("R")) {
792 return "Renamed";
793 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000794 if (status.toUpperCase().startsWith("C")) {
795 return "Copied";
796 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000797 return "Modified";
798 }
799 }
800
801 /**
802 * Get changes information (+/-) for display
803 */
804 getChangesInfo(file: GitDiffFile): string {
805 const additions = file.additions || 0;
806 const deletions = file.deletions || 0;
807
808 if (additions === 0 && deletions === 0) {
809 return "";
810 }
811
812 const parts = [];
813 if (additions > 0) {
814 parts.push(`+${additions}`);
815 }
816 if (deletions > 0) {
817 parts.push(`-${deletions}`);
818 }
819
820 return `(${parts.join(", ")})`;
821 }
822
823 /**
824 * Get path information for display, handling renames
825 */
826 getPathInfo(file: GitDiffFile): string {
827 if (file.old_path && file.old_path !== "") {
828 // For renames, show old_path → new_path
829 return `${file.old_path} → ${file.path}`;
830 }
831 // For regular files, just show the path
832 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700833 }
834
835 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000836 * Render expand all icon (dotted line with arrows pointing away)
837 */
838 renderExpandAllIcon() {
839 return html`
840 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
841 <!-- Dotted line in the middle -->
842 <line
843 x1="2"
844 y1="8"
845 x2="14"
846 y2="8"
847 stroke="currentColor"
848 stroke-width="1"
849 stroke-dasharray="2,1"
850 />
851 <!-- Large arrow pointing up -->
852 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
853 <!-- Large arrow pointing down -->
854 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
855 </svg>
856 `;
857 }
858
859 /**
860 * Render collapse icon (arrows pointing towards dotted line)
861 */
862 renderCollapseIcon() {
863 return html`
864 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
865 <!-- Dotted line in the middle -->
866 <line
867 x1="2"
868 y1="8"
869 x2="14"
870 y2="8"
871 stroke="currentColor"
872 stroke-width="1"
873 stroke-dasharray="2,1"
874 />
875 <!-- Large arrow pointing down towards line -->
876 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
877 <!-- Large arrow pointing up towards line -->
878 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
879 </svg>
880 `;
881 }
882
883 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000884 * Handle file selection change from the dropdown
885 */
886 handleFileSelection(event: Event) {
887 const selectElement = event.target as HTMLSelectElement;
888 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +0000889
David Crawshaw4cd01292025-06-15 18:59:13 +0000890 this.selectedFile = selectedValue;
891 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +0000892
David Crawshaw4cd01292025-06-15 18:59:13 +0000893 // Force re-render
894 this.requestUpdate();
895 }
896
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000897 toggleUntrackedFilesPopup() {
898 this.showUntrackedPopup = !this.showUntrackedPopup;
899 }
900
David Crawshaw4cd01292025-06-15 18:59:13 +0000901 /**
902 * Get display name for file in the selector
903 */
904 getFileDisplayName(file: GitDiffFile): string {
905 const status = this.getFileStatusText(file.status);
906 const pathInfo = this.getPathInfo(file);
907 return `${status}: ${pathInfo}`;
908 }
909
910 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700911 * Render expand/collapse button for single file view in header
912 */
913 renderSingleFileExpandButton() {
914 if (!this.selectedFile) return "";
915
916 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
917
918 return html`
919 <button
banksean54505842025-07-03 00:18:44 +0000920 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"
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700921 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
922 title="${isExpanded
923 ? "Collapse: Hide unchanged regions to focus on changes"
924 : "Expand: Show all lines including unchanged regions"}"
925 >
926 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
927 </button>
928 `;
929 }
930
931 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000932 * Render single file view with full-screen Monaco editor
933 */
934 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +0000935 const selectedFileData = this.files.find(
936 (f) => f.path === this.selectedFile,
937 );
David Crawshaw4cd01292025-06-15 18:59:13 +0000938 if (!selectedFileData) {
banksean54505842025-07-03 00:18:44 +0000939 return html`<div class="text-red-600 p-4">Selected file not found</div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000940 }
941
942 const content = this.fileContents.get(this.selectedFile);
943 if (!content) {
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000944 return html`<div class="flex items-center justify-center h-full">
945 Loading ${this.selectedFile}...
946 </div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000947 }
948
949 return html`
banksean54505842025-07-03 00:18:44 +0000950 <div class="flex-1 flex flex-col h-full min-h-0">
David Crawshaw4cd01292025-06-15 18:59:13 +0000951 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000952 class="flex-1 w-full h-full min-h-0"
David Crawshaw4cd01292025-06-15 18:59:13 +0000953 .originalCode="${content.original}"
954 .modifiedCode="${content.modified}"
955 .originalFilename="${selectedFileData.path}"
956 .modifiedFilename="${selectedFileData.path}"
957 ?readOnly="${!content.editable}"
958 ?editable-right="${content.editable}"
959 @monaco-comment="${this.handleMonacoComment}"
960 @monaco-save="${this.handleMonacoSave}"
961 data-file-path="${selectedFileData.path}"
962 ></sketch-monaco-view>
963 </div>
964 `;
965 }
966
967 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700968 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +0000969 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700970 * This is called when the Monaco diff tab is activated to ensure:
971 * 1. Branch information from git/recentlog is current (branches can change frequently)
972 * 2. The diff content is synchronized with the latest repository state
973 * 3. Users always see up-to-date information without manual refresh
974 */
975 refreshDiffView() {
976 // First refresh the range picker to get updated branch information
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700977 const rangePicker = this.querySelector("sketch-diff-range-picker");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700978 if (rangePicker) {
979 (rangePicker as any).loadCommits();
980 }
Autoformatter8c463622025-05-16 21:54:17 +0000981
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700982 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +0000983 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +0000984 this.currentRange = {
985 type: "range",
986 from: `${this.commit}^`,
987 to: this.commit,
988 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700989 }
Autoformatter8c463622025-05-16 21:54:17 +0000990
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700991 // Then reload diff data based on the current range
992 this.loadDiffData();
993 }
994}
995
996declare global {
997 interface HTMLElementTagNameMap {
998 "sketch-diff2-view": SketchDiff2View;
999 }
1000}