LANCommander/LANCommander.Server/UI/Components/PowerShellConsole.razor
Pat Hartl 885032547b Improve script editor with context-aware completions, validation, and UX
- Filter variable completions and hover by script type so only relevant variables appear (e.g. $AllocatedKey only in KeyChange scripts)
- Add inline validation warnings for package scripts missing New-Package and for variables used outside their valid script type
- Extend debug console to support tools and redistributables, add stop button and elapsed time display
- Disable minimap, enable bracket pair colorization and word wrap
- Add snippet insertion via Monaco snippet controller with selection replacement support
- Add script templates that auto-populate on new script creation
- Add Variables dropdown in editor toolbar based on current script type
- Change Add Script button to type-selection dropdown, auto-populate script name from type, remove Type field from editor dialog
- Move Requires Admin checkbox to toolbar row, move toolbar and editor outside Form to prevent dropdown clicks from closing the modal
- Set MaskClosable=false on script editor modals so Monaco autocomplete clicks don't dismiss the dialog
2026-05-10 19:33:48 -05:00

181 lines
4.8 KiB
Text

@using LANCommander.SDK.PowerShell
@using LANCommander.SDK.Services
@using LANCommander.Server.Services.PowerShell
@using XtermBlazor
@using LogLevel = Microsoft.Extensions.Logging.LogLevel
@inject ScriptDebugger ScriptDebugger
@inject ScriptClient ScriptClient
@inject GameService GameService
@inject ToolService ToolService
@inject RedistributableService RedistributableService
<div class="terminal @(Active ? "" : "hidden")">
@if (Active)
{
<div style="display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; background: #1e1e1e; border-bottom: 1px solid #333; color: #ccc; font-size: 12px; font-family: monospace;">
<span>@FormatElapsed()</span>
<button @onclick="Stop" title="Stop script" style="background: #c0392b; color: white; border: none; padding: 2px 10px; cursor: pointer; font-size: 12px;">Stop</button>
</div>
}
<Terminal @ref="_terminal" Id="@Id.ToString()" Options="_options" />
</div>
@code {
[Parameter] public Guid Id { get; set; }
[Parameter] public bool Active { get; set; }
[Parameter] public EventCallback<bool> ActiveChanged { get; set; }
private Terminal? _terminal;
private CancellationTokenSource? _cts;
private DateTime? _startTime;
private System.Threading.Timer? _elapsedTimer;
private readonly TerminalOptions _options = new()
{
CursorBlink = true,
CursorStyle = CursorStyle.Bar,
};
async Task SetActive(bool active)
{
Active = active;
if (ActiveChanged.HasDelegate)
await ActiveChanged.InvokeAsync(Active);
}
private void InitializeDebugger()
{
ScriptDebugger.OnBreak = Break;
ScriptDebugger.OnStart = Start;
ScriptDebugger.OnEnd = End;
ScriptDebugger.OnOutput = Output;
ScriptClient.Debug = true;
_cts?.Dispose();
_cts = new CancellationTokenSource();
_startTime = DateTime.UtcNow;
_elapsedTimer = new System.Threading.Timer(
_ => InvokeAsync(StateHasChanged),
null,
TimeSpan.Zero,
TimeSpan.FromSeconds(1));
}
public async Task DebugPackagingScript(Guid gameId)
{
InitializeDebugger();
await GameService.PackageAsync(gameId);
}
public async Task DebugToolPackagingScript(Guid toolId)
{
InitializeDebugger();
await ToolService.PackageAsync(toolId);
}
public async Task DebugRedistributablePackagingScript(Guid redistributableId)
{
InitializeDebugger();
await RedistributableService.PackageAsync(redistributableId);
}
public async Task Start(IScriptDebugContext context)
{
if (_terminal is null)
return;
await _terminal.Clear();
await SetActive(true);
await InvokeAsync(StateHasChanged);
await _terminal.Focus();
await _terminal.FitAsync();
}
public async Task End(IScriptDebugContext context)
{
StopTimer();
if (_terminal != null && _startTime.HasValue)
{
var elapsed = DateTime.UtcNow - _startTime.Value;
await _terminal.WriteLine($"\nCompleted in {FormatTimeSpan(elapsed)}", LogLevel.Information);
}
await SetActive(false);
await InvokeAsync(StateHasChanged);
}
public async Task Break(IScriptDebugContext context)
{
if (_terminal is null)
return;
while (true)
{
var input = await _terminal.ReadLineAsync();
if (input.Trim().Equals("exit", StringComparison.OrdinalIgnoreCase))
break;
if (input.StartsWith('$'))
input = "Write-Host " + input;
await context.ExecuteAsync(input);
}
}
public async Task Output(IScriptDebugContext context, LogLevel level, string message)
{
if (_terminal is null)
return;
try
{
await _terminal.WriteLine(message, level);
}
catch (Exception ex)
{
}
}
async Task Stop()
{
_cts?.Cancel();
StopTimer();
if (_terminal != null)
await _terminal.WriteLine("\nScript stopped by user.", LogLevel.Warning);
await SetActive(false);
await InvokeAsync(StateHasChanged);
}
void StopTimer()
{
_elapsedTimer?.Dispose();
_elapsedTimer = null;
}
string FormatElapsed()
{
if (!_startTime.HasValue)
return "";
var elapsed = DateTime.UtcNow - _startTime.Value;
return FormatTimeSpan(elapsed);
}
static string FormatTimeSpan(TimeSpan ts)
{
if (ts.TotalHours >= 1)
return ts.ToString(@"h\:mm\:ss");
return ts.ToString(@"m\:ss");
}
}