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: