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 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:55:58 +08:00
co-authored by Claude Opus 4.8
parent 2bfb2c6575
commit a8dc3d4a7a
8 changed files with 316 additions and 17 deletions
+30 -9
View File
@@ -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;
}
+110 -5
View File
@@ -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 AllDock 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)
{
+103
View File
@@ -0,0 +1,103 @@
using System.Text;
using System.Text.RegularExpressions;
namespace ETTerms.Infrastructure;
/// <summary>
/// 單一分頁的終端機輸出側錄器。按下 Log 按鈕時建立,再按一次(或關閉分頁)時 Dispose。
///
/// - 檔案位置:&lt;exe 路徑&gt;\logs\
/// - 檔名格式:<c>[連線識別]_yyyyMMdd_HHmmss.log</c>,例如 <c>[PowerShell]_20260603_212400.log</c>
/// - 內容格式:每行 <c>[yyyy-MM-dd HH:mm:ss.fff] 文字</c>
///
/// 收到的位元組會去除 ANSI/VT escape 序列後依換行切行,每完成一行才寫出並補上收到當下的時間戳,
/// 未滿一行的殘段先留在緩衝區,待後續資料或 Dispose 時補寫。
/// </summary>
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;
/// <summary>實際寫入的完整檔案路徑。</summary>
public string FilePath { get; }
private SessionLogger(string filePath)
{
FilePath = filePath;
_writer = new StreamWriter(filePath, append: true, new UTF8Encoding(false)) { AutoFlush = true };
}
/// <summary>
/// 為某個連線建立側錄器。<paramref name="logName"/> 例如 "PowerShell" / "COM17" / "etwen@192.168.1.50"。
/// </summary>
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);
}
/// <summary>餵入終端機收到的原始位元組(UTF-8 解碼後側錄)。</summary>
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 */ }
}
}
/// <summary>把連線識別字串清成合法檔名(移除 \ / : * ? " &lt; &gt; | 等)。</summary>
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();
}
}
+6
View File
@@ -9,6 +9,12 @@ public interface ISessionChannel : IDisposable
/// <summary>收到遠端資料(背景執行緒觸發,訂閱者需自行 Invoke 回 UI thread)。</summary>
event Action<byte[]>? DataReceived;
/// <summary>
/// 用於 log 檔名的連線識別字串:
/// Shell → "PowerShell"Serial → "COM17"SSH → "etwen@192.168.1.50"。
/// </summary>
string LogName { get; }
/// <summary>建立連線;失敗丟例外。</summary>
void Open();
+2
View File
@@ -15,6 +15,8 @@ public sealed class SerialChannel : ISessionChannel
public event Action<byte[]>? DataReceived;
public string LogName => _portName;
public SerialChannel(SerialSettings s)
{
_portName = s.PortName;
+61 -3
View File
@@ -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;
/// <summary>底層連線通道。</summary>
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);
}
/// <summary>此分頁是否正在側錄。</summary>
public bool IsLogging => _logger != null;
/// <summary>開始側錄(供 Log All 批次呼叫)。成功或已在側錄回傳 true。</summary>
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;
}
}
/// <summary>停止側錄並收尾關檔。</summary>
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();
}
+2
View File
@@ -24,6 +24,8 @@ public sealed class ShellChannel : ISessionChannel
public event Action<byte[]>? DataReceived;
public string LogName => _settings.ShellType;
public ShellChannel(ShellSettings settings) => _settings = settings;
public void Open()
+2
View File
@@ -23,6 +23,8 @@ public sealed class SshChannel : ISessionChannel
public event Action<byte[]>? DataReceived;
public string LogName => $"{_ssh.Username}@{_ssh.Host}";
public SshChannel(Connection conn)
{
_ssh = conn.Ssh ?? new SshSettings();