blob: 927f2ac5f15ab89d107e5a53e59d25dd3389d69b [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 @property({ attribute: false, type: Object })
165 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000166
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700167 // The gitService must be passed from parent to ensure proper dependency injection
168
169 constructor() {
170 super();
Autoformatter8c463622025-05-16 21:54:17 +0000171 console.log("SketchDiff2View initialized");
172
David Crawshawe2954ce2025-06-15 00:06:34 +0000173 // Fix for monaco-aria-container positioning and hide scrollbars globally
174 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000175 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700176 styleElement.textContent = `
177 .monaco-aria-container {
178 position: absolute !important;
179 top: 0 !important;
180 left: 0 !important;
181 width: 1px !important;
182 height: 1px !important;
183 overflow: hidden !important;
184 clip: rect(1px, 1px, 1px, 1px) !important;
185 white-space: nowrap !important;
186 margin: 0 !important;
187 padding: 0 !important;
188 border: 0 !important;
189 z-index: -1 !important;
190 }
David Crawshawe2954ce2025-06-15 00:06:34 +0000191
192 /* Aggressively hide all Monaco scrollbar elements */
193 .monaco-editor .scrollbar,
194 .monaco-editor .scroll-decoration,
195 .monaco-editor .invisible.scrollbar,
196 .monaco-editor .slider,
197 .monaco-editor .vertical.scrollbar,
198 .monaco-editor .horizontal.scrollbar,
199 .monaco-diff-editor .scrollbar,
200 .monaco-diff-editor .scroll-decoration,
201 .monaco-diff-editor .invisible.scrollbar,
202 .monaco-diff-editor .slider,
203 .monaco-diff-editor .vertical.scrollbar,
204 .monaco-diff-editor .horizontal.scrollbar {
205 display: none !important;
206 visibility: hidden !important;
207 width: 0 !important;
208 height: 0 !important;
209 opacity: 0 !important;
210 }
211
212 /* Target the specific scrollbar classes that Monaco uses */
213 .monaco-scrollable-element > .scrollbar,
214 .monaco-scrollable-element > .scroll-decoration,
215 .monaco-scrollable-element .slider {
216 display: none !important;
217 visibility: hidden !important;
218 width: 0 !important;
219 height: 0 !important;
220 }
221
222 /* Remove scrollbar space/padding from content area */
223 .monaco-editor .monaco-scrollable-element,
224 .monaco-diff-editor .monaco-scrollable-element {
225 padding-right: 0 !important;
226 padding-bottom: 0 !important;
227 margin-right: 0 !important;
228 margin-bottom: 0 !important;
229 }
230
231 /* Ensure the diff content takes full width without scrollbar space */
232 .monaco-diff-editor .editor.modified,
233 .monaco-diff-editor .editor.original {
234 margin-right: 0 !important;
235 padding-right: 0 !important;
236 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700237 `;
238 document.head.appendChild(styleElement);
239 }
240
241 connectedCallback() {
242 super.connectedCallback();
243 // Initialize with default range and load data
244 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000245 if (
246 this.currentRange.type === "range" &&
247 !("from" in this.currentRange && this.currentRange.from)
248 ) {
249 this.gitService
250 .getBaseCommitRef()
251 .then((baseRef) => {
252 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
253 this.loadDiffData();
254 })
255 .catch((error) => {
256 console.error("Error getting base commit ref:", error);
257 // Use default range
258 this.loadDiffData();
259 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700260 } else {
261 this.loadDiffData();
262 }
263 }
264
David Crawshaw26f3f342025-06-14 19:58:32 +0000265 // Toggle hideUnchangedRegions setting for a specific file
266 private toggleFileExpansion(filePath: string) {
267 const currentState = this.fileExpandStates.get(filePath) ?? false;
268 const newState = !currentState;
269 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000270
David Crawshaw26f3f342025-06-14 19:58:32 +0000271 // Apply to the specific Monaco view component for this file
Autoformatter9abf8032025-06-14 23:24:08 +0000272 const monacoView = this.shadowRoot?.querySelector(
273 `sketch-monaco-view[data-file-path="${filePath}"]`,
274 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700275 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000276 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700277 }
Autoformatter9abf8032025-06-14 23:24:08 +0000278
David Crawshaw26f3f342025-06-14 19:58:32 +0000279 // Force a re-render to update the button state
280 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700281 }
Autoformatter8c463622025-05-16 21:54:17 +0000282
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700283 render() {
284 return html`
banksean54505842025-07-03 00:18:44 +0000285 <div class="flex h-full flex-1 flex-col min-h-0 overflow-hidden relative">
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000286 <div
287 class="px-4 py-2 border-b border-gray-300 bg-gray-100 flex-shrink-0"
288 >
banksean54505842025-07-03 00:18:44 +0000289 <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">
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000303 <div
304 class="flex-1 overflow-auto min-h-0 flex flex-col relative h-full"
305 >
306 ${this.renderDiffContent()}
307 </div>
banksean54505842025-07-03 00:18:44 +0000308 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700309 </div>
310 `;
311 }
312
David Crawshaw4cd01292025-06-15 18:59:13 +0000313 renderFileSelector() {
David Crawshaw5c6d8292025-06-15 19:09:19 +0000314 const fileCount = this.files.length;
Autoformatter62554112025-06-15 19:23:33 +0000315
David Crawshaw4cd01292025-06-15 18:59:13 +0000316 return html`
banksean54505842025-07-03 00:18:44 +0000317 <div class="flex items-center gap-2">
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700318 <select
banksean54505842025-07-03 00:18:44 +0000319 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 -0700320 .value="${this.selectedFile}"
321 @change="${this.handleFileSelection}"
322 ?disabled="${fileCount === 0}"
323 >
324 <option value="">All files (${fileCount})</option>
325 ${this.files.map(
326 (file) => html`
327 <option value="${file.path}">
328 ${this.getFileDisplayName(file)}
329 </option>
330 `,
331 )}
332 </select>
333 ${this.selectedFile ? this.renderSingleFileExpandButton() : ""}
334 </div>
David Crawshaw4cd01292025-06-15 18:59:13 +0000335 `;
336 }
337
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700338 renderDiffContent() {
339 if (this.loading) {
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000340 return html`<div class="flex items-center justify-center h-full">
341 Loading diff...
342 </div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700343 }
344
345 if (this.error) {
banksean54505842025-07-03 00:18:44 +0000346 return html`<div class="text-red-600 p-4">${this.error}</div>`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700347 }
348
349 if (this.files.length === 0) {
350 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
351 }
Autoformatter8c463622025-05-16 21:54:17 +0000352
David Crawshaw4cd01292025-06-15 18:59:13 +0000353 // Render single file view if a specific file is selected
354 if (this.selectedFile && this.viewMode === "single") {
355 return this.renderSingleFileView();
356 }
357
358 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700359 return html`
banksean54505842025-07-03 00:18:44 +0000360 <div class="flex flex-col w-full min-h-full">
David Crawshaw26f3f342025-06-14 19:58:32 +0000361 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
362 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700363 `;
364 }
365
366 /**
367 * Load diff data for the current range
368 */
369 async loadDiffData() {
370 this.loading = true;
371 this.error = null;
372
373 try {
374 // Initialize files as empty array if undefined
375 if (!this.files) {
376 this.files = [];
377 }
378
David Crawshaw216d2fc2025-06-15 18:45:53 +0000379 // Load diff data for the range
380 this.files = await this.gitService.getDiff(
381 this.currentRange.from,
382 this.currentRange.to,
383 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700384
385 // Ensure files is always an array, even when API returns null
386 if (!this.files) {
387 this.files = [];
388 }
Autoformatter8c463622025-05-16 21:54:17 +0000389
David Crawshaw26f3f342025-06-14 19:58:32 +0000390 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700391 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000392 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000393 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000394 if (!this.fileExpandStates.has(file.path)) {
395 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
396 }
397 });
398 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700399 } else {
400 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000401 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000402 this.selectedFile = "";
403 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000404 this.fileContents.clear();
405 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700406 }
407 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000408 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700409 this.error = `Error loading diff data: ${error.message}`;
410 // Ensure files is an empty array when an error occurs
411 this.files = [];
412 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000413 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000414 this.selectedFile = "";
415 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000416 this.fileContents.clear();
417 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700418 } finally {
419 this.loading = false;
420 }
421 }
422
423 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000424 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700425 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000426 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700427 this.loading = true;
428 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000429 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700430
431 try {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700432 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000433
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700434 // Determine the commits to compare based on the current range
philip.zeyliger26bc6592025-06-30 20:15:30 -0700435 const _fromCommit = this.currentRange.from;
436 const toCommit = this.currentRange.to;
David Crawshaw216d2fc2025-06-15 18:45:53 +0000437 // Check if this is an unstaged changes view
438 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700439
David Crawshaw26f3f342025-06-14 19:58:32 +0000440 // Load content for all files
441 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700442 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000443 let originalCode = "";
444 let modifiedCode = "";
445 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000446
David Crawshaw26f3f342025-06-14 19:58:32 +0000447 // Load the original code based on file status
448 if (file.status !== "A") {
449 // For modified, renamed, or deleted files: load original content
450 originalCode = await this.gitService.getFileContent(
451 file.old_hash || "",
452 );
453 }
454
455 // For modified code, always use working copy when editable
456 if (editable) {
457 try {
458 // Always use working copy when editable, regardless of diff status
459 modifiedCode = await this.gitService.getWorkingCopyContent(
460 file.path,
461 );
462 } catch (error) {
463 if (file.status === "D") {
464 // For deleted files, silently use empty content
465 console.warn(
466 `Could not get working copy for deleted file ${file.path}, using empty content`,
467 );
468 modifiedCode = "";
469 } else {
470 // For any other file status, propagate the error
471 console.error(
472 `Failed to get working copy for ${file.path}:`,
473 error,
474 );
475 throw error;
476 }
477 }
478 } else {
479 // For non-editable view, use git content based on file status
480 if (file.status === "D") {
481 // Deleted file: empty modified
482 modifiedCode = "";
483 } else {
484 // Added/modified/renamed: use the content from git
485 modifiedCode = await this.gitService.getFileContent(
486 file.new_hash || "",
487 );
488 }
489 }
490
491 // Don't make deleted files editable
492 if (file.status === "D") {
493 editable = false;
494 }
495
496 this.fileContents.set(file.path, {
497 original: originalCode,
498 modified: modifiedCode,
499 editable,
500 });
501 } catch (error) {
502 console.error(`Error loading content for file ${file.path}:`, error);
503 // Store empty content for failed files to prevent blocking
504 this.fileContents.set(file.path, {
505 original: "",
506 modified: "",
507 editable: false,
508 });
509 }
510 });
511
512 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700513 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000514 console.error("Error loading file contents:", error);
515 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700516 } finally {
517 this.loading = false;
518 }
519 }
520
521 /**
522 * Handle range change event from the range picker
523 */
524 handleRangeChange(event: CustomEvent) {
525 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000526 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700527 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000528
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700529 // Load diff data for the new range
530 this.loadDiffData();
531 }
532
533 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000534 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700535 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000536 renderFileDiff(file: GitDiffFile, index: number) {
537 const content = this.fileContents.get(file.path);
538 if (!content) {
539 return html`
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000540 <div
541 class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0"
542 >
543 <div
544 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"
545 >
546 ${this.renderFileHeader(file)}
547 </div>
548 <div class="flex items-center justify-center h-full">
549 Loading ${file.path}...
550 </div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000551 </div>
552 `;
553 }
554
555 return html`
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000556 <div
557 class="flex flex-col border-b-4 border-gray-300 mb-0 last:border-b-0"
558 >
559 <div
560 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"
561 >
562 ${this.renderFileHeader(file)}
563 </div>
banksean54505842025-07-03 00:18:44 +0000564 <div class="flex flex-col min-h-[200px] overflow-visible">
David Crawshaw26f3f342025-06-14 19:58:32 +0000565 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000566 class="flex flex-col w-full min-h-[200px] flex-1"
David Crawshaw26f3f342025-06-14 19:58:32 +0000567 .originalCode="${content.original}"
568 .modifiedCode="${content.modified}"
569 .originalFilename="${file.path}"
570 .modifiedFilename="${file.path}"
571 ?readOnly="${!content.editable}"
572 ?editable-right="${content.editable}"
573 @monaco-comment="${this.handleMonacoComment}"
574 @monaco-save="${this.handleMonacoSave}"
575 @monaco-height-changed="${this.handleMonacoHeightChange}"
576 data-file-index="${index}"
577 data-file-path="${file.path}"
578 ></sketch-monaco-view>
579 </div>
580 </div>
581 `;
582 }
583
584 /**
585 * Render file header with status and path info
586 */
587 renderFileHeader(file: GitDiffFile) {
banksean54505842025-07-03 00:18:44 +0000588 const statusClasses = this.getFileStatusTailwindClasses(file.status);
David Crawshaw26f3f342025-06-14 19:58:32 +0000589 const statusText = this.getFileStatusText(file.status);
590 const changesInfo = this.getChangesInfo(file);
591 const pathInfo = this.getPathInfo(file);
592
593 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000594
David Crawshaw26f3f342025-06-14 19:58:32 +0000595 return html`
banksean54505842025-07-03 00:18:44 +0000596 <div class="flex items-center gap-2">
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000597 <span
598 class="inline-block px-1.5 py-0.5 rounded text-xs font-bold mr-2 ${statusClasses}"
599 >${statusText}</span
600 >
banksean54505842025-07-03 00:18:44 +0000601 <span class="font-mono font-normal text-gray-600">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000602 ${changesInfo
banksean54505842025-07-03 00:18:44 +0000603 ? html`<span class="ml-2 text-xs text-gray-600">${changesInfo}</span>`
Autoformatter9abf8032025-06-14 23:24:08 +0000604 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000605 </div>
banksean54505842025-07-03 00:18:44 +0000606 <div class="flex items-center">
David Crawshaw26f3f342025-06-14 19:58:32 +0000607 <button
banksean54505842025-07-03 00:18:44 +0000608 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 +0000609 @click="${() => this.toggleFileExpansion(file.path)}"
610 title="${isExpanded
611 ? "Collapse: Hide unchanged regions to focus on changes"
612 : "Expand: Show all lines including unchanged regions"}"
613 >
Autoformatter9abf8032025-06-14 23:24:08 +0000614 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000615 </button>
616 </div>
617 `;
618 }
619
620 /**
banksean54505842025-07-03 00:18:44 +0000621 * Get Tailwind CSS classes for file status
David Crawshaw26f3f342025-06-14 19:58:32 +0000622 */
banksean54505842025-07-03 00:18:44 +0000623 getFileStatusTailwindClasses(status: string): string {
David Crawshaw26f3f342025-06-14 19:58:32 +0000624 switch (status.toUpperCase()) {
625 case "A":
banksean54505842025-07-03 00:18:44 +0000626 return "bg-green-100 text-green-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000627 case "M":
banksean54505842025-07-03 00:18:44 +0000628 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000629 case "D":
banksean54505842025-07-03 00:18:44 +0000630 return "bg-red-100 text-red-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000631 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000632 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000633 default:
634 if (status.toUpperCase().startsWith("R")) {
banksean54505842025-07-03 00:18:44 +0000635 return "bg-cyan-100 text-cyan-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000636 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000637 if (status.toUpperCase().startsWith("C")) {
banksean54505842025-07-03 00:18:44 +0000638 return "bg-indigo-100 text-indigo-800";
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000639 }
banksean54505842025-07-03 00:18:44 +0000640 return "bg-yellow-100 text-yellow-800";
David Crawshaw26f3f342025-06-14 19:58:32 +0000641 }
642 }
643
644 /**
645 * Get display text for file status
646 */
647 getFileStatusText(status: string): string {
648 switch (status.toUpperCase()) {
649 case "A":
650 return "Added";
651 case "M":
652 return "Modified";
653 case "D":
654 return "Deleted";
655 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000656 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000657 default:
658 if (status.toUpperCase().startsWith("R")) {
659 return "Renamed";
660 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000661 if (status.toUpperCase().startsWith("C")) {
662 return "Copied";
663 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000664 return "Modified";
665 }
666 }
667
668 /**
669 * Get changes information (+/-) for display
670 */
671 getChangesInfo(file: GitDiffFile): string {
672 const additions = file.additions || 0;
673 const deletions = file.deletions || 0;
674
675 if (additions === 0 && deletions === 0) {
676 return "";
677 }
678
679 const parts = [];
680 if (additions > 0) {
681 parts.push(`+${additions}`);
682 }
683 if (deletions > 0) {
684 parts.push(`-${deletions}`);
685 }
686
687 return `(${parts.join(", ")})`;
688 }
689
690 /**
691 * Get path information for display, handling renames
692 */
693 getPathInfo(file: GitDiffFile): string {
694 if (file.old_path && file.old_path !== "") {
695 // For renames, show old_path → new_path
696 return `${file.old_path} → ${file.path}`;
697 }
698 // For regular files, just show the path
699 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700700 }
701
702 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000703 * Render expand all icon (dotted line with arrows pointing away)
704 */
705 renderExpandAllIcon() {
706 return html`
707 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
708 <!-- Dotted line in the middle -->
709 <line
710 x1="2"
711 y1="8"
712 x2="14"
713 y2="8"
714 stroke="currentColor"
715 stroke-width="1"
716 stroke-dasharray="2,1"
717 />
718 <!-- Large arrow pointing up -->
719 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
720 <!-- Large arrow pointing down -->
721 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
722 </svg>
723 `;
724 }
725
726 /**
727 * Render collapse icon (arrows pointing towards dotted line)
728 */
729 renderCollapseIcon() {
730 return html`
731 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
732 <!-- Dotted line in the middle -->
733 <line
734 x1="2"
735 y1="8"
736 x2="14"
737 y2="8"
738 stroke="currentColor"
739 stroke-width="1"
740 stroke-dasharray="2,1"
741 />
742 <!-- Large arrow pointing down towards line -->
743 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
744 <!-- Large arrow pointing up towards line -->
745 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
746 </svg>
747 `;
748 }
749
750 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000751 * Handle file selection change from the dropdown
752 */
753 handleFileSelection(event: Event) {
754 const selectElement = event.target as HTMLSelectElement;
755 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +0000756
David Crawshaw4cd01292025-06-15 18:59:13 +0000757 this.selectedFile = selectedValue;
758 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +0000759
David Crawshaw4cd01292025-06-15 18:59:13 +0000760 // Force re-render
761 this.requestUpdate();
762 }
763
764 /**
765 * Get display name for file in the selector
766 */
767 getFileDisplayName(file: GitDiffFile): string {
768 const status = this.getFileStatusText(file.status);
769 const pathInfo = this.getPathInfo(file);
770 return `${status}: ${pathInfo}`;
771 }
772
773 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700774 * Render expand/collapse button for single file view in header
775 */
776 renderSingleFileExpandButton() {
777 if (!this.selectedFile) return "";
778
779 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
780
781 return html`
782 <button
banksean54505842025-07-03 00:18:44 +0000783 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 -0700784 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
785 title="${isExpanded
786 ? "Collapse: Hide unchanged regions to focus on changes"
787 : "Expand: Show all lines including unchanged regions"}"
788 >
789 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
790 </button>
791 `;
792 }
793
794 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000795 * Render single file view with full-screen Monaco editor
796 */
797 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +0000798 const selectedFileData = this.files.find(
799 (f) => f.path === this.selectedFile,
800 );
David Crawshaw4cd01292025-06-15 18:59:13 +0000801 if (!selectedFileData) {
banksean54505842025-07-03 00:18:44 +0000802 return html`<div class="text-red-600 p-4">Selected file not found</div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000803 }
804
805 const content = this.fileContents.get(this.selectedFile);
806 if (!content) {
Autoformatter9f5cb2e2025-07-03 00:25:35 +0000807 return html`<div class="flex items-center justify-center h-full">
808 Loading ${this.selectedFile}...
809 </div>`;
David Crawshaw4cd01292025-06-15 18:59:13 +0000810 }
811
812 return html`
banksean54505842025-07-03 00:18:44 +0000813 <div class="flex-1 flex flex-col h-full min-h-0">
David Crawshaw4cd01292025-06-15 18:59:13 +0000814 <sketch-monaco-view
banksean54505842025-07-03 00:18:44 +0000815 class="flex-1 w-full h-full min-h-0"
David Crawshaw4cd01292025-06-15 18:59:13 +0000816 .originalCode="${content.original}"
817 .modifiedCode="${content.modified}"
818 .originalFilename="${selectedFileData.path}"
819 .modifiedFilename="${selectedFileData.path}"
820 ?readOnly="${!content.editable}"
821 ?editable-right="${content.editable}"
822 @monaco-comment="${this.handleMonacoComment}"
823 @monaco-save="${this.handleMonacoSave}"
824 data-file-path="${selectedFileData.path}"
825 ></sketch-monaco-view>
826 </div>
827 `;
828 }
829
830 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700831 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +0000832 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700833 * This is called when the Monaco diff tab is activated to ensure:
834 * 1. Branch information from git/recentlog is current (branches can change frequently)
835 * 2. The diff content is synchronized with the latest repository state
836 * 3. Users always see up-to-date information without manual refresh
837 */
838 refreshDiffView() {
839 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +0000840 const rangePicker = this.shadowRoot?.querySelector(
841 "sketch-diff-range-picker",
842 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700843 if (rangePicker) {
844 (rangePicker as any).loadCommits();
845 }
Autoformatter8c463622025-05-16 21:54:17 +0000846
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700847 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +0000848 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +0000849 this.currentRange = {
850 type: "range",
851 from: `${this.commit}^`,
852 to: this.commit,
853 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700854 }
Autoformatter8c463622025-05-16 21:54:17 +0000855
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700856 // Then reload diff data based on the current range
857 this.loadDiffData();
858 }
859}
860
861declare global {
862 interface HTMLElementTagNameMap {
863 "sketch-diff2-view": SketchDiff2View;
864 }
865}