blob: 1e558f852eec71996e784e8cfad34fe6a256e804 [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 */
bankseand8eb4642025-07-21 03:04:52 +00002import { html } from "lit";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07003import { customElement, property, state } from "lit/decorators.js";
4import { createRef, Ref, ref } from "lit/directives/ref.js";
bankseand8eb4642025-07-21 03:04:52 +00005import { SketchTailwindElement } from "./sketch-tailwind-element.js";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07006
7// See https://rodydavis.com/posts/lit-monaco-editor for some ideas.
8
philip.zeyligerc0a44592025-06-15 21:24:57 -07009import type * as monaco from "monaco-editor";
Philip Zeyliger272a90e2025-05-16 14:49:51 -070010
philip.zeyligerc0a44592025-06-15 21:24:57 -070011// Monaco is loaded dynamically - see loadMonaco() function
12declare global {
13 interface Window {
14 monaco?: typeof monaco;
15 }
16}
17
18// Monaco hash will be injected at build time
19declare const __MONACO_HASH__: string;
20
21// Load Monaco editor dynamically
22let monacoLoadPromise: Promise<any> | null = null;
23
24function loadMonaco(): Promise<typeof monaco> {
25 if (monacoLoadPromise) {
26 return monacoLoadPromise;
27 }
28
29 if (window.monaco) {
30 return Promise.resolve(window.monaco);
31 }
32
Philip Zeyliger3cde2822025-06-21 09:32:38 -070033 monacoLoadPromise = new Promise(async (resolve, reject) => {
34 try {
35 // Check if we're in development mode
36 const isDev = __MONACO_HASH__ === "dev";
Autoformatter2f8464c2025-06-16 04:27:05 +000037
Philip Zeyliger3cde2822025-06-21 09:32:38 -070038 if (isDev) {
39 // In development mode, import Monaco directly
40 const monaco = await import("monaco-editor");
41 window.monaco = monaco;
42 resolve(monaco);
philip.zeyligerc0a44592025-06-15 21:24:57 -070043 } else {
Philip Zeyliger3cde2822025-06-21 09:32:38 -070044 // In production mode, load from external bundle
45 const monacoHash = __MONACO_HASH__;
Autoformatter2f8464c2025-06-16 04:27:05 +000046
Philip Zeyliger3cde2822025-06-21 09:32:38 -070047 // Try to load the external Monaco bundle
48 const script = document.createElement("script");
49 script.onload = () => {
50 // The Monaco bundle should set window.monaco
51 if (window.monaco) {
52 resolve(window.monaco);
53 } else {
54 reject(new Error("Monaco not loaded from external bundle"));
55 }
56 };
57 script.onerror = (error) => {
58 console.warn("Failed to load external Monaco bundle:", error);
59 reject(new Error("Monaco external bundle failed to load"));
60 };
61
62 // Don't set type="module" since we're using IIFE format
63 script.src = `./static/monaco-standalone-${monacoHash}.js`;
64 document.head.appendChild(script);
65 }
66 } catch (error) {
67 reject(error);
68 }
philip.zeyligerc0a44592025-06-15 21:24:57 -070069 });
70
71 return monacoLoadPromise;
72}
Philip Zeyliger272a90e2025-05-16 14:49:51 -070073
74// Define Monaco CSS styles as a string constant
75const monacoStyles = `
76 /* Import Monaco editor styles */
77 @import url('./static/monaco/min/vs/editor/editor.main.css');
bankseand8eb4642025-07-21 03:04:52 +000078
Philip Zeyliger272a90e2025-05-16 14:49:51 -070079 /* Codicon font is now defined globally in sketch-app-shell.css */
bankseand8eb4642025-07-21 03:04:52 +000080
Philip Zeyliger272a90e2025-05-16 14:49:51 -070081 /* Custom Monaco styles */
82 .monaco-editor {
83 width: 100%;
84 height: 100%;
85 }
bankseand8eb4642025-07-21 03:04:52 +000086
Philip Zeyliger272a90e2025-05-16 14:49:51 -070087 /* Ensure light theme colors */
88 .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input {
89 background-color: var(--monaco-editor-bg, #ffffff) !important;
90 }
bankseand8eb4642025-07-21 03:04:52 +000091
Philip Zeyliger272a90e2025-05-16 14:49:51 -070092 .monaco-editor .margin {
93 background-color: var(--monaco-editor-margin, #f5f5f5) !important;
94 }
bankseand8eb4642025-07-21 03:04:52 +000095
Philip Zeyliger3cde2822025-06-21 09:32:38 -070096 /* Glyph decoration styles - only show on hover */
97 .comment-glyph-decoration {
98 width: 16px !important;
99 height: 18px !important;
100 cursor: pointer;
101 opacity: 0;
102 transition: opacity 0.2s ease;
103 }
bankseand8eb4642025-07-21 03:04:52 +0000104
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700105 .comment-glyph-decoration:before {
106 content: '💬';
107 font-size: 12px;
108 line-height: 18px;
109 width: 16px;
110 height: 18px;
111 display: block;
112 text-align: center;
113 }
bankseand8eb4642025-07-21 03:04:52 +0000114
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700115 .comment-glyph-decoration.hover-visible {
116 opacity: 1;
117 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700118`;
119
120// Configure Monaco to use local workers with correct relative paths
121// Monaco looks for this global configuration to determine how to load web workers
122// @ts-ignore - MonacoEnvironment is added to the global scope at runtime
123self.MonacoEnvironment = {
124 getWorkerUrl: function (_moduleId, label) {
125 if (label === "json") {
126 return "./static/json.worker.js";
127 }
128 if (label === "css" || label === "scss" || label === "less") {
129 return "./static/css.worker.js";
130 }
131 if (label === "html" || label === "handlebars" || label === "razor") {
132 return "./static/html.worker.js";
133 }
134 if (label === "typescript" || label === "javascript") {
135 return "./static/ts.worker.js";
136 }
137 return "./static/editor.worker.js";
138 },
139};
140
141@customElement("sketch-monaco-view")
bankseand8eb4642025-07-21 03:04:52 +0000142export class CodeDiffEditor extends SketchTailwindElement {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700143 // Editable state
144 @property({ type: Boolean, attribute: "editable-right" })
145 editableRight?: boolean;
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000146
147 // Inline diff mode (for mobile)
148 @property({ type: Boolean, attribute: "inline" })
149 inline?: boolean;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700150 private container: Ref<HTMLElement> = createRef();
151 editor?: monaco.editor.IStandaloneDiffEditor;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700152
153 // Save state properties
154 @state() private saveState: "idle" | "modified" | "saving" | "saved" = "idle";
155 @state() private debounceSaveTimeout: number | null = null;
156 @state() private lastSavedContent: string = "";
157 @property() originalCode?: string = "// Original code here";
158 @property() modifiedCode?: string = "// Modified code here";
159 @property() originalFilename?: string = "original.js";
160 @property() modifiedFilename?: string = "modified.js";
161
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700162 // Comment system state
163 @state() private showCommentBox: boolean = false;
164 @state() private commentText: string = "";
165 @state() private selectedLines: {
166 startLine: number;
167 endLine: number;
168 editorType: "original" | "modified";
169 text: string;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700170 } | null = null;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700171 @state() private commentBoxPosition: { top: number; left: number } = {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700172 top: 0,
173 left: 0,
174 };
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700175 @state() private isDragging: boolean = false;
176 @state() private dragStartLine: number | null = null;
177 @state() private dragStartEditor: "original" | "modified" | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700178
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700179 // Track visible glyphs to ensure proper cleanup
180 private visibleGlyphs: Set<string> = new Set();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700181
182 // Custom event to request save action from external components
183 private requestSave() {
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000184 if (!this.editableRight || this.saveState !== "modified") return;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700185
186 this.saveState = "saving";
187
188 // Get current content from modified editor
189 const modifiedContent = this.modifiedModel?.getValue() || "";
190
191 // Create and dispatch the save event
192 const saveEvent = new CustomEvent("monaco-save", {
193 detail: {
194 path: this.modifiedFilename,
195 content: modifiedContent,
196 },
197 bubbles: true,
198 composed: true,
199 });
200
201 this.dispatchEvent(saveEvent);
202 }
203
204 // Method to be called from parent when save is complete
205 public notifySaveComplete(success: boolean) {
206 if (success) {
207 this.saveState = "saved";
208 // Update last saved content
209 this.lastSavedContent = this.modifiedModel?.getValue() || "";
210 // Reset to idle after a delay
211 setTimeout(() => {
212 this.saveState = "idle";
213 }, 2000);
214 } else {
215 // Return to modified state on error
216 this.saveState = "modified";
217 }
218 }
219
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000220 // Rescue people with strong save-constantly habits
221 private setupKeyboardShortcuts() {
222 if (!this.editor) return;
223 const modifiedEditor = this.editor.getModifiedEditor();
224 if (!modifiedEditor) return;
225
philip.zeyligerc0a44592025-06-15 21:24:57 -0700226 const monaco = window.monaco;
227 if (!monaco) return;
Autoformatter2f8464c2025-06-16 04:27:05 +0000228
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000229 modifiedEditor.addCommand(
230 monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
231 () => {
232 this.requestSave();
Autoformatter57893c22025-05-29 13:49:53 +0000233 },
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000234 );
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000235 }
236
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700237 // Setup content change listener for debounced save
238 private setupContentChangeListener() {
239 if (!this.editor || !this.editableRight) return;
240
241 const modifiedEditor = this.editor.getModifiedEditor();
242 if (!modifiedEditor || !modifiedEditor.getModel()) return;
243
244 // Store initial content
245 this.lastSavedContent = modifiedEditor.getModel()!.getValue();
246
247 // Listen for content changes
248 modifiedEditor.getModel()!.onDidChangeContent(() => {
249 const currentContent = modifiedEditor.getModel()!.getValue();
250
251 // Check if content has actually changed from last saved state
252 if (currentContent !== this.lastSavedContent) {
253 this.saveState = "modified";
254
255 // Debounce save request
256 if (this.debounceSaveTimeout) {
257 window.clearTimeout(this.debounceSaveTimeout);
258 }
259
260 this.debounceSaveTimeout = window.setTimeout(() => {
261 this.requestSave();
262 this.debounceSaveTimeout = null;
263 }, 1000); // 1 second debounce
264 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700265
266 // Update glyph decorations when content changes
267 setTimeout(() => {
268 if (this.editor && this.modifiedModel) {
269 this.addGlyphDecorationsToEditor(
270 this.editor.getModifiedEditor(),
271 this.modifiedModel,
272 "modified",
273 );
274 }
275 }, 50);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700276 });
277 }
278
bankseand8eb4642025-07-21 03:04:52 +0000279 render() {
280 // Set host element styles for layout (equivalent to :host styles)
281 this.style.cssText = `
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700282 --editor-width: 100%;
283 --editor-height: 100%;
284 display: flex;
bankseand8eb4642025-07-21 03:04:52 +0000285 flex: none;
286 min-height: 0;
David Crawshaw26f3f342025-06-14 19:58:32 +0000287 position: relative;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700288 width: 100%;
bankseand8eb4642025-07-21 03:04:52 +0000289 `;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700290
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700291 return html`
292 <style>
293 ${monacoStyles}
bankseand8eb4642025-07-21 03:04:52 +0000294
295 /* Custom animation for comment box fade-in */
296 @keyframes fadeIn {
297 from {
298 opacity: 0;
299 }
300 to {
301 opacity: 1;
302 }
303 }
304 .animate-fade-in {
305 animation: fadeIn 0.2s ease-in-out;
306 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700307 </style>
bankseand8eb4642025-07-21 03:04:52 +0000308
309 <main
310 ${ref(this.container)}
311 class="w-full h-full border border-gray-300 flex-none min-h-[200px] relative block box-border"
312 ></main>
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700313
314 <!-- Save indicator - shown when editing -->
Philip Zeyligere89b3082025-05-29 03:16:06 +0000315 ${this.editableRight
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700316 ? html`
bankseand8eb4642025-07-21 03:04:52 +0000317 <div
318 class="absolute top-1 right-1 px-2 py-0.5 rounded text-xs font-sans text-white z-[100] opacity-90 pointer-events-none transition-opacity duration-300 ${this
319 .saveState === "idle"
320 ? "bg-gray-500"
321 : this.saveState === "modified"
322 ? "bg-yellow-500"
323 : this.saveState === "saving"
324 ? "bg-blue-400"
325 : this.saveState === "saved"
326 ? "bg-green-500"
327 : "bg-gray-500"}"
328 >
Philip Zeyligere89b3082025-05-29 03:16:06 +0000329 ${this.saveState === "idle"
330 ? "Editable"
331 : this.saveState === "modified"
332 ? "Modified..."
333 : this.saveState === "saving"
334 ? "Saving..."
335 : this.saveState === "saved"
336 ? "Saved"
337 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700338 </div>
339 `
340 : ""}
341
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700342 <!-- Comment box - shown when glyph is clicked -->
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700343 ${this.showCommentBox
344 ? html`
345 <div
bankseand8eb4642025-07-21 03:04:52 +0000346 class="fixed bg-white border border-gray-300 rounded shadow-lg p-3 z-[10001] w-[600px] animate-fade-in max-h-[80vh] overflow-y-auto"
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700347 style="top: ${this.commentBoxPosition.top}px; left: ${this
348 .commentBoxPosition.left}px;"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700349 >
bankseand8eb4642025-07-21 03:04:52 +0000350 <div class="flex justify-between items-center mb-2">
351 <h3 class="m-0 text-sm font-medium">Add comment</h3>
352 <button
353 class="bg-none border-none cursor-pointer text-base text-gray-600 px-1.5 py-0.5 hover:text-gray-800"
354 @click="${this.closeCommentBox}"
355 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700356 ×
357 </button>
358 </div>
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700359 ${this.selectedLines
360 ? html`
philip.zeyliger0accea12025-07-01 09:59:18 -0700361 <div
bankseand8eb4642025-07-21 03:04:52 +0000362 class="bg-gray-100 border border-gray-200 rounded p-2 mb-2.5 font-mono text-xs overflow-y-auto whitespace-pre-wrap break-all leading-relaxed ${this.getPreviewCssClass() ===
363 "small-selection"
364 ? ""
365 : "max-h-[280px]"}"
philip.zeyliger0accea12025-07-01 09:59:18 -0700366 >
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700367 ${this.selectedLines.text}
368 </div>
369 `
370 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700371 <textarea
bankseand8eb4642025-07-21 03:04:52 +0000372 class="w-full min-h-[80px] p-2 border border-gray-300 rounded resize-y font-inherit mb-2.5 box-border"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700373 placeholder="Type your comment here..."
374 .value="${this.commentText}"
375 @input="${this.handleCommentInput}"
376 ></textarea>
bankseand8eb4642025-07-21 03:04:52 +0000377 <div class="flex justify-end gap-2">
378 <button
379 class="px-3 py-1.5 rounded cursor-pointer text-xs bg-transparent border border-gray-300 hover:bg-gray-100"
380 @click="${this.closeCommentBox}"
381 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700382 Cancel
383 </button>
bankseand8eb4642025-07-21 03:04:52 +0000384 <button
385 class="px-3 py-1.5 rounded cursor-pointer text-xs bg-blue-600 text-white border-none hover:bg-blue-700"
386 @click="${this.submitComment}"
387 >
Josh Bleecher Snyderafeafea2025-05-23 20:27:39 +0000388 Add
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700389 </button>
390 </div>
391 </div>
392 `
393 : ""}
394 `;
395 }
396
397 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700398 * Handle changes to the comment text
399 */
400 private handleCommentInput(e: Event) {
401 const target = e.target as HTMLTextAreaElement;
402 this.commentText = target.value;
403 }
404
405 /**
philip.zeyliger0accea12025-07-01 09:59:18 -0700406 * Get CSS class for selected text preview based on number of lines
407 */
408 private getPreviewCssClass(): string {
409 if (!this.selectedLines) {
410 return "large-selection";
411 }
412
413 // Count the number of lines in the selected text
414 const lineCount = this.selectedLines.text.split("\n").length;
415
416 // If 10 lines or fewer, show all content; otherwise, limit height
417 return lineCount <= 10 ? "small-selection" : "large-selection";
418 }
419
420 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700421 * Close the comment box
422 */
423 private closeCommentBox() {
424 this.showCommentBox = false;
425 this.commentText = "";
426 this.selectedLines = null;
427 }
428
429 /**
430 * Submit the comment
431 */
432 private submitComment() {
433 try {
434 if (!this.selectedLines || !this.commentText.trim()) {
435 return;
436 }
437
438 // Store references before closing the comment box
439 const selectedLines = this.selectedLines;
440 const commentText = this.commentText;
441
442 // Get the correct filename based on active editor
443 const fileContext =
444 selectedLines.editorType === "original"
445 ? this.originalFilename || "Original file"
446 : this.modifiedFilename || "Modified file";
447
448 // Include editor info to make it clear which version was commented on
449 const editorLabel =
450 selectedLines.editorType === "original" ? "[Original]" : "[Modified]";
451
452 // Add line number information
453 let lineInfo = "";
454 if (selectedLines.startLine === selectedLines.endLine) {
455 lineInfo = ` (line ${selectedLines.startLine})`;
456 } else {
457 lineInfo = ` (lines ${selectedLines.startLine}-${selectedLines.endLine})`;
458 }
459
460 // Format the comment in a readable way
461 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${selectedLines.text}\n\`\`\`\n\n${commentText}`;
462
463 // Close UI before dispatching to prevent interaction conflicts
464 this.closeCommentBox();
465
466 // Use setTimeout to ensure the UI has updated before sending the event
467 setTimeout(() => {
468 try {
469 // Dispatch a custom event with the comment details
470 const event = new CustomEvent("monaco-comment", {
471 detail: {
472 fileContext,
473 selectedText: selectedLines.text,
474 commentText: commentText,
475 formattedComment,
476 selectionRange: {
477 startLineNumber: selectedLines.startLine,
478 startColumn: 1,
479 endLineNumber: selectedLines.endLine,
480 endColumn: 1,
481 },
482 activeEditor: selectedLines.editorType,
483 },
484 bubbles: true,
485 composed: true,
486 });
487
488 this.dispatchEvent(event);
489 } catch (error) {
490 console.error("Error dispatching comment event:", error);
491 }
492 }, 0);
493 } catch (error) {
494 console.error("Error submitting comment:", error);
495 this.closeCommentBox();
496 }
497 }
498
499 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700500 * Calculate the optimal position for the comment box to keep it in view
501 */
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700502 private calculateCommentBoxPosition(
503 lineNumber: number,
504 editorType: "original" | "modified",
505 ): { top: number; left: number } {
506 try {
507 if (!this.editor) {
508 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700509 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700510
511 const targetEditor =
512 editorType === "original"
513 ? this.editor.getOriginalEditor()
514 : this.editor.getModifiedEditor();
515 if (!targetEditor) {
516 return { top: 100, left: 100 };
517 }
518
519 // Get position from editor
520 const position = {
521 lineNumber: lineNumber,
522 column: 1,
523 };
524
525 // Use editor's built-in method for coordinate conversion
526 const coords = targetEditor.getScrolledVisiblePosition(position);
527
528 if (coords) {
529 // Get accurate DOM position
530 const editorDomNode = targetEditor.getDomNode();
531 if (editorDomNode) {
532 const editorRect = editorDomNode.getBoundingClientRect();
533
534 // Calculate the actual screen position
535 let screenLeft = editorRect.left + coords.left + 20; // Offset to the right
536 let screenTop = editorRect.top + coords.top;
537
538 // Get viewport dimensions
539 const viewportWidth = window.innerWidth;
540 const viewportHeight = window.innerHeight;
541
philip.zeyliger0accea12025-07-01 09:59:18 -0700542 // Estimated box dimensions (updated for wider box)
543 const boxWidth = 600;
544 const boxHeight = 400;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700545
546 // Check if box would go off the right edge
547 if (screenLeft + boxWidth > viewportWidth) {
548 screenLeft = viewportWidth - boxWidth - 20; // Keep 20px margin
549 }
550
551 // Check if box would go off the bottom
552 if (screenTop + boxHeight > viewportHeight) {
553 screenTop = Math.max(10, viewportHeight - boxHeight - 10);
554 }
555
556 // Ensure box is never positioned off-screen
557 screenTop = Math.max(10, screenTop);
558 screenLeft = Math.max(10, screenLeft);
559
560 return { top: screenTop, left: screenLeft };
561 }
562 }
563 } catch (error) {
564 console.error("Error calculating comment box position:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700565 }
566
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700567 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700568 }
569
570 setOriginalCode(code: string, filename?: string) {
571 this.originalCode = code;
572 if (filename) {
573 this.originalFilename = filename;
574 }
575
576 // Update the model if the editor is initialized
577 if (this.editor) {
578 const model = this.editor.getOriginalEditor().getModel();
579 if (model) {
580 model.setValue(code);
581 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700582 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700583 model,
584 this.getLanguageForFile(filename),
585 );
586 }
587 }
588 }
589 }
590
591 setModifiedCode(code: string, filename?: string) {
592 this.modifiedCode = code;
593 if (filename) {
594 this.modifiedFilename = filename;
595 }
596
597 // Update the model if the editor is initialized
598 if (this.editor) {
599 const model = this.editor.getModifiedEditor().getModel();
600 if (model) {
601 model.setValue(code);
602 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700603 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700604 model,
605 this.getLanguageForFile(filename),
606 );
607 }
608 }
609 }
610 }
611
Philip Zeyliger70273072025-05-28 18:26:14 +0000612 private _extensionToLanguageMap: Map<string, string> | null = null;
613
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700614 private getLanguageForFile(filename: string): string {
Philip Zeyliger70273072025-05-28 18:26:14 +0000615 // Get the file extension (including the dot for exact matching)
616 const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
617
618 // Build the extension-to-language map on first use
619 if (!this._extensionToLanguageMap) {
620 this._extensionToLanguageMap = new Map();
philip.zeyligerc0a44592025-06-15 21:24:57 -0700621 const languages = window.monaco!.languages.getLanguages();
Philip Zeyliger70273072025-05-28 18:26:14 +0000622
623 for (const language of languages) {
624 if (language.extensions) {
625 for (const ext of language.extensions) {
626 // Monaco extensions already include the dot, so use them directly
627 this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
628 }
629 }
630 }
631 }
632
633 return this._extensionToLanguageMap.get(extension) || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700634 }
635
636 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700637 * Setup glyph decorations for both editors
638 */
639 private setupGlyphDecorations() {
640 if (!this.editor || !window.monaco) {
641 return;
642 }
643
644 const originalEditor = this.editor.getOriginalEditor();
645 const modifiedEditor = this.editor.getModifiedEditor();
646
647 if (originalEditor && this.originalModel) {
648 this.addGlyphDecorationsToEditor(
649 originalEditor,
650 this.originalModel,
651 "original",
652 );
653 this.setupHoverBehavior(originalEditor);
654 }
655
656 if (modifiedEditor && this.modifiedModel) {
657 this.addGlyphDecorationsToEditor(
658 modifiedEditor,
659 this.modifiedModel,
660 "modified",
661 );
662 this.setupHoverBehavior(modifiedEditor);
663 }
664 }
665
666 /**
667 * Add glyph decorations to a specific editor
668 */
669 private addGlyphDecorationsToEditor(
670 editor: monaco.editor.IStandaloneCodeEditor,
671 model: monaco.editor.ITextModel,
672 editorType: "original" | "modified",
673 ) {
674 if (!window.monaco) {
675 return;
676 }
677
678 // Clear existing decorations
679 if (editorType === "original" && this.originalDecorations) {
680 this.originalDecorations.clear();
681 } else if (editorType === "modified" && this.modifiedDecorations) {
682 this.modifiedDecorations.clear();
683 }
684
685 // Create decorations for every line
686 const lineCount = model.getLineCount();
687 const decorations: monaco.editor.IModelDeltaDecoration[] = [];
688
689 for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
690 decorations.push({
691 range: new window.monaco.Range(lineNumber, 1, lineNumber, 1),
692 options: {
693 isWholeLine: false,
694 glyphMarginClassName: `comment-glyph-decoration comment-glyph-${editorType}-${lineNumber}`,
695 glyphMarginHoverMessage: { value: "Comment line" },
696 stickiness:
697 window.monaco.editor.TrackedRangeStickiness
698 .NeverGrowsWhenTypingAtEdges,
699 },
700 });
701 }
702
703 // Create or update decorations collection
704 if (editorType === "original") {
705 this.originalDecorations =
706 editor.createDecorationsCollection(decorations);
707 } else {
708 this.modifiedDecorations =
709 editor.createDecorationsCollection(decorations);
710 }
711 }
712
713 /**
714 * Setup hover and click behavior for glyph decorations
715 */
716 private setupHoverBehavior(editor: monaco.editor.IStandaloneCodeEditor) {
717 if (!editor) {
718 return;
719 }
720
721 let currentHoveredLine: number | null = null;
722 const editorType =
723 this.editor?.getOriginalEditor() === editor ? "original" : "modified";
724
725 // Listen for mouse move events in the editor
726 editor.onMouseMove((e) => {
727 if (e.target.position) {
728 const lineNumber = e.target.position.lineNumber;
729
730 // Handle real-time drag preview updates
731 if (
732 this.isDragging &&
733 this.dragStartLine !== null &&
734 this.dragStartEditor === editorType &&
735 this.showCommentBox
736 ) {
737 const startLine = Math.min(this.dragStartLine, lineNumber);
738 const endLine = Math.max(this.dragStartLine, lineNumber);
739 this.updateSelectedLinesPreview(startLine, endLine, editorType);
740 }
741
742 // Handle hover glyph visibility (only when not dragging)
743 if (!this.isDragging) {
744 // If we're hovering over a different line, update visibility
745 if (currentHoveredLine !== lineNumber) {
746 // Hide previous line's glyph
747 if (currentHoveredLine !== null) {
748 this.toggleGlyphVisibility(currentHoveredLine, false);
749 }
750
751 // Show current line's glyph
752 this.toggleGlyphVisibility(lineNumber, true);
753 currentHoveredLine = lineNumber;
754 }
755 }
756 }
757 });
758
759 // Listen for mouse down events for click-to-comment and drag selection
760 editor.onMouseDown((e) => {
761 if (
762 e.target.type ===
763 window.monaco?.editor.MouseTargetType.GUTTER_GLYPH_MARGIN
764 ) {
765 if (e.target.position) {
766 const lineNumber = e.target.position.lineNumber;
767
768 // Prevent default Monaco behavior
769 e.event.preventDefault();
770 e.event.stopPropagation();
771
772 // Check if there's an existing selection in this editor
773 const selection = editor.getSelection();
774 if (selection && !selection.isEmpty()) {
775 // Use the existing selection
776 const startLine = selection.startLineNumber;
777 const endLine = selection.endLineNumber;
778 this.showCommentForSelection(
779 startLine,
780 endLine,
781 editorType,
782 selection,
783 );
784 } else {
785 // Start drag selection or show comment for clicked line
786 this.isDragging = true;
787 this.dragStartLine = lineNumber;
788 this.dragStartEditor = editorType;
789
790 // If it's just a click (not drag), show comment box immediately
791 this.showCommentForLines(lineNumber, lineNumber, editorType);
792 }
793 }
794 }
795 });
796
797 // Listen for mouse up events to end drag selection
798 editor.onMouseUp((e) => {
799 if (this.isDragging) {
800 if (
801 e.target.position &&
802 this.dragStartLine !== null &&
803 this.dragStartEditor === editorType
804 ) {
805 const endLine = e.target.position.lineNumber;
806 const startLine = Math.min(this.dragStartLine, endLine);
807 const finalEndLine = Math.max(this.dragStartLine, endLine);
808
809 // Update the final selection (if comment box is not already shown)
810 if (!this.showCommentBox) {
811 this.showCommentForLines(startLine, finalEndLine, editorType);
812 } else {
813 // Just update the final selection since preview was already being updated
814 this.updateSelectedLinesPreview(
815 startLine,
816 finalEndLine,
817 editorType,
818 );
819 }
820 }
821
822 // Reset drag state
823 this.isDragging = false;
824 this.dragStartLine = null;
825 this.dragStartEditor = null;
826 }
827 });
828
829 // // Listen for mouse leave events
830 // editor.onMouseLeave(() => {
831 // if (currentHoveredLine !== null) {
832 // this.toggleGlyphVisibility(currentHoveredLine, false);
833 // currentHoveredLine = null;
834 // }
835 // });
836 }
837
838 /**
839 * Update the selected lines preview during drag operations
840 */
841 private updateSelectedLinesPreview(
842 startLine: number,
843 endLine: number,
844 editorType: "original" | "modified",
845 ) {
846 try {
847 if (!this.editor) {
848 return;
849 }
850
851 const targetModel =
852 editorType === "original" ? this.originalModel : this.modifiedModel;
853
854 if (!targetModel) {
855 return;
856 }
857
858 // Get the text for the selected lines
859 const lines: string[] = [];
860 for (let i = startLine; i <= endLine; i++) {
861 if (i <= targetModel.getLineCount()) {
862 lines.push(targetModel.getLineContent(i));
863 }
864 }
865
866 const selectedText = lines.join("\n");
867
868 // Update the selected lines state
869 this.selectedLines = {
870 startLine,
871 endLine,
872 editorType,
873 text: selectedText,
874 };
875
876 // Request update to refresh the preview
877 this.requestUpdate();
878 } catch (error) {
879 console.error("Error updating selected lines preview:", error);
880 }
881 }
882
883 /**
884 * Show comment box for a Monaco editor selection
885 */
886 private showCommentForSelection(
887 startLine: number,
888 endLine: number,
889 editorType: "original" | "modified",
890 selection: monaco.Selection,
891 ) {
892 try {
893 if (!this.editor) {
894 return;
895 }
896
897 const targetModel =
898 editorType === "original" ? this.originalModel : this.modifiedModel;
899
900 if (!targetModel) {
901 return;
902 }
903
904 // Get the exact selected text from the Monaco selection
905 const selectedText = targetModel.getValueInRange(selection);
906
907 // Set the selected lines state
908 this.selectedLines = {
909 startLine,
910 endLine,
911 editorType,
912 text: selectedText,
913 };
914
915 // Calculate and set comment box position
916 this.commentBoxPosition = this.calculateCommentBoxPosition(
917 startLine,
918 editorType,
919 );
920
921 // Reset comment text and show the box
922 this.commentText = "";
923 this.showCommentBox = true;
924
925 // Clear any visible glyphs since we're showing the comment box
926 this.clearAllVisibleGlyphs();
927
928 // Request update to render the comment box
929 this.requestUpdate();
930 } catch (error) {
931 console.error("Error showing comment for selection:", error);
932 }
933 }
934
935 /**
936 * Show comment box for a range of lines
937 */
938 private showCommentForLines(
939 startLine: number,
940 endLine: number,
941 editorType: "original" | "modified",
942 ) {
943 try {
944 if (!this.editor) {
945 return;
946 }
947
948 const targetEditor =
949 editorType === "original"
950 ? this.editor.getOriginalEditor()
951 : this.editor.getModifiedEditor();
952 const targetModel =
953 editorType === "original" ? this.originalModel : this.modifiedModel;
954
955 if (!targetEditor || !targetModel) {
956 return;
957 }
958
959 // Get the text for the selected lines
960 const lines: string[] = [];
961 for (let i = startLine; i <= endLine; i++) {
962 if (i <= targetModel.getLineCount()) {
963 lines.push(targetModel.getLineContent(i));
964 }
965 }
966
967 const selectedText = lines.join("\n");
968
969 // Set the selected lines state
970 this.selectedLines = {
971 startLine,
972 endLine,
973 editorType,
974 text: selectedText,
975 };
976
977 // Calculate and set comment box position
978 this.commentBoxPosition = this.calculateCommentBoxPosition(
979 startLine,
980 editorType,
981 );
982
983 // Reset comment text and show the box
984 this.commentText = "";
985 this.showCommentBox = true;
986
987 // Clear any visible glyphs since we're showing the comment box
988 this.clearAllVisibleGlyphs();
989
990 // Request update to render the comment box
991 this.requestUpdate();
992 } catch (error) {
993 console.error("Error showing comment for lines:", error);
994 }
995 }
996
997 /**
998 * Clear all currently visible glyphs
999 */
1000 private clearAllVisibleGlyphs() {
1001 try {
1002 this.visibleGlyphs.forEach((glyphId) => {
1003 const element = this.container.value?.querySelector(`.${glyphId}`);
1004 if (element) {
1005 element.classList.remove("hover-visible");
1006 }
1007 });
1008 this.visibleGlyphs.clear();
1009 } catch (error) {
1010 console.error("Error clearing visible glyphs:", error);
1011 }
1012 }
1013
1014 /**
1015 * Toggle the visibility of a glyph decoration for a specific line
1016 */
1017 private toggleGlyphVisibility(lineNumber: number, visible: boolean) {
1018 try {
1019 // If making visible, clear all existing visible glyphs first
1020 if (visible) {
1021 this.clearAllVisibleGlyphs();
1022 }
1023
1024 // Find all glyph decorations for this line in both editors
1025 const selectors = [
1026 `comment-glyph-original-${lineNumber}`,
1027 `comment-glyph-modified-${lineNumber}`,
1028 ];
1029
1030 selectors.forEach((glyphId) => {
1031 const element = this.container.value?.querySelector(`.${glyphId}`);
1032 if (element) {
1033 if (visible) {
1034 element.classList.add("hover-visible");
1035 this.visibleGlyphs.add(glyphId);
1036 } else {
1037 element.classList.remove("hover-visible");
1038 this.visibleGlyphs.delete(glyphId);
1039 }
1040 }
1041 });
1042 } catch (error) {
1043 console.error("Error toggling glyph visibility:", error);
1044 }
1045 }
1046
1047 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001048 * Update editor options
1049 */
1050 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
1051 if (this.editor) {
1052 this.editor.updateOptions(value);
Philip Zeyliger0635c772025-06-25 12:01:16 -07001053 // Re-fit content after options change with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001054 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001055 setTimeout(() => {
1056 // Preserve scroll positions during options change
1057 const originalScrollTop =
1058 this.editor!.getOriginalEditor().getScrollTop();
1059 const modifiedScrollTop =
1060 this.editor!.getModifiedEditor().getScrollTop();
1061
1062 this.fitEditorToContent!();
1063
1064 // Restore scroll positions
1065 requestAnimationFrame(() => {
1066 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1067 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1068 });
1069 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001070 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001071 }
1072 }
1073
1074 /**
1075 * Toggle hideUnchangedRegions feature
1076 */
1077 toggleHideUnchangedRegions(enabled: boolean) {
1078 if (this.editor) {
1079 this.editor.updateOptions({
1080 hideUnchangedRegions: {
1081 enabled: enabled,
1082 contextLineCount: 3,
1083 minimumLineCount: 3,
1084 revealLineCount: 10,
1085 },
1086 });
Philip Zeyliger0635c772025-06-25 12:01:16 -07001087 // Re-fit content after toggling with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001088 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001089 setTimeout(() => {
1090 // Preserve scroll positions during toggle
1091 const originalScrollTop =
1092 this.editor!.getOriginalEditor().getScrollTop();
1093 const modifiedScrollTop =
1094 this.editor!.getModifiedEditor().getScrollTop();
1095
1096 this.fitEditorToContent!();
1097
1098 // Restore scroll positions
1099 requestAnimationFrame(() => {
1100 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1101 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1102 });
1103 }, 100);
David Crawshaw26f3f342025-06-14 19:58:32 +00001104 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001105 }
1106 }
1107
1108 // Models for the editor
1109 private originalModel?: monaco.editor.ITextModel;
1110 private modifiedModel?: monaco.editor.ITextModel;
1111
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001112 // Decoration collections for glyph decorations
1113 private originalDecorations?: monaco.editor.IEditorDecorationsCollection;
1114 private modifiedDecorations?: monaco.editor.IEditorDecorationsCollection;
1115
philip.zeyligerc0a44592025-06-15 21:24:57 -07001116 private async initializeEditor() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001117 try {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001118 // Load Monaco dynamically
1119 const monaco = await loadMonaco();
Autoformatter2f8464c2025-06-16 04:27:05 +00001120
philip.zeyliger7351cd92025-06-14 12:25:31 -07001121 // Disable semantic validation globally for TypeScript/JavaScript if available
1122 if (monaco.languages && monaco.languages.typescript) {
1123 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
1124 noSemanticValidation: true,
1125 });
1126 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
1127 noSemanticValidation: true,
1128 });
1129 }
Autoformatter8c463622025-05-16 21:54:17 +00001130
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001131 // First time initialization
1132 if (!this.editor) {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001133 // Ensure the container ref is available
1134 if (!this.container.value) {
1135 throw new Error(
1136 "Container element not available - component may not be fully rendered",
1137 );
1138 }
1139
David Crawshaw26f3f342025-06-14 19:58:32 +00001140 // Create the diff editor with auto-sizing configuration
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001141 this.editor = monaco.editor.createDiffEditor(this.container.value, {
David Crawshaw26f3f342025-06-14 19:58:32 +00001142 automaticLayout: false, // We'll resize manually
Autoformatter8c463622025-05-16 21:54:17 +00001143 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001144 theme: "vs", // Always use light mode
philip.zeyliger6b8b7662025-06-16 03:06:30 +00001145 renderSideBySide: !this.inline,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001146 ignoreTrimWhitespace: false,
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001147 // Enable glyph margin for both editors to show decorations
1148 glyphMargin: true,
David Crawshaw26f3f342025-06-14 19:58:32 +00001149 scrollbar: {
Philip Zeyligere0860932025-06-18 13:01:17 -07001150 // Ideally we'd handle the mouse wheel for the horizontal scrollbar,
1151 // but there doesn't seem to be that option. Setting
1152 // alwaysConsumeMousewheel false and handleMouseWheel true didn't
1153 // work for me.
1154 handleMouseWheel: false,
David Crawshaw26f3f342025-06-14 19:58:32 +00001155 },
Philip Zeyligere0860932025-06-18 13:01:17 -07001156 renderOverviewRuler: false, // Disable overview ruler
David Crawshaw26f3f342025-06-14 19:58:32 +00001157 scrollBeyondLastLine: false,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001158 // Focus on the differences by hiding unchanged regions
1159 hideUnchangedRegions: {
1160 enabled: true, // Enable the feature
Philip Zeyligere0860932025-06-18 13:01:17 -07001161 contextLineCount: 5, // Show 3 lines of context around each difference
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001162 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
1163 revealLineCount: 10, // Show 10 lines when expanding a hidden region
1164 },
1165 });
1166
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +00001167 this.setupKeyboardShortcuts();
1168
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001169 // If this is an editable view, set the correct read-only state for each editor
1170 if (this.editableRight) {
1171 // Make sure the original editor is always read-only
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001172 this.editor
1173 .getOriginalEditor()
1174 .updateOptions({ readOnly: true, glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001175 // Make sure the modified editor is editable
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001176 this.editor
1177 .getModifiedEditor()
1178 .updateOptions({ readOnly: false, glyphMargin: true });
1179 } else {
1180 // Ensure glyph margin is enabled on both editors even in read-only mode
1181 this.editor.getOriginalEditor().updateOptions({ glyphMargin: true });
1182 this.editor.getModifiedEditor().updateOptions({ glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001183 }
philip.zeyliger7351cd92025-06-14 12:25:31 -07001184
David Crawshaw26f3f342025-06-14 19:58:32 +00001185 // Set up auto-sizing
1186 this.setupAutoSizing();
1187
philip.zeyliger7351cd92025-06-14 12:25:31 -07001188 // Add Monaco editor to debug global
1189 this.addToDebugGlobal();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001190 }
1191
1192 // Create or update models
1193 this.updateModels();
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001194 // Add glyph decorations after models are set
1195 this.setupGlyphDecorations();
Autoformatter8c463622025-05-16 21:54:17 +00001196 // Set up content change listener
1197 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001198
philip.zeyliger7351cd92025-06-14 12:25:31 -07001199 // Fix cursor positioning issues by ensuring fonts are loaded
philip.zeyliger7351cd92025-06-14 12:25:31 -07001200 document.fonts.ready.then(() => {
1201 if (this.editor) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001202 // Preserve scroll positions during font remeasuring
1203 const originalScrollTop = this.editor
1204 .getOriginalEditor()
1205 .getScrollTop();
1206 const modifiedScrollTop = this.editor
1207 .getModifiedEditor()
1208 .getScrollTop();
1209
philip.zeyliger7351cd92025-06-14 12:25:31 -07001210 monaco.editor.remeasureFonts();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001211
1212 if (this.fitEditorToContent) {
1213 this.fitEditorToContent();
1214 }
1215
1216 // Restore scroll positions after font remeasuring
1217 requestAnimationFrame(() => {
1218 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1219 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1220 });
philip.zeyliger7351cd92025-06-14 12:25:31 -07001221 }
1222 });
1223
Philip Zeyliger0635c772025-06-25 12:01:16 -07001224 // Force layout recalculation after a short delay with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001225 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001226 if (this.editor && this.fitEditorToContent) {
1227 // Preserve scroll positions
1228 const originalScrollTop = this.editor
1229 .getOriginalEditor()
1230 .getScrollTop();
1231 const modifiedScrollTop = this.editor
1232 .getModifiedEditor()
1233 .getScrollTop();
1234
David Crawshaw26f3f342025-06-14 19:58:32 +00001235 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001236
1237 // Restore scroll positions
1238 requestAnimationFrame(() => {
1239 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1240 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1241 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001242 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001243 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001244 } catch (error) {
1245 console.error("Error initializing Monaco editor:", error);
1246 }
1247 }
1248
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001249 private updateModels() {
1250 try {
1251 // Get language based on filename
1252 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1253 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1254
1255 // Always create new models with unique URIs based on timestamp to avoid conflicts
1256 const timestamp = new Date().getTime();
1257 // TODO: Could put filename in these URIs; unclear how they're used right now.
philip.zeyligerc0a44592025-06-15 21:24:57 -07001258 const originalUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001259 `file:///original-${timestamp}.${originalLang}`,
1260 );
philip.zeyligerc0a44592025-06-15 21:24:57 -07001261 const modifiedUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001262 `file:///modified-${timestamp}.${modifiedLang}`,
1263 );
1264
1265 // Store references to old models
1266 const oldOriginalModel = this.originalModel;
1267 const oldModifiedModel = this.modifiedModel;
1268
1269 // Nullify instance variables to prevent accidental use
1270 this.originalModel = undefined;
1271 this.modifiedModel = undefined;
1272
1273 // Clear the editor model first to release Monaco's internal references
1274 if (this.editor) {
1275 this.editor.setModel(null);
1276 }
1277
1278 // Now it's safe to dispose the old models
1279 if (oldOriginalModel) {
1280 oldOriginalModel.dispose();
1281 }
1282
1283 if (oldModifiedModel) {
1284 oldModifiedModel.dispose();
1285 }
1286
1287 // Create new models
philip.zeyligerc0a44592025-06-15 21:24:57 -07001288 this.originalModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001289 this.originalCode || "",
1290 originalLang,
1291 originalUri,
1292 );
1293
philip.zeyligerc0a44592025-06-15 21:24:57 -07001294 this.modifiedModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001295 this.modifiedCode || "",
1296 modifiedLang,
1297 modifiedUri,
1298 );
1299
1300 // Set the new models on the editor
1301 if (this.editor) {
1302 this.editor.setModel({
1303 original: this.originalModel,
1304 modified: this.modifiedModel,
1305 });
Autoformatter9abf8032025-06-14 23:24:08 +00001306
David Crawshaw26f3f342025-06-14 19:58:32 +00001307 // Set initial hideUnchangedRegions state (default to enabled/collapsed)
1308 this.editor.updateOptions({
1309 hideUnchangedRegions: {
1310 enabled: true, // Default to collapsed state
1311 contextLineCount: 3,
1312 minimumLineCount: 3,
1313 revealLineCount: 10,
1314 },
1315 });
Autoformatter9abf8032025-06-14 23:24:08 +00001316
Philip Zeyliger0635c772025-06-25 12:01:16 -07001317 // Fit content after setting new models with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001318 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001319 setTimeout(() => {
1320 // Preserve scroll positions when fitting content after model changes
1321 const originalScrollTop =
1322 this.editor!.getOriginalEditor().getScrollTop();
1323 const modifiedScrollTop =
1324 this.editor!.getModifiedEditor().getScrollTop();
1325
1326 this.fitEditorToContent!();
1327
1328 // Restore scroll positions
1329 requestAnimationFrame(() => {
1330 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1331 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1332 });
1333 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001334 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001335
1336 // Add glyph decorations after setting new models
1337 setTimeout(() => this.setupGlyphDecorations(), 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001338 }
1339 this.setupContentChangeListener();
1340 } catch (error) {
1341 console.error("Error updating Monaco models:", error);
1342 }
1343 }
1344
philip.zeyligerc0a44592025-06-15 21:24:57 -07001345 async updated(changedProperties: Map<string, any>) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001346 // If any relevant properties changed, just update the models
1347 if (
1348 changedProperties.has("originalCode") ||
1349 changedProperties.has("modifiedCode") ||
1350 changedProperties.has("originalFilename") ||
1351 changedProperties.has("modifiedFilename") ||
1352 changedProperties.has("editableRight")
1353 ) {
1354 if (this.editor) {
1355 this.updateModels();
1356
David Crawshaw26f3f342025-06-14 19:58:32 +00001357 // Force auto-sizing after model updates
Philip Zeyliger0635c772025-06-25 12:01:16 -07001358 // Use a slightly longer delay to ensure layout is stable with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001359 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001360 if (this.fitEditorToContent && this.editor) {
1361 // Preserve scroll positions during model update layout
1362 const originalScrollTop = this.editor
1363 .getOriginalEditor()
1364 .getScrollTop();
1365 const modifiedScrollTop = this.editor
1366 .getModifiedEditor()
1367 .getScrollTop();
1368
David Crawshaw26f3f342025-06-14 19:58:32 +00001369 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001370
1371 // Restore scroll positions
1372 requestAnimationFrame(() => {
1373 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1374 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1375 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001376 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001377 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001378 } else {
1379 // If the editor isn't initialized yet but we received content,
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001380 // ensure we're connected before initializing
1381 await this.ensureConnectedToDocument();
philip.zeyligerc0a44592025-06-15 21:24:57 -07001382 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001383 }
1384 }
1385 }
1386
David Crawshaw26f3f342025-06-14 19:58:32 +00001387 // Set up auto-sizing for multi-file diff view
1388 private setupAutoSizing() {
1389 if (!this.editor) return;
1390
1391 const fitContent = () => {
1392 try {
1393 const originalEditor = this.editor!.getOriginalEditor();
1394 const modifiedEditor = this.editor!.getModifiedEditor();
Autoformatter9abf8032025-06-14 23:24:08 +00001395
David Crawshaw26f3f342025-06-14 19:58:32 +00001396 const originalHeight = originalEditor.getContentHeight();
1397 const modifiedHeight = modifiedEditor.getContentHeight();
Autoformatter9abf8032025-06-14 23:24:08 +00001398
David Crawshaw26f3f342025-06-14 19:58:32 +00001399 // Use the maximum height of both editors, plus some padding
1400 const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
Autoformatter9abf8032025-06-14 23:24:08 +00001401
David Crawshaw26f3f342025-06-14 19:58:32 +00001402 // Set both container and host height to enable proper scrolling
1403 if (this.container.value) {
1404 // Set explicit heights on both container and host
1405 this.container.value.style.height = `${maxHeight}px`;
1406 this.style.height = `${maxHeight}px`; // Update host element height
Autoformatter9abf8032025-06-14 23:24:08 +00001407
David Crawshaw26f3f342025-06-14 19:58:32 +00001408 // Emit the height change event BEFORE calling layout
1409 // This ensures parent containers resize first
Autoformatter9abf8032025-06-14 23:24:08 +00001410 this.dispatchEvent(
1411 new CustomEvent("monaco-height-changed", {
1412 detail: { height: maxHeight },
1413 bubbles: true,
1414 composed: true,
1415 }),
1416 );
1417
David Crawshaw26f3f342025-06-14 19:58:32 +00001418 // Layout after both this component and parents have updated
1419 setTimeout(() => {
1420 if (this.editor && this.container.value) {
1421 // Use explicit dimensions to ensure Monaco uses full available space
David Crawshawdba26b52025-06-15 00:33:45 +00001422 // Use clientWidth instead of offsetWidth to avoid border overflow
1423 const width = this.container.value.clientWidth;
David Crawshaw26f3f342025-06-14 19:58:32 +00001424 this.editor.layout({
1425 width: width,
Autoformatter9abf8032025-06-14 23:24:08 +00001426 height: maxHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +00001427 });
1428 }
1429 }, 10);
1430 }
1431 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +00001432 console.error("Error in fitContent:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +00001433 }
1434 };
1435
1436 // Store the fit function for external access
1437 this.fitEditorToContent = fitContent;
1438
1439 // Set up listeners for content size changes
1440 this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
1441 this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
1442
1443 // Initial fit
1444 fitContent();
1445 }
1446
1447 private fitEditorToContent: (() => void) | null = null;
1448
David Crawshawe2954ce2025-06-15 00:06:34 +00001449 /**
1450 * Set up window resize handler to ensure Monaco editor adapts to browser window changes
1451 */
1452 private setupWindowResizeHandler() {
1453 // Create a debounced resize handler to avoid too many layout calls
1454 let resizeTimeout: number | null = null;
Autoformatterad15b6c2025-06-15 00:29:26 +00001455
David Crawshawe2954ce2025-06-15 00:06:34 +00001456 this._windowResizeHandler = () => {
1457 // Clear any existing timeout
1458 if (resizeTimeout) {
1459 window.clearTimeout(resizeTimeout);
1460 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001461
David Crawshawe2954ce2025-06-15 00:06:34 +00001462 // Debounce the resize to avoid excessive layout calls
1463 resizeTimeout = window.setTimeout(() => {
1464 if (this.editor && this.container.value) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001465 // Trigger layout recalculation with scroll preservation
David Crawshawe2954ce2025-06-15 00:06:34 +00001466 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001467 // Preserve scroll positions during window resize
1468 const originalScrollTop = this.editor
1469 .getOriginalEditor()
1470 .getScrollTop();
1471 const modifiedScrollTop = this.editor
1472 .getModifiedEditor()
1473 .getScrollTop();
1474
David Crawshawe2954ce2025-06-15 00:06:34 +00001475 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001476
1477 // Restore scroll positions
1478 requestAnimationFrame(() => {
1479 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1480 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1481 });
David Crawshawe2954ce2025-06-15 00:06:34 +00001482 } else {
1483 // Fallback: just trigger a layout with current container dimensions
David Crawshawdba26b52025-06-15 00:33:45 +00001484 // Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
1485 const width = this.container.value.clientWidth;
1486 const height = this.container.value.clientHeight;
David Crawshawe2954ce2025-06-15 00:06:34 +00001487 this.editor.layout({ width, height });
1488 }
1489 }
1490 }, 100); // 100ms debounce
1491 };
Autoformatterad15b6c2025-06-15 00:29:26 +00001492
David Crawshawe2954ce2025-06-15 00:06:34 +00001493 // Add the event listener
Autoformatterad15b6c2025-06-15 00:29:26 +00001494 window.addEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001495 }
1496
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001497 // Add resize observer to ensure editor resizes when container changes
philip.zeyligerc0a44592025-06-15 21:24:57 -07001498 async firstUpdated() {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001499 // Ensure we're connected to the document before Monaco initialization
1500 await this.ensureConnectedToDocument();
1501
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001502 // Initialize the editor
philip.zeyligerc0a44592025-06-15 21:24:57 -07001503 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001504
David Crawshawe2954ce2025-06-15 00:06:34 +00001505 // Set up window resize handler to ensure Monaco editor adapts to browser window changes
1506 this.setupWindowResizeHandler();
1507
David Crawshaw26f3f342025-06-14 19:58:32 +00001508 // For multi-file diff, we don't use ResizeObserver since we control the size
1509 // Instead, we rely on auto-sizing based on content
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001510
1511 // If editable, set up edit mode and content change listener
1512 if (this.editableRight && this.editor) {
1513 // Ensure the original editor is read-only
1514 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1515 // Ensure the modified editor is editable
1516 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1517 }
1518 }
1519
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001520 /**
1521 * Ensure this component and its container are properly connected to the document.
1522 * Monaco editor requires the container to be in the document for proper initialization.
1523 */
1524 private async ensureConnectedToDocument(): Promise<void> {
1525 // Wait for our own render to complete
1526 await this.updateComplete;
1527
1528 // Verify the container ref is available
1529 if (!this.container.value) {
1530 throw new Error("Container element not available after updateComplete");
1531 }
1532
1533 // Check if we're connected to the document
1534 if (!this.isConnected) {
1535 throw new Error("Component is not connected to the document");
1536 }
1537
1538 // Verify the container is also in the document
1539 if (!this.container.value.isConnected) {
1540 throw new Error("Container element is not connected to the document");
1541 }
1542 }
1543
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001544 private _resizeObserver: ResizeObserver | null = null;
David Crawshawe2954ce2025-06-15 00:06:34 +00001545 private _windowResizeHandler: (() => void) | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001546
philip.zeyliger7351cd92025-06-14 12:25:31 -07001547 /**
1548 * Add this Monaco editor instance to the global debug object
1549 * This allows inspection and debugging via browser console
1550 */
1551 private addToDebugGlobal() {
1552 try {
1553 // Initialize the debug global if it doesn't exist
1554 if (!(window as any).sketchDebug) {
1555 (window as any).sketchDebug = {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001556 monaco: window.monaco!,
philip.zeyliger7351cd92025-06-14 12:25:31 -07001557 editors: [],
1558 remeasureFonts: () => {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001559 window.monaco!.editor.remeasureFonts();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001560 (window as any).sketchDebug.editors.forEach(
philip.zeyliger26bc6592025-06-30 20:15:30 -07001561 (editor: any, _index: number) => {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001562 if (editor && editor.layout) {
1563 editor.layout();
1564 }
1565 },
1566 );
1567 },
1568 layoutAll: () => {
1569 (window as any).sketchDebug.editors.forEach(
philip.zeyliger26bc6592025-06-30 20:15:30 -07001570 (editor: any, _index: number) => {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001571 if (editor && editor.layout) {
1572 editor.layout();
1573 }
1574 },
1575 );
1576 },
1577 getActiveEditors: () => {
1578 return (window as any).sketchDebug.editors.filter(
1579 (editor: any) => editor !== null,
1580 );
1581 },
1582 };
1583 }
1584
1585 // Add this editor to the debug collection
1586 if (this.editor) {
1587 (window as any).sketchDebug.editors.push(this.editor);
1588 }
1589 } catch (error) {
1590 console.error("Error adding Monaco editor to debug global:", error);
1591 }
1592 }
1593
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001594 disconnectedCallback() {
1595 super.disconnectedCallback();
1596
1597 try {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001598 // Remove editor from debug global before disposal
1599 if (this.editor && (window as any).sketchDebug?.editors) {
1600 const index = (window as any).sketchDebug.editors.indexOf(this.editor);
1601 if (index > -1) {
1602 (window as any).sketchDebug.editors[index] = null;
1603 }
1604 }
1605
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001606 // Clean up decorations
1607 if (this.originalDecorations) {
1608 this.originalDecorations.clear();
1609 this.originalDecorations = undefined;
1610 }
1611
1612 if (this.modifiedDecorations) {
1613 this.modifiedDecorations.clear();
1614 this.modifiedDecorations = undefined;
1615 }
1616
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001617 // Clean up resources when element is removed
1618 if (this.editor) {
1619 this.editor.dispose();
1620 this.editor = undefined;
1621 }
1622
1623 // Dispose models to prevent memory leaks
1624 if (this.originalModel) {
1625 this.originalModel.dispose();
1626 this.originalModel = undefined;
1627 }
1628
1629 if (this.modifiedModel) {
1630 this.modifiedModel.dispose();
1631 this.modifiedModel = undefined;
1632 }
1633
David Crawshaw26f3f342025-06-14 19:58:32 +00001634 // Clean up resize observer (if any)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001635 if (this._resizeObserver) {
1636 this._resizeObserver.disconnect();
1637 this._resizeObserver = null;
1638 }
Autoformatter9abf8032025-06-14 23:24:08 +00001639
David Crawshaw26f3f342025-06-14 19:58:32 +00001640 // Clear the fit function reference
1641 this.fitEditorToContent = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001642
David Crawshawe2954ce2025-06-15 00:06:34 +00001643 // Remove window resize handler if set
1644 if (this._windowResizeHandler) {
Autoformatterad15b6c2025-06-15 00:29:26 +00001645 window.removeEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001646 this._windowResizeHandler = null;
1647 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001648
1649 // Clear visible glyphs tracking
1650 this.visibleGlyphs.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001651 } catch (error) {
1652 console.error("Error in disconnectedCallback:", error);
1653 }
1654 }
1655
1656 // disconnectedCallback implementation is defined below
1657}
1658
1659declare global {
1660 interface HTMLElementTagNameMap {
1661 "sketch-monaco-view": CodeDiffEditor;
1662 }
1663}