blob: 917b816f9544dd4e32db97aa0b3f401cb0dd213b [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 { createRef, Ref, ref } from "lit/directives/ref.js";
4
5// See https://rodydavis.com/posts/lit-monaco-editor for some ideas.
6
philip.zeyligerc0a44592025-06-15 21:24:57 -07007import type * as monaco from "monaco-editor";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07008
philip.zeyligerc0a44592025-06-15 21:24:57 -07009// Monaco is loaded dynamically - see loadMonaco() function
10declare global {
11 interface Window {
12 monaco?: typeof monaco;
13 }
14}
15
16// Monaco hash will be injected at build time
17declare const __MONACO_HASH__: string;
18
19// Load Monaco editor dynamically
20let monacoLoadPromise: Promise<any> | null = null;
21
22function loadMonaco(): Promise<typeof monaco> {
23 if (monacoLoadPromise) {
24 return monacoLoadPromise;
25 }
26
27 if (window.monaco) {
28 return Promise.resolve(window.monaco);
29 }
30
Philip Zeyliger3cde2822025-06-21 09:32:38 -070031 monacoLoadPromise = new Promise(async (resolve, reject) => {
32 try {
33 // Check if we're in development mode
34 const isDev = __MONACO_HASH__ === "dev";
Autoformatter2f8464c2025-06-16 04:27:05 +000035
Philip Zeyliger3cde2822025-06-21 09:32:38 -070036 if (isDev) {
37 // In development mode, import Monaco directly
38 const monaco = await import("monaco-editor");
39 window.monaco = monaco;
40 resolve(monaco);
philip.zeyligerc0a44592025-06-15 21:24:57 -070041 } else {
Philip Zeyliger3cde2822025-06-21 09:32:38 -070042 // In production mode, load from external bundle
43 const monacoHash = __MONACO_HASH__;
Autoformatter2f8464c2025-06-16 04:27:05 +000044
Philip Zeyliger3cde2822025-06-21 09:32:38 -070045 // Try to load the external Monaco bundle
46 const script = document.createElement("script");
47 script.onload = () => {
48 // The Monaco bundle should set window.monaco
49 if (window.monaco) {
50 resolve(window.monaco);
51 } else {
52 reject(new Error("Monaco not loaded from external bundle"));
53 }
54 };
55 script.onerror = (error) => {
56 console.warn("Failed to load external Monaco bundle:", error);
57 reject(new Error("Monaco external bundle failed to load"));
58 };
59
60 // Don't set type="module" since we're using IIFE format
61 script.src = `./static/monaco-standalone-${monacoHash}.js`;
62 document.head.appendChild(script);
63 }
64 } catch (error) {
65 reject(error);
66 }
philip.zeyligerc0a44592025-06-15 21:24:57 -070067 });
68
69 return monacoLoadPromise;
70}
Philip Zeyliger272a90e2025-05-16 14:49:51 -070071
72// Define Monaco CSS styles as a string constant
73const monacoStyles = `
74 /* Import Monaco editor styles */
75 @import url('./static/monaco/min/vs/editor/editor.main.css');
76
77 /* Codicon font is now defined globally in sketch-app-shell.css */
78
79 /* Custom Monaco styles */
80 .monaco-editor {
81 width: 100%;
82 height: 100%;
83 }
84
Philip Zeyliger272a90e2025-05-16 14:49:51 -070085 /* Ensure light theme colors */
86 .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input {
87 background-color: var(--monaco-editor-bg, #ffffff) !important;
88 }
89
90 .monaco-editor .margin {
91 background-color: var(--monaco-editor-margin, #f5f5f5) !important;
92 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -070093
94 /* Glyph decoration styles - only show on hover */
95 .comment-glyph-decoration {
96 width: 16px !important;
97 height: 18px !important;
98 cursor: pointer;
99 opacity: 0;
100 transition: opacity 0.2s ease;
101 }
102
103 .comment-glyph-decoration:before {
104 content: '💬';
105 font-size: 12px;
106 line-height: 18px;
107 width: 16px;
108 height: 18px;
109 display: block;
110 text-align: center;
111 }
112
113 .comment-glyph-decoration.hover-visible {
114 opacity: 1;
115 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700116`;
117
118// Configure Monaco to use local workers with correct relative paths
119// Monaco looks for this global configuration to determine how to load web workers
120// @ts-ignore - MonacoEnvironment is added to the global scope at runtime
121self.MonacoEnvironment = {
122 getWorkerUrl: function (_moduleId, label) {
123 if (label === "json") {
124 return "./static/json.worker.js";
125 }
126 if (label === "css" || label === "scss" || label === "less") {
127 return "./static/css.worker.js";
128 }
129 if (label === "html" || label === "handlebars" || label === "razor") {
130 return "./static/html.worker.js";
131 }
132 if (label === "typescript" || label === "javascript") {
133 return "./static/ts.worker.js";
134 }
135 return "./static/editor.worker.js";
136 },
137};
138
139@customElement("sketch-monaco-view")
140export class CodeDiffEditor extends LitElement {
141 // Editable state
142 @property({ type: Boolean, attribute: "editable-right" })
143 editableRight?: boolean;
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000144
145 // Inline diff mode (for mobile)
146 @property({ type: Boolean, attribute: "inline" })
147 inline?: boolean;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700148 private container: Ref<HTMLElement> = createRef();
149 editor?: monaco.editor.IStandaloneDiffEditor;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700150
151 // Save state properties
152 @state() private saveState: "idle" | "modified" | "saving" | "saved" = "idle";
153 @state() private debounceSaveTimeout: number | null = null;
154 @state() private lastSavedContent: string = "";
155 @property() originalCode?: string = "// Original code here";
156 @property() modifiedCode?: string = "// Modified code here";
157 @property() originalFilename?: string = "original.js";
158 @property() modifiedFilename?: string = "modified.js";
159
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700160 // Comment system state
161 @state() private showCommentBox: boolean = false;
162 @state() private commentText: string = "";
163 @state() private selectedLines: {
164 startLine: number;
165 endLine: number;
166 editorType: "original" | "modified";
167 text: string;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700168 } | null = null;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700169 @state() private commentBoxPosition: { top: number; left: number } = {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700170 top: 0,
171 left: 0,
172 };
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700173 @state() private isDragging: boolean = false;
174 @state() private dragStartLine: number | null = null;
175 @state() private dragStartEditor: "original" | "modified" | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700176
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700177 // Track visible glyphs to ensure proper cleanup
178 private visibleGlyphs: Set<string> = new Set();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700179
180 // Custom event to request save action from external components
181 private requestSave() {
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000182 if (!this.editableRight || this.saveState !== "modified") return;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700183
184 this.saveState = "saving";
185
186 // Get current content from modified editor
187 const modifiedContent = this.modifiedModel?.getValue() || "";
188
189 // Create and dispatch the save event
190 const saveEvent = new CustomEvent("monaco-save", {
191 detail: {
192 path: this.modifiedFilename,
193 content: modifiedContent,
194 },
195 bubbles: true,
196 composed: true,
197 });
198
199 this.dispatchEvent(saveEvent);
200 }
201
202 // Method to be called from parent when save is complete
203 public notifySaveComplete(success: boolean) {
204 if (success) {
205 this.saveState = "saved";
206 // Update last saved content
207 this.lastSavedContent = this.modifiedModel?.getValue() || "";
208 // Reset to idle after a delay
209 setTimeout(() => {
210 this.saveState = "idle";
211 }, 2000);
212 } else {
213 // Return to modified state on error
214 this.saveState = "modified";
215 }
216 }
217
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000218 // Rescue people with strong save-constantly habits
219 private setupKeyboardShortcuts() {
220 if (!this.editor) return;
221 const modifiedEditor = this.editor.getModifiedEditor();
222 if (!modifiedEditor) return;
223
philip.zeyligerc0a44592025-06-15 21:24:57 -0700224 const monaco = window.monaco;
225 if (!monaco) return;
Autoformatter2f8464c2025-06-16 04:27:05 +0000226
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000227 modifiedEditor.addCommand(
228 monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
229 () => {
230 this.requestSave();
Autoformatter57893c22025-05-29 13:49:53 +0000231 },
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000232 );
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000233 }
234
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700235 // Setup content change listener for debounced save
236 private setupContentChangeListener() {
237 if (!this.editor || !this.editableRight) return;
238
239 const modifiedEditor = this.editor.getModifiedEditor();
240 if (!modifiedEditor || !modifiedEditor.getModel()) return;
241
242 // Store initial content
243 this.lastSavedContent = modifiedEditor.getModel()!.getValue();
244
245 // Listen for content changes
246 modifiedEditor.getModel()!.onDidChangeContent(() => {
247 const currentContent = modifiedEditor.getModel()!.getValue();
248
249 // Check if content has actually changed from last saved state
250 if (currentContent !== this.lastSavedContent) {
251 this.saveState = "modified";
252
253 // Debounce save request
254 if (this.debounceSaveTimeout) {
255 window.clearTimeout(this.debounceSaveTimeout);
256 }
257
258 this.debounceSaveTimeout = window.setTimeout(() => {
259 this.requestSave();
260 this.debounceSaveTimeout = null;
261 }, 1000); // 1 second debounce
262 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700263
264 // Update glyph decorations when content changes
265 setTimeout(() => {
266 if (this.editor && this.modifiedModel) {
267 this.addGlyphDecorationsToEditor(
268 this.editor.getModifiedEditor(),
269 this.modifiedModel,
270 "modified",
271 );
272 }
273 }, 50);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700274 });
275 }
276
277 static styles = css`
278 /* Save indicator styles */
279 .save-indicator {
280 position: absolute;
281 top: 4px;
282 right: 4px;
283 padding: 3px 8px;
284 border-radius: 3px;
285 font-size: 12px;
286 font-family: system-ui, sans-serif;
287 color: white;
288 z-index: 100;
289 opacity: 0.9;
290 pointer-events: none;
291 transition: opacity 0.3s ease;
292 }
293
Philip Zeyligere89b3082025-05-29 03:16:06 +0000294 .save-indicator.idle {
295 background-color: #6c757d;
296 }
297
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700298 .save-indicator.modified {
299 background-color: #f0ad4e;
300 }
301
302 .save-indicator.saving {
303 background-color: #5bc0de;
304 }
305
306 .save-indicator.saved {
307 background-color: #5cb85c;
308 }
309
310 /* Editor host styles */
311 :host {
312 --editor-width: 100%;
313 --editor-height: 100%;
314 display: flex;
David Crawshaw26f3f342025-06-14 19:58:32 +0000315 flex: none; /* Don't grow/shrink - size is determined by content */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700316 min-height: 0; /* Critical for flex layout */
317 position: relative; /* Establish positioning context */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700318 width: 100%; /* Take full width */
David Crawshaw26f3f342025-06-14 19:58:32 +0000319 /* Height will be set dynamically by setupAutoSizing */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700320 }
321 main {
David Crawshaw26f3f342025-06-14 19:58:32 +0000322 width: 100%;
323 height: 100%; /* Fill the host element completely */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700324 border: 1px solid #e0e0e0;
David Crawshaw26f3f342025-06-14 19:58:32 +0000325 flex: none; /* Size determined by parent */
326 min-height: 200px; /* Ensure a minimum height for the editor */
327 /* Remove absolute positioning - use normal block layout */
328 position: relative;
329 display: block;
David Crawshawdba26b52025-06-15 00:33:45 +0000330 box-sizing: border-box; /* Include border in width calculation */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700331 }
332
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700333 /* Comment box styles */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700334 .comment-box {
335 position: fixed;
336 background-color: white;
337 border: 1px solid #ddd;
338 border-radius: 4px;
339 box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
340 padding: 12px;
341 z-index: 10001;
342 width: 350px;
343 animation: fadeIn 0.2s ease-in-out;
344 max-height: 80vh;
345 overflow-y: auto;
346 }
347
348 .comment-box-header {
349 display: flex;
350 justify-content: space-between;
351 align-items: center;
352 margin-bottom: 8px;
353 }
354
355 .comment-box-header h3 {
356 margin: 0;
357 font-size: 14px;
358 font-weight: 500;
359 }
360
361 .close-button {
362 background: none;
363 border: none;
364 cursor: pointer;
365 font-size: 16px;
366 color: #666;
367 padding: 2px 6px;
368 }
369
370 .close-button:hover {
371 color: #333;
372 }
373
374 .selected-text-preview {
375 background-color: #f5f5f5;
376 border: 1px solid #eee;
377 border-radius: 3px;
378 padding: 8px;
379 margin-bottom: 10px;
380 font-family: monospace;
381 font-size: 12px;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700382 max-height: 100px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700383 overflow-y: auto;
384 white-space: pre-wrap;
385 word-break: break-all;
386 }
387
388 .comment-textarea {
389 width: 100%;
390 min-height: 80px;
391 padding: 8px;
392 border: 1px solid #ddd;
393 border-radius: 3px;
394 resize: vertical;
395 font-family: inherit;
396 margin-bottom: 10px;
397 box-sizing: border-box;
398 }
399
400 .comment-actions {
401 display: flex;
402 justify-content: flex-end;
403 gap: 8px;
404 }
405
406 .comment-actions button {
407 padding: 6px 12px;
408 border-radius: 3px;
409 cursor: pointer;
410 font-size: 12px;
411 }
412
413 .cancel-button {
414 background-color: transparent;
415 border: 1px solid #ddd;
416 }
417
418 .cancel-button:hover {
419 background-color: #f5f5f5;
420 }
421
422 .submit-button {
423 background-color: #4285f4;
424 color: white;
425 border: none;
426 }
427
428 .submit-button:hover {
429 background-color: #3367d6;
430 }
431
432 @keyframes fadeIn {
433 from {
434 opacity: 0;
435 }
436 to {
437 opacity: 1;
438 }
439 }
440 `;
441
442 render() {
443 return html`
444 <style>
445 ${monacoStyles}
446 </style>
447 <main ${ref(this.container)}></main>
448
449 <!-- Save indicator - shown when editing -->
Philip Zeyligere89b3082025-05-29 03:16:06 +0000450 ${this.editableRight
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700451 ? html`
452 <div class="save-indicator ${this.saveState}">
Philip Zeyligere89b3082025-05-29 03:16:06 +0000453 ${this.saveState === "idle"
454 ? "Editable"
455 : this.saveState === "modified"
456 ? "Modified..."
457 : this.saveState === "saving"
458 ? "Saving..."
459 : this.saveState === "saved"
460 ? "Saved"
461 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700462 </div>
463 `
464 : ""}
465
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700466 <!-- Comment box - shown when glyph is clicked -->
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700467 ${this.showCommentBox
468 ? html`
469 <div
470 class="comment-box"
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700471 style="top: ${this.commentBoxPosition.top}px; left: ${this
472 .commentBoxPosition.left}px;"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700473 >
474 <div class="comment-box-header">
475 <h3>Add comment</h3>
476 <button class="close-button" @click="${this.closeCommentBox}">
477 ×
478 </button>
479 </div>
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700480 ${this.selectedLines
481 ? html`
482 <div class="selected-text-preview">
483 ${this.selectedLines.text}
484 </div>
485 `
486 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700487 <textarea
488 class="comment-textarea"
489 placeholder="Type your comment here..."
490 .value="${this.commentText}"
491 @input="${this.handleCommentInput}"
492 ></textarea>
493 <div class="comment-actions">
494 <button class="cancel-button" @click="${this.closeCommentBox}">
495 Cancel
496 </button>
497 <button class="submit-button" @click="${this.submitComment}">
Josh Bleecher Snyderafeafea2025-05-23 20:27:39 +0000498 Add
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700499 </button>
500 </div>
501 </div>
502 `
503 : ""}
504 `;
505 }
506
507 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700508 * Handle changes to the comment text
509 */
510 private handleCommentInput(e: Event) {
511 const target = e.target as HTMLTextAreaElement;
512 this.commentText = target.value;
513 }
514
515 /**
516 * Close the comment box
517 */
518 private closeCommentBox() {
519 this.showCommentBox = false;
520 this.commentText = "";
521 this.selectedLines = null;
522 }
523
524 /**
525 * Submit the comment
526 */
527 private submitComment() {
528 try {
529 if (!this.selectedLines || !this.commentText.trim()) {
530 return;
531 }
532
533 // Store references before closing the comment box
534 const selectedLines = this.selectedLines;
535 const commentText = this.commentText;
536
537 // Get the correct filename based on active editor
538 const fileContext =
539 selectedLines.editorType === "original"
540 ? this.originalFilename || "Original file"
541 : this.modifiedFilename || "Modified file";
542
543 // Include editor info to make it clear which version was commented on
544 const editorLabel =
545 selectedLines.editorType === "original" ? "[Original]" : "[Modified]";
546
547 // Add line number information
548 let lineInfo = "";
549 if (selectedLines.startLine === selectedLines.endLine) {
550 lineInfo = ` (line ${selectedLines.startLine})`;
551 } else {
552 lineInfo = ` (lines ${selectedLines.startLine}-${selectedLines.endLine})`;
553 }
554
555 // Format the comment in a readable way
556 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${selectedLines.text}\n\`\`\`\n\n${commentText}`;
557
558 // Close UI before dispatching to prevent interaction conflicts
559 this.closeCommentBox();
560
561 // Use setTimeout to ensure the UI has updated before sending the event
562 setTimeout(() => {
563 try {
564 // Dispatch a custom event with the comment details
565 const event = new CustomEvent("monaco-comment", {
566 detail: {
567 fileContext,
568 selectedText: selectedLines.text,
569 commentText: commentText,
570 formattedComment,
571 selectionRange: {
572 startLineNumber: selectedLines.startLine,
573 startColumn: 1,
574 endLineNumber: selectedLines.endLine,
575 endColumn: 1,
576 },
577 activeEditor: selectedLines.editorType,
578 },
579 bubbles: true,
580 composed: true,
581 });
582
583 this.dispatchEvent(event);
584 } catch (error) {
585 console.error("Error dispatching comment event:", error);
586 }
587 }, 0);
588 } catch (error) {
589 console.error("Error submitting comment:", error);
590 this.closeCommentBox();
591 }
592 }
593
594 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700595 * Calculate the optimal position for the comment box to keep it in view
596 */
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700597 private calculateCommentBoxPosition(
598 lineNumber: number,
599 editorType: "original" | "modified",
600 ): { top: number; left: number } {
601 try {
602 if (!this.editor) {
603 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700604 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700605
606 const targetEditor =
607 editorType === "original"
608 ? this.editor.getOriginalEditor()
609 : this.editor.getModifiedEditor();
610 if (!targetEditor) {
611 return { top: 100, left: 100 };
612 }
613
614 // Get position from editor
615 const position = {
616 lineNumber: lineNumber,
617 column: 1,
618 };
619
620 // Use editor's built-in method for coordinate conversion
621 const coords = targetEditor.getScrolledVisiblePosition(position);
622
623 if (coords) {
624 // Get accurate DOM position
625 const editorDomNode = targetEditor.getDomNode();
626 if (editorDomNode) {
627 const editorRect = editorDomNode.getBoundingClientRect();
628
629 // Calculate the actual screen position
630 let screenLeft = editorRect.left + coords.left + 20; // Offset to the right
631 let screenTop = editorRect.top + coords.top;
632
633 // Get viewport dimensions
634 const viewportWidth = window.innerWidth;
635 const viewportHeight = window.innerHeight;
636
637 // Estimated box dimensions
638 const boxWidth = 350;
639 const boxHeight = 300;
640
641 // Check if box would go off the right edge
642 if (screenLeft + boxWidth > viewportWidth) {
643 screenLeft = viewportWidth - boxWidth - 20; // Keep 20px margin
644 }
645
646 // Check if box would go off the bottom
647 if (screenTop + boxHeight > viewportHeight) {
648 screenTop = Math.max(10, viewportHeight - boxHeight - 10);
649 }
650
651 // Ensure box is never positioned off-screen
652 screenTop = Math.max(10, screenTop);
653 screenLeft = Math.max(10, screenLeft);
654
655 return { top: screenTop, left: screenLeft };
656 }
657 }
658 } catch (error) {
659 console.error("Error calculating comment box position:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700660 }
661
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700662 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700663 }
664
665 setOriginalCode(code: string, filename?: string) {
666 this.originalCode = code;
667 if (filename) {
668 this.originalFilename = filename;
669 }
670
671 // Update the model if the editor is initialized
672 if (this.editor) {
673 const model = this.editor.getOriginalEditor().getModel();
674 if (model) {
675 model.setValue(code);
676 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700677 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700678 model,
679 this.getLanguageForFile(filename),
680 );
681 }
682 }
683 }
684 }
685
686 setModifiedCode(code: string, filename?: string) {
687 this.modifiedCode = code;
688 if (filename) {
689 this.modifiedFilename = filename;
690 }
691
692 // Update the model if the editor is initialized
693 if (this.editor) {
694 const model = this.editor.getModifiedEditor().getModel();
695 if (model) {
696 model.setValue(code);
697 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700698 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700699 model,
700 this.getLanguageForFile(filename),
701 );
702 }
703 }
704 }
705 }
706
Philip Zeyliger70273072025-05-28 18:26:14 +0000707 private _extensionToLanguageMap: Map<string, string> | null = null;
708
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700709 private getLanguageForFile(filename: string): string {
Philip Zeyliger70273072025-05-28 18:26:14 +0000710 // Get the file extension (including the dot for exact matching)
711 const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
712
713 // Build the extension-to-language map on first use
714 if (!this._extensionToLanguageMap) {
715 this._extensionToLanguageMap = new Map();
philip.zeyligerc0a44592025-06-15 21:24:57 -0700716 const languages = window.monaco!.languages.getLanguages();
Philip Zeyliger70273072025-05-28 18:26:14 +0000717
718 for (const language of languages) {
719 if (language.extensions) {
720 for (const ext of language.extensions) {
721 // Monaco extensions already include the dot, so use them directly
722 this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
723 }
724 }
725 }
726 }
727
728 return this._extensionToLanguageMap.get(extension) || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700729 }
730
731 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700732 * Setup glyph decorations for both editors
733 */
734 private setupGlyphDecorations() {
735 if (!this.editor || !window.monaco) {
736 return;
737 }
738
739 const originalEditor = this.editor.getOriginalEditor();
740 const modifiedEditor = this.editor.getModifiedEditor();
741
742 if (originalEditor && this.originalModel) {
743 this.addGlyphDecorationsToEditor(
744 originalEditor,
745 this.originalModel,
746 "original",
747 );
748 this.setupHoverBehavior(originalEditor);
749 }
750
751 if (modifiedEditor && this.modifiedModel) {
752 this.addGlyphDecorationsToEditor(
753 modifiedEditor,
754 this.modifiedModel,
755 "modified",
756 );
757 this.setupHoverBehavior(modifiedEditor);
758 }
759 }
760
761 /**
762 * Add glyph decorations to a specific editor
763 */
764 private addGlyphDecorationsToEditor(
765 editor: monaco.editor.IStandaloneCodeEditor,
766 model: monaco.editor.ITextModel,
767 editorType: "original" | "modified",
768 ) {
769 if (!window.monaco) {
770 return;
771 }
772
773 // Clear existing decorations
774 if (editorType === "original" && this.originalDecorations) {
775 this.originalDecorations.clear();
776 } else if (editorType === "modified" && this.modifiedDecorations) {
777 this.modifiedDecorations.clear();
778 }
779
780 // Create decorations for every line
781 const lineCount = model.getLineCount();
782 const decorations: monaco.editor.IModelDeltaDecoration[] = [];
783
784 for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
785 decorations.push({
786 range: new window.monaco.Range(lineNumber, 1, lineNumber, 1),
787 options: {
788 isWholeLine: false,
789 glyphMarginClassName: `comment-glyph-decoration comment-glyph-${editorType}-${lineNumber}`,
790 glyphMarginHoverMessage: { value: "Comment line" },
791 stickiness:
792 window.monaco.editor.TrackedRangeStickiness
793 .NeverGrowsWhenTypingAtEdges,
794 },
795 });
796 }
797
798 // Create or update decorations collection
799 if (editorType === "original") {
800 this.originalDecorations =
801 editor.createDecorationsCollection(decorations);
802 } else {
803 this.modifiedDecorations =
804 editor.createDecorationsCollection(decorations);
805 }
806 }
807
808 /**
809 * Setup hover and click behavior for glyph decorations
810 */
811 private setupHoverBehavior(editor: monaco.editor.IStandaloneCodeEditor) {
812 if (!editor) {
813 return;
814 }
815
816 let currentHoveredLine: number | null = null;
817 const editorType =
818 this.editor?.getOriginalEditor() === editor ? "original" : "modified";
819
820 // Listen for mouse move events in the editor
821 editor.onMouseMove((e) => {
822 if (e.target.position) {
823 const lineNumber = e.target.position.lineNumber;
824
825 // Handle real-time drag preview updates
826 if (
827 this.isDragging &&
828 this.dragStartLine !== null &&
829 this.dragStartEditor === editorType &&
830 this.showCommentBox
831 ) {
832 const startLine = Math.min(this.dragStartLine, lineNumber);
833 const endLine = Math.max(this.dragStartLine, lineNumber);
834 this.updateSelectedLinesPreview(startLine, endLine, editorType);
835 }
836
837 // Handle hover glyph visibility (only when not dragging)
838 if (!this.isDragging) {
839 // If we're hovering over a different line, update visibility
840 if (currentHoveredLine !== lineNumber) {
841 // Hide previous line's glyph
842 if (currentHoveredLine !== null) {
843 this.toggleGlyphVisibility(currentHoveredLine, false);
844 }
845
846 // Show current line's glyph
847 this.toggleGlyphVisibility(lineNumber, true);
848 currentHoveredLine = lineNumber;
849 }
850 }
851 }
852 });
853
854 // Listen for mouse down events for click-to-comment and drag selection
855 editor.onMouseDown((e) => {
856 if (
857 e.target.type ===
858 window.monaco?.editor.MouseTargetType.GUTTER_GLYPH_MARGIN
859 ) {
860 if (e.target.position) {
861 const lineNumber = e.target.position.lineNumber;
862
863 // Prevent default Monaco behavior
864 e.event.preventDefault();
865 e.event.stopPropagation();
866
867 // Check if there's an existing selection in this editor
868 const selection = editor.getSelection();
869 if (selection && !selection.isEmpty()) {
870 // Use the existing selection
871 const startLine = selection.startLineNumber;
872 const endLine = selection.endLineNumber;
873 this.showCommentForSelection(
874 startLine,
875 endLine,
876 editorType,
877 selection,
878 );
879 } else {
880 // Start drag selection or show comment for clicked line
881 this.isDragging = true;
882 this.dragStartLine = lineNumber;
883 this.dragStartEditor = editorType;
884
885 // If it's just a click (not drag), show comment box immediately
886 this.showCommentForLines(lineNumber, lineNumber, editorType);
887 }
888 }
889 }
890 });
891
892 // Listen for mouse up events to end drag selection
893 editor.onMouseUp((e) => {
894 if (this.isDragging) {
895 if (
896 e.target.position &&
897 this.dragStartLine !== null &&
898 this.dragStartEditor === editorType
899 ) {
900 const endLine = e.target.position.lineNumber;
901 const startLine = Math.min(this.dragStartLine, endLine);
902 const finalEndLine = Math.max(this.dragStartLine, endLine);
903
904 // Update the final selection (if comment box is not already shown)
905 if (!this.showCommentBox) {
906 this.showCommentForLines(startLine, finalEndLine, editorType);
907 } else {
908 // Just update the final selection since preview was already being updated
909 this.updateSelectedLinesPreview(
910 startLine,
911 finalEndLine,
912 editorType,
913 );
914 }
915 }
916
917 // Reset drag state
918 this.isDragging = false;
919 this.dragStartLine = null;
920 this.dragStartEditor = null;
921 }
922 });
923
924 // // Listen for mouse leave events
925 // editor.onMouseLeave(() => {
926 // if (currentHoveredLine !== null) {
927 // this.toggleGlyphVisibility(currentHoveredLine, false);
928 // currentHoveredLine = null;
929 // }
930 // });
931 }
932
933 /**
934 * Update the selected lines preview during drag operations
935 */
936 private updateSelectedLinesPreview(
937 startLine: number,
938 endLine: number,
939 editorType: "original" | "modified",
940 ) {
941 try {
942 if (!this.editor) {
943 return;
944 }
945
946 const targetModel =
947 editorType === "original" ? this.originalModel : this.modifiedModel;
948
949 if (!targetModel) {
950 return;
951 }
952
953 // Get the text for the selected lines
954 const lines: string[] = [];
955 for (let i = startLine; i <= endLine; i++) {
956 if (i <= targetModel.getLineCount()) {
957 lines.push(targetModel.getLineContent(i));
958 }
959 }
960
961 const selectedText = lines.join("\n");
962
963 // Update the selected lines state
964 this.selectedLines = {
965 startLine,
966 endLine,
967 editorType,
968 text: selectedText,
969 };
970
971 // Request update to refresh the preview
972 this.requestUpdate();
973 } catch (error) {
974 console.error("Error updating selected lines preview:", error);
975 }
976 }
977
978 /**
979 * Show comment box for a Monaco editor selection
980 */
981 private showCommentForSelection(
982 startLine: number,
983 endLine: number,
984 editorType: "original" | "modified",
985 selection: monaco.Selection,
986 ) {
987 try {
988 if (!this.editor) {
989 return;
990 }
991
992 const targetModel =
993 editorType === "original" ? this.originalModel : this.modifiedModel;
994
995 if (!targetModel) {
996 return;
997 }
998
999 // Get the exact selected text from the Monaco selection
1000 const selectedText = targetModel.getValueInRange(selection);
1001
1002 // Set the selected lines state
1003 this.selectedLines = {
1004 startLine,
1005 endLine,
1006 editorType,
1007 text: selectedText,
1008 };
1009
1010 // Calculate and set comment box position
1011 this.commentBoxPosition = this.calculateCommentBoxPosition(
1012 startLine,
1013 editorType,
1014 );
1015
1016 // Reset comment text and show the box
1017 this.commentText = "";
1018 this.showCommentBox = true;
1019
1020 // Clear any visible glyphs since we're showing the comment box
1021 this.clearAllVisibleGlyphs();
1022
1023 // Request update to render the comment box
1024 this.requestUpdate();
1025 } catch (error) {
1026 console.error("Error showing comment for selection:", error);
1027 }
1028 }
1029
1030 /**
1031 * Show comment box for a range of lines
1032 */
1033 private showCommentForLines(
1034 startLine: number,
1035 endLine: number,
1036 editorType: "original" | "modified",
1037 ) {
1038 try {
1039 if (!this.editor) {
1040 return;
1041 }
1042
1043 const targetEditor =
1044 editorType === "original"
1045 ? this.editor.getOriginalEditor()
1046 : this.editor.getModifiedEditor();
1047 const targetModel =
1048 editorType === "original" ? this.originalModel : this.modifiedModel;
1049
1050 if (!targetEditor || !targetModel) {
1051 return;
1052 }
1053
1054 // Get the text for the selected lines
1055 const lines: string[] = [];
1056 for (let i = startLine; i <= endLine; i++) {
1057 if (i <= targetModel.getLineCount()) {
1058 lines.push(targetModel.getLineContent(i));
1059 }
1060 }
1061
1062 const selectedText = lines.join("\n");
1063
1064 // Set the selected lines state
1065 this.selectedLines = {
1066 startLine,
1067 endLine,
1068 editorType,
1069 text: selectedText,
1070 };
1071
1072 // Calculate and set comment box position
1073 this.commentBoxPosition = this.calculateCommentBoxPosition(
1074 startLine,
1075 editorType,
1076 );
1077
1078 // Reset comment text and show the box
1079 this.commentText = "";
1080 this.showCommentBox = true;
1081
1082 // Clear any visible glyphs since we're showing the comment box
1083 this.clearAllVisibleGlyphs();
1084
1085 // Request update to render the comment box
1086 this.requestUpdate();
1087 } catch (error) {
1088 console.error("Error showing comment for lines:", error);
1089 }
1090 }
1091
1092 /**
1093 * Clear all currently visible glyphs
1094 */
1095 private clearAllVisibleGlyphs() {
1096 try {
1097 this.visibleGlyphs.forEach((glyphId) => {
1098 const element = this.container.value?.querySelector(`.${glyphId}`);
1099 if (element) {
1100 element.classList.remove("hover-visible");
1101 }
1102 });
1103 this.visibleGlyphs.clear();
1104 } catch (error) {
1105 console.error("Error clearing visible glyphs:", error);
1106 }
1107 }
1108
1109 /**
1110 * Toggle the visibility of a glyph decoration for a specific line
1111 */
1112 private toggleGlyphVisibility(lineNumber: number, visible: boolean) {
1113 try {
1114 // If making visible, clear all existing visible glyphs first
1115 if (visible) {
1116 this.clearAllVisibleGlyphs();
1117 }
1118
1119 // Find all glyph decorations for this line in both editors
1120 const selectors = [
1121 `comment-glyph-original-${lineNumber}`,
1122 `comment-glyph-modified-${lineNumber}`,
1123 ];
1124
1125 selectors.forEach((glyphId) => {
1126 const element = this.container.value?.querySelector(`.${glyphId}`);
1127 if (element) {
1128 if (visible) {
1129 element.classList.add("hover-visible");
1130 this.visibleGlyphs.add(glyphId);
1131 } else {
1132 element.classList.remove("hover-visible");
1133 this.visibleGlyphs.delete(glyphId);
1134 }
1135 }
1136 });
1137 } catch (error) {
1138 console.error("Error toggling glyph visibility:", error);
1139 }
1140 }
1141
1142 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001143 * Update editor options
1144 */
1145 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
1146 if (this.editor) {
1147 this.editor.updateOptions(value);
Philip Zeyliger0635c772025-06-25 12:01:16 -07001148 // Re-fit content after options change with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001149 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001150 setTimeout(() => {
1151 // Preserve scroll positions during options change
1152 const originalScrollTop =
1153 this.editor!.getOriginalEditor().getScrollTop();
1154 const modifiedScrollTop =
1155 this.editor!.getModifiedEditor().getScrollTop();
1156
1157 this.fitEditorToContent!();
1158
1159 // Restore scroll positions
1160 requestAnimationFrame(() => {
1161 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1162 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1163 });
1164 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001165 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001166 }
1167 }
1168
1169 /**
1170 * Toggle hideUnchangedRegions feature
1171 */
1172 toggleHideUnchangedRegions(enabled: boolean) {
1173 if (this.editor) {
1174 this.editor.updateOptions({
1175 hideUnchangedRegions: {
1176 enabled: enabled,
1177 contextLineCount: 3,
1178 minimumLineCount: 3,
1179 revealLineCount: 10,
1180 },
1181 });
Philip Zeyliger0635c772025-06-25 12:01:16 -07001182 // Re-fit content after toggling with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001183 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001184 setTimeout(() => {
1185 // Preserve scroll positions during toggle
1186 const originalScrollTop =
1187 this.editor!.getOriginalEditor().getScrollTop();
1188 const modifiedScrollTop =
1189 this.editor!.getModifiedEditor().getScrollTop();
1190
1191 this.fitEditorToContent!();
1192
1193 // Restore scroll positions
1194 requestAnimationFrame(() => {
1195 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1196 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1197 });
1198 }, 100);
David Crawshaw26f3f342025-06-14 19:58:32 +00001199 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001200 }
1201 }
1202
1203 // Models for the editor
1204 private originalModel?: monaco.editor.ITextModel;
1205 private modifiedModel?: monaco.editor.ITextModel;
1206
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001207 // Decoration collections for glyph decorations
1208 private originalDecorations?: monaco.editor.IEditorDecorationsCollection;
1209 private modifiedDecorations?: monaco.editor.IEditorDecorationsCollection;
1210
philip.zeyligerc0a44592025-06-15 21:24:57 -07001211 private async initializeEditor() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001212 try {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001213 // Load Monaco dynamically
1214 const monaco = await loadMonaco();
Autoformatter2f8464c2025-06-16 04:27:05 +00001215
philip.zeyliger7351cd92025-06-14 12:25:31 -07001216 // Disable semantic validation globally for TypeScript/JavaScript if available
1217 if (monaco.languages && monaco.languages.typescript) {
1218 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
1219 noSemanticValidation: true,
1220 });
1221 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
1222 noSemanticValidation: true,
1223 });
1224 }
Autoformatter8c463622025-05-16 21:54:17 +00001225
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001226 // First time initialization
1227 if (!this.editor) {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001228 // Ensure the container ref is available
1229 if (!this.container.value) {
1230 throw new Error(
1231 "Container element not available - component may not be fully rendered",
1232 );
1233 }
1234
David Crawshaw26f3f342025-06-14 19:58:32 +00001235 // Create the diff editor with auto-sizing configuration
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001236 this.editor = monaco.editor.createDiffEditor(this.container.value, {
David Crawshaw26f3f342025-06-14 19:58:32 +00001237 automaticLayout: false, // We'll resize manually
Autoformatter8c463622025-05-16 21:54:17 +00001238 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001239 theme: "vs", // Always use light mode
philip.zeyliger6b8b7662025-06-16 03:06:30 +00001240 renderSideBySide: !this.inline,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001241 ignoreTrimWhitespace: false,
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001242 // Enable glyph margin for both editors to show decorations
1243 glyphMargin: true,
David Crawshaw26f3f342025-06-14 19:58:32 +00001244 scrollbar: {
Philip Zeyligere0860932025-06-18 13:01:17 -07001245 // Ideally we'd handle the mouse wheel for the horizontal scrollbar,
1246 // but there doesn't seem to be that option. Setting
1247 // alwaysConsumeMousewheel false and handleMouseWheel true didn't
1248 // work for me.
1249 handleMouseWheel: false,
David Crawshaw26f3f342025-06-14 19:58:32 +00001250 },
Philip Zeyligere0860932025-06-18 13:01:17 -07001251 renderOverviewRuler: false, // Disable overview ruler
David Crawshaw26f3f342025-06-14 19:58:32 +00001252 scrollBeyondLastLine: false,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001253 // Focus on the differences by hiding unchanged regions
1254 hideUnchangedRegions: {
1255 enabled: true, // Enable the feature
Philip Zeyligere0860932025-06-18 13:01:17 -07001256 contextLineCount: 5, // Show 3 lines of context around each difference
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001257 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
1258 revealLineCount: 10, // Show 10 lines when expanding a hidden region
1259 },
1260 });
1261
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +00001262 this.setupKeyboardShortcuts();
1263
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001264 // If this is an editable view, set the correct read-only state for each editor
1265 if (this.editableRight) {
1266 // Make sure the original editor is always read-only
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001267 this.editor
1268 .getOriginalEditor()
1269 .updateOptions({ readOnly: true, glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001270 // Make sure the modified editor is editable
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001271 this.editor
1272 .getModifiedEditor()
1273 .updateOptions({ readOnly: false, glyphMargin: true });
1274 } else {
1275 // Ensure glyph margin is enabled on both editors even in read-only mode
1276 this.editor.getOriginalEditor().updateOptions({ glyphMargin: true });
1277 this.editor.getModifiedEditor().updateOptions({ glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001278 }
philip.zeyliger7351cd92025-06-14 12:25:31 -07001279
David Crawshaw26f3f342025-06-14 19:58:32 +00001280 // Set up auto-sizing
1281 this.setupAutoSizing();
1282
philip.zeyliger7351cd92025-06-14 12:25:31 -07001283 // Add Monaco editor to debug global
1284 this.addToDebugGlobal();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001285 }
1286
1287 // Create or update models
1288 this.updateModels();
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001289 // Add glyph decorations after models are set
1290 this.setupGlyphDecorations();
Autoformatter8c463622025-05-16 21:54:17 +00001291 // Set up content change listener
1292 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001293
philip.zeyliger7351cd92025-06-14 12:25:31 -07001294 // Fix cursor positioning issues by ensuring fonts are loaded
philip.zeyliger7351cd92025-06-14 12:25:31 -07001295 document.fonts.ready.then(() => {
1296 if (this.editor) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001297 // Preserve scroll positions during font remeasuring
1298 const originalScrollTop = this.editor
1299 .getOriginalEditor()
1300 .getScrollTop();
1301 const modifiedScrollTop = this.editor
1302 .getModifiedEditor()
1303 .getScrollTop();
1304
philip.zeyliger7351cd92025-06-14 12:25:31 -07001305 monaco.editor.remeasureFonts();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001306
1307 if (this.fitEditorToContent) {
1308 this.fitEditorToContent();
1309 }
1310
1311 // Restore scroll positions after font remeasuring
1312 requestAnimationFrame(() => {
1313 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1314 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1315 });
philip.zeyliger7351cd92025-06-14 12:25:31 -07001316 }
1317 });
1318
Philip Zeyliger0635c772025-06-25 12:01:16 -07001319 // Force layout recalculation after a short delay with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001320 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001321 if (this.editor && this.fitEditorToContent) {
1322 // Preserve scroll positions
1323 const originalScrollTop = this.editor
1324 .getOriginalEditor()
1325 .getScrollTop();
1326 const modifiedScrollTop = this.editor
1327 .getModifiedEditor()
1328 .getScrollTop();
1329
David Crawshaw26f3f342025-06-14 19:58:32 +00001330 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001331
1332 // Restore scroll positions
1333 requestAnimationFrame(() => {
1334 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1335 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1336 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001337 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001338 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001339 } catch (error) {
1340 console.error("Error initializing Monaco editor:", error);
1341 }
1342 }
1343
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001344 private updateModels() {
1345 try {
1346 // Get language based on filename
1347 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1348 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1349
1350 // Always create new models with unique URIs based on timestamp to avoid conflicts
1351 const timestamp = new Date().getTime();
1352 // TODO: Could put filename in these URIs; unclear how they're used right now.
philip.zeyligerc0a44592025-06-15 21:24:57 -07001353 const originalUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001354 `file:///original-${timestamp}.${originalLang}`,
1355 );
philip.zeyligerc0a44592025-06-15 21:24:57 -07001356 const modifiedUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001357 `file:///modified-${timestamp}.${modifiedLang}`,
1358 );
1359
1360 // Store references to old models
1361 const oldOriginalModel = this.originalModel;
1362 const oldModifiedModel = this.modifiedModel;
1363
1364 // Nullify instance variables to prevent accidental use
1365 this.originalModel = undefined;
1366 this.modifiedModel = undefined;
1367
1368 // Clear the editor model first to release Monaco's internal references
1369 if (this.editor) {
1370 this.editor.setModel(null);
1371 }
1372
1373 // Now it's safe to dispose the old models
1374 if (oldOriginalModel) {
1375 oldOriginalModel.dispose();
1376 }
1377
1378 if (oldModifiedModel) {
1379 oldModifiedModel.dispose();
1380 }
1381
1382 // Create new models
philip.zeyligerc0a44592025-06-15 21:24:57 -07001383 this.originalModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001384 this.originalCode || "",
1385 originalLang,
1386 originalUri,
1387 );
1388
philip.zeyligerc0a44592025-06-15 21:24:57 -07001389 this.modifiedModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001390 this.modifiedCode || "",
1391 modifiedLang,
1392 modifiedUri,
1393 );
1394
1395 // Set the new models on the editor
1396 if (this.editor) {
1397 this.editor.setModel({
1398 original: this.originalModel,
1399 modified: this.modifiedModel,
1400 });
Autoformatter9abf8032025-06-14 23:24:08 +00001401
David Crawshaw26f3f342025-06-14 19:58:32 +00001402 // Set initial hideUnchangedRegions state (default to enabled/collapsed)
1403 this.editor.updateOptions({
1404 hideUnchangedRegions: {
1405 enabled: true, // Default to collapsed state
1406 contextLineCount: 3,
1407 minimumLineCount: 3,
1408 revealLineCount: 10,
1409 },
1410 });
Autoformatter9abf8032025-06-14 23:24:08 +00001411
Philip Zeyliger0635c772025-06-25 12:01:16 -07001412 // Fit content after setting new models with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001413 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001414 setTimeout(() => {
1415 // Preserve scroll positions when fitting content after model changes
1416 const originalScrollTop =
1417 this.editor!.getOriginalEditor().getScrollTop();
1418 const modifiedScrollTop =
1419 this.editor!.getModifiedEditor().getScrollTop();
1420
1421 this.fitEditorToContent!();
1422
1423 // Restore scroll positions
1424 requestAnimationFrame(() => {
1425 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1426 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1427 });
1428 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001429 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001430
1431 // Add glyph decorations after setting new models
1432 setTimeout(() => this.setupGlyphDecorations(), 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001433 }
1434 this.setupContentChangeListener();
1435 } catch (error) {
1436 console.error("Error updating Monaco models:", error);
1437 }
1438 }
1439
philip.zeyligerc0a44592025-06-15 21:24:57 -07001440 async updated(changedProperties: Map<string, any>) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001441 // If any relevant properties changed, just update the models
1442 if (
1443 changedProperties.has("originalCode") ||
1444 changedProperties.has("modifiedCode") ||
1445 changedProperties.has("originalFilename") ||
1446 changedProperties.has("modifiedFilename") ||
1447 changedProperties.has("editableRight")
1448 ) {
1449 if (this.editor) {
1450 this.updateModels();
1451
David Crawshaw26f3f342025-06-14 19:58:32 +00001452 // Force auto-sizing after model updates
Philip Zeyliger0635c772025-06-25 12:01:16 -07001453 // Use a slightly longer delay to ensure layout is stable with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001454 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001455 if (this.fitEditorToContent && this.editor) {
1456 // Preserve scroll positions during model update layout
1457 const originalScrollTop = this.editor
1458 .getOriginalEditor()
1459 .getScrollTop();
1460 const modifiedScrollTop = this.editor
1461 .getModifiedEditor()
1462 .getScrollTop();
1463
David Crawshaw26f3f342025-06-14 19:58:32 +00001464 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001465
1466 // Restore scroll positions
1467 requestAnimationFrame(() => {
1468 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1469 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1470 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001471 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001472 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001473 } else {
1474 // If the editor isn't initialized yet but we received content,
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001475 // ensure we're connected before initializing
1476 await this.ensureConnectedToDocument();
philip.zeyligerc0a44592025-06-15 21:24:57 -07001477 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001478 }
1479 }
1480 }
1481
David Crawshaw26f3f342025-06-14 19:58:32 +00001482 // Set up auto-sizing for multi-file diff view
1483 private setupAutoSizing() {
1484 if (!this.editor) return;
1485
1486 const fitContent = () => {
1487 try {
1488 const originalEditor = this.editor!.getOriginalEditor();
1489 const modifiedEditor = this.editor!.getModifiedEditor();
Autoformatter9abf8032025-06-14 23:24:08 +00001490
David Crawshaw26f3f342025-06-14 19:58:32 +00001491 const originalHeight = originalEditor.getContentHeight();
1492 const modifiedHeight = modifiedEditor.getContentHeight();
Autoformatter9abf8032025-06-14 23:24:08 +00001493
David Crawshaw26f3f342025-06-14 19:58:32 +00001494 // Use the maximum height of both editors, plus some padding
1495 const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
Autoformatter9abf8032025-06-14 23:24:08 +00001496
David Crawshaw26f3f342025-06-14 19:58:32 +00001497 // Set both container and host height to enable proper scrolling
1498 if (this.container.value) {
1499 // Set explicit heights on both container and host
1500 this.container.value.style.height = `${maxHeight}px`;
1501 this.style.height = `${maxHeight}px`; // Update host element height
Autoformatter9abf8032025-06-14 23:24:08 +00001502
David Crawshaw26f3f342025-06-14 19:58:32 +00001503 // Emit the height change event BEFORE calling layout
1504 // This ensures parent containers resize first
Autoformatter9abf8032025-06-14 23:24:08 +00001505 this.dispatchEvent(
1506 new CustomEvent("monaco-height-changed", {
1507 detail: { height: maxHeight },
1508 bubbles: true,
1509 composed: true,
1510 }),
1511 );
1512
David Crawshaw26f3f342025-06-14 19:58:32 +00001513 // Layout after both this component and parents have updated
1514 setTimeout(() => {
1515 if (this.editor && this.container.value) {
1516 // Use explicit dimensions to ensure Monaco uses full available space
David Crawshawdba26b52025-06-15 00:33:45 +00001517 // Use clientWidth instead of offsetWidth to avoid border overflow
1518 const width = this.container.value.clientWidth;
David Crawshaw26f3f342025-06-14 19:58:32 +00001519 this.editor.layout({
1520 width: width,
Autoformatter9abf8032025-06-14 23:24:08 +00001521 height: maxHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +00001522 });
1523 }
1524 }, 10);
1525 }
1526 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +00001527 console.error("Error in fitContent:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +00001528 }
1529 };
1530
1531 // Store the fit function for external access
1532 this.fitEditorToContent = fitContent;
1533
1534 // Set up listeners for content size changes
1535 this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
1536 this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
1537
1538 // Initial fit
1539 fitContent();
1540 }
1541
1542 private fitEditorToContent: (() => void) | null = null;
1543
David Crawshawe2954ce2025-06-15 00:06:34 +00001544 /**
1545 * Set up window resize handler to ensure Monaco editor adapts to browser window changes
1546 */
1547 private setupWindowResizeHandler() {
1548 // Create a debounced resize handler to avoid too many layout calls
1549 let resizeTimeout: number | null = null;
Autoformatterad15b6c2025-06-15 00:29:26 +00001550
David Crawshawe2954ce2025-06-15 00:06:34 +00001551 this._windowResizeHandler = () => {
1552 // Clear any existing timeout
1553 if (resizeTimeout) {
1554 window.clearTimeout(resizeTimeout);
1555 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001556
David Crawshawe2954ce2025-06-15 00:06:34 +00001557 // Debounce the resize to avoid excessive layout calls
1558 resizeTimeout = window.setTimeout(() => {
1559 if (this.editor && this.container.value) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001560 // Trigger layout recalculation with scroll preservation
David Crawshawe2954ce2025-06-15 00:06:34 +00001561 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001562 // Preserve scroll positions during window resize
1563 const originalScrollTop = this.editor
1564 .getOriginalEditor()
1565 .getScrollTop();
1566 const modifiedScrollTop = this.editor
1567 .getModifiedEditor()
1568 .getScrollTop();
1569
David Crawshawe2954ce2025-06-15 00:06:34 +00001570 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001571
1572 // Restore scroll positions
1573 requestAnimationFrame(() => {
1574 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1575 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1576 });
David Crawshawe2954ce2025-06-15 00:06:34 +00001577 } else {
1578 // Fallback: just trigger a layout with current container dimensions
David Crawshawdba26b52025-06-15 00:33:45 +00001579 // Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
1580 const width = this.container.value.clientWidth;
1581 const height = this.container.value.clientHeight;
David Crawshawe2954ce2025-06-15 00:06:34 +00001582 this.editor.layout({ width, height });
1583 }
1584 }
1585 }, 100); // 100ms debounce
1586 };
Autoformatterad15b6c2025-06-15 00:29:26 +00001587
David Crawshawe2954ce2025-06-15 00:06:34 +00001588 // Add the event listener
Autoformatterad15b6c2025-06-15 00:29:26 +00001589 window.addEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001590 }
1591
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001592 // Add resize observer to ensure editor resizes when container changes
philip.zeyligerc0a44592025-06-15 21:24:57 -07001593 async firstUpdated() {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001594 // Ensure we're connected to the document before Monaco initialization
1595 await this.ensureConnectedToDocument();
1596
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001597 // Initialize the editor
philip.zeyligerc0a44592025-06-15 21:24:57 -07001598 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001599
David Crawshawe2954ce2025-06-15 00:06:34 +00001600 // Set up window resize handler to ensure Monaco editor adapts to browser window changes
1601 this.setupWindowResizeHandler();
1602
David Crawshaw26f3f342025-06-14 19:58:32 +00001603 // For multi-file diff, we don't use ResizeObserver since we control the size
1604 // Instead, we rely on auto-sizing based on content
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001605
1606 // If editable, set up edit mode and content change listener
1607 if (this.editableRight && this.editor) {
1608 // Ensure the original editor is read-only
1609 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1610 // Ensure the modified editor is editable
1611 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1612 }
1613 }
1614
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001615 /**
1616 * Ensure this component and its container are properly connected to the document.
1617 * Monaco editor requires the container to be in the document for proper initialization.
1618 */
1619 private async ensureConnectedToDocument(): Promise<void> {
1620 // Wait for our own render to complete
1621 await this.updateComplete;
1622
1623 // Verify the container ref is available
1624 if (!this.container.value) {
1625 throw new Error("Container element not available after updateComplete");
1626 }
1627
1628 // Check if we're connected to the document
1629 if (!this.isConnected) {
1630 throw new Error("Component is not connected to the document");
1631 }
1632
1633 // Verify the container is also in the document
1634 if (!this.container.value.isConnected) {
1635 throw new Error("Container element is not connected to the document");
1636 }
1637 }
1638
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001639 private _resizeObserver: ResizeObserver | null = null;
David Crawshawe2954ce2025-06-15 00:06:34 +00001640 private _windowResizeHandler: (() => void) | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001641
philip.zeyliger7351cd92025-06-14 12:25:31 -07001642 /**
1643 * Add this Monaco editor instance to the global debug object
1644 * This allows inspection and debugging via browser console
1645 */
1646 private addToDebugGlobal() {
1647 try {
1648 // Initialize the debug global if it doesn't exist
1649 if (!(window as any).sketchDebug) {
1650 (window as any).sketchDebug = {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001651 monaco: window.monaco!,
philip.zeyliger7351cd92025-06-14 12:25:31 -07001652 editors: [],
1653 remeasureFonts: () => {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001654 window.monaco!.editor.remeasureFonts();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001655 (window as any).sketchDebug.editors.forEach(
1656 (editor: any, index: number) => {
1657 if (editor && editor.layout) {
1658 editor.layout();
1659 }
1660 },
1661 );
1662 },
1663 layoutAll: () => {
1664 (window as any).sketchDebug.editors.forEach(
1665 (editor: any, index: number) => {
1666 if (editor && editor.layout) {
1667 editor.layout();
1668 }
1669 },
1670 );
1671 },
1672 getActiveEditors: () => {
1673 return (window as any).sketchDebug.editors.filter(
1674 (editor: any) => editor !== null,
1675 );
1676 },
1677 };
1678 }
1679
1680 // Add this editor to the debug collection
1681 if (this.editor) {
1682 (window as any).sketchDebug.editors.push(this.editor);
1683 }
1684 } catch (error) {
1685 console.error("Error adding Monaco editor to debug global:", error);
1686 }
1687 }
1688
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001689 disconnectedCallback() {
1690 super.disconnectedCallback();
1691
1692 try {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001693 // Remove editor from debug global before disposal
1694 if (this.editor && (window as any).sketchDebug?.editors) {
1695 const index = (window as any).sketchDebug.editors.indexOf(this.editor);
1696 if (index > -1) {
1697 (window as any).sketchDebug.editors[index] = null;
1698 }
1699 }
1700
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001701 // Clean up decorations
1702 if (this.originalDecorations) {
1703 this.originalDecorations.clear();
1704 this.originalDecorations = undefined;
1705 }
1706
1707 if (this.modifiedDecorations) {
1708 this.modifiedDecorations.clear();
1709 this.modifiedDecorations = undefined;
1710 }
1711
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001712 // Clean up resources when element is removed
1713 if (this.editor) {
1714 this.editor.dispose();
1715 this.editor = undefined;
1716 }
1717
1718 // Dispose models to prevent memory leaks
1719 if (this.originalModel) {
1720 this.originalModel.dispose();
1721 this.originalModel = undefined;
1722 }
1723
1724 if (this.modifiedModel) {
1725 this.modifiedModel.dispose();
1726 this.modifiedModel = undefined;
1727 }
1728
David Crawshaw26f3f342025-06-14 19:58:32 +00001729 // Clean up resize observer (if any)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001730 if (this._resizeObserver) {
1731 this._resizeObserver.disconnect();
1732 this._resizeObserver = null;
1733 }
Autoformatter9abf8032025-06-14 23:24:08 +00001734
David Crawshaw26f3f342025-06-14 19:58:32 +00001735 // Clear the fit function reference
1736 this.fitEditorToContent = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001737
David Crawshawe2954ce2025-06-15 00:06:34 +00001738 // Remove window resize handler if set
1739 if (this._windowResizeHandler) {
Autoformatterad15b6c2025-06-15 00:29:26 +00001740 window.removeEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001741 this._windowResizeHandler = null;
1742 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001743
1744 // Clear visible glyphs tracking
1745 this.visibleGlyphs.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001746 } catch (error) {
1747 console.error("Error in disconnectedCallback:", error);
1748 }
1749 }
1750
1751 // disconnectedCallback implementation is defined below
1752}
1753
1754declare global {
1755 interface HTMLElementTagNameMap {
1756 "sketch-monaco-view": CodeDiffEditor;
1757 }
1758}