blob: 59f59df475ee2c9a5b55c072d5aafb68927d545a [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
Philip Zeyliger272a90e2025-05-16 14:49:51 -07002import { css, html, LitElement } from "lit";
3import { customElement, property, state } from "lit/decorators.js";
4import "./sketch-monaco-view";
5import "./sketch-diff-range-picker";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07006import "./sketch-diff-empty-view";
philip.zeyliger26bc6592025-06-30 20:15:30 -07007import { GitDiffFile, GitDataService } from "./git-data-service";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07008import { DiffRange } from "./sketch-diff-range-picker";
9
10/**
11 * A component that displays diffs using Monaco editor with range and file pickers
12 */
13@customElement("sketch-diff2-view")
14export class SketchDiff2View extends LitElement {
15 /**
16 * Handles comment events from the Monaco editor and forwards them to the chat input
17 * using the same event format as the original diff view for consistency.
18 */
19 private handleMonacoComment(event: CustomEvent) {
20 try {
21 // Validate incoming data
22 if (!event.detail || !event.detail.formattedComment) {
Autoformatter8c463622025-05-16 21:54:17 +000023 console.error("Invalid comment data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070024 return;
25 }
Autoformatter8c463622025-05-16 21:54:17 +000026
Philip Zeyliger272a90e2025-05-16 14:49:51 -070027 // Create and dispatch event using the standardized format
Autoformatter8c463622025-05-16 21:54:17 +000028 const commentEvent = new CustomEvent("diff-comment", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070029 detail: { comment: event.detail.formattedComment },
30 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +000031 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -070032 });
Autoformatter8c463622025-05-16 21:54:17 +000033
Philip Zeyliger272a90e2025-05-16 14:49:51 -070034 this.dispatchEvent(commentEvent);
35 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +000036 console.error("Error handling Monaco comment:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -070037 }
38 }
Autoformatter8c463622025-05-16 21:54:17 +000039
Philip Zeyliger272a90e2025-05-16 14:49:51 -070040 /**
David Crawshaw26f3f342025-06-14 19:58:32 +000041 * Handle height change events from the Monaco editor
42 */
43 private handleMonacoHeightChange(event: CustomEvent) {
44 try {
45 // Get the monaco view that emitted the event
46 const monacoView = event.target as HTMLElement;
47 if (!monacoView) return;
Autoformatter9abf8032025-06-14 23:24:08 +000048
David Crawshaw26f3f342025-06-14 19:58:32 +000049 // Find the parent file-diff-editor container
Autoformatter9abf8032025-06-14 23:24:08 +000050 const fileDiffEditor = monacoView.closest(
51 ".file-diff-editor",
52 ) as HTMLElement;
David Crawshaw26f3f342025-06-14 19:58:32 +000053 if (!fileDiffEditor) return;
Autoformatter9abf8032025-06-14 23:24:08 +000054
David Crawshaw26f3f342025-06-14 19:58:32 +000055 // Get the new height from the event
56 const newHeight = event.detail.height;
Autoformatter9abf8032025-06-14 23:24:08 +000057
David Crawshaw26f3f342025-06-14 19:58:32 +000058 // Only update if the height actually changed to avoid unnecessary layout
59 const currentHeight = fileDiffEditor.style.height;
60 const newHeightStr = `${newHeight}px`;
Autoformatter9abf8032025-06-14 23:24:08 +000061
David Crawshaw26f3f342025-06-14 19:58:32 +000062 if (currentHeight !== newHeightStr) {
63 // Update the file-diff-editor height to match monaco's height
64 fileDiffEditor.style.height = newHeightStr;
Autoformatter9abf8032025-06-14 23:24:08 +000065
David Crawshaw26f3f342025-06-14 19:58:32 +000066 // Remove any previous min-height constraint that might interfere
Autoformatter9abf8032025-06-14 23:24:08 +000067 fileDiffEditor.style.minHeight = "auto";
68
David Crawshaw26f3f342025-06-14 19:58:32 +000069 // IMPORTANT: Tell Monaco to relayout after its container size changed
70 // Monaco has automaticLayout: false, so it won't detect container changes
71 setTimeout(() => {
72 const monacoComponent = monacoView as any;
73 if (monacoComponent && monacoComponent.editor) {
74 // Force layout with explicit dimensions to ensure Monaco fills the space
75 const editorWidth = fileDiffEditor.offsetWidth;
76 monacoComponent.editor.layout({
77 width: editorWidth,
Autoformatter9abf8032025-06-14 23:24:08 +000078 height: newHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +000079 });
80 }
81 }, 0);
82 }
David Crawshaw26f3f342025-06-14 19:58:32 +000083 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +000084 console.error("Error handling Monaco height change:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +000085 }
86 }
87
88 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -070089 * Handle save events from the Monaco editor
90 */
91 private async handleMonacoSave(event: CustomEvent) {
92 try {
93 // Validate incoming data
Autoformatter8c463622025-05-16 21:54:17 +000094 if (
95 !event.detail ||
96 !event.detail.path ||
97 event.detail.content === undefined
98 ) {
99 console.error("Invalid save data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700100 return;
101 }
Autoformatter8c463622025-05-16 21:54:17 +0000102
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700103 const { path, content } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000104
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700105 // Get Monaco view component
Autoformatter8c463622025-05-16 21:54:17 +0000106 const monacoView = this.shadowRoot?.querySelector("sketch-monaco-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700107 if (!monacoView) {
Autoformatter8c463622025-05-16 21:54:17 +0000108 console.error("Monaco view not found");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700109 return;
110 }
Autoformatter8c463622025-05-16 21:54:17 +0000111
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700112 try {
113 await this.gitService?.saveFileContent(path, content);
114 console.log(`File saved: ${path}`);
115 (monacoView as any).notifySaveComplete(true);
116 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000117 console.error(
118 `Error saving file: ${error instanceof Error ? error.message : String(error)}`,
119 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700120 (monacoView as any).notifySaveComplete(false);
121 }
122 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000123 console.error("Error handling save:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700124 }
125 }
126 @property({ type: String })
127 initialCommit: string = "";
Autoformatter8c463622025-05-16 21:54:17 +0000128
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700129 // The commit to show - used when showing a specific commit from timeline
130 @property({ type: String })
131 commit: string = "";
132
133 @property({ type: String })
134 selectedFilePath: string = "";
135
136 @state()
137 private files: GitDiffFile[] = [];
Autoformatter8c463622025-05-16 21:54:17 +0000138
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700139 @state()
Autoformatter8c463622025-05-16 21:54:17 +0000140 private currentRange: DiffRange = { type: "range", from: "", to: "HEAD" };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700141
142 @state()
Autoformatter9abf8032025-06-14 23:24:08 +0000143 private fileContents: Map<
144 string,
145 { original: string; modified: string; editable: boolean }
146 > = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700147
148 @state()
David Crawshaw26f3f342025-06-14 19:58:32 +0000149 private fileExpandStates: Map<string, boolean> = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700150
151 @state()
152 private loading: boolean = false;
153
154 @state()
155 private error: string | null = null;
156
David Crawshaw4cd01292025-06-15 18:59:13 +0000157 @state()
158 private selectedFile: string = ""; // Empty string means "All files"
159
160 @state()
161 private viewMode: "all" | "single" = "all";
162
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700163 static styles = css`
164 :host {
165 display: flex;
166 height: 100%;
167 flex: 1;
168 flex-direction: column;
169 min-height: 0; /* Critical for flex child behavior */
170 overflow: hidden;
171 position: relative; /* Establish positioning context */
172 }
173
174 .controls {
175 padding: 8px 16px;
176 border-bottom: 1px solid var(--border-color, #e0e0e0);
177 background-color: var(--background-light, #f8f8f8);
178 flex-shrink: 0; /* Prevent controls from shrinking */
179 }
Autoformatter8c463622025-05-16 21:54:17 +0000180
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700181 .controls-container {
182 display: flex;
183 flex-direction: column;
184 gap: 12px;
185 }
Autoformatter8c463622025-05-16 21:54:17 +0000186
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700187 .range-row {
188 width: 100%;
189 display: flex;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700190 align-items: center;
David Crawshawdbca8972025-06-14 23:46:58 +0000191 gap: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700192 }
Autoformatter8c463622025-05-16 21:54:17 +0000193
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700194 .file-selector-container {
195 display: flex;
196 align-items: center;
197 gap: 8px;
198 }
199
David Crawshaw4cd01292025-06-15 18:59:13 +0000200 .file-selector {
201 min-width: 200px;
202 padding: 8px 12px;
203 border: 1px solid var(--border-color, #ccc);
204 border-radius: 4px;
205 background-color: var(--background-color, #fff);
206 font-family: var(--font-family, system-ui, sans-serif);
207 font-size: 14px;
208 cursor: pointer;
209 }
210
211 .file-selector:focus {
212 outline: none;
213 border-color: var(--accent-color, #007acc);
214 box-shadow: 0 0 0 2px var(--accent-color-light, rgba(0, 122, 204, 0.2));
215 }
216
David Crawshaw5c6d8292025-06-15 19:09:19 +0000217 .file-selector:disabled {
218 background-color: var(--background-disabled, #f5f5f5);
219 color: var(--text-disabled, #999);
220 cursor: not-allowed;
221 }
222
223 .spacer {
224 flex: 1;
225 }
226
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700227 sketch-diff-range-picker {
David Crawshawdbca8972025-06-14 23:46:58 +0000228 flex: 1;
David Crawshawe2954ce2025-06-15 00:06:34 +0000229 min-width: 400px; /* Ensure minimum width for range picker */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700230 }
Autoformatter8c463622025-05-16 21:54:17 +0000231
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700232 .view-toggle-button,
233 .header-expand-button {
234 background-color: transparent;
235 border: 1px solid var(--border-color, #e0e0e0);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700236 border-radius: 4px;
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700237 padding: 6px 8px;
238 font-size: 14px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700239 cursor: pointer;
240 white-space: nowrap;
241 transition: background-color 0.2s;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000242 display: flex;
243 align-items: center;
244 justify-content: center;
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700245 min-width: 32px;
246 min-height: 32px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700247 }
Autoformatter8c463622025-05-16 21:54:17 +0000248
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700249 .view-toggle-button:hover,
250 .header-expand-button:hover {
251 background-color: var(--background-hover, #e8e8e8);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700252 }
253
254 .diff-container {
255 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000256 overflow: auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700257 display: flex;
258 flex-direction: column;
David Crawshaw26f3f342025-06-14 19:58:32 +0000259 min-height: 0;
260 position: relative;
261 height: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700262 }
263
264 .diff-content {
265 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000266 overflow: auto;
267 min-height: 0;
268 display: flex;
269 flex-direction: column;
270 position: relative;
271 height: 100%;
272 }
273
274 .multi-file-diff-container {
275 display: flex;
276 flex-direction: column;
277 width: 100%;
278 min-height: 100%;
279 }
280
281 .file-diff-section {
282 display: flex;
283 flex-direction: column;
284 border-bottom: 3px solid var(--border-color, #e0e0e0);
285 margin-bottom: 0;
286 }
287
288 .file-diff-section:last-child {
289 border-bottom: none;
290 }
291
292 .file-header {
293 background-color: var(--background-light, #f8f8f8);
294 border-bottom: 1px solid var(--border-color, #e0e0e0);
David Crawshawdbca8972025-06-14 23:46:58 +0000295 padding: 8px 16px;
David Crawshaw26f3f342025-06-14 19:58:32 +0000296 font-family: var(--font-family, system-ui, sans-serif);
297 font-weight: 500;
298 font-size: 14px;
299 color: var(--text-primary-color, #333);
300 position: sticky;
301 top: 0;
302 z-index: 10;
303 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
304 display: flex;
305 justify-content: space-between;
306 align-items: center;
307 }
308
309 .file-header-left {
310 display: flex;
311 align-items: center;
312 gap: 8px;
313 }
314
315 .file-header-right {
316 display: flex;
317 align-items: center;
318 }
319
320 .file-expand-button {
321 background-color: transparent;
322 border: 1px solid var(--border-color, #e0e0e0);
323 border-radius: 4px;
324 padding: 4px 8px;
325 font-size: 14px;
326 cursor: pointer;
327 transition: background-color 0.2s;
328 display: flex;
329 align-items: center;
330 justify-content: center;
331 min-width: 32px;
332 min-height: 32px;
333 }
334
335 .file-expand-button:hover {
336 background-color: var(--background-hover, #e8e8e8);
337 }
338
339 .file-path {
340 font-family: monospace;
341 font-weight: normal;
342 color: var(--text-secondary-color, #666);
343 }
344
345 .file-status {
346 display: inline-block;
347 padding: 2px 6px;
348 border-radius: 3px;
349 font-size: 12px;
350 font-weight: bold;
351 margin-right: 8px;
352 }
353
354 .file-status.added {
355 background-color: #d4edda;
356 color: #155724;
357 }
358
359 .file-status.modified {
360 background-color: #fff3cd;
361 color: #856404;
362 }
363
364 .file-status.deleted {
365 background-color: #f8d7da;
366 color: #721c24;
367 }
368
369 .file-status.renamed {
370 background-color: #d1ecf1;
371 color: #0c5460;
372 }
373
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000374 .file-status.copied {
375 background-color: #e2e3ff;
376 color: #383d41;
377 }
378
David Crawshaw26f3f342025-06-14 19:58:32 +0000379 .file-changes {
380 margin-left: 8px;
381 font-size: 12px;
382 color: var(--text-secondary-color, #666);
383 }
384
385 .file-diff-editor {
386 display: flex;
387 flex-direction: column;
388 min-height: 200px;
389 /* Height will be set dynamically by monaco editor */
390 overflow: visible; /* Ensure content is not clipped */
391 }
392
Autoformatter8c463622025-05-16 21:54:17 +0000393 .loading,
394 .empty-diff {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700395 display: flex;
396 align-items: center;
397 justify-content: center;
398 height: 100%;
399 font-family: var(--font-family, system-ui, sans-serif);
400 }
Autoformatter8c463622025-05-16 21:54:17 +0000401
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700402 .empty-diff {
403 color: var(--text-secondary-color, #666);
404 font-size: 16px;
405 text-align: center;
406 }
407
408 .error {
409 color: var(--error-color, #dc3545);
410 padding: 16px;
411 font-family: var(--font-family, system-ui, sans-serif);
412 }
413
414 sketch-monaco-view {
415 --editor-width: 100%;
416 --editor-height: 100%;
David Crawshaw26f3f342025-06-14 19:58:32 +0000417 display: flex;
418 flex-direction: column;
419 width: 100%;
420 min-height: 200px;
421 /* Ensure Monaco view takes full container space */
422 flex: 1;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700423 }
David Crawshaw4cd01292025-06-15 18:59:13 +0000424
425 /* Single file view styles */
426 .single-file-view {
427 flex: 1;
428 display: flex;
429 flex-direction: column;
430 height: 100%;
431 min-height: 0;
432 }
433
434 .single-file-monaco {
435 flex: 1;
436 width: 100%;
437 height: 100%;
438 min-height: 0;
439 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700440 `;
441
442 @property({ attribute: false, type: Object })
443 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000444
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700445 // The gitService must be passed from parent to ensure proper dependency injection
446
447 constructor() {
448 super();
Autoformatter8c463622025-05-16 21:54:17 +0000449 console.log("SketchDiff2View initialized");
450
David Crawshawe2954ce2025-06-15 00:06:34 +0000451 // Fix for monaco-aria-container positioning and hide scrollbars globally
452 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000453 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700454 styleElement.textContent = `
455 .monaco-aria-container {
456 position: absolute !important;
457 top: 0 !important;
458 left: 0 !important;
459 width: 1px !important;
460 height: 1px !important;
461 overflow: hidden !important;
462 clip: rect(1px, 1px, 1px, 1px) !important;
463 white-space: nowrap !important;
464 margin: 0 !important;
465 padding: 0 !important;
466 border: 0 !important;
467 z-index: -1 !important;
468 }
David Crawshawe2954ce2025-06-15 00:06:34 +0000469
470 /* Aggressively hide all Monaco scrollbar elements */
471 .monaco-editor .scrollbar,
472 .monaco-editor .scroll-decoration,
473 .monaco-editor .invisible.scrollbar,
474 .monaco-editor .slider,
475 .monaco-editor .vertical.scrollbar,
476 .monaco-editor .horizontal.scrollbar,
477 .monaco-diff-editor .scrollbar,
478 .monaco-diff-editor .scroll-decoration,
479 .monaco-diff-editor .invisible.scrollbar,
480 .monaco-diff-editor .slider,
481 .monaco-diff-editor .vertical.scrollbar,
482 .monaco-diff-editor .horizontal.scrollbar {
483 display: none !important;
484 visibility: hidden !important;
485 width: 0 !important;
486 height: 0 !important;
487 opacity: 0 !important;
488 }
489
490 /* Target the specific scrollbar classes that Monaco uses */
491 .monaco-scrollable-element > .scrollbar,
492 .monaco-scrollable-element > .scroll-decoration,
493 .monaco-scrollable-element .slider {
494 display: none !important;
495 visibility: hidden !important;
496 width: 0 !important;
497 height: 0 !important;
498 }
499
500 /* Remove scrollbar space/padding from content area */
501 .monaco-editor .monaco-scrollable-element,
502 .monaco-diff-editor .monaco-scrollable-element {
503 padding-right: 0 !important;
504 padding-bottom: 0 !important;
505 margin-right: 0 !important;
506 margin-bottom: 0 !important;
507 }
508
509 /* Ensure the diff content takes full width without scrollbar space */
510 .monaco-diff-editor .editor.modified,
511 .monaco-diff-editor .editor.original {
512 margin-right: 0 !important;
513 padding-right: 0 !important;
514 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700515 `;
516 document.head.appendChild(styleElement);
517 }
518
519 connectedCallback() {
520 super.connectedCallback();
521 // Initialize with default range and load data
522 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000523 if (
524 this.currentRange.type === "range" &&
525 !("from" in this.currentRange && this.currentRange.from)
526 ) {
527 this.gitService
528 .getBaseCommitRef()
529 .then((baseRef) => {
530 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
531 this.loadDiffData();
532 })
533 .catch((error) => {
534 console.error("Error getting base commit ref:", error);
535 // Use default range
536 this.loadDiffData();
537 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700538 } else {
539 this.loadDiffData();
540 }
541 }
542
David Crawshaw26f3f342025-06-14 19:58:32 +0000543 // Toggle hideUnchangedRegions setting for a specific file
544 private toggleFileExpansion(filePath: string) {
545 const currentState = this.fileExpandStates.get(filePath) ?? false;
546 const newState = !currentState;
547 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000548
David Crawshaw26f3f342025-06-14 19:58:32 +0000549 // Apply to the specific Monaco view component for this file
Autoformatter9abf8032025-06-14 23:24:08 +0000550 const monacoView = this.shadowRoot?.querySelector(
551 `sketch-monaco-view[data-file-path="${filePath}"]`,
552 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700553 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000554 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700555 }
Autoformatter9abf8032025-06-14 23:24:08 +0000556
David Crawshaw26f3f342025-06-14 19:58:32 +0000557 // Force a re-render to update the button state
558 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700559 }
Autoformatter8c463622025-05-16 21:54:17 +0000560
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700561 render() {
562 return html`
563 <div class="controls">
564 <div class="controls-container">
565 <div class="range-row">
566 <sketch-diff-range-picker
567 .gitService="${this.gitService}"
568 @range-change="${this.handleRangeChange}"
569 ></sketch-diff-range-picker>
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700570 <div class="spacer"></div>
571 ${this.renderFileSelector()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700572 </div>
573 </div>
574 </div>
575
576 <div class="diff-container">
Autoformatter8c463622025-05-16 21:54:17 +0000577 <div class="diff-content">${this.renderDiffContent()}</div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700578 </div>
579 `;
580 }
581
David Crawshaw4cd01292025-06-15 18:59:13 +0000582 renderFileSelector() {
David Crawshaw5c6d8292025-06-15 19:09:19 +0000583 const fileCount = this.files.length;
Autoformatter62554112025-06-15 19:23:33 +0000584
David Crawshaw4cd01292025-06-15 18:59:13 +0000585 return html`
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700586 <div class="file-selector-container">
587 <select
588 class="file-selector"
589 .value="${this.selectedFile}"
590 @change="${this.handleFileSelection}"
591 ?disabled="${fileCount === 0}"
592 >
593 <option value="">All files (${fileCount})</option>
594 ${this.files.map(
595 (file) => html`
596 <option value="${file.path}">
597 ${this.getFileDisplayName(file)}
598 </option>
599 `,
600 )}
601 </select>
602 ${this.selectedFile ? this.renderSingleFileExpandButton() : ""}
603 </div>
David Crawshaw4cd01292025-06-15 18:59:13 +0000604 `;
605 }
606
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700607 renderDiffContent() {
608 if (this.loading) {
609 return html`<div class="loading">Loading diff...</div>`;
610 }
611
612 if (this.error) {
613 return html`<div class="error">${this.error}</div>`;
614 }
615
616 if (this.files.length === 0) {
617 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
618 }
Autoformatter8c463622025-05-16 21:54:17 +0000619
David Crawshaw4cd01292025-06-15 18:59:13 +0000620 // Render single file view if a specific file is selected
621 if (this.selectedFile && this.viewMode === "single") {
622 return this.renderSingleFileView();
623 }
624
625 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700626 return html`
David Crawshaw26f3f342025-06-14 19:58:32 +0000627 <div class="multi-file-diff-container">
628 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
629 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700630 `;
631 }
632
633 /**
634 * Load diff data for the current range
635 */
636 async loadDiffData() {
637 this.loading = true;
638 this.error = null;
639
640 try {
641 // Initialize files as empty array if undefined
642 if (!this.files) {
643 this.files = [];
644 }
645
David Crawshaw216d2fc2025-06-15 18:45:53 +0000646 // Load diff data for the range
647 this.files = await this.gitService.getDiff(
648 this.currentRange.from,
649 this.currentRange.to,
650 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700651
652 // Ensure files is always an array, even when API returns null
653 if (!this.files) {
654 this.files = [];
655 }
Autoformatter8c463622025-05-16 21:54:17 +0000656
David Crawshaw26f3f342025-06-14 19:58:32 +0000657 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700658 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000659 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000660 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000661 if (!this.fileExpandStates.has(file.path)) {
662 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
663 }
664 });
665 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700666 } else {
667 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000668 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000669 this.selectedFile = "";
670 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000671 this.fileContents.clear();
672 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700673 }
674 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000675 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700676 this.error = `Error loading diff data: ${error.message}`;
677 // Ensure files is an empty array when an error occurs
678 this.files = [];
679 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000680 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000681 this.selectedFile = "";
682 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000683 this.fileContents.clear();
684 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700685 } finally {
686 this.loading = false;
687 }
688 }
689
690 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000691 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700692 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000693 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700694 this.loading = true;
695 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000696 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700697
698 try {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700699 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000700
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700701 // Determine the commits to compare based on the current range
philip.zeyliger26bc6592025-06-30 20:15:30 -0700702 const _fromCommit = this.currentRange.from;
703 const toCommit = this.currentRange.to;
David Crawshaw216d2fc2025-06-15 18:45:53 +0000704 // Check if this is an unstaged changes view
705 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700706
David Crawshaw26f3f342025-06-14 19:58:32 +0000707 // Load content for all files
708 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700709 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000710 let originalCode = "";
711 let modifiedCode = "";
712 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000713
David Crawshaw26f3f342025-06-14 19:58:32 +0000714 // Load the original code based on file status
715 if (file.status !== "A") {
716 // For modified, renamed, or deleted files: load original content
717 originalCode = await this.gitService.getFileContent(
718 file.old_hash || "",
719 );
720 }
721
722 // For modified code, always use working copy when editable
723 if (editable) {
724 try {
725 // Always use working copy when editable, regardless of diff status
726 modifiedCode = await this.gitService.getWorkingCopyContent(
727 file.path,
728 );
729 } catch (error) {
730 if (file.status === "D") {
731 // For deleted files, silently use empty content
732 console.warn(
733 `Could not get working copy for deleted file ${file.path}, using empty content`,
734 );
735 modifiedCode = "";
736 } else {
737 // For any other file status, propagate the error
738 console.error(
739 `Failed to get working copy for ${file.path}:`,
740 error,
741 );
742 throw error;
743 }
744 }
745 } else {
746 // For non-editable view, use git content based on file status
747 if (file.status === "D") {
748 // Deleted file: empty modified
749 modifiedCode = "";
750 } else {
751 // Added/modified/renamed: use the content from git
752 modifiedCode = await this.gitService.getFileContent(
753 file.new_hash || "",
754 );
755 }
756 }
757
758 // Don't make deleted files editable
759 if (file.status === "D") {
760 editable = false;
761 }
762
763 this.fileContents.set(file.path, {
764 original: originalCode,
765 modified: modifiedCode,
766 editable,
767 });
768 } catch (error) {
769 console.error(`Error loading content for file ${file.path}:`, error);
770 // Store empty content for failed files to prevent blocking
771 this.fileContents.set(file.path, {
772 original: "",
773 modified: "",
774 editable: false,
775 });
776 }
777 });
778
779 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700780 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000781 console.error("Error loading file contents:", error);
782 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700783 } finally {
784 this.loading = false;
785 }
786 }
787
788 /**
789 * Handle range change event from the range picker
790 */
791 handleRangeChange(event: CustomEvent) {
792 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000793 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700794 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000795
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700796 // Load diff data for the new range
797 this.loadDiffData();
798 }
799
800 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000801 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700802 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000803 renderFileDiff(file: GitDiffFile, index: number) {
804 const content = this.fileContents.get(file.path);
805 if (!content) {
806 return html`
807 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000808 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000809 <div class="loading">Loading ${file.path}...</div>
810 </div>
811 `;
812 }
813
814 return html`
815 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000816 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000817 <div class="file-diff-editor">
818 <sketch-monaco-view
819 .originalCode="${content.original}"
820 .modifiedCode="${content.modified}"
821 .originalFilename="${file.path}"
822 .modifiedFilename="${file.path}"
823 ?readOnly="${!content.editable}"
824 ?editable-right="${content.editable}"
825 @monaco-comment="${this.handleMonacoComment}"
826 @monaco-save="${this.handleMonacoSave}"
827 @monaco-height-changed="${this.handleMonacoHeightChange}"
828 data-file-index="${index}"
829 data-file-path="${file.path}"
830 ></sketch-monaco-view>
831 </div>
832 </div>
833 `;
834 }
835
836 /**
837 * Render file header with status and path info
838 */
839 renderFileHeader(file: GitDiffFile) {
840 const statusClass = this.getFileStatusClass(file.status);
841 const statusText = this.getFileStatusText(file.status);
842 const changesInfo = this.getChangesInfo(file);
843 const pathInfo = this.getPathInfo(file);
844
845 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000846
David Crawshaw26f3f342025-06-14 19:58:32 +0000847 return html`
848 <div class="file-header-left">
849 <span class="file-status ${statusClass}">${statusText}</span>
850 <span class="file-path">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000851 ${changesInfo
852 ? html`<span class="file-changes">${changesInfo}</span>`
853 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000854 </div>
855 <div class="file-header-right">
856 <button
857 class="file-expand-button"
858 @click="${() => this.toggleFileExpansion(file.path)}"
859 title="${isExpanded
860 ? "Collapse: Hide unchanged regions to focus on changes"
861 : "Expand: Show all lines including unchanged regions"}"
862 >
Autoformatter9abf8032025-06-14 23:24:08 +0000863 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000864 </button>
865 </div>
866 `;
867 }
868
869 /**
870 * Get CSS class for file status
871 */
872 getFileStatusClass(status: string): string {
873 switch (status.toUpperCase()) {
874 case "A":
875 return "added";
876 case "M":
877 return "modified";
878 case "D":
879 return "deleted";
880 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000881 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000882 default:
883 if (status.toUpperCase().startsWith("R")) {
884 return "renamed";
885 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000886 if (status.toUpperCase().startsWith("C")) {
887 return "copied";
888 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000889 return "modified";
890 }
891 }
892
893 /**
894 * Get display text for file status
895 */
896 getFileStatusText(status: string): string {
897 switch (status.toUpperCase()) {
898 case "A":
899 return "Added";
900 case "M":
901 return "Modified";
902 case "D":
903 return "Deleted";
904 case "R":
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000905 case "C":
David Crawshaw26f3f342025-06-14 19:58:32 +0000906 default:
907 if (status.toUpperCase().startsWith("R")) {
908 return "Renamed";
909 }
Josh Bleecher Snyderb3aff882025-07-01 02:17:27 +0000910 if (status.toUpperCase().startsWith("C")) {
911 return "Copied";
912 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000913 return "Modified";
914 }
915 }
916
917 /**
918 * Get changes information (+/-) for display
919 */
920 getChangesInfo(file: GitDiffFile): string {
921 const additions = file.additions || 0;
922 const deletions = file.deletions || 0;
923
924 if (additions === 0 && deletions === 0) {
925 return "";
926 }
927
928 const parts = [];
929 if (additions > 0) {
930 parts.push(`+${additions}`);
931 }
932 if (deletions > 0) {
933 parts.push(`-${deletions}`);
934 }
935
936 return `(${parts.join(", ")})`;
937 }
938
939 /**
940 * Get path information for display, handling renames
941 */
942 getPathInfo(file: GitDiffFile): string {
943 if (file.old_path && file.old_path !== "") {
944 // For renames, show old_path → new_path
945 return `${file.old_path} → ${file.path}`;
946 }
947 // For regular files, just show the path
948 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700949 }
950
951 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000952 * Render expand all icon (dotted line with arrows pointing away)
953 */
954 renderExpandAllIcon() {
955 return html`
956 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
957 <!-- Dotted line in the middle -->
958 <line
959 x1="2"
960 y1="8"
961 x2="14"
962 y2="8"
963 stroke="currentColor"
964 stroke-width="1"
965 stroke-dasharray="2,1"
966 />
967 <!-- Large arrow pointing up -->
968 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
969 <!-- Large arrow pointing down -->
970 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
971 </svg>
972 `;
973 }
974
975 /**
976 * Render collapse icon (arrows pointing towards dotted line)
977 */
978 renderCollapseIcon() {
979 return html`
980 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
981 <!-- Dotted line in the middle -->
982 <line
983 x1="2"
984 y1="8"
985 x2="14"
986 y2="8"
987 stroke="currentColor"
988 stroke-width="1"
989 stroke-dasharray="2,1"
990 />
991 <!-- Large arrow pointing down towards line -->
992 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
993 <!-- Large arrow pointing up towards line -->
994 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
995 </svg>
996 `;
997 }
998
999 /**
David Crawshaw4cd01292025-06-15 18:59:13 +00001000 * Handle file selection change from the dropdown
1001 */
1002 handleFileSelection(event: Event) {
1003 const selectElement = event.target as HTMLSelectElement;
1004 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +00001005
David Crawshaw4cd01292025-06-15 18:59:13 +00001006 this.selectedFile = selectedValue;
1007 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +00001008
David Crawshaw4cd01292025-06-15 18:59:13 +00001009 // Force re-render
1010 this.requestUpdate();
1011 }
1012
1013 /**
1014 * Get display name for file in the selector
1015 */
1016 getFileDisplayName(file: GitDiffFile): string {
1017 const status = this.getFileStatusText(file.status);
1018 const pathInfo = this.getPathInfo(file);
1019 return `${status}: ${pathInfo}`;
1020 }
1021
1022 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -07001023 * Render expand/collapse button for single file view in header
1024 */
1025 renderSingleFileExpandButton() {
1026 if (!this.selectedFile) return "";
1027
1028 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
1029
1030 return html`
1031 <button
1032 class="header-expand-button"
1033 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
1034 title="${isExpanded
1035 ? "Collapse: Hide unchanged regions to focus on changes"
1036 : "Expand: Show all lines including unchanged regions"}"
1037 >
1038 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
1039 </button>
1040 `;
1041 }
1042
1043 /**
David Crawshaw4cd01292025-06-15 18:59:13 +00001044 * Render single file view with full-screen Monaco editor
1045 */
1046 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +00001047 const selectedFileData = this.files.find(
1048 (f) => f.path === this.selectedFile,
1049 );
David Crawshaw4cd01292025-06-15 18:59:13 +00001050 if (!selectedFileData) {
1051 return html`<div class="error">Selected file not found</div>`;
1052 }
1053
1054 const content = this.fileContents.get(this.selectedFile);
1055 if (!content) {
1056 return html`<div class="loading">Loading ${this.selectedFile}...</div>`;
1057 }
1058
1059 return html`
1060 <div class="single-file-view">
1061 <sketch-monaco-view
1062 class="single-file-monaco"
1063 .originalCode="${content.original}"
1064 .modifiedCode="${content.modified}"
1065 .originalFilename="${selectedFileData.path}"
1066 .modifiedFilename="${selectedFileData.path}"
1067 ?readOnly="${!content.editable}"
1068 ?editable-right="${content.editable}"
1069 @monaco-comment="${this.handleMonacoComment}"
1070 @monaco-save="${this.handleMonacoSave}"
1071 data-file-path="${selectedFileData.path}"
1072 ></sketch-monaco-view>
1073 </div>
1074 `;
1075 }
1076
1077 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001078 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +00001079 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001080 * This is called when the Monaco diff tab is activated to ensure:
1081 * 1. Branch information from git/recentlog is current (branches can change frequently)
1082 * 2. The diff content is synchronized with the latest repository state
1083 * 3. Users always see up-to-date information without manual refresh
1084 */
1085 refreshDiffView() {
1086 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +00001087 const rangePicker = this.shadowRoot?.querySelector(
1088 "sketch-diff-range-picker",
1089 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001090 if (rangePicker) {
1091 (rangePicker as any).loadCommits();
1092 }
Autoformatter8c463622025-05-16 21:54:17 +00001093
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001094 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +00001095 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +00001096 this.currentRange = {
1097 type: "range",
1098 from: `${this.commit}^`,
1099 to: this.commit,
1100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001101 }
Autoformatter8c463622025-05-16 21:54:17 +00001102
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001103 // Then reload diff data based on the current range
1104 this.loadDiffData();
1105 }
1106}
1107
1108declare global {
1109 interface HTMLElementTagNameMap {
1110 "sketch-diff2-view": SketchDiff2View;
1111 }
1112}