blob: f23472d2791dd27de69397129b24f52a21e5ee53 [file] [log] [blame]
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import "./sketch-monaco-view";
4import "./sketch-diff-range-picker";
David Crawshaw26f3f342025-06-14 19:58:32 +00005// import "./sketch-diff-file-picker"; // No longer needed for multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -07006import "./sketch-diff-empty-view";
Autoformatter8c463622025-05-16 21:54:17 +00007import {
8 GitDiffFile,
9 GitDataService,
10 DefaultGitDataService,
11} from "./git-data-service";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070012import { DiffRange } from "./sketch-diff-range-picker";
13
14/**
15 * A component that displays diffs using Monaco editor with range and file pickers
16 */
17@customElement("sketch-diff2-view")
18export class SketchDiff2View extends LitElement {
19 /**
20 * Handles comment events from the Monaco editor and forwards them to the chat input
21 * using the same event format as the original diff view for consistency.
22 */
23 private handleMonacoComment(event: CustomEvent) {
24 try {
25 // Validate incoming data
26 if (!event.detail || !event.detail.formattedComment) {
Autoformatter8c463622025-05-16 21:54:17 +000027 console.error("Invalid comment data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -070028 return;
29 }
Autoformatter8c463622025-05-16 21:54:17 +000030
Philip Zeyliger272a90e2025-05-16 14:49:51 -070031 // Create and dispatch event using the standardized format
Autoformatter8c463622025-05-16 21:54:17 +000032 const commentEvent = new CustomEvent("diff-comment", {
Philip Zeyliger272a90e2025-05-16 14:49:51 -070033 detail: { comment: event.detail.formattedComment },
34 bubbles: true,
Autoformatter8c463622025-05-16 21:54:17 +000035 composed: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -070036 });
Autoformatter8c463622025-05-16 21:54:17 +000037
Philip Zeyliger272a90e2025-05-16 14:49:51 -070038 this.dispatchEvent(commentEvent);
39 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +000040 console.error("Error handling Monaco comment:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -070041 }
42 }
Autoformatter8c463622025-05-16 21:54:17 +000043
Philip Zeyliger272a90e2025-05-16 14:49:51 -070044 /**
David Crawshaw26f3f342025-06-14 19:58:32 +000045 * Handle height change events from the Monaco editor
46 */
47 private handleMonacoHeightChange(event: CustomEvent) {
48 try {
49 // Get the monaco view that emitted the event
50 const monacoView = event.target as HTMLElement;
51 if (!monacoView) return;
Autoformatter9abf8032025-06-14 23:24:08 +000052
David Crawshaw26f3f342025-06-14 19:58:32 +000053 // Find the parent file-diff-editor container
Autoformatter9abf8032025-06-14 23:24:08 +000054 const fileDiffEditor = monacoView.closest(
55 ".file-diff-editor",
56 ) as HTMLElement;
David Crawshaw26f3f342025-06-14 19:58:32 +000057 if (!fileDiffEditor) return;
Autoformatter9abf8032025-06-14 23:24:08 +000058
David Crawshaw26f3f342025-06-14 19:58:32 +000059 // Get the new height from the event
60 const newHeight = event.detail.height;
Autoformatter9abf8032025-06-14 23:24:08 +000061
David Crawshaw26f3f342025-06-14 19:58:32 +000062 // Only update if the height actually changed to avoid unnecessary layout
63 const currentHeight = fileDiffEditor.style.height;
64 const newHeightStr = `${newHeight}px`;
Autoformatter9abf8032025-06-14 23:24:08 +000065
David Crawshaw26f3f342025-06-14 19:58:32 +000066 if (currentHeight !== newHeightStr) {
67 // Update the file-diff-editor height to match monaco's height
68 fileDiffEditor.style.height = newHeightStr;
Autoformatter9abf8032025-06-14 23:24:08 +000069
David Crawshaw26f3f342025-06-14 19:58:32 +000070 // Remove any previous min-height constraint that might interfere
Autoformatter9abf8032025-06-14 23:24:08 +000071 fileDiffEditor.style.minHeight = "auto";
72
David Crawshaw26f3f342025-06-14 19:58:32 +000073 // IMPORTANT: Tell Monaco to relayout after its container size changed
74 // Monaco has automaticLayout: false, so it won't detect container changes
75 setTimeout(() => {
76 const monacoComponent = monacoView as any;
77 if (monacoComponent && monacoComponent.editor) {
78 // Force layout with explicit dimensions to ensure Monaco fills the space
79 const editorWidth = fileDiffEditor.offsetWidth;
80 monacoComponent.editor.layout({
81 width: editorWidth,
Autoformatter9abf8032025-06-14 23:24:08 +000082 height: newHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +000083 });
84 }
85 }, 0);
86 }
David Crawshaw26f3f342025-06-14 19:58:32 +000087 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +000088 console.error("Error handling Monaco height change:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +000089 }
90 }
91
92 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -070093 * Handle save events from the Monaco editor
94 */
95 private async handleMonacoSave(event: CustomEvent) {
96 try {
97 // Validate incoming data
Autoformatter8c463622025-05-16 21:54:17 +000098 if (
99 !event.detail ||
100 !event.detail.path ||
101 event.detail.content === undefined
102 ) {
103 console.error("Invalid save data received");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700104 return;
105 }
Autoformatter8c463622025-05-16 21:54:17 +0000106
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700107 const { path, content } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000108
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700109 // Get Monaco view component
Autoformatter8c463622025-05-16 21:54:17 +0000110 const monacoView = this.shadowRoot?.querySelector("sketch-monaco-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700111 if (!monacoView) {
Autoformatter8c463622025-05-16 21:54:17 +0000112 console.error("Monaco view not found");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700113 return;
114 }
Autoformatter8c463622025-05-16 21:54:17 +0000115
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700116 try {
117 await this.gitService?.saveFileContent(path, content);
118 console.log(`File saved: ${path}`);
119 (monacoView as any).notifySaveComplete(true);
120 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000121 console.error(
122 `Error saving file: ${error instanceof Error ? error.message : String(error)}`,
123 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700124 (monacoView as any).notifySaveComplete(false);
125 }
126 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000127 console.error("Error handling save:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700128 }
129 }
130 @property({ type: String })
131 initialCommit: string = "";
Autoformatter8c463622025-05-16 21:54:17 +0000132
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700133 // The commit to show - used when showing a specific commit from timeline
134 @property({ type: String })
135 commit: string = "";
136
137 @property({ type: String })
138 selectedFilePath: string = "";
139
140 @state()
141 private files: GitDiffFile[] = [];
Autoformatter8c463622025-05-16 21:54:17 +0000142
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700143 @state()
Autoformatter8c463622025-05-16 21:54:17 +0000144 private currentRange: DiffRange = { type: "range", from: "", to: "HEAD" };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700145
146 @state()
Autoformatter9abf8032025-06-14 23:24:08 +0000147 private fileContents: Map<
148 string,
149 { original: string; modified: string; editable: boolean }
150 > = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700151
152 @state()
David Crawshaw26f3f342025-06-14 19:58:32 +0000153 private fileExpandStates: Map<string, boolean> = new Map();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700154
155 @state()
156 private loading: boolean = false;
157
158 @state()
159 private error: string | null = null;
160
David Crawshaw4cd01292025-06-15 18:59:13 +0000161 @state()
162 private selectedFile: string = ""; // Empty string means "All files"
163
164 @state()
165 private viewMode: "all" | "single" = "all";
166
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700167 static styles = css`
168 :host {
169 display: flex;
170 height: 100%;
171 flex: 1;
172 flex-direction: column;
173 min-height: 0; /* Critical for flex child behavior */
174 overflow: hidden;
175 position: relative; /* Establish positioning context */
176 }
177
178 .controls {
179 padding: 8px 16px;
180 border-bottom: 1px solid var(--border-color, #e0e0e0);
181 background-color: var(--background-light, #f8f8f8);
182 flex-shrink: 0; /* Prevent controls from shrinking */
183 }
Autoformatter8c463622025-05-16 21:54:17 +0000184
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700185 .controls-container {
186 display: flex;
187 flex-direction: column;
188 gap: 12px;
189 }
Autoformatter8c463622025-05-16 21:54:17 +0000190
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700191 .range-row {
192 width: 100%;
193 display: flex;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700194 align-items: center;
David Crawshawdbca8972025-06-14 23:46:58 +0000195 gap: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700196 }
Autoformatter8c463622025-05-16 21:54:17 +0000197
David Crawshaw4cd01292025-06-15 18:59:13 +0000198 .file-selector {
199 min-width: 200px;
200 padding: 8px 12px;
201 border: 1px solid var(--border-color, #ccc);
202 border-radius: 4px;
203 background-color: var(--background-color, #fff);
204 font-family: var(--font-family, system-ui, sans-serif);
205 font-size: 14px;
206 cursor: pointer;
207 }
208
209 .file-selector:focus {
210 outline: none;
211 border-color: var(--accent-color, #007acc);
212 box-shadow: 0 0 0 2px var(--accent-color-light, rgba(0, 122, 204, 0.2));
213 }
214
David Crawshaw5c6d8292025-06-15 19:09:19 +0000215 .file-selector:disabled {
216 background-color: var(--background-disabled, #f5f5f5);
217 color: var(--text-disabled, #999);
218 cursor: not-allowed;
219 }
220
221 .spacer {
222 flex: 1;
223 }
224
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700225 sketch-diff-range-picker {
David Crawshawdbca8972025-06-14 23:46:58 +0000226 flex: 1;
David Crawshawe2954ce2025-06-15 00:06:34 +0000227 min-width: 400px; /* Ensure minimum width for range picker */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700228 }
Autoformatter8c463622025-05-16 21:54:17 +0000229
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700230 sketch-diff-file-picker {
231 flex: 1;
232 }
Autoformatter8c463622025-05-16 21:54:17 +0000233
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700234 .view-toggle-button {
235 background-color: #f0f0f0;
236 border: 1px solid #ccc;
237 border-radius: 4px;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000238 padding: 8px;
239 font-size: 16px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700240 cursor: pointer;
241 white-space: nowrap;
242 transition: background-color 0.2s;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000243 display: flex;
244 align-items: center;
245 justify-content: center;
246 min-width: 36px;
247 min-height: 36px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700248 }
Autoformatter8c463622025-05-16 21:54:17 +0000249
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700250 .view-toggle-button:hover {
251 background-color: #e0e0e0;
252 }
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">
David Crawshaw5c6d8292025-06-15 19:09:19 +0000561 ${this.renderFileSelector()}
562 <div class="spacer"></div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700563 <sketch-diff-range-picker
564 .gitService="${this.gitService}"
565 @range-change="${this.handleRangeChange}"
566 ></sketch-diff-range-picker>
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`
581 <select
582 class="file-selector"
583 .value="${this.selectedFile}"
584 @change="${this.handleFileSelection}"
David Crawshaw5c6d8292025-06-15 19:09:19 +0000585 ?disabled="${fileCount === 0}"
David Crawshaw4cd01292025-06-15 18:59:13 +0000586 >
David Crawshaw5c6d8292025-06-15 19:09:19 +0000587 <option value="">All files (${fileCount})</option>
David Crawshaw4cd01292025-06-15 18:59:13 +0000588 ${this.files.map(
589 (file) => html`
590 <option value="${file.path}">
591 ${this.getFileDisplayName(file)}
592 </option>
593 `,
594 )}
595 </select>
596 `;
597 }
598
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700599 renderDiffContent() {
600 if (this.loading) {
601 return html`<div class="loading">Loading diff...</div>`;
602 }
603
604 if (this.error) {
605 return html`<div class="error">${this.error}</div>`;
606 }
607
608 if (this.files.length === 0) {
609 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
610 }
Autoformatter8c463622025-05-16 21:54:17 +0000611
David Crawshaw4cd01292025-06-15 18:59:13 +0000612 // Render single file view if a specific file is selected
613 if (this.selectedFile && this.viewMode === "single") {
614 return this.renderSingleFileView();
615 }
616
617 // Render multi-file view
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700618 return html`
David Crawshaw26f3f342025-06-14 19:58:32 +0000619 <div class="multi-file-diff-container">
620 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
621 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700622 `;
623 }
624
625 /**
626 * Load diff data for the current range
627 */
628 async loadDiffData() {
629 this.loading = true;
630 this.error = null;
631
632 try {
633 // Initialize files as empty array if undefined
634 if (!this.files) {
635 this.files = [];
636 }
637
David Crawshaw216d2fc2025-06-15 18:45:53 +0000638 // Load diff data for the range
639 this.files = await this.gitService.getDiff(
640 this.currentRange.from,
641 this.currentRange.to,
642 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700643
644 // Ensure files is always an array, even when API returns null
645 if (!this.files) {
646 this.files = [];
647 }
Autoformatter8c463622025-05-16 21:54:17 +0000648
David Crawshaw26f3f342025-06-14 19:58:32 +0000649 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700650 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000651 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000652 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000653 if (!this.fileExpandStates.has(file.path)) {
654 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
655 }
656 });
657 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700658 } else {
659 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000660 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000661 this.selectedFile = "";
662 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000663 this.fileContents.clear();
664 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700665 }
666 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000667 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700668 this.error = `Error loading diff data: ${error.message}`;
669 // Ensure files is an empty array when an error occurs
670 this.files = [];
671 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000672 this.selectedFilePath = "";
David Crawshaw4cd01292025-06-15 18:59:13 +0000673 this.selectedFile = "";
674 this.viewMode = "all";
David Crawshaw26f3f342025-06-14 19:58:32 +0000675 this.fileContents.clear();
676 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700677 } finally {
678 this.loading = false;
679 }
680 }
681
682 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000683 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700684 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000685 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700686 this.loading = true;
687 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000688 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700689
690 try {
691 let fromCommit: string;
692 let toCommit: string;
693 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000694
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700695 // Determine the commits to compare based on the current range
David Crawshaw216d2fc2025-06-15 18:45:53 +0000696 fromCommit = this.currentRange.from;
697 toCommit = this.currentRange.to;
698 // Check if this is an unstaged changes view
699 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700700
David Crawshaw26f3f342025-06-14 19:58:32 +0000701 // Load content for all files
702 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700703 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000704 let originalCode = "";
705 let modifiedCode = "";
706 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000707
David Crawshaw26f3f342025-06-14 19:58:32 +0000708 // Load the original code based on file status
709 if (file.status !== "A") {
710 // For modified, renamed, or deleted files: load original content
711 originalCode = await this.gitService.getFileContent(
712 file.old_hash || "",
713 );
714 }
715
716 // For modified code, always use working copy when editable
717 if (editable) {
718 try {
719 // Always use working copy when editable, regardless of diff status
720 modifiedCode = await this.gitService.getWorkingCopyContent(
721 file.path,
722 );
723 } catch (error) {
724 if (file.status === "D") {
725 // For deleted files, silently use empty content
726 console.warn(
727 `Could not get working copy for deleted file ${file.path}, using empty content`,
728 );
729 modifiedCode = "";
730 } else {
731 // For any other file status, propagate the error
732 console.error(
733 `Failed to get working copy for ${file.path}:`,
734 error,
735 );
736 throw error;
737 }
738 }
739 } else {
740 // For non-editable view, use git content based on file status
741 if (file.status === "D") {
742 // Deleted file: empty modified
743 modifiedCode = "";
744 } else {
745 // Added/modified/renamed: use the content from git
746 modifiedCode = await this.gitService.getFileContent(
747 file.new_hash || "",
748 );
749 }
750 }
751
752 // Don't make deleted files editable
753 if (file.status === "D") {
754 editable = false;
755 }
756
757 this.fileContents.set(file.path, {
758 original: originalCode,
759 modified: modifiedCode,
760 editable,
761 });
762 } catch (error) {
763 console.error(`Error loading content for file ${file.path}:`, error);
764 // Store empty content for failed files to prevent blocking
765 this.fileContents.set(file.path, {
766 original: "",
767 modified: "",
768 editable: false,
769 });
770 }
771 });
772
773 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700774 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000775 console.error("Error loading file contents:", error);
776 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700777 } finally {
778 this.loading = false;
779 }
780 }
781
782 /**
783 * Handle range change event from the range picker
784 */
785 handleRangeChange(event: CustomEvent) {
786 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000787 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700788 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000789
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700790 // Load diff data for the new range
791 this.loadDiffData();
792 }
793
794 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000795 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700796 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000797 renderFileDiff(file: GitDiffFile, index: number) {
798 const content = this.fileContents.get(file.path);
799 if (!content) {
800 return html`
801 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000802 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000803 <div class="loading">Loading ${file.path}...</div>
804 </div>
805 `;
806 }
807
808 return html`
809 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000810 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000811 <div class="file-diff-editor">
812 <sketch-monaco-view
813 .originalCode="${content.original}"
814 .modifiedCode="${content.modified}"
815 .originalFilename="${file.path}"
816 .modifiedFilename="${file.path}"
817 ?readOnly="${!content.editable}"
818 ?editable-right="${content.editable}"
819 @monaco-comment="${this.handleMonacoComment}"
820 @monaco-save="${this.handleMonacoSave}"
821 @monaco-height-changed="${this.handleMonacoHeightChange}"
822 data-file-index="${index}"
823 data-file-path="${file.path}"
824 ></sketch-monaco-view>
825 </div>
826 </div>
827 `;
828 }
829
830 /**
831 * Render file header with status and path info
832 */
833 renderFileHeader(file: GitDiffFile) {
834 const statusClass = this.getFileStatusClass(file.status);
835 const statusText = this.getFileStatusText(file.status);
836 const changesInfo = this.getChangesInfo(file);
837 const pathInfo = this.getPathInfo(file);
838
839 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000840
David Crawshaw26f3f342025-06-14 19:58:32 +0000841 return html`
842 <div class="file-header-left">
843 <span class="file-status ${statusClass}">${statusText}</span>
844 <span class="file-path">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000845 ${changesInfo
846 ? html`<span class="file-changes">${changesInfo}</span>`
847 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000848 </div>
849 <div class="file-header-right">
850 <button
851 class="file-expand-button"
852 @click="${() => this.toggleFileExpansion(file.path)}"
853 title="${isExpanded
854 ? "Collapse: Hide unchanged regions to focus on changes"
855 : "Expand: Show all lines including unchanged regions"}"
856 >
Autoformatter9abf8032025-06-14 23:24:08 +0000857 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000858 </button>
859 </div>
860 `;
861 }
862
863 /**
864 * Get CSS class for file status
865 */
866 getFileStatusClass(status: string): string {
867 switch (status.toUpperCase()) {
868 case "A":
869 return "added";
870 case "M":
871 return "modified";
872 case "D":
873 return "deleted";
874 case "R":
875 default:
876 if (status.toUpperCase().startsWith("R")) {
877 return "renamed";
878 }
879 return "modified";
880 }
881 }
882
883 /**
884 * Get display text for file status
885 */
886 getFileStatusText(status: string): string {
887 switch (status.toUpperCase()) {
888 case "A":
889 return "Added";
890 case "M":
891 return "Modified";
892 case "D":
893 return "Deleted";
894 case "R":
895 default:
896 if (status.toUpperCase().startsWith("R")) {
897 return "Renamed";
898 }
899 return "Modified";
900 }
901 }
902
903 /**
904 * Get changes information (+/-) for display
905 */
906 getChangesInfo(file: GitDiffFile): string {
907 const additions = file.additions || 0;
908 const deletions = file.deletions || 0;
909
910 if (additions === 0 && deletions === 0) {
911 return "";
912 }
913
914 const parts = [];
915 if (additions > 0) {
916 parts.push(`+${additions}`);
917 }
918 if (deletions > 0) {
919 parts.push(`-${deletions}`);
920 }
921
922 return `(${parts.join(", ")})`;
923 }
924
925 /**
926 * Get path information for display, handling renames
927 */
928 getPathInfo(file: GitDiffFile): string {
929 if (file.old_path && file.old_path !== "") {
930 // For renames, show old_path → new_path
931 return `${file.old_path} → ${file.path}`;
932 }
933 // For regular files, just show the path
934 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700935 }
936
937 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000938 * Render expand all icon (dotted line with arrows pointing away)
939 */
940 renderExpandAllIcon() {
941 return html`
942 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
943 <!-- Dotted line in the middle -->
944 <line
945 x1="2"
946 y1="8"
947 x2="14"
948 y2="8"
949 stroke="currentColor"
950 stroke-width="1"
951 stroke-dasharray="2,1"
952 />
953 <!-- Large arrow pointing up -->
954 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
955 <!-- Large arrow pointing down -->
956 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
957 </svg>
958 `;
959 }
960
961 /**
962 * Render collapse icon (arrows pointing towards dotted line)
963 */
964 renderCollapseIcon() {
965 return html`
966 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
967 <!-- Dotted line in the middle -->
968 <line
969 x1="2"
970 y1="8"
971 x2="14"
972 y2="8"
973 stroke="currentColor"
974 stroke-width="1"
975 stroke-dasharray="2,1"
976 />
977 <!-- Large arrow pointing down towards line -->
978 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
979 <!-- Large arrow pointing up towards line -->
980 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
981 </svg>
982 `;
983 }
984
985 /**
David Crawshaw4cd01292025-06-15 18:59:13 +0000986 * Handle file selection change from the dropdown
987 */
988 handleFileSelection(event: Event) {
989 const selectElement = event.target as HTMLSelectElement;
990 const selectedValue = selectElement.value;
Autoformatter62554112025-06-15 19:23:33 +0000991
David Crawshaw4cd01292025-06-15 18:59:13 +0000992 this.selectedFile = selectedValue;
993 this.viewMode = selectedValue ? "single" : "all";
Autoformatter62554112025-06-15 19:23:33 +0000994
David Crawshaw4cd01292025-06-15 18:59:13 +0000995 // Force re-render
996 this.requestUpdate();
997 }
998
999 /**
1000 * Get display name for file in the selector
1001 */
1002 getFileDisplayName(file: GitDiffFile): string {
1003 const status = this.getFileStatusText(file.status);
1004 const pathInfo = this.getPathInfo(file);
1005 return `${status}: ${pathInfo}`;
1006 }
1007
1008 /**
1009 * Render single file view with full-screen Monaco editor
1010 */
1011 renderSingleFileView() {
Autoformatter62554112025-06-15 19:23:33 +00001012 const selectedFileData = this.files.find(
1013 (f) => f.path === this.selectedFile,
1014 );
David Crawshaw4cd01292025-06-15 18:59:13 +00001015 if (!selectedFileData) {
1016 return html`<div class="error">Selected file not found</div>`;
1017 }
1018
1019 const content = this.fileContents.get(this.selectedFile);
1020 if (!content) {
1021 return html`<div class="loading">Loading ${this.selectedFile}...</div>`;
1022 }
1023
1024 return html`
1025 <div class="single-file-view">
1026 <sketch-monaco-view
1027 class="single-file-monaco"
1028 .originalCode="${content.original}"
1029 .modifiedCode="${content.modified}"
1030 .originalFilename="${selectedFileData.path}"
1031 .modifiedFilename="${selectedFileData.path}"
1032 ?readOnly="${!content.editable}"
1033 ?editable-right="${content.editable}"
1034 @monaco-comment="${this.handleMonacoComment}"
1035 @monaco-save="${this.handleMonacoSave}"
1036 data-file-path="${selectedFileData.path}"
1037 ></sketch-monaco-view>
1038 </div>
1039 `;
1040 }
1041
1042 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001043 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +00001044 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001045 * This is called when the Monaco diff tab is activated to ensure:
1046 * 1. Branch information from git/recentlog is current (branches can change frequently)
1047 * 2. The diff content is synchronized with the latest repository state
1048 * 3. Users always see up-to-date information without manual refresh
1049 */
1050 refreshDiffView() {
1051 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +00001052 const rangePicker = this.shadowRoot?.querySelector(
1053 "sketch-diff-range-picker",
1054 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001055 if (rangePicker) {
1056 (rangePicker as any).loadCommits();
1057 }
Autoformatter8c463622025-05-16 21:54:17 +00001058
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001059 if (this.commit) {
David Crawshaw216d2fc2025-06-15 18:45:53 +00001060 // Convert single commit to range (commit^ to commit)
Autoformatter62554112025-06-15 19:23:33 +00001061 this.currentRange = {
1062 type: "range",
1063 from: `${this.commit}^`,
1064 to: this.commit,
1065 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001066 }
Autoformatter8c463622025-05-16 21:54:17 +00001067
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001068 // Then reload diff data based on the current range
1069 this.loadDiffData();
1070 }
1071}
1072
1073declare global {
1074 interface HTMLElementTagNameMap {
1075 "sketch-diff2-view": SketchDiff2View;
1076 }
1077}