From a8dc3d4a7a82b4b602b7d17021462afd52de7cd0 Mon Sep 17 00:00:00 2001 From: ETWen Date: Wed, 3 Jun 2026 21:55:58 +0800 Subject: [PATCH] feat(terminal): Add session logging, tab reorder, and settings polish - Add per-tab Log button: record session output to logs\[name]_yyyyMMdd_HHmmss.log - Add Log All toolbar button to toggle logging on every open tab at once - Add drag-to-reorder for top tabs (also rearranges grid layout cells) - Polish Settings > Terminal: align all input boxes, soften folder browse button Logs strip ANSI escapes and prefix each line with [yyyy-MM-dd HH:mm:ss.fff]. LogName per channel: Shell=ShellType, Serial=PortName, SSH=user@host. Co-Authored-By: Claude Opus 4.8 --- src/ETTerms/App/SettingsView.cs | 39 +++++-- src/ETTerms/App/Workspace/WorkspaceView.cs | 115 +++++++++++++++++++- src/ETTerms/Infrastructure/SessionLogger.cs | 103 ++++++++++++++++++ src/ETTerms/Sessions/ISessionChannel.cs | 6 + src/ETTerms/Sessions/SerialChannel.cs | 2 + src/ETTerms/Sessions/SessionPage.cs | 64 ++++++++++- src/ETTerms/Sessions/ShellChannel.cs | 2 + src/ETTerms/Sessions/SshChannel.cs | 2 + 8 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 src/ETTerms/Infrastructure/SessionLogger.cs diff --git a/src/ETTerms/App/SettingsView.cs b/src/ETTerms/App/SettingsView.cs index d0de77e..d9a556f 100644 --- a/src/ETTerms/App/SettingsView.cs +++ b/src/ETTerms/App/SettingsView.cs @@ -107,23 +107,31 @@ public sealed class SettingsView : UserControl shellType.Text = s.ShellType; flow.Controls.Add(MakeRow("Terminal Shell", shellType)); - var shellDir = new TextBox { Width = 160, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, Text = s.ShellStartupDir, BorderStyle = BorderStyle.FixedSingle }; + // 用一個帶邊框的容器包住「輸入框 + 瀏覽鈕」,讓兩者看起來像同一個欄位 + var dirPanel = new Panel { Width = InputWidth, Height = 24, BackColor = Theme.TabBack, BorderStyle = BorderStyle.FixedSingle }; + var shellDir = new TextBox { BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, Text = s.ShellStartupDir, BorderStyle = BorderStyle.None }; var browseBtn = new Button { - Text = "📁", Width = 30, Height = 22, FlatStyle = FlatStyle.Flat, - ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand + Text = "…", Width = 26, FlatStyle = FlatStyle.Flat, + ForeColor = Theme.TextDim, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand }; - browseBtn.FlatAppearance.BorderColor = Theme.Border; + // 無邊框、暗色文字、與輸入框同底色 → 不再突兀 + browseBtn.FlatAppearance.BorderSize = 0; + browseBtn.FlatAppearance.MouseOverBackColor = Theme.Hover; browseBtn.Click += (_, _) => { using var dlg = new FolderBrowserDialog { SelectedPath = shellDir.Text }; if (dlg.ShowDialog(this) == DialogResult.OK) shellDir.Text = dlg.SelectedPath; }; - var dirPanel = new Panel { Width = 200, Height = 24 }; + // 文字框置中於容器、瀏覽鈕貼右 shellDir.Dock = DockStyle.Fill; + shellDir.Margin = new Padding(0); browseBtn.Dock = DockStyle.Right; - dirPanel.Controls.Add(shellDir); + var dirInner = new Panel { Dock = DockStyle.Fill, BackColor = Theme.TabBack, Padding = new Padding(4, 3, 0, 0) }; + dirInner.Controls.Add(shellDir); + // 先加靠右的按鈕、再加 Fill 容器,避免 Fill 蓋到按鈕下方 dirPanel.Controls.Add(browseBtn); + dirPanel.Controls.Add(dirInner); flow.Controls.Add(MakeRow("Startup Directory", dirPanel)); flow.Controls.Add(MakeSpacer(12)); @@ -268,12 +276,25 @@ public sealed class SettingsView : UserControl } // ── Helpers ── + private const int LabelWidth = 150; // 標籤欄固定寬度 + private const int InputWidth = 200; // 所有輸入框統一寬度 + private static Panel MakeRow(string label, Control control) { - var row = new Panel { Width = 400, Height = 34, Margin = new Padding(0, 4, 0, 4) }; - control.Dock = DockStyle.Right; + var row = new Panel { Width = LabelWidth + InputWidth, Height = 32, Margin = new Padding(0, 3, 0, 3) }; + + // 統一輸入框寬度 + 左緣對齊(不再 Dock.Right,避免右對齊造成左緣參差) + control.Width = InputWidth; + int top = (row.Height - control.Height) / 2; + control.Location = new Point(LabelWidth, top < 0 ? 0 : top); + row.Controls.Add(control); - row.Controls.Add(new Label { Text = label, Dock = DockStyle.Left, Width = 160, ForeColor = Theme.Text, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleLeft }); + row.Controls.Add(new Label + { + Text = label, AutoSize = false, Width = LabelWidth, Height = row.Height, + Location = new Point(0, 0), ForeColor = Theme.Text, Font = Theme.UiFont, + TextAlign = ContentAlignment.MiddleLeft + }); return row; } diff --git a/src/ETTerms/App/Workspace/WorkspaceView.cs b/src/ETTerms/App/Workspace/WorkspaceView.cs index f60d1cd..2f3ea0c 100644 --- a/src/ETTerms/App/Workspace/WorkspaceView.cs +++ b/src/ETTerms/App/Workspace/WorkspaceView.cs @@ -23,6 +23,7 @@ public sealed class WorkspaceView : UserControl } private readonly FlowLayoutPanel _toolbar; + private readonly Button _logAll; private readonly Panel _tabStrip; private readonly Panel _body; private readonly Label _empty; @@ -32,6 +33,12 @@ public sealed class WorkspaceView : UserControl private int _rows = 1, _cols = 1; private Session? _hoverTab; + // 拖曳排序狀態 + private Session? _dragTab; // 按住中的分頁(候選拖曳對象) + private int _dragStartX; // 按下時的滑鼠 X,用來判斷是否超過拖曳門檻 + private bool _dragging; // 是否已進入拖曳 + private const int DragThreshold = 5; + private const int StripH = 30; private const int TabW = 180; private const int CloseSz = 14; @@ -44,9 +51,12 @@ public sealed class WorkspaceView : UserControl Dock = DockStyle.Fill; BackColor = Theme.WorkspaceBack; + // 頂部工具列容器:左側為 Layout/Run 群組(Fill),右側為 Log All(Dock Right) + var topBar = new Panel { Dock = DockStyle.Top, Height = 38, BackColor = Theme.RailBack }; + _toolbar = new FlowLayoutPanel { - Dock = DockStyle.Top, Height = 38, BackColor = Theme.RailBack, + Dock = DockStyle.Fill, BackColor = Theme.RailBack, Padding = new Padding(8, 6, 8, 6), WrapContents = false }; _toolbar.Controls.Add(new Label @@ -62,11 +72,24 @@ public sealed class WorkspaceView : UserControl _toolbar.Controls.Add(MakeActionButton("▶ Group2", 90, 4, (_, _) => OnRunGroup(2))); _toolbar.Controls.Add(MakeActionButton("▶ Group3", 90, 4, (_, _) => OnRunGroup(3))); + // ── 右上角:Log All(一次開/關所有分頁側錄)── + var rightBar = new FlowLayoutPanel + { + Dock = DockStyle.Right, AutoSize = true, BackColor = Theme.RailBack, + Padding = new Padding(0, 6, 8, 6), WrapContents = false + }; + _logAll = MakeActionButton("⏺ Log All", 96, 0, OnToggleLogAll); + rightBar.Controls.Add(_logAll); + + topBar.Controls.Add(_toolbar); // Fill(先加 → 後 dock → 吃剩餘空間) + topBar.Controls.Add(rightBar); // Right(後加 → 先 dock → 佔右側) + _tabStrip = new Panel { Dock = DockStyle.Top, Height = StripH, BackColor = Theme.RailBack }; _tabStrip.Paint += OnStripPaint; _tabStrip.MouseDown += OnStripMouseDown; _tabStrip.MouseMove += OnStripMouseMove; - _tabStrip.MouseLeave += (_, _) => { _hoverTab = null; _tabStrip.Invalidate(); }; + _tabStrip.MouseUp += OnStripMouseUp; + _tabStrip.MouseLeave += (_, _) => { if (!_dragging) { _hoverTab = null; _tabStrip.Invalidate(); } }; SetDoubleBuffered(_tabStrip); _body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack }; @@ -80,7 +103,7 @@ public sealed class WorkspaceView : UserControl Controls.Add(_body); // Fill 先加 Controls.Add(_tabStrip); // Top - Controls.Add(_toolbar); // Top(最上) + Controls.Add(topBar); // Top(最上) _body.Controls.Add(_empty); } @@ -251,7 +274,15 @@ public sealed class WorkspaceView : UserControl foreach (var s in _sessions) { if (s.CloseRect.Contains(e.Location)) { CloseSession(s); return; } - if (s.TabBounds.Contains(e.Location)) { _active = s; Relayout(); return; } + if (s.TabBounds.Contains(e.Location)) + { + _active = s; + _dragTab = s; // 記下候選拖曳對象,超過門檻才真的開始拖 + _dragStartX = e.X; + _dragging = false; + Relayout(); + return; + } } } @@ -270,12 +301,47 @@ public sealed class WorkspaceView : UserControl private void OnStripMouseMove(object? sender, MouseEventArgs e) { + // 拖曳排序:按住某分頁並橫向移動,依滑鼠 X 即時換位 + if (e.Button == MouseButtons.Left && _dragTab != null) + { + if (!_dragging && Math.Abs(e.X - _dragStartX) > DragThreshold) + { + _dragging = true; + _tabStrip.Cursor = Cursors.SizeWE; + } + if (_dragging) + { + int target = Math.Clamp(e.X / TabW, 0, _sessions.Count - 1); + int cur = _sessions.IndexOf(_dragTab); + if (cur >= 0 && target != cur) + { + _sessions.RemoveAt(cur); + _sessions.Insert(target, _dragTab); + _tabStrip.Invalidate(); + } + } + return; + } + LayoutTabs(); Session? ht = null; foreach (var s in _sessions) if (s.TabBounds.Contains(e.Location)) { ht = s; break; } if (ht != _hoverTab) { _hoverTab = ht; _tabStrip.Invalidate(); } } + private void OnStripMouseUp(object? sender, MouseEventArgs e) + { + bool reordered = _dragging; + _dragTab = null; + _dragging = false; + _tabStrip.Cursor = Cursors.Default; + if (reordered) + { + RefreshGroupLabels(); // Group 內編號(A/B/C…)依新順序更新 + Relayout(); // 重鋪格子,讓 layout 位置跟著新分頁順序 + } + } + private void OnStripPaint(object? sender, PaintEventArgs e) { var g = e.Graphics; @@ -284,11 +350,15 @@ public sealed class WorkspaceView : UserControl foreach (var s in _sessions) { bool active = s == _active, hover = s == _hoverTab; - using (var b = new SolidBrush(active ? Theme.TabActiveBack : hover ? Theme.Hover : Theme.TabBack)) + bool drag = _dragging && s == _dragTab; + using (var b = new SolidBrush(drag ? Theme.Hover : active ? Theme.TabActiveBack : hover ? Theme.Hover : Theme.TabBack)) g.FillRectangle(b, s.TabBounds); if (active) using (var bar = new SolidBrush(Theme.Accent)) g.FillRectangle(bar, new Rectangle(s.TabBounds.Left, s.TabBounds.Bottom - 2, s.TabBounds.Width, 2)); + if (drag) + using (var pen = new Pen(Theme.Accent, 1)) + g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1)); using (var dot = new SolidBrush(s.IsSsh ? Theme.SshColor : Theme.SerialColor)) g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8); var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH); @@ -328,6 +398,41 @@ public sealed class WorkspaceView : UserControl return b; } + // ── Log All(一次開/關所有分頁側錄) ────────────────────── + private void OnToggleLogAll(object? sender, EventArgs e) + { + if (_sessions.Count == 0) + { + MessageBox.Show(this, "No open sessions to log.", "Log All", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // 只要還有分頁沒在側錄 → 全部開始;否則全部停止。 + bool startAll = _sessions.Any(s => !s.Page.IsLogging); + if (startAll) + { + int failed = 0; + foreach (var s in _sessions) + if (!s.Page.StartLog()) failed++; + SetLogAllActive(true); + if (failed > 0) + MessageBox.Show(this, $"{failed} session(s) failed to start logging. See app log for details.", + "Log All", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + else + { + foreach (var s in _sessions) s.Page.StopLog(); + SetLogAllActive(false); + } + } + + private void SetLogAllActive(bool on) + { + _logAll.Text = on ? "⏺ Logging All" : "⏺ Log All"; + _logAll.Width = on ? 110 : 96; + _logAll.ForeColor = on ? Theme.SerialColor : Theme.Text; + } + // ── Run All(個別執行,拒絕 waitall/sendlnall) ────────── private async void OnRunAllSerial(object? sender, EventArgs e) { diff --git a/src/ETTerms/Infrastructure/SessionLogger.cs b/src/ETTerms/Infrastructure/SessionLogger.cs new file mode 100644 index 0000000..42059a6 --- /dev/null +++ b/src/ETTerms/Infrastructure/SessionLogger.cs @@ -0,0 +1,103 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace ETTerms.Infrastructure; + +/// +/// 單一分頁的終端機輸出側錄器。按下 Log 按鈕時建立,再按一次(或關閉分頁)時 Dispose。 +/// +/// - 檔案位置:<exe 路徑>\logs\ +/// - 檔名格式:[連線識別]_yyyyMMdd_HHmmss.log,例如 [PowerShell]_20260603_212400.log +/// - 內容格式:每行 [yyyy-MM-dd HH:mm:ss.fff] 文字 +/// +/// 收到的位元組會去除 ANSI/VT escape 序列後依換行切行,每完成一行才寫出並補上收到當下的時間戳, +/// 未滿一行的殘段先留在緩衝區,待後續資料或 Dispose 時補寫。 +/// +public sealed class SessionLogger : IDisposable +{ + // 去除 CSI / OSC / 單字元 escape 等 ANSI 控制序列 + private static readonly Regex AnsiRegex = new( + @"\x1B\][^\x07\x1B]*(\x07|\x1B\\)|\x1B[@-Z\\-_]|\x1B\[[0-?]*[ -/]*[@-~]", + RegexOptions.Compiled); + + private readonly object _lock = new(); + private readonly StreamWriter _writer; + private readonly StringBuilder _pending = new(); + private bool _disposed; + + /// 實際寫入的完整檔案路徑。 + public string FilePath { get; } + + private SessionLogger(string filePath) + { + FilePath = filePath; + _writer = new StreamWriter(filePath, append: true, new UTF8Encoding(false)) { AutoFlush = true }; + } + + /// + /// 為某個連線建立側錄器。 例如 "PowerShell" / "COM17" / "etwen@192.168.1.50"。 + /// + public static SessionLogger Start(string logName) + { + string dir = Path.Combine(AppContext.BaseDirectory, "logs"); + Directory.CreateDirectory(dir); + + string safe = Sanitize(logName); + string fileName = $"[{safe}]_{DateTime.Now:yyyyMMdd_HHmmss}.log"; + string path = Path.Combine(dir, fileName); + return new SessionLogger(path); + } + + /// 餵入終端機收到的原始位元組(UTF-8 解碼後側錄)。 + public void Write(byte[] data) + { + if (_disposed || data.Length == 0) return; + string text = Encoding.UTF8.GetString(data); + string clean = AnsiRegex.Replace(text, "").Replace("\r", ""); + if (clean.Length == 0) return; + + lock (_lock) + { + if (_disposed) return; + string stamp = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] "; + int start = 0; + for (int i = 0; i < clean.Length; i++) + { + if (clean[i] != '\n') continue; + _pending.Append(clean, start, i - start); + _writer.WriteLine(stamp + _pending); + _pending.Clear(); + start = i + 1; + } + if (start < clean.Length) + _pending.Append(clean, start, clean.Length - start); + } + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + try + { + if (_pending.Length > 0) + _writer.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {_pending}"); + _writer.Dispose(); + } + catch { /* 收尾失敗不可拖垮 App */ } + } + } + + /// 把連線識別字串清成合法檔名(移除 \ / : * ? " < > | 等)。 + private static string Sanitize(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "session"; + var sb = new StringBuilder(name.Length); + var invalid = Path.GetInvalidFileNameChars(); + foreach (char c in name) + sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c); + return sb.ToString(); + } +} diff --git a/src/ETTerms/Sessions/ISessionChannel.cs b/src/ETTerms/Sessions/ISessionChannel.cs index 8b45abe..f23a79e 100644 --- a/src/ETTerms/Sessions/ISessionChannel.cs +++ b/src/ETTerms/Sessions/ISessionChannel.cs @@ -9,6 +9,12 @@ public interface ISessionChannel : IDisposable /// 收到遠端資料(背景執行緒觸發,訂閱者需自行 Invoke 回 UI thread)。 event Action? DataReceived; + /// + /// 用於 log 檔名的連線識別字串: + /// Shell → "PowerShell";Serial → "COM17";SSH → "etwen@192.168.1.50"。 + /// + string LogName { get; } + /// 建立連線;失敗丟例外。 void Open(); diff --git a/src/ETTerms/Sessions/SerialChannel.cs b/src/ETTerms/Sessions/SerialChannel.cs index a6e72dd..85a3a07 100644 --- a/src/ETTerms/Sessions/SerialChannel.cs +++ b/src/ETTerms/Sessions/SerialChannel.cs @@ -15,6 +15,8 @@ public sealed class SerialChannel : ISessionChannel public event Action? DataReceived; + public string LogName => _portName; + public SerialChannel(SerialSettings s) { _portName = s.PortName; diff --git a/src/ETTerms/Sessions/SessionPage.cs b/src/ETTerms/Sessions/SessionPage.cs index dad40fd..86c049a 100644 --- a/src/ETTerms/Sessions/SessionPage.cs +++ b/src/ETTerms/Sessions/SessionPage.cs @@ -21,8 +21,9 @@ public sealed class SessionPage : UserControl private readonly TerminalView _term; private readonly ScriptRunner _runner = new(); private readonly Label _status; - private readonly Button _run, _stop; + private readonly Button _run, _stop, _log; private bool _started; + private SessionLogger? _logger; /// 底層連線通道。 public ISessionChannel Channel => _channel; @@ -78,9 +79,12 @@ public sealed class SessionPage : UserControl _run = MakeBarButton("▶ Script", OnRunScript); _stop = MakeBarButton("■ Stop", (_, _) => _runner.Cancel()); _stop.Enabled = false; + _log = MakeBarButton("⏺ Log", OnToggleLog); + _log.Width = 92; // 容納 "⏺ Logging" 不被截字 bar.Controls.Add(_status); // Fill 先加 - bar.Controls.Add(_run); // Right - bar.Controls.Add(_stop); // Right + bar.Controls.Add(_log); // Right(最左:Log) + bar.Controls.Add(_run); // Right(中:Script) + bar.Controls.Add(_stop); // Right(最右:Stop) _term = new TerminalView(new TerminalProfile()) { Dock = DockStyle.Fill }; _term.SendData += data => _channel.Write(data); @@ -113,6 +117,56 @@ public sealed class SessionPage : UserControl await _runner.RunAsync(content, name, _channel); } + /// 此分頁是否正在側錄。 + public bool IsLogging => _logger != null; + + /// 開始側錄(供 Log All 批次呼叫)。成功或已在側錄回傳 true。 + public bool StartLog() + { + if (_logger != null) return true; + try + { + _logger = SessionLogger.Start(_channel.LogName); + UpdateLogButton(); + AppLogger.Info($"Session log started: {_logger.FilePath}"); + return true; + } + catch (Exception ex) + { + AppLogger.LogError("Start session log failed", ex); + return false; + } + } + + /// 停止側錄並收尾關檔。 + public void StopLog() + { + if (_logger == null) return; + string path = _logger.FilePath; + _logger.Dispose(); + _logger = null; + UpdateLogButton(); + AppLogger.Info($"Session log stopped: {path}"); + } + + private void OnToggleLog(object? sender, EventArgs e) + { + if (_logger == null) + { + if (!StartLog()) + MessageBox.Show(this, "Failed to start log. See app log for details.", "Log", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + else StopLog(); + } + + private void UpdateLogButton() + { + bool on = _logger != null; + _log.Text = on ? "⏺ Logging" : "⏺ Log"; + _log.ForeColor = on ? Theme.SerialColor : Theme.Text; + _log.FlatAppearance.BorderColor = on ? Theme.SerialColor : Theme.Border; + } + private static bool ContainsGroupCommands(string content) { foreach (var line in content.Split('\n')) @@ -172,6 +226,8 @@ public sealed class SessionPage : UserControl private void OnDataReceived(byte[] data) { + // 側錄不依賴 UI thread,直接在背景緒寫檔(SessionLogger 內部自鎖)。 + _logger?.Write(data); if (IsDisposed || !IsHandleCreated) return; if (InvokeRequired) BeginInvoke(() => _term.Feed(data)); else _term.Feed(data); @@ -183,6 +239,8 @@ public sealed class SessionPage : UserControl { _runner.Cancel(); _channel.DataReceived -= OnDataReceived; + _logger?.Dispose(); + _logger = null; SessionManager.Unregister(_channel); _channel.Dispose(); } diff --git a/src/ETTerms/Sessions/ShellChannel.cs b/src/ETTerms/Sessions/ShellChannel.cs index 9e8081e..90c4f58 100644 --- a/src/ETTerms/Sessions/ShellChannel.cs +++ b/src/ETTerms/Sessions/ShellChannel.cs @@ -24,6 +24,8 @@ public sealed class ShellChannel : ISessionChannel public event Action? DataReceived; + public string LogName => _settings.ShellType; + public ShellChannel(ShellSettings settings) => _settings = settings; public void Open() diff --git a/src/ETTerms/Sessions/SshChannel.cs b/src/ETTerms/Sessions/SshChannel.cs index 47d0807..b65f139 100644 --- a/src/ETTerms/Sessions/SshChannel.cs +++ b/src/ETTerms/Sessions/SshChannel.cs @@ -23,6 +23,8 @@ public sealed class SshChannel : ISessionChannel public event Action? DataReceived; + public string LogName => $"{_ssh.Username}@{_ssh.Host}"; + public SshChannel(Connection conn) { _ssh = conn.Ssh ?? new SshSettings();