blob: 72be0aacdb36f6ceb8b84f45d7f14b846c010965 [file] [log] [blame]
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import "./sketch-monaco-view";
4import "./sketch-diff-range-picker";
David Crawshaw26f3f342025-06-14 19:58:32 +00005// import "./sketch-diff-file-picker"; // No longer needed for multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -07006import "./sketch-diff-empty-view";
Autoformatter8c463622025-05-16 21:54:17 +00007import {
8 GitDiffFile,
9 GitDataService,
10 DefaultGitDataService,
11} from "./git-data-service";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070012import { DiffRange } from "./sketch-diff-range-picker";
13
14/**
15 * A component that displays diffs using Monaco editor with range and file pickers
16 */
17@customElement("sketch-diff2-view")
18export class SketchDiff2View extends LitElement {
19 /**
20 * Handles comment events from the Monaco editor and forwards them to the chat input
21 * using the same event format as the original diff view for consistency.
22 */
23 private handleMonacoComment(event: CustomEvent) {
24 try {
25 // Validate incoming data
26 if (!event.detail || !event.detail.formattedComment) {
Autoformatter8c463622025-05-16 21:54:17 +000027 console.error("Invalid comment data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070028 return;
29 }
Autoformatter8c463622025-05-16 21:54:17 +000030
Philip Zeyliger272a90e2025-05-16 14:49:51 -070031 // Create and dispatch event using the standardized format
Autoformatter8c463622025-05-16 21:54:17 +000032 const commentEvent = new CustomEvent("diff-comment", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070033 detail: { comment: event.detail.formattedComment },
34 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +000035 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -070036 });
Autoformatter8c463622025-05-16 21:54:17 +000037
Philip Zeyliger272a90e2025-05-16 14:49:51 -070038 this.dispatchEvent(commentEvent);
39 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +000040 console.error("Error handling Monaco comment:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -070041 }
42 }
Autoformatter8c463622025-05-16 21:54:17 +000043
Philip Zeyliger272a90e2025-05-16 14:49:51 -070044 /**
David Crawshaw26f3f342025-06-14 19:58:32 +000045 * Handle height change events from the Monaco editor
46 */
47 private handleMonacoHeightChange(event: CustomEvent) {
48 try {
49 // Get the monaco view that emitted the event
50 const monacoView = event.target as HTMLElement;
51 if (!monacoView) return;
Autoformatter9abf8032025-06-14 23:24:08 +000052
David Crawshaw26f3f342025-06-14 19:58:32 +000053 // Find the parent file-diff-editor container
Autoformatter9abf8032025-06-14 23:24:08 +000054 const fileDiffEditor = monacoView.closest(
55 ".file-diff-editor",
56 ) as HTMLElement;
David Crawshaw26f3f342025-06-14 19:58:32 +000057 if (!fileDiffEditor) return;
Autoformatter9abf8032025-06-14 23:24:08 +000058
David Crawshaw26f3f342025-06-14 19:58:32 +000059 // Get the new height from the event
60 const newHeight = event.detail.height;
Autoformatter9abf8032025-06-14 23:24:08 +000061
David Crawshaw26f3f342025-06-14 19:58:32 +000062 // Only update if the height actually changed to avoid unnecessary layout
63 const currentHeight = fileDiffEditor.style.height;
64 const newHeightStr = `${newHeight}px`;
Autoformatter9abf8032025-06-14 23:24:08 +000065
David Crawshaw26f3f342025-06-14 19:58:32 +000066 if (currentHeight !== newHeightStr) {
67 // Update the file-diff-editor height to match monaco's height
68 fileDiffEditor.style.height = newHeightStr;
Autoformatter9abf8032025-06-14 23:24:08 +000069
David Crawshaw26f3f342025-06-14 19:58:32 +000070 // Remove any previous min-height constraint that might interfere
Autoformatter9abf8032025-06-14 23:24:08 +000071 fileDiffEditor.style.minHeight = "auto";
72
David Crawshaw26f3f342025-06-14 19:58:32 +000073 // IMPORTANT: Tell Monaco to relayout after its container size changed
74 // Monaco has automaticLayout: false, so it won't detect container changes
75 setTimeout(() => {
76 const monacoComponent = monacoView as any;
77 if (monacoComponent && monacoComponent.editor) {
78 // Force layout with explicit dimensions to ensure Monaco fills the space
79 const editorWidth = fileDiffEditor.offsetWidth;
80 monacoComponent.editor.layout({
81 width: editorWidth,
Autoformatter9abf8032025-06-14 23:24:08 +000082 height: newHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +000083 });
84 }
85 }, 0);
86 }
David Crawshaw26f3f342025-06-14 19:58:32 +000087 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +000088 console.error("Error handling Monaco height change:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +000089 }
90 }
91
92 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -070093 * Handle save events from the Monaco editor
94 */
95 private async handleMonacoSave(event: CustomEvent) {
96 try {
97 // Validate incoming data
Autoformatter8c463622025-05-16 21:54:17 +000098 if (
99 !event.detail ||
100 !event.detail.path ||
101 event.detail.content === undefined
102 ) {
103 console.error("Invalid save data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700104 return;
105 }
Autoformatter8c463622025-05-16 21:54:17 +0000106
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700107 const { path, content } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000108
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700109 // Get Monaco view component
Autoformatter8c463622025-05-16 21:54:17 +0000110 const monacoView = this.shadowRoot?.querySelector("sketch-monaco-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700111 if (!monacoView) {
Autoformatter8c463622025-05-16 21:54:17 +0000112 console.error("Monaco view not found");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700113 return;
114 }
Autoformatter8c463622025-05-16 21:54:17 +0000115
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700116 try {
117 await this.gitService?.saveFileContent(path, content);
118 console.log(`File saved: ${path}`);
119 (monacoView as any).notifySaveComplete(true);
120 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000121 console.error(
122 `Error saving file: ${error instanceof Error ? error.message : String(error)}`,
123 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700124 (monacoView as any).notifySaveComplete(false);
125 }
126 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000127 console.error("Error handling save:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700128 }
129 }
130 @property({ type: String })
131 initialCommit: string = "";
Autoformatter8c463622025-05-16 21:54:17 +0000132
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700133 // The commit to show - used when showing a specific commit from timeline
134 @property({ type: String })
135 commit: string = "";
136
137 @property({ type: String })
138 selectedFilePath: string = "";
139
140 @state()
141 private files: GitDiffFile[] = [];
Autoformatter8c463622025-05-16 21:54:17 +0000142
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700143 @state()
Autoformatter8c463622025-05-16 21:54:17 +0000144 private currentRange: DiffRange = { type: "range", from: "", to: "HEAD" };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700145
146 @state()
Autoformatter9abf8032025-06-14 23:24:08 +0000147 private fileContents: Map<
148 string,
149 { original: string; modified: string; editable: boolean }
150 > = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700151
152 @state()
David Crawshaw26f3f342025-06-14 19:58:32 +0000153 private fileExpandStates: Map<string, boolean> = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700154
155 @state()
156 private loading: boolean = false;
157
158 @state()
159 private error: string | null = null;
160
David Crawshaw4cd01292025-06-15 18:59:13 +0000161 @state()
162 private selectedFile: string = ""; // Empty string means "All files"
163
164 @state()
165 private viewMode: "all" | "single" = "all";
166
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700167 static styles = css`
168 :host {
169 display: flex;
170 height: 100%;
171 flex: 1;
172 flex-direction: column;
173 min-height: 0; /* Critical for flex child behavior */
174 overflow: hidden;
175 position: relative; /* Establish positioning context */
176 }
177
178 .controls {
179 padding: 8px 16px;
180 border-bottom: 1px solid var(--border-color, #e0e0e0);
181 background-color: var(--background-light, #f8f8f8);
182 flex-shrink: 0; /* Prevent controls from shrinking */
183 }
Autoformatter8c463622025-05-16 21:54:17 +0000184
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700185 .controls-container {
186 display: flex;
187 flex-direction: column;
188 gap: 12px;
189 }
Autoformatter8c463622025-05-16 21:54:17 +0000190
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700191 .range-row {
192 width: 100%;
193 display: flex;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700194 align-items: center;
David Crawshawdbca8972025-06-14 23:46:58 +0000195 gap: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700196 }
Autoformatter8c463622025-05-16 21:54:17 +0000197
David Crawshaw4cd01292025-06-15 18:59:13 +0000198 .file-selector {
199 min-width: 200px;
200 padding: 8px 12px;
201 border: 1px solid var(--border-color, #ccc);
202 border-radius: 4px;
203 background-color: var(--background-color, #fff);
204 font-family: var(--font-family, system-ui, sans-serif);
205 font-size: 14px;
206 cursor: pointer;
207 }
208
209 .file-selector:focus {
210 outline: none;
211 border-color: var(--accent-color, #007acc);
212 box-shadow: 0 0 0 2px var(--accent-color-light, rgba(0, 122, 204, 0.2));
213 }
214
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700215 sketch-diff-range-picker {
David Crawshawdbca8972025-06-14 23:46:58 +0000216 flex: 1;
David Crawshawe2954ce2025-06-15 00:06:34 +0000217 min-width: 400px; /* Ensure minimum width for range picker */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700218 }
Autoformatter8c463622025-05-16 21:54:17 +0000219
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700220 sketch-diff-file-picker {
221 flex: 1;
222 }
Autoformatter8c463622025-05-16 21:54:17 +0000223
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700224 .view-toggle-button {
225 background-color: #f0f0f0;
226 border: 1px solid #ccc;
227 border-radius: 4px;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000228 padding: 8px;
229 font-size: 16px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700230 cursor: pointer;
231 white-space: nowrap;
232 transition: background-color 0.2s;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000233 display: flex;
234 align-items: center;
235 justify-content: center;
236 min-width: 36px;
237 min-height: 36px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700238 }
Autoformatter8c463622025-05-16 21:54:17 +0000239
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700240 .view-toggle-button:hover {
241 background-color: #e0e0e0;
242 }
243
244 .diff-container {
245 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000246 overflow: auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700247 display: flex;
248 flex-direction: column;
David Crawshaw26f3f342025-06-14 19:58:32 +0000249 min-height: 0;
250 position: relative;
251 height: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700252 }
253
254 .diff-content {
255 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000256 overflow: auto;
257 min-height: 0;
258 display: flex;
259 flex-direction: column;
260 position: relative;
261 height: 100%;
262 }
263
264 .multi-file-diff-container {
265 display: flex;
266 flex-direction: column;
267 width: 100%;
268 min-height: 100%;
269 }
270
271 .file-diff-section {
272 display: flex;
273 flex-direction: column;
274 border-bottom: 3px solid var(--border-color, #e0e0e0);
275 margin-bottom: 0;
276 }
277
278 .file-diff-section:last-child {
279 border-bottom: none;
280 }
281
282 .file-header {
283 background-color: var(--background-light, #f8f8f8);
284 border-bottom: 1px solid var(--border-color, #e0e0e0);
David Crawshawdbca8972025-06-14 23:46:58 +0000285 padding: 8px 16px;
David Crawshaw26f3f342025-06-14 19:58:32 +0000286 font-family: var(--font-family, system-ui, sans-serif);
287 font-weight: 500;
288 font-size: 14px;
289 color: var(--text-primary-color, #333);
290 position: sticky;
291 top: 0;
292 z-index: 10;
293 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
294 display: flex;
295 justify-content: space-between;
296 align-items: center;
297 }
298
299 .file-header-left {
300 display: flex;
301 align-items: center;
302 gap: 8px;
303 }
304
305 .file-header-right {
306 display: flex;
307 align-items: center;
308 }
309
310 .file-expand-button {
311 background-color: transparent;
312 border: 1px solid var(--border-color, #e0e0e0);
313 border-radius: 4px;
314 padding: 4px 8px;
315 font-size: 14px;
316 cursor: pointer;
317 transition: background-color 0.2s;
318 display: flex;
319 align-items: center;
320 justify-content: center;
321 min-width: 32px;
322 min-height: 32px;
323 }
324
325 .file-expand-button:hover {
326 background-color: var(--background-hover, #e8e8e8);
327 }
328
329 .file-path {
330 font-family: monospace;
331 font-weight: normal;
332 color: var(--text-secondary-color, #666);
333 }
334
335 .file-status {
336 display: inline-block;
337 padding: 2px 6px;
338 border-radius: 3px;
339 font-size: 12px;
340 font-weight: bold;
341 margin-right: 8px;
342 }
343
344 .file-status.added {
345 background-color: #d4edda;
346 color: #155724;
347 }
348
349 .file-status.modified {
350 background-color: #fff3cd;
351 color: #856404;
352 }
353
354 .file-status.deleted {
355 background-color: #f8d7da;
356 color: #721c24;
357 }
358
359 .file-status.renamed {
360 background-color: #d1ecf1;
361 color: #0c5460;
362 }
363
364 .file-changes {
365 margin-left: 8px;
366 font-size: 12px;
367 color: var(--text-secondary-color, #666);
368 }
369
370 .file-diff-editor {
371 display: flex;
372 flex-direction: column;
373 min-height: 200px;
374 /* Height will be set dynamically by monaco editor */
375 overflow: visible; /* Ensure content is not clipped */
376 }
377
David Crawshaw216d2fc2025-06-15 18:45:53 +0000378
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700379
Autoformatter8c463622025-05-16 21:54:17 +0000380 .loading,
381 .empty-diff {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700382 display: flex;
383 align-items: center;
384 justify-content: center;
385 height: 100%;
386 font-family: var(--font-family, system-ui, sans-serif);
387 }
Autoformatter8c463622025-05-16 21:54:17 +0000388
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700389 .empty-diff {
390 color: var(--text-secondary-color, #666);
391 font-size: 16px;
392 text-align: center;
393 }
394
395 .error {
396 color: var(--error-color, #dc3545);
397 padding: 16px;
398 font-family: var(--font-family, system-ui, sans-serif);
399 }
400
401 sketch-monaco-view {
402 --editor-width: 100%;
403 --editor-height: 100%;
David Crawshaw26f3f342025-06-14 19:58:32 +0000404 display: flex;
405 flex-direction: column;
406 width: 100%;
407 min-height: 200px;
408 /* Ensure Monaco view takes full container space */
409 flex: 1;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700410 }
David Crawshaw4cd01292025-06-15 18:59:13 +0000411
412 /* Single file view styles */
413 .single-file-view {
414 flex: 1;
415 display: flex;
416 flex-direction: column;
417 height: 100%;
418 min-height: 0;
419 }
420
421 .single-file-monaco {
422 flex: 1;
423 width: 100%;
424 height: 100%;
425 min-height: 0;
426 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700427 `;
428
429 @property({ attribute: false, type: Object })
430 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000431
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700432 // The gitService must be passed from parent to ensure proper dependency injection
433
434 constructor() {
435 super();
Autoformatter8c463622025-05-16 21:54:17 +0000436 console.log("SketchDiff2View initialized");
437
David Crawshawe2954ce2025-06-15 00:06:34 +0000438 // Fix for monaco-aria-container positioning and hide scrollbars globally
439 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000440 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700441 styleElement.textContent = `
442 .monaco-aria-container {
443 position: absolute !important;
444 top: 0 !important;
445 left: 0 !important;
446 width: 1px !important;
447 height: 1px !important;
448 overflow: hidden !important;
449 clip: rect(1px, 1px, 1px, 1px) !important;
450 white-space: nowrap !important;
451 margin: 0 !important;
452 padding: 0 !important;
453 border: 0 !important;
454 z-index: -1 !important;
455 }
David Crawshawe2954ce2025-06-15 00:06:34 +0000456
457 /* Aggressively hide all Monaco scrollbar elements */
458 .monaco-editor .scrollbar,
459 .monaco-editor .scroll-decoration,
460 .monaco-editor .invisible.scrollbar,
461 .monaco-editor .slider,
462 .monaco-editor .vertical.scrollbar,
463 .monaco-editor .horizontal.scrollbar,
464 .monaco-diff-editor .scrollbar,
465 .monaco-diff-editor .scroll-decoration,
466 .monaco-diff-editor .invisible.scrollbar,
467 .monaco-diff-editor .slider,
468 .monaco-diff-editor .vertical.scrollbar,
469 .monaco-diff-editor .horizontal.scrollbar {
470 display: none !important;
471 visibility: hidden !important;
472 width: 0 !important;
473 height: 0 !important;
474 opacity: 0 !important;
475 }
476
477 /* Target the specific scrollbar classes that Monaco uses */
478 .monaco-scrollable-element > .scrollbar,
479 .monaco-scrollable-element > .scroll-decoration,
480 .monaco-scrollable-element .slider {
481 display: none !important;
482 visibility: hidden !important;
483 width: 0 !important;
484 height: 0 !important;
485 }
486
487 /* Remove scrollbar space/padding from content area */
488 .monaco-editor .monaco-scrollable-element,
489 .monaco-diff-editor .monaco-scrollable-element {
490 padding-right: 0 !important;
491 padding-bottom: 0 !important;
492 margin-right: 0 !important;
493 margin-bottom: 0 !important;
494 }
495
496 /* Ensure the diff content takes full width without scrollbar space */
497 .monaco-diff-editor .editor.modified,
498 .monaco-diff-editor .editor.original {
499 margin-right: 0 !important;
500 padding-right: 0 !important;
501 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700502 `;
503 document.head.appendChild(styleElement);
504 }
505
506 connectedCallback() {
507 super.connectedCallback();
508 // Initialize with default range and load data
509 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000510 if (
511 this.currentRange.type === "range" &&
512 !("from" in this.currentRange && this.currentRange.from)
513 ) {
514 this.gitService
515 .getBaseCommitRef()
516 .then((baseRef) => {
517 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
518 this.loadDiffData();
519 })
520 .catch((error) => {
521 console.error("Error getting base commit ref:", error);
522 // Use default range
523 this.loadDiffData();
524 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700525 } else {
526 this.loadDiffData();
527 }
528 }
529
David Crawshaw26f3f342025-06-14 19:58:32 +0000530 // Toggle hideUnchangedRegions setting for a specific file
531 private toggleFileExpansion(filePath: string) {
532 const currentState = this.fileExpandStates.get(filePath) ?? false;
533 const newState = !currentState;
534 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000535
David Crawshaw26f3f342025-06-14 19:58:32 +0000536 // Apply to the specific Monaco view component for this file
Autoformatter9abf8032025-06-14 23:24:08 +0000537 const monacoView = this.shadowRoot?.querySelector(
538 `sketch-monaco-view[data-file-path="${filePath}"]`,
539 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700540 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000541 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700542 }
Autoformatter9abf8032025-06-14 23:24:08 +0000543
David Crawshaw26f3f342025-06-14 19:58:32 +0000544 // Force a re-render to update the button state
545 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700546 }
Autoformatter8c463622025-05-16 21:54:17 +0000547
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700548 render() {
549 return html`
550 <div class="controls">
551 <div class="controls-container">
552 <div class="range-row">
553 <sketch-diff-range-picker
554 .gitService="${this.gitService}"
555 @range-change="${this.handleRangeChange}"
556 ></sketch-diff-range-picker>
David Crawshaw4cd01292025-06-15 18:59:13 +0000557 ${this.renderFileSelector()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700558 </div>
559 </div>
560 </div>
561
562 <div class="diff-container">
Autoformatter8c463622025-05-16 21:54:17 +0000563 <div class="diff-content">${this.renderDiffContent()}</div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700564 </div>
565 `;
566 }
567
David Crawshaw4cd01292025-06-15 18:59:13 +0000568 renderFileSelector() {
569 if (this.files.length === 0) {
570 return html``;
571 }
572
573 return html`
574 <select
575 class="file-selector"
576 .value="${this.selectedFile}"
577 @change="${this.handleFileSelection}"
578 >
579 <option value="">All files (${this.files.length})</option>
580 ${this.files.map(
581 (file) => html`
582 <option value="${file.path}">
583 ${this.getFileDisplayName(file)}
584 </option>
585 `,
586 )}
587 </select>
588 `;
589 }
590
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700591 renderDiffContent() {
592 if (this.loading) {
593 return html`<div class="loading">Loading diff...</div>`;
594 }
595
596 if (this.error) {
597 return html`<div class="error">${this.error}</div>`;
598 }
599
600 if (this.files.length === 0) {
601 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
602 }
Autoformatter8c463622025-05-16 21:54:17 +0000603
David Crawshaw4cd01292025-06-15 18:59:13 +0000604 // Render single file view if a specific file is selected
605 if (this.selectedFile && this.viewMode === "single") {
606 return this.renderSingleFileView();
607 }
608
609 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700610 return html`
David Crawshaw26f3f342025-06-14 19:58:32 +0000611 <div class="multi-file-diff-container">
612 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
613 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700614 `;
615 }
616
617 /**
618 * Load diff data for the current range
619 */
620 async loadDiffData() {
621 this.loading = true;
622 this.error = null;
623
624 try {
625 // Initialize files as empty array if undefined
626 if (!this.files) {
627 this.files = [];
628 }
629
David Crawshaw216d2fc2025-06-15 18:45:53 +0000630 // Load diff data for the range
631 this.files = await this.gitService.getDiff(
632 this.currentRange.from,
633 this.currentRange.to,
634 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700635
636 // Ensure files is always an array, even when API returns null
637 if (!this.files) {
638 this.files = [];
639 }
Autoformatter8c463622025-05-16 21:54:17 +0000640
David Crawshaw26f3f342025-06-14 19:58:32 +0000641 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700642 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000643 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000644 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000645 if (!this.fileExpandStates.has(file.path)) {
646 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
647 }
648 });
649 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700650 } else {
651 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000652 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000653 this.selectedFile = "";
654 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000655 this.fileContents.clear();
656 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700657 }
658 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000659 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700660 this.error = `Error loading diff data: ${error.message}`;
661 // Ensure files is an empty array when an error occurs
662 this.files = [];
663 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000664 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000665 this.selectedFile = "";
666 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000667 this.fileContents.clear();
668 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700669 } finally {
670 this.loading = false;
671 }
672 }
673
674 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000675 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700676 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000677 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700678 this.loading = true;
679 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000680 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700681
682 try {
683 let fromCommit: string;
684 let toCommit: string;
685 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000686
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700687 // Determine the commits to compare based on the current range
David Crawshaw216d2fc2025-06-15 18:45:53 +0000688 fromCommit = this.currentRange.from;
689 toCommit = this.currentRange.to;
690 // Check if this is an unstaged changes view
691 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700692
David Crawshaw26f3f342025-06-14 19:58:32 +0000693 // Load content for all files
694 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700695 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000696 let originalCode = "";
697 let modifiedCode = "";
698 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000699
David Crawshaw26f3f342025-06-14 19:58:32 +0000700 // Load the original code based on file status
701 if (file.status !== "A") {
702 // For modified, renamed, or deleted files: load original content
703 originalCode = await this.gitService.getFileContent(
704 file.old_hash || "",
705 );
706 }
707
708 // For modified code, always use working copy when editable
709 if (editable) {
710 try {
711 // Always use working copy when editable, regardless of diff status
712 modifiedCode = await this.gitService.getWorkingCopyContent(
713 file.path,
714 );
715 } catch (error) {
716 if (file.status === "D") {
717 // For deleted files, silently use empty content
718 console.warn(
719 `Could not get working copy for deleted file ${file.path}, using empty content`,
720 );
721 modifiedCode = "";
722 } else {
723 // For any other file status, propagate the error
724 console.error(
725 `Failed to get working copy for ${file.path}:`,
726 error,
727 );
728 throw error;
729 }
730 }
731 } else {
732 // For non-editable view, use git content based on file status
733 if (file.status === "D") {
734 // Deleted file: empty modified
735 modifiedCode = "";
736 } else {
737 // Added/modified/renamed: use the content from git
738 modifiedCode = await this.gitService.getFileContent(
739 file.new_hash || "",
740 );
741 }
742 }
743
744 // Don't make deleted files editable
745 if (file.status === "D") {
746 editable = false;
747 }
748
749 this.fileContents.set(file.path, {
750 original: originalCode,
751 modified: modifiedCode,
752 editable,
753 });
754 } catch (error) {
755 console.error(`Error loading content for file ${file.path}:`, error);
756 // Store empty content for failed files to prevent blocking
757 this.fileContents.set(file.path, {
758 original: "",
759 modified: "",
760 editable: false,
761 });
762 }
763 });
764
765 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700766 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000767 console.error("Error loading file contents:", error);
768 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700769 } finally {
770 this.loading = false;
771 }
772 }
773
774 /**
775 * Handle range change event from the range picker
776 */
777 handleRangeChange(event: CustomEvent) {
778 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000779 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700780 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000781
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700782 // Load diff data for the new range
783 this.loadDiffData();
784 }
785
786 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000787 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700788 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000789 renderFileDiff(file: GitDiffFile, index: number) {
790 const content = this.fileContents.get(file.path);
791 if (!content) {
792 return html`
793 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000794 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000795 <div class="loading">Loading ${file.path}...</div>
796 </div>
797 `;
798 }
799
800 return html`
801 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000802 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000803 <div class="file-diff-editor">
804 <sketch-monaco-view
805 .originalCode="${content.original}"
806 .modifiedCode="${content.modified}"
807 .originalFilename="${file.path}"
808 .modifiedFilename="${file.path}"
809 ?readOnly="${!content.editable}"
810 ?editable-right="${content.editable}"
811 @monaco-comment="${this.handleMonacoComment}"
812 @monaco-save="${this.handleMonacoSave}"
813 @monaco-height-changed="${this.handleMonacoHeightChange}"
814 data-file-index="${index}"
815 data-file-path="${file.path}"
816 ></sketch-monaco-view>
817 </div>
818 </div>
819 `;
820 }
821
822 /**
823 * Render file header with status and path info
824 */
825 renderFileHeader(file: GitDiffFile) {
826 const statusClass = this.getFileStatusClass(file.status);
827 const statusText = this.getFileStatusText(file.status);
828 const changesInfo = this.getChangesInfo(file);
829 const pathInfo = this.getPathInfo(file);
830
831 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000832
David Crawshaw26f3f342025-06-14 19:58:32 +0000833 return html`
834 <div class="file-header-left">
835 <span class="file-status ${statusClass}">${statusText}</span>
836 <span class="file-path">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000837 ${changesInfo
838 ? html`<span class="file-changes">${changesInfo}</span>`
839 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000840 </div>
841 <div class="file-header-right">
842 <button
843 class="file-expand-button"
844 @click="${() => this.toggleFileExpansion(file.path)}"
845 title="${isExpanded
846 ? "Collapse: Hide unchanged regions to focus on changes"
847 : "Expand: Show all lines including unchanged regions"}"
848 >
Autoformatter9abf8032025-06-14 23:24:08 +0000849 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000850 </button>
851 </div>
852 `;
853 }
854
855 /**
856 * Get CSS class for file status
857 */
858 getFileStatusClass(status: string): string {
859 switch (status.toUpperCase()) {
860 case "A":
861 return "added";
862 case "M":
863 return "modified";
864 case "D":
865 return "deleted";
866 case "R":
867 default:
868 if (status.toUpperCase().startsWith("R")) {
869 return "renamed";
870 }
871 return "modified";
872 }
873 }
874
875 /**
876 * Get display text for file status
877 */
878 getFileStatusText(status: string): string {
879 switch (status.toUpperCase()) {
880 case "A":
881 return "Added";
882 case "M":
883 return "Modified";
884 case "D":
885 return "Deleted";
886 case "R":
887 default:
888 if (status.toUpperCase().startsWith("R")) {
889 return "Renamed";
890 }
891 return "Modified";
892 }
893 }
894
895 /**
896 * Get changes information (+/-) for display
897 */
898 getChangesInfo(file: GitDiffFile): string {
899 const additions = file.additions || 0;
900 const deletions = file.deletions || 0;
901
902 if (additions === 0 && deletions === 0) {
903 return "";
904 }
905
906 const parts = [];
907 if (additions > 0) {
908 parts.push(`+${additions}`);
909 }
910 if (deletions > 0) {
911 parts.push(`-${deletions}`);
912 }
913
914 return `(${parts.join(", ")})`;
915 }
916
917 /**
918 * Get path information for display, handling renames
919 */
920 getPathInfo(file: GitDiffFile): string {
921 if (file.old_path && file.old_path !== "") {
922 // For renames, show old_path → new_path
923 return `${file.old_path} → ${file.path}`;
924 }
925 // For regular files, just show the path
926 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700927 }
928
929 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000930 * Render expand all icon (dotted line with arrows pointing away)
931 */
932 renderExpandAllIcon() {
933 return html`
934 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
935 <!-- Dotted line in the middle -->
936 <line
937 x1="2"
938 y1="8"
939 x2="14"
940 y2="8"
941 stroke="currentColor"
942 stroke-width="1"
943 stroke-dasharray="2,1"
944 />
945 <!-- Large arrow pointing up -->
946 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
947 <!-- Large arrow pointing down -->
948 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
949 </svg>
950 `;
951 }
952
953 /**
954 * Render collapse icon (arrows pointing towards dotted line)
955 */
956 renderCollapseIcon() {
957 return html`
958 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
959 <!-- Dotted line in the middle -->
960 <line
961 x1="2"
962 y1="8"
963 x2="14"
964 y2="8"
965 stroke="currentColor"
966 stroke-width="1"
967 stroke-dasharray="2,1"
968 />
969 <!-- Large arrow pointing down towards line -->
970 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
971 <!-- Large arrow pointing up towards line -->
972 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
973 </svg>
974 `;
975 }
976
977 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000978 * Handle file selection change from the dropdown
979 */
980 handleFileSelection(event: Event) {
981 const selectElement = event.target as HTMLSelectElement;
982 const selectedValue = selectElement.value;
983
984 this.selectedFile = selectedValue;
985 this.viewMode = selectedValue ? "single" : "all";
986
987 // Force re-render
988 this.requestUpdate();
989 }
990
991 /**
992 * Get display name for file in the selector
993 */
994 getFileDisplayName(file: GitDiffFile): string {
995 const status = this.getFileStatusText(file.status);
996 const pathInfo = this.getPathInfo(file);
997 return `${status}: ${pathInfo}`;
998 }
999
1000 /**
1001 * Render single file view with full-screen Monaco editor
1002 */
1003 renderSingleFileView() {
1004 const selectedFileData = this.files.find(f => f.path === this.selectedFile);
1005 if (!selectedFileData) {
1006 return html`<div class="error">Selected file not found</div>`;
1007 }
1008
1009 const content = this.fileContents.get(this.selectedFile);
1010 if (!content) {
1011 return html`<div class="loading">Loading ${this.selectedFile}...</div>`;
1012 }
1013
1014 return html`
1015 <div class="single-file-view">
1016 <sketch-monaco-view
1017 class="single-file-monaco"
1018 .originalCode="${content.original}"
1019 .modifiedCode="${content.modified}"
1020 .originalFilename="${selectedFileData.path}"
1021 .modifiedFilename="${selectedFileData.path}"
1022 ?readOnly="${!content.editable}"
1023 ?editable-right="${content.editable}"
1024 @monaco-comment="${this.handleMonacoComment}"
1025 @monaco-save="${this.handleMonacoSave}"
1026 data-file-path="${selectedFileData.path}"
1027 ></sketch-monaco-view>
1028 </div>
1029 `;
1030 }
1031
1032 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001033 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +00001034 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001035 * This is called when the Monaco diff tab is activated to ensure:
1036 * 1. Branch information from git/recentlog is current (branches can change frequently)
1037 * 2. The diff content is synchronized with the latest repository state
1038 * 3. Users always see up-to-date information without manual refresh
1039 */
1040 refreshDiffView() {
1041 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +00001042 const rangePicker = this.shadowRoot?.querySelector(
1043 "sketch-diff-range-picker",
1044 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001045 if (rangePicker) {
1046 (rangePicker as any).loadCommits();
1047 }
Autoformatter8c463622025-05-16 21:54:17 +00001048
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001049 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +00001050 // Convert single commit to range (commit^ to commit)
1051 this.currentRange = { type: "range", from: `${this.commit}^`, to: this.commit };
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001052 }
Autoformatter8c463622025-05-16 21:54:17 +00001053
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001054 // Then reload diff data based on the current range
1055 this.loadDiffData();
1056 }
1057}
1058
1059declare global {
1060 interface HTMLElementTagNameMap {
1061 "sketch-diff2-view": SketchDiff2View;
1062 }
1063}