diff --git a/apps/ui/project.json b/apps/ui/project.json index 03e1b4d..455bb10 100644 --- a/apps/ui/project.json +++ b/apps/ui/project.json @@ -14,7 +14,15 @@ "main": "apps/ui/src/main.ts", "polyfills": ["zone.js"], "tsConfig": "apps/ui/tsconfig.app.json", - "assets": ["apps/ui/src/favicon.ico", "apps/ui/src/assets"], + "assets": [ + "apps/ui/src/favicon.ico", + "apps/ui/src/assets", + { + "glob": "**/*", + "input": "node_modules/monaco-editor/min/vs", + "output": "/assets/monaco/vs" + } + ], "styles": ["node_modules/bootstrap/dist/css/bootstrap.min.css", "apps/ui/src/styles.scss"], "scripts": [] }, diff --git a/apps/ui/src/app/playground/playground.component.css b/apps/ui/src/app/playground/playground.component.css index 56833cd..85f074c 100644 --- a/apps/ui/src/app/playground/playground.component.css +++ b/apps/ui/src/app/playground/playground.component.css @@ -115,6 +115,102 @@ background: #777; } +.monaco-editor-wrapper { + border-radius: 8px; + border: 1px solid #404040; + overflow: hidden; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.monaco-editor-wrapper.monaco-editor-invalid { + border-color: #dc3545; + border-width: 3px; + box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25); +} + +.monaco-editor-container { + overflow: hidden; +} + +.editor-resize-handle { + height: 14px; + cursor: row-resize; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + transition: background 0.15s; + margin-top: 4px; +} + +.editor-resize-handle:hover, +.editor-resize-handle:active { + background: rgba(100, 149, 237, 0.15); +} + +.editor-resize-grip { + width: 40px; + height: 4px; + border-radius: 2px; + background: #6c757d; + transition: background 0.15s, width 0.15s; +} + +.editor-resize-handle:hover .editor-resize-grip, +.editor-resize-handle:active .editor-resize-grip { + background: #0d6efd; + width: 60px; +} + +/* ── Resizable split pane ─────────────────────────────────── */ + +.split-pane { + display: flex; + flex-direction: row; + width: 100%; + min-height: 0; +} + +.split-pane-left, +.split-pane-right { + overflow: auto; + flex-shrink: 0; + flex-grow: 0; + min-width: 0; + padding: 0 12px; +} + +.split-divider { + flex: 0 0 8px; + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + transition: background 0.15s; + z-index: 10; + position: relative; +} + +.split-divider:hover, +.split-divider:active { + background: rgba(100, 149, 237, 0.15); +} + +.split-divider-handle { + width: 4px; + height: 40px; + border-radius: 2px; + background: #6c757d; + transition: background 0.15s, height 0.15s; +} + +.split-divider:hover .split-divider-handle, +.split-divider:active .split-divider-handle { + background: #0d6efd; + height: 60px; +} + .table-bordered th, .table-bordered td { border: 1px solid #dee2e6; diff --git a/apps/ui/src/app/playground/playground.component.html b/apps/ui/src/app/playground/playground.component.html index 35a28f1..ea16d1f 100644 --- a/apps/ui/src/app/playground/playground.component.html +++ b/apps/ui/src/app/playground/playground.component.html @@ -1,7 +1,7 @@
-
+
-
+

Configuration

@@ -16,7 +16,16 @@

Configuration

- +
+
+
+
+
+
+
+ +
+
+
+ -
+

Live Logs

diff --git a/apps/ui/src/app/playground/playground.component.ts b/apps/ui/src/app/playground/playground.component.ts index a9fd79f..6d26804 100644 --- a/apps/ui/src/app/playground/playground.component.ts +++ b/apps/ui/src/app/playground/playground.component.ts @@ -1,8 +1,10 @@ -import { Component, ViewChild, ElementRef, OnInit, AfterViewChecked, OnDestroy, NgZone, ChangeDetectorRef } from '@angular/core'; +import { Component, ViewChild, ElementRef, OnInit, AfterViewInit, AfterViewChecked, OnDestroy, NgZone, ChangeDetectorRef } from '@angular/core'; import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { PlaygroundService, RenovateLogMessage, RenovateRunResult } from './playground.service'; import { Subscription } from 'rxjs'; + +declare const require: any; // Define the Dependency interface outside the component class interface Dependency { type: string; @@ -30,8 +32,9 @@ interface LogEntry { templateUrl: './playground.component.html', styleUrls: ['./playground.component.css'], }) -export class PlaygroundComponent implements OnInit, AfterViewChecked, OnDestroy { +export class PlaygroundComponent implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy { @ViewChild('logContainer') private readonly logContainer: ElementRef; + @ViewChild('monacoContainer', { static: false }) private readonly monacoContainer: ElementRef; renovateForm: FormGroup; isRunning = false; @@ -40,6 +43,26 @@ export class PlaygroundComponent implements OnInit, AfterViewChecked, OnDestroy private currentEventSource: { close: () => void } | null = null; private currentSubscription: Subscription | null = null; private shouldAutoScroll = true; + private monacoEditor: any; + private editorInitialized = false; + + // Resizable split pane + splitPosition = 50; // percentage for left pane + private readonly MIN_SPLIT = 5; // minimum percentage for either pane + private readonly MAX_SPLIT = 95; // maximum percentage for either pane + private isDragging = false; + private boundMouseMove: ((e: MouseEvent) => void) | null = null; + private boundMouseUp: (() => void) | null = null; + + // Resizable Monaco editor height + editorHeight = 350; // pixels + private readonly MIN_EDITOR_HEIGHT = 100; + private readonly MAX_EDITOR_HEIGHT = 800; + private isEditorResizing = false; + private editorResizeStartY = 0; + private editorResizeStartHeight = 0; + private boundEditorResizeMove: ((e: MouseEvent) => void) | null = null; + private boundEditorResizeUp: (() => void) | null = null; constructor( private readonly fb: FormBuilder, @@ -59,10 +82,79 @@ export class PlaygroundComponent implements OnInit, AfterViewChecked, OnDestroy }); } + ngAfterViewInit(): void { + this.initMonaco(); + } + ngAfterViewChecked(): void { this.scrollToBottom(); } + private initMonaco(): void { + if (this.editorInitialized) return; + + const onGotAmdLoader = () => { + const vsPath = 'assets/monaco/vs'; + (window as any).require.config({ paths: { vs: vsPath } }); + (window as any).require(['vs/editor/editor.main'], () => { + this.ngZone.run(() => this.createEditor()); + }); + }; + + // Load AMD loader if not already loaded + if (!(window as any).require) { + const loaderScript = document.createElement('script'); + loaderScript.type = 'text/javascript'; + loaderScript.src = 'assets/monaco/vs/loader.js'; + loaderScript.addEventListener('load', onGotAmdLoader); + document.body.appendChild(loaderScript); + } else { + onGotAmdLoader(); + } + } + + private createEditor(): void { + if (!this.monacoContainer?.nativeElement) return; + + const monaco = (window as any).monaco; + const initialValue = this.renovateForm.get('renovateConfig')?.value || '{}'; + + this.monacoEditor = monaco.editor.create(this.monacoContainer.nativeElement, { + value: initialValue, + language: 'json', + theme: 'vs-dark', + automaticLayout: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 14, + lineNumbers: 'on', + roundedSelection: false, + scrollbar: { + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, + tabSize: 2, + formatOnPaste: true, + formatOnType: true, + }); + + // Sync editor content back to the form control + this.monacoEditor.onDidChangeModelContent(() => { + const value = this.monacoEditor.getValue(); + this.renovateForm.get('renovateConfig')?.setValue(value, { emitEvent: false }); + this.renovateForm.get('renovateConfig')?.updateValueAndValidity(); + }); + + // Listen for form control changes to update editor + this.renovateForm.get('renovateConfig')?.valueChanges.subscribe((value: string) => { + if (this.monacoEditor && value !== this.monacoEditor.getValue()) { + this.monacoEditor.setValue(value); + } + }); + + this.editorInitialized = true; + } + runRenovate(): void { if (this.renovateForm.invalid) { return; @@ -361,6 +453,10 @@ export class PlaygroundComponent implements OnInit, AfterViewChecked, OnDestroy ngOnDestroy(): void { this.cleanupConnections(); + if (this.monacoEditor) { + this.monacoEditor.dispose(); + this.monacoEditor = null; + } } private cleanupConnections(): void { @@ -449,4 +545,109 @@ export class PlaygroundComponent implements OnInit, AfterViewChecked, OnDestroy return 'Unknown'; } } + + // ── Resizable split pane ────────────────────────────────────── + + onDividerMouseDown(event: MouseEvent): void { + event.preventDefault(); + this.isDragging = true; + + this.boundMouseMove = (e: MouseEvent) => this.onMouseMove(e); + this.boundMouseUp = () => this.onMouseUp(); + + document.addEventListener('mousemove', this.boundMouseMove); + document.addEventListener('mouseup', this.boundMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + private onMouseMove(event: MouseEvent): void { + if (!this.isDragging) return; + + const container = document.querySelector('.split-pane') as HTMLElement; + if (!container) return; + + const rect = container.getBoundingClientRect(); + let pct = ((event.clientX - rect.left) / rect.width) * 100; + + // Clamp to min/max + pct = Math.max(this.MIN_SPLIT, Math.min(this.MAX_SPLIT, pct)); + + this.ngZone.run(() => { + this.splitPosition = pct; + // Notify Monaco that its container size changed + if (this.monacoEditor) { + this.monacoEditor.layout(); + } + }); + } + + private onMouseUp(): void { + this.isDragging = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + if (this.boundMouseMove) { + document.removeEventListener('mousemove', this.boundMouseMove); + this.boundMouseMove = null; + } + if (this.boundMouseUp) { + document.removeEventListener('mouseup', this.boundMouseUp); + this.boundMouseUp = null; + } + } + + resetSplitPosition(): void { + this.splitPosition = 50; + if (this.monacoEditor) { + this.monacoEditor.layout(); + } + } + + // ── Resizable Monaco editor height ──────────────────────── + + onEditorResizeMouseDown(event: MouseEvent): void { + event.preventDefault(); + this.isEditorResizing = true; + this.editorResizeStartY = event.clientY; + this.editorResizeStartHeight = this.editorHeight; + + this.boundEditorResizeMove = (e: MouseEvent) => this.onEditorResizeMove(e); + this.boundEditorResizeUp = () => this.onEditorResizeUp(); + + document.addEventListener('mousemove', this.boundEditorResizeMove); + document.addEventListener('mouseup', this.boundEditorResizeUp); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + } + + private onEditorResizeMove(event: MouseEvent): void { + if (!this.isEditorResizing) return; + + const delta = event.clientY - this.editorResizeStartY; + let newHeight = this.editorResizeStartHeight + delta; + newHeight = Math.max(this.MIN_EDITOR_HEIGHT, Math.min(this.MAX_EDITOR_HEIGHT, newHeight)); + + this.ngZone.run(() => { + this.editorHeight = newHeight; + if (this.monacoEditor) { + this.monacoEditor.layout(); + } + }); + } + + private onEditorResizeUp(): void { + this.isEditorResizing = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + if (this.boundEditorResizeMove) { + document.removeEventListener('mousemove', this.boundEditorResizeMove); + this.boundEditorResizeMove = null; + } + if (this.boundEditorResizeUp) { + document.removeEventListener('mouseup', this.boundEditorResizeUp); + this.boundEditorResizeUp = null; + } + } } diff --git a/package.json b/package.json index 96ed3a4..4ccc4b5 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@nestjs/typeorm": "^11.0.0", "@types/sqlite3": "^5.0.0", "bootstrap": "^5.3.3", + "monaco-editor": "^0.55.1", "renovate": "^41.46.5", "rxjs": "~7.8.0", "sqlite3": "^5.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53b68ab..314bbcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: bootstrap: specifier: ^5.3.3 version: 5.3.7(@popperjs/core@2.11.8) + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 renovate: specifier: ^41.46.5 version: 41.173.1(encoding@0.1.13)(typanion@3.14.0) @@ -4362,6 +4365,9 @@ packages: '@types/treeify@1.0.3': resolution: {integrity: sha512-hx0o7zWEUU4R2Amn+pjCBQQt23Khy/Dk56gQU5xi5jtPL1h83ACJCeFaB2M/+WO1AntvWrSoVnnCAfI1AQH4Cg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -6046,6 +6052,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -8143,6 +8152,11 @@ packages: peerDependencies: marked: '>=1 <16' + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} @@ -8496,6 +8510,9 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} @@ -17961,6 +17978,9 @@ snapshots: '@types/treeify@1.0.3': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@types/ws@8.18.1': @@ -19803,6 +19823,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -22479,6 +22503,8 @@ snapshots: node-emoji: 2.2.0 supports-hyperlinks: 3.2.0 + marked@14.0.0: {} + marked@15.0.12: {} matcher@3.0.0: @@ -22998,6 +23024,11 @@ snapshots: moment@2.30.1: optional: true + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + moo@0.5.2: {} more-entropy@0.0.7: