blob: fa657f9f90a90fd89f03390bcfb68b4df8eef66e [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any, no-async-promise-executor, @typescript-eslint/ban-ts-comment */
Philip Zeyliger272a90e2025-05-16 14:49:51 -07002import { css, html, LitElement } from "lit";
3import { customElement, property, state } from "lit/decorators.js";
4import { createRef, Ref, ref } from "lit/directives/ref.js";
5
6// See https://rodydavis.com/posts/lit-monaco-editor for some ideas.
7
philip.zeyligerc0a44592025-06-15 21:24:57 -07008import type * as monaco from "monaco-editor";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07009
philip.zeyligerc0a44592025-06-15 21:24:57 -070010// Monaco is loaded dynamically - see loadMonaco() function
11declare global {
12 interface Window {
13 monaco?: typeof monaco;
14 }
15}
16
17// Monaco hash will be injected at build time
18declare const __MONACO_HASH__: string;
19
20// Load Monaco editor dynamically
21let monacoLoadPromise: Promise<any> | null = null;
22
23function loadMonaco(): Promise<typeof monaco> {
24 if (monacoLoadPromise) {
25 return monacoLoadPromise;
26 }
27
28 if (window.monaco) {
29 return Promise.resolve(window.monaco);
30 }
31
Philip Zeyliger3cde2822025-06-21 09:32:38 -070032 monacoLoadPromise = new Promise(async (resolve, reject) => {
33 try {
34 // Check if we're in development mode
35 const isDev = __MONACO_HASH__ === "dev";
Autoformatter2f8464c2025-06-16 04:27:05 +000036
Philip Zeyliger3cde2822025-06-21 09:32:38 -070037 if (isDev) {
38 // In development mode, import Monaco directly
39 const monaco = await import("monaco-editor");
40 window.monaco = monaco;
41 resolve(monaco);
philip.zeyligerc0a44592025-06-15 21:24:57 -070042 } else {
Philip Zeyliger3cde2822025-06-21 09:32:38 -070043 // In production mode, load from external bundle
44 const monacoHash = __MONACO_HASH__;
Autoformatter2f8464c2025-06-16 04:27:05 +000045
Philip Zeyliger3cde2822025-06-21 09:32:38 -070046 // Try to load the external Monaco bundle
47 const script = document.createElement("script");
48 script.onload = () => {
49 // The Monaco bundle should set window.monaco
50 if (window.monaco) {
51 resolve(window.monaco);
52 } else {
53 reject(new Error("Monaco not loaded from external bundle"));
54 }
55 };
56 script.onerror = (error) => {
57 console.warn("Failed to load external Monaco bundle:", error);
58 reject(new Error("Monaco external bundle failed to load"));
59 };
60
61 // Don't set type="module" since we're using IIFE format
62 script.src = `./static/monaco-standalone-${monacoHash}.js`;
63 document.head.appendChild(script);
64 }
65 } catch (error) {
66 reject(error);
67 }
philip.zeyligerc0a44592025-06-15 21:24:57 -070068 });
69
70 return monacoLoadPromise;
71}
Philip Zeyliger272a90e2025-05-16 14:49:51 -070072
73// Define Monaco CSS styles as a string constant
74const monacoStyles = `
75 /* Import Monaco editor styles */
76 @import url('./static/monaco/min/vs/editor/editor.main.css');
77
78 /* Codicon font is now defined globally in sketch-app-shell.css */
79
80 /* Custom Monaco styles */
81 .monaco-editor {
82 width: 100%;
83 height: 100%;
84 }
85
Philip Zeyliger272a90e2025-05-16 14:49:51 -070086 /* Ensure light theme colors */
87 .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input {
88 background-color: var(--monaco-editor-bg, #ffffff) !important;
89 }
90
91 .monaco-editor .margin {
92 background-color: var(--monaco-editor-margin, #f5f5f5) !important;
93 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -070094
95 /* Glyph decoration styles - only show on hover */
96 .comment-glyph-decoration {
97 width: 16px !important;
98 height: 18px !important;
99 cursor: pointer;
100 opacity: 0;
101 transition: opacity 0.2s ease;
102 }
103
104 .comment-glyph-decoration:before {
105 content: '💬';
106 font-size: 12px;
107 line-height: 18px;
108 width: 16px;
109 height: 18px;
110 display: block;
111 text-align: center;
112 }
113
114 .comment-glyph-decoration.hover-visible {
115 opacity: 1;
116 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700117`;
118
119// Configure Monaco to use local workers with correct relative paths
120// Monaco looks for this global configuration to determine how to load web workers
121// @ts-ignore - MonacoEnvironment is added to the global scope at runtime
122self.MonacoEnvironment = {
123 getWorkerUrl: function (_moduleId, label) {
124 if (label === "json") {
125 return "./static/json.worker.js";
126 }
127 if (label === "css" || label === "scss" || label === "less") {
128 return "./static/css.worker.js";
129 }
130 if (label === "html" || label === "handlebars" || label === "razor") {
131 return "./static/html.worker.js";
132 }
133 if (label === "typescript" || label === "javascript") {
134 return "./static/ts.worker.js";
135 }
136 return "./static/editor.worker.js";
137 },
138};
139
140@customElement("sketch-monaco-view")
141export class CodeDiffEditor extends LitElement {
142 // Editable state
143 @property({ type: Boolean, attribute: "editable-right" })
144 editableRight?: boolean;
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000145
146 // Inline diff mode (for mobile)
147 @property({ type: Boolean, attribute: "inline" })
148 inline?: boolean;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700149 private container: Ref<HTMLElement> = createRef();
150 editor?: monaco.editor.IStandaloneDiffEditor;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700151
152 // Save state properties
153 @state() private saveState: "idle" | "modified" | "saving" | "saved" = "idle";
154 @state() private debounceSaveTimeout: number | null = null;
155 @state() private lastSavedContent: string = "";
156 @property() originalCode?: string = "// Original code here";
157 @property() modifiedCode?: string = "// Modified code here";
158 @property() originalFilename?: string = "original.js";
159 @property() modifiedFilename?: string = "modified.js";
160
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700161 // Comment system state
162 @state() private showCommentBox: boolean = false;
163 @state() private commentText: string = "";
164 @state() private selectedLines: {
165 startLine: number;
166 endLine: number;
167 editorType: "original" | "modified";
168 text: string;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700169 } | null = null;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700170 @state() private commentBoxPosition: { top: number; left: number } = {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700171 top: 0,
172 left: 0,
173 };
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700174 @state() private isDragging: boolean = false;
175 @state() private dragStartLine: number | null = null;
176 @state() private dragStartEditor: "original" | "modified" | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700177
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700178 // Track visible glyphs to ensure proper cleanup
179 private visibleGlyphs: Set<string> = new Set();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700180
181 // Custom event to request save action from external components
182 private requestSave() {
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000183 if (!this.editableRight || this.saveState !== "modified") return;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700184
185 this.saveState = "saving";
186
187 // Get current content from modified editor
188 const modifiedContent = this.modifiedModel?.getValue() || "";
189
190 // Create and dispatch the save event
191 const saveEvent = new CustomEvent("monaco-save", {
192 detail: {
193 path: this.modifiedFilename,
194 content: modifiedContent,
195 },
196 bubbles: true,
197 composed: true,
198 });
199
200 this.dispatchEvent(saveEvent);
201 }
202
203 // Method to be called from parent when save is complete
204 public notifySaveComplete(success: boolean) {
205 if (success) {
206 this.saveState = "saved";
207 // Update last saved content
208 this.lastSavedContent = this.modifiedModel?.getValue() || "";
209 // Reset to idle after a delay
210 setTimeout(() => {
211 this.saveState = "idle";
212 }, 2000);
213 } else {
214 // Return to modified state on error
215 this.saveState = "modified";
216 }
217 }
218
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000219 // Rescue people with strong save-constantly habits
220 private setupKeyboardShortcuts() {
221 if (!this.editor) return;
222 const modifiedEditor = this.editor.getModifiedEditor();
223 if (!modifiedEditor) return;
224
philip.zeyligerc0a44592025-06-15 21:24:57 -0700225 const monaco = window.monaco;
226 if (!monaco) return;
Autoformatter2f8464c2025-06-16 04:27:05 +0000227
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000228 modifiedEditor.addCommand(
229 monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
230 () => {
231 this.requestSave();
Autoformatter57893c22025-05-29 13:49:53 +0000232 },
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000233 );
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000234 }
235
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700236 // Setup content change listener for debounced save
237 private setupContentChangeListener() {
238 if (!this.editor || !this.editableRight) return;
239
240 const modifiedEditor = this.editor.getModifiedEditor();
241 if (!modifiedEditor || !modifiedEditor.getModel()) return;
242
243 // Store initial content
244 this.lastSavedContent = modifiedEditor.getModel()!.getValue();
245
246 // Listen for content changes
247 modifiedEditor.getModel()!.onDidChangeContent(() => {
248 const currentContent = modifiedEditor.getModel()!.getValue();
249
250 // Check if content has actually changed from last saved state
251 if (currentContent !== this.lastSavedContent) {
252 this.saveState = "modified";
253
254 // Debounce save request
255 if (this.debounceSaveTimeout) {
256 window.clearTimeout(this.debounceSaveTimeout);
257 }
258
259 this.debounceSaveTimeout = window.setTimeout(() => {
260 this.requestSave();
261 this.debounceSaveTimeout = null;
262 }, 1000); // 1 second debounce
263 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700264
265 // Update glyph decorations when content changes
266 setTimeout(() => {
267 if (this.editor && this.modifiedModel) {
268 this.addGlyphDecorationsToEditor(
269 this.editor.getModifiedEditor(),
270 this.modifiedModel,
271 "modified",
272 );
273 }
274 }, 50);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700275 });
276 }
277
278 static styles = css`
279 /* Save indicator styles */
280 .save-indicator {
281 position: absolute;
282 top: 4px;
283 right: 4px;
284 padding: 3px 8px;
285 border-radius: 3px;
286 font-size: 12px;
287 font-family: system-ui, sans-serif;
288 color: white;
289 z-index: 100;
290 opacity: 0.9;
291 pointer-events: none;
292 transition: opacity 0.3s ease;
293 }
294
Philip Zeyligere89b3082025-05-29 03:16:06 +0000295 .save-indicator.idle {
296 background-color: #6c757d;
297 }
298
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700299 .save-indicator.modified {
300 background-color: #f0ad4e;
301 }
302
303 .save-indicator.saving {
304 background-color: #5bc0de;
305 }
306
307 .save-indicator.saved {
308 background-color: #5cb85c;
309 }
310
311 /* Editor host styles */
312 :host {
313 --editor-width: 100%;
314 --editor-height: 100%;
315 display: flex;
David Crawshaw26f3f342025-06-14 19:58:32 +0000316 flex: none; /* Don't grow/shrink - size is determined by content */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700317 min-height: 0; /* Critical for flex layout */
318 position: relative; /* Establish positioning context */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700319 width: 100%; /* Take full width */
David Crawshaw26f3f342025-06-14 19:58:32 +0000320 /* Height will be set dynamically by setupAutoSizing */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700321 }
322 main {
David Crawshaw26f3f342025-06-14 19:58:32 +0000323 width: 100%;
324 height: 100%; /* Fill the host element completely */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700325 border: 1px solid #e0e0e0;
David Crawshaw26f3f342025-06-14 19:58:32 +0000326 flex: none; /* Size determined by parent */
327 min-height: 200px; /* Ensure a minimum height for the editor */
328 /* Remove absolute positioning - use normal block layout */
329 position: relative;
330 display: block;
David Crawshawdba26b52025-06-15 00:33:45 +0000331 box-sizing: border-box; /* Include border in width calculation */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700332 }
333
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700334 /* Comment box styles */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700335 .comment-box {
336 position: fixed;
337 background-color: white;
338 border: 1px solid #ddd;
339 border-radius: 4px;
340 box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
341 padding: 12px;
342 z-index: 10001;
343 width: 350px;
344 animation: fadeIn 0.2s ease-in-out;
345 max-height: 80vh;
346 overflow-y: auto;
347 }
348
349 .comment-box-header {
350 display: flex;
351 justify-content: space-between;
352 align-items: center;
353 margin-bottom: 8px;
354 }
355
356 .comment-box-header h3 {
357 margin: 0;
358 font-size: 14px;
359 font-weight: 500;
360 }
361
362 .close-button {
363 background: none;
364 border: none;
365 cursor: pointer;
366 font-size: 16px;
367 color: #666;
368 padding: 2px 6px;
369 }
370
371 .close-button:hover {
372 color: #333;
373 }
374
375 .selected-text-preview {
376 background-color: #f5f5f5;
377 border: 1px solid #eee;
378 border-radius: 3px;
379 padding: 8px;
380 margin-bottom: 10px;
381 font-family: monospace;
382 font-size: 12px;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700383 max-height: 100px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700384 overflow-y: auto;
385 white-space: pre-wrap;
386 word-break: break-all;
387 }
388
389 .comment-textarea {
390 width: 100%;
391 min-height: 80px;
392 padding: 8px;
393 border: 1px solid #ddd;
394 border-radius: 3px;
395 resize: vertical;
396 font-family: inherit;
397 margin-bottom: 10px;
398 box-sizing: border-box;
399 }
400
401 .comment-actions {
402 display: flex;
403 justify-content: flex-end;
404 gap: 8px;
405 }
406
407 .comment-actions button {
408 padding: 6px 12px;
409 border-radius: 3px;
410 cursor: pointer;
411 font-size: 12px;
412 }
413
414 .cancel-button {
415 background-color: transparent;
416 border: 1px solid #ddd;
417 }
418
419 .cancel-button:hover {
420 background-color: #f5f5f5;
421 }
422
423 .submit-button {
424 background-color: #4285f4;
425 color: white;
426 border: none;
427 }
428
429 .submit-button:hover {
430 background-color: #3367d6;
431 }
432
433 @keyframes fadeIn {
434 from {
435 opacity: 0;
436 }
437 to {
438 opacity: 1;
439 }
440 }
441 `;
442
443 render() {
444 return html`
445 <style>
446 ${monacoStyles}
447 </style>
448 <main ${ref(this.container)}></main>
449
450 <!-- Save indicator - shown when editing -->
Philip Zeyligere89b3082025-05-29 03:16:06 +0000451 ${this.editableRight
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700452 ? html`
453 <div class="save-indicator ${this.saveState}">
Philip Zeyligere89b3082025-05-29 03:16:06 +0000454 ${this.saveState === "idle"
455 ? "Editable"
456 : this.saveState === "modified"
457 ? "Modified..."
458 : this.saveState === "saving"
459 ? "Saving..."
460 : this.saveState === "saved"
461 ? "Saved"
462 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700463 </div>
464 `
465 : ""}
466
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700467 <!-- Comment box - shown when glyph is clicked -->
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700468 ${this.showCommentBox
469 ? html`
470 <div
471 class="comment-box"
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700472 style="top: ${this.commentBoxPosition.top}px; left: ${this
473 .commentBoxPosition.left}px;"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700474 >
475 <div class="comment-box-header">
476 <h3>Add comment</h3>
477 <button class="close-button" @click="${this.closeCommentBox}">
478 ×
479 </button>
480 </div>
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700481 ${this.selectedLines
482 ? html`
483 <div class="selected-text-preview">
484 ${this.selectedLines.text}
485 </div>
486 `
487 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700488 <textarea
489 class="comment-textarea"
490 placeholder="Type your comment here..."
491 .value="${this.commentText}"
492 @input="${this.handleCommentInput}"
493 ></textarea>
494 <div class="comment-actions">
495 <button class="cancel-button" @click="${this.closeCommentBox}">
496 Cancel
497 </button>
498 <button class="submit-button" @click="${this.submitComment}">
Josh Bleecher Snyderafeafea2025-05-23 20:27:39 +0000499 Add
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700500 </button>
501 </div>
502 </div>
503 `
504 : ""}
505 `;
506 }
507
508 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700509 * Handle changes to the comment text
510 */
511 private handleCommentInput(e: Event) {
512 const target = e.target as HTMLTextAreaElement;
513 this.commentText = target.value;
514 }
515
516 /**
517 * Close the comment box
518 */
519 private closeCommentBox() {
520 this.showCommentBox = false;
521 this.commentText = "";
522 this.selectedLines = null;
523 }
524
525 /**
526 * Submit the comment
527 */
528 private submitComment() {
529 try {
530 if (!this.selectedLines || !this.commentText.trim()) {
531 return;
532 }
533
534 // Store references before closing the comment box
535 const selectedLines = this.selectedLines;
536 const commentText = this.commentText;
537
538 // Get the correct filename based on active editor
539 const fileContext =
540 selectedLines.editorType === "original"
541 ? this.originalFilename || "Original file"
542 : this.modifiedFilename || "Modified file";
543
544 // Include editor info to make it clear which version was commented on
545 const editorLabel =
546 selectedLines.editorType === "original" ? "[Original]" : "[Modified]";
547
548 // Add line number information
549 let lineInfo = "";
550 if (selectedLines.startLine === selectedLines.endLine) {
551 lineInfo = ` (line ${selectedLines.startLine})`;
552 } else {
553 lineInfo = ` (lines ${selectedLines.startLine}-${selectedLines.endLine})`;
554 }
555
556 // Format the comment in a readable way
557 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${selectedLines.text}\n\`\`\`\n\n${commentText}`;
558
559 // Close UI before dispatching to prevent interaction conflicts
560 this.closeCommentBox();
561
562 // Use setTimeout to ensure the UI has updated before sending the event
563 setTimeout(() => {
564 try {
565 // Dispatch a custom event with the comment details
566 const event = new CustomEvent("monaco-comment", {
567 detail: {
568 fileContext,
569 selectedText: selectedLines.text,
570 commentText: commentText,
571 formattedComment,
572 selectionRange: {
573 startLineNumber: selectedLines.startLine,
574 startColumn: 1,
575 endLineNumber: selectedLines.endLine,
576 endColumn: 1,
577 },
578 activeEditor: selectedLines.editorType,
579 },
580 bubbles: true,
581 composed: true,
582 });
583
584 this.dispatchEvent(event);
585 } catch (error) {
586 console.error("Error dispatching comment event:", error);
587 }
588 }, 0);
589 } catch (error) {
590 console.error("Error submitting comment:", error);
591 this.closeCommentBox();
592 }
593 }
594
595 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700596 * Calculate the optimal position for the comment box to keep it in view
597 */
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700598 private calculateCommentBoxPosition(
599 lineNumber: number,
600 editorType: "original" | "modified",
601 ): { top: number; left: number } {
602 try {
603 if (!this.editor) {
604 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700605 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700606
607 const targetEditor =
608 editorType === "original"
609 ? this.editor.getOriginalEditor()
610 : this.editor.getModifiedEditor();
611 if (!targetEditor) {
612 return { top: 100, left: 100 };
613 }
614
615 // Get position from editor
616 const position = {
617 lineNumber: lineNumber,
618 column: 1,
619 };
620
621 // Use editor's built-in method for coordinate conversion
622 const coords = targetEditor.getScrolledVisiblePosition(position);
623
624 if (coords) {
625 // Get accurate DOM position
626 const editorDomNode = targetEditor.getDomNode();
627 if (editorDomNode) {
628 const editorRect = editorDomNode.getBoundingClientRect();
629
630 // Calculate the actual screen position
631 let screenLeft = editorRect.left + coords.left + 20; // Offset to the right
632 let screenTop = editorRect.top + coords.top;
633
634 // Get viewport dimensions
635 const viewportWidth = window.innerWidth;
636 const viewportHeight = window.innerHeight;
637
638 // Estimated box dimensions
639 const boxWidth = 350;
640 const boxHeight = 300;
641
642 // Check if box would go off the right edge
643 if (screenLeft + boxWidth > viewportWidth) {
644 screenLeft = viewportWidth - boxWidth - 20; // Keep 20px margin
645 }
646
647 // Check if box would go off the bottom
648 if (screenTop + boxHeight > viewportHeight) {
649 screenTop = Math.max(10, viewportHeight - boxHeight - 10);
650 }
651
652 // Ensure box is never positioned off-screen
653 screenTop = Math.max(10, screenTop);
654 screenLeft = Math.max(10, screenLeft);
655
656 return { top: screenTop, left: screenLeft };
657 }
658 }
659 } catch (error) {
660 console.error("Error calculating comment box position:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700661 }
662
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700663 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700664 }
665
666 setOriginalCode(code: string, filename?: string) {
667 this.originalCode = code;
668 if (filename) {
669 this.originalFilename = filename;
670 }
671
672 // Update the model if the editor is initialized
673 if (this.editor) {
674 const model = this.editor.getOriginalEditor().getModel();
675 if (model) {
676 model.setValue(code);
677 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700678 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700679 model,
680 this.getLanguageForFile(filename),
681 );
682 }
683 }
684 }
685 }
686
687 setModifiedCode(code: string, filename?: string) {
688 this.modifiedCode = code;
689 if (filename) {
690 this.modifiedFilename = filename;
691 }
692
693 // Update the model if the editor is initialized
694 if (this.editor) {
695 const model = this.editor.getModifiedEditor().getModel();
696 if (model) {
697 model.setValue(code);
698 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700699 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700700 model,
701 this.getLanguageForFile(filename),
702 );
703 }
704 }
705 }
706 }
707
Philip Zeyliger70273072025-05-28 18:26:14 +0000708 private _extensionToLanguageMap: Map<string, string> | null = null;
709
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700710 private getLanguageForFile(filename: string): string {
Philip Zeyliger70273072025-05-28 18:26:14 +0000711 // Get the file extension (including the dot for exact matching)
712 const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
713
714 // Build the extension-to-language map on first use
715 if (!this._extensionToLanguageMap) {
716 this._extensionToLanguageMap = new Map();
philip.zeyligerc0a44592025-06-15 21:24:57 -0700717 const languages = window.monaco!.languages.getLanguages();
Philip Zeyliger70273072025-05-28 18:26:14 +0000718
719 for (const language of languages) {
720 if (language.extensions) {
721 for (const ext of language.extensions) {
722 // Monaco extensions already include the dot, so use them directly
723 this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
724 }
725 }
726 }
727 }
728
729 return this._extensionToLanguageMap.get(extension) || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700730 }
731
732 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700733 * Setup glyph decorations for both editors
734 */
735 private setupGlyphDecorations() {
736 if (!this.editor || !window.monaco) {
737 return;
738 }
739
740 const originalEditor = this.editor.getOriginalEditor();
741 const modifiedEditor = this.editor.getModifiedEditor();
742
743 if (originalEditor && this.originalModel) {
744 this.addGlyphDecorationsToEditor(
745 originalEditor,
746 this.originalModel,
747 "original",
748 );
749 this.setupHoverBehavior(originalEditor);
750 }
751
752 if (modifiedEditor && this.modifiedModel) {
753 this.addGlyphDecorationsToEditor(
754 modifiedEditor,
755 this.modifiedModel,
756 "modified",
757 );
758 this.setupHoverBehavior(modifiedEditor);
759 }
760 }
761
762 /**
763 * Add glyph decorations to a specific editor
764 */
765 private addGlyphDecorationsToEditor(
766 editor: monaco.editor.IStandaloneCodeEditor,
767 model: monaco.editor.ITextModel,
768 editorType: "original" | "modified",
769 ) {
770 if (!window.monaco) {
771 return;
772 }
773
774 // Clear existing decorations
775 if (editorType === "original" && this.originalDecorations) {
776 this.originalDecorations.clear();
777 } else if (editorType === "modified" && this.modifiedDecorations) {
778 this.modifiedDecorations.clear();
779 }
780
781 // Create decorations for every line
782 const lineCount = model.getLineCount();
783 const decorations: monaco.editor.IModelDeltaDecoration[] = [];
784
785 for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
786 decorations.push({
787 range: new window.monaco.Range(lineNumber, 1, lineNumber, 1),
788 options: {
789 isWholeLine: false,
790 glyphMarginClassName: `comment-glyph-decoration comment-glyph-${editorType}-${lineNumber}`,
791 glyphMarginHoverMessage: { value: "Comment line" },
792 stickiness:
793 window.monaco.editor.TrackedRangeStickiness
794 .NeverGrowsWhenTypingAtEdges,
795 },
796 });
797 }
798
799 // Create or update decorations collection
800 if (editorType === "original") {
801 this.originalDecorations =
802 editor.createDecorationsCollection(decorations);
803 } else {
804 this.modifiedDecorations =
805 editor.createDecorationsCollection(decorations);
806 }
807 }
808
809 /**
810 * Setup hover and click behavior for glyph decorations
811 */
812 private setupHoverBehavior(editor: monaco.editor.IStandaloneCodeEditor) {
813 if (!editor) {
814 return;
815 }
816
817 let currentHoveredLine: number | null = null;
818 const editorType =
819 this.editor?.getOriginalEditor() === editor ? "original" : "modified";
820
821 // Listen for mouse move events in the editor
822 editor.onMouseMove((e) => {
823 if (e.target.position) {
824 const lineNumber = e.target.position.lineNumber;
825
826 // Handle real-time drag preview updates
827 if (
828 this.isDragging &&
829 this.dragStartLine !== null &&
830 this.dragStartEditor === editorType &&
831 this.showCommentBox
832 ) {
833 const startLine = Math.min(this.dragStartLine, lineNumber);
834 const endLine = Math.max(this.dragStartLine, lineNumber);
835 this.updateSelectedLinesPreview(startLine, endLine, editorType);
836 }
837
838 // Handle hover glyph visibility (only when not dragging)
839 if (!this.isDragging) {
840 // If we're hovering over a different line, update visibility
841 if (currentHoveredLine !== lineNumber) {
842 // Hide previous line's glyph
843 if (currentHoveredLine !== null) {
844 this.toggleGlyphVisibility(currentHoveredLine, false);
845 }
846
847 // Show current line's glyph
848 this.toggleGlyphVisibility(lineNumber, true);
849 currentHoveredLine = lineNumber;
850 }
851 }
852 }
853 });
854
855 // Listen for mouse down events for click-to-comment and drag selection
856 editor.onMouseDown((e) => {
857 if (
858 e.target.type ===
859 window.monaco?.editor.MouseTargetType.GUTTER_GLYPH_MARGIN
860 ) {
861 if (e.target.position) {
862 const lineNumber = e.target.position.lineNumber;
863
864 // Prevent default Monaco behavior
865 e.event.preventDefault();
866 e.event.stopPropagation();
867
868 // Check if there's an existing selection in this editor
869 const selection = editor.getSelection();
870 if (selection && !selection.isEmpty()) {
871 // Use the existing selection
872 const startLine = selection.startLineNumber;
873 const endLine = selection.endLineNumber;
874 this.showCommentForSelection(
875 startLine,
876 endLine,
877 editorType,
878 selection,
879 );
880 } else {
881 // Start drag selection or show comment for clicked line
882 this.isDragging = true;
883 this.dragStartLine = lineNumber;
884 this.dragStartEditor = editorType;
885
886 // If it's just a click (not drag), show comment box immediately
887 this.showCommentForLines(lineNumber, lineNumber, editorType);
888 }
889 }
890 }
891 });
892
893 // Listen for mouse up events to end drag selection
894 editor.onMouseUp((e) => {
895 if (this.isDragging) {
896 if (
897 e.target.position &&
898 this.dragStartLine !== null &&
899 this.dragStartEditor === editorType
900 ) {
901 const endLine = e.target.position.lineNumber;
902 const startLine = Math.min(this.dragStartLine, endLine);
903 const finalEndLine = Math.max(this.dragStartLine, endLine);
904
905 // Update the final selection (if comment box is not already shown)
906 if (!this.showCommentBox) {
907 this.showCommentForLines(startLine, finalEndLine, editorType);
908 } else {
909 // Just update the final selection since preview was already being updated
910 this.updateSelectedLinesPreview(
911 startLine,
912 finalEndLine,
913 editorType,
914 );
915 }
916 }
917
918 // Reset drag state
919 this.isDragging = false;
920 this.dragStartLine = null;
921 this.dragStartEditor = null;
922 }
923 });
924
925 // // Listen for mouse leave events
926 // editor.onMouseLeave(() => {
927 // if (currentHoveredLine !== null) {
928 // this.toggleGlyphVisibility(currentHoveredLine, false);
929 // currentHoveredLine = null;
930 // }
931 // });
932 }
933
934 /**
935 * Update the selected lines preview during drag operations
936 */
937 private updateSelectedLinesPreview(
938 startLine: number,
939 endLine: number,
940 editorType: "original" | "modified",
941 ) {
942 try {
943 if (!this.editor) {
944 return;
945 }
946
947 const targetModel =
948 editorType === "original" ? this.originalModel : this.modifiedModel;
949
950 if (!targetModel) {
951 return;
952 }
953
954 // Get the text for the selected lines
955 const lines: string[] = [];
956 for (let i = startLine; i <= endLine; i++) {
957 if (i <= targetModel.getLineCount()) {
958 lines.push(targetModel.getLineContent(i));
959 }
960 }
961
962 const selectedText = lines.join("\n");
963
964 // Update the selected lines state
965 this.selectedLines = {
966 startLine,
967 endLine,
968 editorType,
969 text: selectedText,
970 };
971
972 // Request update to refresh the preview
973 this.requestUpdate();
974 } catch (error) {
975 console.error("Error updating selected lines preview:", error);
976 }
977 }
978
979 /**
980 * Show comment box for a Monaco editor selection
981 */
982 private showCommentForSelection(
983 startLine: number,
984 endLine: number,
985 editorType: "original" | "modified",
986 selection: monaco.Selection,
987 ) {
988 try {
989 if (!this.editor) {
990 return;
991 }
992
993 const targetModel =
994 editorType === "original" ? this.originalModel : this.modifiedModel;
995
996 if (!targetModel) {
997 return;
998 }
999
1000 // Get the exact selected text from the Monaco selection
1001 const selectedText = targetModel.getValueInRange(selection);
1002
1003 // Set the selected lines state
1004 this.selectedLines = {
1005 startLine,
1006 endLine,
1007 editorType,
1008 text: selectedText,
1009 };
1010
1011 // Calculate and set comment box position
1012 this.commentBoxPosition = this.calculateCommentBoxPosition(
1013 startLine,
1014 editorType,
1015 );
1016
1017 // Reset comment text and show the box
1018 this.commentText = "";
1019 this.showCommentBox = true;
1020
1021 // Clear any visible glyphs since we're showing the comment box
1022 this.clearAllVisibleGlyphs();
1023
1024 // Request update to render the comment box
1025 this.requestUpdate();
1026 } catch (error) {
1027 console.error("Error showing comment for selection:", error);
1028 }
1029 }
1030
1031 /**
1032 * Show comment box for a range of lines
1033 */
1034 private showCommentForLines(
1035 startLine: number,
1036 endLine: number,
1037 editorType: "original" | "modified",
1038 ) {
1039 try {
1040 if (!this.editor) {
1041 return;
1042 }
1043
1044 const targetEditor =
1045 editorType === "original"
1046 ? this.editor.getOriginalEditor()
1047 : this.editor.getModifiedEditor();
1048 const targetModel =
1049 editorType === "original" ? this.originalModel : this.modifiedModel;
1050
1051 if (!targetEditor || !targetModel) {
1052 return;
1053 }
1054
1055 // Get the text for the selected lines
1056 const lines: string[] = [];
1057 for (let i = startLine; i <= endLine; i++) {
1058 if (i <= targetModel.getLineCount()) {
1059 lines.push(targetModel.getLineContent(i));
1060 }
1061 }
1062
1063 const selectedText = lines.join("\n");
1064
1065 // Set the selected lines state
1066 this.selectedLines = {
1067 startLine,
1068 endLine,
1069 editorType,
1070 text: selectedText,
1071 };
1072
1073 // Calculate and set comment box position
1074 this.commentBoxPosition = this.calculateCommentBoxPosition(
1075 startLine,
1076 editorType,
1077 );
1078
1079 // Reset comment text and show the box
1080 this.commentText = "";
1081 this.showCommentBox = true;
1082
1083 // Clear any visible glyphs since we're showing the comment box
1084 this.clearAllVisibleGlyphs();
1085
1086 // Request update to render the comment box
1087 this.requestUpdate();
1088 } catch (error) {
1089 console.error("Error showing comment for lines:", error);
1090 }
1091 }
1092
1093 /**
1094 * Clear all currently visible glyphs
1095 */
1096 private clearAllVisibleGlyphs() {
1097 try {
1098 this.visibleGlyphs.forEach((glyphId) => {
1099 const element = this.container.value?.querySelector(`.${glyphId}`);
1100 if (element) {
1101 element.classList.remove("hover-visible");
1102 }
1103 });
1104 this.visibleGlyphs.clear();
1105 } catch (error) {
1106 console.error("Error clearing visible glyphs:", error);
1107 }
1108 }
1109
1110 /**
1111 * Toggle the visibility of a glyph decoration for a specific line
1112 */
1113 private toggleGlyphVisibility(lineNumber: number, visible: boolean) {
1114 try {
1115 // If making visible, clear all existing visible glyphs first
1116 if (visible) {
1117 this.clearAllVisibleGlyphs();
1118 }
1119
1120 // Find all glyph decorations for this line in both editors
1121 const selectors = [
1122 `comment-glyph-original-${lineNumber}`,
1123 `comment-glyph-modified-${lineNumber}`,
1124 ];
1125
1126 selectors.forEach((glyphId) => {
1127 const element = this.container.value?.querySelector(`.${glyphId}`);
1128 if (element) {
1129 if (visible) {
1130 element.classList.add("hover-visible");
1131 this.visibleGlyphs.add(glyphId);
1132 } else {
1133 element.classList.remove("hover-visible");
1134 this.visibleGlyphs.delete(glyphId);
1135 }
1136 }
1137 });
1138 } catch (error) {
1139 console.error("Error toggling glyph visibility:", error);
1140 }
1141 }
1142
1143 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001144 * Update editor options
1145 */
1146 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
1147 if (this.editor) {
1148 this.editor.updateOptions(value);
Philip Zeyliger0635c772025-06-25 12:01:16 -07001149 // Re-fit content after options change with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001150 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001151 setTimeout(() => {
1152 // Preserve scroll positions during options change
1153 const originalScrollTop =
1154 this.editor!.getOriginalEditor().getScrollTop();
1155 const modifiedScrollTop =
1156 this.editor!.getModifiedEditor().getScrollTop();
1157
1158 this.fitEditorToContent!();
1159
1160 // Restore scroll positions
1161 requestAnimationFrame(() => {
1162 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1163 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1164 });
1165 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001166 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001167 }
1168 }
1169
1170 /**
1171 * Toggle hideUnchangedRegions feature
1172 */
1173 toggleHideUnchangedRegions(enabled: boolean) {
1174 if (this.editor) {
1175 this.editor.updateOptions({
1176 hideUnchangedRegions: {
1177 enabled: enabled,
1178 contextLineCount: 3,
1179 minimumLineCount: 3,
1180 revealLineCount: 10,
1181 },
1182 });
Philip Zeyliger0635c772025-06-25 12:01:16 -07001183 // Re-fit content after toggling with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001184 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001185 setTimeout(() => {
1186 // Preserve scroll positions during toggle
1187 const originalScrollTop =
1188 this.editor!.getOriginalEditor().getScrollTop();
1189 const modifiedScrollTop =
1190 this.editor!.getModifiedEditor().getScrollTop();
1191
1192 this.fitEditorToContent!();
1193
1194 // Restore scroll positions
1195 requestAnimationFrame(() => {
1196 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1197 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1198 });
1199 }, 100);
David Crawshaw26f3f342025-06-14 19:58:32 +00001200 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001201 }
1202 }
1203
1204 // Models for the editor
1205 private originalModel?: monaco.editor.ITextModel;
1206 private modifiedModel?: monaco.editor.ITextModel;
1207
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001208 // Decoration collections for glyph decorations
1209 private originalDecorations?: monaco.editor.IEditorDecorationsCollection;
1210 private modifiedDecorations?: monaco.editor.IEditorDecorationsCollection;
1211
philip.zeyligerc0a44592025-06-15 21:24:57 -07001212 private async initializeEditor() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001213 try {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001214 // Load Monaco dynamically
1215 const monaco = await loadMonaco();
Autoformatter2f8464c2025-06-16 04:27:05 +00001216
philip.zeyliger7351cd92025-06-14 12:25:31 -07001217 // Disable semantic validation globally for TypeScript/JavaScript if available
1218 if (monaco.languages && monaco.languages.typescript) {
1219 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
1220 noSemanticValidation: true,
1221 });
1222 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
1223 noSemanticValidation: true,
1224 });
1225 }
Autoformatter8c463622025-05-16 21:54:17 +00001226
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001227 // First time initialization
1228 if (!this.editor) {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001229 // Ensure the container ref is available
1230 if (!this.container.value) {
1231 throw new Error(
1232 "Container element not available - component may not be fully rendered",
1233 );
1234 }
1235
David Crawshaw26f3f342025-06-14 19:58:32 +00001236 // Create the diff editor with auto-sizing configuration
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001237 this.editor = monaco.editor.createDiffEditor(this.container.value, {
David Crawshaw26f3f342025-06-14 19:58:32 +00001238 automaticLayout: false, // We'll resize manually
Autoformatter8c463622025-05-16 21:54:17 +00001239 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001240 theme: "vs", // Always use light mode
philip.zeyliger6b8b7662025-06-16 03:06:30 +00001241 renderSideBySide: !this.inline,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001242 ignoreTrimWhitespace: false,
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001243 // Enable glyph margin for both editors to show decorations
1244 glyphMargin: true,
David Crawshaw26f3f342025-06-14 19:58:32 +00001245 scrollbar: {
Philip Zeyligere0860932025-06-18 13:01:17 -07001246 // Ideally we'd handle the mouse wheel for the horizontal scrollbar,
1247 // but there doesn't seem to be that option. Setting
1248 // alwaysConsumeMousewheel false and handleMouseWheel true didn't
1249 // work for me.
1250 handleMouseWheel: false,
David Crawshaw26f3f342025-06-14 19:58:32 +00001251 },
Philip Zeyligere0860932025-06-18 13:01:17 -07001252 renderOverviewRuler: false, // Disable overview ruler
David Crawshaw26f3f342025-06-14 19:58:32 +00001253 scrollBeyondLastLine: false,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001254 // Focus on the differences by hiding unchanged regions
1255 hideUnchangedRegions: {
1256 enabled: true, // Enable the feature
Philip Zeyligere0860932025-06-18 13:01:17 -07001257 contextLineCount: 5, // Show 3 lines of context around each difference
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001258 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
1259 revealLineCount: 10, // Show 10 lines when expanding a hidden region
1260 },
1261 });
1262
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +00001263 this.setupKeyboardShortcuts();
1264
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001265 // If this is an editable view, set the correct read-only state for each editor
1266 if (this.editableRight) {
1267 // Make sure the original editor is always read-only
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001268 this.editor
1269 .getOriginalEditor()
1270 .updateOptions({ readOnly: true, glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001271 // Make sure the modified editor is editable
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001272 this.editor
1273 .getModifiedEditor()
1274 .updateOptions({ readOnly: false, glyphMargin: true });
1275 } else {
1276 // Ensure glyph margin is enabled on both editors even in read-only mode
1277 this.editor.getOriginalEditor().updateOptions({ glyphMargin: true });
1278 this.editor.getModifiedEditor().updateOptions({ glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001279 }
philip.zeyliger7351cd92025-06-14 12:25:31 -07001280
David Crawshaw26f3f342025-06-14 19:58:32 +00001281 // Set up auto-sizing
1282 this.setupAutoSizing();
1283
philip.zeyliger7351cd92025-06-14 12:25:31 -07001284 // Add Monaco editor to debug global
1285 this.addToDebugGlobal();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001286 }
1287
1288 // Create or update models
1289 this.updateModels();
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001290 // Add glyph decorations after models are set
1291 this.setupGlyphDecorations();
Autoformatter8c463622025-05-16 21:54:17 +00001292 // Set up content change listener
1293 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001294
philip.zeyliger7351cd92025-06-14 12:25:31 -07001295 // Fix cursor positioning issues by ensuring fonts are loaded
philip.zeyliger7351cd92025-06-14 12:25:31 -07001296 document.fonts.ready.then(() => {
1297 if (this.editor) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001298 // Preserve scroll positions during font remeasuring
1299 const originalScrollTop = this.editor
1300 .getOriginalEditor()
1301 .getScrollTop();
1302 const modifiedScrollTop = this.editor
1303 .getModifiedEditor()
1304 .getScrollTop();
1305
philip.zeyliger7351cd92025-06-14 12:25:31 -07001306 monaco.editor.remeasureFonts();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001307
1308 if (this.fitEditorToContent) {
1309 this.fitEditorToContent();
1310 }
1311
1312 // Restore scroll positions after font remeasuring
1313 requestAnimationFrame(() => {
1314 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1315 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1316 });
philip.zeyliger7351cd92025-06-14 12:25:31 -07001317 }
1318 });
1319
Philip Zeyliger0635c772025-06-25 12:01:16 -07001320 // Force layout recalculation after a short delay with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001321 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001322 if (this.editor && this.fitEditorToContent) {
1323 // Preserve scroll positions
1324 const originalScrollTop = this.editor
1325 .getOriginalEditor()
1326 .getScrollTop();
1327 const modifiedScrollTop = this.editor
1328 .getModifiedEditor()
1329 .getScrollTop();
1330
David Crawshaw26f3f342025-06-14 19:58:32 +00001331 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001332
1333 // Restore scroll positions
1334 requestAnimationFrame(() => {
1335 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1336 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1337 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001338 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001339 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001340 } catch (error) {
1341 console.error("Error initializing Monaco editor:", error);
1342 }
1343 }
1344
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001345 private updateModels() {
1346 try {
1347 // Get language based on filename
1348 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1349 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1350
1351 // Always create new models with unique URIs based on timestamp to avoid conflicts
1352 const timestamp = new Date().getTime();
1353 // TODO: Could put filename in these URIs; unclear how they're used right now.
philip.zeyligerc0a44592025-06-15 21:24:57 -07001354 const originalUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001355 `file:///original-${timestamp}.${originalLang}`,
1356 );
philip.zeyligerc0a44592025-06-15 21:24:57 -07001357 const modifiedUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001358 `file:///modified-${timestamp}.${modifiedLang}`,
1359 );
1360
1361 // Store references to old models
1362 const oldOriginalModel = this.originalModel;
1363 const oldModifiedModel = this.modifiedModel;
1364
1365 // Nullify instance variables to prevent accidental use
1366 this.originalModel = undefined;
1367 this.modifiedModel = undefined;
1368
1369 // Clear the editor model first to release Monaco's internal references
1370 if (this.editor) {
1371 this.editor.setModel(null);
1372 }
1373
1374 // Now it's safe to dispose the old models
1375 if (oldOriginalModel) {
1376 oldOriginalModel.dispose();
1377 }
1378
1379 if (oldModifiedModel) {
1380 oldModifiedModel.dispose();
1381 }
1382
1383 // Create new models
philip.zeyligerc0a44592025-06-15 21:24:57 -07001384 this.originalModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001385 this.originalCode || "",
1386 originalLang,
1387 originalUri,
1388 );
1389
philip.zeyligerc0a44592025-06-15 21:24:57 -07001390 this.modifiedModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001391 this.modifiedCode || "",
1392 modifiedLang,
1393 modifiedUri,
1394 );
1395
1396 // Set the new models on the editor
1397 if (this.editor) {
1398 this.editor.setModel({
1399 original: this.originalModel,
1400 modified: this.modifiedModel,
1401 });
Autoformatter9abf8032025-06-14 23:24:08 +00001402
David Crawshaw26f3f342025-06-14 19:58:32 +00001403 // Set initial hideUnchangedRegions state (default to enabled/collapsed)
1404 this.editor.updateOptions({
1405 hideUnchangedRegions: {
1406 enabled: true, // Default to collapsed state
1407 contextLineCount: 3,
1408 minimumLineCount: 3,
1409 revealLineCount: 10,
1410 },
1411 });
Autoformatter9abf8032025-06-14 23:24:08 +00001412
Philip Zeyliger0635c772025-06-25 12:01:16 -07001413 // Fit content after setting new models with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001414 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001415 setTimeout(() => {
1416 // Preserve scroll positions when fitting content after model changes
1417 const originalScrollTop =
1418 this.editor!.getOriginalEditor().getScrollTop();
1419 const modifiedScrollTop =
1420 this.editor!.getModifiedEditor().getScrollTop();
1421
1422 this.fitEditorToContent!();
1423
1424 // Restore scroll positions
1425 requestAnimationFrame(() => {
1426 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1427 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1428 });
1429 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001430 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001431
1432 // Add glyph decorations after setting new models
1433 setTimeout(() => this.setupGlyphDecorations(), 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001434 }
1435 this.setupContentChangeListener();
1436 } catch (error) {
1437 console.error("Error updating Monaco models:", error);
1438 }
1439 }
1440
philip.zeyligerc0a44592025-06-15 21:24:57 -07001441 async updated(changedProperties: Map<string, any>) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001442 // If any relevant properties changed, just update the models
1443 if (
1444 changedProperties.has("originalCode") ||
1445 changedProperties.has("modifiedCode") ||
1446 changedProperties.has("originalFilename") ||
1447 changedProperties.has("modifiedFilename") ||
1448 changedProperties.has("editableRight")
1449 ) {
1450 if (this.editor) {
1451 this.updateModels();
1452
David Crawshaw26f3f342025-06-14 19:58:32 +00001453 // Force auto-sizing after model updates
Philip Zeyliger0635c772025-06-25 12:01:16 -07001454 // Use a slightly longer delay to ensure layout is stable with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001455 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001456 if (this.fitEditorToContent && this.editor) {
1457 // Preserve scroll positions during model update layout
1458 const originalScrollTop = this.editor
1459 .getOriginalEditor()
1460 .getScrollTop();
1461 const modifiedScrollTop = this.editor
1462 .getModifiedEditor()
1463 .getScrollTop();
1464
David Crawshaw26f3f342025-06-14 19:58:32 +00001465 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001466
1467 // Restore scroll positions
1468 requestAnimationFrame(() => {
1469 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1470 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1471 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001472 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001473 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001474 } else {
1475 // If the editor isn't initialized yet but we received content,
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001476 // ensure we're connected before initializing
1477 await this.ensureConnectedToDocument();
philip.zeyligerc0a44592025-06-15 21:24:57 -07001478 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001479 }
1480 }
1481 }
1482
David Crawshaw26f3f342025-06-14 19:58:32 +00001483 // Set up auto-sizing for multi-file diff view
1484 private setupAutoSizing() {
1485 if (!this.editor) return;
1486
1487 const fitContent = () => {
1488 try {
1489 const originalEditor = this.editor!.getOriginalEditor();
1490 const modifiedEditor = this.editor!.getModifiedEditor();
Autoformatter9abf8032025-06-14 23:24:08 +00001491
David Crawshaw26f3f342025-06-14 19:58:32 +00001492 const originalHeight = originalEditor.getContentHeight();
1493 const modifiedHeight = modifiedEditor.getContentHeight();
Autoformatter9abf8032025-06-14 23:24:08 +00001494
David Crawshaw26f3f342025-06-14 19:58:32 +00001495 // Use the maximum height of both editors, plus some padding
1496 const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
Autoformatter9abf8032025-06-14 23:24:08 +00001497
David Crawshaw26f3f342025-06-14 19:58:32 +00001498 // Set both container and host height to enable proper scrolling
1499 if (this.container.value) {
1500 // Set explicit heights on both container and host
1501 this.container.value.style.height = `${maxHeight}px`;
1502 this.style.height = `${maxHeight}px`; // Update host element height
Autoformatter9abf8032025-06-14 23:24:08 +00001503
David Crawshaw26f3f342025-06-14 19:58:32 +00001504 // Emit the height change event BEFORE calling layout
1505 // This ensures parent containers resize first
Autoformatter9abf8032025-06-14 23:24:08 +00001506 this.dispatchEvent(
1507 new CustomEvent("monaco-height-changed", {
1508 detail: { height: maxHeight },
1509 bubbles: true,
1510 composed: true,
1511 }),
1512 );
1513
David Crawshaw26f3f342025-06-14 19:58:32 +00001514 // Layout after both this component and parents have updated
1515 setTimeout(() => {
1516 if (this.editor && this.container.value) {
1517 // Use explicit dimensions to ensure Monaco uses full available space
David Crawshawdba26b52025-06-15 00:33:45 +00001518 // Use clientWidth instead of offsetWidth to avoid border overflow
1519 const width = this.container.value.clientWidth;
David Crawshaw26f3f342025-06-14 19:58:32 +00001520 this.editor.layout({
1521 width: width,
Autoformatter9abf8032025-06-14 23:24:08 +00001522 height: maxHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +00001523 });
1524 }
1525 }, 10);
1526 }
1527 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +00001528 console.error("Error in fitContent:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +00001529 }
1530 };
1531
1532 // Store the fit function for external access
1533 this.fitEditorToContent = fitContent;
1534
1535 // Set up listeners for content size changes
1536 this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
1537 this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
1538
1539 // Initial fit
1540 fitContent();
1541 }
1542
1543 private fitEditorToContent: (() => void) | null = null;
1544
David Crawshawe2954ce2025-06-15 00:06:34 +00001545 /**
1546 * Set up window resize handler to ensure Monaco editor adapts to browser window changes
1547 */
1548 private setupWindowResizeHandler() {
1549 // Create a debounced resize handler to avoid too many layout calls
1550 let resizeTimeout: number | null = null;
Autoformatterad15b6c2025-06-15 00:29:26 +00001551
David Crawshawe2954ce2025-06-15 00:06:34 +00001552 this._windowResizeHandler = () => {
1553 // Clear any existing timeout
1554 if (resizeTimeout) {
1555 window.clearTimeout(resizeTimeout);
1556 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001557
David Crawshawe2954ce2025-06-15 00:06:34 +00001558 // Debounce the resize to avoid excessive layout calls
1559 resizeTimeout = window.setTimeout(() => {
1560 if (this.editor && this.container.value) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001561 // Trigger layout recalculation with scroll preservation
David Crawshawe2954ce2025-06-15 00:06:34 +00001562 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001563 // Preserve scroll positions during window resize
1564 const originalScrollTop = this.editor
1565 .getOriginalEditor()
1566 .getScrollTop();
1567 const modifiedScrollTop = this.editor
1568 .getModifiedEditor()
1569 .getScrollTop();
1570
David Crawshawe2954ce2025-06-15 00:06:34 +00001571 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001572
1573 // Restore scroll positions
1574 requestAnimationFrame(() => {
1575 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1576 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1577 });
David Crawshawe2954ce2025-06-15 00:06:34 +00001578 } else {
1579 // Fallback: just trigger a layout with current container dimensions
David Crawshawdba26b52025-06-15 00:33:45 +00001580 // Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
1581 const width = this.container.value.clientWidth;
1582 const height = this.container.value.clientHeight;
David Crawshawe2954ce2025-06-15 00:06:34 +00001583 this.editor.layout({ width, height });
1584 }
1585 }
1586 }, 100); // 100ms debounce
1587 };
Autoformatterad15b6c2025-06-15 00:29:26 +00001588
David Crawshawe2954ce2025-06-15 00:06:34 +00001589 // Add the event listener
Autoformatterad15b6c2025-06-15 00:29:26 +00001590 window.addEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001591 }
1592
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001593 // Add resize observer to ensure editor resizes when container changes
philip.zeyligerc0a44592025-06-15 21:24:57 -07001594 async firstUpdated() {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001595 // Ensure we're connected to the document before Monaco initialization
1596 await this.ensureConnectedToDocument();
1597
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001598 // Initialize the editor
philip.zeyligerc0a44592025-06-15 21:24:57 -07001599 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001600
David Crawshawe2954ce2025-06-15 00:06:34 +00001601 // Set up window resize handler to ensure Monaco editor adapts to browser window changes
1602 this.setupWindowResizeHandler();
1603
David Crawshaw26f3f342025-06-14 19:58:32 +00001604 // For multi-file diff, we don't use ResizeObserver since we control the size
1605 // Instead, we rely on auto-sizing based on content
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001606
1607 // If editable, set up edit mode and content change listener
1608 if (this.editableRight && this.editor) {
1609 // Ensure the original editor is read-only
1610 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1611 // Ensure the modified editor is editable
1612 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1613 }
1614 }
1615
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001616 /**
1617 * Ensure this component and its container are properly connected to the document.
1618 * Monaco editor requires the container to be in the document for proper initialization.
1619 */
1620 private async ensureConnectedToDocument(): Promise<void> {
1621 // Wait for our own render to complete
1622 await this.updateComplete;
1623
1624 // Verify the container ref is available
1625 if (!this.container.value) {
1626 throw new Error("Container element not available after updateComplete");
1627 }
1628
1629 // Check if we're connected to the document
1630 if (!this.isConnected) {
1631 throw new Error("Component is not connected to the document");
1632 }
1633
1634 // Verify the container is also in the document
1635 if (!this.container.value.isConnected) {
1636 throw new Error("Container element is not connected to the document");
1637 }
1638 }
1639
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001640 private _resizeObserver: ResizeObserver | null = null;
David Crawshawe2954ce2025-06-15 00:06:34 +00001641 private _windowResizeHandler: (() => void) | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001642
philip.zeyliger7351cd92025-06-14 12:25:31 -07001643 /**
1644 * Add this Monaco editor instance to the global debug object
1645 * This allows inspection and debugging via browser console
1646 */
1647 private addToDebugGlobal() {
1648 try {
1649 // Initialize the debug global if it doesn't exist
1650 if (!(window as any).sketchDebug) {
1651 (window as any).sketchDebug = {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001652 monaco: window.monaco!,
philip.zeyliger7351cd92025-06-14 12:25:31 -07001653 editors: [],
1654 remeasureFonts: () => {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001655 window.monaco!.editor.remeasureFonts();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001656 (window as any).sketchDebug.editors.forEach(
philip.zeyliger26bc6592025-06-30 20:15:30 -07001657 (editor: any, _index: number) => {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001658 if (editor && editor.layout) {
1659 editor.layout();
1660 }
1661 },
1662 );
1663 },
1664 layoutAll: () => {
1665 (window as any).sketchDebug.editors.forEach(
philip.zeyliger26bc6592025-06-30 20:15:30 -07001666 (editor: any, _index: number) => {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001667 if (editor && editor.layout) {
1668 editor.layout();
1669 }
1670 },
1671 );
1672 },
1673 getActiveEditors: () => {
1674 return (window as any).sketchDebug.editors.filter(
1675 (editor: any) => editor !== null,
1676 );
1677 },
1678 };
1679 }
1680
1681 // Add this editor to the debug collection
1682 if (this.editor) {
1683 (window as any).sketchDebug.editors.push(this.editor);
1684 }
1685 } catch (error) {
1686 console.error("Error adding Monaco editor to debug global:", error);
1687 }
1688 }
1689
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001690 disconnectedCallback() {
1691 super.disconnectedCallback();
1692
1693 try {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001694 // Remove editor from debug global before disposal
1695 if (this.editor && (window as any).sketchDebug?.editors) {
1696 const index = (window as any).sketchDebug.editors.indexOf(this.editor);
1697 if (index > -1) {
1698 (window as any).sketchDebug.editors[index] = null;
1699 }
1700 }
1701
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001702 // Clean up decorations
1703 if (this.originalDecorations) {
1704 this.originalDecorations.clear();
1705 this.originalDecorations = undefined;
1706 }
1707
1708 if (this.modifiedDecorations) {
1709 this.modifiedDecorations.clear();
1710 this.modifiedDecorations = undefined;
1711 }
1712
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001713 // Clean up resources when element is removed
1714 if (this.editor) {
1715 this.editor.dispose();
1716 this.editor = undefined;
1717 }
1718
1719 // Dispose models to prevent memory leaks
1720 if (this.originalModel) {
1721 this.originalModel.dispose();
1722 this.originalModel = undefined;
1723 }
1724
1725 if (this.modifiedModel) {
1726 this.modifiedModel.dispose();
1727 this.modifiedModel = undefined;
1728 }
1729
David Crawshaw26f3f342025-06-14 19:58:32 +00001730 // Clean up resize observer (if any)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001731 if (this._resizeObserver) {
1732 this._resizeObserver.disconnect();
1733 this._resizeObserver = null;
1734 }
Autoformatter9abf8032025-06-14 23:24:08 +00001735
David Crawshaw26f3f342025-06-14 19:58:32 +00001736 // Clear the fit function reference
1737 this.fitEditorToContent = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001738
David Crawshawe2954ce2025-06-15 00:06:34 +00001739 // Remove window resize handler if set
1740 if (this._windowResizeHandler) {
Autoformatterad15b6c2025-06-15 00:29:26 +00001741 window.removeEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001742 this._windowResizeHandler = null;
1743 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001744
1745 // Clear visible glyphs tracking
1746 this.visibleGlyphs.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001747 } catch (error) {
1748 console.error("Error in disconnectedCallback:", error);
1749 }
1750 }
1751
1752 // disconnectedCallback implementation is defined below
1753}
1754
1755declare global {
1756 interface HTMLElementTagNameMap {
1757 "sketch-monaco-view": CodeDiffEditor;
1758 }
1759}