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:
@@ -107,23 +107,31 @@ public sealed class SettingsView : UserControl
|
|||||||
shellType.Text = s.ShellType;
|
shellType.Text = s.ShellType;
|
||||||
flow.Controls.Add(MakeRow("Terminal Shell", 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
|
var browseBtn = new Button
|
||||||
{
|
{
|
||||||
Text = "📁", Width = 30, Height = 22, FlatStyle = FlatStyle.Flat,
|
Text = "…", Width = 26, FlatStyle = FlatStyle.Flat,
|
||||||
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
|
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 += (_, _) =>
|
browseBtn.Click += (_, _) =>
|
||||||
{
|
{
|
||||||
using var dlg = new FolderBrowserDialog { SelectedPath = shellDir.Text };
|
using var dlg = new FolderBrowserDialog { SelectedPath = shellDir.Text };
|
||||||
if (dlg.ShowDialog(this) == DialogResult.OK) shellDir.Text = dlg.SelectedPath;
|
if (dlg.ShowDialog(this) == DialogResult.OK) shellDir.Text = dlg.SelectedPath;
|
||||||
};
|
};
|
||||||
var dirPanel = new Panel { Width = 200, Height = 24 };
|
// 文字框置中於容器、瀏覽鈕貼右
|
||||||
shellDir.Dock = DockStyle.Fill;
|
shellDir.Dock = DockStyle.Fill;
|
||||||
|
shellDir.Margin = new Padding(0);
|
||||||
browseBtn.Dock = DockStyle.Right;
|
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(browseBtn);
|
||||||
|
dirPanel.Controls.Add(dirInner);
|
||||||
flow.Controls.Add(MakeRow("Startup Directory", dirPanel));
|
flow.Controls.Add(MakeRow("Startup Directory", dirPanel));
|
||||||
flow.Controls.Add(MakeSpacer(12));
|
flow.Controls.Add(MakeSpacer(12));
|
||||||
|
|
||||||
@@ -268,12 +276,25 @@ public sealed class SettingsView : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
private const int LabelWidth = 150; // 標籤欄固定寬度
|
||||||
|
private const int InputWidth = 200; // 所有輸入框統一寬度
|
||||||
|
|
||||||
private static Panel MakeRow(string label, Control control)
|
private static Panel MakeRow(string label, Control control)
|
||||||
{
|
{
|
||||||
var row = new Panel { Width = 400, Height = 34, Margin = new Padding(0, 4, 0, 4) };
|
var row = new Panel { Width = LabelWidth + InputWidth, Height = 32, Margin = new Padding(0, 3, 0, 3) };
|
||||||
control.Dock = DockStyle.Right;
|
|
||||||
|
// 統一輸入框寬度 + 左緣對齊(不再 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(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;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class WorkspaceView : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly FlowLayoutPanel _toolbar;
|
private readonly FlowLayoutPanel _toolbar;
|
||||||
|
private readonly Button _logAll;
|
||||||
private readonly Panel _tabStrip;
|
private readonly Panel _tabStrip;
|
||||||
private readonly Panel _body;
|
private readonly Panel _body;
|
||||||
private readonly Label _empty;
|
private readonly Label _empty;
|
||||||
@@ -32,6 +33,12 @@ public sealed class WorkspaceView : UserControl
|
|||||||
private int _rows = 1, _cols = 1;
|
private int _rows = 1, _cols = 1;
|
||||||
private Session? _hoverTab;
|
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 StripH = 30;
|
||||||
private const int TabW = 180;
|
private const int TabW = 180;
|
||||||
private const int CloseSz = 14;
|
private const int CloseSz = 14;
|
||||||
@@ -44,9 +51,12 @@ public sealed class WorkspaceView : UserControl
|
|||||||
Dock = DockStyle.Fill;
|
Dock = DockStyle.Fill;
|
||||||
BackColor = Theme.WorkspaceBack;
|
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
|
_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
|
Padding = new Padding(8, 6, 8, 6), WrapContents = false
|
||||||
};
|
};
|
||||||
_toolbar.Controls.Add(new Label
|
_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("▶ Group2", 90, 4, (_, _) => OnRunGroup(2)));
|
||||||
_toolbar.Controls.Add(MakeActionButton("▶ Group3", 90, 4, (_, _) => OnRunGroup(3)));
|
_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 = new Panel { Dock = DockStyle.Top, Height = StripH, BackColor = Theme.RailBack };
|
||||||
_tabStrip.Paint += OnStripPaint;
|
_tabStrip.Paint += OnStripPaint;
|
||||||
_tabStrip.MouseDown += OnStripMouseDown;
|
_tabStrip.MouseDown += OnStripMouseDown;
|
||||||
_tabStrip.MouseMove += OnStripMouseMove;
|
_tabStrip.MouseMove += OnStripMouseMove;
|
||||||
_tabStrip.MouseLeave += (_, _) => { _hoverTab = null; _tabStrip.Invalidate(); };
|
_tabStrip.MouseUp += OnStripMouseUp;
|
||||||
|
_tabStrip.MouseLeave += (_, _) => { if (!_dragging) { _hoverTab = null; _tabStrip.Invalidate(); } };
|
||||||
SetDoubleBuffered(_tabStrip);
|
SetDoubleBuffered(_tabStrip);
|
||||||
|
|
||||||
_body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack };
|
_body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack };
|
||||||
@@ -80,7 +103,7 @@ public sealed class WorkspaceView : UserControl
|
|||||||
|
|
||||||
Controls.Add(_body); // Fill 先加
|
Controls.Add(_body); // Fill 先加
|
||||||
Controls.Add(_tabStrip); // Top
|
Controls.Add(_tabStrip); // Top
|
||||||
Controls.Add(_toolbar); // Top(最上)
|
Controls.Add(topBar); // Top(最上)
|
||||||
_body.Controls.Add(_empty);
|
_body.Controls.Add(_empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +274,15 @@ public sealed class WorkspaceView : UserControl
|
|||||||
foreach (var s in _sessions)
|
foreach (var s in _sessions)
|
||||||
{
|
{
|
||||||
if (s.CloseRect.Contains(e.Location)) { CloseSession(s); return; }
|
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)
|
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();
|
LayoutTabs();
|
||||||
Session? ht = null;
|
Session? ht = null;
|
||||||
foreach (var s in _sessions) if (s.TabBounds.Contains(e.Location)) { ht = s; break; }
|
foreach (var s in _sessions) if (s.TabBounds.Contains(e.Location)) { ht = s; break; }
|
||||||
if (ht != _hoverTab) { _hoverTab = ht; _tabStrip.Invalidate(); }
|
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)
|
private void OnStripPaint(object? sender, PaintEventArgs e)
|
||||||
{
|
{
|
||||||
var g = e.Graphics;
|
var g = e.Graphics;
|
||||||
@@ -284,11 +350,15 @@ public sealed class WorkspaceView : UserControl
|
|||||||
foreach (var s in _sessions)
|
foreach (var s in _sessions)
|
||||||
{
|
{
|
||||||
bool active = s == _active, hover = s == _hoverTab;
|
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);
|
g.FillRectangle(b, s.TabBounds);
|
||||||
if (active)
|
if (active)
|
||||||
using (var bar = new SolidBrush(Theme.Accent))
|
using (var bar = new SolidBrush(Theme.Accent))
|
||||||
g.FillRectangle(bar, new Rectangle(s.TabBounds.Left, s.TabBounds.Bottom - 2, s.TabBounds.Width, 2));
|
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))
|
using (var dot = new SolidBrush(s.IsSsh ? Theme.SshColor : Theme.SerialColor))
|
||||||
g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8);
|
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);
|
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;
|
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) ──────────
|
// ── Run All(個別執行,拒絕 waitall/sendlnall) ──────────
|
||||||
private async void OnRunAllSerial(object? sender, EventArgs e)
|
private async void OnRunAllSerial(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 單一分頁的終端機輸出側錄器。按下 Log 按鈕時建立,再按一次(或關閉分頁)時 Dispose。
|
||||||
|
///
|
||||||
|
/// - 檔案位置:<exe 路徑>\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>把連線識別字串清成合法檔名(移除 \ / : * ? " < > | 等)。</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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ public interface ISessionChannel : IDisposable
|
|||||||
/// <summary>收到遠端資料(背景執行緒觸發,訂閱者需自行 Invoke 回 UI thread)。</summary>
|
/// <summary>收到遠端資料(背景執行緒觸發,訂閱者需自行 Invoke 回 UI thread)。</summary>
|
||||||
event Action<byte[]>? DataReceived;
|
event Action<byte[]>? DataReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用於 log 檔名的連線識別字串:
|
||||||
|
/// Shell → "PowerShell";Serial → "COM17";SSH → "etwen@192.168.1.50"。
|
||||||
|
/// </summary>
|
||||||
|
string LogName { get; }
|
||||||
|
|
||||||
/// <summary>建立連線;失敗丟例外。</summary>
|
/// <summary>建立連線;失敗丟例外。</summary>
|
||||||
void Open();
|
void Open();
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public sealed class SerialChannel : ISessionChannel
|
|||||||
|
|
||||||
public event Action<byte[]>? DataReceived;
|
public event Action<byte[]>? DataReceived;
|
||||||
|
|
||||||
|
public string LogName => _portName;
|
||||||
|
|
||||||
public SerialChannel(SerialSettings s)
|
public SerialChannel(SerialSettings s)
|
||||||
{
|
{
|
||||||
_portName = s.PortName;
|
_portName = s.PortName;
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ public sealed class SessionPage : UserControl
|
|||||||
private readonly TerminalView _term;
|
private readonly TerminalView _term;
|
||||||
private readonly ScriptRunner _runner = new();
|
private readonly ScriptRunner _runner = new();
|
||||||
private readonly Label _status;
|
private readonly Label _status;
|
||||||
private readonly Button _run, _stop;
|
private readonly Button _run, _stop, _log;
|
||||||
private bool _started;
|
private bool _started;
|
||||||
|
private SessionLogger? _logger;
|
||||||
|
|
||||||
/// <summary>底層連線通道。</summary>
|
/// <summary>底層連線通道。</summary>
|
||||||
public ISessionChannel Channel => _channel;
|
public ISessionChannel Channel => _channel;
|
||||||
@@ -78,9 +79,12 @@ public sealed class SessionPage : UserControl
|
|||||||
_run = MakeBarButton("▶ Script", OnRunScript);
|
_run = MakeBarButton("▶ Script", OnRunScript);
|
||||||
_stop = MakeBarButton("■ Stop", (_, _) => _runner.Cancel());
|
_stop = MakeBarButton("■ Stop", (_, _) => _runner.Cancel());
|
||||||
_stop.Enabled = false;
|
_stop.Enabled = false;
|
||||||
|
_log = MakeBarButton("⏺ Log", OnToggleLog);
|
||||||
|
_log.Width = 92; // 容納 "⏺ Logging" 不被截字
|
||||||
bar.Controls.Add(_status); // Fill 先加
|
bar.Controls.Add(_status); // Fill 先加
|
||||||
bar.Controls.Add(_run); // Right
|
bar.Controls.Add(_log); // Right(最左:Log)
|
||||||
bar.Controls.Add(_stop); // Right
|
bar.Controls.Add(_run); // Right(中:Script)
|
||||||
|
bar.Controls.Add(_stop); // Right(最右:Stop)
|
||||||
|
|
||||||
_term = new TerminalView(new TerminalProfile()) { Dock = DockStyle.Fill };
|
_term = new TerminalView(new TerminalProfile()) { Dock = DockStyle.Fill };
|
||||||
_term.SendData += data => _channel.Write(data);
|
_term.SendData += data => _channel.Write(data);
|
||||||
@@ -113,6 +117,56 @@ public sealed class SessionPage : UserControl
|
|||||||
await _runner.RunAsync(content, name, _channel);
|
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)
|
private static bool ContainsGroupCommands(string content)
|
||||||
{
|
{
|
||||||
foreach (var line in content.Split('\n'))
|
foreach (var line in content.Split('\n'))
|
||||||
@@ -172,6 +226,8 @@ public sealed class SessionPage : UserControl
|
|||||||
|
|
||||||
private void OnDataReceived(byte[] data)
|
private void OnDataReceived(byte[] data)
|
||||||
{
|
{
|
||||||
|
// 側錄不依賴 UI thread,直接在背景緒寫檔(SessionLogger 內部自鎖)。
|
||||||
|
_logger?.Write(data);
|
||||||
if (IsDisposed || !IsHandleCreated) return;
|
if (IsDisposed || !IsHandleCreated) return;
|
||||||
if (InvokeRequired) BeginInvoke(() => _term.Feed(data));
|
if (InvokeRequired) BeginInvoke(() => _term.Feed(data));
|
||||||
else _term.Feed(data);
|
else _term.Feed(data);
|
||||||
@@ -183,6 +239,8 @@ public sealed class SessionPage : UserControl
|
|||||||
{
|
{
|
||||||
_runner.Cancel();
|
_runner.Cancel();
|
||||||
_channel.DataReceived -= OnDataReceived;
|
_channel.DataReceived -= OnDataReceived;
|
||||||
|
_logger?.Dispose();
|
||||||
|
_logger = null;
|
||||||
SessionManager.Unregister(_channel);
|
SessionManager.Unregister(_channel);
|
||||||
_channel.Dispose();
|
_channel.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public sealed class ShellChannel : ISessionChannel
|
|||||||
|
|
||||||
public event Action<byte[]>? DataReceived;
|
public event Action<byte[]>? DataReceived;
|
||||||
|
|
||||||
|
public string LogName => _settings.ShellType;
|
||||||
|
|
||||||
public ShellChannel(ShellSettings settings) => _settings = settings;
|
public ShellChannel(ShellSettings settings) => _settings = settings;
|
||||||
|
|
||||||
public void Open()
|
public void Open()
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed class SshChannel : ISessionChannel
|
|||||||
|
|
||||||
public event Action<byte[]>? DataReceived;
|
public event Action<byte[]>? DataReceived;
|
||||||
|
|
||||||
|
public string LogName => $"{_ssh.Username}@{_ssh.Host}";
|
||||||
|
|
||||||
public SshChannel(Connection conn)
|
public SshChannel(Connection conn)
|
||||||
{
|
{
|
||||||
_ssh = conn.Ssh ?? new SshSettings();
|
_ssh = conn.Ssh ?? new SshSettings();
|
||||||
|
|||||||
Reference in New Issue
Block a user