feat: initial release v0.1.0

- KKTerm-style UI: Activity Rail, Connection Sidebar, Tabbed Workspace
- Serial Port connection (System.IO.Ports)
- SSH connection (SSH.NET, password/key/keyboard-interactive)
- Local Shell via Windows ConPTY (PowerShell/Bash/Cmd)
- VT100/ANSI terminal rendering (owner-drawn, double-buffered)
- TTL script engine: send, sendln, wait, pause, timeout, if/while, messagebox
- Group execution: waitall, sendlnall, sendlngroup (Barrier sync)
- Run All / Run Group toolbar buttons
- SFTP file browser sidebar (SSH.NET SftpClient)
- PDU control via SNMP (pduconnect/pductrl commands)
- Settings: Terminal prefs, Shell config, PDU monitoring
- About page with changelog
- AppSettings persisted to %LocalAppData%\ETTerms\
- Dark theme throughout (KKTerm purple accent)
This commit is contained in:
2026-06-03 17:09:55 +08:00
commit 2bfb2c6575
46 changed files with 5838 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
using System.Drawing;
using System.Reflection;
using System.Windows.Forms;
namespace ETTerms.App;
/// <summary>About page: app info (left) + changelog (right).</summary>
public sealed class AboutView : UserControl
{
public AboutView()
{
Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack;
AutoScroll = true;
var version = Assembly.GetExecutingAssembly().GetName().Version;
var versionStr = version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "0.1.0";
// ── Main layout: left fixed + right scrollable ──
var left = new FlowLayoutPanel
{
Dock = DockStyle.Left, Width = 380, FlowDirection = FlowDirection.TopDown,
WrapContents = false, AutoScroll = false, Padding = new Padding(20),
BackColor = Theme.WorkspaceBack
};
// App card
left.Controls.Add(MakeCard(340, 140, p =>
{
p.Controls.Add(MakeLine($"ETTerms", Theme.UiFontBold, Theme.Accent, ContentAlignment.MiddleCenter));
p.Controls.Add(MakeLine($"v{versionStr}", Theme.UiFont, Theme.TextDim, ContentAlignment.MiddleCenter));
p.Controls.Add(MakeLine("", Theme.UiFont, Theme.TextDim, ContentAlignment.MiddleCenter));
p.Controls.Add(MakeLine("A native Windows terminal workspace", Theme.UiFont, Theme.Text, ContentAlignment.MiddleCenter));
p.Controls.Add(MakeLine("for SSH & Serial Port connections,", Theme.UiFont, Theme.Text, ContentAlignment.MiddleCenter));
p.Controls.Add(MakeLine("with TTL scripting engine.", Theme.UiFont, Theme.Text, ContentAlignment.MiddleCenter));
}));
// Author card
left.Controls.Add(MakeCard(340, 110, p =>
{
p.Controls.Add(MakeLine("👤 Developer", Theme.UiFontBold, Theme.Text));
p.Controls.Add(MakeRow("Name", "ET Wen"));
p.Controls.Add(MakeRow("Email", "eric441151893@gmail.com"));
p.Controls.Add(MakeRow("GitHub", "github.com/ETWen"));
}));
// Tech stack card
left.Controls.Add(MakeCard(340, 120, p =>
{
p.Controls.Add(MakeLine("🔧 Tech Stack", Theme.UiFontBold, Theme.Text));
p.Controls.Add(MakeLine(" .NET 8 · WinForms · C#", Theme.UiFont, Theme.TextDim));
p.Controls.Add(MakeLine(" SSH.NET · System.IO.Ports", Theme.UiFont, Theme.TextDim));
p.Controls.Add(MakeLine(" SQLite · Windows Credential Manager", Theme.UiFont, Theme.TextDim));
p.Controls.Add(MakeLine(" TTL Script Engine (ported from MyTeraTerm)", Theme.UiFont, Theme.TextDim));
}));
// ═══ RIGHT PANEL — Changelog (scrollable) ═══
var right = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, AutoScroll = true, Padding = new Padding(10, 20, 20, 20),
BackColor = Theme.WorkspaceBack
};
right.Controls.Add(MakeLine("📋 Changelog", Theme.UiFontBold, Theme.Text));
right.Controls.Add(MakeSpacer(8));
foreach (var entry in Changelog)
{
var header = new Label
{
AutoSize = false, Width = 500, Height = 22,
Text = $"● v{entry.Version} · {entry.Title} ({entry.Date:yyyy-MM-dd})",
ForeColor = Theme.Accent, Font = Theme.UiFontBold,
Margin = new Padding(0, 8, 0, 2)
};
right.Controls.Add(header);
foreach (var change in entry.Changes)
{
right.Controls.Add(new Label
{
AutoSize = false, Width = 500, Height = 20,
Text = $" • {change}",
ForeColor = Theme.Text, Font = Theme.UiFont,
Margin = new Padding(0)
});
}
}
Controls.Add(right); // Fill first
Controls.Add(left); // Left
}
// ── Helpers ──
private static Panel MakeCard(int width, int height, Action<FlowLayoutPanel> build)
{
var card = new Panel
{
Width = width, Height = height, Margin = new Padding(0, 0, 0, 12),
BackColor = Theme.TabBack, Padding = new Padding(12)
};
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.TabBack, AutoSize = false
};
build(flow);
card.Controls.Add(flow);
return card;
}
private static Label MakeLine(string text, Font font, Color color, ContentAlignment align = ContentAlignment.MiddleLeft)
{
return new Label
{
AutoSize = false, Width = 310, Height = 20,
Text = text, Font = font, ForeColor = color,
TextAlign = align, Margin = new Padding(0)
};
}
private static Panel MakeRow(string label, string value)
{
var row = new Panel { Width = 310, Height = 20, Margin = new Padding(0, 2, 0, 0), BackColor = Color.Transparent };
row.Controls.Add(new Label
{
Text = value, AutoSize = false, Width = 230, Height = 20, Dock = DockStyle.Right,
ForeColor = Theme.Text, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleRight
});
row.Controls.Add(new Label
{
Text = label, AutoSize = false, Width = 70, Height = 20, Dock = DockStyle.Left,
ForeColor = Theme.TextDim, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleLeft
});
return row;
}
private static Control MakeSpacer(int height) => new Panel { Width = 10, Height = height, Margin = Padding.Empty };
// ── Changelog Data ──
private static readonly ChangelogEntry[] Changelog =
[
new("0.1.0", new DateOnly(2026, 6, 3), "Initial Release",
[
"Beta Version: expect bugs and missing features. Feedback welcome!",
]),
];
private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes);
}
+117
View File
@@ -0,0 +1,117 @@
using System.Drawing;
using System.Windows.Forms;
namespace ETTerms.App;
/// <summary>
/// 左側圖示列(仿 KKTerm ActivityRail)。
/// 三個檢視:Terminal / Scripts / Settings。
/// 點擊會觸發 <see cref="ViewSelected"/>。
/// </summary>
public sealed class ActivityRail : UserControl
{
public enum RailView { Terminal, Settings, About }
public event EventHandler<RailView>? ViewSelected;
private RailView _active = RailView.Terminal;
private RailView? _hover;
private const int ItemSize = 56;
private static readonly (RailView view, string glyph, string tip)[] Items =
{
(RailView.Terminal, "▤", "Terminal"),
(RailView.Settings, "⚙", "Settings"),
(RailView.About, "", "About"),
};
private readonly ToolTip _toolTip = new();
public ActivityRail()
{
Width = ItemSize;
Dock = DockStyle.Left;
BackColor = Theme.RailBack;
DoubleBuffered = true;
SetStyle(ControlStyles.ResizeRedraw, true);
Cursor = Cursors.Hand;
}
[System.ComponentModel.DesignerSerializationVisibility(
System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public RailView ActiveView
{
get => _active;
set { _active = value; Invalidate(); }
}
private int IndexAt(int y)
{
int idx = y / ItemSize;
return (idx >= 0 && idx < Items.Length) ? idx : -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
int idx = IndexAt(e.Y);
RailView? newHover = idx >= 0 ? Items[idx].view : null;
if (newHover != _hover)
{
_hover = newHover;
_toolTip.SetToolTip(this, idx >= 0 ? Items[idx].tip : "");
Invalidate();
}
}
protected override void OnMouseLeave(EventArgs e)
{
base.OnMouseLeave(e);
_hover = null;
Invalidate();
}
protected override void OnMouseClick(MouseEventArgs e)
{
base.OnMouseClick(e);
int idx = IndexAt(e.Y);
if (idx < 0) return;
ActiveView = Items[idx].view;
ViewSelected?.Invoke(this, Items[idx].view);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.Clear(Theme.RailBack);
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
using var glyphFont = new Font("Segoe UI Symbol", 17f);
var fmt = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center
};
for (int i = 0; i < Items.Length; i++)
{
var rect = new Rectangle(0, i * ItemSize, ItemSize, ItemSize);
bool isActive = Items[i].view == _active;
bool isHover = _hover == Items[i].view;
if (isHover && !isActive)
using (var b = new SolidBrush(Theme.Hover)) g.FillRectangle(b, rect);
if (isActive)
{
using var ab = new SolidBrush(Theme.AccentDim);
g.FillRectangle(ab, rect);
using var bar = new SolidBrush(Theme.Accent);
g.FillRectangle(bar, new Rectangle(0, rect.Top, 3, ItemSize));
}
using var tb = new SolidBrush(isActive ? Theme.Text : Theme.TextDim);
g.DrawString(Items[i].glyph, glyphFont, tb, rect, fmt);
}
}
}
+663
View File
@@ -0,0 +1,663 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.App.Dialogs;
using ETTerms.Connections;
namespace ETTerms.App;
/// <summary>
/// Saved Connections 側欄(仿 KKTerm):可自建資料夾、把連線分類進去。
/// 功能:搜尋、Quick Connect、新增資料夾 / 連線、改名、刪除、拖曳分類、展開 / 收合。
/// Phase 2:連線資料綁 <see cref="ConnectionStore"/>SQLite)持久化,密碼走 <see cref="CredentialVault"/>。
/// 資料夾以連線的 GroupName'/'-join 路徑)持久化;空資料夾為 session-only。
/// </summary>
public sealed class ConnectionSidebar : UserControl
{
/// <summary>使用者啟用(雙擊 / Open / Quick Connect)一條連線時觸發。</summary>
public event EventHandler<Connection>? ConnectionActivated;
// ── in-memory tree(連線節點掛 Connection;資料夾節點只用 Name)──
private enum Kind { Folder, Connection }
private sealed class Node
{
public Kind Kind;
public string Name = ""; // 資料夾名稱
public Connection? Conn; // 連線資料(Kind == Connection
public bool Expanded = true;
public Node? Parent;
public readonly List<Node> Children = new();
public bool IsFolder => Kind == Kind.Folder;
public string DisplayName => IsFolder ? Name : (Conn?.Name ?? "");
public string Detail => Conn?.Detail ?? "";
public bool IsSsh => Conn?.IsSsh ?? false;
}
private readonly Node _root = new() { Kind = Kind.Folder, Name = "root" };
private readonly ConnectionStore _store = new();
private int _sortSeq;
private readonly TreeView _tree;
private readonly TextBox _search;
private TreeNode? _dragNode;
public ConnectionSidebar()
{
Width = 250;
Dock = DockStyle.Left;
BackColor = Theme.SidebarBack;
// ── Tab bar (Sessions / SFTP) ──
var tabBar = new FlowLayoutPanel
{
Dock = DockStyle.Top, Height = 30, BackColor = Theme.RailBack,
Padding = new Padding(4, 3, 4, 0), WrapContents = false
};
var sessionsPanel = new Panel { Dock = DockStyle.Fill, BackColor = Theme.SidebarBack };
var sftpPanel = new Panel { Dock = DockStyle.Fill, BackColor = Theme.SidebarBack, Visible = false };
Button? activeTab = null;
Button MakeTabBtn(string text, Panel panel)
{
var b = new Button
{
Text = text, Width = 70, Height = 24, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
};
b.FlatAppearance.BorderColor = Theme.Border;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += (_, _) =>
{
sessionsPanel.Visible = panel == sessionsPanel;
sftpPanel.Visible = panel == sftpPanel;
if (activeTab != null) activeTab.BackColor = Theme.TabBack;
b.BackColor = Theme.TabActiveBack;
activeTab = b;
};
return b;
}
var sessBtn = MakeTabBtn("📡 Sessions", sessionsPanel);
var sftpBtn = MakeTabBtn("📁 SFTP", sftpPanel);
sessBtn.BackColor = Theme.TabActiveBack;
activeTab = sessBtn;
tabBar.Controls.Add(sessBtn);
tabBar.Controls.Add(sftpBtn);
// ── Sessions panel content ──
var header = new Panel { Dock = DockStyle.Top, Height = 34, BackColor = Theme.SidebarBack };
var title = new Label
{
Text = "CONNECTIONS",
Dock = DockStyle.Fill,
ForeColor = Theme.Text,
Font = Theme.UiFontBold,
TextAlign = ContentAlignment.MiddleLeft,
Padding = new Padding(10, 0, 0, 0)
};
var btnNewFolder = IconButton("🗀", "New Folder", (_, _) => NewFolder(SelectedFolder()));
var btnNewConn = IconButton("", "New Connection", (_, _) => NewConnection(SelectedFolder()));
btnNewConn.Dock = DockStyle.Right;
btnNewFolder.Dock = DockStyle.Right;
header.Controls.Add(title);
header.Controls.Add(btnNewFolder);
header.Controls.Add(btnNewConn);
var searchHost = new Panel { Dock = DockStyle.Top, Height = 32, BackColor = Theme.SidebarBack, Padding = new Padding(8, 2, 8, 4) };
_search = new TextBox
{
Dock = DockStyle.Fill,
BackColor = Theme.WorkspaceBack,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.UiFont,
PlaceholderText = "Search hosts, folders"
};
_search.TextChanged += (_, _) => Rebuild();
searchHost.Controls.Add(_search);
var quick = new Button
{
Text = "▷ Quick Connect",
Dock = DockStyle.Top,
Height = 34,
FlatStyle = FlatStyle.Flat,
ForeColor = Color.White,
BackColor = Theme.Accent,
Font = Theme.UiFontBold,
Cursor = Cursors.Hand,
Margin = new Padding(8)
};
quick.FlatAppearance.BorderSize = 0;
quick.FlatAppearance.MouseOverBackColor = Theme.AccentDim;
quick.Click += (_, _) => QuickConnect();
var quickHost = new Panel { Dock = DockStyle.Top, Height = 42, BackColor = Theme.SidebarBack, Padding = new Padding(8, 4, 8, 4) };
quickHost.Controls.Add(quick);
var shellBtn = new Button
{
Text = "🖥 Local Shell", Dock = DockStyle.Top, Height = 30,
FlatStyle = FlatStyle.Flat, ForeColor = Theme.Text, BackColor = Theme.TabBack,
Font = Theme.UiFont, Cursor = Cursors.Hand
};
shellBtn.FlatAppearance.BorderColor = Theme.Border;
shellBtn.FlatAppearance.MouseOverBackColor = Theme.Hover;
shellBtn.Click += (_, _) => OpenLocalShell();
var shellHost = new Panel { Dock = DockStyle.Top, Height = 36, BackColor = Theme.SidebarBack, Padding = new Padding(8, 2, 8, 4) };
shellHost.Controls.Add(shellBtn);
var toolbar = new Panel { Dock = DockStyle.Top, Height = 28, BackColor = Theme.SidebarBack };
var btnExpand = IconButton("⊞", "Expand All", (_, _) => SetAllExpanded(true));
var btnCollapse = IconButton("⊟", "Collapse All", (_, _) => SetAllExpanded(false));
btnExpand.Dock = DockStyle.Right;
btnCollapse.Dock = DockStyle.Right;
toolbar.Controls.Add(btnExpand);
toolbar.Controls.Add(btnCollapse);
_tree = new TreeView
{
Dock = DockStyle.Fill,
BackColor = Theme.SidebarBack,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.None,
Font = Theme.UiFont,
HideSelection = false,
ShowLines = false,
ShowRootLines = true,
ShowPlusMinus = true,
FullRowSelect = true,
ItemHeight = 26,
Indent = 18,
AllowDrop = true
};
_tree.NodeMouseDoubleClick += OnNodeDoubleClick;
_tree.AfterExpand += (_, e) => { if (e.Node?.Tag is Node n) n.Expanded = true; };
_tree.AfterCollapse += (_, e) => { if (e.Node?.Tag is Node n) n.Expanded = false; };
_tree.MouseDown += OnTreeMouseDown;
_tree.ItemDrag += OnItemDrag;
_tree.DragEnter += (_, e) => e.Effect = DragDropEffects.Move;
_tree.DragOver += OnDragOver;
_tree.DragDrop += OnDragDrop;
sessionsPanel.Controls.Add(_tree);
sessionsPanel.Controls.Add(toolbar);
sessionsPanel.Controls.Add(shellHost);
sessionsPanel.Controls.Add(quickHost);
sessionsPanel.Controls.Add(searchHost);
sessionsPanel.Controls.Add(header);
// ── SFTP panel content ──
BuildSftpPanel(sftpPanel);
// ── Main layout ──
Controls.Add(sessionsPanel);
Controls.Add(sftpPanel);
Controls.Add(tabBar);
LoadFromStore();
Rebuild();
}
// ── SFTP panel builder ──────────────────────────────────────
private Renci.SshNet.SftpClient? _sftp;
private ListView? _sftpList;
private TextBox? _sftpPath;
private void BuildSftpPanel(Panel panel)
{
// Connection row
var connRow = new FlowLayoutPanel
{
Dock = DockStyle.Top, Height = 30, BackColor = Theme.SidebarBack,
Padding = new Padding(4, 4, 4, 0), WrapContents = false
};
var sshLabel = new Label { Text = "SSH:", AutoSize = true, ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 4, 4, 0) };
var sshCombo = new ComboBox { Width = 120, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont };
var connectBtn = new Button
{
Text = "Connect", Width = 64, Height = 24, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand, Margin = new Padding(4, 0, 0, 0)
};
connectBtn.FlatAppearance.BorderColor = Theme.SerialColor;
connRow.Controls.Add(sshLabel);
connRow.Controls.Add(sshCombo);
connRow.Controls.Add(connectBtn);
// Path bar
_sftpPath = new TextBox
{
Dock = DockStyle.Top, Height = 24, Text = "/",
BackColor = Theme.TabBack, ForeColor = Theme.Accent, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle
};
_sftpPath.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter && _sftp?.IsConnected == true) SftpNavigate(_sftpPath.Text); };
// File list
_sftpList = new ListView
{
Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true,
BackColor = Theme.SidebarBack, ForeColor = Theme.Text, Font = Theme.UiFont,
BorderStyle = BorderStyle.None, HeaderStyle = ColumnHeaderStyle.Nonclickable
};
_sftpList.Columns.Add("Name", 150);
_sftpList.Columns.Add("Size", 60, HorizontalAlignment.Right);
_sftpList.DoubleClick += (_, _) =>
{
if (_sftpList.SelectedItems.Count == 0 || _sftp?.IsConnected != true) return;
var item = _sftpList.SelectedItems[0];
if (item.Tag is string dir) SftpNavigate(dir);
};
panel.Controls.Add(_sftpList);
panel.Controls.Add(_sftpPath);
panel.Controls.Add(connRow);
// Populate SSH connections on tab show
panel.VisibleChanged += (_, _) =>
{
if (!panel.Visible) return;
sshCombo.Items.Clear();
foreach (var conn in _store.GetAll().Where(c => c.IsSsh))
sshCombo.Items.Add(conn);
sshCombo.DisplayMember = "Name";
};
connectBtn.Click += (_, _) =>
{
if (_sftp?.IsConnected == true) { _sftp.Disconnect(); _sftp.Dispose(); _sftp = null; connectBtn.Text = "Connect"; _sftpList!.Items.Clear(); return; }
if (sshCombo.SelectedItem is not Connections.Connection conn || conn.Ssh == null) return;
try
{
var secret = Connections.CredentialVault.Get(conn.CredentialKey);
var methods = new List<Renci.SshNet.AuthenticationMethod>();
if (!string.IsNullOrEmpty(secret))
methods.Add(new Renci.SshNet.PasswordAuthenticationMethod(conn.Ssh.Username, secret));
var ci = new Renci.SshNet.ConnectionInfo(conn.Ssh.Host, conn.Ssh.Port, conn.Ssh.Username, methods.ToArray());
_sftp = new Renci.SshNet.SftpClient(ci);
_sftp.Connect();
connectBtn.Text = "Disconnect";
SftpNavigate(_sftp.WorkingDirectory ?? "/");
}
catch (Exception ex)
{
MessageBox.Show(this, $"SFTP connect failed:\n{ex.Message}", "SFTP", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
};
}
private void SftpNavigate(string path)
{
if (_sftp == null || _sftpList == null || _sftpPath == null) return;
try
{
var items = _sftp.ListDirectory(path).OrderByDescending(f => f.IsDirectory).ThenBy(f => f.Name).ToList();
_sftpPath.Text = path;
_sftpList.Items.Clear();
if (path != "/")
{
var parent = path.TrimEnd('/');
int lastSlash = parent.LastIndexOf('/');
string parentPath = lastSlash <= 0 ? "/" : parent[..lastSlash];
var up = new ListViewItem(new[] { "..", "" }) { Tag = parentPath, ForeColor = Theme.Accent };
_sftpList.Items.Add(up);
}
foreach (var f in items)
{
if (f.Name == "." || f.Name == "..") continue;
string size = f.IsDirectory ? "" : FormatSize(f.Length);
var lvi = new ListViewItem(new[] { (f.IsDirectory ? "📁 " : "📄 ") + f.Name, size });
lvi.ForeColor = f.IsDirectory ? Theme.Accent : Theme.Text;
if (f.IsDirectory) lvi.Tag = f.FullName;
_sftpList.Items.Add(lvi);
}
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "SFTP", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes}",
< 1024 * 1024 => $"{bytes / 1024}K",
_ => $"{bytes / (1024 * 1024)}M"
};
// ── 從 SQLite 載入並重建資料夾樹 ─────────────────────────
private void LoadFromStore()
{
foreach (var conn in _store.GetAll())
{
var folder = EnsureFolderPath(conn.GroupName);
AddNode(folder, new Node { Kind = Kind.Connection, Conn = conn });
_sortSeq = Math.Max(_sortSeq, conn.SortOrder);
}
}
private Node EnsureFolderPath(string? group)
{
var cur = _root;
if (string.IsNullOrEmpty(group)) return cur;
foreach (var part in group.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
var next = cur.Children.FirstOrDefault(c => c.IsFolder && c.Name == part)
?? AddNode(cur, new Node { Kind = Kind.Folder, Name = part });
cur = next;
}
return cur;
}
private string? PathOf(Node folder)
{
var parts = new List<string>();
for (var n = folder; n != null && n != _root; n = n.Parent) parts.Insert(0, n.Name);
return parts.Count == 0 ? null : string.Join("/", parts);
}
private static Node AddNode(Node parent, Node child)
{
child.Parent = parent;
parent.Children.Add(child);
return child;
}
// ── 重建 TreeView(套用搜尋過濾、保留展開狀態)────────────
private void Rebuild()
{
string q = _search.Text.Trim().ToLowerInvariant();
_tree.BeginUpdate();
_tree.Nodes.Clear();
foreach (var child in _root.Children)
{
var tn = BuildTreeNode(child, q);
if (tn != null) _tree.Nodes.Add(tn);
}
_tree.EndUpdate();
}
private TreeNode? BuildTreeNode(Node node, string filter)
{
bool selfMatch = filter.Length == 0
|| node.DisplayName.ToLowerInvariant().Contains(filter)
|| node.Detail.ToLowerInvariant().Contains(filter);
if (node.IsFolder)
{
var childNodes = new List<TreeNode>();
foreach (var c in node.Children)
{
var ctn = BuildTreeNode(c, filter);
if (ctn != null) childNodes.Add(ctn);
}
if (!selfMatch && childNodes.Count == 0) return null;
var tn = new TreeNode($"📁 {node.Name} ({CountConnections(node)})")
{
Tag = node,
ForeColor = Theme.TextDim
};
tn.Nodes.AddRange(childNodes.ToArray());
if (filter.Length > 0 || node.Expanded) tn.Expand();
return tn;
}
if (!selfMatch) return null;
string detail = node.Conn != null ? $" ({(node.IsSsh ? node.Conn.Ssh?.Host : node.Conn.Serial?.PortName)})" : "";
return new TreeNode($"{(node.IsSsh ? "🖧" : "🔌")} {node.DisplayName}{detail}")
{
Tag = node,
ToolTipText = node.Detail,
ForeColor = node.IsSsh ? Theme.SshColor : Theme.SerialColor
};
}
private static int CountConnections(Node folder)
{
int n = 0;
foreach (var c in folder.Children)
n += c.IsFolder ? CountConnections(c) : 1;
return n;
}
// ── 操作:新增 / 改名 / 刪除 ──────────────────────────────
private Node SelectedFolder()
{
if (_tree.SelectedNode?.Tag is Node n)
return n.IsFolder ? n : (n.Parent ?? _root);
return _root;
}
private void NewFolder(Node parent)
{
string? name = TextPromptDialog.Ask(this, "New Folder", "Folder name", "New Folder");
if (name == null) return;
var node = AddNode(parent, new Node { Kind = Kind.Folder, Name = name });
parent.Expanded = true;
Rebuild();
SelectModelNode(node);
}
private void NewConnection(Node parent)
{
using var d = new ConnectionEditDialog("New Connection");
if (d.ShowDialog(this) != DialogResult.OK) return;
var conn = d.Result;
conn.GroupName = PathOf(parent);
conn.SortOrder = ++_sortSeq;
_store.Upsert(conn);
if (d.Password.Length > 0) CredentialVault.Set(conn.CredentialKey, d.Password);
var node = AddNode(parent, new Node { Kind = Kind.Connection, Conn = conn });
parent.Expanded = true;
Rebuild();
SelectModelNode(node);
}
private void RenameNode(Node node)
{
if (node.IsFolder)
{
string? name = TextPromptDialog.Ask(this, "Rename Folder", "Folder name", node.Name);
if (name == null) return;
node.Name = name;
PersistSubtreeGroups(node); // 子連線的 GroupName 路徑跟著變
}
else
{
using var d = new ConnectionEditDialog("Edit Connection", node.Conn);
if (d.ShowDialog(this) != DialogResult.OK) return;
node.Conn = d.Result; // 保留 Id / GroupName / SortOrder
_store.Upsert(node.Conn);
if (d.Password.Length > 0) CredentialVault.Set(node.Conn.CredentialKey, d.Password);
}
Rebuild();
SelectModelNode(node);
}
private void DeleteNode(Node node)
{
string what = node.IsFolder ? $"folder \"{node.Name}\" and its contents" : $"connection \"{node.DisplayName}\"";
if (MessageBox.Show($"Delete {what}?", "Delete", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning)
!= DialogResult.OK) return;
DeleteFromStore(node);
node.Parent?.Children.Remove(node);
Rebuild();
}
private void DeleteFromStore(Node node)
{
if (node.IsFolder)
{
foreach (var c in node.Children) DeleteFromStore(c);
}
else if (node.Conn is { } c)
{
_store.Delete(c.Id);
CredentialVault.Delete(c.CredentialKey);
}
}
/// <summary>把資料夾子樹下所有連線的 GroupName 重設為現在的路徑並存檔。</summary>
private void PersistSubtreeGroups(Node folder)
{
foreach (var c in folder.Children)
{
if (c.IsFolder) PersistSubtreeGroups(c);
else if (c.Conn is { } conn) { conn.GroupName = PathOf(folder); _store.Upsert(conn); }
}
}
private void QuickConnect()
{
using var d = new ConnectionEditDialog("Quick Connect");
if (d.ShowDialog(this) != DialogResult.OK) return;
ConnectionActivated?.Invoke(this, d.Result); // ad-hoc:不存入樹 / SQLite
}
private void OpenLocalShell()
{
var s = Infrastructure.AppSettings.Instance;
var conn = new Connection
{
Name = s.ShellType,
Type = ConnectionType.Shell,
Shell = new ShellSettings { ShellType = s.ShellType, StartupDirectory = s.ShellStartupDir }
};
ConnectionActivated?.Invoke(this, conn);
}
private void SetAllExpanded(bool expanded)
{
void Walk(Node n) { if (n.IsFolder) { n.Expanded = expanded; foreach (var c in n.Children) Walk(c); } }
foreach (var c in _root.Children) Walk(c);
Rebuild();
}
// ── 事件 ─────────────────────────────────────────────────
private void Activate(Node n)
{
if (n.Conn is { } c) ConnectionActivated?.Invoke(this, c);
}
private void OnNodeDoubleClick(object? sender, TreeNodeMouseClickEventArgs e)
{
if (e.Node?.Tag is Node { IsFolder: false } n) Activate(n);
}
private void OnTreeMouseDown(object? sender, MouseEventArgs e)
{
var node = _tree.GetNodeAt(e.Location);
if (node != null) _tree.SelectedNode = node;
if (e.Button == MouseButtons.Right)
ShowContextMenu(node?.Tag as Node, e.Location);
}
private void ShowContextMenu(Node? node, Point at)
{
var menu = new ContextMenuStrip { BackColor = Theme.SidebarBack, ForeColor = Theme.Text };
if (node is { IsFolder: false } conn)
{
menu.Items.Add("Open", null, (_, _) => Activate(conn));
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("Rename / Edit", null, (_, _) => RenameNode(conn));
menu.Items.Add("Delete", null, (_, _) => DeleteNode(conn));
}
else
{
var target = node ?? _root;
menu.Items.Add("New Folder", null, (_, _) => NewFolder(target));
menu.Items.Add("New Connection", null, (_, _) => NewConnection(target));
if (node != null)
{
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("Rename", null, (_, _) => RenameNode(node));
menu.Items.Add("Delete", null, (_, _) => DeleteNode(node));
}
}
menu.Show(_tree, at);
}
// ── 拖曳分類(更新 GroupName 並持久化)────────────────────
private void OnItemDrag(object? sender, ItemDragEventArgs e)
{
_dragNode = e.Item as TreeNode;
if (_dragNode != null) _tree.DoDragDrop(_dragNode, DragDropEffects.Move);
}
private void OnDragOver(object? sender, DragEventArgs e)
{
var pt = _tree.PointToClient(new Point(e.X, e.Y));
_tree.SelectedNode = _tree.GetNodeAt(pt);
e.Effect = DragDropEffects.Move;
}
private void OnDragDrop(object? sender, DragEventArgs e)
{
if (_dragNode?.Tag is not Node src) return;
var pt = _tree.PointToClient(new Point(e.X, e.Y));
var targetNode = _tree.GetNodeAt(pt)?.Tag as Node;
Node dest = targetNode == null ? _root
: targetNode.IsFolder ? targetNode
: (targetNode.Parent ?? _root);
if (src == dest || IsDescendant(src, dest)) return;
src.Parent?.Children.Remove(src);
src.Parent = dest;
dest.Children.Add(src);
dest.Expanded = true;
if (src.IsFolder) PersistSubtreeGroups(src);
else if (src.Conn is { } c) { c.GroupName = PathOf(dest); _store.Upsert(c); }
Rebuild();
SelectModelNode(src);
_dragNode = null;
}
private static bool IsDescendant(Node ancestor, Node maybe)
{
for (var p = maybe.Parent; p != null; p = p.Parent)
if (p == ancestor) return true;
return false;
}
// ── 小工具 ───────────────────────────────────────────────
private void SelectModelNode(Node target)
{
TreeNode? Find(TreeNodeCollection nodes)
{
foreach (TreeNode tn in nodes)
{
if (ReferenceEquals(tn.Tag, target)) return tn;
var r = Find(tn.Nodes);
if (r != null) return r;
}
return null;
}
var found = Find(_tree.Nodes);
if (found != null) _tree.SelectedNode = found;
}
private Button IconButton(string glyph, string tip, EventHandler onClick)
{
var b = new Button
{
Text = glyph,
Width = 34,
Height = 34,
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.TextDim,
BackColor = Theme.SidebarBack,
Font = new Font("Segoe UI Symbol", 11f),
Cursor = Cursors.Hand
};
b.FlatAppearance.BorderSize = 0;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += onClick;
new ToolTip().SetToolTip(b, tip);
return b;
}
}
@@ -0,0 +1,208 @@
using System.Drawing;
using System.IO.Ports;
using System.Windows.Forms;
using ETTerms.Connections;
namespace ETTerms.App.Dialogs;
/// <summary>
/// 連線新增 / 編輯對話框:依類型切換 SSHhost/port/user/auth/key/password
/// 與 SerialCOM/baud/databits/parity/stopbits/handshake)表單。
/// 輸出 <see cref="Result"/>Connection)與 <see cref="Password"/>(空 = 不變更)。
/// </summary>
public sealed class ConnectionEditDialog : DarkDialog
{
private readonly Connection? _existing;
private readonly TextBox _name;
private readonly ComboBox _type;
// SSH(於 BuildSshPanel 指派)
private readonly Panel _sshPanel;
private TextBox _host = null!, _user = null!, _keyPath = null!, _password = null!;
private NumericUpDown _port = null!;
private ComboBox _auth = null!;
// Serial(於 BuildSerialPanel 指派)
private readonly Panel _serialPanel;
private ComboBox _com = null!, _baud = null!, _parity = null!, _stopBits = null!, _handshake = null!;
private NumericUpDown _dataBits = null!;
public string Password => _password.Text;
public Connection Result { get; private set; } = new();
public ConnectionEditDialog(string title, Connection? existing = null)
{
_existing = existing;
Text = title;
ClientSize = new Size(420, 452);
var lblName = MakeLabel("Name"); lblName.Location = new Point(16, 12);
_name = MakeTextBox(existing?.Name ?? ""); _name.SetBounds(16, 34, 388, 26);
var lblType = MakeLabel("Type"); lblType.Location = new Point(16, 66);
_type = MakeCombo(false); _type.Items.AddRange(new object[] { "SSH", "Serial" });
_type.SetBounds(16, 88, 388, 26);
_type.SelectedIndexChanged += (_, _) => ToggleType();
_sshPanel = new Panel { Bounds = new Rectangle(12, 122, 404, 300), BackColor = Theme.SidebarBack };
_serialPanel = new Panel { Bounds = new Rectangle(12, 122, 404, 300), BackColor = Theme.SidebarBack };
BuildSshPanel(existing?.Ssh);
BuildSerialPanel(existing?.Serial);
var ok = MakeButton("OK", DialogResult.OK, accent: true); ok.Location = new Point(224, 414);
var cancel = MakeButton("Cancel", DialogResult.Cancel); cancel.Location = new Point(316, 414);
Controls.AddRange(new Control[] { lblName, _name, lblType, _type, _sshPanel, _serialPanel, ok, cancel });
AcceptButton = ok;
CancelButton = cancel;
_type.SelectedIndex = (existing?.Type ?? ConnectionType.Ssh) == ConnectionType.Ssh ? 0 : 1;
ToggleType();
}
private void ToggleType()
{
bool ssh = _type.SelectedIndex == 0;
_sshPanel.Visible = ssh;
_serialPanel.Visible = !ssh;
}
private void BuildSshPanel(SshSettings? s)
{
var lblHost = MakeLabel("Host"); lblHost.Location = new Point(4, 4);
_host = MakeTextBox(s?.Host ?? ""); _host.SetBounds(4, 26, 250, 26);
var lblPort = MakeLabel("Port"); lblPort.Location = new Point(264, 4);
_port = MakeNumeric(1, 65535, s?.Port ?? 22); _port.SetBounds(264, 26, 136, 26);
var lblUser = MakeLabel("Username"); lblUser.Location = new Point(4, 58);
_user = MakeTextBox(s?.Username ?? ""); _user.SetBounds(4, 80, 396, 26);
var lblAuth = MakeLabel("Auth Method"); lblAuth.Location = new Point(4, 112);
_auth = MakeCombo(false);
_auth.Items.AddRange(new object[] { "Password", "Private Key", "Keyboard Interactive" });
_auth.SelectedIndex = (int)(s?.AuthMethod ?? SshAuthMethod.Password);
_auth.SetBounds(4, 134, 396, 26);
var lblKey = MakeLabel("Private Key Path (optional)"); lblKey.Location = new Point(4, 166);
_keyPath = MakeTextBox(s?.PrivateKeyPath ?? ""); _keyPath.SetBounds(4, 188, 396, 26);
var lblPwd = MakeLabel("Password / Passphrase (blank = unchanged)"); lblPwd.Location = new Point(4, 220);
_password = MakeTextBox(""); _password.UseSystemPasswordChar = true; _password.SetBounds(4, 242, 396, 26);
_sshPanel.Controls.AddRange(new Control[]
{
lblHost, _host, lblPort, _port, lblUser, _user,
lblAuth, _auth, lblKey, _keyPath, lblPwd, _password
});
}
private void BuildSerialPanel(SerialSettings? s)
{
var lblCom = MakeLabel("COM Port"); lblCom.Location = new Point(4, 4);
_com = MakeCombo(true); _com.Items.AddRange(SerialPort.GetPortNames());
_com.Text = s?.PortName ?? (_com.Items.Count > 0 ? _com.Items[0]!.ToString()! : "COM1");
_com.SetBounds(4, 26, 196, 26);
var lblBaud = MakeLabel("Baud Rate"); lblBaud.Location = new Point(208, 4);
_baud = MakeCombo(true);
_baud.Items.AddRange(new object[] { 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600 });
_baud.Text = (s?.BaudRate ?? 115200).ToString();
_baud.SetBounds(208, 26, 192, 26);
var lblData = MakeLabel("Data Bits"); lblData.Location = new Point(4, 58);
_dataBits = MakeNumeric(5, 8, s?.DataBits ?? 8); _dataBits.SetBounds(4, 80, 196, 26);
var lblParity = MakeLabel("Parity"); lblParity.Location = new Point(208, 58);
_parity = MakeEnumCombo<Parity>(s?.Parity ?? Parity.None); _parity.SetBounds(208, 80, 192, 26);
var lblStop = MakeLabel("Stop Bits"); lblStop.Location = new Point(4, 112);
_stopBits = MakeEnumCombo<StopBits>(s?.StopBits ?? StopBits.One); _stopBits.SetBounds(4, 134, 196, 26);
var lblHand = MakeLabel("Handshake"); lblHand.Location = new Point(208, 112);
_handshake = MakeEnumCombo<Handshake>(s?.Handshake ?? Handshake.None); _handshake.SetBounds(208, 134, 192, 26);
_serialPanel.Controls.AddRange(new Control[]
{
lblCom, _com, lblBaud, _baud, lblData, _dataBits,
lblParity, _parity, lblStop, _stopBits, lblHand, _handshake
});
}
private Connection BuildResult()
{
var c = new Connection
{
Id = _existing?.Id ?? Guid.NewGuid(),
Name = _name.Text.Trim(),
SortOrder = _existing?.SortOrder ?? 0,
GroupName = _existing?.GroupName,
LastUsedUtc = _existing?.LastUsedUtc ?? DateTime.UtcNow
};
if (_type.SelectedIndex == 0)
{
c.Type = ConnectionType.Ssh;
c.Ssh = new SshSettings
{
Host = _host.Text.Trim(),
Port = (int)_port.Value,
Username = _user.Text.Trim(),
AuthMethod = (SshAuthMethod)_auth.SelectedIndex,
PrivateKeyPath = string.IsNullOrWhiteSpace(_keyPath.Text) ? null : _keyPath.Text.Trim()
};
}
else
{
c.Type = ConnectionType.Serial;
c.Serial = new SerialSettings
{
PortName = _com.Text.Trim(),
BaudRate = int.TryParse(_baud.Text, out int b) ? b : 115200,
DataBits = (int)_dataBits.Value,
Parity = Enum.TryParse<Parity>(_parity.Text, out var p) ? p : Parity.None,
StopBits = Enum.TryParse<StopBits>(_stopBits.Text, out var sb) ? sb : StopBits.One,
Handshake = Enum.TryParse<Handshake>(_handshake.Text, out var h) ? h : Handshake.None
};
}
return c;
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (DialogResult == DialogResult.OK)
{
if (_name.Text.Trim().Length == 0) { e.Cancel = true; _name.Focus(); return; }
Result = BuildResult();
}
base.OnFormClosing(e);
}
// ── 控制項工廠 ───────────────────────────────────────────
private ComboBox MakeCombo(bool editable) => new()
{
DropDownStyle = editable ? ComboBoxStyle.DropDown : ComboBoxStyle.DropDownList,
BackColor = Theme.WorkspaceBack,
ForeColor = Theme.Text,
FlatStyle = FlatStyle.Flat,
Font = Theme.UiFont
};
private ComboBox MakeEnumCombo<TEnum>(TEnum selected) where TEnum : struct, Enum
{
var cb = MakeCombo(false);
cb.Items.AddRange(Enum.GetNames<TEnum>());
cb.SelectedItem = selected.ToString();
return cb;
}
private static NumericUpDown MakeNumeric(int min, int max, int value) => new()
{
Minimum = min,
Maximum = max,
Value = Math.Clamp(value, min, max),
BackColor = Theme.WorkspaceBack,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.UiFont
};
}
+65
View File
@@ -0,0 +1,65 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.Infrastructure;
namespace ETTerms.App.Dialogs;
/// <summary>深色對話框基底:統一配色、深色標題列、固定大小、Enter/Esc。</summary>
public abstract class DarkDialog : Form
{
protected DarkDialog()
{
BackColor = Theme.SidebarBack;
ForeColor = Theme.Text;
Font = Theme.UiFont;
FormBorderStyle = FormBorderStyle.FixedDialog;
StartPosition = FormStartPosition.CenterParent;
MaximizeBox = false;
MinimizeBox = false;
ShowInTaskbar = false;
}
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
NativeTheme.ApplyDarkTitleBar(this);
}
protected Button MakeButton(string text, DialogResult result, bool accent = false)
{
var b = new Button
{
Text = text,
DialogResult = result,
Size = new Size(88, 30),
FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text,
BackColor = accent ? Theme.Accent : Theme.TabBack,
Font = Theme.UiFont,
Cursor = Cursors.Hand
};
b.FlatAppearance.BorderColor = Theme.Border;
b.FlatAppearance.MouseOverBackColor = accent ? Theme.AccentDim : Theme.Hover;
return b;
}
protected TextBox MakeTextBox(string initial = "")
{
return new TextBox
{
Text = initial,
BackColor = Theme.WorkspaceBack,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.UiFont
};
}
protected Label MakeLabel(string text) => new()
{
Text = text,
AutoSize = true,
ForeColor = Theme.TextDim,
Font = Theme.UiFont
};
}
@@ -0,0 +1,41 @@
using System.Drawing;
using System.Windows.Forms;
namespace ETTerms.App.Dialogs;
/// <summary>單行文字輸入對話框(資料夾命名 / 改名用)。</summary>
public sealed class TextPromptDialog : DarkDialog
{
private readonly TextBox _input;
public string Value => _input.Text.Trim();
public TextPromptDialog(string title, string prompt, string initial = "")
{
Text = title;
ClientSize = new Size(360, 130);
var lbl = MakeLabel(prompt);
lbl.Location = new Point(16, 16);
_input = MakeTextBox(initial);
_input.SetBounds(16, 42, 328, 26);
_input.SelectAll();
var ok = MakeButton("OK", DialogResult.OK, accent: true);
ok.Location = new Point(164, 84);
var cancel = MakeButton("Cancel", DialogResult.Cancel);
cancel.Location = new Point(256, 84);
Controls.AddRange(new Control[] { lbl, _input, ok, cancel });
AcceptButton = ok;
CancelButton = cancel;
}
/// <summary>顯示對話框;回傳輸入值,取消或空白回傳 null。</summary>
public static string? Ask(IWin32Window owner, string title, string prompt, string initial = "")
{
using var d = new TextPromptDialog(title, prompt, initial);
return d.ShowDialog(owner) == DialogResult.OK && d.Value.Length > 0 ? d.Value : null;
}
}
+33
View File
@@ -0,0 +1,33 @@
#nullable enable
using System.Drawing;
namespace ETTerms.App;
partial class MainForm
{
private System.ComponentModel.IContainer? components = null;
protected override void Dispose(bool disposing)
{
if (disposing && components != null)
components.Dispose();
base.Dispose(disposing);
}
private void InitializeComponent()
{
SuspendLayout();
AutoScaleDimensions = new SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(1100, 700);
MinimumSize = new Size(720, 480);
BackColor = Theme.WorkspaceBack;
ForeColor = Theme.Text;
Font = Theme.UiFont;
Text = "ETTerms";
StartPosition = FormStartPosition.CenterScreen;
ResumeLayout(false);
}
}
+81
View File
@@ -0,0 +1,81 @@
using System.Windows.Forms;
using ETTerms.App.Workspace;
using ETTerms.Infrastructure;
namespace ETTerms.App;
/// <summary>
/// 主視窗:三欄佈局 = ActivityRail(左圖示列)+ ConnectionSidebar(連線清單)
/// + WorkspaceTabs(分頁工作區)。Phase 1 只做外殼,連線為佔位。
/// </summary>
public partial class MainForm : Form
{
private readonly ActivityRail _rail = new();
private readonly ConnectionSidebar _sidebar = new();
private readonly WorkspaceView _workspace = new();
private readonly SettingsView _settings = new();
private readonly AboutView _about = new();
private readonly StatusStrip _status = new();
private readonly ToolStripStatusLabel _statusLabel = new();
public MainForm()
{
InitializeComponent();
BuildLayout();
WireEvents();
AppSettings.Instance.ApplyWindowPosition(this);
AppLogger.Info("MainForm initialized");
}
private void BuildLayout()
{
Controls.Add(_workspace); // Fill
Controls.Add(_settings); // Fill (hidden)
Controls.Add(_about); // Fill (hidden)
Controls.Add(_sidebar); // Left (內側)
Controls.Add(_rail); // Left (最外側)
_settings.Visible = false;
_about.Visible = false;
_statusLabel.Text = "Ready";
_statusLabel.ForeColor = Theme.TextDim;
_status.Items.Add(_statusLabel);
_status.BackColor = Theme.RailBack;
_status.SizingGrip = false;
Controls.Add(_status);
}
private void WireEvents()
{
_rail.ViewSelected += (_, view) =>
{
_statusLabel.Text = $"View: {view}";
_sidebar.Visible = view == ActivityRail.RailView.Terminal;
_workspace.Visible = view == ActivityRail.RailView.Terminal;
_settings.Visible = view == ActivityRail.RailView.Settings;
_about.Visible = view == ActivityRail.RailView.About;
AppLogger.LogInfo($"View selected: {view}");
};
_sidebar.ConnectionActivated += (_, conn) =>
{
_workspace.OpenConnection(conn);
_statusLabel.Text = $"Opened: {conn.Name}";
AppLogger.LogInfo($"Open session: {conn.Name} ({(conn.IsSsh ? "SSH" : "Serial")})");
};
}
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
NativeTheme.ApplyDarkTitleBar(this);
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
AppSettings.Instance.SaveWindowPosition(this);
AppLogger.LogApplicationClose();
base.OnFormClosed(e);
}
}
+294
View File
@@ -0,0 +1,294 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.Infrastructure;
using ETTerms.Scripting.Pdu;
namespace ETTerms.App;
/// <summary>Settings page with tabs: Terminal / PDU.</summary>
public sealed class SettingsView : UserControl
{
public SettingsView()
{
Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack;
// Use a custom tab strip + panel swapping instead of TabControl to avoid white borders
var tabBar = new FlowLayoutPanel
{
Dock = DockStyle.Top, Height = 32, BackColor = Theme.RailBack,
Padding = new Padding(4, 4, 4, 0), WrapContents = false
};
var terminalPage = BuildTerminalTab();
var pduPage = BuildPduTab();
terminalPage.Dock = DockStyle.Fill;
pduPage.Dock = DockStyle.Fill;
pduPage.Visible = false;
var body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack };
body.Controls.Add(terminalPage);
body.Controls.Add(pduPage);
Button? activeBtn = null;
Button MakeTab(string text, Panel page)
{
var b = new Button
{
Text = text, AutoSize = false, Width = 80, Height = 26, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand
};
b.FlatAppearance.BorderColor = Theme.Border;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += (_, _) =>
{
terminalPage.Visible = page == terminalPage;
pduPage.Visible = page == pduPage;
if (activeBtn != null) activeBtn.BackColor = Theme.TabBack;
b.BackColor = Theme.TabActiveBack;
activeBtn = b;
};
return b;
}
var termBtn = MakeTab("Terminal", terminalPage);
var pduBtn = MakeTab("PDU", pduPage);
tabBar.Controls.Add(termBtn);
tabBar.Controls.Add(pduBtn);
// Set initial active
termBtn.BackColor = Theme.TabActiveBack;
activeBtn = termBtn;
Controls.Add(body);
Controls.Add(tabBar);
}
// ═══ Terminal Tab ═══
private Panel BuildTerminalTab()
{
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var s = AppSettings.Instance;
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
};
var font = new ComboBox { Width = 200, DropDownStyle = ComboBoxStyle.DropDown, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat };
font.Items.AddRange(new object[] { "Cascadia Mono", "Consolas", "Courier New", "Lucida Console", "JetBrains Mono" });
font.Text = s.FontFamily;
var fontSize = new NumericUpDown { Width = 80, Minimum = 8, Maximum = 24, DecimalPlaces = 1, Increment = 0.5m, Value = (decimal)s.FontSize, BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle };
var scrollback = new NumericUpDown { Width = 100, Minimum = 500, Maximum = 50000, Increment = 500, Value = s.ScrollbackLines, BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle };
var scheme = new ComboBox { Width = 150, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat };
scheme.Items.AddRange(new object[] { "Dark", "Solarized Dark", "Monokai" });
scheme.Text = s.ColorScheme;
var newline = new ComboBox { Width = 120, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat };
newline.Items.AddRange(new object[] { "\\r\\n", "\\r", "\\n" });
newline.Text = s.DefaultNewLine;
flow.Controls.Add(MakeRow("Font Family", font));
flow.Controls.Add(MakeRow("Font Size", fontSize));
flow.Controls.Add(MakeRow("Scrollback Lines", scrollback));
flow.Controls.Add(MakeRow("Color Scheme", scheme));
flow.Controls.Add(MakeRow("Default Newline", newline));
flow.Controls.Add(MakeSpacer(16));
// Shell settings
flow.Controls.Add(new Label { Text = "Shell Settings", AutoSize = true, ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4) });
var shellType = new ComboBox { Width = 150, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat };
shellType.Items.AddRange(new object[] { "PowerShell", "Bash", "Cmd" });
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 browseBtn = new Button
{
Text = "📁", Width = 30, Height = 22, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
};
browseBtn.FlatAppearance.BorderColor = Theme.Border;
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;
browseBtn.Dock = DockStyle.Right;
dirPanel.Controls.Add(shellDir);
dirPanel.Controls.Add(browseBtn);
flow.Controls.Add(MakeRow("Startup Directory", dirPanel));
flow.Controls.Add(MakeSpacer(12));
// ── Live Preview ──
var preview = new Panel { Width = 460, Height = 100, BackColor = Color.FromArgb(20, 20, 24), Margin = new Padding(0, 0, 0, 8) };
var previewLabel = new Label
{
Dock = DockStyle.Fill, BackColor = Color.FromArgb(20, 20, 24), ForeColor = Color.FromArgb(200, 200, 200),
Text = "admin@switch01:~$ show version\nCisco IOS v15.2 — 設定預覽\nABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789",
TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(10, 5, 10, 5)
};
preview.Controls.Add(previewLabel);
flow.Controls.Add(preview);
void UpdatePreview()
{
try { previewLabel.Font = new Font(font.Text, (float)fontSize.Value); } catch { }
}
font.TextChanged += (_, _) => UpdatePreview();
fontSize.ValueChanged += (_, _) => UpdatePreview();
UpdatePreview();
flow.Controls.Add(MakeSpacer(8));
var save = MakeButton("Save", Theme.Accent);
save.Click += (_, _) =>
{
s.FontFamily = font.Text;
s.FontSize = (float)fontSize.Value;
s.ScrollbackLines = (int)scrollback.Value;
s.ColorScheme = scheme.Text;
s.DefaultNewLine = newline.Text;
s.ShellType = shellType.Text;
s.ShellStartupDir = shellDir.Text;
s.Save();
MessageBox.Show(this, "Settings saved.\nNew sessions will use these settings.\nExisting sessions keep their current font.", "Settings", MessageBoxButtons.OK, MessageBoxIcon.Information);
};
flow.Controls.Add(save);
page.Controls.Add(flow);
return page;
}
// ═══ PDU Tab ═══
private Panel BuildPduTab()
{
var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) };
var flow = new FlowLayoutPanel
{
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true
};
// Connection row
var ipBox = new TextBox { Width = 160, Text = "192.168.1.21", BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont };
var connectBtn = MakeButton("Connect", Theme.SerialColor);
var statusLabel = new Label { AutoSize = true, Text = "Disconnected", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(8, 8, 0, 0) };
var connRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 500, Height = 36, WrapContents = false, Margin = new Padding(0, 0, 0, 8) };
connRow.Controls.Add(new Label { Text = "PDU IP:", AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) });
connRow.Controls.Add(ipBox);
connRow.Controls.Add(connectBtn);
connRow.Controls.Add(statusLabel);
flow.Controls.Add(connRow);
// Port status grid
var grid = new DataGridView
{
Width = 480, Height = 310, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border,
BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text },
ColumnHeadersDefaultCellStyle = { BackColor = Theme.RailBack, ForeColor = Theme.Accent, Font = Theme.UiFontBold },
ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single,
EnableHeadersVisualStyles = false, RowHeadersVisible = false,
AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true,
AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
ScrollBars = ScrollBars.None, Font = Theme.UiFont,
RowTemplate = { Height = 24 },
Margin = new Padding(0, 8, 0, 8)
};
grid.Columns.Add("Port", "Port");
grid.Columns.Add("Status", "Status");
grid.Columns.Add("Current", "Current (mA)");
grid.Columns.Add("Power", "Power (W)");
for (int i = 1; i <= 12; i++)
grid.Rows.Add($"Port {i}", "—", "—", "—");
flow.Controls.Add(grid);
// Refresh button
var refreshBtn = MakeButton("Refresh", Theme.Accent);
flow.Controls.Add(refreshBtn);
// Logic
PduController? pdu = null;
connectBtn.Click += (_, _) =>
{
if (pdu != null) { pdu.Dispose(); pdu = null; statusLabel.Text = "Disconnected"; statusLabel.ForeColor = Theme.TextDim; connectBtn.Text = "Connect"; return; }
var ip = ipBox.Text.Trim();
var p = new PduController(ip);
if (p.CheckConnection())
{
pdu = p;
statusLabel.Text = $"Connected to {ip}";
statusLabel.ForeColor = Theme.SerialColor;
connectBtn.Text = "Disconnect";
RefreshPduGrid(pdu, grid);
}
else
{
p.Dispose();
MessageBox.Show(this, $"Failed to connect to PDU at {ip}", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
};
refreshBtn.Click += (_, _) =>
{
if (pdu == null) { MessageBox.Show(this, "Connect to PDU first.", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Information); return; }
RefreshPduGrid(pdu, grid);
};
page.Controls.Add(flow);
return page;
}
private static void RefreshPduGrid(PduController pdu, DataGridView grid)
{
for (int i = 0; i < 12; i++)
{
int port = i + 1;
var state = pdu.GetPortState(port);
var current = pdu.GetPortCurrent(port);
var power = pdu.GetPortPowerWatts(port);
var row = grid.Rows[i];
row.Cells["Status"].Value = state == true ? "ON" : state == false ? "OFF" : "—";
row.Cells["Current"].Value = current.HasValue ? $"{current.Value}" : "—";
row.Cells["Power"].Value = power.HasValue ? $"{power.Value:F1}" : "—";
row.DefaultCellStyle.BackColor = state == true ? Color.FromArgb(40, 80, 40) : state == false ? Color.FromArgb(60, 40, 40) : Theme.TabBack;
}
}
// ── Helpers ──
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;
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 });
return row;
}
private static Button MakeButton(string text, Color borderColor)
{
var b = new Button
{
Text = text, Width = 90, Height = 28, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0)
};
b.FlatAppearance.BorderColor = borderColor;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
return b;
}
private static Control MakeSpacer(int h) => new Panel { Width = 10, Height = h, Margin = Padding.Empty };
}
+30
View File
@@ -0,0 +1,30 @@
using System.Drawing;
namespace ETTerms.App;
/// <summary>
/// 全域深色配色(KKTerm 風格)。Phase 1 先集中在這裡,
/// 後續 Phase 5 的 TerminalProfile / Settings 可覆寫。
/// </summary>
public static class Theme
{
public static readonly Color RailBack = Color.FromArgb(24, 24, 28);
public static readonly Color SidebarBack = Color.FromArgb(32, 32, 38);
public static readonly Color WorkspaceBack = Color.FromArgb(40, 40, 46);
public static readonly Color TabBack = Color.FromArgb(48, 48, 56);
public static readonly Color TabActiveBack = Color.FromArgb(40, 40, 46);
public static readonly Color Accent = Color.FromArgb(138, 99, 210); // KKTerm 紫
public static readonly Color AccentDim = Color.FromArgb(86, 64, 130);
public static readonly Color Text = Color.FromArgb(222, 222, 226);
public static readonly Color TextDim = Color.FromArgb(150, 150, 158);
public static readonly Color Border = Color.FromArgb(58, 58, 66);
public static readonly Color Hover = Color.FromArgb(60, 60, 70);
public static readonly Color SshColor = Color.FromArgb(120, 180, 255);
public static readonly Color SerialColor = Color.FromArgb(140, 210, 140);
public static readonly Font UiFont = new("Segoe UI", 9.5f);
public static readonly Font UiFontBold = new("Segoe UI", 9.5f, FontStyle.Bold);
}
+413
View File
@@ -0,0 +1,413 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.Connections;
using ETTerms.Scripting;
using ETTerms.Sessions;
namespace ETTerms.App.Workspace;
/// <summary>
/// 工作區(MobaXterm 風格):所有連線是頂部「分頁」,預設單一視圖(1×1)。
/// 點 Layout1×1 / 1×2 / 2×1 / 2×2 / 2×3 / 3×3)會把已開好的分頁自動鋪進格子,
/// 每格一個 session(下方顯示標題)。無 per-pane 動作鈕,開分頁只能從側欄連線。
/// </summary>
public sealed class WorkspaceView : UserControl
{
private sealed class Session
{
public required string Title;
public required bool IsSsh;
public required SessionPage Page;
public Rectangle TabBounds;
public Rectangle CloseRect;
}
private readonly FlowLayoutPanel _toolbar;
private readonly Panel _tabStrip;
private readonly Panel _body;
private readonly Label _empty;
private readonly List<Session> _sessions = new();
private Session? _active;
private int _rows = 1, _cols = 1;
private Session? _hoverTab;
private const int StripH = 30;
private const int TabW = 180;
private const int CloseSz = 14;
private static readonly (string label, int r, int c)[] Presets =
{ ("1×1", 1, 1), ("1×2", 1, 2), ("2×1", 2, 1), ("2×2", 2, 2), ("2×3", 2, 3), ("3×3", 3, 3) };
public WorkspaceView()
{
Dock = DockStyle.Fill;
BackColor = Theme.WorkspaceBack;
_toolbar = new FlowLayoutPanel
{
Dock = DockStyle.Top, Height = 38, BackColor = Theme.RailBack,
Padding = new Padding(8, 6, 8, 6), WrapContents = false
};
_toolbar.Controls.Add(new Label
{
Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim,
Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0)
});
foreach (var p in Presets) _toolbar.Controls.Add(MakePresetButton(p.label, p.r, p.c));
// ── Run All / Run Group 按鈕 ──
_toolbar.Controls.Add(MakeActionButton("▶ Run All", 80, 16, OnRunAllSerial));
_toolbar.Controls.Add(MakeActionButton("▶ Group1", 90, 8, (_, _) => OnRunGroup(1)));
_toolbar.Controls.Add(MakeActionButton("▶ Group2", 90, 4, (_, _) => OnRunGroup(2)));
_toolbar.Controls.Add(MakeActionButton("▶ Group3", 90, 4, (_, _) => OnRunGroup(3)));
_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(); };
SetDoubleBuffered(_tabStrip);
_body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack };
_empty = new Label
{
Dock = DockStyle.Fill,
Text = "No sessions\nDouble-click a connection to open",
ForeColor = Theme.TextDim, BackColor = Theme.WorkspaceBack,
Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleCenter
};
Controls.Add(_body); // Fill 先加
Controls.Add(_tabStrip); // Top
Controls.Add(_toolbar); // Top(最上)
_body.Controls.Add(_empty);
}
/// <summary>從側欄開啟連線:新增一個分頁並設為 active。</summary>
public void OpenConnection(Connection conn)
{
var page = BuildPage(conn);
var s = new Session { Title = conn.Name, IsSsh = conn.IsSsh, Page = page };
page.ConnectFailed += msg => OnConnectFailed(s, msg);
_sessions.Add(s);
_active = s;
Relayout();
}
private void OnConnectFailed(Session s, string msg)
{
if (IsDisposed) return;
BeginInvoke(() =>
{
MessageBox.Show(this, msg, "Connection Failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
CloseSession(s);
});
}
private static SessionPage BuildPage(Connection conn)
{
if (conn.Type == ConnectionType.Serial && conn.Serial != null)
return new SessionPage(new SerialChannel(conn.Serial), conn.Name);
if (conn.Type == ConnectionType.Shell && conn.Shell != null)
return new SessionPage(new ShellChannel(conn.Shell), conn.Name);
return new SessionPage(new SshChannel(conn), conn.Name);
}
private void SetLayout(int r, int c) { _rows = r; _cols = c; Relayout(); }
private void CloseSession(Session s)
{
int idx = _sessions.IndexOf(s);
s.Page.Parent = null;
_sessions.Remove(s);
s.Page.Dispose();
if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null;
RefreshGroupLabels();
Relayout();
}
// ── Group 管理 ───────────────────────────────────────────
private void SetSessionGroup(Session s, int group)
{
s.Page.Group = group;
RefreshGroupLabels();
Relayout();
}
private void RefreshGroupLabels()
{
for (int g = 1; g <= 3; g++)
{
char letter = 'A';
foreach (var s in _sessions.Where(x => x.Page.Group == g))
s.Page.GroupLabel = $"Group{g}-{letter++}";
}
foreach (var s in _sessions.Where(x => x.Page.Group == 0))
s.Page.GroupLabel = "";
}
// ── 依目前 Layout 把分頁鋪進格子 ─────────────────────────
private void Relayout()
{
foreach (var s in _sessions) s.Page.Parent = null; // 先卸下(保留存活)
_body.SuspendLayout();
for (int i = _body.Controls.Count - 1; i >= 0; i--)
{
var c = _body.Controls[i];
if (c != _empty) { _body.Controls.Remove(c); c.Dispose(); }
}
if (_sessions.Count == 0)
{
_empty.Visible = true;
_body.ResumeLayout();
_tabStrip.Invalidate();
return;
}
_empty.Visible = false;
_active ??= _sessions[0];
int cells = _rows * _cols;
int activeIdx = _sessions.IndexOf(_active);
int start = (activeIdx / cells) * cells; // 含 active 的那一頁
var grid = new TableLayoutPanel
{
Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack,
ColumnCount = _cols, RowCount = _rows, Margin = Padding.Empty, Padding = Padding.Empty
};
for (int c = 0; c < _cols; c++) grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f / _cols));
for (int r = 0; r < _rows; r++) grid.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / _rows));
for (int i = 0; i < cells; i++)
{
int si = start + i;
Control cell = si < _sessions.Count ? MakeCell(_sessions[si], cells > 1) : new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack };
grid.Controls.Add(cell, i % _cols, i / _cols);
}
_body.Controls.Add(grid);
grid.BringToFront();
_body.ResumeLayout();
_tabStrip.Invalidate();
FocusActive();
}
private Control MakeCell(Session s, bool withLabel)
{
var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) };
s.Page.Dock = DockStyle.Fill;
s.Page.Visible = true;
cell.Controls.Add(s.Page); // Fill 先加
if (withLabel)
{
string labelText = string.IsNullOrEmpty(s.Page.GroupLabel)
? $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title}"
: $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title} [{s.Page.GroupLabel}]";
var lbl = new Label
{
Dock = DockStyle.Bottom, Height = 22,
Text = labelText,
ForeColor = s == _active ? Theme.Text : Theme.TextDim,
BackColor = Theme.RailBack, Font = Theme.UiFont,
TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(8, 0, 0, 0)
};
lbl.Click += (_, _) => { _active = s; Relayout(); };
cell.Controls.Add(lbl); // Bottom
}
return cell;
}
private void FocusActive()
{
if (_active?.Page is { IsDisposed: false } p && p.IsHandleCreated)
p.Focus();
}
// ── 頂部 Tab 列 ──────────────────────────────────────────
private void LayoutTabs()
{
int x = 0;
foreach (var s in _sessions)
{
s.TabBounds = new Rectangle(x, 0, TabW, StripH);
s.CloseRect = new Rectangle(s.TabBounds.Right - CloseSz - 6, (StripH - CloseSz) / 2, CloseSz, CloseSz);
x += TabW;
}
}
private void OnStripMouseDown(object? sender, MouseEventArgs e)
{
LayoutTabs();
if (e.Button == MouseButtons.Right)
{
foreach (var s in _sessions)
{
if (s.TabBounds.Contains(e.Location)) { ShowGroupMenu(s, e.Location); return; }
}
return;
}
foreach (var s in _sessions)
{
if (s.CloseRect.Contains(e.Location)) { CloseSession(s); return; }
if (s.TabBounds.Contains(e.Location)) { _active = s; Relayout(); return; }
}
}
private void ShowGroupMenu(Session s, Point pt)
{
var menu = new ContextMenuStrip();
menu.Items.Add("No Group", null, (_, _) => SetSessionGroup(s, 0));
menu.Items.Add("Group 1", null, (_, _) => SetSessionGroup(s, 1));
menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2));
menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3));
// Check current
int current = s.Page.Group;
((ToolStripMenuItem)menu.Items[current]).Checked = true;
menu.Show(_tabStrip, pt);
}
private void OnStripMouseMove(object? sender, MouseEventArgs e)
{
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 OnStripPaint(object? sender, PaintEventArgs e)
{
var g = e.Graphics;
g.Clear(Theme.RailBack);
LayoutTabs();
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))
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));
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);
TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, "✕", Theme.UiFont, s.CloseRect, hover ? Theme.Text : Theme.TextDim,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
// ── Layout 按鈕 ──────────────────────────────────────────
private Button MakePresetButton(string label, int rows, int cols)
{
var b = new Button
{
Text = label, AutoSize = false, Size = new Size(44, 26), FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(3, 0, 3, 0), Cursor = Cursors.Hand
};
b.FlatAppearance.BorderColor = Theme.Border;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += (_, _) => SetLayout(rows, cols);
return b;
}
private static Button MakeActionButton(string text, int width, int leftMargin, EventHandler onClick)
{
var b = new Button
{
Text = text, AutoSize = false, Size = new Size(width, 26), FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont,
Margin = new Padding(leftMargin, 0, 3, 0), Cursor = Cursors.Hand
};
b.FlatAppearance.BorderColor = Theme.SerialColor;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += onClick;
return b;
}
// ── Run All(個別執行,拒絕 waitall/sendlnall ──────────
private async void OnRunAllSerial(object? sender, EventArgs e)
{
var serials = _sessions
.Where(s => !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
.Select(s => s.Page)
.ToList();
if (serials.Count == 0)
{
MessageBox.Show(this, "No available Serial sessions.", "Run All", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
string content, name;
using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" })
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
content = File.ReadAllText(dlg.FileName);
name = Path.GetFileName(dlg.FileName);
}
// Run All 拒絕含 waitall / sendlnall 的腳本
if (ScriptContainsGroupCommands(content))
{
MessageBox.Show(this,
"Script contains 'waitall' or 'sendlnall' which require Group execution.\nPlease use Run Group buttons instead.",
"Run All", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
await Task.WhenAll(serials.Select(sp => sp.RunScriptAsync(content, name)));
}
// ── Run Group(支援 waitall/sendlnall 同步) ─────────────
private async void OnRunGroup(int group)
{
var members = _sessions
.Where(s => s.Page.Group == group && !s.Page.IsScriptRunning)
.Select(s => s.Page)
.ToList();
if (members.Count == 0)
{
MessageBox.Show(this, $"No available sessions in Group {group}.", $"Run Group{group}", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
string content, name;
using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" })
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
content = File.ReadAllText(dlg.FileName);
name = Path.GetFileName(dlg.FileName);
}
var sync = new GroupSyncContext(members.Count);
await Task.WhenAll(members.Select((sp, i) => sp.RunGroupScriptAsync(content, name, sync, ((char)('A' + i)).ToString())));
}
private static bool ScriptContainsGroupCommands(string content)
{
foreach (var line in content.Split('\n'))
{
var t = line.Trim().ToLower();
if (t.StartsWith("waitall") || t.StartsWith("sendlnall") || t.StartsWith("sendlngroup"))
return true;
}
return false;
}
protected override void Dispose(bool disposing)
{
if (disposing)
foreach (var s in _sessions) { try { s.Page.Dispose(); } catch { } }
base.Dispose(disposing);
}
private static void SetDoubleBuffered(Control c) =>
typeof(Control).GetProperty("DoubleBuffered",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
?.SetValue(c, true, null);
}
+60
View File
@@ -0,0 +1,60 @@
using System.IO.Ports;
namespace ETTerms.Connections;
public enum ConnectionType { Ssh = 0, Serial = 1, Shell = 2 }
public enum SshAuthMethod { Password = 0, PrivateKey = 1, KeyboardInteractive = 2 }
/// <summary>SSH 連線設定(Type == Ssh 時有效)。passphrase / password 走 CredentialVault。</summary>
public sealed class SshSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 22;
public string Username { get; set; } = "";
public SshAuthMethod AuthMethod { get; set; } = SshAuthMethod.Password;
public string? PrivateKeyPath { get; set; }
}
/// <summary>Serial 連線設定(Type == Serial 時有效)。</summary>
public sealed class SerialSettings
{
public string PortName { get; set; } = "COM1";
public int BaudRate { get; set; } = 115200;
public int DataBits { get; set; } = 8;
public Parity Parity { get; set; } = Parity.None;
public StopBits StopBits { get; set; } = StopBits.One;
public Handshake Handshake { get; set; } = Handshake.None;
public string NewLine { get; set; } = "\r\n";
}
/// <summary>Local shell settings (Type == Shell).</summary>
public sealed class ShellSettings
{
public string ShellType { get; set; } = "PowerShell"; // "PowerShell", "Bash", "Cmd"
public string StartupDirectory { get; set; } = "";
}
/// <summary>連線 metadata。明碼密碼絕不存這裡,只存指向 Credential Manager 的 <see cref="CredentialKey"/>。</summary>
public sealed class Connection
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public ConnectionType Type { get; set; }
public int SortOrder { get; set; }
public string? GroupName { get; set; } // 資料夾路徑('/'-join
public DateTime LastUsedUtc { get; set; }
public SshSettings? Ssh { get; set; }
public SerialSettings? Serial { get; set; }
public ShellSettings? Shell { get; set; }
public string CredentialKey => $"ETTerms/{Id}";
public bool IsSsh => Type == ConnectionType.Ssh;
/// <summary>sidebar 顯示用摘要。</summary>
public string Detail => IsSsh
? $"{Ssh?.Host}:{Ssh?.Port}"
: $"{Serial?.PortName} @ {Serial?.BaudRate}";
}
+115
View File
@@ -0,0 +1,115 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
using ETTerms.Infrastructure;
namespace ETTerms.Connections;
/// <summary>
/// 連線 metadata 的 SQLite 持久化(單表 Connections)。
/// 路徑:%LocalAppData%\ETTerms\ettermsdb.sqlite,首次使用自動建表。
/// 密碼不存這裡——只存 CredentialKey,明碼走 CredentialVault。
/// </summary>
public sealed class ConnectionStore
{
private readonly string _connString;
private sealed record SettingsBlob(SshSettings? Ssh, SerialSettings? Serial);
public ConnectionStore()
{
string dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ETTerms");
Directory.CreateDirectory(dir);
_connString = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(dir, "ettermsdb.sqlite")
}.ToString();
using var conn = Open();
Exec(conn, """
CREATE TABLE IF NOT EXISTS Connections (
Id TEXT PRIMARY KEY,
Name TEXT NOT NULL,
Type INTEGER NOT NULL,
SortOrder INTEGER NOT NULL,
GroupName TEXT,
LastUsedUtc TEXT NOT NULL,
SettingsJson TEXT NOT NULL,
CredentialKey TEXT
);
""");
AppLogger.Info("ConnectionStore ready");
}
public List<Connection> GetAll()
{
var list = new List<Connection>();
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT Id,Name,Type,SortOrder,GroupName,LastUsedUtc,SettingsJson FROM Connections ORDER BY SortOrder";
using var r = cmd.ExecuteReader();
while (r.Read())
{
var blob = JsonSerializer.Deserialize<SettingsBlob>(r.GetString(6));
list.Add(new Connection
{
Id = Guid.Parse(r.GetString(0)),
Name = r.GetString(1),
Type = (ConnectionType)r.GetInt32(2),
SortOrder = r.GetInt32(3),
GroupName = r.IsDBNull(4) ? null : r.GetString(4),
LastUsedUtc = DateTime.Parse(r.GetString(5)),
Ssh = blob?.Ssh,
Serial = blob?.Serial
});
}
return list;
}
/// <summary>新增或更新一條連線(以 Id 為鍵)。</summary>
public void Upsert(Connection c)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Connections (Id,Name,Type,SortOrder,GroupName,LastUsedUtc,SettingsJson,CredentialKey)
VALUES ($id,$name,$type,$sort,$group,$used,$json,$cred)
ON CONFLICT(Id) DO UPDATE SET
Name=$name, Type=$type, SortOrder=$sort, GroupName=$group,
LastUsedUtc=$used, SettingsJson=$json, CredentialKey=$cred;
""";
cmd.Parameters.AddWithValue("$id", c.Id.ToString());
cmd.Parameters.AddWithValue("$name", c.Name);
cmd.Parameters.AddWithValue("$type", (int)c.Type);
cmd.Parameters.AddWithValue("$sort", c.SortOrder);
cmd.Parameters.AddWithValue("$group", (object?)c.GroupName ?? DBNull.Value);
cmd.Parameters.AddWithValue("$used", c.LastUsedUtc.ToString("o"));
cmd.Parameters.AddWithValue("$json", JsonSerializer.Serialize(new SettingsBlob(c.Ssh, c.Serial)));
cmd.Parameters.AddWithValue("$cred", c.CredentialKey);
cmd.ExecuteNonQuery();
}
public void Delete(Guid id)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM Connections WHERE Id=$id";
cmd.Parameters.AddWithValue("$id", id.ToString());
cmd.ExecuteNonQuery();
}
private SqliteConnection Open()
{
var c = new SqliteConnection(_connString);
c.Open();
return c;
}
private static void Exec(SqliteConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
}
@@ -0,0 +1,85 @@
using System.Runtime.InteropServices;
using System.Text;
using ETTerms.Infrastructure;
namespace ETTerms.Connections;
/// <summary>
/// Windows Credential Manager 封裝(advapi32 P/Invoke)。
/// 連線密碼 / passphrase 一律存這裡,SQLite 只存 CredentialKey。明碼絕不落地。
/// </summary>
public static class CredentialVault
{
private const int CRED_TYPE_GENERIC = 1;
private const int CRED_PERSIST_LOCAL_MACHINE = 2;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL
{
public int Flags;
public int Type;
public string TargetName;
public string? Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public int Persist;
public int AttributeCount;
public IntPtr Attributes;
public string? TargetAlias;
public string? UserName;
}
[DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredWrite(ref CREDENTIAL credential, int flags);
[DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredRead(string target, int type, int flags, out IntPtr credentialPtr);
[DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredDelete(string target, int type, int flags);
[DllImport("advapi32.dll", EntryPoint = "CredFree")]
private static extern void CredFree(IntPtr buffer);
/// <summary>寫入 / 覆寫一筆密碼。</summary>
public static void Set(string key, string secret)
{
byte[] bytes = Encoding.Unicode.GetBytes(secret);
IntPtr blob = Marshal.AllocCoTaskMem(bytes.Length);
try
{
Marshal.Copy(bytes, 0, blob, bytes.Length);
var cred = new CREDENTIAL
{
Type = CRED_TYPE_GENERIC,
TargetName = key,
CredentialBlobSize = bytes.Length,
CredentialBlob = blob,
Persist = CRED_PERSIST_LOCAL_MACHINE,
UserName = "ETTerms"
};
if (!CredWrite(ref cred, 0))
AppLogger.LogError($"CredWrite failed for {key}: {Marshal.GetLastWin32Error()}");
}
finally { Marshal.FreeCoTaskMem(blob); }
}
/// <summary>讀取密碼;不存在回傳 null。</summary>
public static string? Get(string key)
{
if (!CredRead(key, CRED_TYPE_GENERIC, 0, out IntPtr ptr)) return null;
try
{
var cred = Marshal.PtrToStructure<CREDENTIAL>(ptr);
if (cred.CredentialBlobSize == 0) return "";
byte[] bytes = new byte[cred.CredentialBlobSize];
Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length);
return Encoding.Unicode.GetString(bytes);
}
finally { CredFree(ptr); }
}
/// <summary>刪除密碼(不存在則忽略)。</summary>
public static void Delete(string key) => CredDelete(key, CRED_TYPE_GENERIC, 0);
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ETTerms</RootNamespace>
<AssemblyName>ETTerms</AssemblyName>
<!-- 版本資訊 -->
<Version>0.1.0</Version>
<Product>ETTerms</Product>
<Company>ETTerms Project</Company>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
<PackageReference Include="SnmpSharpNet" Version="0.9.7">
<NoWarn>NU1701</NoWarn>
</PackageReference>
<PackageReference Include="SSH.NET" Version="2024.2.0" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
</Project>
+107
View File
@@ -0,0 +1,107 @@
using System.Diagnostics;
namespace ETTerms.Infrastructure;
/// <summary>
/// 應用程式 LoggerPhase 1 精簡版,移植自 MyTeraTerm.AppLogger)。
/// - 永遠寫入檔案:%LocalAppData%\ETTerms\logs\ETTerms_yyyyMMdd.log
/// - 同時輸出到 Debug(VS 輸出視窗)
/// 後續 Phase 可再補 Console 即時視窗 / log 輪替清理。
/// </summary>
public static class AppLogger
{
private static readonly object _lock = new();
private static string _logFilePath = "";
private static bool _initialized;
public static void Initialize()
{
lock (_lock)
{
if (_initialized) return;
try
{
string dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ETTerms", "logs");
Directory.CreateDirectory(dir);
_logFilePath = Path.Combine(dir, $"ETTerms_{DateTime.Now:yyyyMMdd}.log");
_initialized = true;
Info(new string('=', 60));
Info("=== ETTerms Started ===");
Info($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
Info($"Version: {GetVersion()}");
Info($"User: {Environment.UserName}@{Environment.MachineName}");
Info($"OS: {Environment.OSVersion}");
Info($".NET: {Environment.Version}");
Info(new string('=', 60));
}
catch (Exception ex)
{
Debug.WriteLine($"[AppLogger] init failed: {ex.Message}");
}
}
}
public static void LogDebug(string message) => Write("DEBUG", message);
public static void Info(string message) => Write("INFO", message);
public static void LogInfo(string message) => Write("INFO", message);
public static void LogWarning(string message) => Write("WARN", message);
public static void LogError(string message, Exception? ex = null)
{
Write("ERROR", ex is null ? message : $"{message} :: {ex.GetType().Name}: {ex.Message}");
}
public static string GetLogFilePath() => _logFilePath;
public static void OpenLogsFolder()
{
try
{
string dir = Path.GetDirectoryName(_logFilePath) ?? "";
if (Directory.Exists(dir))
Process.Start(new ProcessStartInfo("explorer.exe", dir) { UseShellExecute = true });
}
catch (Exception ex)
{
LogError("Failed to open logs folder", ex);
}
}
public static void LogApplicationClose()
{
Info("=== ETTerms Closing ===");
Info("");
}
private static void Write(string level, string message)
{
string line = $"{DateTime.Now:HH:mm:ss.fff} [{level,-5}] {message}";
Debug.WriteLine(line);
if (!_initialized) return;
lock (_lock)
{
try
{
File.AppendAllText(_logFilePath, line + Environment.NewLine);
}
catch
{
// log 失敗不可拖垮 App
}
}
}
private static string GetVersion()
{
try
{
var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
return v is null ? "Unknown" : $"{v.Major}.{v.Minor}.{v.Build}";
}
catch { return "Unknown"; }
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Drawing;
using System.Text.Json;
namespace ETTerms.Infrastructure;
/// <summary>
/// User settings persisted to %LocalAppData%\ETTerms\settings.json.
/// Loaded once at startup; saved on change.
/// </summary>
public sealed class AppSettings
{
private static readonly string Dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ETTerms");
private static readonly string FilePath = Path.Combine(Dir, "settings.json");
private static AppSettings? _instance;
public static AppSettings Instance => _instance ??= Load();
// ── Terminal ──
public string FontFamily { get; set; } = "Cascadia Mono";
public float FontSize { get; set; } = 11f;
public int ScrollbackLines { get; set; } = 5000;
public string DefaultNewLine { get; set; } = "\\r\\n";
public string ColorScheme { get; set; } = "Dark";
// ── Shell ──
public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd
public string ShellStartupDir { get; set; } = "";
// ── Window ──
public int WindowX { get; set; } = -1;
public int WindowY { get; set; } = -1;
public int WindowW { get; set; } = 1280;
public int WindowH { get; set; } = 800;
public bool WindowMaximized { get; set; }
public void Save()
{
Directory.CreateDirectory(Dir);
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(FilePath, json);
}
public void ApplyWindowPosition(System.Windows.Forms.Form form)
{
if (WindowMaximized)
{
form.WindowState = System.Windows.Forms.FormWindowState.Maximized;
return;
}
if (WindowX >= 0 && WindowY >= 0)
{
form.StartPosition = System.Windows.Forms.FormStartPosition.Manual;
form.Location = new Point(WindowX, WindowY);
}
form.Size = new Size(WindowW, WindowH);
}
public void SaveWindowPosition(System.Windows.Forms.Form form)
{
WindowMaximized = form.WindowState == System.Windows.Forms.FormWindowState.Maximized;
if (!WindowMaximized)
{
WindowX = form.Location.X;
WindowY = form.Location.Y;
WindowW = form.Size.Width;
WindowH = form.Size.Height;
}
Save();
}
private static AppSettings Load()
{
try
{
if (File.Exists(FilePath))
{
var json = File.ReadAllText(FilePath);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new();
}
}
catch { }
return new();
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace ETTerms.Infrastructure;
/// <summary>Windows 原生外觀輔助(深色標題列)。</summary>
public static class NativeTheme
{
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
/// <summary>把視窗標題列改成深色(Windows 10 1809+ / 11)。</summary>
public static void ApplyDarkTitleBar(IWin32Window window)
{
try
{
int useDark = 1;
// DWMWA_USE_IMMERSIVE_DARK_MODE = 2019 為較舊 build 的編號)
if (DwmSetWindowAttribute(window.Handle, 20, ref useDark, sizeof(int)) != 0)
DwmSetWindowAttribute(window.Handle, 19, ref useDark, sizeof(int));
}
catch (Exception ex)
{
AppLogger.LogWarning($"Dark title bar not applied: {ex.Message}");
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using ETTerms.App;
using ETTerms.Infrastructure;
namespace ETTerms;
static class Program
{
/// <summary>The main entry point for the application.</summary>
[STAThread]
static void Main()
{
AppLogger.Initialize();
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
Application.ThreadException += (_, e) =>
AppLogger.LogError("Unhandled UI exception", e.Exception);
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
AppLogger.LogError("Unhandled domain exception", e.ExceptionObject as Exception);
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace ETTerms.Scripting;
/// <summary>
/// Group 成員間的同步上下文。waitall 會等所有成員都到達同一個 barrier 後才繼續。
/// sendlnall 會等所有成員到達後,每個成員各自對自己的 channel 送出指令再繼續。
/// </summary>
public sealed class GroupSyncContext
{
private readonly int _memberCount;
private readonly Barrier _barrier;
public GroupSyncContext(int memberCount)
{
_memberCount = memberCount;
_barrier = new Barrier(memberCount);
}
/// <summary>等待所有 group 成員到達此同步點。</summary>
public void WaitAll(CancellationToken ct)
{
_barrier.SignalAndWait(ct);
}
}
@@ -0,0 +1,73 @@
using System.Net;
using SnmpSharpNet;
namespace ETTerms.Scripting.Pdu;
/// <summary>
/// PDU Controller for iPoMan II/III models via SNMP.
/// Ported from MyTeraTerm's PDUControlLib.
/// </summary>
public sealed class PduController : IDisposable
{
private readonly string _ip;
private const string Community = "private";
private const int SnmpPort = 161;
private const int Timeout = 3000;
public PduController(string ip) => _ip = ip;
public bool CheckConnection()
{
var name = SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4");
return !string.IsNullOrEmpty(name) && name.Contains("PDU");
}
public bool SetPortOn(int port) => SnmpSet(PortControlOid(port), new Integer32(3));
public bool SetPortOff(int port) => SnmpSet(PortControlOid(port), new Integer32(4));
public bool? GetPortState(int port)
{
var r = SnmpGet(PortStateOid(port));
return r == "3" ? true : (r == "2" || r == "4") ? false : null;
}
public int? GetPortCurrent(int port) => int.TryParse(SnmpGet(PortCurrentOid(port)), out int v) ? v : null;
public double? GetPortPowerWatts(int port) => int.TryParse(SnmpGet(PortPowerOid(port)), out int v) ? v / 10.0 : null;
private static string PortControlOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.4.1.2.{port}";
private static string PortStateOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.2.{port}";
private static string PortCurrentOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.3.{port}";
private static string PortPowerOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.5.{port}";
private bool SnmpSet(string oid, AsnType value)
{
try
{
var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
var pdu = new SnmpSharpNet.Pdu(PduType.Set);
pdu.VbList.Add(new Oid(oid), value);
var result = (SnmpV1Packet)target.Request(pdu, param);
return result?.Pdu.ErrorStatus == 0;
}
catch { return false; }
}
private string? SnmpGet(string oid)
{
try
{
var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
var pdu = new SnmpSharpNet.Pdu(PduType.Get);
pdu.VbList.Add(new Oid(oid));
var result = (SnmpV1Packet)target.Request(pdu, param);
if (result?.Pdu.ErrorStatus == 0)
foreach (var v in result.Pdu.VbList) return v.Value.ToString();
}
catch { }
return null;
}
public void Dispose() { }
}
+90
View File
@@ -0,0 +1,90 @@
using ETTerms.Sessions;
namespace ETTerms.Scripting;
/// <summary>
/// 非同步驅動 <see cref="TTLInterpreter"/>:背景執行緒執行腳本,
/// 轉發 StatusChanged / Output,並在結束時觸發 Finished。可隨時 <see cref="Cancel"/>。
/// 事件可能在背景執行緒觸發,UI 訂閱者需自行 marshal 回 UI thread。
/// </summary>
public sealed class ScriptRunner
{
private TTLInterpreter? _interp;
/// <summary>(檔名, 行號, 指令) 進度。</summary>
public event Action<string, int, string>? StatusChanged;
/// <summary>輸出訊息(log / 提示 / 錯誤)。</summary>
public event Action<string>? Output;
/// <summary>結束:(成功, 訊息)。</summary>
public event Action<bool, string>? Finished;
public bool IsRunning { get; private set; }
public async Task RunAsync(string content, string fileName, ISessionChannel channel)
{
if (IsRunning) return;
IsRunning = true;
var interp = new TTLInterpreter(channel);
interp.StatusChanged += (f, l, c) => StatusChanged?.Invoke(f, l, c);
interp.Output += m => Output?.Invoke(m);
_interp = interp;
try
{
await Task.Run(() => interp.ExecuteScriptContent(content, fileName));
Finished?.Invoke(true, "Completed");
}
catch (OperationCanceledException)
{
Finished?.Invoke(false, "Cancelled");
}
catch (Exception ex)
{
Output?.Invoke($"[error] {ex.Message}");
Finished?.Invoke(false, ex.Message);
}
finally
{
interp.Dispose();
_interp = null;
IsRunning = false;
}
}
public async Task RunGroupAsync(string content, string fileName, ISessionChannel channel, GroupSyncContext sync, string memberLabel = "")
{
if (IsRunning) return;
IsRunning = true;
var interp = new TTLInterpreter(channel, sync, memberLabel);
interp.StatusChanged += (f, l, c) => StatusChanged?.Invoke(f, l, c);
interp.Output += m => Output?.Invoke(m);
_interp = interp;
try
{
await Task.Run(() => interp.ExecuteScriptContent(content, fileName));
Finished?.Invoke(true, "Completed");
}
catch (OperationCanceledException)
{
Finished?.Invoke(false, "Cancelled");
}
catch (Exception ex)
{
Output?.Invoke($"[error] {ex.Message}");
Finished?.Invoke(false, ex.Message);
}
finally
{
interp.Dispose();
_interp = null;
IsRunning = false;
}
}
public void Cancel() => _interp?.Cancel();
}
+565
View File
@@ -0,0 +1,565 @@
using System.Text;
using System.Text.RegularExpressions;
using ETTerms.Scripting.Pdu;
using ETTerms.Sessions;
namespace ETTerms.Scripting;
/// <summary>
/// TTL (Tera Term Language) 直譯器,移植自 MyTeraTerm,改驅動 <see cref="ISessionChannel"/>
/// (取代舊版的 ComPortBridge)。送出走 <c>channel.Write</c>,接收監聽 <c>channel.DataReceived</c>。
/// 支援:send / sendln / pause / wait / timeout / flushrecv / logopen / logwrite / logclose /
/// messagebox / if-elseif-else-endif / while-endwhile / 變數指派。
/// 阻塞執行(wait/pause 用 Thread.Sleep),由 <see cref="ScriptRunner"/> 放到背景執行緒驅動。
/// </summary>
public sealed class TTLInterpreter : IDisposable
{
private readonly ISessionChannel _channel;
private readonly Encoding _enc = new UTF8Encoding(false);
private readonly Dictionary<string, object> _vars = new();
private readonly StringBuilder _recv = new();
private readonly CancellationTokenSource _cts = new();
private GroupSyncContext? _groupSync;
private string _groupMemberLabel = "";
private readonly Dictionary<int, PduController> _pdus = new();
private StreamWriter? _logWriter;
private int _timeout; // 0 = 無限等待(wait 會一直等到關鍵字出現或被取消)
private int _result;
private string[] _lines = Array.Empty<string>();
private int _line;
private string _scriptFile = "";
private volatile bool _cancelled;
private bool _disposed;
/// <summary>(檔名, 行號, 當前指令) 進度更新。</summary>
public event Action<string, int, string>? StatusChanged;
/// <summary>輸出訊息(log / 提示 / 錯誤),供 UI 顯示。</summary>
public event Action<string>? Output;
public TTLInterpreter(ISessionChannel channel, GroupSyncContext? groupSync = null, string memberLabel = "")
{
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
_groupSync = groupSync;
_groupMemberLabel = memberLabel;
_channel.DataReceived += OnData;
}
private void OnData(byte[] data)
{
lock (_recv) _recv.Append(_enc.GetString(data));
}
#region Script Execution
public void ExecuteScriptContent(string scriptContent, string fileName = "")
{
_scriptFile = string.IsNullOrEmpty(fileName) ? "Inline Script" : fileName;
_cancelled = false;
lock (_recv) _recv.Clear();
_lines = Preprocess(scriptContent);
_line = 0;
while (_line < _lines.Length)
{
ThrowIfCancelled();
StatusChanged?.Invoke(_scriptFile, _line + 1, _lines[_line]);
ExecuteLine(_lines[_line]);
_line++;
}
StatusChanged?.Invoke(_scriptFile, _lines.Length, "Completed");
LogClose();
}
private static string[] Preprocess(string content)
{
var lines = new List<string>();
foreach (string raw in content.Split('\n'))
{
string t = raw.Trim();
int comment = t.IndexOf(';');
if (comment >= 0) t = t.Substring(0, comment).Trim();
if (!string.IsNullOrWhiteSpace(t)) lines.Add(t);
}
return lines.ToArray();
}
private void ThrowIfCancelled()
{
if (_cancelled) throw new OperationCanceledException("Script execution was cancelled by user");
}
#endregion
#region Line Execution
private void ExecuteLine(string line)
{
string original = line;
bool isAssignment = original.Contains('=')
&& !original.Contains("==") && !original.Contains("!=")
&& !original.Contains(">=") && !original.Contains("<=")
&& !original.StartsWith("if ") && !original.StartsWith("elseif ");
string command = GetCommand(line).ToLower();
if (!isAssignment && command != "while" && command != "if")
line = ReplaceVariables(line);
if (line.StartsWith(":")) return; // label
string args = GetArgs(line);
switch (command)
{
case "send": Send(args, false); break;
case "sendln": Send(args, true); break;
case "pause": Pause(args); break;
case "wait": Wait(args); break;
case "timeout": SetTimeout(args); break;
case "flushrecv": FlushReceive(); break;
case "logopen": LogOpen(args); break;
case "logwrite": LogWrite(args); break;
case "logclose": LogClose(); break;
case "messagebox": ShowMessageBox(args); break;
case "waitall": ExecuteWaitAll(args); break;
case "sendlnall": ExecuteSendlnAll(args); break;
case "sendlngroup": ExecuteSendlnGroup(args); break;
case "pduconnect": ExecutePduConnect(args); break;
case "pductrl": ExecutePduCtrl(args); break;
case "while": ExecuteWhile(original); break;
case "if": ExecuteIf(original); break;
case "endwhile":
case "elseif":
case "else":
case "endif":
break; // handled by ExecuteWhile / ExecuteIf
case "":
if (isAssignment) ExecuteAssignment(original);
break;
default:
if (isAssignment) ExecuteAssignment(original);
else Output?.Invoke($"[ttl] Unknown command: {command}");
break;
}
}
#endregion
#region Control Flow - If
private void ExecuteIf(string line)
{
var match = Regex.Match(line, @"if\s+(.+?)\s+then", RegexOptions.IgnoreCase);
if (!match.Success) match = Regex.Match(line, @"if\s+(.+)", RegexOptions.IgnoreCase);
if (!match.Success) throw new Exception("Invalid if statement");
string condition = match.Groups[1].Value.Trim();
int ifStart = _line;
int endif = FindBlockEnd(ifStart, "if ", "endif");
if (endif < 0) throw new Exception("if statement without matching endif");
var elseifLines = new List<int>();
int elseLine = -1;
for (int i = ifStart + 1; i < endif; i++)
{
string t = _lines[i].Trim().ToLower();
if (t.StartsWith("elseif")) elseifLines.Add(i);
else if (t.StartsWith("else")) { elseLine = i; break; }
}
bool met = false;
int start = -1, end = -1;
if (EvaluateCondition(ReplaceVariables(condition)))
{
met = true; start = ifStart + 1;
end = elseifLines.Count > 0 ? elseifLines[0] : (elseLine >= 0 ? elseLine : endif);
}
else
{
for (int i = 0; i < elseifLines.Count && !met; i++)
{
var em = Regex.Match(_lines[elseifLines[i]], @"elseif\s+(.+?)\s+then", RegexOptions.IgnoreCase);
if (!em.Success) em = Regex.Match(_lines[elseifLines[i]], @"elseif\s+(.+)", RegexOptions.IgnoreCase);
if (em.Success && EvaluateCondition(ReplaceVariables(em.Groups[1].Value.Trim())))
{
met = true; start = elseifLines[i] + 1;
end = (i + 1 < elseifLines.Count) ? elseifLines[i + 1] : (elseLine >= 0 ? elseLine : endif);
}
}
if (!met && elseLine >= 0) { met = true; start = elseLine + 1; end = endif; }
}
if (met && start >= 0 && end > start)
{
_line = start;
while (_line < end)
{
ThrowIfCancelled();
StatusChanged?.Invoke(_scriptFile, _line + 1, _lines[_line]);
ExecuteLine(_lines[_line]);
_line++;
}
}
_line = endif;
}
#endregion
#region Control Flow - While
private void ExecuteWhile(string line)
{
var match = Regex.Match(line, @"while\s+(.+)", RegexOptions.IgnoreCase);
if (!match.Success) throw new Exception("Invalid while statement");
string condition = match.Groups[1].Value.Trim();
int whileStart = _line;
int endwhile = FindBlockEnd(whileStart, "while", "endwhile");
if (endwhile < 0) throw new Exception("while statement without matching endwhile");
while (true)
{
ThrowIfCancelled();
if (!EvaluateCondition(ReplaceVariables(condition))) break;
_line = whileStart + 1;
while (_line < endwhile)
{
ThrowIfCancelled();
StatusChanged?.Invoke(_scriptFile, _line + 1, _lines[_line]);
ExecuteLine(_lines[_line]);
_line++;
}
}
_line = endwhile;
}
/// <summary>找出與 <paramref name="open"/> 配對的 <paramref name="close"/>(支援巢狀)。</summary>
private int FindBlockEnd(int startIndex, string open, string close)
{
int depth = 0;
for (int i = startIndex; i < _lines.Length; i++)
{
string t = _lines[i].Trim().ToLower();
if (t.StartsWith(open)) depth++;
else if (t.StartsWith(close)) { depth--; if (depth == 0) return i; }
}
return -1;
}
#endregion
#region Condition Evaluation
private bool EvaluateCondition(string condition)
{
condition = condition.Trim();
foreach (var (op, isNumeric) in new[] { (">=", true), ("<=", true), ("==", false), ("!=", false), (">", true), ("<", true) })
{
int at = condition.IndexOf(op, StringComparison.Ordinal);
if (at < 0) continue;
string l = condition.Substring(0, at);
string r = condition.Substring(at + op.Length);
if (isNumeric)
{
int li = ParseIntDirect(l), ri = ParseIntDirect(r);
return op switch { ">=" => li >= ri, "<=" => li <= ri, ">" => li > ri, _ => li < ri };
}
string ls = l.Trim().Trim('\'', '"'), rs = r.Trim().Trim('\'', '"');
return op == "==" ? ls == rs : ls != rs;
}
// single '=' as equality
int eq = condition.IndexOf('=');
if (eq >= 0)
{
string l = condition.Substring(0, eq).Trim(), r = condition.Substring(eq + 1).Trim();
if (int.TryParse(l, out int li) && int.TryParse(r, out int ri)) return li == ri;
return l.Trim('\'', '"') == r.Trim('\'', '"');
}
return ParseIntDirect(condition) != 0;
}
#endregion
#region Variable Assignment
private void ExecuteAssignment(string line)
{
int eq = line.IndexOf('=');
if (eq < 0) return;
string name = line.Substring(0, eq).Trim();
string expr = line.Substring(eq + 1).Trim();
if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
{
Output?.Invoke($"[ttl] Invalid variable name '{name}'");
return;
}
_vars[name] = EvaluateExpression(ReplaceVariables(expr));
}
private object EvaluateExpression(string expr)
{
expr = expr.Trim();
foreach (char op in new[] { '+', '-', '*', '/' })
{
if (op == '-' && expr.StartsWith("-")) continue;
int at = expr.IndexOf(op);
if (at <= 0) continue;
int l = ParseIntDirect(expr.Substring(0, at));
int r = ParseIntDirect(expr.Substring(at + 1));
return op switch { '+' => l + r, '-' => l - r, '*' => l * r, _ => r != 0 ? l / r : 0 };
}
if (expr.StartsWith("\"") && expr.EndsWith("\"") && expr.Length >= 2)
return expr.Substring(1, expr.Length - 2);
if (int.TryParse(expr, out int v)) return v;
return expr;
}
#endregion
#region Basic Commands
private void Send(string args, bool newline)
{
string text = args.Trim('\'', '"');
_channel.Write(_enc.GetBytes(text + (newline ? "\r\n" : "")));
_logWriter?.WriteLine($">> {text}");
Output?.Invoke($">> {text}");
}
private void Wait(string text)
{
text = ReplaceVariables(text.Trim().Trim('"', '\''));
if (string.IsNullOrEmpty(text)) return; // 沒有關鍵字可等,直接略過
Output?.Invoke($"[wait] '{text}'");
_logWriter?.WriteLine($"[Wait] {text}");
int elapsed = 0;
while (true)
{
ThrowIfCancelled();
lock (_recv)
{
string buf = _recv.ToString();
int idx = buf.IndexOf(text, StringComparison.Ordinal);
if (idx >= 0)
{
_result = 1;
_recv.Clear();
_recv.Append(buf.Substring(idx + text.Length));
return;
}
}
// 只有在明確設定 timeout(>0) 且超時才中止腳本;否則一直等到關鍵字出現或被取消。
if (_timeout > 0 && elapsed >= _timeout)
{
_result = 0;
_logWriter?.WriteLine($"[Wait] timeout: {text}");
throw new TimeoutException($"wait timeout ({_timeout}ms): '{text}'");
}
Thread.Sleep(100);
elapsed += 100;
}
}
private void FlushReceive()
{
lock (_recv) _recv.Clear();
}
private void Pause(string args)
{
int total = ParseInt(args) * 1000, elapsed = 0;
while (elapsed < total)
{
ThrowIfCancelled();
int slice = Math.Min(100, total - elapsed);
Thread.Sleep(slice);
elapsed += slice;
}
}
private void SetTimeout(string args)
{
var m = Regex.Match(args, @"=\s*(\d+)");
_timeout = (m.Success ? ParseInt(m.Groups[1].Value) : ParseInt(args)) * 1000;
}
private void LogOpen(string args)
{
var m = Regex.Match(args, @"['""]([^'""]+)['""]");
string file = m.Success ? m.Groups[1].Value : args.Trim().Trim('\'', '"');
if (string.IsNullOrWhiteSpace(file)) return;
_logWriter = new StreamWriter(file, false);
Output?.Invoke($"[log] opened: {file}");
}
private void LogWrite(string args)
{
if (_logWriter == null) return;
string text = args.Trim('\'', '"');
_logWriter.WriteLine(text);
_logWriter.Flush();
}
private void LogClose()
{
if (_logWriter == null) return;
_logWriter.Flush();
_logWriter.Dispose();
_logWriter = null;
}
private void ShowMessageBox(string args)
{
string message = ReplaceVariables(args.Trim('\'', '"'));
Output?.Invoke($"[messagebox] {message}");
var main = System.Windows.Forms.Application.OpenForms.Count > 0
? System.Windows.Forms.Application.OpenForms[0] : null;
if (main != null && main.InvokeRequired)
main.Invoke(() => System.Windows.Forms.MessageBox.Show(main, message, "TTL Script",
System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Information));
else
System.Windows.Forms.MessageBox.Show(main, message, "TTL Script",
System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Information);
}
private void ExecuteWaitAll(string args)
{
if (_groupSync == null)
throw new InvalidOperationException("'waitall' can only be used in Group execution mode.");
// 先各自等到關鍵字出現
string text = ReplaceVariables(args.Trim().Trim('"', '\''));
if (!string.IsNullOrEmpty(text))
Wait(text);
// 再等所有成員都完成 wait
Output?.Invoke("[waitall] waiting for all group members...");
_groupSync.WaitAll(_cts.Token);
Output?.Invoke("[waitall] all members synchronized");
}
private void ExecuteSendlnAll(string args)
{
if (_groupSync == null)
throw new InvalidOperationException("'sendlnall' can only be used in Group execution mode.");
Output?.Invoke("[sendlnall] waiting for all group members...");
_groupSync.WaitAll(_cts.Token);
Send(args, true);
}
private void ExecuteSendlnGroup(string args)
{
if (_groupSync == null)
throw new InvalidOperationException("'sendlngroup' can only be used in Group execution mode.");
// 格式: sendlngroup A "show version"
var m = Regex.Match(args, @"^(\w+)\s+(.+)$");
if (!m.Success) { Output?.Invoke("[sendlngroup] invalid syntax, expected: sendlngroup A \"command\""); return; }
string target = m.Groups[1].Value.ToUpper();
string cmd = m.Groups[2].Value.Trim('\'', '"');
if (string.Equals(target, _groupMemberLabel, StringComparison.OrdinalIgnoreCase))
Send(cmd, true);
}
// ── PDU Commands ──
private void ExecutePduConnect(string args)
{
// pduconnect <device> <ip>
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) { Output?.Invoke("[pduconnect] syntax: pduconnect <device> <ip>"); _result = 0; return; }
int device = ParseIntDirect(parts[0]);
string ip = parts[1].Trim('\'', '"');
var pdu = new PduController(ip);
if (pdu.CheckConnection())
{
_pdus[device] = pdu;
_result = 1;
Output?.Invoke($"[pduconnect] connected to device {device} at {ip}");
}
else
{
pdu.Dispose();
_result = 0;
Output?.Invoke($"[pduconnect] failed to connect to {ip}");
}
}
private void ExecutePduCtrl(string args)
{
// pductrl <device> <port> <action: 1=on, 0=off>
args = ReplaceVariables(args);
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3) { Output?.Invoke("[pductrl] syntax: pductrl <device> <port> <0|1>"); _result = 0; return; }
int device = ParseIntDirect(parts[0]);
int port = ParseIntDirect(parts[1]);
int action = ParseIntDirect(parts[2]);
if (!_pdus.TryGetValue(device, out var pdu))
{
Output?.Invoke($"[pductrl] device {device} not connected. Use pduconnect first.");
_result = 0; return;
}
bool ok = action == 1 ? pdu.SetPortOn(port) : pdu.SetPortOff(port);
_result = ok ? 1 : 0;
string act = action == 1 ? "ON" : "OFF";
Output?.Invoke($"[pductrl] device {device} port {port} {act} → {(ok ? "OK" : "FAILED")}");
_logWriter?.WriteLine($"[pductrl] device={device} port={port} action={act} result={_result}");
}
#endregion
#region Helpers
private static string GetCommand(string line)
{
int sp = line.IndexOf(' ');
return sp > 0 ? line.Substring(0, sp).Trim() : line.Trim();
}
private static string GetArgs(string line)
{
int sp = line.IndexOf(' ');
return sp > 0 ? line.Substring(sp + 1).Trim() : "";
}
private string ReplaceVariables(string text)
{
foreach (var kvp in _vars)
text = Regex.Replace(text, @"\b" + Regex.Escape(kvp.Key) + @"\b", kvp.Value?.ToString() ?? "");
return Regex.Replace(text, @"\bresult\b", _result.ToString());
}
private int ParseInt(string value) => ParseIntDirect(ReplaceVariables(value.Trim()));
private static int ParseIntDirect(string value) =>
int.TryParse(value.Trim(), out int r) ? r : 0;
#endregion
public void Cancel() { _cancelled = true; _cts.Cancel(); }
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cancelled = true;
_cts.Cancel();
_cts.Dispose();
LogClose();
foreach (var pdu in _pdus.Values) pdu.Dispose();
_pdus.Clear();
_channel.DataReceived -= OnData;
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace ETTerms.Sessions;
/// <summary>
/// Host key 指紋的 trust-on-first-use 記錄:%LocalAppData%\ETTerms\known_hosts.txt
/// 每行 "host:port SHA256base64"。
/// </summary>
public static class HostKeyStore
{
private static readonly string _path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ETTerms", "known_hosts.txt");
private static readonly object _lock = new();
/// <summary>取回已信任指紋;無則 null。</summary>
public static string? Get(string hostKey)
{
lock (_lock)
{
if (!File.Exists(_path)) return null;
foreach (var line in File.ReadAllLines(_path))
{
var sp = line.Split(' ', 2);
if (sp.Length == 2 && sp[0] == hostKey) return sp[1];
}
return null;
}
}
/// <summary>記錄新信任的指紋。</summary>
public static void Set(string hostKey, string fingerprint)
{
lock (_lock)
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
File.AppendAllText(_path, $"{hostKey} {fingerprint}{Environment.NewLine}");
}
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace ETTerms.Sessions;
/// <summary>
/// 連線通道抽象:TerminalView / ScriptEngine 都只認得這個介面,
/// 因此 SSH 與 Serial 對上層一致(同一套腳本可驅動兩種連線)。
/// </summary>
public interface ISessionChannel : IDisposable
{
/// <summary>收到遠端資料(背景執行緒觸發,訂閱者需自行 Invoke 回 UI thread)。</summary>
event Action<byte[]>? DataReceived;
/// <summary>建立連線;失敗丟例外。</summary>
void Open();
/// <summary>送出位元組到遠端。</summary>
void Write(byte[] data);
/// <summary>通知遠端終端機大小(PTY size);Serial 為 no-op。</summary>
void Resize(int cols, int rows);
/// <summary>關閉連線並釋放底層資源。</summary>
void Close();
}
+84
View File
@@ -0,0 +1,84 @@
using System.IO.Ports;
using ETTerms.Connections;
using ETTerms.Infrastructure;
namespace ETTerms.Sessions;
/// <summary>
/// Serial 連線通道:包 <see cref="SerialPort"/>。開啟前向 SessionManager 占用 COM port(互斥)。
/// </summary>
public sealed class SerialChannel : ISessionChannel
{
private readonly SerialPort _port;
private readonly string _portName;
private bool _opened;
public event Action<byte[]>? DataReceived;
public SerialChannel(SerialSettings s)
{
_portName = s.PortName;
_port = new SerialPort(s.PortName, s.BaudRate, s.Parity, s.DataBits, s.StopBits)
{
Handshake = s.Handshake,
NewLine = s.NewLine
};
_port.DataReceived += OnData;
}
public void Open()
{
if (!SessionManager.TryReservePort(_portName))
throw new InvalidOperationException($"COM port {_portName} is already in use by another session.");
try
{
_port.Open();
_opened = true;
AppLogger.Info($"Serial opened: {_portName} @ {_port.BaudRate}");
}
catch
{
SessionManager.ReleasePort(_portName);
throw;
}
}
private void OnData(object sender, SerialDataReceivedEventArgs e)
{
try
{
int n = _port.BytesToRead;
if (n <= 0) return;
var buf = new byte[n];
int read = _port.Read(buf, 0, n);
if (read > 0) DataReceived?.Invoke(read == n ? buf : buf[..read]);
}
catch (Exception ex)
{
AppLogger.LogWarning($"Serial read error on {_portName}: {ex.Message}");
}
}
public void Write(byte[] data)
{
if (_port.IsOpen) _port.Write(data, 0, data.Length);
}
public void Resize(int cols, int rows) { /* serial 無 PTY size */ }
public void Close()
{
if (!_opened) return;
_opened = false;
try { if (_port.IsOpen) _port.Close(); } catch { /* 忽略關閉錯誤 */ }
SessionManager.ReleasePort(_portName);
AppLogger.Info($"Serial closed: {_portName}");
}
public void Dispose()
{
_port.DataReceived -= OnData;
Close();
_port.Dispose();
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace ETTerms.Sessions;
/// <summary>
/// 全域 session 登錄表 + Serial COM port 互斥管理。
/// 一個 COM port 同時只能被一個 session 開啟。
/// </summary>
public static class SessionManager
{
private static readonly object _lock = new();
private static readonly HashSet<string> _busyPorts = new(StringComparer.OrdinalIgnoreCase);
private static readonly List<ISessionChannel> _active = new();
/// <summary>目前 active 的 session 快照。</summary>
public static IReadOnlyList<ISessionChannel> Active
{
get { lock (_lock) return _active.ToArray(); }
}
/// <summary>嘗試占用 COM port;已被占用回 false。</summary>
public static bool TryReservePort(string portName)
{
lock (_lock) return _busyPorts.Add(portName);
}
public static void ReleasePort(string portName)
{
lock (_lock) _busyPorts.Remove(portName);
}
public static void Register(ISessionChannel channel)
{
lock (_lock) _active.Add(channel);
}
public static void Unregister(ISessionChannel channel)
{
lock (_lock) _active.Remove(channel);
}
}
+191
View File
@@ -0,0 +1,191 @@
using System.Drawing;
using System.Windows.Forms;
using ETTerms.App;
using ETTerms.Infrastructure;
using ETTerms.Scripting;
using ETTerms.Terminal;
namespace ETTerms.Sessions;
/// <summary>
/// 一個連線分頁的內容:頂部腳本列(狀態 + 載入執行 / 停止)+ 自繪 VT100 <see cref="TerminalView"/>
/// + 綁定的 <see cref="ISessionChannel"/>。每個分頁各自對自己的 channel 跑 TTL 腳本。
/// channel 在 handle 建立後才 Open,確保 DataReceived 能安全 Invoke 回 UI thread。
/// </summary>
public sealed class SessionPage : UserControl
{
private const int MaxStatus = 40; // 狀態列指令最大字數,超過以 ... 顯示
private readonly ISessionChannel _channel;
private readonly string _title;
private readonly TerminalView _term;
private readonly ScriptRunner _runner = new();
private readonly Label _status;
private readonly Button _run, _stop;
private bool _started;
/// <summary>底層連線通道。</summary>
public ISessionChannel Channel => _channel;
/// <summary>此分頁是否為 Serial 連線。</summary>
public bool IsSerial => _channel is SerialChannel;
/// <summary>目前是否正在跑腳本。</summary>
public bool IsScriptRunning => _runner.IsRunning;
/// <summary>所屬 Group0=無, 1/2/3)。</summary>
public int Group { get; set; }
/// <summary>Group 內的編號標籤,如 "Group1-A"。外部設定。</summary>
public string GroupLabel { get; set; } = "";
/// <summary>連線標題(COM名稱)。</summary>
public string Title => _title;
/// <summary>外部觸發腳本執行(供 Run All 使用)。</summary>
public async Task RunScriptAsync(string content, string fileName)
{
if (_runner.IsRunning) return;
SetRunning(true);
await _runner.RunAsync(content, fileName, _channel);
}
/// <summary>以 Group 模式執行腳本(支援 waitall/sendlnall/sendlngroup 同步)。</summary>
public async Task RunGroupScriptAsync(string content, string fileName, GroupSyncContext sync, string memberLabel)
{
if (_runner.IsRunning) return;
SetRunning(true);
await _runner.RunGroupAsync(content, fileName, _channel, sync, memberLabel);
}
/// <summary>同步開啟失敗(主要是 Serial 連不上 / 被占用)時觸發,附帶訊息。</summary>
public event Action<string>? ConnectFailed;
public SessionPage(ISessionChannel channel, string title)
{
_channel = channel;
_title = title;
Dock = DockStyle.Fill;
// ── 頂部腳本列 ──
var bar = new Panel { Dock = DockStyle.Top, Height = 24, BackColor = Theme.RailBack };
_status = new Label
{
Dock = DockStyle.Fill, Text = "Idle",
ForeColor = Theme.TextDim, Font = Theme.UiFont,
TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(8, 0, 0, 0)
};
_run = MakeBarButton("▶ Script", OnRunScript);
_stop = MakeBarButton("■ Stop", (_, _) => _runner.Cancel());
_stop.Enabled = false;
bar.Controls.Add(_status); // Fill 先加
bar.Controls.Add(_run); // Right
bar.Controls.Add(_stop); // Right
_term = new TerminalView(new TerminalProfile()) { Dock = DockStyle.Fill };
_term.SendData += data => _channel.Write(data);
_term.Resized += (cols, rows) => _channel.Resize(cols, rows);
Controls.Add(_term); // Fill 先加
Controls.Add(bar); // Top
_runner.StatusChanged += (_, line, cmd) => Ui(() => _status.Text = $"line {line}: {Trunc(cmd)}");
_runner.Finished += (_, msg) => Ui(() => { _status.Text = msg; SetRunning(false); });
}
private async void OnRunScript(object? sender, EventArgs e)
{
string content, name;
using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" })
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
content = File.ReadAllText(dlg.FileName);
name = Path.GetFileName(dlg.FileName);
}
if (ContainsGroupCommands(content))
{
MessageBox.Show(this,
"Script contains 'waitall' or 'sendlnall' which require Group execution.\nPlease use Run Group buttons instead.",
"Script", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
SetRunning(true);
await _runner.RunAsync(content, name, _channel);
}
private static bool ContainsGroupCommands(string content)
{
foreach (var line in content.Split('\n'))
{
var t = line.Trim().ToLower();
if (t.StartsWith("waitall") || t.StartsWith("sendlnall") || t.StartsWith("sendlngroup"))
return true;
}
return false;
}
private void SetRunning(bool running)
{
_run.Enabled = !running;
_stop.Enabled = running;
}
private static string Trunc(string s) => s.Length <= MaxStatus ? s : s.Substring(0, MaxStatus) + "...";
private void Ui(Action action)
{
if (IsDisposed || !IsHandleCreated) return;
if (InvokeRequired) BeginInvoke(action);
else action();
}
private static Button MakeBarButton(string text, EventHandler onClick)
{
var b = new Button
{
Text = text, Dock = DockStyle.Right, Width = 72, FlatStyle = FlatStyle.Flat,
ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand
};
b.FlatAppearance.BorderColor = Theme.Border;
b.FlatAppearance.MouseOverBackColor = Theme.Hover;
b.Click += onClick;
return b;
}
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
if (_started) return;
_started = true;
_channel.DataReceived += OnDataReceived;
try
{
_channel.Open();
SessionManager.Register(_channel);
}
catch (Exception ex)
{
AppLogger.LogError($"Open channel failed: {_title}", ex);
ConnectFailed?.Invoke(ex.Message);
}
}
private void OnDataReceived(byte[] data)
{
if (IsDisposed || !IsHandleCreated) return;
if (InvokeRequired) BeginInvoke(() => _term.Feed(data));
else _term.Feed(data);
}
protected override void Dispose(bool disposing)
{
if (disposing && _started)
{
_runner.Cancel();
_channel.DataReceived -= OnDataReceived;
SessionManager.Unregister(_channel);
_channel.Dispose();
}
base.Dispose(disposing);
}
}
+185
View File
@@ -0,0 +1,185 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using ETTerms.Connections;
using Microsoft.Win32.SafeHandles;
namespace ETTerms.Sessions;
/// <summary>
/// Local shell channel using Windows ConPTY for proper PTY support.
/// This allows PowerShell/Bash/Cmd to behave like a real terminal with
/// arrow keys, history, backspace, PSReadLine, etc.
/// </summary>
public sealed class ShellChannel : ISessionChannel
{
private readonly ShellSettings _settings;
private SafeFileHandle? _ptyOutput;
private SafeFileHandle? _ptyInput;
private FileStream? _writeStream;
private IntPtr _ptyHandle;
private Process? _proc;
private Thread? _reader;
private bool _closed;
public event Action<byte[]>? DataReceived;
public ShellChannel(ShellSettings settings) => _settings = settings;
public void Open()
{
// Create pipes
CreatePipe(out var inputReadSide, out var inputWriteSide);
CreatePipe(out var outputReadSide, out var outputWriteSide);
// Create pseudo console (ConPTY)
var size = new COORD { X = 120, Y = 30 };
int hr = CreatePseudoConsole(size, inputReadSide, outputWriteSide, 0, out _ptyHandle);
if (hr != 0) throw new Exception($"CreatePseudoConsole failed: 0x{hr:X8}");
// Close handles that are now owned by the pseudo console
inputReadSide.Dispose();
outputWriteSide.Dispose();
_ptyInput = inputWriteSide;
_ptyOutput = outputReadSide;
_writeStream = new FileStream(_ptyInput, FileAccess.Write, 256, false);
// Start the shell process attached to the pseudo console
var (exe, args) = _settings.ShellType.ToLower() switch
{
"bash" => ("bash", "--login -i"),
"cmd" => ("cmd.exe", ""),
_ => ("powershell.exe", "-NoLogo")
};
var si = new STARTUPINFOEX();
si.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
// Initialize thread attribute list
IntPtr attrSize = IntPtr.Zero;
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attrSize);
si.lpAttributeList = Marshal.AllocHGlobal(attrSize.ToInt32());
InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, ref attrSize);
UpdateProcThreadAttribute(si.lpAttributeList, 0, (IntPtr)0x00020016 /* PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE */,
_ptyHandle, IntPtr.Size, IntPtr.Zero, IntPtr.Zero);
string workDir = string.IsNullOrWhiteSpace(_settings.StartupDirectory)
? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
: _settings.StartupDirectory;
bool ok = CreateProcess(null, $"{exe} {args}".TrimEnd(), IntPtr.Zero, IntPtr.Zero, false,
0x00080000 /* EXTENDED_STARTUPINFO_PRESENT */, IntPtr.Zero, workDir, ref si, out var pi);
if (!ok) throw new Exception($"CreateProcess failed: {Marshal.GetLastWin32Error()}");
CloseHandle(pi.hThread);
_proc = Process.GetProcessById(pi.dwProcessId);
// Start reader thread
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "ConPTY-Reader" };
_reader.Start();
}
private void ReadLoop()
{
var buf = new byte[4096];
try
{
using var stream = new FileStream(_ptyOutput!, FileAccess.Read, 4096, false);
while (!_closed)
{
int n = stream.Read(buf, 0, buf.Length);
if (n <= 0) break;
var data = new byte[n];
Buffer.BlockCopy(buf, 0, data, 0, n);
DataReceived?.Invoke(data);
}
}
catch when (_closed) { }
}
public void Write(byte[] data)
{
if (_closed || _writeStream == null) return;
_writeStream.Write(data, 0, data.Length);
_writeStream.Flush();
}
public void Resize(int cols, int rows)
{
if (_ptyHandle != IntPtr.Zero)
ResizePseudoConsole(_ptyHandle, new COORD { X = (short)cols, Y = (short)rows });
}
public void Close()
{
if (_closed) return;
_closed = true;
_writeStream?.Dispose();
if (_ptyHandle != IntPtr.Zero) { ClosePseudoConsole(_ptyHandle); _ptyHandle = IntPtr.Zero; }
_ptyInput?.Dispose();
_ptyOutput?.Dispose();
if (_proc is { HasExited: false }) { try { _proc.Kill(); } catch { } }
_proc?.Dispose();
}
public void Dispose() => Close();
// ── Win32 Interop ──
[StructLayout(LayoutKind.Sequential)]
private struct COORD { public short X, Y; }
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX
{
public STARTUPINFO StartupInfo;
public IntPtr lpAttributeList;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct STARTUPINFO
{
public int cb; public IntPtr lpReserved, lpDesktop, lpTitle;
public int dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
public short wShowWindow, cbReserved2; public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess, hThread;
public int dwProcessId, dwThreadId;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern int CreatePseudoConsole(COORD size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC);
[DllImport("kernel32.dll")]
private static extern int ResizePseudoConsole(IntPtr hPC, COORD size);
[DllImport("kernel32.dll")]
private static extern void ClosePseudoConsole(IntPtr hPC);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CreatePipe(out SafeFileHandle hReadPipe, out SafeFileHandle hWritePipe, IntPtr lpPipeAttributes, int nSize);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref IntPtr lpSize);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool CreateProcess(string? lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr hObject);
private static void CreatePipe(out SafeFileHandle read, out SafeFileHandle write)
{
if (!CreatePipe(out read, out write, IntPtr.Zero, 0))
throw new Exception($"CreatePipe failed: {Marshal.GetLastWin32Error()}");
}
}
+133
View File
@@ -0,0 +1,133 @@
using System.Reflection;
using System.Text;
using ETTerms.Connections;
using ETTerms.Infrastructure;
using Renci.SshNet;
using Renci.SshNet.Common;
namespace ETTerms.Sessions;
/// <summary>
/// SSH 連線通道:SSH.NET SshClient + ShellStream,實作 <see cref="ISessionChannel"/>。
/// 支援 password / private keypassphrase 走 CredentialVault/ keyboard-interactive
/// host key 指紋 TOFU(首次記錄、之後比對、不符中止)。連線在背景執行緒進行避免凍結 UI。
/// </summary>
public sealed class SshChannel : ISessionChannel
{
private readonly SshSettings _ssh;
private readonly string? _secret;
private SshClient? _client;
private ShellStream? _shell;
private int _cols = 80, _rows = 24;
private bool _closed;
public event Action<byte[]>? DataReceived;
public SshChannel(Connection conn)
{
_ssh = conn.Ssh ?? new SshSettings();
_secret = CredentialVault.Get(conn.CredentialKey);
}
public void Open() => Task.Run(Connect);
private void Connect()
{
try
{
var ci = BuildConnectionInfo();
_client = new SshClient(ci);
_client.HostKeyReceived += OnHostKey;
_client.Connect();
_shell = _client.CreateShellStream("xterm-256color", (uint)_cols, (uint)_rows, 0, 0, 4096);
_shell.DataReceived += (_, e) => DataReceived?.Invoke(e.Data);
AppLogger.Info($"SSH connected: {_ssh.Username}@{_ssh.Host}:{_ssh.Port}");
}
catch (Exception ex)
{
Emit($"\r\n[SSH 連線失敗] {ex.Message}\r\n");
AppLogger.LogError($"SSH connect failed {_ssh.Host}", ex);
}
}
private ConnectionInfo BuildConnectionInfo()
{
AuthenticationMethod method = _ssh.AuthMethod switch
{
SshAuthMethod.PrivateKey => new PrivateKeyAuthenticationMethod(_ssh.Username, LoadKey()),
SshAuthMethod.KeyboardInteractive => KeyboardInteractive(),
_ => new PasswordAuthenticationMethod(_ssh.Username, _secret ?? "")
};
return new ConnectionInfo(_ssh.Host, _ssh.Port, _ssh.Username, method);
}
private PrivateKeyFile LoadKey() => string.IsNullOrEmpty(_secret)
? new PrivateKeyFile(_ssh.PrivateKeyPath!)
: new PrivateKeyFile(_ssh.PrivateKeyPath!, _secret);
private KeyboardInteractiveAuthenticationMethod KeyboardInteractive()
{
var ki = new KeyboardInteractiveAuthenticationMethod(_ssh.Username);
ki.AuthenticationPrompt += (_, e) =>
{
foreach (AuthenticationPrompt p in e.Prompts) p.Response = _secret ?? "";
};
return ki;
}
private void OnHostKey(object? sender, HostKeyEventArgs e)
{
string key = $"{_ssh.Host}:{_ssh.Port}";
string fp = e.FingerPrintSHA256;
string? known = HostKeyStore.Get(key);
if (known == null)
{
HostKeyStore.Set(key, fp);
Emit($"\r\n[host key TOFU] 首次連線,已記錄 {key}\r\nSHA256:{fp}\r\n");
e.CanTrust = true;
}
else if (known == fp)
{
e.CanTrust = true;
}
else
{
Emit($"\r\n[警告] {key} host key 不符,可能遭中間人攻擊!連線中止。\r\n預期 SHA256:{known}\r\n實際 SHA256:{fp}\r\n");
e.CanTrust = false;
}
}
public void Write(byte[] data)
{
if (_shell == null) return;
_shell.Write(data, 0, data.Length);
_shell.Flush();
}
public void Resize(int cols, int rows)
{
_cols = cols; _rows = rows;
if (_shell == null) return;
// SSH.NET ShellStream 未公開 resize;反射呼叫底層 channel 的 SendWindowChangeRequest
try
{
var channel = _shell.GetType()
.GetField("_channel", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(_shell);
channel?.GetType().GetMethod("SendWindowChangeRequest", BindingFlags.Public | BindingFlags.Instance)?
.Invoke(channel, new object[] { (uint)cols, (uint)rows, 0u, 0u });
}
catch (Exception ex) { AppLogger.LogWarning($"SSH resize failed: {ex.Message}"); }
}
public void Close()
{
if (_closed) return;
_closed = true;
try { _shell?.Dispose(); } catch { }
try { _client?.Disconnect(); _client?.Dispose(); } catch { }
}
public void Dispose() => Close();
private void Emit(string text) => DataReceived?.Invoke(Encoding.UTF8.GetBytes(text));
}
+217
View File
@@ -0,0 +1,217 @@
using System.Drawing;
using System.Text;
namespace ETTerms.Terminal;
/// <summary>VT100 / 常見 ANSI escape 狀態機,驅動 <see cref="ScreenBuffer"/>。</summary>
public sealed class AnsiParser
{
private enum State { Ground, Esc, Csi, Osc }
private readonly ScreenBuffer _b;
private readonly Decoder _dec = Encoding.UTF8.GetDecoder();
private State _state = State.Ground;
private readonly StringBuilder _params = new();
private bool _priv;
private bool _oscEsc;
private int _skipCharset;
/// <summary>DECCKMapplication cursor keys(影響方向鍵送出序列)。</summary>
public bool AppCursorKeys { get; private set; }
public AnsiParser(ScreenBuffer buffer) => _b = buffer;
public void Feed(byte[] data)
{
var chars = new char[data.Length];
int n = _dec.GetChars(data, 0, data.Length, chars, 0);
for (int i = 0; i < n; i++) Step(chars[i]);
}
private void Step(char ch)
{
if (_skipCharset > 0) { _skipCharset--; return; }
switch (_state)
{
case State.Ground: Ground(ch); break;
case State.Esc: Esc(ch); break;
case State.Csi: Csi(ch); break;
case State.Osc: Osc(ch); break;
}
}
private void Ground(char ch)
{
switch (ch)
{
case '\x1b': _state = State.Esc; break;
case '\r': _b.CarriageReturn(); break;
case '\n': case '\v': case '\f': _b.LineFeed(); break;
case '\b': _b.Backspace(); break;
case '\t': _b.Tab(); break;
case '\a': break;
default: if (ch >= ' ') _b.Print(ch); break;
}
}
private void Esc(char ch)
{
switch (ch)
{
case '[': _params.Clear(); _priv = false; _state = State.Csi; break;
case ']': _oscEsc = false; _state = State.Osc; break;
case '(': case ')': case '*': case '+': _skipCharset = 1; _state = State.Ground; break;
case '7': _b.SaveCursor(); _state = State.Ground; break;
case '8': _b.RestoreCursor(); _state = State.Ground; break;
case 'M': _b.ReverseLineFeed(); _state = State.Ground; break;
case 'D': _b.LineFeed(); _state = State.Ground; break;
case 'E': _b.NextLine(); _state = State.Ground; break;
case 'c': _b.ResetAttrs(); _b.EraseInDisplay(2); _b.MoveCursor(0, 0); _state = State.Ground; break;
default: _state = State.Ground; break;
}
}
private void Csi(char ch)
{
if (ch == '?' || ch == '>' || ch == '!') { _priv = true; return; }
if ((ch >= '0' && ch <= '9') || ch == ';') { _params.Append(ch); return; }
if (ch >= 0x40 && ch <= 0x7e) { Dispatch(ch); _state = State.Ground; return; }
// 其餘 intermediate 忽略
}
private void Osc(char ch)
{
if (ch == '\a') { _state = State.Ground; return; }
if (_oscEsc && ch == '\\') { _state = State.Ground; return; }
_oscEsc = ch == '\x1b';
}
private int[] Params()
{
if (_params.Length == 0) return Array.Empty<int>();
var parts = _params.ToString().Split(';');
var r = new int[parts.Length];
for (int i = 0; i < parts.Length; i++) r[i] = int.TryParse(parts[i], out var v) ? v : 0;
return r;
}
private int P(int[] p, int i, int def) => (i < p.Length && p[i] > 0) ? p[i] : def;
private void Dispatch(char f)
{
var p = Params();
switch (f)
{
case 'A': _b.CursorUp(P(p, 0, 1)); break;
case 'B': _b.CursorDown(P(p, 0, 1)); break;
case 'C': _b.CursorRight(P(p, 0, 1)); break;
case 'D': _b.CursorLeft(P(p, 0, 1)); break;
case 'E': _b.MoveCursor(_b.CursorRow + P(p, 0, 1), 0); break;
case 'F': _b.MoveCursor(_b.CursorRow - P(p, 0, 1), 0); break;
case 'G': case '`': _b.SetColumn(P(p, 0, 1) - 1); break;
case 'd': _b.SetRow(P(p, 0, 1) - 1); break;
case 'H': case 'f': _b.MoveCursor(P(p, 0, 1) - 1, P(p, 1, 1) - 1); break;
case 'J': _b.EraseInDisplay(p.Length > 0 ? p[0] : 0); break;
case 'K': _b.EraseInLine(p.Length > 0 ? p[0] : 0); break;
case 'L': _b.InsertLines(P(p, 0, 1)); break;
case 'M': _b.DeleteLines(P(p, 0, 1)); break;
case 'P': _b.DeleteChars(P(p, 0, 1)); break;
case '@': _b.InsertChars(P(p, 0, 1)); break;
case 'X': _b.EraseChars(P(p, 0, 1)); break;
case 'S': _b.ScrollUp(P(p, 0, 1)); break;
case 'T': _b.ScrollDown(P(p, 0, 1)); break;
case 'r': _b.SetScrollRegion(P(p, 0, 1) - 1, p.Length > 1 && p[1] > 0 ? p[1] - 1 : _b.Rows - 1); break;
case 's': _b.SaveCursor(); break;
case 'u': _b.RestoreCursor(); break;
case 'h': SetMode(p, true); break;
case 'l': SetMode(p, false); break;
case 'm': Sgr(p); break;
}
}
private void SetMode(int[] p, bool set)
{
if (!_priv) return;
foreach (var code in p)
switch (code)
{
case 25: _b.CursorVisible = set; break;
case 7: _b.AutoWrap = set; break;
case 1: AppCursorKeys = set; break;
case 47: case 1047: case 1049:
if (set) _b.EnterAlt(); else _b.ExitAlt(); break;
}
}
private void Sgr(int[] p)
{
if (p.Length == 0) { _b.ResetAttrs(); return; }
for (int i = 0; i < p.Length; i++)
{
int c = p[i];
switch (c)
{
case 0: _b.ResetAttrs(); break;
case 1: _b.PenAttr |= CellAttr.Bold; break;
case 4: _b.PenAttr |= CellAttr.Underline; break;
case 7: _b.PenAttr |= CellAttr.Inverse; break;
case 22: _b.PenAttr &= ~CellAttr.Bold; break;
case 24: _b.PenAttr &= ~CellAttr.Underline; break;
case 27: _b.PenAttr &= ~CellAttr.Inverse; break;
case 39: _b.PenFg = _b.DefaultFg; break;
case 49: _b.PenBg = _b.DefaultBg; break;
case >= 30 and <= 37: _b.PenFg = Palette.Ansi(c - 30); break;
case >= 40 and <= 47: _b.PenBg = Palette.Ansi(c - 40); break;
case >= 90 and <= 97: _b.PenFg = Palette.Ansi(c - 90 + 8); break;
case >= 100 and <= 107: _b.PenBg = Palette.Ansi(c - 100 + 8); break;
case 38: i = Ext(p, i, true); break;
case 48: i = Ext(p, i, false); break;
}
}
}
private int Ext(int[] p, int i, bool fg)
{
if (i + 1 < p.Length && p[i + 1] == 5 && i + 2 < p.Length)
{
var col = Palette.Xterm256(p[i + 2]);
if (fg) _b.PenFg = col; else _b.PenBg = col;
return i + 2;
}
if (i + 1 < p.Length && p[i + 1] == 2 && i + 4 < p.Length)
{
var col = Color.FromArgb(p[i + 2], p[i + 3], p[i + 4]);
if (fg) _b.PenFg = col; else _b.PenBg = col;
return i + 4;
}
return i;
}
}
/// <summary>ANSI 16 色 + xterm-256 色盤。</summary>
public static class Palette
{
private static readonly Color[] Base16 =
{
Color.FromArgb(0,0,0), Color.FromArgb(205,49,49), Color.FromArgb(13,188,121), Color.FromArgb(229,229,16),
Color.FromArgb(36,114,200), Color.FromArgb(188,63,188), Color.FromArgb(17,168,205), Color.FromArgb(229,229,229),
Color.FromArgb(102,102,102), Color.FromArgb(241,76,76), Color.FromArgb(35,209,139), Color.FromArgb(245,245,67),
Color.FromArgb(59,142,234), Color.FromArgb(214,112,214), Color.FromArgb(41,184,219), Color.FromArgb(255,255,255),
};
public static Color Ansi(int i) => Base16[Math.Clamp(i, 0, 15)];
public static Color Xterm256(int n)
{
if (n < 16) return Base16[n];
if (n < 232)
{
n -= 16;
int r = n / 36, g = (n / 6) % 6, b = n % 6;
static int V(int x) => x == 0 ? 0 : 55 + x * 40;
return Color.FromArgb(V(r), V(g), V(b));
}
int v = 8 + (n - 232) * 10;
return Color.FromArgb(v, v, v);
}
}
+263
View File
@@ -0,0 +1,263 @@
using System.Drawing;
namespace ETTerms.Terminal;
[Flags]
public enum CellAttr : byte { None = 0, Bold = 1, Underline = 2, Inverse = 4 }
public struct Cell
{
public char Ch;
public Color Fg;
public Color Bg;
public CellAttr Attr;
}
/// <summary>
/// 終端機字格緩衝:rows×cols 畫面 + scrollback + 滾動區(DECSTBM+ alt screen。
/// 由 <see cref="AnsiParser"/> 驅動;<see cref="TerminalView"/> 讀取繪製。座標 0-based。
/// </summary>
public sealed class ScreenBuffer
{
public int Rows { get; private set; }
public int Cols { get; private set; }
public Color DefaultFg, DefaultBg;
// 目前畫筆(SGR
public Color PenFg, PenBg;
public CellAttr PenAttr;
public int CursorRow { get; private set; }
public int CursorCol { get; private set; }
public bool CursorVisible = true;
public bool AutoWrap = true;
private Cell[][] _screen;
private readonly List<Cell[]> _scrollback = new();
private readonly int _maxScroll;
private int _top, _bottom; // 滾動區(含)
private bool _wrapPending;
// 儲存游標(ESC 7 / CSI s
private int _scx, _scy; private Color _sfg, _sbg; private CellAttr _sattr;
// alt screen
private Cell[][]? _mainScreen;
public bool AltActive { get; private set; }
public ScreenBuffer(int cols, int rows, Color fg, Color bg, int maxScrollback)
{
Cols = Math.Max(1, cols); Rows = Math.Max(1, rows);
DefaultFg = fg; DefaultBg = bg; PenFg = fg; PenBg = bg;
_maxScroll = maxScrollback;
_screen = NewGrid(Rows, Cols);
_top = 0; _bottom = Rows - 1;
}
// ── 給 TerminalView 讀取 ─────────────────────────────────
public int ScrollbackCount => _scrollback.Count;
public int TotalRows => _scrollback.Count + Rows;
public Cell[] LineAt(int abs) => abs < _scrollback.Count ? _scrollback[abs] : _screen[abs - _scrollback.Count];
// ── 內部建構工具 ─────────────────────────────────────────
private Cell BlankPen() => new() { Ch = ' ', Fg = PenFg, Bg = PenBg, Attr = CellAttr.None };
private Cell BlankDefault() => new() { Ch = ' ', Fg = DefaultFg, Bg = DefaultBg, Attr = CellAttr.None };
private Cell[] BlankLine() { var l = new Cell[Cols]; for (int i = 0; i < Cols; i++) l[i] = BlankPen(); return l; }
private Cell[][] NewGrid(int r, int c)
{
var g = new Cell[r][];
for (int i = 0; i < r; i++) { g[i] = new Cell[c]; for (int j = 0; j < c; j++) g[i][j] = BlankDefault(); }
return g;
}
private void PushScroll(Cell[] line)
{
_scrollback.Add(line);
if (_scrollback.Count > _maxScroll) _scrollback.RemoveRange(0, _scrollback.Count - _maxScroll);
}
// ── 輸出字元 ─────────────────────────────────────────────
public void Print(char ch)
{
if (_wrapPending) { _wrapPending = false; CursorCol = 0; LineFeed(); }
if (CursorRow < 0 || CursorRow >= Rows) CursorRow = Math.Clamp(CursorRow, 0, Rows - 1);
_screen[CursorRow][CursorCol] = new Cell { Ch = ch, Fg = PenFg, Bg = PenBg, Attr = PenAttr };
if (CursorCol >= Cols - 1) { if (AutoWrap) _wrapPending = true; }
else CursorCol++;
}
// ── 游標 / 換行 ──────────────────────────────────────────
public void CarriageReturn() { _wrapPending = false; CursorCol = 0; }
public void LineFeed()
{
_wrapPending = false;
if (CursorRow == _bottom) ScrollUp(1);
else if (CursorRow < Rows - 1) CursorRow++;
}
public void ReverseLineFeed()
{
_wrapPending = false;
if (CursorRow == _top) ScrollDown(1);
else if (CursorRow > 0) CursorRow--;
}
public void NextLine() { CarriageReturn(); LineFeed(); }
public void Backspace() { _wrapPending = false; if (CursorCol > 0) CursorCol--; }
public void Tab() { _wrapPending = false; CursorCol = Math.Min(Cols - 1, (CursorCol / 8 + 1) * 8); }
public void MoveCursor(int row, int col)
{
_wrapPending = false;
CursorRow = Math.Clamp(row, 0, Rows - 1);
CursorCol = Math.Clamp(col, 0, Cols - 1);
}
public void CursorUp(int n) { _wrapPending = false; CursorRow = Math.Max(_top, CursorRow - n); }
public void CursorDown(int n) { _wrapPending = false; CursorRow = Math.Min(_bottom, CursorRow + n); }
public void CursorLeft(int n) { _wrapPending = false; CursorCol = Math.Max(0, CursorCol - n); }
public void CursorRight(int n) { _wrapPending = false; CursorCol = Math.Min(Cols - 1, CursorCol + n); }
public void SetColumn(int col) { _wrapPending = false; CursorCol = Math.Clamp(col, 0, Cols - 1); }
public void SetRow(int row) { _wrapPending = false; CursorRow = Math.Clamp(row, 0, Rows - 1); }
public void SaveCursor() { _scx = CursorCol; _scy = CursorRow; _sfg = PenFg; _sbg = PenBg; _sattr = PenAttr; }
public void RestoreCursor() { CursorCol = _scx; CursorRow = _scy; PenFg = _sfg; PenBg = _sbg; PenAttr = _sattr; _wrapPending = false; }
// ── 滾動 ─────────────────────────────────────────────────
public void SetScrollRegion(int top, int bottom)
{
_top = Math.Clamp(top, 0, Rows - 1);
_bottom = Math.Clamp(bottom, _top, Rows - 1);
MoveCursor(0, 0);
}
public void ScrollUp(int n)
{
for (int k = 0; k < n; k++)
{
var line = _screen[_top];
if (_top == 0 && !AltActive) PushScroll(line);
for (int r = _top; r < _bottom; r++) _screen[r] = _screen[r + 1];
_screen[_bottom] = BlankLine();
}
}
public void ScrollDown(int n)
{
for (int k = 0; k < n; k++)
{
for (int r = _bottom; r > _top; r--) _screen[r] = _screen[r - 1];
_screen[_top] = BlankLine();
}
}
public void InsertLines(int n)
{
if (CursorRow < _top || CursorRow > _bottom) return;
for (int k = 0; k < n; k++)
{
for (int r = _bottom; r > CursorRow; r--) _screen[r] = _screen[r - 1];
_screen[CursorRow] = BlankLine();
}
}
public void DeleteLines(int n)
{
if (CursorRow < _top || CursorRow > _bottom) return;
for (int k = 0; k < n; k++)
{
for (int r = CursorRow; r < _bottom; r++) _screen[r] = _screen[r + 1];
_screen[_bottom] = BlankLine();
}
}
// ── 行內字元操作 ─────────────────────────────────────────
public void InsertChars(int n)
{
var row = _screen[CursorRow];
for (int c = Cols - 1; c >= CursorCol + n; c--) row[c] = row[c - n];
for (int c = CursorCol; c < Math.Min(Cols, CursorCol + n); c++) row[c] = BlankPen();
}
public void DeleteChars(int n)
{
var row = _screen[CursorRow];
for (int c = CursorCol; c < Cols; c++) row[c] = (c + n < Cols) ? row[c + n] : BlankPen();
}
public void EraseChars(int n)
{
var row = _screen[CursorRow];
for (int c = CursorCol; c < Math.Min(Cols, CursorCol + n); c++) row[c] = BlankPen();
}
// ── 清除 ─────────────────────────────────────────────────
public void EraseInLine(int mode)
{
var row = _screen[CursorRow];
int from = mode == 1 ? 0 : CursorCol;
int to = mode == 0 ? Cols - 1 : (mode == 1 ? CursorCol : Cols - 1);
for (int c = from; c <= to; c++) row[c] = BlankPen();
}
public void EraseInDisplay(int mode)
{
if (mode == 3) { _scrollback.Clear(); return; }
if (mode == 2) { for (int r = 0; r < Rows; r++) _screen[r] = BlankLine(); return; }
if (mode == 0)
{
EraseInLine(0);
for (int r = CursorRow + 1; r < Rows; r++) _screen[r] = BlankLine();
}
else // mode 1
{
EraseInLine(1);
for (int r = 0; r < CursorRow; r++) _screen[r] = BlankLine();
}
}
// ── alt screen ───────────────────────────────────────────
public void EnterAlt()
{
if (AltActive) return;
_mainScreen = _screen;
_screen = NewGrid(Rows, Cols);
AltActive = true;
_top = 0; _bottom = Rows - 1;
MoveCursor(0, 0);
}
public void ExitAlt()
{
if (!AltActive || _mainScreen == null) return;
_screen = _mainScreen;
_mainScreen = null;
AltActive = false;
_top = 0; _bottom = Rows - 1;
}
public void ResetAttrs() { PenFg = DefaultFg; PenBg = DefaultBg; PenAttr = CellAttr.None; }
// ── resize(簡單 reflow:保留左上重疊區)──────────────────
public void Resize(int cols, int rows)
{
cols = Math.Max(1, cols); rows = Math.Max(1, rows);
if (cols == Cols && rows == Rows) return;
var ng = new Cell[rows][];
for (int r = 0; r < rows; r++)
{
ng[r] = new Cell[cols];
for (int c = 0; c < cols; c++)
ng[r][c] = (r < Rows && c < Cols) ? _screen[r][c] : BlankDefault();
}
_screen = ng;
Rows = rows; Cols = cols;
_top = 0; _bottom = Rows - 1;
CursorRow = Math.Clamp(CursorRow, 0, Rows - 1);
CursorCol = Math.Clamp(CursorCol, 0, Cols - 1);
_wrapPending = false;
_mainScreen = null;
if (AltActive) { /* alt 下 resize:重建 alt 畫面 */ }
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Text;
using System.Windows.Forms;
namespace ETTerms.Terminal;
/// <summary>把特殊鍵(方向鍵 / Fn / Home/End… / Enter/Tab/Backspace/Esc)對應成終端機 byte 序列。
/// 一般可列印字元回傳 null,交給 KeyPress 處理。</summary>
public static class TerminalInput
{
public static byte[]? Map(KeyEventArgs e, bool appCursor)
{
string? seq = e.KeyCode switch
{
Keys.Up => appCursor ? "\x1bOA" : "\x1b[A",
Keys.Down => appCursor ? "\x1bOB" : "\x1b[B",
Keys.Right => appCursor ? "\x1bOC" : "\x1b[C",
Keys.Left => appCursor ? "\x1bOD" : "\x1b[D",
Keys.Home => "\x1b[H",
Keys.End => "\x1b[F",
Keys.Insert => "\x1b[2~",
Keys.Delete => "\x1b[3~",
Keys.PageUp => "\x1b[5~",
Keys.PageDown => "\x1b[6~",
Keys.Enter => "\r",
Keys.Tab => "\t",
Keys.Escape => "\x1b",
Keys.Back => "\x7f",
Keys.F1 => "\x1bOP",
Keys.F2 => "\x1bOQ",
Keys.F3 => "\x1bOR",
Keys.F4 => "\x1bOS",
Keys.F5 => "\x1b[15~",
Keys.F6 => "\x1b[17~",
Keys.F7 => "\x1b[18~",
Keys.F8 => "\x1b[19~",
Keys.F9 => "\x1b[20~",
Keys.F10 => "\x1b[21~",
Keys.F11 => "\x1b[23~",
Keys.F12 => "\x1b[24~",
_ => null
};
return seq is null ? null : Encoding.ASCII.GetBytes(seq);
}
}
+13
View File
@@ -0,0 +1,13 @@
using ETTerms.Infrastructure;
namespace ETTerms.Terminal;
/// <summary>終端機顯示偏好,從 AppSettings 載入。</summary>
public sealed class TerminalProfile
{
public string FontFamily { get; set; } = AppSettings.Instance.FontFamily;
public float FontSize { get; set; } = AppSettings.Instance.FontSize;
public int Cols { get; set; } = 80;
public int Rows { get; set; } = 24;
public int ScrollbackLines { get; set; } = AppSettings.Instance.ScrollbackLines;
}
+242
View File
@@ -0,0 +1,242 @@
using System.Drawing;
using System.Text;
using System.Windows.Forms;
namespace ETTerms.Terminal;
/// <summary>
/// 自繪 VT100 終端機控制項:bytes → AnsiParser → ScreenBuffer → 雙緩衝繪製。
/// 支援 scrollback(滑鼠滾輪)、選取 / 複製(Ctrl+C)/ 貼上(Ctrl+V、右鍵、Shift+Insert)、resize 通知。
/// </summary>
public sealed class TerminalView : UserControl
{
public event Action<byte[]>? SendData; // 鍵盤 / 貼上 → channel
public event Action<int, int>? Resized; // cols, rowsPTY size
private readonly ScreenBuffer _buf;
private readonly AnsiParser _parser;
private readonly Font _font;
private int _cellW, _cellH;
private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback
private int _lastCols = -1, _lastRows = -1;
// 選取(以絕對行 abs、欄 col 表示)
private bool _selecting;
private (int row, int col) _selStart, _selEnd;
private bool _hasSel;
public TerminalView(TerminalProfile profile)
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Color.FromArgb(18, 18, 22);
_font = new Font(profile.FontFamily, profile.FontSize);
using (var g = CreateGraphics())
{
var sz = TextRenderer.MeasureText(g, "W", _font, Size.Empty, TextFormatFlags.NoPadding);
_cellW = Math.Max(1, sz.Width);
_cellH = Math.Max(1, _font.Height);
}
_buf = new ScreenBuffer(profile.Cols, profile.Rows,
Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines);
_parser = new AnsiParser(_buf);
}
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
public void Feed(byte[] data)
{
_parser.Feed(data);
_scrollOffset = 0; // 新輸出貼底
Invalidate();
}
private int VisibleRows => Math.Max(1, ClientSize.Height / _cellH);
private int VisibleCols => Math.Max(1, ClientSize.Width / _cellW);
// ── resize → 通知 PTY ────────────────────────────────────
protected override void OnSizeChanged(EventArgs e)
{
base.OnSizeChanged(e);
int cols = VisibleCols, rows = VisibleRows;
if (cols == _lastCols && rows == _lastRows) return;
_lastCols = cols; _lastRows = rows;
_buf.Resize(cols, rows);
Resized?.Invoke(cols, rows);
Invalidate();
}
// ── 繪製 ─────────────────────────────────────────────────
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.Clear(BackColor);
int rows = VisibleRows;
int top = _buf.ScrollbackCount - _scrollOffset; // 視窗第一列的絕對 index
if (top < 0) top = 0;
for (int vr = 0; vr < rows; vr++)
{
int abs = top + vr;
if (abs >= _buf.TotalRows) break;
DrawLine(g, _buf.LineAt(abs), vr, abs);
}
DrawCursor(g, top, rows);
}
private void DrawLine(Graphics g, Cell[] line, int vr, int abs)
{
int y = vr * _cellH;
int c = 0;
while (c < line.Length)
{
var cell = line[c];
ResolveColors(cell, out var fg, out var bg, abs, c);
// 合併同屬性連續格
int start = c;
var sb = new StringBuilder();
while (c < line.Length)
{
var cur = line[c];
ResolveColors(cur, out var f2, out var b2, abs, c);
if (f2 != fg || b2 != bg || (cur.Attr & CellAttr.Bold) != (cell.Attr & CellAttr.Bold)) break;
sb.Append(cur.Ch == '\0' ? ' ' : cur.Ch);
c++;
}
var rect = new Rectangle(start * _cellW, y, (c - start) * _cellW, _cellH);
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, rect);
var style = (cell.Attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular;
if ((cell.Attr & CellAttr.Underline) != 0) style |= FontStyle.Underline;
using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style);
TextRenderer.DrawText(g, sb.ToString(), fnt, rect, fg,
TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left);
}
}
private void ResolveColors(Cell cell, out Color fg, out Color bg, int abs, int col)
{
fg = cell.Fg.A == 0 ? _buf.DefaultFg : cell.Fg;
bg = cell.Bg.A == 0 ? _buf.DefaultBg : cell.Bg;
if ((cell.Attr & CellAttr.Inverse) != 0) (fg, bg) = (bg, fg);
if (_hasSel && InSelection(abs, col)) (fg, bg) = (bg, Color.FromArgb(70, 90, 140));
}
private void DrawCursor(Graphics g, int top, int rows)
{
if (!_buf.CursorVisible || _scrollOffset != 0 || !Focused) return;
int vr = (_buf.ScrollbackCount + _buf.CursorRow) - top;
if (vr < 0 || vr >= rows) return;
var rect = new Rectangle(_buf.CursorCol * _cellW, vr * _cellH, _cellW, _cellH);
using var b = new SolidBrush(Color.FromArgb(160, 200, 200, 200));
g.FillRectangle(b, rect);
}
// ── 鍵盤 ─────────────────────────────────────────────────
protected override bool IsInputKey(Keys keyData) => true; // 攔截方向鍵 / Tab
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Control && e.KeyCode == Keys.C && _hasSel) { CopySelection(); e.Handled = e.SuppressKeyPress = true; return; }
if ((e.Control && e.KeyCode == Keys.V) || (e.Shift && e.KeyCode == Keys.Insert)) { Paste(); e.Handled = e.SuppressKeyPress = true; return; }
var bytes = TerminalInput.Map(e, _parser.AppCursorKeys);
if (bytes != null) { SendData?.Invoke(bytes); e.Handled = e.SuppressKeyPress = true; }
base.OnKeyDown(e);
}
protected override void OnKeyPress(KeyPressEventArgs e)
{
if (!char.IsControl(e.KeyChar) || e.KeyChar is '\r' or '\t' or '\b' or '\x1b')
{
// \r,\t,\b,\x1b 已由 OnKeyDown 送出,這裡只送一般可列印字元與 Ctrl 組合碼
}
if (e.KeyChar >= ' ' && e.KeyChar != '\x7f')
SendData?.Invoke(Encoding.UTF8.GetBytes(e.KeyChar.ToString()));
else if (char.IsControl(e.KeyChar) && e.KeyChar is not ('\r' or '\t' or '\b' or '\x1b'))
SendData?.Invoke(new[] { (byte)e.KeyChar }); // Ctrl+letter 控制碼
e.Handled = true;
}
// ── 滑鼠:scrollback / 選取 / 貼上 ───────────────────────
protected override void OnMouseWheel(MouseEventArgs e)
{
int delta = e.Delta / 120 * 3;
_scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount);
Invalidate();
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.Button == MouseButtons.Left)
{
_selecting = true; _hasSel = false;
_selStart = _selEnd = HitCell(e.Location);
Invalidate();
}
else if (e.Button == MouseButtons.Right)
{
if (_hasSel) CopySelection(); else Paste();
}
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (!_selecting) return;
_selEnd = HitCell(e.Location);
_hasSel = _selStart != _selEnd;
Invalidate();
}
protected override void OnMouseUp(MouseEventArgs e) => _selecting = false;
private (int row, int col) HitCell(Point p)
{
int top = _buf.ScrollbackCount - _scrollOffset;
int row = top + Math.Clamp(p.Y / _cellH, 0, VisibleRows - 1);
int col = Math.Clamp(p.X / _cellW, 0, _buf.Cols);
return (row, col);
}
private bool InSelection(int abs, int col)
{
var (a, b) = Ordered();
if (abs < a.row || abs > b.row) return false;
if (abs == a.row && col < a.col) return false;
if (abs == b.row && col >= b.col) return false;
return true;
}
private ((int row, int col) a, (int row, int col) b) Ordered()
{
var s = _selStart; var e = _selEnd;
bool sFirst = s.row < e.row || (s.row == e.row && s.col <= e.col);
return sFirst ? (s, e) : (e, s);
}
private void CopySelection()
{
var (a, b) = Ordered();
var sb = new StringBuilder();
for (int abs = a.row; abs <= b.row && abs < _buf.TotalRows; abs++)
{
var line = _buf.LineAt(abs);
int from = abs == a.row ? a.col : 0;
int to = abs == b.row ? b.col : line.Length;
for (int c = from; c < Math.Min(to, line.Length); c++)
sb.Append(line[c].Ch == '\0' ? ' ' : line[c].Ch);
if (abs < b.row) sb.Append("\r\n");
}
var text = sb.ToString();
if (text.Length > 0) { try { Clipboard.SetText(text); } catch { } }
}
private void Paste()
{
try
{
if (Clipboard.ContainsText())
SendData?.Invoke(Encoding.UTF8.GetBytes(Clipboard.GetText().Replace("\r\n", "\r")));
}
catch { }
}
}