blob: 650826c5c5b2ec7fc07d9b80ccd964fb077c66c5 [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
374 .file-changes {
375 margin-left: 8px;
376 font-size: 12px;
377 color: var(--text-secondary-color, #666);
378 }
379
380 .file-diff-editor {
381 display: flex;
382 flex-direction: column;
383 min-height: 200px;
384 /* Height will be set dynamically by monaco editor */
385 overflow: visible; /* Ensure content is not clipped */
386 }
387
Autoformatter8c463622025-05-16 21:54:17 +0000388 .loading,
389 .empty-diff {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700390 display: flex;
391 align-items: center;
392 justify-content: center;
393 height: 100%;
394 font-family: var(--font-family, system-ui, sans-serif);
395 }
Autoformatter8c463622025-05-16 21:54:17 +0000396
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700397 .empty-diff {
398 color: var(--text-secondary-color, #666);
399 font-size: 16px;
400 text-align: center;
401 }
402
403 .error {
404 color: var(--error-color, #dc3545);
405 padding: 16px;
406 font-family: var(--font-family, system-ui, sans-serif);
407 }
408
409 sketch-monaco-view {
410 --editor-width: 100%;
411 --editor-height: 100%;
David Crawshaw26f3f342025-06-14 19:58:32 +0000412 display: flex;
413 flex-direction: column;
414 width: 100%;
415 min-height: 200px;
416 /* Ensure Monaco view takes full container space */
417 flex: 1;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700418 }
David Crawshaw4cd01292025-06-15 18:59:13 +0000419
420 /* Single file view styles */
421 .single-file-view {
422 flex: 1;
423 display: flex;
424 flex-direction: column;
425 height: 100%;
426 min-height: 0;
427 }
428
429 .single-file-monaco {
430 flex: 1;
431 width: 100%;
432 height: 100%;
433 min-height: 0;
434 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700435 `;
436
437 @property({ attribute: false, type: Object })
438 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000439
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700440 // The gitService must be passed from parent to ensure proper dependency injection
441
442 constructor() {
443 super();
Autoformatter8c463622025-05-16 21:54:17 +0000444 console.log("SketchDiff2View initialized");
445
David Crawshawe2954ce2025-06-15 00:06:34 +0000446 // Fix for monaco-aria-container positioning and hide scrollbars globally
447 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000448 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700449 styleElement.textContent = `
450 .monaco-aria-container {
451 position: absolute !important;
452 top: 0 !important;
453 left: 0 !important;
454 width: 1px !important;
455 height: 1px !important;
456 overflow: hidden !important;
457 clip: rect(1px, 1px, 1px, 1px) !important;
458 white-space: nowrap !important;
459 margin: 0 !important;
460 padding: 0 !important;
461 border: 0 !important;
462 z-index: -1 !important;
463 }
David Crawshawe2954ce2025-06-15 00:06:34 +0000464
465 /* Aggressively hide all Monaco scrollbar elements */
466 .monaco-editor .scrollbar,
467 .monaco-editor .scroll-decoration,
468 .monaco-editor .invisible.scrollbar,
469 .monaco-editor .slider,
470 .monaco-editor .vertical.scrollbar,
471 .monaco-editor .horizontal.scrollbar,
472 .monaco-diff-editor .scrollbar,
473 .monaco-diff-editor .scroll-decoration,
474 .monaco-diff-editor .invisible.scrollbar,
475 .monaco-diff-editor .slider,
476 .monaco-diff-editor .vertical.scrollbar,
477 .monaco-diff-editor .horizontal.scrollbar {
478 display: none !important;
479 visibility: hidden !important;
480 width: 0 !important;
481 height: 0 !important;
482 opacity: 0 !important;
483 }
484
485 /* Target the specific scrollbar classes that Monaco uses */
486 .monaco-scrollable-element > .scrollbar,
487 .monaco-scrollable-element > .scroll-decoration,
488 .monaco-scrollable-element .slider {
489 display: none !important;
490 visibility: hidden !important;
491 width: 0 !important;
492 height: 0 !important;
493 }
494
495 /* Remove scrollbar space/padding from content area */
496 .monaco-editor .monaco-scrollable-element,
497 .monaco-diff-editor .monaco-scrollable-element {
498 padding-right: 0 !important;
499 padding-bottom: 0 !important;
500 margin-right: 0 !important;
501 margin-bottom: 0 !important;
502 }
503
504 /* Ensure the diff content takes full width without scrollbar space */
505 .monaco-diff-editor .editor.modified,
506 .monaco-diff-editor .editor.original {
507 margin-right: 0 !important;
508 padding-right: 0 !important;
509 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700510 `;
511 document.head.appendChild(styleElement);
512 }
513
514 connectedCallback() {
515 super.connectedCallback();
516 // Initialize with default range and load data
517 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000518 if (
519 this.currentRange.type === "range" &&
520 !("from" in this.currentRange && this.currentRange.from)
521 ) {
522 this.gitService
523 .getBaseCommitRef()
524 .then((baseRef) => {
525 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
526 this.loadDiffData();
527 })
528 .catch((error) => {
529 console.error("Error getting base commit ref:", error);
530 // Use default range
531 this.loadDiffData();
532 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700533 } else {
534 this.loadDiffData();
535 }
536 }
537
David Crawshaw26f3f342025-06-14 19:58:32 +0000538 // Toggle hideUnchangedRegions setting for a specific file
539 private toggleFileExpansion(filePath: string) {
540 const currentState = this.fileExpandStates.get(filePath) ?? false;
541 const newState = !currentState;
542 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000543
David Crawshaw26f3f342025-06-14 19:58:32 +0000544 // Apply to the specific Monaco view component for this file
Autoformatter9abf8032025-06-14 23:24:08 +0000545 const monacoView = this.shadowRoot?.querySelector(
546 `sketch-monaco-view[data-file-path="${filePath}"]`,
547 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700548 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000549 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700550 }
Autoformatter9abf8032025-06-14 23:24:08 +0000551
David Crawshaw26f3f342025-06-14 19:58:32 +0000552 // Force a re-render to update the button state
553 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700554 }
Autoformatter8c463622025-05-16 21:54:17 +0000555
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700556 render() {
557 return html`
558 <div class="controls">
559 <div class="controls-container">
560 <div class="range-row">
561 <sketch-diff-range-picker
562 .gitService="${this.gitService}"
563 @range-change="${this.handleRangeChange}"
564 ></sketch-diff-range-picker>
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700565 <div class="spacer"></div>
566 ${this.renderFileSelector()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700567 </div>
568 </div>
569 </div>
570
571 <div class="diff-container">
Autoformatter8c463622025-05-16 21:54:17 +0000572 <div class="diff-content">${this.renderDiffContent()}</div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700573 </div>
574 `;
575 }
576
David Crawshaw4cd01292025-06-15 18:59:13 +0000577 renderFileSelector() {
David Crawshaw5c6d8292025-06-15 19:09:19 +0000578 const fileCount = this.files.length;
Autoformatter62554112025-06-15 19:23:33 +0000579
David Crawshaw4cd01292025-06-15 18:59:13 +0000580 return html`
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700581 <div class="file-selector-container">
582 <select
583 class="file-selector"
584 .value="${this.selectedFile}"
585 @change="${this.handleFileSelection}"
586 ?disabled="${fileCount === 0}"
587 >
588 <option value="">All files (${fileCount})</option>
589 ${this.files.map(
590 (file) => html`
591 <option value="${file.path}">
592 ${this.getFileDisplayName(file)}
593 </option>
594 `,
595 )}
596 </select>
597 ${this.selectedFile ? this.renderSingleFileExpandButton() : ""}
598 </div>
David Crawshaw4cd01292025-06-15 18:59:13 +0000599 `;
600 }
601
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700602 renderDiffContent() {
603 if (this.loading) {
604 return html`<div class="loading">Loading diff...</div>`;
605 }
606
607 if (this.error) {
608 return html`<div class="error">${this.error}</div>`;
609 }
610
611 if (this.files.length === 0) {
612 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
613 }
Autoformatter8c463622025-05-16 21:54:17 +0000614
David Crawshaw4cd01292025-06-15 18:59:13 +0000615 // Render single file view if a specific file is selected
616 if (this.selectedFile && this.viewMode === "single") {
617 return this.renderSingleFileView();
618 }
619
620 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700621 return html`
David Crawshaw26f3f342025-06-14 19:58:32 +0000622 <div class="multi-file-diff-container">
623 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
624 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700625 `;
626 }
627
628 /**
629 * Load diff data for the current range
630 */
631 async loadDiffData() {
632 this.loading = true;
633 this.error = null;
634
635 try {
636 // Initialize files as empty array if undefined
637 if (!this.files) {
638 this.files = [];
639 }
640
David Crawshaw216d2fc2025-06-15 18:45:53 +0000641 // Load diff data for the range
642 this.files = await this.gitService.getDiff(
643 this.currentRange.from,
644 this.currentRange.to,
645 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700646
647 // Ensure files is always an array, even when API returns null
648 if (!this.files) {
649 this.files = [];
650 }
Autoformatter8c463622025-05-16 21:54:17 +0000651
David Crawshaw26f3f342025-06-14 19:58:32 +0000652 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700653 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000654 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000655 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000656 if (!this.fileExpandStates.has(file.path)) {
657 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
658 }
659 });
660 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700661 } else {
662 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000663 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000664 this.selectedFile = "";
665 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000666 this.fileContents.clear();
667 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700668 }
669 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000670 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700671 this.error = `Error loading diff data: ${error.message}`;
672 // Ensure files is an empty array when an error occurs
673 this.files = [];
674 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000675 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000676 this.selectedFile = "";
677 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000678 this.fileContents.clear();
679 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700680 } finally {
681 this.loading = false;
682 }
683 }
684
685 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000686 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700687 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000688 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700689 this.loading = true;
690 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000691 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700692
693 try {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700694 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000695
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700696 // Determine the commits to compare based on the current range
philip.zeyliger26bc6592025-06-30 20:15:30 -0700697 const _fromCommit = this.currentRange.from;
698 const toCommit = this.currentRange.to;
David Crawshaw216d2fc2025-06-15 18:45:53 +0000699 // Check if this is an unstaged changes view
700 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700701
David Crawshaw26f3f342025-06-14 19:58:32 +0000702 // Load content for all files
703 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700704 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000705 let originalCode = "";
706 let modifiedCode = "";
707 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000708
David Crawshaw26f3f342025-06-14 19:58:32 +0000709 // Load the original code based on file status
710 if (file.status !== "A") {
711 // For modified, renamed, or deleted files: load original content
712 originalCode = await this.gitService.getFileContent(
713 file.old_hash || "",
714 );
715 }
716
717 // For modified code, always use working copy when editable
718 if (editable) {
719 try {
720 // Always use working copy when editable, regardless of diff status
721 modifiedCode = await this.gitService.getWorkingCopyContent(
722 file.path,
723 );
724 } catch (error) {
725 if (file.status === "D") {
726 // For deleted files, silently use empty content
727 console.warn(
728 `Could not get working copy for deleted file ${file.path}, using empty content`,
729 );
730 modifiedCode = "";
731 } else {
732 // For any other file status, propagate the error
733 console.error(
734 `Failed to get working copy for ${file.path}:`,
735 error,
736 );
737 throw error;
738 }
739 }
740 } else {
741 // For non-editable view, use git content based on file status
742 if (file.status === "D") {
743 // Deleted file: empty modified
744 modifiedCode = "";
745 } else {
746 // Added/modified/renamed: use the content from git
747 modifiedCode = await this.gitService.getFileContent(
748 file.new_hash || "",
749 );
750 }
751 }
752
753 // Don't make deleted files editable
754 if (file.status === "D") {
755 editable = false;
756 }
757
758 this.fileContents.set(file.path, {
759 original: originalCode,
760 modified: modifiedCode,
761 editable,
762 });
763 } catch (error) {
764 console.error(`Error loading content for file ${file.path}:`, error);
765 // Store empty content for failed files to prevent blocking
766 this.fileContents.set(file.path, {
767 original: "",
768 modified: "",
769 editable: false,
770 });
771 }
772 });
773
774 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700775 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000776 console.error("Error loading file contents:", error);
777 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700778 } finally {
779 this.loading = false;
780 }
781 }
782
783 /**
784 * Handle range change event from the range picker
785 */
786 handleRangeChange(event: CustomEvent) {
787 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000788 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700789 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000790
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700791 // Load diff data for the new range
792 this.loadDiffData();
793 }
794
795 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000796 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700797 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000798 renderFileDiff(file: GitDiffFile, index: number) {
799 const content = this.fileContents.get(file.path);
800 if (!content) {
801 return html`
802 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000803 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000804 <div class="loading">Loading ${file.path}...</div>
805 </div>
806 `;
807 }
808
809 return html`
810 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000811 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000812 <div class="file-diff-editor">
813 <sketch-monaco-view
814 .originalCode="${content.original}"
815 .modifiedCode="${content.modified}"
816 .originalFilename="${file.path}"
817 .modifiedFilename="${file.path}"
818 ?readOnly="${!content.editable}"
819 ?editable-right="${content.editable}"
820 @monaco-comment="${this.handleMonacoComment}"
821 @monaco-save="${this.handleMonacoSave}"
822 @monaco-height-changed="${this.handleMonacoHeightChange}"
823 data-file-index="${index}"
824 data-file-path="${file.path}"
825 ></sketch-monaco-view>
826 </div>
827 </div>
828 `;
829 }
830
831 /**
832 * Render file header with status and path info
833 */
834 renderFileHeader(file: GitDiffFile) {
835 const statusClass = this.getFileStatusClass(file.status);
836 const statusText = this.getFileStatusText(file.status);
837 const changesInfo = this.getChangesInfo(file);
838 const pathInfo = this.getPathInfo(file);
839
840 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000841
David Crawshaw26f3f342025-06-14 19:58:32 +0000842 return html`
843 <div class="file-header-left">
844 <span class="file-status ${statusClass}">${statusText}</span>
845 <span class="file-path">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000846 ${changesInfo
847 ? html`<span class="file-changes">${changesInfo}</span>`
848 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000849 </div>
850 <div class="file-header-right">
851 <button
852 class="file-expand-button"
853 @click="${() => this.toggleFileExpansion(file.path)}"
854 title="${isExpanded
855 ? "Collapse: Hide unchanged regions to focus on changes"
856 : "Expand: Show all lines including unchanged regions"}"
857 >
Autoformatter9abf8032025-06-14 23:24:08 +0000858 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000859 </button>
860 </div>
861 `;
862 }
863
864 /**
865 * Get CSS class for file status
866 */
867 getFileStatusClass(status: string): string {
868 switch (status.toUpperCase()) {
869 case "A":
870 return "added";
871 case "M":
872 return "modified";
873 case "D":
874 return "deleted";
875 case "R":
876 default:
877 if (status.toUpperCase().startsWith("R")) {
878 return "renamed";
879 }
880 return "modified";
881 }
882 }
883
884 /**
885 * Get display text for file status
886 */
887 getFileStatusText(status: string): string {
888 switch (status.toUpperCase()) {
889 case "A":
890 return "Added";
891 case "M":
892 return "Modified";
893 case "D":
894 return "Deleted";
895 case "R":
896 default:
897 if (status.toUpperCase().startsWith("R")) {
898 return "Renamed";
899 }
900 return "Modified";
901 }
902 }
903
904 /**
905 * Get changes information (+/-) for display
906 */
907 getChangesInfo(file: GitDiffFile): string {
908 const additions = file.additions || 0;
909 const deletions = file.deletions || 0;
910
911 if (additions === 0 && deletions === 0) {
912 return "";
913 }
914
915 const parts = [];
916 if (additions > 0) {
917 parts.push(`+${additions}`);
918 }
919 if (deletions > 0) {
920 parts.push(`-${deletions}`);
921 }
922
923 return `(${parts.join(", ")})`;
924 }
925
926 /**
927 * Get path information for display, handling renames
928 */
929 getPathInfo(file: GitDiffFile): string {
930 if (file.old_path && file.old_path !== "") {
931 // For renames, show old_path → new_path
932 return `${file.old_path} → ${file.path}`;
933 }
934 // For regular files, just show the path
935 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700936 }
937
938 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000939 * Render expand all icon (dotted line with arrows pointing away)
940 */
941 renderExpandAllIcon() {
942 return html`
943 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
944 <!-- Dotted line in the middle -->
945 <line
946 x1="2"
947 y1="8"
948 x2="14"
949 y2="8"
950 stroke="currentColor"
951 stroke-width="1"
952 stroke-dasharray="2,1"
953 />
954 <!-- Large arrow pointing up -->
955 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
956 <!-- Large arrow pointing down -->
957 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
958 </svg>
959 `;
960 }
961
962 /**
963 * Render collapse icon (arrows pointing towards dotted line)
964 */
965 renderCollapseIcon() {
966 return html`
967 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
968 <!-- Dotted line in the middle -->
969 <line
970 x1="2"
971 y1="8"
972 x2="14"
973 y2="8"
974 stroke="currentColor"
975 stroke-width="1"
976 stroke-dasharray="2,1"
977 />
978 <!-- Large arrow pointing down towards line -->
979 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
980 <!-- Large arrow pointing up towards line -->
981 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
982 </svg>
983 `;
984 }
985
986 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000987 * Handle file selection change from the dropdown
988 */
989 handleFileSelection(event: Event) {
990 const selectElement = event.target as HTMLSelectElement;
991 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +0000992
David Crawshaw4cd01292025-06-15 18:59:13 +0000993 this.selectedFile = selectedValue;
994 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +0000995
David Crawshaw4cd01292025-06-15 18:59:13 +0000996 // Force re-render
997 this.requestUpdate();
998 }
999
1000 /**
1001 * Get display name for file in the selector
1002 */
1003 getFileDisplayName(file: GitDiffFile): string {
1004 const status = this.getFileStatusText(file.status);
1005 const pathInfo = this.getPathInfo(file);
1006 return `${status}: ${pathInfo}`;
1007 }
1008
1009 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -07001010 * Render expand/collapse button for single file view in header
1011 */
1012 renderSingleFileExpandButton() {
1013 if (!this.selectedFile) return "";
1014
1015 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
1016
1017 return html`
1018 <button
1019 class="header-expand-button"
1020 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
1021 title="${isExpanded
1022 ? "Collapse: Hide unchanged regions to focus on changes"
1023 : "Expand: Show all lines including unchanged regions"}"
1024 >
1025 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
1026 </button>
1027 `;
1028 }
1029
1030 /**
David Crawshaw4cd01292025-06-15 18:59:13 +00001031 * Render single file view with full-screen Monaco editor
1032 */
1033 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +00001034 const selectedFileData = this.files.find(
1035 (f) => f.path === this.selectedFile,
1036 );
David Crawshaw4cd01292025-06-15 18:59:13 +00001037 if (!selectedFileData) {
1038 return html`<div class="error">Selected file not found</div>`;
1039 }
1040
1041 const content = this.fileContents.get(this.selectedFile);
1042 if (!content) {
1043 return html`<div class="loading">Loading ${this.selectedFile}...</div>`;
1044 }
1045
1046 return html`
1047 <div class="single-file-view">
1048 <sketch-monaco-view
1049 class="single-file-monaco"
1050 .originalCode="${content.original}"
1051 .modifiedCode="${content.modified}"
1052 .originalFilename="${selectedFileData.path}"
1053 .modifiedFilename="${selectedFileData.path}"
1054 ?readOnly="${!content.editable}"
1055 ?editable-right="${content.editable}"
1056 @monaco-comment="${this.handleMonacoComment}"
1057 @monaco-save="${this.handleMonacoSave}"
1058 data-file-path="${selectedFileData.path}"
1059 ></sketch-monaco-view>
1060 </div>
1061 `;
1062 }
1063
1064 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001065 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +00001066 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001067 * This is called when the Monaco diff tab is activated to ensure:
1068 * 1. Branch information from git/recentlog is current (branches can change frequently)
1069 * 2. The diff content is synchronized with the latest repository state
1070 * 3. Users always see up-to-date information without manual refresh
1071 */
1072 refreshDiffView() {
1073 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +00001074 const rangePicker = this.shadowRoot?.querySelector(
1075 "sketch-diff-range-picker",
1076 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001077 if (rangePicker) {
1078 (rangePicker as any).loadCommits();
1079 }
Autoformatter8c463622025-05-16 21:54:17 +00001080
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001081 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +00001082 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +00001083 this.currentRange = {
1084 type: "range",
1085 from: `${this.commit}^`,
1086 to: this.commit,
1087 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001088 }
Autoformatter8c463622025-05-16 21:54:17 +00001089
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001090 // Then reload diff data based on the current range
1091 this.loadDiffData();
1092 }
1093}
1094
1095declare global {
1096 interface HTMLElementTagNameMap {
1097 "sketch-diff2-view": SketchDiff2View;
1098 }
1099}