blob: 12dc00a47883c821e122fd966bc6738c8c69efe8 [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);
David Crawshaw26f3f342025-06-14 19:58:32 +00001148 // Re-fit content after options change
1149 if (this.fitEditorToContent) {
1150 setTimeout(() => this.fitEditorToContent!(), 50);
1151 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001152 }
1153 }
1154
1155 /**
1156 * Toggle hideUnchangedRegions feature
1157 */
1158 toggleHideUnchangedRegions(enabled: boolean) {
1159 if (this.editor) {
1160 this.editor.updateOptions({
1161 hideUnchangedRegions: {
1162 enabled: enabled,
1163 contextLineCount: 3,
1164 minimumLineCount: 3,
1165 revealLineCount: 10,
1166 },
1167 });
David Crawshaw26f3f342025-06-14 19:58:32 +00001168 // Re-fit content after toggling
1169 if (this.fitEditorToContent) {
1170 setTimeout(() => this.fitEditorToContent!(), 100);
1171 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001172 }
1173 }
1174
1175 // Models for the editor
1176 private originalModel?: monaco.editor.ITextModel;
1177 private modifiedModel?: monaco.editor.ITextModel;
1178
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001179 // Decoration collections for glyph decorations
1180 private originalDecorations?: monaco.editor.IEditorDecorationsCollection;
1181 private modifiedDecorations?: monaco.editor.IEditorDecorationsCollection;
1182
philip.zeyligerc0a44592025-06-15 21:24:57 -07001183 private async initializeEditor() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001184 try {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001185 // Load Monaco dynamically
1186 const monaco = await loadMonaco();
Autoformatter2f8464c2025-06-16 04:27:05 +00001187
philip.zeyliger7351cd92025-06-14 12:25:31 -07001188 // Disable semantic validation globally for TypeScript/JavaScript if available
1189 if (monaco.languages && monaco.languages.typescript) {
1190 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
1191 noSemanticValidation: true,
1192 });
1193 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
1194 noSemanticValidation: true,
1195 });
1196 }
Autoformatter8c463622025-05-16 21:54:17 +00001197
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001198 // First time initialization
1199 if (!this.editor) {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001200 // Ensure the container ref is available
1201 if (!this.container.value) {
1202 throw new Error(
1203 "Container element not available - component may not be fully rendered",
1204 );
1205 }
1206
David Crawshaw26f3f342025-06-14 19:58:32 +00001207 // Create the diff editor with auto-sizing configuration
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001208 this.editor = monaco.editor.createDiffEditor(this.container.value, {
David Crawshaw26f3f342025-06-14 19:58:32 +00001209 automaticLayout: false, // We'll resize manually
Autoformatter8c463622025-05-16 21:54:17 +00001210 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001211 theme: "vs", // Always use light mode
philip.zeyliger6b8b7662025-06-16 03:06:30 +00001212 renderSideBySide: !this.inline,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001213 ignoreTrimWhitespace: false,
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001214 // Enable glyph margin for both editors to show decorations
1215 glyphMargin: true,
David Crawshaw26f3f342025-06-14 19:58:32 +00001216 scrollbar: {
Philip Zeyligere0860932025-06-18 13:01:17 -07001217 // Ideally we'd handle the mouse wheel for the horizontal scrollbar,
1218 // but there doesn't seem to be that option. Setting
1219 // alwaysConsumeMousewheel false and handleMouseWheel true didn't
1220 // work for me.
1221 handleMouseWheel: false,
David Crawshaw26f3f342025-06-14 19:58:32 +00001222 },
Philip Zeyligere0860932025-06-18 13:01:17 -07001223 renderOverviewRuler: false, // Disable overview ruler
David Crawshaw26f3f342025-06-14 19:58:32 +00001224 scrollBeyondLastLine: false,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001225 // Focus on the differences by hiding unchanged regions
1226 hideUnchangedRegions: {
1227 enabled: true, // Enable the feature
Philip Zeyligere0860932025-06-18 13:01:17 -07001228 contextLineCount: 5, // Show 3 lines of context around each difference
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001229 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
1230 revealLineCount: 10, // Show 10 lines when expanding a hidden region
1231 },
1232 });
1233
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +00001234 this.setupKeyboardShortcuts();
1235
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001236 // If this is an editable view, set the correct read-only state for each editor
1237 if (this.editableRight) {
1238 // Make sure the original editor is always read-only
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001239 this.editor
1240 .getOriginalEditor()
1241 .updateOptions({ readOnly: true, glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001242 // Make sure the modified editor is editable
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001243 this.editor
1244 .getModifiedEditor()
1245 .updateOptions({ readOnly: false, glyphMargin: true });
1246 } else {
1247 // Ensure glyph margin is enabled on both editors even in read-only mode
1248 this.editor.getOriginalEditor().updateOptions({ glyphMargin: true });
1249 this.editor.getModifiedEditor().updateOptions({ glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001250 }
philip.zeyliger7351cd92025-06-14 12:25:31 -07001251
David Crawshaw26f3f342025-06-14 19:58:32 +00001252 // Set up auto-sizing
1253 this.setupAutoSizing();
1254
philip.zeyliger7351cd92025-06-14 12:25:31 -07001255 // Add Monaco editor to debug global
1256 this.addToDebugGlobal();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001257 }
1258
1259 // Create or update models
1260 this.updateModels();
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001261 // Add glyph decorations after models are set
1262 this.setupGlyphDecorations();
Autoformatter8c463622025-05-16 21:54:17 +00001263 // Set up content change listener
1264 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001265
philip.zeyliger7351cd92025-06-14 12:25:31 -07001266 // Fix cursor positioning issues by ensuring fonts are loaded
philip.zeyliger7351cd92025-06-14 12:25:31 -07001267 document.fonts.ready.then(() => {
1268 if (this.editor) {
1269 monaco.editor.remeasureFonts();
David Crawshaw26f3f342025-06-14 19:58:32 +00001270 this.fitEditorToContent();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001271 }
1272 });
1273
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001274 // Force layout recalculation after a short delay
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001275 setTimeout(() => {
1276 if (this.editor) {
David Crawshaw26f3f342025-06-14 19:58:32 +00001277 this.fitEditorToContent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001278 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001279 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001280 } catch (error) {
1281 console.error("Error initializing Monaco editor:", error);
1282 }
1283 }
1284
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001285 private updateModels() {
1286 try {
1287 // Get language based on filename
1288 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1289 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1290
1291 // Always create new models with unique URIs based on timestamp to avoid conflicts
1292 const timestamp = new Date().getTime();
1293 // TODO: Could put filename in these URIs; unclear how they're used right now.
philip.zeyligerc0a44592025-06-15 21:24:57 -07001294 const originalUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001295 `file:///original-${timestamp}.${originalLang}`,
1296 );
philip.zeyligerc0a44592025-06-15 21:24:57 -07001297 const modifiedUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001298 `file:///modified-${timestamp}.${modifiedLang}`,
1299 );
1300
1301 // Store references to old models
1302 const oldOriginalModel = this.originalModel;
1303 const oldModifiedModel = this.modifiedModel;
1304
1305 // Nullify instance variables to prevent accidental use
1306 this.originalModel = undefined;
1307 this.modifiedModel = undefined;
1308
1309 // Clear the editor model first to release Monaco's internal references
1310 if (this.editor) {
1311 this.editor.setModel(null);
1312 }
1313
1314 // Now it's safe to dispose the old models
1315 if (oldOriginalModel) {
1316 oldOriginalModel.dispose();
1317 }
1318
1319 if (oldModifiedModel) {
1320 oldModifiedModel.dispose();
1321 }
1322
1323 // Create new models
philip.zeyligerc0a44592025-06-15 21:24:57 -07001324 this.originalModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001325 this.originalCode || "",
1326 originalLang,
1327 originalUri,
1328 );
1329
philip.zeyligerc0a44592025-06-15 21:24:57 -07001330 this.modifiedModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001331 this.modifiedCode || "",
1332 modifiedLang,
1333 modifiedUri,
1334 );
1335
1336 // Set the new models on the editor
1337 if (this.editor) {
1338 this.editor.setModel({
1339 original: this.originalModel,
1340 modified: this.modifiedModel,
1341 });
Autoformatter9abf8032025-06-14 23:24:08 +00001342
David Crawshaw26f3f342025-06-14 19:58:32 +00001343 // Set initial hideUnchangedRegions state (default to enabled/collapsed)
1344 this.editor.updateOptions({
1345 hideUnchangedRegions: {
1346 enabled: true, // Default to collapsed state
1347 contextLineCount: 3,
1348 minimumLineCount: 3,
1349 revealLineCount: 10,
1350 },
1351 });
Autoformatter9abf8032025-06-14 23:24:08 +00001352
David Crawshaw26f3f342025-06-14 19:58:32 +00001353 // Fit content after setting new models
1354 if (this.fitEditorToContent) {
1355 setTimeout(() => this.fitEditorToContent!(), 50);
1356 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001357
1358 // Add glyph decorations after setting new models
1359 setTimeout(() => this.setupGlyphDecorations(), 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001360 }
1361 this.setupContentChangeListener();
1362 } catch (error) {
1363 console.error("Error updating Monaco models:", error);
1364 }
1365 }
1366
philip.zeyligerc0a44592025-06-15 21:24:57 -07001367 async updated(changedProperties: Map<string, any>) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001368 // If any relevant properties changed, just update the models
1369 if (
1370 changedProperties.has("originalCode") ||
1371 changedProperties.has("modifiedCode") ||
1372 changedProperties.has("originalFilename") ||
1373 changedProperties.has("modifiedFilename") ||
1374 changedProperties.has("editableRight")
1375 ) {
1376 if (this.editor) {
1377 this.updateModels();
1378
David Crawshaw26f3f342025-06-14 19:58:32 +00001379 // Force auto-sizing after model updates
1380 // Use a slightly longer delay to ensure layout is stable
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001381 setTimeout(() => {
David Crawshaw26f3f342025-06-14 19:58:32 +00001382 if (this.fitEditorToContent) {
1383 this.fitEditorToContent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001384 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001385 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001386 } else {
1387 // If the editor isn't initialized yet but we received content,
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001388 // ensure we're connected before initializing
1389 await this.ensureConnectedToDocument();
philip.zeyligerc0a44592025-06-15 21:24:57 -07001390 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001391 }
1392 }
1393 }
1394
David Crawshaw26f3f342025-06-14 19:58:32 +00001395 // Set up auto-sizing for multi-file diff view
1396 private setupAutoSizing() {
1397 if (!this.editor) return;
1398
1399 const fitContent = () => {
1400 try {
1401 const originalEditor = this.editor!.getOriginalEditor();
1402 const modifiedEditor = this.editor!.getModifiedEditor();
Autoformatter9abf8032025-06-14 23:24:08 +00001403
David Crawshaw26f3f342025-06-14 19:58:32 +00001404 const originalHeight = originalEditor.getContentHeight();
1405 const modifiedHeight = modifiedEditor.getContentHeight();
Autoformatter9abf8032025-06-14 23:24:08 +00001406
David Crawshaw26f3f342025-06-14 19:58:32 +00001407 // Use the maximum height of both editors, plus some padding
1408 const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
Autoformatter9abf8032025-06-14 23:24:08 +00001409
David Crawshaw26f3f342025-06-14 19:58:32 +00001410 // Set both container and host height to enable proper scrolling
1411 if (this.container.value) {
1412 // Set explicit heights on both container and host
1413 this.container.value.style.height = `${maxHeight}px`;
1414 this.style.height = `${maxHeight}px`; // Update host element height
Autoformatter9abf8032025-06-14 23:24:08 +00001415
David Crawshaw26f3f342025-06-14 19:58:32 +00001416 // Emit the height change event BEFORE calling layout
1417 // This ensures parent containers resize first
Autoformatter9abf8032025-06-14 23:24:08 +00001418 this.dispatchEvent(
1419 new CustomEvent("monaco-height-changed", {
1420 detail: { height: maxHeight },
1421 bubbles: true,
1422 composed: true,
1423 }),
1424 );
1425
David Crawshaw26f3f342025-06-14 19:58:32 +00001426 // Layout after both this component and parents have updated
1427 setTimeout(() => {
1428 if (this.editor && this.container.value) {
1429 // Use explicit dimensions to ensure Monaco uses full available space
David Crawshawdba26b52025-06-15 00:33:45 +00001430 // Use clientWidth instead of offsetWidth to avoid border overflow
1431 const width = this.container.value.clientWidth;
David Crawshaw26f3f342025-06-14 19:58:32 +00001432 this.editor.layout({
1433 width: width,
Autoformatter9abf8032025-06-14 23:24:08 +00001434 height: maxHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +00001435 });
1436 }
1437 }, 10);
1438 }
1439 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +00001440 console.error("Error in fitContent:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +00001441 }
1442 };
1443
1444 // Store the fit function for external access
1445 this.fitEditorToContent = fitContent;
1446
1447 // Set up listeners for content size changes
1448 this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
1449 this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
1450
1451 // Initial fit
1452 fitContent();
1453 }
1454
1455 private fitEditorToContent: (() => void) | null = null;
1456
David Crawshawe2954ce2025-06-15 00:06:34 +00001457 /**
1458 * Set up window resize handler to ensure Monaco editor adapts to browser window changes
1459 */
1460 private setupWindowResizeHandler() {
1461 // Create a debounced resize handler to avoid too many layout calls
1462 let resizeTimeout: number | null = null;
Autoformatterad15b6c2025-06-15 00:29:26 +00001463
David Crawshawe2954ce2025-06-15 00:06:34 +00001464 this._windowResizeHandler = () => {
1465 // Clear any existing timeout
1466 if (resizeTimeout) {
1467 window.clearTimeout(resizeTimeout);
1468 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001469
David Crawshawe2954ce2025-06-15 00:06:34 +00001470 // Debounce the resize to avoid excessive layout calls
1471 resizeTimeout = window.setTimeout(() => {
1472 if (this.editor && this.container.value) {
1473 // Trigger layout recalculation
1474 if (this.fitEditorToContent) {
1475 this.fitEditorToContent();
1476 } else {
1477 // Fallback: just trigger a layout with current container dimensions
David Crawshawdba26b52025-06-15 00:33:45 +00001478 // Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
1479 const width = this.container.value.clientWidth;
1480 const height = this.container.value.clientHeight;
David Crawshawe2954ce2025-06-15 00:06:34 +00001481 this.editor.layout({ width, height });
1482 }
1483 }
1484 }, 100); // 100ms debounce
1485 };
Autoformatterad15b6c2025-06-15 00:29:26 +00001486
David Crawshawe2954ce2025-06-15 00:06:34 +00001487 // Add the event listener
Autoformatterad15b6c2025-06-15 00:29:26 +00001488 window.addEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001489 }
1490
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001491 // Add resize observer to ensure editor resizes when container changes
philip.zeyligerc0a44592025-06-15 21:24:57 -07001492 async firstUpdated() {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001493 // Ensure we're connected to the document before Monaco initialization
1494 await this.ensureConnectedToDocument();
1495
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001496 // Initialize the editor
philip.zeyligerc0a44592025-06-15 21:24:57 -07001497 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001498
David Crawshawe2954ce2025-06-15 00:06:34 +00001499 // Set up window resize handler to ensure Monaco editor adapts to browser window changes
1500 this.setupWindowResizeHandler();
1501
David Crawshaw26f3f342025-06-14 19:58:32 +00001502 // For multi-file diff, we don't use ResizeObserver since we control the size
1503 // Instead, we rely on auto-sizing based on content
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001504
1505 // If editable, set up edit mode and content change listener
1506 if (this.editableRight && this.editor) {
1507 // Ensure the original editor is read-only
1508 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1509 // Ensure the modified editor is editable
1510 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1511 }
1512 }
1513
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001514 /**
1515 * Ensure this component and its container are properly connected to the document.
1516 * Monaco editor requires the container to be in the document for proper initialization.
1517 */
1518 private async ensureConnectedToDocument(): Promise<void> {
1519 // Wait for our own render to complete
1520 await this.updateComplete;
1521
1522 // Verify the container ref is available
1523 if (!this.container.value) {
1524 throw new Error("Container element not available after updateComplete");
1525 }
1526
1527 // Check if we're connected to the document
1528 if (!this.isConnected) {
1529 throw new Error("Component is not connected to the document");
1530 }
1531
1532 // Verify the container is also in the document
1533 if (!this.container.value.isConnected) {
1534 throw new Error("Container element is not connected to the document");
1535 }
1536 }
1537
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001538 private _resizeObserver: ResizeObserver | null = null;
David Crawshawe2954ce2025-06-15 00:06:34 +00001539 private _windowResizeHandler: (() => void) | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001540
philip.zeyliger7351cd92025-06-14 12:25:31 -07001541 /**
1542 * Add this Monaco editor instance to the global debug object
1543 * This allows inspection and debugging via browser console
1544 */
1545 private addToDebugGlobal() {
1546 try {
1547 // Initialize the debug global if it doesn't exist
1548 if (!(window as any).sketchDebug) {
1549 (window as any).sketchDebug = {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001550 monaco: window.monaco!,
philip.zeyliger7351cd92025-06-14 12:25:31 -07001551 editors: [],
1552 remeasureFonts: () => {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001553 window.monaco!.editor.remeasureFonts();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001554 (window as any).sketchDebug.editors.forEach(
1555 (editor: any, index: number) => {
1556 if (editor && editor.layout) {
1557 editor.layout();
1558 }
1559 },
1560 );
1561 },
1562 layoutAll: () => {
1563 (window as any).sketchDebug.editors.forEach(
1564 (editor: any, index: number) => {
1565 if (editor && editor.layout) {
1566 editor.layout();
1567 }
1568 },
1569 );
1570 },
1571 getActiveEditors: () => {
1572 return (window as any).sketchDebug.editors.filter(
1573 (editor: any) => editor !== null,
1574 );
1575 },
1576 };
1577 }
1578
1579 // Add this editor to the debug collection
1580 if (this.editor) {
1581 (window as any).sketchDebug.editors.push(this.editor);
1582 }
1583 } catch (error) {
1584 console.error("Error adding Monaco editor to debug global:", error);
1585 }
1586 }
1587
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001588 disconnectedCallback() {
1589 super.disconnectedCallback();
1590
1591 try {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001592 // Remove editor from debug global before disposal
1593 if (this.editor && (window as any).sketchDebug?.editors) {
1594 const index = (window as any).sketchDebug.editors.indexOf(this.editor);
1595 if (index > -1) {
1596 (window as any).sketchDebug.editors[index] = null;
1597 }
1598 }
1599
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001600 // Clean up decorations
1601 if (this.originalDecorations) {
1602 this.originalDecorations.clear();
1603 this.originalDecorations = undefined;
1604 }
1605
1606 if (this.modifiedDecorations) {
1607 this.modifiedDecorations.clear();
1608 this.modifiedDecorations = undefined;
1609 }
1610
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001611 // Clean up resources when element is removed
1612 if (this.editor) {
1613 this.editor.dispose();
1614 this.editor = undefined;
1615 }
1616
1617 // Dispose models to prevent memory leaks
1618 if (this.originalModel) {
1619 this.originalModel.dispose();
1620 this.originalModel = undefined;
1621 }
1622
1623 if (this.modifiedModel) {
1624 this.modifiedModel.dispose();
1625 this.modifiedModel = undefined;
1626 }
1627
David Crawshaw26f3f342025-06-14 19:58:32 +00001628 // Clean up resize observer (if any)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001629 if (this._resizeObserver) {
1630 this._resizeObserver.disconnect();
1631 this._resizeObserver = null;
1632 }
Autoformatter9abf8032025-06-14 23:24:08 +00001633
David Crawshaw26f3f342025-06-14 19:58:32 +00001634 // Clear the fit function reference
1635 this.fitEditorToContent = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001636
David Crawshawe2954ce2025-06-15 00:06:34 +00001637 // Remove window resize handler if set
1638 if (this._windowResizeHandler) {
Autoformatterad15b6c2025-06-15 00:29:26 +00001639 window.removeEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001640 this._windowResizeHandler = null;
1641 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001642
1643 // Clear visible glyphs tracking
1644 this.visibleGlyphs.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001645 } catch (error) {
1646 console.error("Error in disconnectedCallback:", error);
1647 }
1648 }
1649
1650 // disconnectedCallback implementation is defined below
1651}
1652
1653declare global {
1654 interface HTMLElementTagNameMap {
1655 "sketch-monaco-view": CodeDiffEditor;
1656 }
1657}