blob: 04f3fc204c65b2d30d150e779a7d0f7cd7b9c7de [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">
431 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.
432 </div>
433 </div>
434 </div>
435
436 <div class="max-h-32 overflow-y-auto">
437 <div class="text-sm text-gray-700">
438 ${this.untrackedFiles.map(
439 (file) => html`
440 <div
441 class="py-1 px-2 hover:bg-gray-100 rounded font-mono text-xs"
442 >
443 ${file}
444 </div>
445 `,
446 )}
447 </div>
448 </div>
449 </div>
450 </div>
451 `
452 : ""}
453 </div>
454 `;
455 }
456
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700457 renderDiffContent() {
458 if (this.loading) {
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000459 return html`<div class="flex items-center justify-center h-full">
460 Loading diff...
461 </div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700462 }
463
464 if (this.error) {
banksean54505842025-07-03 00:18:44 +0000465 return html`<div class="text-red-600 p-4">${this.error}</div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700466 }
467
468 if (this.files.length === 0) {
469 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
470 }
Autoformatter8c463622025-05-16 21:54:17 +0000471
David Crawshaw4cd01292025-06-15 18:59:13 +0000472 // Render single file view if a specific file is selected
473 if (this.selectedFile && this.viewMode === "single") {
474 return this.renderSingleFileView();
475 }
476
477 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700478 return html`
banksean54505842025-07-03 00:18:44 +0000479 <div class="flex flex-col w-full min-h-full">
David Crawshaw26f3f342025-06-14 19:58:32 +0000480 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
481 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700482 `;
483 }
484
485 /**
486 * Load diff data for the current range
487 */
488 async loadDiffData() {
489 this.loading = true;
490 this.error = null;
491
492 try {
493 // Initialize files as empty array if undefined
494 if (!this.files) {
495 this.files = [];
496 }
497
David Crawshaw216d2fc2025-06-15 18:45:53 +0000498 // Load diff data for the range
499 this.files = await this.gitService.getDiff(
500 this.currentRange.from,
501 this.currentRange.to,
502 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700503
504 // Ensure files is always an array, even when API returns null
505 if (!this.files) {
506 this.files = [];
507 }
Autoformatter8c463622025-05-16 21:54:17 +0000508
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000509 // Load untracked files for notification
510 try {
511 this.untrackedFiles = await this.gitService.getUntrackedFiles();
512 } catch (error) {
513 console.error("Error loading untracked files:", error);
514 this.untrackedFiles = [];
515 }
516
David Crawshaw26f3f342025-06-14 19:58:32 +0000517 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700518 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000519 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000520 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000521 if (!this.fileExpandStates.has(file.path)) {
522 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
523 }
524 });
525 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700526 } else {
527 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000528 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000529 this.selectedFile = "";
530 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000531 this.fileContents.clear();
532 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700533 }
534 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000535 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700536 this.error = `Error loading diff data: ${error.message}`;
537 // Ensure files is an empty array when an error occurs
538 this.files = [];
539 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000540 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000541 this.selectedFile = "";
542 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000543 this.fileContents.clear();
544 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700545 } finally {
546 this.loading = false;
547 }
548 }
549
550 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000551 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700552 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000553 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700554 this.loading = true;
555 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000556 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700557
558 try {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700559 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000560
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700561 // Determine the commits to compare based on the current range
philip.zeyliger26bc6592025-06-30 20:15:30 -0700562 const _fromCommit = this.currentRange.from;
563 const toCommit = this.currentRange.to;
David Crawshaw216d2fc2025-06-15 18:45:53 +0000564 // Check if this is an unstaged changes view
565 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700566
David Crawshaw26f3f342025-06-14 19:58:32 +0000567 // Load content for all files
568 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700569 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000570 let originalCode = "";
571 let modifiedCode = "";
572 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000573
David Crawshaw26f3f342025-06-14 19:58:32 +0000574 // Load the original code based on file status
575 if (file.status !== "A") {
576 // For modified, renamed, or deleted files: load original content
577 originalCode = await this.gitService.getFileContent(
578 file.old_hash || "",
579 );
580 }
581
582 // For modified code, always use working copy when editable
583 if (editable) {
584 try {
585 // Always use working copy when editable, regardless of diff status
586 modifiedCode = await this.gitService.getWorkingCopyContent(
587 file.path,
588 );
589 } catch (error) {
590 if (file.status === "D") {
591 // For deleted files, silently use empty content
592 console.warn(
593 `Could not get working copy for deleted file ${file.path}, using empty content`,
594 );
595 modifiedCode = "";
596 } else {
597 // For any other file status, propagate the error
598 console.error(
599 `Failed to get working copy for ${file.path}:`,
600 error,
601 );
602 throw error;
603 }
604 }
605 } else {
606 // For non-editable view, use git content based on file status
607 if (file.status === "D") {
608 // Deleted file: empty modified
609 modifiedCode = "";
610 } else {
611 // Added/modified/renamed: use the content from git
612 modifiedCode = await this.gitService.getFileContent(
613 file.new_hash || "",
614 );
615 }
616 }
617
618 // Don't make deleted files editable
619 if (file.status === "D") {
620 editable = false;
621 }
622
623 this.fileContents.set(file.path, {
624 original: originalCode,
625 modified: modifiedCode,
626 editable,
627 });
628 } catch (error) {
629 console.error(`Error loading content for file ${file.path}:`, error);
630 // Store empty content for failed files to prevent blocking
631 this.fileContents.set(file.path, {
632 original: "",
633 modified: "",
634 editable: false,
635 });
636 }
637 });
638
639 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700640 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000641 console.error("Error loading file contents:", error);
642 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700643 } finally {
644 this.loading = false;
645 }
646 }
647
648 /**
649 * Handle range change event from the range picker
650 */
651 handleRangeChange(event: CustomEvent) {
652 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000653 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700654 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000655
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700656 // Load diff data for the new range
657 this.loadDiffData();
658 }
659
660 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000661 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700662 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000663 renderFileDiff(file: GitDiffFile, index: number) {
664 const content = this.fileContents.get(file.path);
665 if (!content) {
666 return html`
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000667 <div
668 class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0"
669 >
670 <div
671 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"
672 >
673 ${this.renderFileHeader(file)}
674 </div>
675 <div class="flex items-center justify-center h-full">
676 Loading ${file.path}...
677 </div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000678 </div>
679 `;
680 }
681
682 return html`
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000683 <div
684 class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0"
685 >
686 <div
687 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"
688 >
689 ${this.renderFileHeader(file)}
690 </div>
David Crawshaw255dc432025-07-06 21:58:00 +0000691 <div class="flex flex-col w-full min-h-[200px] flex-1">
David Crawshaw26f3f342025-06-14 19:58:32 +0000692 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000693 class="flex flex-col w-full min-h-[200px] flex-1"
David Crawshaw26f3f342025-06-14 19:58:32 +0000694 .originalCode="${content.original}"
695 .modifiedCode="${content.modified}"
696 .originalFilename="${file.path}"
697 .modifiedFilename="${file.path}"
698 ?readOnly="${!content.editable}"
699 ?editable-right="${content.editable}"
700 @monaco-comment="${this.handleMonacoComment}"
701 @monaco-save="${this.handleMonacoSave}"
702 @monaco-height-changed="${this.handleMonacoHeightChange}"
703 data-file-index="${index}"
704 data-file-path="${file.path}"
705 ></sketch-monaco-view>
706 </div>
707 </div>
708 `;
709 }
710
711 /**
712 * Render file header with status and path info
713 */
714 renderFileHeader(file: GitDiffFile) {
banksean54505842025-07-03 00:18:44 +0000715 const statusClasses = this.getFileStatusTailwindClasses(file.status);
David Crawshaw26f3f342025-06-14 19:58:32 +0000716 const statusText = this.getFileStatusText(file.status);
717 const changesInfo = this.getChangesInfo(file);
718 const pathInfo = this.getPathInfo(file);
719
720 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000721
David Crawshaw26f3f342025-06-14 19:58:32 +0000722 return html`
banksean54505842025-07-03 00:18:44 +0000723 <div class="flex items-center gap-2">
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000724 <span
725 class="inline-block px-1.5 py-0.5 rounded text-xs font-bold mr-2 ${statusClasses}"
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000726 >
David Crawshaw255dc432025-07-06 21:58:00 +0000727 ${statusText}
728 </span>
banksean54505842025-07-03 00:18:44 +0000729 <span class="font-mono font-normal text-gray-600">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000730 ${changesInfo
banksean54505842025-07-03 00:18:44 +0000731 ? html`<span class="ml-2 text-xs text-gray-600">${changesInfo}</span>`
Autoformatter9abf8032025-06-14 23:24:08 +0000732 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000733 </div>
banksean54505842025-07-03 00:18:44 +0000734 <div class="flex items-center">
David Crawshaw26f3f342025-06-14 19:58:32 +0000735 <button
banksean54505842025-07-03 00:18:44 +0000736 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 +0000737 @click="${() => this.toggleFileExpansion(file.path)}"
738 title="${isExpanded
739 ? "Collapse: Hide unchanged regions to focus on changes"
740 : "Expand: Show all lines including unchanged regions"}"
741 >
Autoformatter9abf8032025-06-14 23:24:08 +0000742 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000743 </button>
744 </div>
745 `;
746 }
747
748 /**
banksean54505842025-07-03 00:18:44 +0000749 * Get Tailwind CSS classes for file status
David Crawshaw26f3f342025-06-14 19:58:32 +0000750 */
banksean54505842025-07-03 00:18:44 +0000751 getFileStatusTailwindClasses(status: string): string {
David Crawshaw26f3f342025-06-14 19:58:32 +0000752 switch (status.toUpperCase()) {
753 case "A":
banksean54505842025-07-03 00:18:44 +0000754 return "bg-green-100 text-green-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000755 case "M":
banksean54505842025-07-03 00:18:44 +0000756 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000757 case "D":
banksean54505842025-07-03 00:18:44 +0000758 return "bg-red-100 text-red-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000759 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000760 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000761 default:
762 if (status.toUpperCase().startsWith("R")) {
banksean54505842025-07-03 00:18:44 +0000763 return "bg-cyan-100 text-cyan-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000764 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000765 if (status.toUpperCase().startsWith("C")) {
banksean54505842025-07-03 00:18:44 +0000766 return "bg-indigo-100 text-indigo-800";
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000767 }
banksean54505842025-07-03 00:18:44 +0000768 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000769 }
770 }
771
772 /**
773 * Get display text for file status
774 */
775 getFileStatusText(status: string): string {
776 switch (status.toUpperCase()) {
777 case "A":
778 return "Added";
779 case "M":
780 return "Modified";
781 case "D":
782 return "Deleted";
783 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000784 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000785 default:
786 if (status.toUpperCase().startsWith("R")) {
787 return "Renamed";
788 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000789 if (status.toUpperCase().startsWith("C")) {
790 return "Copied";
791 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000792 return "Modified";
793 }
794 }
795
796 /**
797 * Get changes information (+/-) for display
798 */
799 getChangesInfo(file: GitDiffFile): string {
800 const additions = file.additions || 0;
801 const deletions = file.deletions || 0;
802
803 if (additions === 0 && deletions === 0) {
804 return "";
805 }
806
807 const parts = [];
808 if (additions > 0) {
809 parts.push(`+${additions}`);
810 }
811 if (deletions > 0) {
812 parts.push(`-${deletions}`);
813 }
814
815 return `(${parts.join(", ")})`;
816 }
817
818 /**
819 * Get path information for display, handling renames
820 */
821 getPathInfo(file: GitDiffFile): string {
822 if (file.old_path && file.old_path !== "") {
823 // For renames, show old_path → new_path
824 return `${file.old_path} → ${file.path}`;
825 }
826 // For regular files, just show the path
827 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700828 }
829
830 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000831 * Render expand all icon (dotted line with arrows pointing away)
832 */
833 renderExpandAllIcon() {
834 return html`
835 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
836 <!-- Dotted line in the middle -->
837 <line
838 x1="2"
839 y1="8"
840 x2="14"
841 y2="8"
842 stroke="currentColor"
843 stroke-width="1"
844 stroke-dasharray="2,1"
845 />
846 <!-- Large arrow pointing up -->
847 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
848 <!-- Large arrow pointing down -->
849 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
850 </svg>
851 `;
852 }
853
854 /**
855 * Render collapse icon (arrows pointing towards dotted line)
856 */
857 renderCollapseIcon() {
858 return html`
859 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
860 <!-- Dotted line in the middle -->
861 <line
862 x1="2"
863 y1="8"
864 x2="14"
865 y2="8"
866 stroke="currentColor"
867 stroke-width="1"
868 stroke-dasharray="2,1"
869 />
870 <!-- Large arrow pointing down towards line -->
871 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
872 <!-- Large arrow pointing up towards line -->
873 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
874 </svg>
875 `;
876 }
877
878 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000879 * Handle file selection change from the dropdown
880 */
881 handleFileSelection(event: Event) {
882 const selectElement = event.target as HTMLSelectElement;
883 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +0000884
David Crawshaw4cd01292025-06-15 18:59:13 +0000885 this.selectedFile = selectedValue;
886 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +0000887
David Crawshaw4cd01292025-06-15 18:59:13 +0000888 // Force re-render
889 this.requestUpdate();
890 }
891
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000892 toggleUntrackedFilesPopup() {
893 this.showUntrackedPopup = !this.showUntrackedPopup;
894 }
895
David Crawshaw4cd01292025-06-15 18:59:13 +0000896 /**
897 * Get display name for file in the selector
898 */
899 getFileDisplayName(file: GitDiffFile): string {
900 const status = this.getFileStatusText(file.status);
901 const pathInfo = this.getPathInfo(file);
902 return `${status}: ${pathInfo}`;
903 }
904
905 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700906 * Render expand/collapse button for single file view in header
907 */
908 renderSingleFileExpandButton() {
909 if (!this.selectedFile) return "";
910
911 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
912
913 return html`
914 <button
banksean54505842025-07-03 00:18:44 +0000915 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 -0700916 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
917 title="${isExpanded
918 ? "Collapse: Hide unchanged regions to focus on changes"
919 : "Expand: Show all lines including unchanged regions"}"
920 >
921 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
922 </button>
923 `;
924 }
925
926 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000927 * Render single file view with full-screen Monaco editor
928 */
929 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +0000930 const selectedFileData = this.files.find(
931 (f) => f.path === this.selectedFile,
932 );
David Crawshaw4cd01292025-06-15 18:59:13 +0000933 if (!selectedFileData) {
banksean54505842025-07-03 00:18:44 +0000934 return html`<div class="text-red-600 p-4">Selected file not found</div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000935 }
936
937 const content = this.fileContents.get(this.selectedFile);
938 if (!content) {
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000939 return html`<div class="flex items-center justify-center h-full">
940 Loading ${this.selectedFile}...
941 </div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000942 }
943
944 return html`
banksean54505842025-07-03 00:18:44 +0000945 <div class="flex-1 flex flex-col h-full min-h-0">
David Crawshaw4cd01292025-06-15 18:59:13 +0000946 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000947 class="flex-1 w-full h-full min-h-0"
David Crawshaw4cd01292025-06-15 18:59:13 +0000948 .originalCode="${content.original}"
949 .modifiedCode="${content.modified}"
950 .originalFilename="${selectedFileData.path}"
951 .modifiedFilename="${selectedFileData.path}"
952 ?readOnly="${!content.editable}"
953 ?editable-right="${content.editable}"
954 @monaco-comment="${this.handleMonacoComment}"
955 @monaco-save="${this.handleMonacoSave}"
956 data-file-path="${selectedFileData.path}"
957 ></sketch-monaco-view>
958 </div>
959 `;
960 }
961
962 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700963 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +0000964 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700965 * This is called when the Monaco diff tab is activated to ensure:
966 * 1. Branch information from git/recentlog is current (branches can change frequently)
967 * 2. The diff content is synchronized with the latest repository state
968 * 3. Users always see up-to-date information without manual refresh
969 */
970 refreshDiffView() {
971 // First refresh the range picker to get updated branch information
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700972 const rangePicker = this.querySelector("sketch-diff-range-picker");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700973 if (rangePicker) {
974 (rangePicker as any).loadCommits();
975 }
Autoformatter8c463622025-05-16 21:54:17 +0000976
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700977 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +0000978 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +0000979 this.currentRange = {
980 type: "range",
981 from: `${this.commit}^`,
982 to: this.commit,
983 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700984 }
Autoformatter8c463622025-05-16 21:54:17 +0000985
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700986 // Then reload diff data based on the current range
987 this.loadDiffData();
988 }
989}
990
991declare global {
992 interface HTMLElementTagNameMap {
993 "sketch-diff2-view": SketchDiff2View;
994 }
995}