blob: 5ac7459f533437c1d815a687d804a6c80e7bc504 [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";
5import "./sketch-diff-file-picker";
6import "./sketch-diff-empty-view";
7import { GitDiffFile, GitDataService, DefaultGitDataService } from "./git-data-service";
8import { DiffRange } from "./sketch-diff-range-picker";
9
10/**
11 * A component that displays diffs using Monaco editor with range and file pickers
12 */
13@customElement("sketch-diff2-view")
14export class SketchDiff2View extends LitElement {
15 /**
16 * Handles comment events from the Monaco editor and forwards them to the chat input
17 * using the same event format as the original diff view for consistency.
18 */
19 private handleMonacoComment(event: CustomEvent) {
20 try {
21 // Validate incoming data
22 if (!event.detail || !event.detail.formattedComment) {
23 console.error('Invalid comment data received');
24 return;
25 }
26
27 // Create and dispatch event using the standardized format
28 const commentEvent = new CustomEvent('diff-comment', {
29 detail: { comment: event.detail.formattedComment },
30 bubbles: true,
31 composed: true
32 });
33
34 this.dispatchEvent(commentEvent);
35 } catch (error) {
36 console.error('Error handling Monaco comment:', error);
37 }
38 }
39
40 /**
41 * Handle save events from the Monaco editor
42 */
43 private async handleMonacoSave(event: CustomEvent) {
44 try {
45 // Validate incoming data
46 if (!event.detail || !event.detail.path || event.detail.content === undefined) {
47 console.error('Invalid save data received');
48 return;
49 }
50
51 const { path, content } = event.detail;
52
53 // Get Monaco view component
54 const monacoView = this.shadowRoot?.querySelector('sketch-monaco-view');
55 if (!monacoView) {
56 console.error('Monaco view not found');
57 return;
58 }
59
60 try {
61 await this.gitService?.saveFileContent(path, content);
62 console.log(`File saved: ${path}`);
63 (monacoView as any).notifySaveComplete(true);
64 } catch (error) {
65 console.error(`Error saving file: ${error instanceof Error ? error.message : String(error)}`);
66 (monacoView as any).notifySaveComplete(false);
67 }
68 } catch (error) {
69 console.error('Error handling save:', error);
70 }
71 }
72 @property({ type: String })
73 initialCommit: string = "";
74
75 // The commit to show - used when showing a specific commit from timeline
76 @property({ type: String })
77 commit: string = "";
78
79 @property({ type: String })
80 selectedFilePath: string = "";
81
82 @state()
83 private files: GitDiffFile[] = [];
84
85 @state()
86 private currentRange: DiffRange = { type: 'range', from: '', to: 'HEAD' };
87
88 @state()
89 private originalCode: string = "";
90
91 @state()
92 private modifiedCode: string = "";
93
94 @state()
95 private isRightEditable: boolean = false;
96
97 @state()
98 private loading: boolean = false;
99
100 @state()
101 private error: string | null = null;
102
103 static styles = css`
104 :host {
105 display: flex;
106 height: 100%;
107 flex: 1;
108 flex-direction: column;
109 min-height: 0; /* Critical for flex child behavior */
110 overflow: hidden;
111 position: relative; /* Establish positioning context */
112 }
113
114 .controls {
115 padding: 8px 16px;
116 border-bottom: 1px solid var(--border-color, #e0e0e0);
117 background-color: var(--background-light, #f8f8f8);
118 flex-shrink: 0; /* Prevent controls from shrinking */
119 }
120
121 .controls-container {
122 display: flex;
123 flex-direction: column;
124 gap: 12px;
125 }
126
127 .range-row {
128 width: 100%;
129 display: flex;
130 }
131
132 .file-row {
133 width: 100%;
134 display: flex;
135 justify-content: space-between;
136 align-items: center;
137 gap: 10px;
138 }
139
140 sketch-diff-range-picker {
141 width: 100%;
142 }
143
144 sketch-diff-file-picker {
145 flex: 1;
146 }
147
148 .view-toggle-button {
149 background-color: #f0f0f0;
150 border: 1px solid #ccc;
151 border-radius: 4px;
152 padding: 6px 12px;
153 font-size: 12px;
154 cursor: pointer;
155 white-space: nowrap;
156 transition: background-color 0.2s;
157 }
158
159 .view-toggle-button:hover {
160 background-color: #e0e0e0;
161 }
162
163 .diff-container {
164 flex: 1;
165 overflow: hidden;
166 display: flex;
167 flex-direction: column;
168 min-height: 0; /* Critical for flex child to respect parent height */
169 position: relative; /* Establish positioning context */
170 height: 100%; /* Take full height */
171 }
172
173 .diff-content {
174 flex: 1;
175 overflow: hidden;
176 min-height: 0; /* Required for proper flex behavior */
177 display: flex; /* Required for child to take full height */
178 position: relative; /* Establish positioning context */
179 height: 100%; /* Take full height */
180 }
181
182 .loading, .empty-diff {
183 display: flex;
184 align-items: center;
185 justify-content: center;
186 height: 100%;
187 font-family: var(--font-family, system-ui, sans-serif);
188 }
189
190 .empty-diff {
191 color: var(--text-secondary-color, #666);
192 font-size: 16px;
193 text-align: center;
194 }
195
196 .error {
197 color: var(--error-color, #dc3545);
198 padding: 16px;
199 font-family: var(--font-family, system-ui, sans-serif);
200 }
201
202 sketch-monaco-view {
203 --editor-width: 100%;
204 --editor-height: 100%;
205 flex: 1; /* Make Monaco view take full height */
206 display: flex; /* Required for child to take full height */
207 position: absolute; /* Absolute positioning to take full space */
208 top: 0;
209 left: 0;
210 right: 0;
211 bottom: 0;
212 height: 100%; /* Take full height */
213 width: 100%; /* Take full width */
214 }
215 `;
216
217 @property({ attribute: false, type: Object })
218 gitService!: GitDataService;
219
220 // The gitService must be passed from parent to ensure proper dependency injection
221
222 constructor() {
223 super();
224 console.log('SketchDiff2View initialized');
225
226 // Fix for monaco-aria-container positioning
227 // Add a global style to ensure proper positioning of aria containers
228 const styleElement = document.createElement('style');
229 styleElement.textContent = `
230 .monaco-aria-container {
231 position: absolute !important;
232 top: 0 !important;
233 left: 0 !important;
234 width: 1px !important;
235 height: 1px !important;
236 overflow: hidden !important;
237 clip: rect(1px, 1px, 1px, 1px) !important;
238 white-space: nowrap !important;
239 margin: 0 !important;
240 padding: 0 !important;
241 border: 0 !important;
242 z-index: -1 !important;
243 }
244 `;
245 document.head.appendChild(styleElement);
246 }
247
248 connectedCallback() {
249 super.connectedCallback();
250 // Initialize with default range and load data
251 // Get base commit if not set
252 if (this.currentRange.type === 'range' && !('from' in this.currentRange && this.currentRange.from)) {
253 this.gitService.getBaseCommitRef().then(baseRef => {
254 this.currentRange = { type: 'range', from: baseRef, to: 'HEAD' };
255 this.loadDiffData();
256 }).catch(error => {
257 console.error('Error getting base commit ref:', error);
258 // Use default range
259 this.loadDiffData();
260 });
261 } else {
262 this.loadDiffData();
263 }
264 }
265
266 // Toggle hideUnchangedRegions setting
267 @state()
268 private hideUnchangedRegionsEnabled: boolean = true;
269
270 // Toggle hideUnchangedRegions setting
271 private toggleHideUnchangedRegions() {
272 this.hideUnchangedRegionsEnabled = !this.hideUnchangedRegionsEnabled;
273
274 // Get the Monaco view component
275 const monacoView = this.shadowRoot?.querySelector('sketch-monaco-view');
276 if (monacoView) {
277 (monacoView as any).toggleHideUnchangedRegions(this.hideUnchangedRegionsEnabled);
278 }
279 }
280
281 render() {
282 return html`
283 <div class="controls">
284 <div class="controls-container">
285 <div class="range-row">
286 <sketch-diff-range-picker
287 .gitService="${this.gitService}"
288 @range-change="${this.handleRangeChange}"
289 ></sketch-diff-range-picker>
290 </div>
291
292 <div class="file-row">
293 <sketch-diff-file-picker
294 .files="${this.files}"
295 .selectedPath="${this.selectedFilePath}"
296 @file-selected="${this.handleFileSelected}"
297 ></sketch-diff-file-picker>
298
299 <div style="display: flex; gap: 8px;">
300 ${this.isRightEditable ? html`
301 <div class="editable-indicator" title="This file is editable">
302 <span style="padding: 6px 12px; background-color: #e9ecef; border-radius: 4px; font-size: 12px; color: #495057;">
303 Editable
304 </span>
305 </div>
306 ` : ''}
307 <button
308 class="view-toggle-button"
309 @click="${this.toggleHideUnchangedRegions}"
310 title="${this.hideUnchangedRegionsEnabled ? 'Expand All' : 'Hide Unchanged'}"
311 >
312 ${this.hideUnchangedRegionsEnabled ? 'Expand All' : 'Hide Unchanged'}
313 </button>
314 </div>
315 </div>
316 </div>
317 </div>
318
319 <div class="diff-container">
320 <div class="diff-content">
321 ${this.renderDiffContent()}
322 </div>
323 </div>
324 `;
325 }
326
327 renderDiffContent() {
328 if (this.loading) {
329 return html`<div class="loading">Loading diff...</div>`;
330 }
331
332 if (this.error) {
333 return html`<div class="error">${this.error}</div>`;
334 }
335
336 if (this.files.length === 0) {
337 return html`<sketch-diff-empty-view></sketch-diff-empty-view>`;
338 }
339
340 if (!this.selectedFilePath) {
341 return html`<div class="loading">Select a file to view diff</div>`;
342 }
343
344 return html`
345 <sketch-monaco-view
346 .originalCode="${this.originalCode}"
347 .modifiedCode="${this.modifiedCode}"
348 .originalFilename="${this.selectedFilePath}"
349 .modifiedFilename="${this.selectedFilePath}"
350 ?readOnly="${!this.isRightEditable}"
351 ?editable-right="${this.isRightEditable}"
352 @monaco-comment="${this.handleMonacoComment}"
353 @monaco-save="${this.handleMonacoSave}"
354 ></sketch-monaco-view>
355 `;
356 }
357
358 /**
359 * Load diff data for the current range
360 */
361 async loadDiffData() {
362 this.loading = true;
363 this.error = null;
364
365 try {
366 // Initialize files as empty array if undefined
367 if (!this.files) {
368 this.files = [];
369 }
370
371 // Load diff data based on the current range type
372 if (this.currentRange.type === 'single') {
373 this.files = await this.gitService.getCommitDiff(this.currentRange.commit);
374 } else {
375 this.files = await this.gitService.getDiff(this.currentRange.from, this.currentRange.to);
376 }
377
378 // Ensure files is always an array, even when API returns null
379 if (!this.files) {
380 this.files = [];
381 }
382
383 // If we have files, select the first one and load its content
384 if (this.files.length > 0) {
385 const firstFile = this.files[0];
386 this.selectedFilePath = firstFile.path;
387
388 // Directly load the file content, especially important when there's only one file
389 // as sometimes the file-selected event might not fire in that case
390 this.loadFileContent(firstFile);
391 } else {
392 // No files to display - reset the view to initial state
393 this.selectedFilePath = '';
394 this.originalCode = '';
395 this.modifiedCode = '';
396 }
397 } catch (error) {
398 console.error('Error loading diff data:', error);
399 this.error = `Error loading diff data: ${error.message}`;
400 // Ensure files is an empty array when an error occurs
401 this.files = [];
402 // Reset the view to initial state
403 this.selectedFilePath = '';
404 this.originalCode = '';
405 this.modifiedCode = '';
406 } finally {
407 this.loading = false;
408 }
409 }
410
411 /**
412 * Load the content of the selected file
413 */
414 async loadFileContent(file: GitDiffFile) {
415 this.loading = true;
416 this.error = null;
417
418 try {
419 let fromCommit: string;
420 let toCommit: string;
421 let isUnstagedChanges = false;
422
423 // Determine the commits to compare based on the current range
424 if (this.currentRange.type === 'single') {
425 fromCommit = `${this.currentRange.commit}^`;
426 toCommit = this.currentRange.commit;
427 } else {
428 fromCommit = this.currentRange.from;
429 toCommit = this.currentRange.to;
430 // Check if this is an unstaged changes view
431 isUnstagedChanges = toCommit === '';
432 }
433
434 // Set editability based on whether we're showing uncommitted changes
435 this.isRightEditable = isUnstagedChanges;
436
437 // Load the original code based on file status
438 if (file.status === 'A') {
439 // Added file: empty original
440 this.originalCode = '';
441 } else {
442 // For modified, renamed, or deleted files: load original content
443 this.originalCode = await this.gitService.getFileContent(file.old_hash || '');
444 }
445
446 // For modified code, always use working copy when editable
447 if (this.isRightEditable) {
448 try {
449 // Always use working copy when editable, regardless of diff status
450 // This ensures we have the latest content even if the diff hasn't been refreshed
451 this.modifiedCode = await this.gitService.getWorkingCopyContent(file.path);
452 } catch (error) {
453 if (file.status === 'D') {
454 // For deleted files, silently use empty content
455 console.warn(`Could not get working copy for deleted file ${file.path}, using empty content`);
456 this.modifiedCode = '';
457 } else {
458 // For any other file status, propagate the error
459 console.error(`Failed to get working copy for ${file.path}:`, error);
460 throw error; // Rethrow to be caught by the outer try/catch
461 }
462 }
463 } else {
464 // For non-editable view, use git content based on file status
465 if (file.status === 'D') {
466 // Deleted file: empty modified
467 this.modifiedCode = '';
468 } else {
469 // Added/modified/renamed: use the content from git
470 this.modifiedCode = await this.gitService.getFileContent(file.new_hash || '');
471 }
472 }
473
474 // Don't make deleted files editable
475 if (file.status === 'D') {
476 this.isRightEditable = false;
477 }
478 } catch (error) {
479 console.error('Error loading file content:', error);
480 this.error = `Error loading file content: ${error.message}`;
481 this.isRightEditable = false;
482 } finally {
483 this.loading = false;
484 }
485 }
486
487 /**
488 * Handle range change event from the range picker
489 */
490 handleRangeChange(event: CustomEvent) {
491 const { range } = event.detail;
492 console.log('Range changed:', range);
493 this.currentRange = range;
494
495 // Load diff data for the new range
496 this.loadDiffData();
497 }
498
499 /**
500 * Handle file selection event from the file picker
501 */
502 handleFileSelected(event: CustomEvent) {
503 const file = event.detail.file as GitDiffFile;
504 this.selectedFilePath = file.path;
505 this.loadFileContent(file);
506 }
507
508 /**
509 * Refresh the diff view by reloading commits and diff data
510 *
511 * This is called when the Monaco diff tab is activated to ensure:
512 * 1. Branch information from git/recentlog is current (branches can change frequently)
513 * 2. The diff content is synchronized with the latest repository state
514 * 3. Users always see up-to-date information without manual refresh
515 */
516 refreshDiffView() {
517 // First refresh the range picker to get updated branch information
518 const rangePicker = this.shadowRoot?.querySelector('sketch-diff-range-picker');
519 if (rangePicker) {
520 (rangePicker as any).loadCommits();
521 }
522
523 if (this.commit) {
524 this.currentRange = { type: 'single', commit: this.commit };
525 }
526
527 // Then reload diff data based on the current range
528 this.loadDiffData();
529 }
530}
531
532declare global {
533 interface HTMLElementTagNameMap {
534 "sketch-diff2-view": SketchDiff2View;
535 }
536}