feat(plugins): add syntax highlighting for script viewer and fix blank exclusions handling
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details

main
Juan Manuel Bardallo 2026-10-22 11:23:33 +02:00
parent 67572c313c
commit a5c4b7b43a
4 changed files with 363 additions and 51 deletions

View File

@ -10,10 +10,12 @@
- Generador dinámico de formularios de configuración basados en esquemas JSON (`app-plugin-settings`) con botón de autogeneración y actualización reactiva de scripts según los parámetros seleccionados.
- Soporte de insignias de estado (badges) contextuales por cliente en las vistas de mosaico y lista de equipos (`groups.component`).
- Nuevo elemento condicional "Plugins" en el menú lateral (`sidebar`), visible únicamente cuando existen plugins activos en el sistema.
- Resaltador de sintaxis enriquecido y visor de terminal para scripts de configuración con numeración de líneas, colores semánticos (comentarios en verde itálica, comandos, parámetros, cadenas), botón de copiado rápido y alternancia entre vista coloreada y editor manual.
- Comportamiento 100% neutro e idéntico a 1.15.0 si no hay plugins instalados.
### Fixed
- **Recarga de página en Plugins (`plugins.component`, `plugin-settings.component`)**: Corregido el problema de sincronización reactiva al refrescar directamente (F5) la vista de `/plugins`, añadiendo un estado de carga visual y suscripción a `plugins$` para evitar mostrar prematuramente la pantalla de "No hay plugins instalados".
- **Rutas excluidas vacías en ogfreeze (`plugin-settings.component`)**: Corregida la regeneración de scripts cuando el campo de rutas excluidas se deja intencionadamente en blanco; ahora omite de forma limpia el bloque de exclusiones en lugar de forzar los valores predeterminados.
## [1.15.0] - 2026-10-20

View File

@ -181,46 +181,203 @@
height: 48px;
}
/* Visor de código para scripts con alto contraste */
.code-field {
/* Editor de código y visor con resaltado de sintaxis */
.code-editor-box {
margin-top: 4px;
background-color: #0b1120;
border: 1px solid #1e293b;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.code-field .mat-mdc-text-field-wrapper {
background-color: #0b1120 !important;
border-radius: 8px !important;
.code-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #0f172a;
padding: 8px 14px;
border-bottom: 1px solid #1e293b;
}
textarea.code-textarea,
.code-field textarea.mat-mdc-input-element {
font-family: 'Consolas', 'Fira Code', 'Monaco', monospace !important;
font-size: 0.92rem !important;
line-height: 1.6 !important;
background-color: #0b1120 !important;
color: #38bdf8 !important;
-webkit-text-fill-color: #38bdf8 !important;
caret-color: #38bdf8 !important;
padding: 14px !important;
border-radius: 6px !important;
.code-header-left {
display: flex;
align-items: center;
gap: 7px;
}
.code-field .mdc-notched-outline__leading,
.code-field .mdc-notched-outline__notch,
.code-field .mdc-notched-outline__trailing {
border-color: #334155 !important;
.terminal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.code-field:hover .mdc-notched-outline__leading,
.code-field:hover .mdc-notched-outline__notch,
.code-field:hover .mdc-notched-outline__trailing {
border-color: #64748b !important;
.terminal-dot.red {
background-color: #ef4444;
}
.code-field.mat-focused .mdc-notched-outline__leading,
.code-field.mat-focused .mdc-notched-outline__notch,
.code-field.mat-focused .mdc-notched-outline__trailing {
border-color: #38bdf8 !important;
border-width: 2px !important;
.terminal-dot.yellow {
background-color: #f59e0b;
}
.terminal-dot.green {
background-color: #10b981;
}
.code-lang-badge {
font-family: 'Consolas', 'Fira Code', 'Monaco', monospace;
font-size: 0.72rem;
font-weight: 600;
color: #94a3b8;
background: #1e293b;
padding: 2px 8px;
border-radius: 4px;
margin-left: 8px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.code-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.code-action-btn {
display: flex;
align-items: center;
gap: 5px;
background: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
border-radius: 6px;
padding: 4px 10px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.code-action-btn:hover {
background: #334155;
color: #f8fafc;
border-color: #475569;
}
.code-action-btn.active {
background: #0284c7;
color: #ffffff;
border-color: #0284c7;
}
.action-btn-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
.code-preview-scroll {
max-height: 280px;
overflow-y: auto;
overflow-x: auto;
padding: 10px 0;
background: #0b1120;
}
.code-lines-table {
display: flex;
flex-direction: column;
width: 100%;
font-family: 'Consolas', 'Fira Code', 'Monaco', monospace;
font-size: 0.92rem;
line-height: 1.6;
}
.code-line-row {
display: flex;
width: 100%;
transition: background-color 0.15s;
}
.code-line-row:hover {
background-color: rgba(255, 255, 255, 0.04);
}
.line-number {
min-width: 42px;
padding: 0 12px;
text-align: right;
color: #475569;
user-select: none;
border-right: 1px solid #1e293b;
font-size: 0.82rem;
}
.line-code {
padding: 0 16px;
white-space: pre;
color: #f8fafc;
flex: 1;
}
/* Tokens de resaltado de sintaxis */
.syntax-comment {
color: #86efac;
font-style: italic;
}
.syntax-cmd {
color: #38bdf8;
font-weight: 600;
}
.syntax-keyword {
color: #c084fc;
font-weight: 500;
}
.syntax-flag {
color: #f472b6;
}
.syntax-string {
color: #fde047;
}
.syntax-val {
color: #34d399;
font-weight: 600;
}
.syntax-num {
color: #fb923c;
}
.code-textarea-wrapper {
padding: 10px;
background: #0b1120;
}
.code-raw-textarea {
width: 100%;
background-color: #090d16;
color: #f8fafc;
border: 1px solid #334155;
border-radius: 6px;
padding: 12px;
font-family: 'Consolas', 'Fira Code', 'Monaco', monospace;
font-size: 0.92rem;
line-height: 1.6;
resize: vertical;
box-sizing: border-box;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.code-raw-textarea:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 1px #38bdf8;
}
.form-actions-bar {

View File

@ -58,7 +58,7 @@
@switch (field.type) {
@case ('select') {
<mat-form-field appearance="outline" class="w-100">
<mat-select [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
<mat-select [id]="'field-' + field.key" [(ngModel)]="configData[field.key]" (ngModelChange)="onParamChange()">
@for (opt of field.options; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
@ -68,7 +68,7 @@
@case ('checkbox') {
<div class="toggle-wrapper">
<mat-slide-toggle [id]="'field-' + field.key" color="primary" [(ngModel)]="configData[field.key]">
<mat-slide-toggle [id]="'field-' + field.key" color="primary" [(ngModel)]="configData[field.key]" (ngModelChange)="onParamChange()">
{{ configData[field.key] ? 'Habilitado' : 'Deshabilitado' }}
</mat-slide-toggle>
</div>
@ -76,20 +76,61 @@
@case ('number') {
<mat-form-field appearance="outline" class="w-100">
<input matInput type="number" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
<input matInput type="number" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]" (ngModelChange)="onParamChange()">
</mat-form-field>
}
@case ('text') {
<mat-form-field appearance="outline" class="w-100">
<input matInput type="text" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
<input matInput type="text" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]" (ngModelChange)="onParamChange()">
</mat-form-field>
}
@case ('textarea') {
<mat-form-field appearance="outline" class="w-100 code-field">
<textarea matInput rows="7" [id]="'field-' + field.key" class="code-textarea" [(ngModel)]="configData[field.key]"></textarea>
</mat-form-field>
<div class="code-editor-box">
<div class="code-editor-header">
<div class="code-header-left">
<span class="terminal-dot red"></span>
<span class="terminal-dot yellow"></span>
<span class="terminal-dot green"></span>
<span class="code-lang-badge">PowerShell</span>
</div>
<div class="code-header-actions">
<button type="button" class="code-action-btn" (click)="copyToClipboard(configData[field.key])" title="Copiar script">
<mat-icon class="action-btn-icon">content_copy</mat-icon>
<span>Copiar</span>
</button>
<button type="button" class="code-action-btn" [class.active]="isEditingScript(field.key)" (click)="toggleScriptEdit(field.key)">
<mat-icon class="action-btn-icon">{{ isEditingScript(field.key) ? 'visibility' : 'edit' }}</mat-icon>
<span>{{ isEditingScript(field.key) ? 'Ver coloreado' : 'Editar código' }}</span>
</button>
</div>
</div>
@if (!isEditingScript(field.key)) {
<div class="code-preview-scroll">
<div class="code-lines-table">
@for (line of getScriptLines(configData[field.key]); track $index) {
<div class="code-line-row">
<span class="line-number">{{ $index + 1 }}</span>
<span class="line-code" [innerHTML]="line"></span>
</div>
}
</div>
</div>
} @else {
<div class="code-textarea-wrapper">
<textarea
[id]="'field-' + field.key"
class="code-raw-textarea"
rows="9"
[(ngModel)]="configData[field.key]"
(ngModelChange)="onManualScriptInput(field.key)"
spellcheck="false"
></textarea>
</div>
}
</div>
}
}
</div>

View File

@ -1,6 +1,7 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { PluginManifest, PluginService, SchemaField } from '@services/plugin.service';
import { ToastrService } from 'ngx-toastr';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
@Component({
@ -14,7 +15,11 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
plugin?: PluginManifest;
schema: SchemaField[] = [];
configData: Record<string, any> = {};
editingScripts: Record<string, boolean> = {};
hasManualScriptEdits: boolean = false;
private pluginSub?: Subscription;
private sanitizer = inject(DomSanitizer);
constructor(
private pluginService: PluginService,
@ -51,41 +56,147 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
return this.schema.some(f => f.key === 'freeze_script' || f.key === 'unfreeze_script');
}
regenerateScripts(): void {
onParamChange(): void {
if (!this.hasManualScriptEdits) {
this.regenerateScripts(false);
}
}
onManualScriptInput(key: string): void {
this.hasManualScriptEdits = true;
}
isEditingScript(key: string): boolean {
return !!this.editingScripts[key];
}
toggleScriptEdit(key: string): void {
this.editingScripts[key] = !this.editingScripts[key];
}
copyToClipboard(text: string): void {
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
this.toast.success('Script copiado al portapapeles', 'Portapapeles');
}).catch(() => {
this.toast.error('No se pudo copiar el script al portapapeles', 'Error');
});
}
getScriptLines(script: string): SafeHtml[] {
if (!script) return [];
const lines = script.split('\n');
return lines.map(line => this.highlightLine(line));
}
private escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
private highlightLine(rawLine: string): SafeHtml {
const trimmed = rawLine.trim();
if (trimmed.length === 0) {
return this.sanitizer.bypassSecurityTrustHtml('&nbsp;');
}
// Comentario completo
if (trimmed.startsWith('#')) {
const escaped = this.escapeHtml(rawLine);
return this.sanitizer.bypassSecurityTrustHtml(`<span class="syntax-comment">${escaped}</span>`);
}
let escaped = this.escapeHtml(rawLine);
// 1. Cadenas entre comillas dobles o simples
const strings: string[] = [];
escaped = escaped.replace(/(["'])(.*?)\1/g, (match) => {
strings.push(match);
return `___STR_${strings.length - 1}___`;
});
// 2. Parámetros / flags (/r, /t, -c, etc.)
escaped = escaped.replace(/(\s)([\/-][a-zA-Z0-9?]+)/g, '$1<span class="syntax-flag">$2</span>');
// 3. Comandos ejecutables
escaped = escaped.replace(/\b(uwfmgr\.exe|shutdown|powershell\.exe|cmd\.exe)\b/gi, '<span class="syntax-cmd">$1</span>');
// 4. Subcomandos y palabras clave
escaped = escaped.replace(/\b(overlay|filter|volume|file|enable|disable|protect|set-type|set-size|add-exclusion)\b/gi, '<span class="syntax-keyword">$1</span>');
// 5. Valores y tipos
escaped = escaped.replace(/\b(RAM|DISK|true|false)\b/gi, '<span class="syntax-val">$1</span>');
// 6. Números
escaped = escaped.replace(/\b(\d+)\b/g, '<span class="syntax-num">$1</span>');
// Restaurar cadenas
escaped = escaped.replace(/___STR_(\d+)___/g, (_, index) => {
const original = strings[Number(index)];
return `<span class="syntax-string">${original}</span>`;
});
return this.sanitizer.bypassSecurityTrustHtml(escaped);
}
regenerateScripts(showToast: boolean = true): void {
if (!this.pluginName) return;
if (this.pluginName === 'ogfreeze') {
const overlayType = this.configData['overlay_type'] || 'RAM';
const overlaySize = this.configData['overlay_size'] || 2048;
const protectC = this.configData['protect_c'] !== false;
const exclusionsRaw = this.configData['exclusions'] || 'C:\\.ogimage, C:\\OpenGnsys';
const exclusions = exclusionsRaw
.split(',')
.map((s: string) => s.trim())
.filter((s: string) => s.length > 0);
let freezeScript = `# 1. Excluir metadatos y carpetas del filtro UWF\n`;
for (const excl of exclusions) {
freezeScript += `uwfmgr.exe file add-exclusion "${excl}"\n`;
// Obtener exclusiones de forma estricta (no usar fallback si el usuario las borró)
const rawExcl = this.configData['exclusions'];
const exclusionsRaw = (rawExcl !== undefined && rawExcl !== null) ? String(rawExcl).trim() : '';
const exclusions = exclusionsRaw.length > 0
? exclusionsRaw.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0)
: [];
let freezeScript = '';
let step = 1;
// Solo incluir el paso de exclusiones si el usuario ha especificado rutas
if (exclusions.length > 0) {
freezeScript += `# ${step}. Excluir metadatos y carpetas del filtro UWF\n`;
for (const excl of exclusions) {
freezeScript += `uwfmgr.exe file add-exclusion "${excl}"\n`;
}
freezeScript += `\n`;
step++;
}
freezeScript += `\n# 2. Configurar buffer temporal de escritura\n`;
freezeScript += `# ${step}. Configurar buffer temporal de escritura\n`;
freezeScript += `uwfmgr.exe overlay set-type ${overlayType}\n`;
freezeScript += `uwfmgr.exe overlay set-size ${overlaySize}\n\n`;
step++;
freezeScript += `# 3. Proteger volumen y habilitar filtro\n`;
freezeScript += `# ${step}. Proteger volumen y habilitar filtro\n`;
if (protectC) {
freezeScript += `uwfmgr.exe volume protect C:\n`;
}
freezeScript += `uwfmgr.exe filter enable\n\n`;
freezeScript += `# 4. Reinicio obligatorio para que entre en vigor\n`;
step++;
freezeScript += `# ${step}. Reinicio obligatorio para que entre en vigor\n`;
freezeScript += `shutdown /r /t 5 -c "OpenGnsys: Reiniciando para aplicar congelacion de sistema"`;
this.configData['freeze_script'] = freezeScript;
this.configData['unfreeze_script'] = `# Desactivar filtro de protección UWF en el próximo inicio\nuwfmgr.exe filter disable\n\n# Reiniciar para aplicar cambios\nshutdown /r /t 5 -c "OpenGnsys: Reiniciando para descongelar sistema"`;
this.toast.info('Scripts de PowerShell generados a partir de los parámetros actuales', this.plugin?.title || 'Plugin');
this.hasManualScriptEdits = false;
if (showToast) {
this.toast.info('Scripts de PowerShell generados a partir de los parámetros actuales', this.plugin?.title || 'Plugin');
}
} else {
this.toast.info('Scripts actualizados con los parámetros predeterminados', this.plugin?.title || 'Plugin');
if (showToast) {
this.toast.info('Scripts actualizados con los parámetros predeterminados', this.plugin?.title || 'Plugin');
}
}
}
@ -98,6 +209,7 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
reset(): void {
if (!this.pluginName) return;
this.configData = this.pluginService.resetPluginConfig(this.pluginName);
this.hasManualScriptEdits = false;
this.toast.info('Configuración restaurada a valores por defecto', this.plugin?.title || 'Plugin');
}
}