blob: b74097a66a0eb211e4dc4b4fae5ba58f9261c643 [file] [log] [blame]
Josh Bleecher Snydera6b995b2025-07-24 00:45:05 +00001/* eslint-disable 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}"
Josh Bleecher Snyder01bf5ae2025-07-24 17:50:00 +0000376 @keydown="${this.handleCommentKeydown}"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700377 ></textarea>
bankseand8eb4642025-07-21 03:04:52 +0000378 <div class="flex justify-end gap-2">
379 <button
380 class="px-3 py-1.5 rounded cursor-pointer text-xs bg-transparent border border-gray-300 hover:bg-gray-100"
381 @click="${this.closeCommentBox}"
382 >
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700383 Cancel
384 </button>
bankseand8eb4642025-07-21 03:04:52 +0000385 <button
386 class="px-3 py-1.5 rounded cursor-pointer text-xs bg-blue-600 text-white border-none hover:bg-blue-700"
387 @click="${this.submitComment}"
388 >
Josh Bleecher Snyderafeafea2025-05-23 20:27:39 +0000389 Add
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700390 </button>
391 </div>
392 </div>
393 `
394 : ""}
395 `;
396 }
397
398 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700399 * Handle changes to the comment text
400 */
401 private handleCommentInput(e: Event) {
402 const target = e.target as HTMLTextAreaElement;
403 this.commentText = target.value;
404 }
405
406 /**
Josh Bleecher Snyder01bf5ae2025-07-24 17:50:00 +0000407 * Handle keyboard shortcuts in the comment textarea
408 */
409 private handleCommentKeydown(e: KeyboardEvent) {
410 // Check for Command+Enter (Mac) or Ctrl+Enter (other platforms)
411 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
412 e.preventDefault();
413 this.submitComment();
414 }
415 }
416
417 /**
philip.zeyliger0accea12025-07-01 09:59:18 -0700418 * Get CSS class for selected text preview based on number of lines
419 */
420 private getPreviewCssClass(): string {
421 if (!this.selectedLines) {
422 return "large-selection";
423 }
424
425 // Count the number of lines in the selected text
426 const lineCount = this.selectedLines.text.split("\n").length;
427
428 // If 10 lines or fewer, show all content; otherwise, limit height
429 return lineCount <= 10 ? "small-selection" : "large-selection";
430 }
431
432 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700433 * Close the comment box
434 */
435 private closeCommentBox() {
436 this.showCommentBox = false;
437 this.commentText = "";
438 this.selectedLines = null;
439 }
440
441 /**
442 * Submit the comment
443 */
444 private submitComment() {
445 try {
446 if (!this.selectedLines || !this.commentText.trim()) {
447 return;
448 }
449
450 // Store references before closing the comment box
451 const selectedLines = this.selectedLines;
452 const commentText = this.commentText;
453
454 // Get the correct filename based on active editor
455 const fileContext =
456 selectedLines.editorType === "original"
457 ? this.originalFilename || "Original file"
458 : this.modifiedFilename || "Modified file";
459
460 // Include editor info to make it clear which version was commented on
461 const editorLabel =
462 selectedLines.editorType === "original" ? "[Original]" : "[Modified]";
463
464 // Add line number information
465 let lineInfo = "";
466 if (selectedLines.startLine === selectedLines.endLine) {
467 lineInfo = ` (line ${selectedLines.startLine})`;
468 } else {
469 lineInfo = ` (lines ${selectedLines.startLine}-${selectedLines.endLine})`;
470 }
471
472 // Format the comment in a readable way
473 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${selectedLines.text}\n\`\`\`\n\n${commentText}`;
474
475 // Close UI before dispatching to prevent interaction conflicts
476 this.closeCommentBox();
477
478 // Use setTimeout to ensure the UI has updated before sending the event
479 setTimeout(() => {
480 try {
481 // Dispatch a custom event with the comment details
482 const event = new CustomEvent("monaco-comment", {
483 detail: {
484 fileContext,
485 selectedText: selectedLines.text,
486 commentText: commentText,
487 formattedComment,
488 selectionRange: {
489 startLineNumber: selectedLines.startLine,
490 startColumn: 1,
491 endLineNumber: selectedLines.endLine,
492 endColumn: 1,
493 },
494 activeEditor: selectedLines.editorType,
495 },
496 bubbles: true,
497 composed: true,
498 });
499
500 this.dispatchEvent(event);
501 } catch (error) {
502 console.error("Error dispatching comment event:", error);
503 }
504 }, 0);
505 } catch (error) {
506 console.error("Error submitting comment:", error);
507 this.closeCommentBox();
508 }
509 }
510
511 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700512 * Calculate the optimal position for the comment box to keep it in view
513 */
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700514 private calculateCommentBoxPosition(
515 lineNumber: number,
516 editorType: "original" | "modified",
517 ): { top: number; left: number } {
518 try {
519 if (!this.editor) {
520 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700521 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700522
523 const targetEditor =
524 editorType === "original"
525 ? this.editor.getOriginalEditor()
526 : this.editor.getModifiedEditor();
527 if (!targetEditor) {
528 return { top: 100, left: 100 };
529 }
530
531 // Get position from editor
532 const position = {
533 lineNumber: lineNumber,
534 column: 1,
535 };
536
537 // Use editor's built-in method for coordinate conversion
538 const coords = targetEditor.getScrolledVisiblePosition(position);
539
540 if (coords) {
541 // Get accurate DOM position
542 const editorDomNode = targetEditor.getDomNode();
543 if (editorDomNode) {
544 const editorRect = editorDomNode.getBoundingClientRect();
545
546 // Calculate the actual screen position
547 let screenLeft = editorRect.left + coords.left + 20; // Offset to the right
548 let screenTop = editorRect.top + coords.top;
549
550 // Get viewport dimensions
551 const viewportWidth = window.innerWidth;
552 const viewportHeight = window.innerHeight;
553
philip.zeyliger0accea12025-07-01 09:59:18 -0700554 // Estimated box dimensions (updated for wider box)
555 const boxWidth = 600;
556 const boxHeight = 400;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700557
558 // Check if box would go off the right edge
559 if (screenLeft + boxWidth > viewportWidth) {
560 screenLeft = viewportWidth - boxWidth - 20; // Keep 20px margin
561 }
562
563 // Check if box would go off the bottom
564 if (screenTop + boxHeight > viewportHeight) {
565 screenTop = Math.max(10, viewportHeight - boxHeight - 10);
566 }
567
568 // Ensure box is never positioned off-screen
569 screenTop = Math.max(10, screenTop);
570 screenLeft = Math.max(10, screenLeft);
571
572 return { top: screenTop, left: screenLeft };
573 }
574 }
575 } catch (error) {
576 console.error("Error calculating comment box position:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700577 }
578
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700579 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700580 }
581
582 setOriginalCode(code: string, filename?: string) {
583 this.originalCode = code;
584 if (filename) {
585 this.originalFilename = filename;
586 }
587
588 // Update the model if the editor is initialized
589 if (this.editor) {
590 const model = this.editor.getOriginalEditor().getModel();
591 if (model) {
592 model.setValue(code);
593 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700594 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700595 model,
596 this.getLanguageForFile(filename),
597 );
598 }
599 }
600 }
601 }
602
603 setModifiedCode(code: string, filename?: string) {
604 this.modifiedCode = code;
605 if (filename) {
606 this.modifiedFilename = filename;
607 }
608
609 // Update the model if the editor is initialized
610 if (this.editor) {
611 const model = this.editor.getModifiedEditor().getModel();
612 if (model) {
613 model.setValue(code);
614 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700615 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700616 model,
617 this.getLanguageForFile(filename),
618 );
619 }
620 }
621 }
622 }
623
Philip Zeyliger70273072025-05-28 18:26:14 +0000624 private _extensionToLanguageMap: Map<string, string> | null = null;
625
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700626 private getLanguageForFile(filename: string): string {
Philip Zeyliger70273072025-05-28 18:26:14 +0000627 // Get the file extension (including the dot for exact matching)
628 const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
629
630 // Build the extension-to-language map on first use
631 if (!this._extensionToLanguageMap) {
632 this._extensionToLanguageMap = new Map();
philip.zeyligerc0a44592025-06-15 21:24:57 -0700633 const languages = window.monaco!.languages.getLanguages();
Philip Zeyliger70273072025-05-28 18:26:14 +0000634
635 for (const language of languages) {
636 if (language.extensions) {
637 for (const ext of language.extensions) {
638 // Monaco extensions already include the dot, so use them directly
639 this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
640 }
641 }
642 }
643 }
644
645 return this._extensionToLanguageMap.get(extension) || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700646 }
647
648 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700649 * Setup glyph decorations for both editors
650 */
651 private setupGlyphDecorations() {
652 if (!this.editor || !window.monaco) {
653 return;
654 }
655
656 const originalEditor = this.editor.getOriginalEditor();
657 const modifiedEditor = this.editor.getModifiedEditor();
658
659 if (originalEditor && this.originalModel) {
660 this.addGlyphDecorationsToEditor(
661 originalEditor,
662 this.originalModel,
663 "original",
664 );
665 this.setupHoverBehavior(originalEditor);
666 }
667
668 if (modifiedEditor && this.modifiedModel) {
669 this.addGlyphDecorationsToEditor(
670 modifiedEditor,
671 this.modifiedModel,
672 "modified",
673 );
674 this.setupHoverBehavior(modifiedEditor);
675 }
676 }
677
678 /**
679 * Add glyph decorations to a specific editor
680 */
681 private addGlyphDecorationsToEditor(
682 editor: monaco.editor.IStandaloneCodeEditor,
683 model: monaco.editor.ITextModel,
684 editorType: "original" | "modified",
685 ) {
686 if (!window.monaco) {
687 return;
688 }
689
690 // Clear existing decorations
691 if (editorType === "original" && this.originalDecorations) {
692 this.originalDecorations.clear();
693 } else if (editorType === "modified" && this.modifiedDecorations) {
694 this.modifiedDecorations.clear();
695 }
696
697 // Create decorations for every line
698 const lineCount = model.getLineCount();
699 const decorations: monaco.editor.IModelDeltaDecoration[] = [];
700
701 for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
702 decorations.push({
703 range: new window.monaco.Range(lineNumber, 1, lineNumber, 1),
704 options: {
705 isWholeLine: false,
706 glyphMarginClassName: `comment-glyph-decoration comment-glyph-${editorType}-${lineNumber}`,
707 glyphMarginHoverMessage: { value: "Comment line" },
708 stickiness:
709 window.monaco.editor.TrackedRangeStickiness
710 .NeverGrowsWhenTypingAtEdges,
711 },
712 });
713 }
714
715 // Create or update decorations collection
716 if (editorType === "original") {
717 this.originalDecorations =
718 editor.createDecorationsCollection(decorations);
719 } else {
720 this.modifiedDecorations =
721 editor.createDecorationsCollection(decorations);
722 }
723 }
724
725 /**
726 * Setup hover and click behavior for glyph decorations
727 */
728 private setupHoverBehavior(editor: monaco.editor.IStandaloneCodeEditor) {
729 if (!editor) {
730 return;
731 }
732
733 let currentHoveredLine: number | null = null;
734 const editorType =
735 this.editor?.getOriginalEditor() === editor ? "original" : "modified";
736
737 // Listen for mouse move events in the editor
738 editor.onMouseMove((e) => {
739 if (e.target.position) {
740 const lineNumber = e.target.position.lineNumber;
741
742 // Handle real-time drag preview updates
743 if (
744 this.isDragging &&
745 this.dragStartLine !== null &&
746 this.dragStartEditor === editorType &&
747 this.showCommentBox
748 ) {
749 const startLine = Math.min(this.dragStartLine, lineNumber);
750 const endLine = Math.max(this.dragStartLine, lineNumber);
751 this.updateSelectedLinesPreview(startLine, endLine, editorType);
752 }
753
754 // Handle hover glyph visibility (only when not dragging)
755 if (!this.isDragging) {
756 // If we're hovering over a different line, update visibility
757 if (currentHoveredLine !== lineNumber) {
758 // Hide previous line's glyph
759 if (currentHoveredLine !== null) {
760 this.toggleGlyphVisibility(currentHoveredLine, false);
761 }
762
763 // Show current line's glyph
764 this.toggleGlyphVisibility(lineNumber, true);
765 currentHoveredLine = lineNumber;
766 }
767 }
768 }
769 });
770
771 // Listen for mouse down events for click-to-comment and drag selection
772 editor.onMouseDown((e) => {
773 if (
774 e.target.type ===
775 window.monaco?.editor.MouseTargetType.GUTTER_GLYPH_MARGIN
776 ) {
777 if (e.target.position) {
778 const lineNumber = e.target.position.lineNumber;
779
780 // Prevent default Monaco behavior
781 e.event.preventDefault();
782 e.event.stopPropagation();
783
784 // Check if there's an existing selection in this editor
785 const selection = editor.getSelection();
786 if (selection && !selection.isEmpty()) {
787 // Use the existing selection
788 const startLine = selection.startLineNumber;
789 const endLine = selection.endLineNumber;
790 this.showCommentForSelection(
791 startLine,
792 endLine,
793 editorType,
794 selection,
795 );
796 } else {
797 // Start drag selection or show comment for clicked line
798 this.isDragging = true;
799 this.dragStartLine = lineNumber;
800 this.dragStartEditor = editorType;
801
802 // If it's just a click (not drag), show comment box immediately
803 this.showCommentForLines(lineNumber, lineNumber, editorType);
804 }
805 }
806 }
807 });
808
809 // Listen for mouse up events to end drag selection
810 editor.onMouseUp((e) => {
811 if (this.isDragging) {
812 if (
813 e.target.position &&
814 this.dragStartLine !== null &&
815 this.dragStartEditor === editorType
816 ) {
817 const endLine = e.target.position.lineNumber;
818 const startLine = Math.min(this.dragStartLine, endLine);
819 const finalEndLine = Math.max(this.dragStartLine, endLine);
820
821 // Update the final selection (if comment box is not already shown)
822 if (!this.showCommentBox) {
823 this.showCommentForLines(startLine, finalEndLine, editorType);
824 } else {
825 // Just update the final selection since preview was already being updated
826 this.updateSelectedLinesPreview(
827 startLine,
828 finalEndLine,
829 editorType,
830 );
831 }
832 }
833
834 // Reset drag state
835 this.isDragging = false;
836 this.dragStartLine = null;
837 this.dragStartEditor = null;
838 }
839 });
840
841 // // Listen for mouse leave events
842 // editor.onMouseLeave(() => {
843 // if (currentHoveredLine !== null) {
844 // this.toggleGlyphVisibility(currentHoveredLine, false);
845 // currentHoveredLine = null;
846 // }
847 // });
848 }
849
850 /**
851 * Update the selected lines preview during drag operations
852 */
853 private updateSelectedLinesPreview(
854 startLine: number,
855 endLine: number,
856 editorType: "original" | "modified",
857 ) {
858 try {
859 if (!this.editor) {
860 return;
861 }
862
863 const targetModel =
864 editorType === "original" ? this.originalModel : this.modifiedModel;
865
866 if (!targetModel) {
867 return;
868 }
869
870 // Get the text for the selected lines
871 const lines: string[] = [];
872 for (let i = startLine; i <= endLine; i++) {
873 if (i <= targetModel.getLineCount()) {
874 lines.push(targetModel.getLineContent(i));
875 }
876 }
877
878 const selectedText = lines.join("\n");
879
880 // Update the selected lines state
881 this.selectedLines = {
882 startLine,
883 endLine,
884 editorType,
885 text: selectedText,
886 };
887
888 // Request update to refresh the preview
889 this.requestUpdate();
890 } catch (error) {
891 console.error("Error updating selected lines preview:", error);
892 }
893 }
894
895 /**
896 * Show comment box for a Monaco editor selection
897 */
898 private showCommentForSelection(
899 startLine: number,
900 endLine: number,
901 editorType: "original" | "modified",
902 selection: monaco.Selection,
903 ) {
904 try {
905 if (!this.editor) {
906 return;
907 }
908
909 const targetModel =
910 editorType === "original" ? this.originalModel : this.modifiedModel;
911
912 if (!targetModel) {
913 return;
914 }
915
916 // Get the exact selected text from the Monaco selection
917 const selectedText = targetModel.getValueInRange(selection);
918
919 // Set the selected lines state
920 this.selectedLines = {
921 startLine,
922 endLine,
923 editorType,
924 text: selectedText,
925 };
926
927 // Calculate and set comment box position
928 this.commentBoxPosition = this.calculateCommentBoxPosition(
929 startLine,
930 editorType,
931 );
932
933 // Reset comment text and show the box
934 this.commentText = "";
935 this.showCommentBox = true;
936
937 // Clear any visible glyphs since we're showing the comment box
938 this.clearAllVisibleGlyphs();
939
940 // Request update to render the comment box
941 this.requestUpdate();
942 } catch (error) {
943 console.error("Error showing comment for selection:", error);
944 }
945 }
946
947 /**
948 * Show comment box for a range of lines
949 */
950 private showCommentForLines(
951 startLine: number,
952 endLine: number,
953 editorType: "original" | "modified",
954 ) {
955 try {
956 if (!this.editor) {
957 return;
958 }
959
960 const targetEditor =
961 editorType === "original"
962 ? this.editor.getOriginalEditor()
963 : this.editor.getModifiedEditor();
964 const targetModel =
965 editorType === "original" ? this.originalModel : this.modifiedModel;
966
967 if (!targetEditor || !targetModel) {
968 return;
969 }
970
971 // Get the text for the selected lines
972 const lines: string[] = [];
973 for (let i = startLine; i <= endLine; i++) {
974 if (i <= targetModel.getLineCount()) {
975 lines.push(targetModel.getLineContent(i));
976 }
977 }
978
979 const selectedText = lines.join("\n");
980
981 // Set the selected lines state
982 this.selectedLines = {
983 startLine,
984 endLine,
985 editorType,
986 text: selectedText,
987 };
988
989 // Calculate and set comment box position
990 this.commentBoxPosition = this.calculateCommentBoxPosition(
991 startLine,
992 editorType,
993 );
994
995 // Reset comment text and show the box
996 this.commentText = "";
997 this.showCommentBox = true;
998
999 // Clear any visible glyphs since we're showing the comment box
1000 this.clearAllVisibleGlyphs();
1001
1002 // Request update to render the comment box
1003 this.requestUpdate();
1004 } catch (error) {
1005 console.error("Error showing comment for lines:", error);
1006 }
1007 }
1008
1009 /**
1010 * Clear all currently visible glyphs
1011 */
1012 private clearAllVisibleGlyphs() {
1013 try {
1014 this.visibleGlyphs.forEach((glyphId) => {
1015 const element = this.container.value?.querySelector(`.${glyphId}`);
1016 if (element) {
1017 element.classList.remove("hover-visible");
1018 }
1019 });
1020 this.visibleGlyphs.clear();
1021 } catch (error) {
1022 console.error("Error clearing visible glyphs:", error);
1023 }
1024 }
1025
1026 /**
1027 * Toggle the visibility of a glyph decoration for a specific line
1028 */
1029 private toggleGlyphVisibility(lineNumber: number, visible: boolean) {
1030 try {
1031 // If making visible, clear all existing visible glyphs first
1032 if (visible) {
1033 this.clearAllVisibleGlyphs();
1034 }
1035
1036 // Find all glyph decorations for this line in both editors
1037 const selectors = [
1038 `comment-glyph-original-${lineNumber}`,
1039 `comment-glyph-modified-${lineNumber}`,
1040 ];
1041
1042 selectors.forEach((glyphId) => {
1043 const element = this.container.value?.querySelector(`.${glyphId}`);
1044 if (element) {
1045 if (visible) {
1046 element.classList.add("hover-visible");
1047 this.visibleGlyphs.add(glyphId);
1048 } else {
1049 element.classList.remove("hover-visible");
1050 this.visibleGlyphs.delete(glyphId);
1051 }
1052 }
1053 });
1054 } catch (error) {
1055 console.error("Error toggling glyph visibility:", error);
1056 }
1057 }
1058
1059 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001060 * Update editor options
1061 */
1062 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
1063 if (this.editor) {
1064 this.editor.updateOptions(value);
Philip Zeyliger0635c772025-06-25 12:01:16 -07001065 // Re-fit content after options change with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001066 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001067 setTimeout(() => {
1068 // Preserve scroll positions during options change
1069 const originalScrollTop =
1070 this.editor!.getOriginalEditor().getScrollTop();
1071 const modifiedScrollTop =
1072 this.editor!.getModifiedEditor().getScrollTop();
1073
1074 this.fitEditorToContent!();
1075
1076 // Restore scroll positions
1077 requestAnimationFrame(() => {
1078 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1079 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1080 });
1081 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001082 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001083 }
1084 }
1085
1086 /**
1087 * Toggle hideUnchangedRegions feature
1088 */
1089 toggleHideUnchangedRegions(enabled: boolean) {
1090 if (this.editor) {
1091 this.editor.updateOptions({
1092 hideUnchangedRegions: {
1093 enabled: enabled,
1094 contextLineCount: 3,
1095 minimumLineCount: 3,
1096 revealLineCount: 10,
1097 },
1098 });
Philip Zeyliger0635c772025-06-25 12:01:16 -07001099 // Re-fit content after toggling with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001100 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001101 setTimeout(() => {
1102 // Preserve scroll positions during toggle
1103 const originalScrollTop =
1104 this.editor!.getOriginalEditor().getScrollTop();
1105 const modifiedScrollTop =
1106 this.editor!.getModifiedEditor().getScrollTop();
1107
1108 this.fitEditorToContent!();
1109
1110 // Restore scroll positions
1111 requestAnimationFrame(() => {
1112 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1113 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1114 });
1115 }, 100);
David Crawshaw26f3f342025-06-14 19:58:32 +00001116 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001117 }
1118 }
1119
1120 // Models for the editor
1121 private originalModel?: monaco.editor.ITextModel;
1122 private modifiedModel?: monaco.editor.ITextModel;
1123
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001124 // Decoration collections for glyph decorations
1125 private originalDecorations?: monaco.editor.IEditorDecorationsCollection;
1126 private modifiedDecorations?: monaco.editor.IEditorDecorationsCollection;
1127
philip.zeyligerc0a44592025-06-15 21:24:57 -07001128 private async initializeEditor() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001129 try {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001130 // Load Monaco dynamically
1131 const monaco = await loadMonaco();
Autoformatter2f8464c2025-06-16 04:27:05 +00001132
philip.zeyliger7351cd92025-06-14 12:25:31 -07001133 // Disable semantic validation globally for TypeScript/JavaScript if available
1134 if (monaco.languages && monaco.languages.typescript) {
1135 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
1136 noSemanticValidation: true,
1137 });
1138 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
1139 noSemanticValidation: true,
1140 });
1141 }
Autoformatter8c463622025-05-16 21:54:17 +00001142
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001143 // First time initialization
1144 if (!this.editor) {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001145 // Ensure the container ref is available
1146 if (!this.container.value) {
1147 throw new Error(
1148 "Container element not available - component may not be fully rendered",
1149 );
1150 }
1151
David Crawshaw26f3f342025-06-14 19:58:32 +00001152 // Create the diff editor with auto-sizing configuration
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001153 this.editor = monaco.editor.createDiffEditor(this.container.value, {
David Crawshaw26f3f342025-06-14 19:58:32 +00001154 automaticLayout: false, // We'll resize manually
Autoformatter8c463622025-05-16 21:54:17 +00001155 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001156 theme: "vs", // Always use light mode
philip.zeyliger6b8b7662025-06-16 03:06:30 +00001157 renderSideBySide: !this.inline,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001158 ignoreTrimWhitespace: false,
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001159 // Enable glyph margin for both editors to show decorations
1160 glyphMargin: true,
David Crawshaw26f3f342025-06-14 19:58:32 +00001161 scrollbar: {
Philip Zeyligere0860932025-06-18 13:01:17 -07001162 // Ideally we'd handle the mouse wheel for the horizontal scrollbar,
1163 // but there doesn't seem to be that option. Setting
1164 // alwaysConsumeMousewheel false and handleMouseWheel true didn't
1165 // work for me.
1166 handleMouseWheel: false,
David Crawshaw26f3f342025-06-14 19:58:32 +00001167 },
Philip Zeyligere0860932025-06-18 13:01:17 -07001168 renderOverviewRuler: false, // Disable overview ruler
David Crawshaw26f3f342025-06-14 19:58:32 +00001169 scrollBeyondLastLine: false,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001170 // Focus on the differences by hiding unchanged regions
1171 hideUnchangedRegions: {
1172 enabled: true, // Enable the feature
Philip Zeyligere0860932025-06-18 13:01:17 -07001173 contextLineCount: 5, // Show 3 lines of context around each difference
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001174 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
1175 revealLineCount: 10, // Show 10 lines when expanding a hidden region
1176 },
1177 });
1178
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +00001179 this.setupKeyboardShortcuts();
1180
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001181 // If this is an editable view, set the correct read-only state for each editor
1182 if (this.editableRight) {
1183 // Make sure the original editor is always read-only
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001184 this.editor
1185 .getOriginalEditor()
1186 .updateOptions({ readOnly: true, glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001187 // Make sure the modified editor is editable
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001188 this.editor
1189 .getModifiedEditor()
1190 .updateOptions({ readOnly: false, glyphMargin: true });
1191 } else {
1192 // Ensure glyph margin is enabled on both editors even in read-only mode
1193 this.editor.getOriginalEditor().updateOptions({ glyphMargin: true });
1194 this.editor.getModifiedEditor().updateOptions({ glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001195 }
philip.zeyliger7351cd92025-06-14 12:25:31 -07001196
David Crawshaw26f3f342025-06-14 19:58:32 +00001197 // Set up auto-sizing
1198 this.setupAutoSizing();
1199
philip.zeyliger7351cd92025-06-14 12:25:31 -07001200 // Add Monaco editor to debug global
1201 this.addToDebugGlobal();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001202 }
1203
1204 // Create or update models
1205 this.updateModels();
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001206 // Add glyph decorations after models are set
1207 this.setupGlyphDecorations();
Autoformatter8c463622025-05-16 21:54:17 +00001208 // Set up content change listener
1209 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001210
philip.zeyliger7351cd92025-06-14 12:25:31 -07001211 // Fix cursor positioning issues by ensuring fonts are loaded
philip.zeyliger7351cd92025-06-14 12:25:31 -07001212 document.fonts.ready.then(() => {
1213 if (this.editor) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001214 // Preserve scroll positions during font remeasuring
1215 const originalScrollTop = this.editor
1216 .getOriginalEditor()
1217 .getScrollTop();
1218 const modifiedScrollTop = this.editor
1219 .getModifiedEditor()
1220 .getScrollTop();
1221
philip.zeyliger7351cd92025-06-14 12:25:31 -07001222 monaco.editor.remeasureFonts();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001223
1224 if (this.fitEditorToContent) {
1225 this.fitEditorToContent();
1226 }
1227
1228 // Restore scroll positions after font remeasuring
1229 requestAnimationFrame(() => {
1230 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1231 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1232 });
philip.zeyliger7351cd92025-06-14 12:25:31 -07001233 }
1234 });
1235
Philip Zeyliger0635c772025-06-25 12:01:16 -07001236 // Force layout recalculation after a short delay with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001237 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001238 if (this.editor && this.fitEditorToContent) {
1239 // Preserve scroll positions
1240 const originalScrollTop = this.editor
1241 .getOriginalEditor()
1242 .getScrollTop();
1243 const modifiedScrollTop = this.editor
1244 .getModifiedEditor()
1245 .getScrollTop();
1246
David Crawshaw26f3f342025-06-14 19:58:32 +00001247 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001248
1249 // Restore scroll positions
1250 requestAnimationFrame(() => {
1251 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1252 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1253 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001254 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001255 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001256 } catch (error) {
1257 console.error("Error initializing Monaco editor:", error);
1258 }
1259 }
1260
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001261 private updateModels() {
1262 try {
1263 // Get language based on filename
1264 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1265 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1266
1267 // Always create new models with unique URIs based on timestamp to avoid conflicts
1268 const timestamp = new Date().getTime();
1269 // TODO: Could put filename in these URIs; unclear how they're used right now.
philip.zeyligerc0a44592025-06-15 21:24:57 -07001270 const originalUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001271 `file:///original-${timestamp}.${originalLang}`,
1272 );
philip.zeyligerc0a44592025-06-15 21:24:57 -07001273 const modifiedUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001274 `file:///modified-${timestamp}.${modifiedLang}`,
1275 );
1276
1277 // Store references to old models
1278 const oldOriginalModel = this.originalModel;
1279 const oldModifiedModel = this.modifiedModel;
1280
1281 // Nullify instance variables to prevent accidental use
1282 this.originalModel = undefined;
1283 this.modifiedModel = undefined;
1284
1285 // Clear the editor model first to release Monaco's internal references
1286 if (this.editor) {
1287 this.editor.setModel(null);
1288 }
1289
1290 // Now it's safe to dispose the old models
1291 if (oldOriginalModel) {
1292 oldOriginalModel.dispose();
1293 }
1294
1295 if (oldModifiedModel) {
1296 oldModifiedModel.dispose();
1297 }
1298
1299 // Create new models
philip.zeyligerc0a44592025-06-15 21:24:57 -07001300 this.originalModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001301 this.originalCode || "",
1302 originalLang,
1303 originalUri,
1304 );
1305
philip.zeyligerc0a44592025-06-15 21:24:57 -07001306 this.modifiedModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001307 this.modifiedCode || "",
1308 modifiedLang,
1309 modifiedUri,
1310 );
1311
1312 // Set the new models on the editor
1313 if (this.editor) {
1314 this.editor.setModel({
1315 original: this.originalModel,
1316 modified: this.modifiedModel,
1317 });
Autoformatter9abf8032025-06-14 23:24:08 +00001318
David Crawshaw26f3f342025-06-14 19:58:32 +00001319 // Set initial hideUnchangedRegions state (default to enabled/collapsed)
1320 this.editor.updateOptions({
1321 hideUnchangedRegions: {
1322 enabled: true, // Default to collapsed state
1323 contextLineCount: 3,
1324 minimumLineCount: 3,
1325 revealLineCount: 10,
1326 },
1327 });
Autoformatter9abf8032025-06-14 23:24:08 +00001328
Philip Zeyliger0635c772025-06-25 12:01:16 -07001329 // Fit content after setting new models with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001330 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001331 setTimeout(() => {
1332 // Preserve scroll positions when fitting content after model changes
1333 const originalScrollTop =
1334 this.editor!.getOriginalEditor().getScrollTop();
1335 const modifiedScrollTop =
1336 this.editor!.getModifiedEditor().getScrollTop();
1337
1338 this.fitEditorToContent!();
1339
1340 // Restore scroll positions
1341 requestAnimationFrame(() => {
1342 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1343 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1344 });
1345 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001346 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001347
1348 // Add glyph decorations after setting new models
1349 setTimeout(() => this.setupGlyphDecorations(), 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001350 }
1351 this.setupContentChangeListener();
1352 } catch (error) {
1353 console.error("Error updating Monaco models:", error);
1354 }
1355 }
1356
philip.zeyligerc0a44592025-06-15 21:24:57 -07001357 async updated(changedProperties: Map<string, any>) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001358 // If any relevant properties changed, just update the models
1359 if (
1360 changedProperties.has("originalCode") ||
1361 changedProperties.has("modifiedCode") ||
1362 changedProperties.has("originalFilename") ||
1363 changedProperties.has("modifiedFilename") ||
1364 changedProperties.has("editableRight")
1365 ) {
1366 if (this.editor) {
1367 this.updateModels();
1368
David Crawshaw26f3f342025-06-14 19:58:32 +00001369 // Force auto-sizing after model updates
Philip Zeyliger0635c772025-06-25 12:01:16 -07001370 // Use a slightly longer delay to ensure layout is stable with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001371 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001372 if (this.fitEditorToContent && this.editor) {
1373 // Preserve scroll positions during model update layout
1374 const originalScrollTop = this.editor
1375 .getOriginalEditor()
1376 .getScrollTop();
1377 const modifiedScrollTop = this.editor
1378 .getModifiedEditor()
1379 .getScrollTop();
1380
David Crawshaw26f3f342025-06-14 19:58:32 +00001381 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001382
1383 // Restore scroll positions
1384 requestAnimationFrame(() => {
1385 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1386 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1387 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001388 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001389 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001390 } else {
1391 // If the editor isn't initialized yet but we received content,
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001392 // ensure we're connected before initializing
1393 await this.ensureConnectedToDocument();
philip.zeyligerc0a44592025-06-15 21:24:57 -07001394 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001395 }
1396 }
1397 }
1398
David Crawshaw26f3f342025-06-14 19:58:32 +00001399 // Set up auto-sizing for multi-file diff view
1400 private setupAutoSizing() {
1401 if (!this.editor) return;
1402
1403 const fitContent = () => {
1404 try {
1405 const originalEditor = this.editor!.getOriginalEditor();
1406 const modifiedEditor = this.editor!.getModifiedEditor();
Autoformatter9abf8032025-06-14 23:24:08 +00001407
David Crawshaw26f3f342025-06-14 19:58:32 +00001408 const originalHeight = originalEditor.getContentHeight();
1409 const modifiedHeight = modifiedEditor.getContentHeight();
Autoformatter9abf8032025-06-14 23:24:08 +00001410
David Crawshaw26f3f342025-06-14 19:58:32 +00001411 // Use the maximum height of both editors, plus some padding
1412 const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
Autoformatter9abf8032025-06-14 23:24:08 +00001413
David Crawshaw26f3f342025-06-14 19:58:32 +00001414 // Set both container and host height to enable proper scrolling
1415 if (this.container.value) {
1416 // Set explicit heights on both container and host
1417 this.container.value.style.height = `${maxHeight}px`;
1418 this.style.height = `${maxHeight}px`; // Update host element height
Autoformatter9abf8032025-06-14 23:24:08 +00001419
David Crawshaw26f3f342025-06-14 19:58:32 +00001420 // Emit the height change event BEFORE calling layout
1421 // This ensures parent containers resize first
Autoformatter9abf8032025-06-14 23:24:08 +00001422 this.dispatchEvent(
1423 new CustomEvent("monaco-height-changed", {
1424 detail: { height: maxHeight },
1425 bubbles: true,
1426 composed: true,
1427 }),
1428 );
1429
David Crawshaw26f3f342025-06-14 19:58:32 +00001430 // Layout after both this component and parents have updated
1431 setTimeout(() => {
1432 if (this.editor && this.container.value) {
1433 // Use explicit dimensions to ensure Monaco uses full available space
David Crawshawdba26b52025-06-15 00:33:45 +00001434 // Use clientWidth instead of offsetWidth to avoid border overflow
1435 const width = this.container.value.clientWidth;
David Crawshaw26f3f342025-06-14 19:58:32 +00001436 this.editor.layout({
1437 width: width,
Autoformatter9abf8032025-06-14 23:24:08 +00001438 height: maxHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +00001439 });
1440 }
1441 }, 10);
1442 }
1443 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +00001444 console.error("Error in fitContent:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +00001445 }
1446 };
1447
1448 // Store the fit function for external access
1449 this.fitEditorToContent = fitContent;
1450
1451 // Set up listeners for content size changes
1452 this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
1453 this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
1454
1455 // Initial fit
1456 fitContent();
1457 }
1458
1459 private fitEditorToContent: (() => void) | null = null;
1460
David Crawshawe2954ce2025-06-15 00:06:34 +00001461 /**
1462 * Set up window resize handler to ensure Monaco editor adapts to browser window changes
1463 */
1464 private setupWindowResizeHandler() {
1465 // Create a debounced resize handler to avoid too many layout calls
1466 let resizeTimeout: number | null = null;
Autoformatterad15b6c2025-06-15 00:29:26 +00001467
David Crawshawe2954ce2025-06-15 00:06:34 +00001468 this._windowResizeHandler = () => {
1469 // Clear any existing timeout
1470 if (resizeTimeout) {
1471 window.clearTimeout(resizeTimeout);
1472 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001473
David Crawshawe2954ce2025-06-15 00:06:34 +00001474 // Debounce the resize to avoid excessive layout calls
1475 resizeTimeout = window.setTimeout(() => {
1476 if (this.editor && this.container.value) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001477 // Trigger layout recalculation with scroll preservation
David Crawshawe2954ce2025-06-15 00:06:34 +00001478 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001479 // Preserve scroll positions during window resize
1480 const originalScrollTop = this.editor
1481 .getOriginalEditor()
1482 .getScrollTop();
1483 const modifiedScrollTop = this.editor
1484 .getModifiedEditor()
1485 .getScrollTop();
1486
David Crawshawe2954ce2025-06-15 00:06:34 +00001487 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001488
1489 // Restore scroll positions
1490 requestAnimationFrame(() => {
1491 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1492 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1493 });
David Crawshawe2954ce2025-06-15 00:06:34 +00001494 } else {
1495 // Fallback: just trigger a layout with current container dimensions
David Crawshawdba26b52025-06-15 00:33:45 +00001496 // Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
1497 const width = this.container.value.clientWidth;
1498 const height = this.container.value.clientHeight;
David Crawshawe2954ce2025-06-15 00:06:34 +00001499 this.editor.layout({ width, height });
1500 }
1501 }
1502 }, 100); // 100ms debounce
1503 };
Autoformatterad15b6c2025-06-15 00:29:26 +00001504
David Crawshawe2954ce2025-06-15 00:06:34 +00001505 // Add the event listener
Autoformatterad15b6c2025-06-15 00:29:26 +00001506 window.addEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001507 }
1508
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001509 // Add resize observer to ensure editor resizes when container changes
philip.zeyligerc0a44592025-06-15 21:24:57 -07001510 async firstUpdated() {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001511 // Ensure we're connected to the document before Monaco initialization
1512 await this.ensureConnectedToDocument();
1513
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001514 // Initialize the editor
philip.zeyligerc0a44592025-06-15 21:24:57 -07001515 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001516
David Crawshawe2954ce2025-06-15 00:06:34 +00001517 // Set up window resize handler to ensure Monaco editor adapts to browser window changes
1518 this.setupWindowResizeHandler();
1519
David Crawshaw26f3f342025-06-14 19:58:32 +00001520 // For multi-file diff, we don't use ResizeObserver since we control the size
1521 // Instead, we rely on auto-sizing based on content
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001522
1523 // If editable, set up edit mode and content change listener
1524 if (this.editableRight && this.editor) {
1525 // Ensure the original editor is read-only
1526 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1527 // Ensure the modified editor is editable
1528 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1529 }
1530 }
1531
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001532 /**
1533 * Ensure this component and its container are properly connected to the document.
1534 * Monaco editor requires the container to be in the document for proper initialization.
1535 */
1536 private async ensureConnectedToDocument(): Promise<void> {
1537 // Wait for our own render to complete
1538 await this.updateComplete;
1539
1540 // Verify the container ref is available
1541 if (!this.container.value) {
1542 throw new Error("Container element not available after updateComplete");
1543 }
1544
1545 // Check if we're connected to the document
1546 if (!this.isConnected) {
1547 throw new Error("Component is not connected to the document");
1548 }
1549
1550 // Verify the container is also in the document
1551 if (!this.container.value.isConnected) {
1552 throw new Error("Container element is not connected to the document");
1553 }
1554 }
1555
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001556 private _resizeObserver: ResizeObserver | null = null;
David Crawshawe2954ce2025-06-15 00:06:34 +00001557 private _windowResizeHandler: (() => void) | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001558
philip.zeyliger7351cd92025-06-14 12:25:31 -07001559 /**
1560 * Add this Monaco editor instance to the global debug object
1561 * This allows inspection and debugging via browser console
1562 */
1563 private addToDebugGlobal() {
1564 try {
1565 // Initialize the debug global if it doesn't exist
1566 if (!(window as any).sketchDebug) {
1567 (window as any).sketchDebug = {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001568 monaco: window.monaco!,
philip.zeyliger7351cd92025-06-14 12:25:31 -07001569 editors: [],
1570 remeasureFonts: () => {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001571 window.monaco!.editor.remeasureFonts();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001572 (window as any).sketchDebug.editors.forEach(
philip.zeyliger26bc6592025-06-30 20:15:30 -07001573 (editor: any, _index: number) => {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001574 if (editor && editor.layout) {
1575 editor.layout();
1576 }
1577 },
1578 );
1579 },
1580 layoutAll: () => {
1581 (window as any).sketchDebug.editors.forEach(
philip.zeyliger26bc6592025-06-30 20:15:30 -07001582 (editor: any, _index: number) => {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001583 if (editor && editor.layout) {
1584 editor.layout();
1585 }
1586 },
1587 );
1588 },
1589 getActiveEditors: () => {
1590 return (window as any).sketchDebug.editors.filter(
1591 (editor: any) => editor !== null,
1592 );
1593 },
1594 };
1595 }
1596
1597 // Add this editor to the debug collection
1598 if (this.editor) {
1599 (window as any).sketchDebug.editors.push(this.editor);
1600 }
1601 } catch (error) {
1602 console.error("Error adding Monaco editor to debug global:", error);
1603 }
1604 }
1605
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001606 disconnectedCallback() {
1607 super.disconnectedCallback();
1608
1609 try {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001610 // Remove editor from debug global before disposal
1611 if (this.editor && (window as any).sketchDebug?.editors) {
1612 const index = (window as any).sketchDebug.editors.indexOf(this.editor);
1613 if (index > -1) {
1614 (window as any).sketchDebug.editors[index] = null;
1615 }
1616 }
1617
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001618 // Clean up decorations
1619 if (this.originalDecorations) {
1620 this.originalDecorations.clear();
1621 this.originalDecorations = undefined;
1622 }
1623
1624 if (this.modifiedDecorations) {
1625 this.modifiedDecorations.clear();
1626 this.modifiedDecorations = undefined;
1627 }
1628
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001629 // Clean up resources when element is removed
1630 if (this.editor) {
1631 this.editor.dispose();
1632 this.editor = undefined;
1633 }
1634
1635 // Dispose models to prevent memory leaks
1636 if (this.originalModel) {
1637 this.originalModel.dispose();
1638 this.originalModel = undefined;
1639 }
1640
1641 if (this.modifiedModel) {
1642 this.modifiedModel.dispose();
1643 this.modifiedModel = undefined;
1644 }
1645
David Crawshaw26f3f342025-06-14 19:58:32 +00001646 // Clean up resize observer (if any)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001647 if (this._resizeObserver) {
1648 this._resizeObserver.disconnect();
1649 this._resizeObserver = null;
1650 }
Autoformatter9abf8032025-06-14 23:24:08 +00001651
David Crawshaw26f3f342025-06-14 19:58:32 +00001652 // Clear the fit function reference
1653 this.fitEditorToContent = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001654
David Crawshawe2954ce2025-06-15 00:06:34 +00001655 // Remove window resize handler if set
1656 if (this._windowResizeHandler) {
Autoformatterad15b6c2025-06-15 00:29:26 +00001657 window.removeEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001658 this._windowResizeHandler = null;
1659 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001660
1661 // Clear visible glyphs tracking
1662 this.visibleGlyphs.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001663 } catch (error) {
1664 console.error("Error in disconnectedCallback:", error);
1665 }
1666 }
1667
1668 // disconnectedCallback implementation is defined below
1669}
1670
1671declare global {
1672 interface HTMLElementTagNameMap {
1673 "sketch-monaco-view": CodeDiffEditor;
1674 }
1675}