blob: 722be567f67e0160f5827dfdd57de0038fce3502 [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
161 static styles = css`
162 :host {
163 display: flex;
164 height: 100%;
165 flex: 1;
166 flex-direction: column;
167 min-height: 0; /* Critical for flex child behavior */
168 overflow: hidden;
169 position: relative; /* Establish positioning context */
170 }
171
172 .controls {
173 padding: 8px 16px;
174 border-bottom: 1px solid var(--border-color, #e0e0e0);
175 background-color: var(--background-light, #f8f8f8);
176 flex-shrink: 0; /* Prevent controls from shrinking */
177 }
Autoformatter8c463622025-05-16 21:54:17 +0000178
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700179 .controls-container {
180 display: flex;
181 flex-direction: column;
182 gap: 12px;
183 }
Autoformatter8c463622025-05-16 21:54:17 +0000184
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700185 .range-row {
186 width: 100%;
187 display: flex;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700188 align-items: center;
David Crawshawdbca8972025-06-14 23:46:58 +0000189 gap: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700190 }
Autoformatter8c463622025-05-16 21:54:17 +0000191
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700192 sketch-diff-range-picker {
David Crawshawdbca8972025-06-14 23:46:58 +0000193 flex: 1;
David Crawshawe2954ce2025-06-15 00:06:34 +0000194 min-width: 400px; /* Ensure minimum width for range picker */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700195 }
Autoformatter8c463622025-05-16 21:54:17 +0000196
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700197 sketch-diff-file-picker {
198 flex: 1;
199 }
Autoformatter8c463622025-05-16 21:54:17 +0000200
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700201 .view-toggle-button {
202 background-color: #f0f0f0;
203 border: 1px solid #ccc;
204 border-radius: 4px;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000205 padding: 8px;
206 font-size: 16px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700207 cursor: pointer;
208 white-space: nowrap;
209 transition: background-color 0.2s;
Philip Zeyligere89b3082025-05-29 03:16:06 +0000210 display: flex;
211 align-items: center;
212 justify-content: center;
213 min-width: 36px;
214 min-height: 36px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700215 }
Autoformatter8c463622025-05-16 21:54:17 +0000216
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700217 .view-toggle-button:hover {
218 background-color: #e0e0e0;
219 }
220
221 .diff-container {
222 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000223 overflow: auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700224 display: flex;
225 flex-direction: column;
David Crawshaw26f3f342025-06-14 19:58:32 +0000226 min-height: 0;
227 position: relative;
228 height: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700229 }
230
231 .diff-content {
232 flex: 1;
David Crawshaw26f3f342025-06-14 19:58:32 +0000233 overflow: auto;
234 min-height: 0;
235 display: flex;
236 flex-direction: column;
237 position: relative;
238 height: 100%;
239 }
240
241 .multi-file-diff-container {
242 display: flex;
243 flex-direction: column;
244 width: 100%;
245 min-height: 100%;
246 }
247
248 .file-diff-section {
249 display: flex;
250 flex-direction: column;
251 border-bottom: 3px solid var(--border-color, #e0e0e0);
252 margin-bottom: 0;
253 }
254
255 .file-diff-section:last-child {
256 border-bottom: none;
257 }
258
259 .file-header {
260 background-color: var(--background-light, #f8f8f8);
261 border-bottom: 1px solid var(--border-color, #e0e0e0);
David Crawshawdbca8972025-06-14 23:46:58 +0000262 padding: 8px 16px;
David Crawshaw26f3f342025-06-14 19:58:32 +0000263 font-family: var(--font-family, system-ui, sans-serif);
264 font-weight: 500;
265 font-size: 14px;
266 color: var(--text-primary-color, #333);
267 position: sticky;
268 top: 0;
269 z-index: 10;
270 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
271 display: flex;
272 justify-content: space-between;
273 align-items: center;
274 }
275
276 .file-header-left {
277 display: flex;
278 align-items: center;
279 gap: 8px;
280 }
281
282 .file-header-right {
283 display: flex;
284 align-items: center;
285 }
286
287 .file-expand-button {
288 background-color: transparent;
289 border: 1px solid var(--border-color, #e0e0e0);
290 border-radius: 4px;
291 padding: 4px 8px;
292 font-size: 14px;
293 cursor: pointer;
294 transition: background-color 0.2s;
295 display: flex;
296 align-items: center;
297 justify-content: center;
298 min-width: 32px;
299 min-height: 32px;
300 }
301
302 .file-expand-button:hover {
303 background-color: var(--background-hover, #e8e8e8);
304 }
305
306 .file-path {
307 font-family: monospace;
308 font-weight: normal;
309 color: var(--text-secondary-color, #666);
310 }
311
312 .file-status {
313 display: inline-block;
314 padding: 2px 6px;
315 border-radius: 3px;
316 font-size: 12px;
317 font-weight: bold;
318 margin-right: 8px;
319 }
320
321 .file-status.added {
322 background-color: #d4edda;
323 color: #155724;
324 }
325
326 .file-status.modified {
327 background-color: #fff3cd;
328 color: #856404;
329 }
330
331 .file-status.deleted {
332 background-color: #f8d7da;
333 color: #721c24;
334 }
335
336 .file-status.renamed {
337 background-color: #d1ecf1;
338 color: #0c5460;
339 }
340
341 .file-changes {
342 margin-left: 8px;
343 font-size: 12px;
344 color: var(--text-secondary-color, #666);
345 }
346
347 .file-diff-editor {
348 display: flex;
349 flex-direction: column;
350 min-height: 200px;
351 /* Height will be set dynamically by monaco editor */
352 overflow: visible; /* Ensure content is not clipped */
353 }
354
355 .file-count {
356 font-size: 14px;
357 color: var(--text-secondary-color, #666);
358 font-weight: 500;
359 padding: 8px 12px;
360 background-color: var(--background-light, #f8f8f8);
361 border-radius: 4px;
362 border: 1px solid var(--border-color, #e0e0e0);
David Crawshawdbca8972025-06-14 23:46:58 +0000363 white-space: nowrap;
364 flex-shrink: 0;
David Crawshawe2954ce2025-06-15 00:06:34 +0000365 min-width: 120px; /* Ensure minimum width for file count */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700366 }
367
Autoformatter8c463622025-05-16 21:54:17 +0000368 .loading,
369 .empty-diff {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700370 display: flex;
371 align-items: center;
372 justify-content: center;
373 height: 100%;
374 font-family: var(--font-family, system-ui, sans-serif);
375 }
Autoformatter8c463622025-05-16 21:54:17 +0000376
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700377 .empty-diff {
378 color: var(--text-secondary-color, #666);
379 font-size: 16px;
380 text-align: center;
381 }
382
383 .error {
384 color: var(--error-color, #dc3545);
385 padding: 16px;
386 font-family: var(--font-family, system-ui, sans-serif);
387 }
388
389 sketch-monaco-view {
390 --editor-width: 100%;
391 --editor-height: 100%;
David Crawshaw26f3f342025-06-14 19:58:32 +0000392 display: flex;
393 flex-direction: column;
394 width: 100%;
395 min-height: 200px;
396 /* Ensure Monaco view takes full container space */
397 flex: 1;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700398 }
399 `;
400
401 @property({ attribute: false, type: Object })
402 gitService!: GitDataService;
Autoformatter8c463622025-05-16 21:54:17 +0000403
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700404 // The gitService must be passed from parent to ensure proper dependency injection
405
406 constructor() {
407 super();
Autoformatter8c463622025-05-16 21:54:17 +0000408 console.log("SketchDiff2View initialized");
409
David Crawshawe2954ce2025-06-15 00:06:34 +0000410 // Fix for monaco-aria-container positioning and hide scrollbars globally
411 // Add a global style to ensure proper positioning of aria containers and hide scrollbars
Autoformatter8c463622025-05-16 21:54:17 +0000412 const styleElement = document.createElement("style");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700413 styleElement.textContent = `
414 .monaco-aria-container {
415 position: absolute !important;
416 top: 0 !important;
417 left: 0 !important;
418 width: 1px !important;
419 height: 1px !important;
420 overflow: hidden !important;
421 clip: rect(1px, 1px, 1px, 1px) !important;
422 white-space: nowrap !important;
423 margin: 0 !important;
424 padding: 0 !important;
425 border: 0 !important;
426 z-index: -1 !important;
427 }
David Crawshawe2954ce2025-06-15 00:06:34 +0000428
429 /* Aggressively hide all Monaco scrollbar elements */
430 .monaco-editor .scrollbar,
431 .monaco-editor .scroll-decoration,
432 .monaco-editor .invisible.scrollbar,
433 .monaco-editor .slider,
434 .monaco-editor .vertical.scrollbar,
435 .monaco-editor .horizontal.scrollbar,
436 .monaco-diff-editor .scrollbar,
437 .monaco-diff-editor .scroll-decoration,
438 .monaco-diff-editor .invisible.scrollbar,
439 .monaco-diff-editor .slider,
440 .monaco-diff-editor .vertical.scrollbar,
441 .monaco-diff-editor .horizontal.scrollbar {
442 display: none !important;
443 visibility: hidden !important;
444 width: 0 !important;
445 height: 0 !important;
446 opacity: 0 !important;
447 }
448
449 /* Target the specific scrollbar classes that Monaco uses */
450 .monaco-scrollable-element > .scrollbar,
451 .monaco-scrollable-element > .scroll-decoration,
452 .monaco-scrollable-element .slider {
453 display: none !important;
454 visibility: hidden !important;
455 width: 0 !important;
456 height: 0 !important;
457 }
458
459 /* Remove scrollbar space/padding from content area */
460 .monaco-editor .monaco-scrollable-element,
461 .monaco-diff-editor .monaco-scrollable-element {
462 padding-right: 0 !important;
463 padding-bottom: 0 !important;
464 margin-right: 0 !important;
465 margin-bottom: 0 !important;
466 }
467
468 /* Ensure the diff content takes full width without scrollbar space */
469 .monaco-diff-editor .editor.modified,
470 .monaco-diff-editor .editor.original {
471 margin-right: 0 !important;
472 padding-right: 0 !important;
473 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700474 `;
475 document.head.appendChild(styleElement);
476 }
477
478 connectedCallback() {
479 super.connectedCallback();
480 // Initialize with default range and load data
481 // Get base commit if not set
Autoformatter8c463622025-05-16 21:54:17 +0000482 if (
483 this.currentRange.type === "range" &&
484 !("from" in this.currentRange && this.currentRange.from)
485 ) {
486 this.gitService
487 .getBaseCommitRef()
488 .then((baseRef) => {
489 this.currentRange = { type: "range", from: baseRef, to: "HEAD" };
490 this.loadDiffData();
491 })
492 .catch((error) => {
493 console.error("Error getting base commit ref:", error);
494 // Use default range
495 this.loadDiffData();
496 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700497 } else {
498 this.loadDiffData();
499 }
500 }
501
David Crawshaw26f3f342025-06-14 19:58:32 +0000502 // Toggle hideUnchangedRegions setting for a specific file
503 private toggleFileExpansion(filePath: string) {
504 const currentState = this.fileExpandStates.get(filePath) ?? false;
505 const newState = !currentState;
506 this.fileExpandStates.set(filePath, newState);
Autoformatter9abf8032025-06-14 23:24:08 +0000507
David Crawshaw26f3f342025-06-14 19:58:32 +0000508 // Apply to the specific Monaco view component for this file
Autoformatter9abf8032025-06-14 23:24:08 +0000509 const monacoView = this.shadowRoot?.querySelector(
510 `sketch-monaco-view[data-file-path="${filePath}"]`,
511 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700512 if (monacoView) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000513 (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700514 }
Autoformatter9abf8032025-06-14 23:24:08 +0000515
David Crawshaw26f3f342025-06-14 19:58:32 +0000516 // Force a re-render to update the button state
517 this.requestUpdate();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700518 }
Autoformatter8c463622025-05-16 21:54:17 +0000519
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700520 render() {
521 return html`
522 <div class="controls">
523 <div class="controls-container">
524 <div class="range-row">
525 <sketch-diff-range-picker
526 .gitService="${this.gitService}"
527 @range-change="${this.handleRangeChange}"
528 ></sketch-diff-range-picker>
David Crawshaw26f3f342025-06-14 19:58:32 +0000529 <div class="file-count">
Autoformatter9abf8032025-06-14 23:24:08 +0000530 ${this.files.length > 0
David Crawshawdbca8972025-06-14 23:46:58 +0000531 ? `${this.files.length} file${this.files.length === 1 ? "" : "s"}`
Autoformatter9abf8032025-06-14 23:24:08 +0000532 : "No files"}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700533 </div>
534 </div>
535 </div>
536 </div>
537
538 <div class="diff-container">
Autoformatter8c463622025-05-16 21:54:17 +0000539 <div class="diff-content">${this.renderDiffContent()}</div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700540 </div>
541 `;
542 }
543
544 renderDiffContent() {
545 if (this.loading) {
546 return html`<div class="loading">Loading diff...</div>`;
547 }
548
549 if (this.error) {
550 return html`<div class="error">${this.error}</div>`;
551 }
552
553 if (this.files.length === 0) {
554 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
555 }
Autoformatter8c463622025-05-16 21:54:17 +0000556
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700557 return html`
David Crawshaw26f3f342025-06-14 19:58:32 +0000558 <div class="multi-file-diff-container">
559 ${this.files.map((file, index) => this.renderFileDiff(file, index))}
560 </div>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700561 `;
562 }
563
564 /**
565 * Load diff data for the current range
566 */
567 async loadDiffData() {
568 this.loading = true;
569 this.error = null;
570
571 try {
572 // Initialize files as empty array if undefined
573 if (!this.files) {
574 this.files = [];
575 }
576
577 // Load diff data based on the current range type
Autoformatter8c463622025-05-16 21:54:17 +0000578 if (this.currentRange.type === "single") {
579 this.files = await this.gitService.getCommitDiff(
580 this.currentRange.commit,
581 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700582 } else {
Autoformatter8c463622025-05-16 21:54:17 +0000583 this.files = await this.gitService.getDiff(
584 this.currentRange.from,
585 this.currentRange.to,
586 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700587 }
588
589 // Ensure files is always an array, even when API returns null
590 if (!this.files) {
591 this.files = [];
592 }
Autoformatter8c463622025-05-16 21:54:17 +0000593
David Crawshaw26f3f342025-06-14 19:58:32 +0000594 // Load content for all files
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700595 if (this.files.length > 0) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000596 // Initialize expand states for new files (default to collapsed)
Autoformatter9abf8032025-06-14 23:24:08 +0000597 this.files.forEach((file) => {
David Crawshaw26f3f342025-06-14 19:58:32 +0000598 if (!this.fileExpandStates.has(file.path)) {
599 this.fileExpandStates.set(file.path, false); // false = collapsed (hide unchanged regions)
600 }
601 });
602 await this.loadAllFileContents();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700603 } else {
604 // No files to display - reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000605 this.selectedFilePath = "";
David Crawshaw26f3f342025-06-14 19:58:32 +0000606 this.fileContents.clear();
607 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700608 }
609 } catch (error) {
Autoformatter8c463622025-05-16 21:54:17 +0000610 console.error("Error loading diff data:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700611 this.error = `Error loading diff data: ${error.message}`;
612 // Ensure files is an empty array when an error occurs
613 this.files = [];
614 // Reset the view to initial state
Autoformatter8c463622025-05-16 21:54:17 +0000615 this.selectedFilePath = "";
David Crawshaw26f3f342025-06-14 19:58:32 +0000616 this.fileContents.clear();
617 this.fileExpandStates.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700618 } finally {
619 this.loading = false;
620 }
621 }
622
623 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000624 * Load content for all files in the diff
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700625 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000626 async loadAllFileContents() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700627 this.loading = true;
628 this.error = null;
David Crawshaw26f3f342025-06-14 19:58:32 +0000629 this.fileContents.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700630
631 try {
632 let fromCommit: string;
633 let toCommit: string;
634 let isUnstagedChanges = false;
Autoformatter8c463622025-05-16 21:54:17 +0000635
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700636 // Determine the commits to compare based on the current range
Autoformatter8c463622025-05-16 21:54:17 +0000637 if (this.currentRange.type === "single") {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700638 fromCommit = `${this.currentRange.commit}^`;
639 toCommit = this.currentRange.commit;
640 } else {
641 fromCommit = this.currentRange.from;
642 toCommit = this.currentRange.to;
643 // Check if this is an unstaged changes view
Autoformatter8c463622025-05-16 21:54:17 +0000644 isUnstagedChanges = toCommit === "";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700645 }
646
David Crawshaw26f3f342025-06-14 19:58:32 +0000647 // Load content for all files
648 const promises = this.files.map(async (file) => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700649 try {
David Crawshaw26f3f342025-06-14 19:58:32 +0000650 let originalCode = "";
651 let modifiedCode = "";
652 let editable = isUnstagedChanges;
Autoformatter8c463622025-05-16 21:54:17 +0000653
David Crawshaw26f3f342025-06-14 19:58:32 +0000654 // Load the original code based on file status
655 if (file.status !== "A") {
656 // For modified, renamed, or deleted files: load original content
657 originalCode = await this.gitService.getFileContent(
658 file.old_hash || "",
659 );
660 }
661
662 // For modified code, always use working copy when editable
663 if (editable) {
664 try {
665 // Always use working copy when editable, regardless of diff status
666 modifiedCode = await this.gitService.getWorkingCopyContent(
667 file.path,
668 );
669 } catch (error) {
670 if (file.status === "D") {
671 // For deleted files, silently use empty content
672 console.warn(
673 `Could not get working copy for deleted file ${file.path}, using empty content`,
674 );
675 modifiedCode = "";
676 } else {
677 // For any other file status, propagate the error
678 console.error(
679 `Failed to get working copy for ${file.path}:`,
680 error,
681 );
682 throw error;
683 }
684 }
685 } else {
686 // For non-editable view, use git content based on file status
687 if (file.status === "D") {
688 // Deleted file: empty modified
689 modifiedCode = "";
690 } else {
691 // Added/modified/renamed: use the content from git
692 modifiedCode = await this.gitService.getFileContent(
693 file.new_hash || "",
694 );
695 }
696 }
697
698 // Don't make deleted files editable
699 if (file.status === "D") {
700 editable = false;
701 }
702
703 this.fileContents.set(file.path, {
704 original: originalCode,
705 modified: modifiedCode,
706 editable,
707 });
708 } catch (error) {
709 console.error(`Error loading content for file ${file.path}:`, error);
710 // Store empty content for failed files to prevent blocking
711 this.fileContents.set(file.path, {
712 original: "",
713 modified: "",
714 editable: false,
715 });
716 }
717 });
718
719 await Promise.all(promises);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700720 } catch (error) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000721 console.error("Error loading file contents:", error);
722 this.error = `Error loading file contents: ${error.message}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700723 } finally {
724 this.loading = false;
725 }
726 }
727
728 /**
729 * Handle range change event from the range picker
730 */
731 handleRangeChange(event: CustomEvent) {
732 const { range } = event.detail;
Autoformatter8c463622025-05-16 21:54:17 +0000733 console.log("Range changed:", range);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700734 this.currentRange = range;
Autoformatter8c463622025-05-16 21:54:17 +0000735
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700736 // Load diff data for the new range
737 this.loadDiffData();
738 }
739
740 /**
David Crawshaw26f3f342025-06-14 19:58:32 +0000741 * Render a single file diff section
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700742 */
David Crawshaw26f3f342025-06-14 19:58:32 +0000743 renderFileDiff(file: GitDiffFile, index: number) {
744 const content = this.fileContents.get(file.path);
745 if (!content) {
746 return html`
747 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000748 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000749 <div class="loading">Loading ${file.path}...</div>
750 </div>
751 `;
752 }
753
754 return html`
755 <div class="file-diff-section">
Autoformatter9abf8032025-06-14 23:24:08 +0000756 <div class="file-header">${this.renderFileHeader(file)}</div>
David Crawshaw26f3f342025-06-14 19:58:32 +0000757 <div class="file-diff-editor">
758 <sketch-monaco-view
759 .originalCode="${content.original}"
760 .modifiedCode="${content.modified}"
761 .originalFilename="${file.path}"
762 .modifiedFilename="${file.path}"
763 ?readOnly="${!content.editable}"
764 ?editable-right="${content.editable}"
765 @monaco-comment="${this.handleMonacoComment}"
766 @monaco-save="${this.handleMonacoSave}"
767 @monaco-height-changed="${this.handleMonacoHeightChange}"
768 data-file-index="${index}"
769 data-file-path="${file.path}"
770 ></sketch-monaco-view>
771 </div>
772 </div>
773 `;
774 }
775
776 /**
777 * Render file header with status and path info
778 */
779 renderFileHeader(file: GitDiffFile) {
780 const statusClass = this.getFileStatusClass(file.status);
781 const statusText = this.getFileStatusText(file.status);
782 const changesInfo = this.getChangesInfo(file);
783 const pathInfo = this.getPathInfo(file);
784
785 const isExpanded = this.fileExpandStates.get(file.path) ?? false;
Autoformatter9abf8032025-06-14 23:24:08 +0000786
David Crawshaw26f3f342025-06-14 19:58:32 +0000787 return html`
788 <div class="file-header-left">
789 <span class="file-status ${statusClass}">${statusText}</span>
790 <span class="file-path">${pathInfo}</span>
Autoformatter9abf8032025-06-14 23:24:08 +0000791 ${changesInfo
792 ? html`<span class="file-changes">${changesInfo}</span>`
793 : ""}
David Crawshaw26f3f342025-06-14 19:58:32 +0000794 </div>
795 <div class="file-header-right">
796 <button
797 class="file-expand-button"
798 @click="${() => this.toggleFileExpansion(file.path)}"
799 title="${isExpanded
800 ? "Collapse: Hide unchanged regions to focus on changes"
801 : "Expand: Show all lines including unchanged regions"}"
802 >
Autoformatter9abf8032025-06-14 23:24:08 +0000803 ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
David Crawshaw26f3f342025-06-14 19:58:32 +0000804 </button>
805 </div>
806 `;
807 }
808
809 /**
810 * Get CSS class for file status
811 */
812 getFileStatusClass(status: string): string {
813 switch (status.toUpperCase()) {
814 case "A":
815 return "added";
816 case "M":
817 return "modified";
818 case "D":
819 return "deleted";
820 case "R":
821 default:
822 if (status.toUpperCase().startsWith("R")) {
823 return "renamed";
824 }
825 return "modified";
826 }
827 }
828
829 /**
830 * Get display text for file status
831 */
832 getFileStatusText(status: string): string {
833 switch (status.toUpperCase()) {
834 case "A":
835 return "Added";
836 case "M":
837 return "Modified";
838 case "D":
839 return "Deleted";
840 case "R":
841 default:
842 if (status.toUpperCase().startsWith("R")) {
843 return "Renamed";
844 }
845 return "Modified";
846 }
847 }
848
849 /**
850 * Get changes information (+/-) for display
851 */
852 getChangesInfo(file: GitDiffFile): string {
853 const additions = file.additions || 0;
854 const deletions = file.deletions || 0;
855
856 if (additions === 0 && deletions === 0) {
857 return "";
858 }
859
860 const parts = [];
861 if (additions > 0) {
862 parts.push(`+${additions}`);
863 }
864 if (deletions > 0) {
865 parts.push(`-${deletions}`);
866 }
867
868 return `(${parts.join(", ")})`;
869 }
870
871 /**
872 * Get path information for display, handling renames
873 */
874 getPathInfo(file: GitDiffFile): string {
875 if (file.old_path && file.old_path !== "") {
876 // For renames, show old_path → new_path
877 return `${file.old_path} → ${file.path}`;
878 }
879 // For regular files, just show the path
880 return file.path;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700881 }
882
883 /**
Philip Zeyligere89b3082025-05-29 03:16:06 +0000884 * Render expand all icon (dotted line with arrows pointing away)
885 */
886 renderExpandAllIcon() {
887 return html`
888 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
889 <!-- Dotted line in the middle -->
890 <line
891 x1="2"
892 y1="8"
893 x2="14"
894 y2="8"
895 stroke="currentColor"
896 stroke-width="1"
897 stroke-dasharray="2,1"
898 />
899 <!-- Large arrow pointing up -->
900 <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
901 <!-- Large arrow pointing down -->
902 <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
903 </svg>
904 `;
905 }
906
907 /**
908 * Render collapse icon (arrows pointing towards dotted line)
909 */
910 renderCollapseIcon() {
911 return html`
912 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
913 <!-- Dotted line in the middle -->
914 <line
915 x1="2"
916 y1="8"
917 x2="14"
918 y2="8"
919 stroke="currentColor"
920 stroke-width="1"
921 stroke-dasharray="2,1"
922 />
923 <!-- Large arrow pointing down towards line -->
924 <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
925 <!-- Large arrow pointing up towards line -->
926 <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
927 </svg>
928 `;
929 }
930
931 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700932 * Refresh the diff view by reloading commits and diff data
Autoformatter8c463622025-05-16 21:54:17 +0000933 *
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700934 * This is called when the Monaco diff tab is activated to ensure:
935 * 1. Branch information from git/recentlog is current (branches can change frequently)
936 * 2. The diff content is synchronized with the latest repository state
937 * 3. Users always see up-to-date information without manual refresh
938 */
939 refreshDiffView() {
940 // First refresh the range picker to get updated branch information
Autoformatter8c463622025-05-16 21:54:17 +0000941 const rangePicker = this.shadowRoot?.querySelector(
942 "sketch-diff-range-picker",
943 );
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700944 if (rangePicker) {
945 (rangePicker as any).loadCommits();
946 }
Autoformatter8c463622025-05-16 21:54:17 +0000947
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700948 if (this.commit) {
Autoformatter8c463622025-05-16 21:54:17 +0000949 this.currentRange = { type: "single", commit: this.commit };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700950 }
Autoformatter8c463622025-05-16 21:54:17 +0000951
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700952 // Then reload diff data based on the current range
953 this.loadDiffData();
954 }
955}
956
957declare global {
958 interface HTMLElementTagNameMap {
959 "sketch-diff2-view": SketchDiff2View;
960 }
961}