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();