blob: ccdfebc292d1df8055e60f04f4457a302f0f2795 [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";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07005import "./sketch-diff-empty-view";
Autoformatter8c463622025-05-16 21:54:17 +00006import {
7 GitDiffFile,
8 GitDataService,
9 DefaultGitDataService,
10} from "./git-data-service";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070011import { DiffRange } from "./sketch-diff-range-picker";
12
13/**
14 * A component that displays diffs using Monaco editor with range and file pickers
15 */
16@customElement("sketch-diff2-view")
17export class SketchDiff2View extends LitElement {
18 /**
19 * Handles comment events from the Monaco editor and forwards them to the chat input
20 * using the same event format as the original diff view for consistency.
21 */
22 private handleMonacoComment(event: CustomEvent) {
23 try {
24 // Validate incoming data
25 if (!event.detail || !event.detail.formattedComment) {
Autoformatter8c463622025-05-16 21:54:17 +000026 console.error("Invalid comment data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070027 return;
28 }
Autoformatter8c463622025-05-16 21:54:17 +000029
Philip Zeyliger272a90e2025-05-16 14:49:51 -070030 // Create and dispatch event using the standardized format
Autoformatter8c463622025-05-16 21:54:17 +000031 const commentEvent = new CustomEvent("diff-comment", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070032 detail: { comment: event.detail.formattedComment },
33 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +000034 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -070035 });
Autoformatter8c463622025-05-16 21:54:17 +000036
Philip Zeyliger272a90e2025-05-16 14:49:51 -070037 this.dispatchEvent(commentEvent);
38 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +000039 console.error("Error handling Monaco comment:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -070040 }
41 }
Autoformatter8c463622025-05-16 21:54:17 +000042
Philip Zeyliger272a90e2025-05-16 14:49:51 -070043 /**
David Crawshaw26f3f342025-06-14 19:58:32 +000044 * Handle height change events from the Monaco editor
45 */
46 private handleMonacoHeightChange(event: CustomEvent) {
47 try {
48 // Get the monaco view that emitted the event
49 const monacoView = event.target as HTMLElement;
50 if (!monacoView) return;
Autoformatter9abf8032025-06-14 23:24:08 +000051
David Crawshaw26f3f342025-06-14 19:58:32 +000052 // Find the parent file-diff-editor container
Autoformatter9abf8032025-06-14 23:24:08 +000053 const fileDiffEditor = monacoView.closest(
54 ".file-diff-editor",
55 ) as HTMLElement;
David Crawshaw26f3f342025-06-14 19:58:32 +000056 if (!fileDiffEditor) return;
Autoformatter9abf8032025-06-14 23:24:08 +000057
David Crawshaw26f3f342025-06-14 19:58:32 +000058 // Get the new height from the event
59 const newHeight = event.detail.height;
Autoformatter9abf8032025-06-14 23:24:08 +000060
David Crawshaw26f3f342025-06-14 19:58:32 +000061 // Only update if the height actually changed to avoid unnecessary layout
62 const currentHeight = fileDiffEditor.style.height;
63 const newHeightStr = `${newHeight}px`;
Autoformatter9abf8032025-06-14 23:24:08 +000064
David Crawshaw26f3f342025-06-14 19:58:32 +000065 if (currentHeight !== newHeightStr) {
66 // Update the file-diff-editor height to match monaco's height
67 fileDiffEditor.style.height = newHeightStr;
Autoformatter9abf8032025-06-14 23:24:08 +000068
David Crawshaw26f3f342025-06-14 19:58:32 +000069 // Remove any previous min-height constraint that might interfere
Autoformatter9abf8032025-06-14 23:24:08 +000070 fileDiffEditor.style.minHeight = "auto";
71
David Crawshaw26f3f342025-06-14 19:58:32 +000072 // IMPORTANT: Tell Monaco to relayout after its container size changed
73 // Monaco has automaticLayout: false, so it won't detect container changes
74 setTimeout(() => {
75 const monacoComponent = monacoView as any;
76 if (monacoComponent && monacoComponent.editor) {
77 // Force layout with explicit dimensions to ensure Monaco fills the space
78 const editorWidth = fileDiffEditor.offsetWidth;
79 monacoComponent.editor.layout({
80 width: editorWidth,
Autoformatter9abf8032025-06-14 23:24:08 +000081 height: newHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +000082 });
83 }
84 }, 0);
85 }
David Crawshaw26f3f342025-06-14 19:58:32 +000086 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +000087 console.error("Error handling Monaco height change:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +000088 }
89 }
90
91 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -070092 * Handle save events from the Monaco editor
93 */
94 private async handleMonacoSave(event: CustomEvent) {
95 try {
96 // Validate incoming data
Autoformatter8c463622025-05-16 21:54:17 +000097 if (
98 !event.detail ||
99 !event.detail.path ||
100 event.detail.content === undefined
101 ) {
102 console.error("Invalid save data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700103 return;
104 }
Autoformatter8c463622025-05-16 21:54:17 +0000105
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700106 const { path, content } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000107
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700108 // Get Monaco view component
Autoformatter8c463622025-05-16 21:54:17 +0000109 const monacoView = this.shadowRoot?.querySelector("sketch-monaco-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700110 if (!monacoView) {
Autoformatter8c463622025-05-16 21:54:17 +0000111 console.error("Monaco view not found");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700112 return;
113 }
Autoformatter8c463622025-05-16 21:54:17 +0000114
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700115 try {
116 await this.gitService?.saveFileContent(path, content);
117 console.log(`File saved: ${path}`);
118 (monacoView as any).notifySaveComplete(true);
119 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000120 console.error(
121 `Error saving file: ${error instanceof Error ? error.message : String(error)}`,
122 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700123 (monacoView as any).notifySaveComplete(false);
124 }
125 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000126 console.error("Error handling save:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700127 }
128 }
129 @property({ type: String })
130 initialCommit: string = "";
Autoformatter8c463622025-05-16 21:54:17 +0000131
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700132 // The commit to show - used when showing a specific commit from timeline
133 @property({ type: String })
134 commit: string = "";
135
136 @property({ type: String })
137 selectedFilePath: string = "";
138
139 @state()
140 private files: GitDiffFile[] = [];
Autoformatter8c463622025-05-16 21:54:17 +0000141
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700142 @state()
Autoformatter8c463622025-05-16 21:54:17 +0000143 private currentRange: DiffRange = { type: "range", from: "", to: "HEAD" };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700144
145 @state()
Autoformatter9abf8032025-06-14 23:24:08 +0000146 private fileContents: Map<
147 string,
148 { original: string; modified: string; editable: boolean }
149 > = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700150
151 @state()
David Crawshaw26f3f342025-06-14 19:58:32 +0000152 private fileExpandStates: Map<string, boolean> = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700153
154 @state()
155 private loading: boolean = false;
156
157 @state()
158 private error: string | null = null;
159
David Crawshaw4cd01292025-06-15 18:59:13 +0000160 @state()
161 private selectedFile: string = ""; // Empty string means "All files"
162
163 @state()
164 private viewMode: "all" | "single" = "all";
165
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700166 static styles = css`
167 :host {
168 display: flex;
169 height: 100%;
170 flex: 1;
171 flex-direction: column;
172 min-height: 0; /* Critical for flex child behavior */
173 overflow: hidden;
174 position: relative; /* Establish positioning context */
175 }
176
177 .controls {
178 padding: 8px 16px;
179 border-bottom: 1px solid var(--border-color, #e0e0e0);
180 background-color: var(--background-light, #f8f8f8);
181 flex-shrink: 0; /* Prevent controls from shrinking */
182 }
Autoformatter8c463622025-05-16 21:54:17 +0000183
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700184 .controls-container {
185 display: flex;
186 flex-direction: column;
187 gap: 12px;
188 }
Autoformatter8c463622025-05-16 21:54:17 +0000189
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700190 .range-row {
191 width: 100%;
192 display: flex;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700193 align-items: center;
David Crawshawdbca8972025-06-14 23:46:58 +0000194 gap: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700195 }
Autoformatter8c463622025-05-16 21:54:17 +0000196
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700197 .file-selector-container {
198 display: flex;
199 align-items: center;
200 gap: 8px;
201 }
202
David Crawshaw4cd01292025-06-15 18:59:13 +0000203 .file-selector {
204 min-width: 200px;
205 padding: 8px 12px;
206 border: 1px solid var(--border-color, #ccc);
207 border-radius: 4px;
208 background-color: var(--background-color, #fff);
209 font-family: var(--font-family, system-ui, sans-serif);
210 font-size: 14px;
211 cursor: pointer;
212 }
213
214 .file-selector:focus {
215 outline: none;
216 border-color: var(--accent-color, #007acc);
217 box-shadow: 0 0 0 2px var(--accent-color-light, rgba(0, 122, 204, 0.2));
218 }
219
David Crawshaw5c6d8292025-06-15 19:09:19 +0000220 .file-selector:disabled {
221 background-color: var(--background-disabled, #f5f5f5);
222 color: var(--text-disabled, #999);
223 cursor: not-allowed;
224 }
225
226 .spacer {
227 flex: 1;
228 }
229
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700230 sketch-diff-range-picker {
David Crawshawdbca8972025-06-14 23:46:58 +0000231 flex: 1;
David Crawshawe2954ce2025-06-15 00:06:34 +0000232 min-width: 400px; /* Ensure minimum width for range picker */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700233 }
Autoformatter8c463622025-05-16 21:54:17 +0000234
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700235 .view-toggle-button,
236 .header-expand-button {
237 background-color: transparent;
238 border: 1px solid var(--border-color, #e0e0e0);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700239 border-radius: 4px;
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700240 padding: 6px 8px;
241 font-size: 14px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700242 cursor: pointer;
243 white-space: nowrap;
244 transition: background-color 0.2s;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000245 display: flex;
246 align-items: center;
247 justify-content: center;
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700248 min-width: 32px;
249 min-height: 32px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700250 }
Autoformatter8c463622025-05-16 21:54:17 +0000251
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700252 .view-toggle-button:hover,
253 .header-expand-button:hover {
254 background-color: var(--background-hover, #e8e8e8);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700255 }
256
257 .diff-container {
258 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000259 overflow: auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700260 display: flex;
261 flex-direction: column;
David Crawshaw26f3f342025-06-14 19:58:32 +0000262 min-height: 0;
263 position: relative;
264 height: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700265 }
266
267 .diff-content {
268 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000269 overflow: auto;
270 min-height: 0;
271 display: flex;
272 flex-direction: column;
273 position: relative;
274 height: 100%;
275 }
276
277 .multi-file-diff-container {
278 display: flex;
279 flex-direction: column;
280 width: 100%;
281 min-height: 100%;
282 }
283
284 .file-diff-section {
285 display: flex;
286 flex-direction: column;
287 border-bottom: 3px solid var(--border-color, #e0e0e0);
288 margin-bottom: 0;
289 }
290
291 .file-diff-section:last-child {
292 border-bottom: none;
293 }
294
295 .file-header {
296 background-color: var(--background-light, #f8f8f8);
297 border-bottom: 1px solid var(--border-color, #e0e0e0);
David Crawshawdbca8972025-06-14 23:46:58 +0000298 padding: 8px 16px;
David Crawshaw26f3f342025-06-14 19:58:32 +0000299 font-family: var(--font-family, system-ui, sans-serif);
300 font-weight: 500;
301 font-size: 14px;
302 color: var(--text-primary-color, #333);
303 position: sticky;
304 top: 0;
305 z-index: 10;
306 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
307 display: flex;
308 justify-content: space-between;
309 align-items: center;
310 }
311
312 .file-header-left {
313 display: flex;
314 align-items: center;
315 gap: 8px;
316 }
317
318 .file-header-right {
319 display: flex;
320 align-items: center;
321 }
322
323 .file-expand-button {
324 background-color: transparent;
325 border: 1px solid var(--border-color, #e0e0e0);
326 border-radius: 4px;
327 padding: 4px 8px;
328 font-size: 14px;
329 cursor: pointer;
330 transition: background-color 0.2s;
331 display: flex;
332 align-items: center;
333 justify-content: center;
334 min-width: 32px;
335 min-height: 32px;
336 }
337
338 .file-expand-button:hover {
339 background-color: var(--background-hover, #e8e8e8);
340 }
341
342 .file-path {
343 font-family: monospace;
344 font-weight: normal;
345 color: var(--text-secondary-color, #666);
346 }
347
348 .file-status {
349 display: inline-block;
350 padding: 2px 6px;
351 border-radius: 3px;
352 font-size: 12px;
353 font-weight: bold;
354 margin-right: 8px;
355 }
356
357 .file-status.added {
358 background-color: #d4edda;
359 color: #155724;
360 }
361
362 .file-status.modified {
363 background-color: #fff3cd;
364 color: #856404;
365 }
366
367 .file-status.deleted {
368 background-color: #f8d7da;
369 color: #721c24;
370 }
371
372 .file-status.renamed {
373 background-color: #d1ecf1;
374 color: #0c5460;
375 }
376
377 .file-changes {
378 margin-left: 8px;
379 font-size: 12px;
380 color: var(--text-secondary-color, #666);
381 }
382
383 .file-diff-editor {
384 display: flex;
385 flex-direction: column;
386 min-height: 200px;
387 /* Height will be set dynamically by monaco editor */
388 overflow: visible; /* Ensure content is not clipped */
389 }
390
Autoformatter8c463622025-05-16 21:54:17 +0000391 .loading,
392 .empty-diff {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700393 display: flex;
394 align-items: center;
395 justify-content: center;
396 height: 100%;
397 font-family: var(--font-family, system-ui, sans-serif);
398 }
Autoformatter8c463622025-05-16 21:54:17 +0000399
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700400 .empty-diff {
401 color: var(--text-secondary-color, #666);
402 font-size: 16px;
403 text-align: center;
404 }
405
406 .error {
407 color: var(--error-color, #dc3545);
408 padding: 16px;
409 font-family: var(--font-family, system-ui, sans-serif);
410 }
411
412 sketch-monaco-view {
413 --editor-width: 100%;
414 --editor-height: 100%;
David Crawshaw26f3f342025-06-14 19:58:32 +0000415 display: flex;
416 flex-direction: column;
417 width: 100%;
418 min-height: 200px;
419 /* Ensure Monaco view takes full container space */
420 flex: 1;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700421 }
David Crawshaw4cd01292025-06-15 18:59:13 +0000422
423 /* Single file view styles */
424 .single-file-view {
425 flex: 1;
426 display: flex;
427 flex-direction: column;
428 height: 100%;
429 min-height: 0;
430 }
431
432 .single-file-monaco {
433 flex: 1;
434 width: 100%;
435 height: 100%;
436 min-height: 0;
437 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700438 `;
439
440 @property({ attribute: false, type: Object })
441 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000442
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700443 // The gitService must be passed from parent to ensure proper dependency injection
444
445 constructor() {
446 super();
Autoformatter8c463622025-05-16 21:54:17 +0000447 console.log("SketchDiff2View initialized");
448
David Crawshawe2954ce2025-06-15 00:06:34 +0000449 // Fix for monaco-aria-container positioning and hide scrollbars globally
450 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000451 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700452 styleElement.textContent = `
453 .monaco-aria-container {
454 position: absolute !important;
455 top: 0 !important;
456 left: 0 !important;
457 width: 1px !important;
458 height: 1px !important;
459 overflow: hidden !important;
460 clip: rect(1px, 1px, 1px, 1px) !important;
461 white-space: nowrap !important;
462 margin: 0 !important;
463 padding: 0 !important;
464 border: 0 !important;
465 z-index: -1 !important;
466 }
David Crawshawe2954ce2025-06-15 00:06:34 +0000467
468 /* Aggressively hide all Monaco scrollbar elements */
469 .monaco-editor .scrollbar,
470 .monaco-editor .scroll-decoration,
471 .monaco-editor .invisible.scrollbar,
472 .monaco-editor .slider,
473 .monaco-editor .vertical.scrollbar,
474 .monaco-editor .horizontal.scrollbar,
475 .monaco-diff-editor .scrollbar,
476 .monaco-diff-editor .scroll-decoration,
477 .monaco-diff-editor .invisible.scrollbar,
478 .monaco-diff-editor .slider,
479 .monaco-diff-editor .vertical.scrollbar,
480 .monaco-diff-editor .horizontal.scrollbar {
481 display: none !important;
482 visibility: hidden !important;
483 width: 0 !important;
484 height: 0 !important;
485 opacity: 0 !important;
486 }
487
488 /* Target the specific scrollbar classes that Monaco uses */
489 .monaco-scrollable-element > .scrollbar,
490 .monaco-scrollable-element > .scroll-decoration,
491 .monaco-scrollable-element .slider {
492 display: none !important;
493 visibility: hidden !important;
494 width: 0 !important;
495 height: 0 !important;
496 }
497
498 /* Remove scrollbar space/padding from content area */
499 .monaco-editor .monaco-scrollable-element,
500 .monaco-diff-editor .monaco-scrollable-element {
501 padding-right: 0 !important;
502 padding-bottom: 0 !important;
503 margin-right: 0 !important;
504 margin-bottom: 0 !important;
505 }
506
507 /* Ensure the diff content takes full width without scrollbar space */
508 .monaco-diff-editor .editor.modified,
509 .monaco-diff-editor .editor.original {
510 margin-right: 0 !important;
511 padding-right: 0 !important;
512 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700513 `;
514 document.head.appendChild(styleElement);
515 }
516
517 connectedCallback() {
518 super.connectedCallback();
519 // Initialize with default range and load data
520 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000521 if (
522 this.currentRange.type === "range" &&
523 !("from" in this.currentRange && this.currentRange.from)
524 ) {
525 this.gitService
526 .getBaseCommitRef()
527 .then((baseRef) => {
528 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
529 this.loadDiffData();
530 })
531 .catch((error) => {
532 console.error("Error getting base commit ref:", error);
533 // Use default range
534 this.loadDiffData();
535 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700536 } else {
537 this.loadDiffData();
538 }
539 }
540
David Crawshaw26f3f342025-06-14 19:58:32 +0000541 // Toggle hideUnchangedRegions setting for a specific file
542 private toggleFileExpansion(filePath: string) {
543 const currentState = this.fileExpandStates.get(filePath) ?? false;
544 const newState = !currentState;
545 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000546
David Crawshaw26f3f342025-06-14 19:58:32 +0000547 // Apply to the specific Monaco view component for this file
Autoformatter9abf8032025-06-14 23:24:08 +0000548 const monacoView = this.shadowRoot?.querySelector(
549 `sketch-monaco-view[data-file-path="${filePath}"]`,
550 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700551 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000552 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700553 }
Autoformatter9abf8032025-06-14 23:24:08 +0000554
David Crawshaw26f3f342025-06-14 19:58:32 +0000555 // Force a re-render to update the button state
556 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700557 }
Autoformatter8c463622025-05-16 21:54:17 +0000558
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700559 render() {
560 return html`
561 <div class="controls">
562 <div class="controls-container">
563 <div class="range-row">
564 <sketch-diff-range-picker
565 .gitService="${this.gitService}"
566 @range-change="${this.handleRangeChange}"
567 ></sketch-diff-range-picker>
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700568 <div class="spacer"></div>
569 ${this.renderFileSelector()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700570 </div>
571 </div>
572 </div>
573
574 <div class="diff-container">
Autoformatter8c463622025-05-16 21:54:17 +0000575 <div class="diff-content">${this.renderDiffContent()}</div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700576 </div>
577 `;
578 }
579
David Crawshaw4cd01292025-06-15 18:59:13 +0000580 renderFileSelector() {
David Crawshaw5c6d8292025-06-15 19:09:19 +0000581 const fileCount = this.files.length;
Autoformatter62554112025-06-15 19:23:33 +0000582
David Crawshaw4cd01292025-06-15 18:59:13 +0000583 return html`
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700584 <div class="file-selector-container">
585 <select
586 class="file-selector"
587 .value="${this.selectedFile}"
588 @change="${this.handleFileSelection}"
589 ?disabled="${fileCount === 0}"
590 >
591 <option value="">All files (${fileCount})</option>
592 ${this.files.map(
593 (file) => html`
594 <option value="${file.path}">
595 ${this.getFileDisplayName(file)}
596 </option>
597 `,
598 )}
599 </select>
600 ${this.selectedFile ? this.renderSingleFileExpandButton() : ""}
601 </div>
David Crawshaw4cd01292025-06-15 18:59:13 +0000602 `;
603 }
604
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700605 renderDiffContent() {
606 if (this.loading) {
607 return html`<div class="loading">Loading diff...</div>`;
608 }
609
610 if (this.error) {
611 return html`<div class="error">${this.error}</div>`;
612 }
613
614 if (this.files.length === 0) {
615 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
616 }
Autoformatter8c463622025-05-16 21:54:17 +0000617
David Crawshaw4cd01292025-06-15 18:59:13 +0000618 // Render single file view if a specific file is selected
619 if (this.selectedFile && this.viewMode === "single") {
620 return this.renderSingleFileView();
621 }
622
623 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700624 return html`
David Crawshaw26f3f342025-06-14 19:58:32 +0000625 <div class="multi-file-diff-container">
626 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
627 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700628 `;
629 }
630
631 /**
632 * Load diff data for the current range
633 */
634 async loadDiffData() {
635 this.loading = true;
636 this.error = null;
637
638 try {
639 // Initialize files as empty array if undefined
640 if (!this.files) {
641 this.files = [];
642 }
643
David Crawshaw216d2fc2025-06-15 18:45:53 +0000644 // Load diff data for the range
645 this.files = await this.gitService.getDiff(
646 this.currentRange.from,
647 this.currentRange.to,
648 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700649
650 // Ensure files is always an array, even when API returns null
651 if (!this.files) {
652 this.files = [];
653 }
Autoformatter8c463622025-05-16 21:54:17 +0000654
David Crawshaw26f3f342025-06-14 19:58:32 +0000655 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700656 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000657 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000658 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000659 if (!this.fileExpandStates.has(file.path)) {
660 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
661 }
662 });
663 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700664 } else {
665 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000666 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000667 this.selectedFile = "";
668 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000669 this.fileContents.clear();
670 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700671 }
672 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000673 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700674 this.error = `Error loading diff data: ${error.message}`;
675 // Ensure files is an empty array when an error occurs
676 this.files = [];
677 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000678 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000679 this.selectedFile = "";
680 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000681 this.fileContents.clear();
682 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700683 } finally {
684 this.loading = false;
685 }
686 }
687
688 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000689 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700690 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000691 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700692 this.loading = true;
693 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000694 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700695
696 try {
697 let fromCommit: string;
698 let toCommit: string;
699 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
David Crawshaw216d2fc2025-06-15 18:45:53 +0000702 fromCommit = this.currentRange.from;
703 toCommit = this.currentRange.to;
704 // 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":
881 default:
882 if (status.toUpperCase().startsWith("R")) {
883 return "renamed";
884 }
885 return "modified";
886 }
887 }
888
889 /**
890 * Get display text for file status
891 */
892 getFileStatusText(status: string): string {
893 switch (status.toUpperCase()) {
894 case "A":
895 return "Added";
896 case "M":
897 return "Modified";
898 case "D":
899 return "Deleted";
900 case "R":
901 default:
902 if (status.toUpperCase().startsWith("R")) {
903 return "Renamed";
904 }
905 return "Modified";
906 }
907 }
908
909 /**
910 * Get changes information (+/-) for display
911 */
912 getChangesInfo(file: GitDiffFile): string {
913 const additions = file.additions || 0;
914 const deletions = file.deletions || 0;
915
916 if (additions === 0 && deletions === 0) {
917 return "";
918 }
919
920 const parts = [];
921 if (additions > 0) {
922 parts.push(`+${additions}`);
923 }
924 if (deletions > 0) {
925 parts.push(`-${deletions}`);
926 }
927
928 return `(${parts.join(", ")})`;
929 }
930
931 /**
932 * Get path information for display, handling renames
933 */
934 getPathInfo(file: GitDiffFile): string {
935 if (file.old_path && file.old_path !== "") {
936 // For renames, show old_path → new_path
937 return `${file.old_path} → ${file.path}`;
938 }
939 // For regular files, just show the path
940 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700941 }
942
943 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000944 * Render expand all icon (dotted line with arrows pointing away)
945 */
946 renderExpandAllIcon() {
947 return html`
948 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
949 <!-- Dotted line in the middle -->
950 <line
951 x1="2"
952 y1="8"
953 x2="14"
954 y2="8"
955 stroke="currentColor"
956 stroke-width="1"
957 stroke-dasharray="2,1"
958 />
959 <!-- Large arrow pointing up -->
960 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
961 <!-- Large arrow pointing down -->
962 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
963 </svg>
964 `;
965 }
966
967 /**
968 * Render collapse icon (arrows pointing towards dotted line)
969 */
970 renderCollapseIcon() {
971 return html`
972 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
973 <!-- Dotted line in the middle -->
974 <line
975 x1="2"
976 y1="8"
977 x2="14"
978 y2="8"
979 stroke="currentColor"
980 stroke-width="1"
981 stroke-dasharray="2,1"
982 />
983 <!-- Large arrow pointing down towards line -->
984 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
985 <!-- Large arrow pointing up towards line -->
986 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
987 </svg>
988 `;
989 }
990
991 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000992 * Handle file selection change from the dropdown
993 */
994 handleFileSelection(event: Event) {
995 const selectElement = event.target as HTMLSelectElement;
996 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +0000997
David Crawshaw4cd01292025-06-15 18:59:13 +0000998 this.selectedFile = selectedValue;
999 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +00001000
David Crawshaw4cd01292025-06-15 18:59:13 +00001001 // Force re-render
1002 this.requestUpdate();
1003 }
1004
1005 /**
1006 * Get display name for file in the selector
1007 */
1008 getFileDisplayName(file: GitDiffFile): string {
1009 const status = this.getFileStatusText(file.status);
1010 const pathInfo = this.getPathInfo(file);
1011 return `${status}: ${pathInfo}`;
1012 }
1013
1014 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -07001015 * Render expand/collapse button for single file view in header
1016 */
1017 renderSingleFileExpandButton() {
1018 if (!this.selectedFile) return "";
1019
1020 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
1021
1022 return html`
1023 <button
1024 class="header-expand-button"
1025 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
1026 title="${isExpanded
1027 ? "Collapse: Hide unchanged regions to focus on changes"
1028 : "Expand: Show all lines including unchanged regions"}"
1029 >
1030 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
1031 </button>
1032 `;
1033 }
1034
1035 /**
David Crawshaw4cd01292025-06-15 18:59:13 +00001036 * Render single file view with full-screen Monaco editor
1037 */
1038 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +00001039 const selectedFileData = this.files.find(
1040 (f) => f.path === this.selectedFile,
1041 );
David Crawshaw4cd01292025-06-15 18:59:13 +00001042 if (!selectedFileData) {
1043 return html`<div class="error">Selected file not found</div>`;
1044 }
1045
1046 const content = this.fileContents.get(this.selectedFile);
1047 if (!content) {
1048 return html`<div class="loading">Loading ${this.selectedFile}...</div>`;
1049 }
1050
1051 return html`
1052 <div class="single-file-view">
1053 <sketch-monaco-view
1054 class="single-file-monaco"
1055 .originalCode="${content.original}"
1056 .modifiedCode="${content.modified}"
1057 .originalFilename="${selectedFileData.path}"
1058 .modifiedFilename="${selectedFileData.path}"
1059 ?readOnly="${!content.editable}"
1060 ?editable-right="${content.editable}"
1061 @monaco-comment="${this.handleMonacoComment}"
1062 @monaco-save="${this.handleMonacoSave}"
1063 data-file-path="${selectedFileData.path}"
1064 ></sketch-monaco-view>
1065 </div>
1066 `;
1067 }
1068
1069 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001070 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +00001071 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001072 * This is called when the Monaco diff tab is activated to ensure:
1073 * 1. Branch information from git/recentlog is current (branches can change frequently)
1074 * 2. The diff content is synchronized with the latest repository state
1075 * 3. Users always see up-to-date information without manual refresh
1076 */
1077 refreshDiffView() {
1078 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +00001079 const rangePicker = this.shadowRoot?.querySelector(
1080 "sketch-diff-range-picker",
1081 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001082 if (rangePicker) {
1083 (rangePicker as any).loadCommits();
1084 }
Autoformatter8c463622025-05-16 21:54:17 +00001085
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001086 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +00001087 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +00001088 this.currentRange = {
1089 type: "range",
1090 from: `${this.commit}^`,
1091 to: this.commit,
1092 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001093 }
Autoformatter8c463622025-05-16 21:54:17 +00001094
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001095 // Then reload diff data based on the current range
1096 this.loadDiffData();
1097 }
1098}
1099
1100declare global {
1101 interface HTMLElementTagNameMap {
1102 "sketch-diff2-view": SketchDiff2View;
1103 }
1104}