using Spectre.Console; using Spectre.Console.Rendering; namespace HanaTui.Tui.Components; /// /// A timestamped log entry. /// public sealed class LogEntry { public DateTime Time { get; init; } = DateTime.Now; public string Text { get; init; } = ""; public LogLevel Level { get; init; } = LogLevel.Info; } public enum LogLevel { Info, Sql, Done, Warn, Error } /// /// Maintains a bounded list of log entries and renders them as a /// Spectre.Console IRenderable panel, showing the most recent N lines. /// Thread-safe. /// public sealed class LogPanel { private const int MaxEntries = 500; private const int VisibleLines = 20; // shown inside the panel private readonly List _entries = new(MaxEntries); private readonly object _lock = new(); public void Add(string text) { var level = DetectLevel(text); var entry = new LogEntry { Time = DateTime.Now, Text = text, Level = level }; lock (_lock) { if (_entries.Count >= MaxEntries) _entries.RemoveAt(0); _entries.Add(entry); } } /// /// Returns a renderable panel showing the last N log lines. /// public IRenderable Build() { List snapshot; lock (_lock) { var start = Math.Max(0, _entries.Count - VisibleLines); snapshot = _entries.GetRange(start, _entries.Count - start); } var rows = new Grid(); rows.AddColumn(new GridColumn().NoWrap()); // Pad to VisibleLines so the panel doesn't resize each tick var padCount = VisibleLines - snapshot.Count; for (int i = 0; i < padCount; i++) rows.AddRow(new Text("")); foreach (var entry in snapshot) { var timeStr = entry.Time.ToString("HH:mm:ss"); var (tagLabel, color) = entry.Level switch { LogLevel.Sql => ("SQL ", "blue"), LogLevel.Done => ("DONE", "green"), LogLevel.Warn => ("WARN", "yellow"), LogLevel.Error => ("ERR ", "red"), _ => ("INFO", "grey"), }; // Strip the leading [TAG] prefix from the raw text if present, // since we re-render it with color. Then escape the remainder. var rawText = StripKnownPrefix(entry.Text); var safeText = Markup.Escape(rawText); rows.AddRow(new Markup( $"[dim]{timeStr}[/] [{color}][[{tagLabel}]][/] {safeText}")); } return new Panel(rows) { Header = new PanelHeader("[bold] OPERATION LOG [/]"), Border = BoxBorder.Rounded, Padding = new Padding(1, 0), }; } private static LogLevel DetectLevel(string text) { if (text.Contains("[SQL ]") || text.Contains("[SQL]")) return LogLevel.Sql; if (text.Contains("[DONE]")) return LogLevel.Done; if (text.Contains("[WARN]")) return LogLevel.Warn; if (text.Contains("[ERROR]") || text.Contains("[ERR]") || text.Contains("[ERR ]")) return LogLevel.Error; return LogLevel.Info; } private static readonly string[] KnownPrefixes = ["[INFO] ", "[SQL ] ", "[SQL] ", "[DONE] ", "[WARN] ", "[ERROR] ", "[ERR ] ", "[ERR] "]; private static string StripKnownPrefix(string text) { foreach (var prefix in KnownPrefixes) { if (text.StartsWith(prefix, StringComparison.Ordinal)) return text[prefix.Length..]; } return text; } }