blob: 2f668246a246d4861218f065508005728cf2715c [file] [log] [blame]
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import "./sketch-monaco-view";
4import "./sketch-diff-range-picker";
David Crawshaw26f3f342025-06-14 19:58:32 +00005// import "./sketch-diff-file-picker"; // No longer needed for multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -07006import "./sketch-diff-empty-view";
Autoformatter8c463622025-05-16 21:54:17 +00007import {
8 GitDiffFile,
9 GitDataService,
10 DefaultGitDataService,
11} from "./git-data-service";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070012import { DiffRange } from "./sketch-diff-range-picker";
13
14/**
15 * A component that displays diffs using Monaco editor with range and file pickers
16 */
17@customElement("sketch-diff2-view")
18export class SketchDiff2View extends LitElement {
19 /**
20 * Handles comment events from the Monaco editor and forwards them to the chat input
21 * using the same event format as the original diff view for consistency.
22 */
23 private handleMonacoComment(event: CustomEvent) {
24 try {
25 // Validate incoming data
26 if (!event.detail || !event.detail.formattedComment) {
Autoformatter8c463622025-05-16 21:54:17 +000027 console.error("Invalid comment data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070028 return;
29 }
Autoformatter8c463622025-05-16 21:54:17 +000030
Philip Zeyliger272a90e2025-05-16 14:49:51 -070031 // Create and dispatch event using the standardized format
Autoformatter8c463622025-05-16 21:54:17 +000032 const commentEvent = new CustomEvent("diff-comment", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070033 detail: { comment: event.detail.formattedComment },
34 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +000035 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -070036 });
Autoformatter8c463622025-05-16 21:54:17 +000037
Philip Zeyliger272a90e2025-05-16 14:49:51 -070038 this.dispatchEvent(commentEvent);
39 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +000040 console.error("Error handling Monaco comment:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -070041 }
42 }
Autoformatter8c463622025-05-16 21:54:17 +000043
Philip Zeyliger272a90e2025-05-16 14:49:51 -070044 /**
David Crawshaw26f3f342025-06-14 19:58:32 +000045 * Handle height change events from the Monaco editor
46 */
47 private handleMonacoHeightChange(event: CustomEvent) {
48 try {
49 // Get the monaco view that emitted the event
50 const monacoView = event.target as HTMLElement;
51 if (!monacoView) return;
Autoformatter9abf8032025-06-14 23:24:08 +000052
David Crawshaw26f3f342025-06-14 19:58:32 +000053 // Find the parent file-diff-editor container
Autoformatter9abf8032025-06-14 23:24:08 +000054 const fileDiffEditor = monacoView.closest(
55 ".file-diff-editor",
56 ) as HTMLElement;
David Crawshaw26f3f342025-06-14 19:58:32 +000057 if (!fileDiffEditor) return;
Autoformatter9abf8032025-06-14 23:24:08 +000058
David Crawshaw26f3f342025-06-14 19:58:32 +000059 // Get the new height from the event
60 const newHeight = event.detail.height;
Autoformatter9abf8032025-06-14 23:24:08 +000061
David Crawshaw26f3f342025-06-14 19:58:32 +000062 // Only update if the height actually changed to avoid unnecessary layout
63 const currentHeight = fileDiffEditor.style.height;
64 const newHeightStr = `${newHeight}px`;
Autoformatter9abf8032025-06-14 23:24:08 +000065
David Crawshaw26f3f342025-06-14 19:58:32 +000066 if (currentHeight !== newHeightStr) {
67 // Update the file-diff-editor height to match monaco's height
68 fileDiffEditor.style.height = newHeightStr;
Autoformatter9abf8032025-06-14 23:24:08 +000069
David Crawshaw26f3f342025-06-14 19:58:32 +000070 // Remove any previous min-height constraint that might interfere
Autoformatter9abf8032025-06-14 23:24:08 +000071 fileDiffEditor.style.minHeight = "auto";
72
David Crawshaw26f3f342025-06-14 19:58:32 +000073 // IMPORTANT: Tell Monaco to relayout after its container size changed
74 // Monaco has automaticLayout: false, so it won't detect container changes
75 setTimeout(() => {
76 const monacoComponent = monacoView as any;
77 if (monacoComponent && monacoComponent.editor) {
78 // Force layout with explicit dimensions to ensure Monaco fills the space
79 const editorWidth = fileDiffEditor.offsetWidth;
80 monacoComponent.editor.layout({
81 width: editorWidth,
Autoformatter9abf8032025-06-14 23:24:08 +000082 height: newHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +000083 });
84 }
85 }, 0);
86 }
David Crawshaw26f3f342025-06-14 19:58:32 +000087 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +000088 console.error("Error handling Monaco height change:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +000089 }
90 }
91
92 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -070093 * Handle save events from the Monaco editor
94 */
95 private async handleMonacoSave(event: CustomEvent) {
96 try {
97 // Validate incoming data
Autoformatter8c463622025-05-16 21:54:17 +000098 if (
99 !event.detail ||
100 !event.detail.path ||
101 event.detail.content === undefined
102 ) {
103 console.error("Invalid save data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700104 return;
105 }
Autoformatter8c463622025-05-16 21:54:17 +0000106
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700107 const { path, content } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000108
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700109 // Get Monaco view component
Autoformatter8c463622025-05-16 21:54:17 +0000110 const monacoView = this.shadowRoot?.querySelector("sketch-monaco-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700111 if (!monacoView) {
Autoformatter8c463622025-05-16 21:54:17 +0000112 console.error("Monaco view not found");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700113 return;
114 }
Autoformatter8c463622025-05-16 21:54:17 +0000115
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700116 try {
117 await this.gitService?.saveFileContent(path, content);
118 console.log(`File saved: ${path}`);
119 (monacoView as any).notifySaveComplete(true);
120 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000121 console.error(
122 `Error saving file: ${error instanceof Error ? error.message : String(error)}`,
123 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700124 (monacoView as any).notifySaveComplete(false);
125 }
126 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000127 console.error("Error handling save:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700128 }
129 }
130 @property({ type: String })
131 initialCommit: string = "";
Autoformatter8c463622025-05-16 21:54:17 +0000132
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700133 // The commit to show - used when showing a specific commit from timeline
134 @property({ type: String })
135 commit: string = "";
136
137 @property({ type: String })
138 selectedFilePath: string = "";
139
140 @state()
141 private files: GitDiffFile[] = [];
Autoformatter8c463622025-05-16 21:54:17 +0000142
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700143 @state()
Autoformatter8c463622025-05-16 21:54:17 +0000144 private currentRange: DiffRange = { type: "range", from: "", to: "HEAD" };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700145
146 @state()
Autoformatter9abf8032025-06-14 23:24:08 +0000147 private fileContents: Map<
148 string,
149 { original: string; modified: string; editable: boolean }
150 > = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700151
152 @state()
David Crawshaw26f3f342025-06-14 19:58:32 +0000153 private fileExpandStates: Map<string, boolean> = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700154
155 @state()
156 private loading: boolean = false;
157
158 @state()
159 private error: string | null = null;
160
David Crawshaw4cd01292025-06-15 18:59:13 +0000161 @state()
162 private selectedFile: string = ""; // Empty string means "All files"
163
164 @state()
165 private viewMode: "all" | "single" = "all";
166
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700167 static styles = css`
168 :host {
169 display: flex;
170 height: 100%;
171 flex: 1;
172 flex-direction: column;
173 min-height: 0; /* Critical for flex child behavior */
174 overflow: hidden;
175 position: relative; /* Establish positioning context */
176 }
177
178 .controls {
179 padding: 8px 16px;
180 border-bottom: 1px solid var(--border-color, #e0e0e0);
181 background-color: var(--background-light, #f8f8f8);
182 flex-shrink: 0; /* Prevent controls from shrinking */
183 }
Autoformatter8c463622025-05-16 21:54:17 +0000184
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700185 .controls-container {
186 display: flex;
187 flex-direction: column;
188 gap: 12px;
189 }
Autoformatter8c463622025-05-16 21:54:17 +0000190
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700191 .range-row {
192 width: 100%;
193 display: flex;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700194 align-items: center;
David Crawshawdbca8972025-06-14 23:46:58 +0000195 gap: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700196 }
Autoformatter8c463622025-05-16 21:54:17 +0000197
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700198 .file-selector-container {
199 display: flex;
200 align-items: center;
201 gap: 8px;
202 }
203
David Crawshaw4cd01292025-06-15 18:59:13 +0000204 .file-selector {
205 min-width: 200px;
206 padding: 8px 12px;
207 border: 1px solid var(--border-color, #ccc);
208 border-radius: 4px;
209 background-color: var(--background-color, #fff);
210 font-family: var(--font-family, system-ui, sans-serif);
211 font-size: 14px;
212 cursor: pointer;
213 }
214
215 .file-selector:focus {
216 outline: none;
217 border-color: var(--accent-color, #007acc);
218 box-shadow: 0 0 0 2px var(--accent-color-light, rgba(0, 122, 204, 0.2));
219 }
220
David Crawshaw5c6d8292025-06-15 19:09:19 +0000221 .file-selector:disabled {
222 background-color: var(--background-disabled, #f5f5f5);
223 color: var(--text-disabled, #999);
224 cursor: not-allowed;
225 }
226
227 .spacer {
228 flex: 1;
229 }
230
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700231 sketch-diff-range-picker {
David Crawshawdbca8972025-06-14 23:46:58 +0000232 flex: 1;
David Crawshawe2954ce2025-06-15 00:06:34 +0000233 min-width: 400px; /* Ensure minimum width for range picker */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700234 }
Autoformatter8c463622025-05-16 21:54:17 +0000235
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700236 sketch-diff-file-picker {
237 flex: 1;
238 }
Autoformatter8c463622025-05-16 21:54:17 +0000239
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700240 .view-toggle-button,
241 .header-expand-button {
242 background-color: transparent;
243 border: 1px solid var(--border-color, #e0e0e0);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700244 border-radius: 4px;
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700245 padding: 6px 8px;
246 font-size: 14px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700247 cursor: pointer;
248 white-space: nowrap;
249 transition: background-color 0.2s;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000250 display: flex;
251 align-items: center;
252 justify-content: center;
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700253 min-width: 32px;
254 min-height: 32px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700255 }
Autoformatter8c463622025-05-16 21:54:17 +0000256
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700257 .view-toggle-button:hover,
258 .header-expand-button:hover {
259 background-color: var(--background-hover, #e8e8e8);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700260 }
261
262 .diff-container {
263 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000264 overflow: auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700265 display: flex;
266 flex-direction: column;
David Crawshaw26f3f342025-06-14 19:58:32 +0000267 min-height: 0;
268 position: relative;
269 height: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700270 }
271
272 .diff-content {
273 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000274 overflow: auto;
275 min-height: 0;
276 display: flex;
277 flex-direction: column;
278 position: relative;
279 height: 100%;
280 }
281
282 .multi-file-diff-container {
283 display: flex;
284 flex-direction: column;
285 width: 100%;
286 min-height: 100%;
287 }
288
289 .file-diff-section {
290 display: flex;
291 flex-direction: column;
292 border-bottom: 3px solid var(--border-color, #e0e0e0);
293 margin-bottom: 0;
294 }
295
296 .file-diff-section:last-child {
297 border-bottom: none;
298 }
299
300 .file-header {
301 background-color: var(--background-light, #f8f8f8);
302 border-bottom: 1px solid var(--border-color, #e0e0e0);
David Crawshawdbca8972025-06-14 23:46:58 +0000303 padding: 8px 16px;
David Crawshaw26f3f342025-06-14 19:58:32 +0000304 font-family: var(--font-family, system-ui, sans-serif);
305 font-weight: 500;
306 font-size: 14px;
307 color: var(--text-primary-color, #333);
308 position: sticky;
309 top: 0;
310 z-index: 10;
311 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
312 display: flex;
313 justify-content: space-between;
314 align-items: center;
315 }
316
317 .file-header-left {
318 display: flex;
319 align-items: center;
320 gap: 8px;
321 }
322
323 .file-header-right {
324 display: flex;
325 align-items: center;
326 }
327
328 .file-expand-button {
329 background-color: transparent;
330 border: 1px solid var(--border-color, #e0e0e0);
331 border-radius: 4px;
332 padding: 4px 8px;
333 font-size: 14px;
334 cursor: pointer;
335 transition: background-color 0.2s;
336 display: flex;
337 align-items: center;
338 justify-content: center;
339 min-width: 32px;
340 min-height: 32px;
341 }
342
343 .file-expand-button:hover {
344 background-color: var(--background-hover, #e8e8e8);
345 }
346
347 .file-path {
348 font-family: monospace;
349 font-weight: normal;
350 color: var(--text-secondary-color, #666);
351 }
352
353 .file-status {
354 display: inline-block;
355 padding: 2px 6px;
356 border-radius: 3px;
357 font-size: 12px;
358 font-weight: bold;
359 margin-right: 8px;
360 }
361
362 .file-status.added {
363 background-color: #d4edda;
364 color: #155724;
365 }
366
367 .file-status.modified {
368 background-color: #fff3cd;
369 color: #856404;
370 }
371
372 .file-status.deleted {
373 background-color: #f8d7da;
374 color: #721c24;
375 }
376
377 .file-status.renamed {
378 background-color: #d1ecf1;
379 color: #0c5460;
380 }
381
382 .file-changes {
383 margin-left: 8px;
384 font-size: 12px;
385 color: var(--text-secondary-color, #666);
386 }
387
388 .file-diff-editor {
389 display: flex;
390 flex-direction: column;
391 min-height: 200px;
392 /* Height will be set dynamically by monaco editor */
393 overflow: visible; /* Ensure content is not clipped */
394 }
395
Autoformatter8c463622025-05-16 21:54:17 +0000396 .loading,
397 .empty-diff {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700398 display: flex;
399 align-items: center;
400 justify-content: center;
401 height: 100%;
402 font-family: var(--font-family, system-ui, sans-serif);
403 }
Autoformatter8c463622025-05-16 21:54:17 +0000404
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700405 .empty-diff {
406 color: var(--text-secondary-color, #666);
407 font-size: 16px;
408 text-align: center;
409 }
410
411 .error {
412 color: var(--error-color, #dc3545);
413 padding: 16px;
414 font-family: var(--font-family, system-ui, sans-serif);
415 }
416
417 sketch-monaco-view {
418 --editor-width: 100%;
419 --editor-height: 100%;
David Crawshaw26f3f342025-06-14 19:58:32 +0000420 display: flex;
421 flex-direction: column;
422 width: 100%;
423 min-height: 200px;
424 /* Ensure Monaco view takes full container space */
425 flex: 1;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700426 }
David Crawshaw4cd01292025-06-15 18:59:13 +0000427
428 /* Single file view styles */
429 .single-file-view {
430 flex: 1;
431 display: flex;
432 flex-direction: column;
433 height: 100%;
434 min-height: 0;
435 }
436
437 .single-file-monaco {
438 flex: 1;
439 width: 100%;
440 height: 100%;
441 min-height: 0;
442 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700443 `;
444
445 @property({ attribute: false, type: Object })
446 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000447
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700448 // The gitService must be passed from parent to ensure proper dependency injection
449
450 constructor() {
451 super();
Autoformatter8c463622025-05-16 21:54:17 +0000452 console.log("SketchDiff2View initialized");
453
David Crawshawe2954ce2025-06-15 00:06:34 +0000454 // Fix for monaco-aria-container positioning and hide scrollbars globally
455 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000456 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700457 styleElement.textContent = `
458 .monaco-aria-container {
459 position: absolute !important;
460 top: 0 !important;
461 left: 0 !important;
462 width: 1px !important;
463 height: 1px !important;
464 overflow: hidden !important;
465 clip: rect(1px, 1px, 1px, 1px) !important;
466 white-space: nowrap !important;
467 margin: 0 !important;
468 padding: 0 !important;
469 border: 0 !important;
470 z-index: -1 !important;
471 }
David Crawshawe2954ce2025-06-15 00:06:34 +0000472
473 /* Aggressively hide all Monaco scrollbar elements */
474 .monaco-editor .scrollbar,
475 .monaco-editor .scroll-decoration,
476 .monaco-editor .invisible.scrollbar,
477 .monaco-editor .slider,
478 .monaco-editor .vertical.scrollbar,
479 .monaco-editor .horizontal.scrollbar,
480 .monaco-diff-editor .scrollbar,
481 .monaco-diff-editor .scroll-decoration,
482 .monaco-diff-editor .invisible.scrollbar,
483 .monaco-diff-editor .slider,
484 .monaco-diff-editor .vertical.scrollbar,
485 .monaco-diff-editor .horizontal.scrollbar {
486 display: none !important;
487 visibility: hidden !important;
488 width: 0 !important;
489 height: 0 !important;
490 opacity: 0 !important;
491 }
492
493 /* Target the specific scrollbar classes that Monaco uses */
494 .monaco-scrollable-element > .scrollbar,
495 .monaco-scrollable-element > .scroll-decoration,
496 .monaco-scrollable-element .slider {
497 display: none !important;
498 visibility: hidden !important;
499 width: 0 !important;
500 height: 0 !important;
501 }
502
503 /* Remove scrollbar space/padding from content area */
504 .monaco-editor .monaco-scrollable-element,
505 .monaco-diff-editor .monaco-scrollable-element {
506 padding-right: 0 !important;
507 padding-bottom: 0 !important;
508 margin-right: 0 !important;
509 margin-bottom: 0 !important;
510 }
511
512 /* Ensure the diff content takes full width without scrollbar space */
513 .monaco-diff-editor .editor.modified,
514 .monaco-diff-editor .editor.original {
515 margin-right: 0 !important;
516 padding-right: 0 !important;
517 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700518 `;
519 document.head.appendChild(styleElement);
520 }
521
522 connectedCallback() {
523 super.connectedCallback();
524 // Initialize with default range and load data
525 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000526 if (
527 this.currentRange.type === "range" &&
528 !("from" in this.currentRange && this.currentRange.from)
529 ) {
530 this.gitService
531 .getBaseCommitRef()
532 .then((baseRef) => {
533 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
534 this.loadDiffData();
535 })
536 .catch((error) => {
537 console.error("Error getting base commit ref:", error);
538 // Use default range
539 this.loadDiffData();
540 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700541 } else {
542 this.loadDiffData();
543 }
544 }
545
David Crawshaw26f3f342025-06-14 19:58:32 +0000546 // Toggle hideUnchangedRegions setting for a specific file
547 private toggleFileExpansion(filePath: string) {
548 const currentState = this.fileExpandStates.get(filePath) ?? false;
549 const newState = !currentState;
550 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000551
David Crawshaw26f3f342025-06-14 19:58:32 +0000552 // Apply to the specific Monaco view component for this file
Autoformatter9abf8032025-06-14 23:24:08 +0000553 const monacoView = this.shadowRoot?.querySelector(
554 `sketch-monaco-view[data-file-path="${filePath}"]`,
555 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700556 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000557 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700558 }
Autoformatter9abf8032025-06-14 23:24:08 +0000559
David Crawshaw26f3f342025-06-14 19:58:32 +0000560 // Force a re-render to update the button state
561 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700562 }
Autoformatter8c463622025-05-16 21:54:17 +0000563
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700564 render() {
565 return html`
566 <div class="controls">
567 <div class="controls-container">
568 <div class="range-row">
569 <sketch-diff-range-picker
570 .gitService="${this.gitService}"
571 @range-change="${this.handleRangeChange}"
572 ></sketch-diff-range-picker>
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700573 <div class="spacer"></div>
574 ${this.renderFileSelector()}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700575 </div>
576 </div>
577 </div>
578
579 <div class="diff-container">
Autoformatter8c463622025-05-16 21:54:17 +0000580 <div class="diff-content">${this.renderDiffContent()}</div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700581 </div>
582 `;
583 }
584
David Crawshaw4cd01292025-06-15 18:59:13 +0000585 renderFileSelector() {
David Crawshaw5c6d8292025-06-15 19:09:19 +0000586 const fileCount = this.files.length;
Autoformatter62554112025-06-15 19:23:33 +0000587
David Crawshaw4cd01292025-06-15 18:59:13 +0000588 return html`
Philip Zeyliger38499cc2025-06-15 21:17:05 -0700589 <div class="file-selector-container">
590 <select
591 class="file-selector"
592 .value="${this.selectedFile}"
593 @change="${this.handleFileSelection}"
594 ?disabled="${fileCount === 0}"
595 >
596 <option value="">All files (${fileCount})</option>
597 ${this.files.map(
598 (file) => html`
599 <option value="${file.path}">
600 ${this.getFileDisplayName(file)}
601 </option>
602 `,
603 )}
604 </select>
605 ${this.selectedFile ? this.renderSingleFileExpandButton() : ""}
606 </div>
David Crawshaw4cd01292025-06-15 18:59:13 +0000607 `;
608 }
609
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700610 renderDiffContent() {
611 if (this.loading) {
612 return html`<div class="loading">Loading diff...</div>`;
613 }
614
615 if (this.error) {
616 return html`<div class="error">${this.error}</div>`;
617 }
618
619 if (this.files.length === 0) {
620 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
621 }
Autoformatter8c463622025-05-16 21:54:17 +0000622
David Crawshaw4cd01292025-06-15 18:59:13 +0000623 // Render single file view if a specific file is selected
624 if (this.selectedFile && this.viewMode === "single") {
625 return this.renderSingleFileView();
626 }
627
628 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700629 return html`
David Crawshaw26f3f342025-06-14 19:58:32 +0000630 <div class="multi-file-diff-container">
631 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
632 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700633 `;
634 }
635
636 /**
637 * Load diff data for the current range
638 */
639 async loadDiffData() {
640 this.loading = true;
641 this.error = null;
642
643 try {
644 // Initialize files as empty array if undefined
645 if (!this.files) {
646 this.files = [];
647 }
648
David Crawshaw216d2fc2025-06-15 18:45:53 +0000649 // Load diff data for the range
650 this.files = await this.gitService.getDiff(
651 this.currentRange.from,
652 this.currentRange.to,
653 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700654
655 // Ensure files is always an array, even when API returns null
656 if (!this.files) {
657 this.files = [];
658 }
Autoformatter8c463622025-05-16 21:54:17 +0000659
David Crawshaw26f3f342025-06-14 19:58:32 +0000660 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700661 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000662 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000663 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000664 if (!this.fileExpandStates.has(file.path)) {
665 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
666 }
667 });
668 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700669 } else {
670 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000671 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000672 this.selectedFile = "";
673 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000674 this.fileContents.clear();
675 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700676 }
677 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000678 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700679 this.error = `Error loading diff data: ${error.message}`;
680 // Ensure files is an empty array when an error occurs
681 this.files = [];
682 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000683 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000684 this.selectedFile = "";
685 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000686 this.fileContents.clear();
687 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700688 } finally {
689 this.loading = false;
690 }
691 }
692
693 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000694 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700695 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000696 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700697 this.loading = true;
698 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000699 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700700
701 try {
702 let fromCommit: string;
703 let toCommit: string;
704 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000705
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700706 // Determine the commits to compare based on the current range
David Crawshaw216d2fc2025-06-15 18:45:53 +0000707 fromCommit = this.currentRange.from;
708 toCommit = this.currentRange.to;
709 // Check if this is an unstaged changes view
710 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700711
David Crawshaw26f3f342025-06-14 19:58:32 +0000712 // Load content for all files
713 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700714 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000715 let originalCode = "";
716 let modifiedCode = "";
717 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000718
David Crawshaw26f3f342025-06-14 19:58:32 +0000719 // Load the original code based on file status
720 if (file.status !== "A") {
721 // For modified, renamed, or deleted files: load original content
722 originalCode = await this.gitService.getFileContent(
723 file.old_hash || "",
724 );
725 }
726
727 // For modified code, always use working copy when editable
728 if (editable) {
729 try {
730 // Always use working copy when editable, regardless of diff status
731 modifiedCode = await this.gitService.getWorkingCopyContent(
732 file.path,
733 );
734 } catch (error) {
735 if (file.status === "D") {
736 // For deleted files, silently use empty content
737 console.warn(
738 `Could not get working copy for deleted file ${file.path}, using empty content`,
739 );
740 modifiedCode = "";
741 } else {
742 // For any other file status, propagate the error
743 console.error(
744 `Failed to get working copy for ${file.path}:`,
745 error,
746 );
747 throw error;
748 }
749 }
750 } else {
751 // For non-editable view, use git content based on file status
752 if (file.status === "D") {
753 // Deleted file: empty modified
754 modifiedCode = "";
755 } else {
756 // Added/modified/renamed: use the content from git
757 modifiedCode = await this.gitService.getFileContent(
758 file.new_hash || "",
759 );
760 }
761 }
762
763 // Don't make deleted files editable
764 if (file.status === "D") {
765 editable = false;
766 }
767
768 this.fileContents.set(file.path, {
769 original: originalCode,
770 modified: modifiedCode,
771 editable,
772 });
773 } catch (error) {
774 console.error(`Error loading content for file ${file.path}:`, error);
775 // Store empty content for failed files to prevent blocking
776 this.fileContents.set(file.path, {
777 original: "",
778 modified: "",
779 editable: false,
780 });
781 }
782 });
783
784 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700785 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000786 console.error("Error loading file contents:", error);
787 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700788 } finally {
789 this.loading = false;
790 }
791 }
792
793 /**
794 * Handle range change event from the range picker
795 */
796 handleRangeChange(event: CustomEvent) {
797 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000798 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700799 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000800
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700801 // Load diff data for the new range
802 this.loadDiffData();
803 }
804
805 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000806 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700807 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000808 renderFileDiff(file: GitDiffFile, index: number) {
809 const content = this.fileContents.get(file.path);
810 if (!content) {
811 return html`
812 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000813 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000814 <div class="loading">Loading ${file.path}...</div>
815 </div>
816 `;
817 }
818
819 return html`
820 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000821 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000822 <div class="file-diff-editor">
823 <sketch-monaco-view
824 .originalCode="${content.original}"
825 .modifiedCode="${content.modified}"
826 .originalFilename="${file.path}"
827 .modifiedFilename="${file.path}"
828 ?readOnly="${!content.editable}"
829 ?editable-right="${content.editable}"
830 @monaco-comment="${this.handleMonacoComment}"
831 @monaco-save="${this.handleMonacoSave}"
832 @monaco-height-changed="${this.handleMonacoHeightChange}"
833 data-file-index="${index}"
834 data-file-path="${file.path}"
835 ></sketch-monaco-view>
836 </div>
837 </div>
838 `;
839 }
840
841 /**
842 * Render file header with status and path info
843 */
844 renderFileHeader(file: GitDiffFile) {
845 const statusClass = this.getFileStatusClass(file.status);
846 const statusText = this.getFileStatusText(file.status);
847 const changesInfo = this.getChangesInfo(file);
848 const pathInfo = this.getPathInfo(file);
849
850 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000851
David Crawshaw26f3f342025-06-14 19:58:32 +0000852 return html`
853 <div class="file-header-left">
854 <span class="file-status ${statusClass}">${statusText}</span>
855 <span class="file-path">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000856 ${changesInfo
857 ? html`<span class="file-changes">${changesInfo}</span>`
858 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000859 </div>
860 <div class="file-header-right">
861 <button
862 class="file-expand-button"
863 @click="${() => this.toggleFileExpansion(file.path)}"
864 title="${isExpanded
865 ? "Collapse: Hide unchanged regions to focus on changes"
866 : "Expand: Show all lines including unchanged regions"}"
867 >
Autoformatter9abf8032025-06-14 23:24:08 +0000868 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000869 </button>
870 </div>
871 `;
872 }
873
874 /**
875 * Get CSS class for file status
876 */
877 getFileStatusClass(status: string): string {
878 switch (status.toUpperCase()) {
879 case "A":
880 return "added";
881 case "M":
882 return "modified";
883 case "D":
884 return "deleted";
885 case "R":
886 default:
887 if (status.toUpperCase().startsWith("R")) {
888 return "renamed";
889 }
890 return "modified";
891 }
892 }
893
894 /**
895 * Get display text for file status
896 */
897 getFileStatusText(status: string): string {
898 switch (status.toUpperCase()) {
899 case "A":
900 return "Added";
901 case "M":
902 return "Modified";
903 case "D":
904 return "Deleted";
905 case "R":
906 default:
907 if (status.toUpperCase().startsWith("R")) {
908 return "Renamed";
909 }
910 return "Modified";
911 }
912 }
913
914 /**
915 * Get changes information (+/-) for display
916 */
917 getChangesInfo(file: GitDiffFile): string {
918 const additions = file.additions || 0;
919 const deletions = file.deletions || 0;
920
921 if (additions === 0 && deletions === 0) {
922 return "";
923 }
924
925 const parts = [];
926 if (additions > 0) {
927 parts.push(`+${additions}`);
928 }
929 if (deletions > 0) {
930 parts.push(`-${deletions}`);
931 }
932
933 return `(${parts.join(", ")})`;
934 }
935
936 /**
937 * Get path information for display, handling renames
938 */
939 getPathInfo(file: GitDiffFile): string {
940 if (file.old_path && file.old_path !== "") {
941 // For renames, show old_path → new_path
942 return `${file.old_path} → ${file.path}`;
943 }
944 // For regular files, just show the path
945 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700946 }
947
948 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000949 * Render expand all icon (dotted line with arrows pointing away)
950 */
951 renderExpandAllIcon() {
952 return html`
953 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
954 <!-- Dotted line in the middle -->
955 <line
956 x1="2"
957 y1="8"
958 x2="14"
959 y2="8"
960 stroke="currentColor"
961 stroke-width="1"
962 stroke-dasharray="2,1"
963 />
964 <!-- Large arrow pointing up -->
965 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
966 <!-- Large arrow pointing down -->
967 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
968 </svg>
969 `;
970 }
971
972 /**
973 * Render collapse icon (arrows pointing towards dotted line)
974 */
975 renderCollapseIcon() {
976 return html`
977 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
978 <!-- Dotted line in the middle -->
979 <line
980 x1="2"
981 y1="8"
982 x2="14"
983 y2="8"
984 stroke="currentColor"
985 stroke-width="1"
986 stroke-dasharray="2,1"
987 />
988 <!-- Large arrow pointing down towards line -->
989 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
990 <!-- Large arrow pointing up towards line -->
991 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
992 </svg>
993 `;
994 }
995
996 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000997 * Handle file selection change from the dropdown
998 */
999 handleFileSelection(event: Event) {
1000 const selectElement = event.target as HTMLSelectElement;
1001 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +00001002
David Crawshaw4cd01292025-06-15 18:59:13 +00001003 this.selectedFile = selectedValue;
1004 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +00001005
David Crawshaw4cd01292025-06-15 18:59:13 +00001006 // Force re-render
1007 this.requestUpdate();
1008 }
1009
1010 /**
1011 * Get display name for file in the selector
1012 */
1013 getFileDisplayName(file: GitDiffFile): string {
1014 const status = this.getFileStatusText(file.status);
1015 const pathInfo = this.getPathInfo(file);
1016 return `${status}: ${pathInfo}`;
1017 }
1018
1019 /**
Philip Zeyliger38499cc2025-06-15 21:17:05 -07001020 * Render expand/collapse button for single file view in header
1021 */
1022 renderSingleFileExpandButton() {
1023 if (!this.selectedFile) return "";
1024
1025 const isExpanded = this.fileExpandStates.get(this.selectedFile) ?? false;
1026
1027 return html`
1028 <button
1029 class="header-expand-button"
1030 @click="${() => this.toggleFileExpansion(this.selectedFile)}"
1031 title="${isExpanded
1032 ? "Collapse: Hide unchanged regions to focus on changes"
1033 : "Expand: Show all lines including unchanged regions"}"
1034 >
1035 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
1036 </button>
1037 `;
1038 }
1039
1040 /**
David Crawshaw4cd01292025-06-15 18:59:13 +00001041 * Render single file view with full-screen Monaco editor
1042 */
1043 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +00001044 const selectedFileData = this.files.find(
1045 (f) => f.path === this.selectedFile,
1046 );
David Crawshaw4cd01292025-06-15 18:59:13 +00001047 if (!selectedFileData) {
1048 return html`<div class="error">Selected file not found</div>`;
1049 }
1050
1051 const content = this.fileContents.get(this.selectedFile);
1052 if (!content) {
1053 return html`<div class="loading">Loading ${this.selectedFile}...</div>`;
1054 }
1055
1056 return html`
1057 <div class="single-file-view">
1058 <sketch-monaco-view
1059 class="single-file-monaco"
1060 .originalCode="${content.original}"
1061 .modifiedCode="${content.modified}"
1062 .originalFilename="${selectedFileData.path}"
1063 .modifiedFilename="${selectedFileData.path}"
1064 ?readOnly="${!content.editable}"
1065 ?editable-right="${content.editable}"
1066 @monaco-comment="${this.handleMonacoComment}"
1067 @monaco-save="${this.handleMonacoSave}"
1068 data-file-path="${selectedFileData.path}"
1069 ></sketch-monaco-view>
1070 </div>
1071 `;
1072 }
1073
1074 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001075 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +00001076 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001077 * This is called when the Monaco diff tab is activated to ensure:
1078 * 1. Branch information from git/recentlog is current (branches can change frequently)
1079 * 2. The diff content is synchronized with the latest repository state
1080 * 3. Users always see up-to-date information without manual refresh
1081 */
1082 refreshDiffView() {
1083 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +00001084 const rangePicker = this.shadowRoot?.querySelector(
1085 "sketch-diff-range-picker",
1086 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001087 if (rangePicker) {
1088 (rangePicker as any).loadCommits();
1089 }
Autoformatter8c463622025-05-16 21:54:17 +00001090
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001091 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +00001092 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +00001093 this.currentRange = {
1094 type: "range",
1095 from: `${this.commit}^`,
1096 to: this.commit,
1097 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001098 }
Autoformatter8c463622025-05-16 21:54:17 +00001099
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001100 // Then reload diff data based on the current range
1101 this.loadDiffData();
1102 }
1103}
1104
1105declare global {
1106 interface HTMLElementTagNameMap {
1107 "sketch-diff2-view": SketchDiff2View;
1108 }
1109}