268 lines
9.8 KiB
C#
268 lines
9.8 KiB
C#
using HanaTui.System;
|
|
using HanaTui.Tui.Components;
|
|
using Spectre.Console;
|
|
using Spectre.Console.Rendering;
|
|
|
|
namespace HanaTui.Tui;
|
|
|
|
/// <summary>
|
|
/// Runs an operation while showing a live split-panel:
|
|
/// Left: System stats (CPU, RAM, Swap, elapsed)
|
|
/// Right: Streaming operation log
|
|
///
|
|
/// Abort: first Q warns, second Q within 3s sends SIGTERM then SIGKILL.
|
|
/// Post-completion: 10s countdown, any key cancels and holds; Enter/Esc returns.
|
|
/// </summary>
|
|
public static class TaskRunnerScreen
|
|
{
|
|
private const int CountdownSeconds = 10;
|
|
private const double AbortWindowSeconds = 3.0;
|
|
|
|
public static async Task RunAsync(
|
|
string operationTitle,
|
|
Func<Action<string>, CancellationToken, Task<bool>> operation)
|
|
{
|
|
AnsiConsole.Clear();
|
|
|
|
var logPanel = new LogPanel();
|
|
var stats = new SystemStats();
|
|
var cts = new CancellationTokenSource();
|
|
var startTime = DateTime.Now;
|
|
|
|
var operationDone = false;
|
|
var operationSuccess = false;
|
|
DateTime? firstQTime = null;
|
|
|
|
// Key listener
|
|
var keyTask = Task.Run(async () =>
|
|
{
|
|
while (!operationDone)
|
|
{
|
|
if (Console.KeyAvailable)
|
|
{
|
|
var key = Console.ReadKey(intercept: true);
|
|
if (key.Key == ConsoleKey.Q || key.KeyChar is 'q' or 'Q')
|
|
{
|
|
if (cts.IsCancellationRequested) break;
|
|
|
|
if (firstQTime is null)
|
|
{
|
|
firstQTime = DateTime.Now;
|
|
logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
|
|
}
|
|
else if ((DateTime.Now - firstQTime.Value).TotalSeconds <= AbortWindowSeconds)
|
|
{
|
|
logPanel.Add("[WARN] Aborting operation...");
|
|
await cts.CancelAsync();
|
|
}
|
|
else
|
|
{
|
|
firstQTime = DateTime.Now;
|
|
logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
|
|
}
|
|
}
|
|
}
|
|
await Task.Delay(50, CancellationToken.None);
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
// Operation task
|
|
var operationTask = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
operationSuccess = await operation(line => logPanel.Add(line), cts.Token);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logPanel.Add($"[ERROR] Unexpected exception: {ex.Message}");
|
|
operationSuccess = false;
|
|
}
|
|
finally
|
|
{
|
|
operationDone = true;
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
// Live render loop
|
|
await AnsiConsole.Live(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running))
|
|
.AutoClear(false)
|
|
.StartAsync(async ctx =>
|
|
{
|
|
while (!operationDone)
|
|
{
|
|
ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running));
|
|
await Task.Delay(800, CancellationToken.None);
|
|
}
|
|
|
|
var elapsed = DateTime.Now - startTime;
|
|
RunState finalState;
|
|
if (cts.IsCancellationRequested)
|
|
{
|
|
finalState = RunState.Aborted;
|
|
logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed)}.");
|
|
}
|
|
else if (operationSuccess)
|
|
{
|
|
finalState = RunState.Success;
|
|
logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed)}.");
|
|
}
|
|
else
|
|
{
|
|
finalState = RunState.Failed;
|
|
logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed)}.");
|
|
}
|
|
|
|
ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, finalState));
|
|
});
|
|
|
|
stats.Dispose();
|
|
await operationTask;
|
|
operationDone = true;
|
|
await keyTask;
|
|
|
|
await PostCompletionWaitAsync(operationTitle, logPanel, stats, startTime,
|
|
cts.IsCancellationRequested ? RunState.Aborted :
|
|
operationSuccess ? RunState.Success : RunState.Failed);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
private enum RunState { Running, Success, Failed, Aborted }
|
|
|
|
private static async Task PostCompletionWaitAsync(
|
|
string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state)
|
|
{
|
|
var countdownCancelled = false;
|
|
var returnNow = false;
|
|
var staying = false;
|
|
var startCountdown = DateTime.Now;
|
|
|
|
var countdownTask = Task.Run(async () =>
|
|
{
|
|
for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--)
|
|
await Task.Delay(1000, CancellationToken.None);
|
|
if (!countdownCancelled) returnNow = true;
|
|
}, CancellationToken.None);
|
|
|
|
await AnsiConsole.Live(BuildLayout(title, logPanel, stats, startTime, state))
|
|
.AutoClear(false)
|
|
.StartAsync(async ctx =>
|
|
{
|
|
while (!returnNow)
|
|
{
|
|
if (Console.KeyAvailable)
|
|
{
|
|
var key = Console.ReadKey(intercept: true);
|
|
countdownCancelled = true;
|
|
|
|
if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape)
|
|
{
|
|
returnNow = true;
|
|
break;
|
|
}
|
|
|
|
if (!staying)
|
|
{
|
|
staying = true;
|
|
logPanel.Add("[INFO] Countdown cancelled. Press Enter or Esc to return to menu.");
|
|
}
|
|
}
|
|
|
|
string footerText;
|
|
if (!staying)
|
|
{
|
|
var secsLeft = Math.Max(0, CountdownSeconds -
|
|
(int)(DateTime.Now - startCountdown).TotalSeconds);
|
|
footerText = $"Returning to menu in {secsLeft}s... (any key to stay)";
|
|
}
|
|
else
|
|
{
|
|
footerText = "Press Enter or Esc to return to menu.";
|
|
}
|
|
|
|
ctx.UpdateTarget(BuildLayout(title, logPanel, stats, startTime, state, footerText));
|
|
await Task.Delay(200, CancellationToken.None);
|
|
}
|
|
});
|
|
|
|
await countdownTask;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Layout
|
|
// -----------------------------------------------------------------------
|
|
|
|
private static IRenderable BuildLayout(
|
|
string title,
|
|
LogPanel logPanel,
|
|
SystemStats stats,
|
|
DateTime startTime,
|
|
RunState state,
|
|
string? footerText = null)
|
|
{
|
|
var elapsed = DateTime.Now - startTime;
|
|
var snap = stats.CurrentSnapshot;
|
|
|
|
const int statsPanelWidth = 40;
|
|
var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4);
|
|
var logRenderable = logPanel.Build();
|
|
|
|
var layout = new Layout("root").SplitColumns(
|
|
new Layout("stats").Size(statsPanelWidth),
|
|
new Layout("log"));
|
|
layout["stats"].Update(statsRenderable);
|
|
layout["log"].Update(logRenderable);
|
|
|
|
var outerGrid = new Grid();
|
|
outerGrid.AddColumn(new GridColumn());
|
|
|
|
// Title row — state label uses Text objects, not markup interpolation
|
|
var titleGrid = new Grid();
|
|
titleGrid.AddColumn(new GridColumn().NoWrap());
|
|
titleGrid.AddColumn(new GridColumn().NoWrap());
|
|
titleGrid.AddColumn(new GridColumn().NoWrap());
|
|
titleGrid.AddColumn(new GridColumn().NoWrap());
|
|
titleGrid.AddRow(
|
|
new Markup("[bold dodgerblue1] Operation:[/]"),
|
|
new Text(" " + title, new Style(Color.Yellow)),
|
|
new Markup(" [bold dodgerblue1]Status:[/]"),
|
|
StateLabel(state)
|
|
);
|
|
outerGrid.AddRow(titleGrid);
|
|
outerGrid.AddRow(layout);
|
|
|
|
// Footer row — plain Text, no markup parsing of dynamic content
|
|
var footerGrid = new Grid();
|
|
footerGrid.AddColumn(new GridColumn().NoWrap());
|
|
footerGrid.AddColumn(new GridColumn().NoWrap());
|
|
|
|
var footerLeft = footerText is not null
|
|
? new Text(" " + footerText, new Style(Color.Grey, decoration: Decoration.Dim))
|
|
: (IRenderable)new Text(" Press Q to abort (twice within 3s)", new Style(Color.Grey, decoration: Decoration.Dim));
|
|
|
|
var footerRight = new Text(" [Q] abort", new Style(Color.Grey, decoration: Decoration.Dim));
|
|
|
|
footerGrid.AddRow(footerLeft, footerRight);
|
|
outerGrid.AddRow(footerGrid);
|
|
|
|
return outerGrid;
|
|
}
|
|
|
|
private static IRenderable StateLabel(RunState state) => state switch
|
|
{
|
|
RunState.Running => new Markup(" [yellow]Running...[/]"),
|
|
RunState.Success => new Markup(" [green]Completed[/]"),
|
|
RunState.Failed => new Markup(" [red]Failed[/]"),
|
|
RunState.Aborted => new Markup(" [red]Aborted[/]"),
|
|
_ => new Text(""),
|
|
};
|
|
|
|
private static string FormatElapsed(TimeSpan t)
|
|
{
|
|
if (t.TotalHours >= 1) return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s";
|
|
if (t.TotalMinutes >= 1) return $"{t.Minutes}m {t.Seconds:D2}s";
|
|
return $"{t.Seconds}s";
|
|
}
|
|
}
|