blob: c963e6def738714fb5a3d4044e68390fa8ec27c5 [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 Crawshaw26f3f342025-06-14 19:58:32 +000050 // Find the parent file-diff-editor container
Autoformatter9abf8032025-06-14 23:24:08 +000051 const fileDiffEditor = monacoView.closest(
52 ".file-diff-editor",
53 ) 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) {
64 // Update the file-diff-editor height to match monaco's height
65 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
Autoformatter8c463622025-05-16 21:54:17 +0000107 const monacoView = this.shadowRoot?.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
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700164
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700165
166 @property({ attribute: false, type: Object })
167 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000168
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700169 // The gitService must be passed from parent to ensure proper dependency injection
170
171 constructor() {
172 super();
Autoformatter8c463622025-05-16 21:54:17 +0000173 console.log("SketchDiff2View initialized");
174
David Crawshawe2954ce2025-06-15 00:06:34 +0000175 // Fix for monaco-aria-container positioning and hide scrollbars globally
176 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000177 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700178 styleElement.textContent = `
179 .monaco-aria-container {
180 position: absolute !important;
181 top: 0 !important;
182 left: 0 !important;
183 width: 1px !important;
184 height: 1px !important;
185 overflow: hidden !important;
186 clip: rect(1px, 1px, 1px, 1px) !important;
187 white-space: nowrap !important;
188 margin: 0 !important;
189 padding: 0 !important;
190 border: 0 !important;
191 z-index: -1 !important;
192 }
David Crawshawe2954ce2025-06-15 00:06:34 +0000193
194 /* Aggressively hide all Monaco scrollbar elements */
195 .monaco-editor .scrollbar,
196 .monaco-editor .scroll-decoration,
197 .monaco-editor .invisible.scrollbar,
198 .monaco-editor .slider,
199 .monaco-editor .vertical.scrollbar,
200 .monaco-editor .horizontal.scrollbar,
201 .monaco-diff-editor .scrollbar,
202 .monaco-diff-editor .scroll-decoration,
203 .monaco-diff-editor .invisible.scrollbar,
204 .monaco-diff-editor .slider,
205 .monaco-diff-editor .vertical.scrollbar,
206 .monaco-diff-editor .horizontal.scrollbar {
207 display: none !important;
208 visibility: hidden !important;
209 width: 0 !important;
210 height: 0 !important;
211 opacity: 0 !important;
212 }
213
214 /* Target the specific scrollbar classes that Monaco uses */
215 .monaco-scrollable-element > .scrollbar,
216 .monaco-scrollable-element > .scroll-decoration,
217 .monaco-scrollable-element .slider {
218 display: none !important;
219 visibility: hidden !important;
220 width: 0 !important;
221 height: 0 !important;
222 }
223
224 /* Remove scrollbar space/padding from content area */
225 .monaco-editor .monaco-scrollable-element,
226 .monaco-diff-editor .monaco-scrollable-element {
227 padding-right: 0 !important;
228 padding-bottom: 0 !important;
229 margin-right: 0 !important;
230 margin-bottom: 0 !important;
231 }
232
233 /* Ensure the diff content takes full width without scrollbar space */
234 .monaco-diff-editor .editor.modified,
235 .monaco-diff-editor .editor.original {
236 margin-right: 0 !important;
237 padding-right: 0 !important;
238 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700239 `;
240 document.head.appendChild(styleElement);
241 }
242
243 connectedCallback() {
244 super.connectedCallback();
245 // Initialize with default range and load data
246 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000247 if (
248 this.currentRange.type === "range" &&
249 !("from" in this.currentRange && this.currentRange.from)
250 ) {
251 this.gitService
252 .getBaseCommitRef()
253 .then((baseRef) => {
254 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
255 this.loadDiffData();
256 })
257 .catch((error) => {
258 console.error("Error getting base commit ref:", error);
259 // Use default range
260 this.loadDiffData();
261 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700262 } else {
263 this.loadDiffData();
264 }
265 }
266
David Crawshaw26f3f342025-06-14 19:58:32 +0000267 // Toggle hideUnchangedRegions setting for a specific file
268 private toggleFileExpansion(filePath: string) {
269 const currentState = this.fileExpandStates.get(filePath) ?? false;
270 const newState = !currentState;
271 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000272
David Crawshaw26f3f342025-06-14 19:58:32 +0000273 // Apply to the specific Monaco view component for this file
Autoformatter9abf8032025-06-14 23:24:08 +0000274 const monacoView = this.shadowRoot?.querySelector(
275 `sketch-monaco-view[data-file-path="${filePath}"]`,
276 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700277 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000278 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700279 }
Autoformatter9abf8032025-06-14 23:24:08 +0000280
David Crawshaw26f3f342025-06-14 19:58:32 +0000281 // Force a re-render to update the button state
282 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700283 }
Autoformatter8c463622025-05-16 21:54:17 +0000284
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700285 render() {
286 return html`
banksean54505842025-07-03 00:18:44 +0000287 <div class="flex h-full flex-1 flex-col min-h-0 overflow-hidden relative">
288 <div class="px-4 py-2 border-b border-gray-300 bg-gray-100 flex-shrink-0">
289 <div class="flex flex-col gap-3">
290 <div class="w-full flex items-center gap-3">
291 <sketch-diff-range-picker
292 class="flex-1 min-w-[400px]"
293 .gitService="${this.gitService}"
294 @range-change="${this.handleRangeChange}"
295 ></sketch-diff-range-picker>
296 <div class="flex-1"></div>
297 ${this.renderFileSelector()}
298 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700299 </div>
300 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700301
banksean54505842025-07-03 00:18:44 +0000302 <div class="flex-1 overflow-auto flex flex-col min-h-0 relative h-full">
303 <div class="flex-1 overflow-auto min-h-0 flex flex-col relative h-full">${this.renderDiffContent()}</div>
304 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700305 </div>
306 `;
307 }
308
David Crawshaw4cd01292025-06-15 18:59:13 +0000309 renderFileSelector() {
David Crawshaw5c6d8292025-06-15 19:09:19 +0000310 const fileCount = this.files.length;
Autoformatter62554112025-06-15 19:23:33 +0000311
David Crawshaw4cd01292025-06-15 18:59:13 +0000312 return html`
banksean54505842025-07-03 00:18:44 +0000313 <div class="flex items-center gap-2">
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700314 <select
banksean54505842025-07-03 00:18:44 +0000315 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 -0700316 .value="${this.selectedFile}"
317 @change="${this.handleFileSelection}"
318 ?disabled="${fileCount === 0}"
319 >
320 <option value="">All files (${fileCount})</option>
321 ${this.files.map(
322 (file) => html`
323 <option value="${file.path}">
324 ${this.getFileDisplayName(file)}
325 </option>
326 `,
327 )}
328 </select>
329 ${this.selectedFile ? this.renderSingleFileExpandButton() : ""}
330 </div>
David Crawshaw4cd01292025-06-15 18:59:13 +0000331 `;
332 }
333
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700334 renderDiffContent() {
335 if (this.loading) {
banksean54505842025-07-03 00:18:44 +0000336 return html`<div class="flex items-center justify-center h-full">Loading diff...</div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700337 }
338
339 if (this.error) {
banksean54505842025-07-03 00:18:44 +0000340 return html`<div class="text-red-600 p-4">${this.error}</div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700341 }
342
343 if (this.files.length === 0) {
344 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
345 }
Autoformatter8c463622025-05-16 21:54:17 +0000346
David Crawshaw4cd01292025-06-15 18:59:13 +0000347 // Render single file view if a specific file is selected
348 if (this.selectedFile && this.viewMode === "single") {
349 return this.renderSingleFileView();
350 }
351
352 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700353 return html`
banksean54505842025-07-03 00:18:44 +0000354 <div class="flex flex-col w-full min-h-full">
David Crawshaw26f3f342025-06-14 19:58:32 +0000355 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
356 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700357 `;
358 }
359
360 /**
361 * Load diff data for the current range
362 */
363 async loadDiffData() {
364 this.loading = true;
365 this.error = null;
366
367 try {
368 // Initialize files as empty array if undefined
369 if (!this.files) {
370 this.files = [];
371 }
372
David Crawshaw216d2fc2025-06-15 18:45:53 +0000373 // Load diff data for the range
374 this.files = await this.gitService.getDiff(
375 this.currentRange.from,
376 this.currentRange.to,
377 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700378
379 // Ensure files is always an array, even when API returns null
380 if (!this.files) {
381 this.files = [];
382 }
Autoformatter8c463622025-05-16 21:54:17 +0000383
David Crawshaw26f3f342025-06-14 19:58:32 +0000384 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700385 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000386 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000387 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000388 if (!this.fileExpandStates.has(file.path)) {
389 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
390 }
391 });
392 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700393 } else {
394 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000395 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000396 this.selectedFile = "";
397 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000398 this.fileContents.clear();
399 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700400 }
401 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000402 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700403 this.error = `Error loading diff data: ${error.message}`;
404 // Ensure files is an empty array when an error occurs
405 this.files = [];
406 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000407 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000408 this.selectedFile = "";
409 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000410 this.fileContents.clear();
411 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700412 } finally {
413 this.loading = false;
414 }
415 }
416
417 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000418 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700419 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000420 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700421 this.loading = true;
422 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000423 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700424
425 try {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700426 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000427
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700428 // Determine the commits to compare based on the current range
philip.zeyliger26bc6592025-06-30 20:15:30 -0700429 const _fromCommit = this.currentRange.from;
430 const toCommit = this.currentRange.to;
David Crawshaw216d2fc2025-06-15 18:45:53 +0000431 // Check if this is an unstaged changes view
432 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700433
David Crawshaw26f3f342025-06-14 19:58:32 +0000434 // Load content for all files
435 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700436 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000437 let originalCode = "";
438 let modifiedCode = "";
439 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000440
David Crawshaw26f3f342025-06-14 19:58:32 +0000441 // Load the original code based on file status
442 if (file.status !== "A") {
443 // For modified, renamed, or deleted files: load original content
444 originalCode = await this.gitService.getFileContent(
445 file.old_hash || "",
446 );
447 }
448
449 // For modified code, always use working copy when editable
450 if (editable) {
451 try {
452 // Always use working copy when editable, regardless of diff status
453 modifiedCode = await this.gitService.getWorkingCopyContent(
454 file.path,
455 );
456 } catch (error) {
457 if (file.status === "D") {
458 // For deleted files, silently use empty content
459 console.warn(
460 `Could not get working copy for deleted file ${file.path}, using empty content`,
461 );
462 modifiedCode = "";
463 } else {
464 // For any other file status, propagate the error
465 console.error(
466 `Failed to get working copy for ${file.path}:`,
467 error,
468 );
469 throw error;
470 }
471 }
472 } else {
473 // For non-editable view, use git content based on file status
474 if (file.status === "D") {
475 // Deleted file: empty modified
476 modifiedCode = "";
477 } else {
478 // Added/modified/renamed: use the content from git
479 modifiedCode = await this.gitService.getFileContent(
480 file.new_hash || "",
481 );
482 }
483 }
484
485 // Don't make deleted files editable
486 if (file.status === "D") {
487 editable = false;
488 }
489
490 this.fileContents.set(file.path, {
491 original: originalCode,
492 modified: modifiedCode,
493 editable,
494 });
495 } catch (error) {
496 console.error(`Error loading content for file ${file.path}:`, error);
497 // Store empty content for failed files to prevent blocking
498 this.fileContents.set(file.path, {
499 original: "",
500 modified: "",
501 editable: false,
502 });
503 }
504 });
505
506 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700507 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000508 console.error("Error loading file contents:", error);
509 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700510 } finally {
511 this.loading = false;
512 }
513 }
514
515 /**
516 * Handle range change event from the range picker
517 */
518 handleRangeChange(event: CustomEvent) {
519 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000520 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700521 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000522
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700523 // Load diff data for the new range
524 this.loadDiffData();
525 }
526
527 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000528 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700529 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000530 renderFileDiff(file: GitDiffFile, index: number) {
531 const content = this.fileContents.get(file.path);
532 if (!content) {
533 return html`
banksean54505842025-07-03 00:18:44 +0000534 <div class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0">
535 <div class="bg-gray-100 border-b border-gray-300 px-4 py-2 font-medium text-sm text-gray-800 sticky top-0 z-10 shadow-sm flex justify-between items-center">${this.renderFileHeader(file)}</div>
536 <div class="flex items-center justify-center h-full">Loading ${file.path}...</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000537 </div>
538 `;
539 }
540
541 return html`
banksean54505842025-07-03 00:18:44 +0000542 <div class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0">
543 <div class="bg-gray-100 border-b border-gray-300 px-4 py-2 font-medium text-sm text-gray-800 sticky top-0 z-10 shadow-sm flex justify-between items-center">${this.renderFileHeader(file)}</div>
544 <div class="flex flex-col min-h-[200px] overflow-visible">
David Crawshaw26f3f342025-06-14 19:58:32 +0000545 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000546 class="flex flex-col w-full min-h-[200px] flex-1"
David Crawshaw26f3f342025-06-14 19:58:32 +0000547 .originalCode="${content.original}"
548 .modifiedCode="${content.modified}"
549 .originalFilename="${file.path}"
550 .modifiedFilename="${file.path}"
551 ?readOnly="${!content.editable}"
552 ?editable-right="${content.editable}"
553 @monaco-comment="${this.handleMonacoComment}"
554 @monaco-save="${this.handleMonacoSave}"
555 @monaco-height-changed="${this.handleMonacoHeightChange}"
556 data-file-index="${index}"
557 data-file-path="${file.path}"
558 ></sketch-monaco-view>
559 </div>
560 </div>
561 `;
562 }
563
564 /**
565 * Render file header with status and path info
566 */
567 renderFileHeader(file: GitDiffFile) {
banksean54505842025-07-03 00:18:44 +0000568 const statusClasses = this.getFileStatusTailwindClasses(file.status);
David Crawshaw26f3f342025-06-14 19:58:32 +0000569 const statusText = this.getFileStatusText(file.status);
570 const changesInfo = this.getChangesInfo(file);
571 const pathInfo = this.getPathInfo(file);
572
573 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000574
David Crawshaw26f3f342025-06-14 19:58:32 +0000575 return html`
banksean54505842025-07-03 00:18:44 +0000576 <div class="flex items-center gap-2">
577 <span class="inline-block px-1.5 py-0.5 rounded text-xs font-bold mr-2 ${statusClasses}">${statusText}</span>
578 <span class="font-mono font-normal text-gray-600">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000579 ${changesInfo
banksean54505842025-07-03 00:18:44 +0000580 ? html`<span class="ml-2 text-xs text-gray-600">${changesInfo}</span>`
Autoformatter9abf8032025-06-14 23:24:08 +0000581 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000582 </div>
banksean54505842025-07-03 00:18:44 +0000583 <div class="flex items-center">
David Crawshaw26f3f342025-06-14 19:58:32 +0000584 <button
banksean54505842025-07-03 00:18:44 +0000585 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 +0000586 @click="${() => this.toggleFileExpansion(file.path)}"
587 title="${isExpanded
588 ? "Collapse: Hide unchanged regions to focus on changes"
589 : "Expand: Show all lines including unchanged regions"}"
590 >
Autoformatter9abf8032025-06-14 23:24:08 +0000591 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000592 </button>
593 </div>
594 `;
595 }
596
597 /**
banksean54505842025-07-03 00:18:44 +0000598 * Get Tailwind CSS classes for file status
David Crawshaw26f3f342025-06-14 19:58:32 +0000599 */
banksean54505842025-07-03 00:18:44 +0000600 getFileStatusTailwindClasses(status: string): string {
David Crawshaw26f3f342025-06-14 19:58:32 +0000601 switch (status.toUpperCase()) {
602 case "A":
banksean54505842025-07-03 00:18:44 +0000603 return "bg-green-100 text-green-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000604 case "M":
banksean54505842025-07-03 00:18:44 +0000605 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000606 case "D":
banksean54505842025-07-03 00:18:44 +0000607 return "bg-red-100 text-red-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000608 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000609 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000610 default:
611 if (status.toUpperCase().startsWith("R")) {
banksean54505842025-07-03 00:18:44 +0000612 return "bg-cyan-100 text-cyan-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000613 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000614 if (status.toUpperCase().startsWith("C")) {
banksean54505842025-07-03 00:18:44 +0000615 return "bg-indigo-100 text-indigo-800";
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000616 }
banksean54505842025-07-03 00:18:44 +0000617 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000618 }
619 }
620
621 /**
622 * Get display text for file status
623 */
624 getFileStatusText(status: string): string {
625 switch (status.toUpperCase()) {
626 case "A":
627 return "Added";
628 case "M":
629 return "Modified";
630 case "D":
631 return "Deleted";
632 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000633 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000634 default:
635 if (status.toUpperCase().startsWith("R")) {
636 return "Renamed";
637 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000638 if (status.toUpperCase().startsWith("C")) {
639 return "Copied";
640 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000641 return "Modified";
642 }
643 }
644
645 /**
646 * Get changes information (+/-) for display
647 */
648 getChangesInfo(file: GitDiffFile): string {
649 const additions = file.additions || 0;
650 const deletions = file.deletions || 0;
651
652 if (additions === 0 && deletions === 0) {
653 return "";
654 }
655
656 const parts = [];
657 if (additions > 0) {
658 parts.push(`+${additions}`);
659 }
660 if (deletions > 0) {
661 parts.push(`-${deletions}`);
662 }
663
664 return `(${parts.join(", ")})`;
665 }
666
667 /**
668 * Get path information for display, handling renames
669 */
670 getPathInfo(file: GitDiffFile): string {
671 if (file.old_path && file.old_path !== "") {
672 // For renames, show old_path → new_path
673 return `${file.old_path} → ${file.path}`;
674 }
675 // For regular files, just show the path
676 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700677 }
678
679 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000680 * Render expand all icon (dotted line with arrows pointing away)
681 */
682 renderExpandAllIcon() {
683 return html`
684 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
685 <!-- Dotted line in the middle -->
686 <line
687 x1="2"
688 y1="8"
689 x2="14"
690 y2="8"
691 stroke="currentColor"
692 stroke-width="1"
693 stroke-dasharray="2,1"
694 />
695 <!-- Large arrow pointing up -->
696 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
697 <!-- Large arrow pointing down -->
698 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
699 </svg>
700 `;
701 }
702
703 /**
704 * Render collapse icon (arrows pointing towards dotted line)
705 */
706 renderCollapseIcon() {
707 return html`
708 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
709 <!-- Dotted line in the middle -->
710 <line
711 x1="2"
712 y1="8"
713 x2="14"
714 y2="8"
715 stroke="currentColor"
716 stroke-width="1"
717 stroke-dasharray="2,1"
718 />
719 <!-- Large arrow pointing down towards line -->
720 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
721 <!-- Large arrow pointing up towards line -->
722 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
723 </svg>
724 `;
725 }
726
727 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000728 * Handle file selection change from the dropdown
729 */
730 handleFileSelection(event: Event) {
731 const selectElement = event.target as HTMLSelectElement;
732 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +0000733
David Crawshaw4cd01292025-06-15 18:59:13 +0000734 this.selectedFile = selectedValue;
735 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +0000736
David Crawshaw4cd01292025-06-15 18:59:13 +0000737 // Force re-render
738 this.requestUpdate();
739 }
740
741 /**
742 * Get display name for file in the selector
743 */
744 getFileDisplayName(file: GitDiffFile): string {
745 const status = this.getFileStatusText(file.status);
746 const pathInfo = this.getPathInfo(file);
747 return `${status}: ${pathInfo}`;
748 }
749
750 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700751 * Render expand/collapse button for single file view in header
752 */
753 renderSingleFileExpandButton() {
754 if (!this.selectedFile) return "";
755
756 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
757
758 return html`
759 <button
banksean54505842025-07-03 00:18:44 +0000760 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 -0700761 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
762 title="${isExpanded
763 ? "Collapse: Hide unchanged regions to focus on changes"
764 : "Expand: Show all lines including unchanged regions"}"
765 >
766 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
767 </button>
768 `;
769 }
770
771 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000772 * Render single file view with full-screen Monaco editor
773 */
774 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +0000775 const selectedFileData = this.files.find(
776 (f) => f.path === this.selectedFile,
777 );
David Crawshaw4cd01292025-06-15 18:59:13 +0000778 if (!selectedFileData) {
banksean54505842025-07-03 00:18:44 +0000779 return html`<div class="text-red-600 p-4">Selected file not found</div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000780 }
781
782 const content = this.fileContents.get(this.selectedFile);
783 if (!content) {
banksean54505842025-07-03 00:18:44 +0000784 return html`<div class="flex items-center justify-center h-full">Loading ${this.selectedFile}...</div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000785 }
786
787 return html`
banksean54505842025-07-03 00:18:44 +0000788 <div class="flex-1 flex flex-col h-full min-h-0">
David Crawshaw4cd01292025-06-15 18:59:13 +0000789 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000790 class="flex-1 w-full h-full min-h-0"
David Crawshaw4cd01292025-06-15 18:59:13 +0000791 .originalCode="${content.original}"
792 .modifiedCode="${content.modified}"
793 .originalFilename="${selectedFileData.path}"
794 .modifiedFilename="${selectedFileData.path}"
795 ?readOnly="${!content.editable}"
796 ?editable-right="${content.editable}"
797 @monaco-comment="${this.handleMonacoComment}"
798 @monaco-save="${this.handleMonacoSave}"
799 data-file-path="${selectedFileData.path}"
800 ></sketch-monaco-view>
801 </div>
802 `;
803 }
804
805 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700806 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +0000807 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700808 * This is called when the Monaco diff tab is activated to ensure:
809 * 1. Branch information from git/recentlog is current (branches can change frequently)
810 * 2. The diff content is synchronized with the latest repository state
811 * 3. Users always see up-to-date information without manual refresh
812 */
813 refreshDiffView() {
814 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +0000815 const rangePicker = this.shadowRoot?.querySelector(
816 "sketch-diff-range-picker",
817 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700818 if (rangePicker) {
819 (rangePicker as any).loadCommits();
820 }
Autoformatter8c463622025-05-16 21:54:17 +0000821
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700822 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +0000823 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +0000824 this.currentRange = {
825 type: "range",
826 from: `${this.commit}^`,
827 to: this.commit,
828 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700829 }
Autoformatter8c463622025-05-16 21:54:17 +0000830
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700831 // Then reload diff data based on the current range
832 this.loadDiffData();
833 }
834}
835
836declare global {
837 interface HTMLElementTagNameMap {
838 "sketch-diff2-view": SketchDiff2View;
839 }
840}