blob: 8dbf5db80c84c7fbe2640db3a310650452323f51 [file] [log] [blame]
banksean54505842025-07-03 00:18:44 +00001import { html } from "lit";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07002import { customElement, property, state } from "lit/decorators.js";
banksean54505842025-07-03 00:18:44 +00003import { SketchTailwindElement } from "./sketch-tailwind-element.js";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07004import "./sketch-monaco-view";
5import "./sketch-diff-range-picker";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07006import "./sketch-diff-empty-view";
philip.zeyliger26bc6592025-06-30 20:15:30 -07007import { GitDiffFile, GitDataService } from "./git-data-service";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07008import { DiffRange } from "./sketch-diff-range-picker";
9
10/**
11 * A component that displays diffs using Monaco editor with range and file pickers
12 */
13@customElement("sketch-diff2-view")
banksean54505842025-07-03 00:18:44 +000014export class SketchDiff2View extends SketchTailwindElement {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070015 /**
16 * Handles comment events from the Monaco editor and forwards them to the chat input
17 * using the same event format as the original diff view for consistency.
18 */
19 private handleMonacoComment(event: CustomEvent) {
20 try {
21 // Validate incoming data
22 if (!event.detail || !event.detail.formattedComment) {
Autoformatter8c463622025-05-16 21:54:17 +000023 console.error("Invalid comment data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070024 return;
25 }
Autoformatter8c463622025-05-16 21:54:17 +000026
Philip Zeyliger272a90e2025-05-16 14:49:51 -070027 // Create and dispatch event using the standardized format
Autoformatter8c463622025-05-16 21:54:17 +000028 const commentEvent = new CustomEvent("diff-comment", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070029 detail: { comment: event.detail.formattedComment },
30 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +000031 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -070032 });
Autoformatter8c463622025-05-16 21:54:17 +000033
Philip Zeyliger272a90e2025-05-16 14:49:51 -070034 this.dispatchEvent(commentEvent);
35 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +000036 console.error("Error handling Monaco comment:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -070037 }
38 }
Autoformatter8c463622025-05-16 21:54:17 +000039
Philip Zeyliger272a90e2025-05-16 14:49:51 -070040 /**
David Crawshaw26f3f342025-06-14 19:58:32 +000041 * Handle height change events from the Monaco editor
42 */
43 private handleMonacoHeightChange(event: CustomEvent) {
44 try {
45 // Get the monaco view that emitted the event
46 const monacoView = event.target as HTMLElement;
47 if (!monacoView) return;
Autoformatter9abf8032025-06-14 23:24:08 +000048
David Crawshaw255dc432025-07-06 21:58:00 +000049 // Find the parent Monaco editor container (now using Tailwind classes)
Autoformatter9abf8032025-06-14 23:24:08 +000050 const fileDiffEditor = monacoView.closest(
David Crawshaw255dc432025-07-06 21:58:00 +000051 ".flex.flex-col.w-full.min-h-\\[200px\\].flex-1",
Autoformatter9abf8032025-06-14 23:24:08 +000052 ) as HTMLElement;
David Crawshaw26f3f342025-06-14 19:58:32 +000053 if (!fileDiffEditor) return;
Autoformatter9abf8032025-06-14 23:24:08 +000054
David Crawshaw26f3f342025-06-14 19:58:32 +000055 // Get the new height from the event
56 const newHeight = event.detail.height;
Autoformatter9abf8032025-06-14 23:24:08 +000057
David Crawshaw26f3f342025-06-14 19:58:32 +000058 // Only update if the height actually changed to avoid unnecessary layout
59 const currentHeight = fileDiffEditor.style.height;
60 const newHeightStr = `${newHeight}px`;
Autoformatter9abf8032025-06-14 23:24:08 +000061
David Crawshaw26f3f342025-06-14 19:58:32 +000062 if (currentHeight !== newHeightStr) {
David Crawshaw255dc432025-07-06 21:58:00 +000063 // Update the container height to match monaco's height
David Crawshaw26f3f342025-06-14 19:58:32 +000064 fileDiffEditor.style.height = newHeightStr;
Autoformatter9abf8032025-06-14 23:24:08 +000065
David Crawshaw26f3f342025-06-14 19:58:32 +000066 // Remove any previous min-height constraint that might interfere
Autoformatter9abf8032025-06-14 23:24:08 +000067 fileDiffEditor.style.minHeight = "auto";
68
David Crawshaw26f3f342025-06-14 19:58:32 +000069 // IMPORTANT: Tell Monaco to relayout after its container size changed
70 // Monaco has automaticLayout: false, so it won't detect container changes
71 setTimeout(() => {
72 const monacoComponent = monacoView as any;
73 if (monacoComponent && monacoComponent.editor) {
74 // Force layout with explicit dimensions to ensure Monaco fills the space
75 const editorWidth = fileDiffEditor.offsetWidth;
76 monacoComponent.editor.layout({
77 width: editorWidth,
Autoformatter9abf8032025-06-14 23:24:08 +000078 height: newHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +000079 });
80 }
81 }, 0);
82 }
David Crawshaw26f3f342025-06-14 19:58:32 +000083 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +000084 console.error("Error handling Monaco height change:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +000085 }
86 }
87
88 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -070089 * Handle save events from the Monaco editor
90 */
91 private async handleMonacoSave(event: CustomEvent) {
92 try {
93 // Validate incoming data
Autoformatter8c463622025-05-16 21:54:17 +000094 if (
95 !event.detail ||
96 !event.detail.path ||
97 event.detail.content === undefined
98 ) {
99 console.error("Invalid save data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700100 return;
101 }
Autoformatter8c463622025-05-16 21:54:17 +0000102
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700103 const { path, content } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000104
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700105 // Get Monaco view component
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700106 const monacoView = this.querySelector("sketch-monaco-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700107 if (!monacoView) {
Autoformatter8c463622025-05-16 21:54:17 +0000108 console.error("Monaco view not found");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700109 return;
110 }
Autoformatter8c463622025-05-16 21:54:17 +0000111
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700112 try {
113 await this.gitService?.saveFileContent(path, content);
114 console.log(`File saved: ${path}`);
115 (monacoView as any).notifySaveComplete(true);
116 } catch (error) {
Josh Bleecher Snyder7cae9d52025-07-22 23:08:39 +0000117 const errorMessage =
118 error instanceof Error ? error.message : String(error);
119 alert(`Failed to save changes to ${path}:\n\n${errorMessage}`);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700120 (monacoView as any).notifySaveComplete(false);
121 }
122 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000123 console.error("Error handling save:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700124 }
125 }
126 @property({ type: String })
127 initialCommit: string = "";
Autoformatter8c463622025-05-16 21:54:17 +0000128
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700129 // The commit to show - used when showing a specific commit from timeline
130 @property({ type: String })
131 commit: string = "";
132
133 @property({ type: String })
134 selectedFilePath: string = "";
135
136 @state()
137 private files: GitDiffFile[] = [];
Autoformatter8c463622025-05-16 21:54:17 +0000138
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700139 @state()
Autoformatter8c463622025-05-16 21:54:17 +0000140 private currentRange: DiffRange = { type: "range", from: "", to: "HEAD" };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700141
142 @state()
Autoformatter9abf8032025-06-14 23:24:08 +0000143 private fileContents: Map<
144 string,
145 { original: string; modified: string; editable: boolean }
146 > = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700147
148 @state()
David Crawshaw26f3f342025-06-14 19:58:32 +0000149 private fileExpandStates: Map<string, boolean> = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700150
151 @state()
152 private loading: boolean = false;
153
154 @state()
155 private error: string | null = null;
156
David Crawshaw4cd01292025-06-15 18:59:13 +0000157 @state()
158 private selectedFile: string = ""; // Empty string means "All files"
159
160 @state()
161 private viewMode: "all" | "single" = "all";
162
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000163 @state()
164 private untrackedFiles: string[] = [];
165
166 @state()
167 private showUntrackedPopup: boolean = false;
168
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700169 @property({ attribute: false, type: Object })
170 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000171
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700172 // The gitService must be passed from parent to ensure proper dependency injection
173
174 constructor() {
175 super();
Autoformatter8c463622025-05-16 21:54:17 +0000176 console.log("SketchDiff2View initialized");
177
David Crawshawe2954ce2025-06-15 00:06:34 +0000178 // Fix for monaco-aria-container positioning and hide scrollbars globally
179 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000180 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700181 styleElement.textContent = `
182 .monaco-aria-container {
183 position: absolute !important;
184 top: 0 !important;
185 left: 0 !important;
186 width: 1px !important;
187 height: 1px !important;
188 overflow: hidden !important;
189 clip: rect(1px, 1px, 1px, 1px) !important;
190 white-space: nowrap !important;
191 margin: 0 !important;
192 padding: 0 !important;
193 border: 0 !important;
194 z-index: -1 !important;
195 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700196
David Crawshawe2954ce2025-06-15 00:06:34 +0000197 /* Aggressively hide all Monaco scrollbar elements */
198 .monaco-editor .scrollbar,
199 .monaco-editor .scroll-decoration,
200 .monaco-editor .invisible.scrollbar,
201 .monaco-editor .slider,
202 .monaco-editor .vertical.scrollbar,
203 .monaco-editor .horizontal.scrollbar,
204 .monaco-diff-editor .scrollbar,
205 .monaco-diff-editor .scroll-decoration,
206 .monaco-diff-editor .invisible.scrollbar,
207 .monaco-diff-editor .slider,
208 .monaco-diff-editor .vertical.scrollbar,
209 .monaco-diff-editor .horizontal.scrollbar {
210 display: none !important;
211 visibility: hidden !important;
212 width: 0 !important;
213 height: 0 !important;
214 opacity: 0 !important;
215 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700216
David Crawshawe2954ce2025-06-15 00:06:34 +0000217 /* Target the specific scrollbar classes that Monaco uses */
218 .monaco-scrollable-element > .scrollbar,
219 .monaco-scrollable-element > .scroll-decoration,
220 .monaco-scrollable-element .slider {
221 display: none !important;
222 visibility: hidden !important;
223 width: 0 !important;
224 height: 0 !important;
225 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700226
David Crawshawe2954ce2025-06-15 00:06:34 +0000227 /* Remove scrollbar space/padding from content area */
228 .monaco-editor .monaco-scrollable-element,
229 .monaco-diff-editor .monaco-scrollable-element {
230 padding-right: 0 !important;
231 padding-bottom: 0 !important;
232 margin-right: 0 !important;
233 margin-bottom: 0 !important;
234 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700235
David Crawshawe2954ce2025-06-15 00:06:34 +0000236 /* Ensure the diff content takes full width without scrollbar space */
237 .monaco-diff-editor .editor.modified,
238 .monaco-diff-editor .editor.original {
239 margin-right: 0 !important;
240 padding-right: 0 !important;
241 }
Sean McCulloughdf234032025-07-02 20:45:29 -0700242 `;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700243 document.head.appendChild(styleElement);
244 }
245
Sean McCulloughdf234032025-07-02 20:45:29 -0700246 // Override createRenderRoot to apply host styles for proper sizing while still using light DOM
247 createRenderRoot() {
248 // Use light DOM like SketchTailwindElement but still apply host styles
249 const style = document.createElement("style");
250 style.textContent = `
251 sketch-diff2-view {
252 height: -webkit-fill-available;
253 }
254 `;
255
256 // Add the style to the document head if not already present
257 if (!document.head.querySelector("style[data-sketch-diff2-view]")) {
258 style.setAttribute("data-sketch-diff2-view", "");
259 document.head.appendChild(style);
260 }
261
262 return this;
263 }
264
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700265 connectedCallback() {
266 super.connectedCallback();
267 // Initialize with default range and load data
268 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000269 if (
270 this.currentRange.type === "range" &&
271 !("from" in this.currentRange && this.currentRange.from)
272 ) {
273 this.gitService
274 .getBaseCommitRef()
275 .then((baseRef) => {
276 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
277 this.loadDiffData();
278 })
279 .catch((error) => {
280 console.error("Error getting base commit ref:", error);
281 // Use default range
282 this.loadDiffData();
283 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700284 } else {
285 this.loadDiffData();
286 }
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000287
288 // Add click listener to close popup when clicking outside
289 document.addEventListener("click", this.handleDocumentClick.bind(this));
290 }
291
292 disconnectedCallback() {
293 super.disconnectedCallback();
294 document.removeEventListener("click", this.handleDocumentClick.bind(this));
295 }
296
297 handleDocumentClick(event: Event) {
298 if (this.showUntrackedPopup) {
299 const target = event.target as HTMLElement;
300 // Check if click is outside the popup and button
301 if (!target.closest(".relative")) {
302 this.showUntrackedPopup = false;
303 }
304 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700305 }
306
David Crawshaw26f3f342025-06-14 19:58:32 +0000307 // Toggle hideUnchangedRegions setting for a specific file
308 private toggleFileExpansion(filePath: string) {
309 const currentState = this.fileExpandStates.get(filePath) ?? false;
310 const newState = !currentState;
311 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000312
David Crawshaw26f3f342025-06-14 19:58:32 +0000313 // Apply to the specific Monaco view component for this file
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700314 const monacoView = this.querySelector(
Autoformatter9abf8032025-06-14 23:24:08 +0000315 `sketch-monaco-view[data-file-path="${filePath}"]`,
316 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700317 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000318 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700319 }
Autoformatter9abf8032025-06-14 23:24:08 +0000320
David Crawshaw26f3f342025-06-14 19:58:32 +0000321 // Force a re-render to update the button state
322 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700323 }
Autoformatter8c463622025-05-16 21:54:17 +0000324
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700325 render() {
326 return html`
David Crawshaw255dc432025-07-06 21:58:00 +0000327 <div class="px-4 py-2 border-b border-gray-300 bg-gray-100 flex-shrink-0">
328 <div class="flex flex-col gap-3">
329 <div class="w-full flex items-center gap-3">
330 <sketch-diff-range-picker
331 class="flex-1 min-w-[400px]"
332 .gitService="${this.gitService}"
333 @range-change="${this.handleRangeChange}"
334 ></sketch-diff-range-picker>
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000335 ${this.renderUntrackedFilesNotification()}
David Crawshaw255dc432025-07-06 21:58:00 +0000336 <div class="flex-1"></div>
337 ${this.renderFileSelector()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700338 </div>
339 </div>
David Crawshaw255dc432025-07-06 21:58:00 +0000340 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700341
David Crawshaw255dc432025-07-06 21:58:00 +0000342 <div class="flex-1 overflow-auto flex flex-col min-h-0 relative h-full">
343 ${this.renderDiffContent()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700344 </div>
345 `;
346 }
347
David Crawshaw4cd01292025-06-15 18:59:13 +0000348 renderFileSelector() {
David Crawshaw5c6d8292025-06-15 19:09:19 +0000349 const fileCount = this.files.length;
Autoformatter62554112025-06-15 19:23:33 +0000350
David Crawshaw4cd01292025-06-15 18:59:13 +0000351 return html`
banksean54505842025-07-03 00:18:44 +0000352 <div class="flex items-center gap-2">
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700353 <select
banksean54505842025-07-03 00:18:44 +0000354 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 -0700355 .value="${this.selectedFile}"
356 @change="${this.handleFileSelection}"
357 ?disabled="${fileCount === 0}"
358 >
359 <option value="">All files (${fileCount})</option>
360 ${this.files.map(
361 (file) => html`
362 <option value="${file.path}">
363 ${this.getFileDisplayName(file)}
364 </option>
365 `,
366 )}
367 </select>
368 ${this.selectedFile ? this.renderSingleFileExpandButton() : ""}
369 </div>
David Crawshaw4cd01292025-06-15 18:59:13 +0000370 `;
371 }
372
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000373 renderUntrackedFilesNotification() {
374 if (!this.untrackedFiles || this.untrackedFiles.length === 0) {
375 return "";
376 }
377
378 const fileCount = this.untrackedFiles.length;
379 const fileCountText =
380 fileCount === 1 ? "1 untracked file" : `${fileCount} untracked files`;
381
382 return html`
383 <div class="relative">
384 <button
385 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"
386 @click="${this.toggleUntrackedFilesPopup}"
387 type="button"
388 >
389 ${fileCount} untracked
390 <svg
391 class="w-4 h-4"
392 fill="none"
393 stroke="currentColor"
394 viewBox="0 0 24 24"
395 >
396 <path
397 stroke-linecap="round"
398 stroke-linejoin="round"
399 stroke-width="2"
400 d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
401 />
402 </svg>
403 </button>
404
405 ${this.showUntrackedPopup
406 ? html`
407 <div
408 class="absolute top-full left-0 mt-2 w-80 bg-white border border-gray-300 rounded-lg shadow-lg z-50"
409 >
410 <div class="p-4">
411 <div class="flex items-start gap-3 mb-3">
412 <svg
413 class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5"
414 fill="none"
415 stroke="currentColor"
416 viewBox="0 0 24 24"
417 >
418 <path
419 stroke-linecap="round"
420 stroke-linejoin="round"
421 stroke-width="2"
422 d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
423 />
424 </svg>
425 <div class="flex-1">
426 <div class="font-medium text-gray-900 mb-1">
427 ${fileCountText}
428 </div>
429 <div class="text-sm text-gray-600 mb-3">
Autoformatter390f8472025-07-16 20:09:45 +0000430 These files are not tracked by git. They will be lost if
431 the session ends now. The agent typically does not add
432 files to git until it is ready for feedback.
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000433 </div>
434 </div>
435 </div>
436
437 <div class="max-h-32 overflow-y-auto">
438 <div class="text-sm text-gray-700">
439 ${this.untrackedFiles.map(
440 (file) => html`
441 <div
442 class="py-1 px-2 hover:bg-gray-100 rounded font-mono text-xs"
443 >
444 ${file}
445 </div>
446 `,
447 )}
448 </div>
449 </div>
450 </div>
451 </div>
452 `
453 : ""}
454 </div>
455 `;
456 }
457
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700458 renderDiffContent() {
459 if (this.loading) {
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000460 return html`<div class="flex items-center justify-center h-full">
461 Loading diff...
462 </div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700463 }
464
465 if (this.error) {
banksean54505842025-07-03 00:18:44 +0000466 return html`<div class="text-red-600 p-4">${this.error}</div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700467 }
468
469 if (this.files.length === 0) {
470 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
471 }
Autoformatter8c463622025-05-16 21:54:17 +0000472
David Crawshaw4cd01292025-06-15 18:59:13 +0000473 // Render single file view if a specific file is selected
474 if (this.selectedFile && this.viewMode === "single") {
475 return this.renderSingleFileView();
476 }
477
478 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700479 return html`
banksean54505842025-07-03 00:18:44 +0000480 <div class="flex flex-col w-full min-h-full">
David Crawshaw26f3f342025-06-14 19:58:32 +0000481 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
482 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700483 `;
484 }
485
486 /**
487 * Load diff data for the current range
488 */
489 async loadDiffData() {
490 this.loading = true;
491 this.error = null;
492
493 try {
494 // Initialize files as empty array if undefined
495 if (!this.files) {
496 this.files = [];
497 }
498
David Crawshaw216d2fc2025-06-15 18:45:53 +0000499 // Load diff data for the range
500 this.files = await this.gitService.getDiff(
501 this.currentRange.from,
502 this.currentRange.to,
503 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700504
505 // Ensure files is always an array, even when API returns null
506 if (!this.files) {
507 this.files = [];
508 }
Autoformatter8c463622025-05-16 21:54:17 +0000509
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000510 // Load untracked files for notification
511 try {
512 this.untrackedFiles = await this.gitService.getUntrackedFiles();
513 } catch (error) {
514 console.error("Error loading untracked files:", error);
515 this.untrackedFiles = [];
516 }
517
David Crawshaw26f3f342025-06-14 19:58:32 +0000518 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700519 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000520 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000521 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000522 if (!this.fileExpandStates.has(file.path)) {
523 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
524 }
525 });
526 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700527 } else {
528 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000529 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000530 this.selectedFile = "";
531 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000532 this.fileContents.clear();
533 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700534 }
535 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000536 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700537 this.error = `Error loading diff data: ${error.message}`;
538 // Ensure files is an empty array when an error occurs
539 this.files = [];
540 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000541 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000542 this.selectedFile = "";
543 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000544 this.fileContents.clear();
545 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700546 } finally {
547 this.loading = false;
Josh Bleecher Snyderd37f7a72025-07-22 13:43:52 +0000548 // Request a re-render.
549 // Empirically, without this line, diffs are visibly slow to load.
550 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700551 }
552 }
553
554 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000555 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700556 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000557 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700558 this.loading = true;
559 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000560 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700561
562 try {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700563 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000564
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700565 // Determine the commits to compare based on the current range
philip.zeyliger26bc6592025-06-30 20:15:30 -0700566 const _fromCommit = this.currentRange.from;
567 const toCommit = this.currentRange.to;
David Crawshaw216d2fc2025-06-15 18:45:53 +0000568 // Check if this is an unstaged changes view
569 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700570
David Crawshaw26f3f342025-06-14 19:58:32 +0000571 // Load content for all files
572 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700573 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000574 let originalCode = "";
575 let modifiedCode = "";
576 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000577
David Crawshaw26f3f342025-06-14 19:58:32 +0000578 // Load the original code based on file status
579 if (file.status !== "A") {
580 // For modified, renamed, or deleted files: load original content
581 originalCode = await this.gitService.getFileContent(
582 file.old_hash || "",
583 );
584 }
585
586 // For modified code, always use working copy when editable
587 if (editable) {
588 try {
589 // Always use working copy when editable, regardless of diff status
590 modifiedCode = await this.gitService.getWorkingCopyContent(
591 file.path,
592 );
593 } catch (error) {
594 if (file.status === "D") {
595 // For deleted files, silently use empty content
596 console.warn(
597 `Could not get working copy for deleted file ${file.path}, using empty content`,
598 );
599 modifiedCode = "";
600 } else {
601 // For any other file status, propagate the error
602 console.error(
603 `Failed to get working copy for ${file.path}:`,
604 error,
605 );
606 throw error;
607 }
608 }
609 } else {
610 // For non-editable view, use git content based on file status
611 if (file.status === "D") {
612 // Deleted file: empty modified
613 modifiedCode = "";
614 } else {
615 // Added/modified/renamed: use the content from git
616 modifiedCode = await this.gitService.getFileContent(
617 file.new_hash || "",
618 );
619 }
620 }
621
622 // Don't make deleted files editable
623 if (file.status === "D") {
624 editable = false;
625 }
626
627 this.fileContents.set(file.path, {
628 original: originalCode,
629 modified: modifiedCode,
630 editable,
631 });
632 } catch (error) {
633 console.error(`Error loading content for file ${file.path}:`, error);
634 // Store empty content for failed files to prevent blocking
635 this.fileContents.set(file.path, {
636 original: "",
637 modified: "",
638 editable: false,
639 });
640 }
641 });
642
643 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700644 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000645 console.error("Error loading file contents:", error);
646 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700647 } finally {
648 this.loading = false;
649 }
650 }
651
652 /**
653 * Handle range change event from the range picker
654 */
655 handleRangeChange(event: CustomEvent) {
656 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000657 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700658 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000659
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700660 // Load diff data for the new range
661 this.loadDiffData();
662 }
663
664 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000665 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700666 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000667 renderFileDiff(file: GitDiffFile, index: number) {
668 const content = this.fileContents.get(file.path);
669 if (!content) {
670 return html`
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000671 <div
672 class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0"
673 >
674 <div
675 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"
676 >
677 ${this.renderFileHeader(file)}
678 </div>
679 <div class="flex items-center justify-center h-full">
680 Loading ${file.path}...
681 </div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000682 </div>
683 `;
684 }
685
686 return html`
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000687 <div
688 class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0"
689 >
690 <div
691 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"
692 >
693 ${this.renderFileHeader(file)}
694 </div>
David Crawshaw255dc432025-07-06 21:58:00 +0000695 <div class="flex flex-col w-full min-h-[200px] flex-1">
David Crawshaw26f3f342025-06-14 19:58:32 +0000696 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000697 class="flex flex-col w-full min-h-[200px] flex-1"
David Crawshaw26f3f342025-06-14 19:58:32 +0000698 .originalCode="${content.original}"
699 .modifiedCode="${content.modified}"
700 .originalFilename="${file.path}"
701 .modifiedFilename="${file.path}"
702 ?readOnly="${!content.editable}"
703 ?editable-right="${content.editable}"
704 @monaco-comment="${this.handleMonacoComment}"
705 @monaco-save="${this.handleMonacoSave}"
706 @monaco-height-changed="${this.handleMonacoHeightChange}"
707 data-file-index="${index}"
708 data-file-path="${file.path}"
709 ></sketch-monaco-view>
710 </div>
711 </div>
712 `;
713 }
714
715 /**
716 * Render file header with status and path info
717 */
718 renderFileHeader(file: GitDiffFile) {
banksean54505842025-07-03 00:18:44 +0000719 const statusClasses = this.getFileStatusTailwindClasses(file.status);
David Crawshaw26f3f342025-06-14 19:58:32 +0000720 const statusText = this.getFileStatusText(file.status);
721 const changesInfo = this.getChangesInfo(file);
722 const pathInfo = this.getPathInfo(file);
723
724 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000725
David Crawshaw26f3f342025-06-14 19:58:32 +0000726 return html`
banksean54505842025-07-03 00:18:44 +0000727 <div class="flex items-center gap-2">
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000728 <span
729 class="inline-block px-1.5 py-0.5 rounded text-xs font-bold mr-2 ${statusClasses}"
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000730 >
David Crawshaw255dc432025-07-06 21:58:00 +0000731 ${statusText}
732 </span>
banksean54505842025-07-03 00:18:44 +0000733 <span class="font-mono font-normal text-gray-600">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000734 ${changesInfo
banksean54505842025-07-03 00:18:44 +0000735 ? html`<span class="ml-2 text-xs text-gray-600">${changesInfo}</span>`
Autoformatter9abf8032025-06-14 23:24:08 +0000736 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000737 </div>
banksean54505842025-07-03 00:18:44 +0000738 <div class="flex items-center">
David Crawshaw26f3f342025-06-14 19:58:32 +0000739 <button
banksean54505842025-07-03 00:18:44 +0000740 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 +0000741 @click="${() => this.toggleFileExpansion(file.path)}"
742 title="${isExpanded
743 ? "Collapse: Hide unchanged regions to focus on changes"
744 : "Expand: Show all lines including unchanged regions"}"
745 >
Autoformatter9abf8032025-06-14 23:24:08 +0000746 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000747 </button>
748 </div>
749 `;
750 }
751
752 /**
banksean54505842025-07-03 00:18:44 +0000753 * Get Tailwind CSS classes for file status
David Crawshaw26f3f342025-06-14 19:58:32 +0000754 */
banksean54505842025-07-03 00:18:44 +0000755 getFileStatusTailwindClasses(status: string): string {
David Crawshaw26f3f342025-06-14 19:58:32 +0000756 switch (status.toUpperCase()) {
757 case "A":
banksean54505842025-07-03 00:18:44 +0000758 return "bg-green-100 text-green-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000759 case "M":
banksean54505842025-07-03 00:18:44 +0000760 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000761 case "D":
banksean54505842025-07-03 00:18:44 +0000762 return "bg-red-100 text-red-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000763 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000764 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000765 default:
766 if (status.toUpperCase().startsWith("R")) {
banksean54505842025-07-03 00:18:44 +0000767 return "bg-cyan-100 text-cyan-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000768 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000769 if (status.toUpperCase().startsWith("C")) {
banksean54505842025-07-03 00:18:44 +0000770 return "bg-indigo-100 text-indigo-800";
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000771 }
banksean54505842025-07-03 00:18:44 +0000772 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000773 }
774 }
775
776 /**
777 * Get display text for file status
778 */
779 getFileStatusText(status: string): string {
780 switch (status.toUpperCase()) {
781 case "A":
782 return "Added";
783 case "M":
784 return "Modified";
785 case "D":
786 return "Deleted";
787 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000788 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000789 default:
790 if (status.toUpperCase().startsWith("R")) {
791 return "Renamed";
792 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000793 if (status.toUpperCase().startsWith("C")) {
794 return "Copied";
795 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000796 return "Modified";
797 }
798 }
799
800 /**
801 * Get changes information (+/-) for display
802 */
803 getChangesInfo(file: GitDiffFile): string {
804 const additions = file.additions || 0;
805 const deletions = file.deletions || 0;
806
807 if (additions === 0 && deletions === 0) {
808 return "";
809 }
810
811 const parts = [];
812 if (additions > 0) {
813 parts.push(`+${additions}`);
814 }
815 if (deletions > 0) {
816 parts.push(`-${deletions}`);
817 }
818
819 return `(${parts.join(", ")})`;
820 }
821
822 /**
823 * Get path information for display, handling renames
824 */
825 getPathInfo(file: GitDiffFile): string {
826 if (file.old_path && file.old_path !== "") {
827 // For renames, show old_path → new_path
828 return `${file.old_path} → ${file.path}`;
829 }
830 // For regular files, just show the path
831 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700832 }
833
834 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000835 * Render expand all icon (dotted line with arrows pointing away)
836 */
837 renderExpandAllIcon() {
838 return html`
839 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
840 <!-- Dotted line in the middle -->
841 <line
842 x1="2"
843 y1="8"
844 x2="14"
845 y2="8"
846 stroke="currentColor"
847 stroke-width="1"
848 stroke-dasharray="2,1"
849 />
850 <!-- Large arrow pointing up -->
851 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
852 <!-- Large arrow pointing down -->
853 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
854 </svg>
855 `;
856 }
857
858 /**
859 * Render collapse icon (arrows pointing towards dotted line)
860 */
861 renderCollapseIcon() {
862 return html`
863 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
864 <!-- Dotted line in the middle -->
865 <line
866 x1="2"
867 y1="8"
868 x2="14"
869 y2="8"
870 stroke="currentColor"
871 stroke-width="1"
872 stroke-dasharray="2,1"
873 />
874 <!-- Large arrow pointing down towards line -->
875 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
876 <!-- Large arrow pointing up towards line -->
877 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
878 </svg>
879 `;
880 }
881
882 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000883 * Handle file selection change from the dropdown
884 */
885 handleFileSelection(event: Event) {
886 const selectElement = event.target as HTMLSelectElement;
887 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +0000888
David Crawshaw4cd01292025-06-15 18:59:13 +0000889 this.selectedFile = selectedValue;
890 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +0000891
David Crawshaw4cd01292025-06-15 18:59:13 +0000892 // Force re-render
893 this.requestUpdate();
894 }
895
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000896 toggleUntrackedFilesPopup() {
897 this.showUntrackedPopup = !this.showUntrackedPopup;
898 }
899
David Crawshaw4cd01292025-06-15 18:59:13 +0000900 /**
901 * Get display name for file in the selector
902 */
903 getFileDisplayName(file: GitDiffFile): string {
904 const status = this.getFileStatusText(file.status);
905 const pathInfo = this.getPathInfo(file);
906 return `${status}: ${pathInfo}`;
907 }
908
909 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700910 * Render expand/collapse button for single file view in header
911 */
912 renderSingleFileExpandButton() {
913 if (!this.selectedFile) return "";
914
915 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
916
917 return html`
918 <button
banksean54505842025-07-03 00:18:44 +0000919 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 -0700920 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
921 title="${isExpanded
922 ? "Collapse: Hide unchanged regions to focus on changes"
923 : "Expand: Show all lines including unchanged regions"}"
924 >
925 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
926 </button>
927 `;
928 }
929
930 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000931 * Render single file view with full-screen Monaco editor
932 */
933 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +0000934 const selectedFileData = this.files.find(
935 (f) => f.path === this.selectedFile,
936 );
David Crawshaw4cd01292025-06-15 18:59:13 +0000937 if (!selectedFileData) {
banksean54505842025-07-03 00:18:44 +0000938 return html`<div class="text-red-600 p-4">Selected file not found</div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000939 }
940
941 const content = this.fileContents.get(this.selectedFile);
942 if (!content) {
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000943 return html`<div class="flex items-center justify-center h-full">
944 Loading ${this.selectedFile}...
945 </div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000946 }
947
948 return html`
banksean54505842025-07-03 00:18:44 +0000949 <div class="flex-1 flex flex-col h-full min-h-0">
David Crawshaw4cd01292025-06-15 18:59:13 +0000950 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000951 class="flex-1 w-full h-full min-h-0"
David Crawshaw4cd01292025-06-15 18:59:13 +0000952 .originalCode="${content.original}"
953 .modifiedCode="${content.modified}"
954 .originalFilename="${selectedFileData.path}"
955 .modifiedFilename="${selectedFileData.path}"
956 ?readOnly="${!content.editable}"
957 ?editable-right="${content.editable}"
958 @monaco-comment="${this.handleMonacoComment}"
959 @monaco-save="${this.handleMonacoSave}"
960 data-file-path="${selectedFileData.path}"
961 ></sketch-monaco-view>
962 </div>
963 `;
964 }
965
966 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700967 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +0000968 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700969 * This is called when the Monaco diff tab is activated to ensure:
970 * 1. Branch information from git/recentlog is current (branches can change frequently)
971 * 2. The diff content is synchronized with the latest repository state
972 * 3. Users always see up-to-date information without manual refresh
973 */
974 refreshDiffView() {
975 // First refresh the range picker to get updated branch information
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700976 const rangePicker = this.querySelector("sketch-diff-range-picker");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700977 if (rangePicker) {
978 (rangePicker as any).loadCommits();
979 }
Autoformatter8c463622025-05-16 21:54:17 +0000980
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700981 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +0000982 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +0000983 this.currentRange = {
984 type: "range",
985 from: `${this.commit}^`,
986 to: this.commit,
987 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700988 }
Autoformatter8c463622025-05-16 21:54:17 +0000989
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700990 // Then reload diff data based on the current range
991 this.loadDiffData();
992 }
993}
994
995declare global {
996 interface HTMLElementTagNameMap {
997 "sketch-diff2-view": SketchDiff2View;
998 }
999}