refactor: split termapp into proper internal packages

Replace monolithic internal/termapp with proper separation:
- internal/app: App struct, lifecycle, output loops
- internal/command: CommandHost interface, Dispatcher, handlers
- internal/tui: Model, hotkeys, panels, render (with panelError + border fixes)
- internal/console: RunConsole, escape parsing, entry point logic
- cmd/serialterminal: thin main() calling console.Run()

Eliminate global vars (cfg, sess, out) via dependency injection.
Break App->CommandDispatcher cycle via CommandHost interface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
JiXieShi
2026-05-23 22:46:02 +08:00
parent daad844d4f
commit d8fc9d7374
22 changed files with 859 additions and 2080 deletions
+227
View File
@@ -0,0 +1,227 @@
package command
import (
"fmt"
"strconv"
"strings"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
)
func (d *Dispatcher) handleForwardCommand(args []string) error {
if len(args) < 2 {
if d.host.UIEnabled() {
d.host.OpenPanel(event.UIPanelForward)
return nil
}
args = []string{".forward", "list"}
}
sub := strings.ToLower(args[1])
switch sub {
case "list", "stats":
if d.host.UIEnabled() {
d.host.OpenPanel(event.UIPanelForward)
return nil
}
items := d.host.Forward().List()
if len(items) == 0 {
d.host.Notifyf("[forward] empty")
return nil
}
d.host.Notifyf("[forward] ID Mode Enabled Connected Address InBytes OutBytes LastError")
for _, it := range items {
d.host.Notifyf("[forward] %d %s %v %v %s %d %d %s", it.ID, it.Mode, it.Enabled, it.Connected, it.Address, it.ReadBytes, it.WriteByte, it.LastError)
}
return nil
case "add":
if len(args) < 4 {
return fmt.Errorf("usage: .forward add <tcp|udp> <address>")
}
mode, ok := forward.ParseMode(args[2])
if !ok {
return fmt.Errorf("unknown forward mode: %s", args[2])
}
id, err := d.host.Forward().Add(mode, args[3])
if err != nil {
return err
}
d.host.Statusf("[forward] added #%d", id)
return nil
case "remove", "enable", "disable":
if len(args) < 3 {
return fmt.Errorf("usage: .forward %s <id>", sub)
}
id, err := strconv.Atoi(args[2])
if err != nil {
return err
}
switch sub {
case "remove":
return d.host.Forward().Remove(id)
case "enable":
return d.host.Forward().Enable(id)
case "disable":
return d.host.Forward().Disable(id)
}
case "update":
if len(args) < 5 {
return fmt.Errorf("usage: .forward update <id> <tcp|udp> <address>")
}
id, err := strconv.Atoi(args[2])
if err != nil {
return err
}
mode, ok := forward.ParseMode(args[3])
if !ok {
return fmt.Errorf("unknown forward mode: %s", args[3])
}
if err = d.host.Forward().Update(id, mode, args[4]); err != nil {
return err
}
d.host.Statusf("[forward] updated #%d", id)
return nil
}
return fmt.Errorf("unknown subcommand: %s", sub)
}
func (d *Dispatcher) handlePluginCommand(args []string) error {
if len(args) < 2 {
if d.host.UIEnabled() {
d.host.OpenPanel(event.UIPanelPlugin)
return nil
}
args = []string{".plugin", "list"}
}
sub := strings.ToLower(args[1])
switch sub {
case "list":
if d.host.UIEnabled() {
d.host.OpenPanel(event.UIPanelPlugin)
return nil
}
items := d.host.Plugins().List()
if len(items) == 0 {
d.host.Notifyf("[plugin] empty")
return nil
}
for _, it := range items {
d.host.Notifyf("[plugin] %s enabled=%v path=%s", it.Name, it.Enabled, it.Path)
}
return nil
case "load":
if len(args) < 3 {
return fmt.Errorf("usage: .plugin load <path>")
}
name, err := d.host.Plugins().Load(args[2])
if err != nil {
return err
}
d.host.Statusf("[plugin] loaded %s", name)
return nil
case "unload", "enable", "disable", "reload":
if len(args) < 3 {
return fmt.Errorf("usage: .plugin %s <name>", sub)
}
name := args[2]
switch sub {
case "unload":
return d.host.Plugins().Unload(name)
case "enable":
return d.host.Plugins().Enable(name)
case "disable":
return d.host.Plugins().Disable(name)
case "reload":
return d.host.Plugins().Reload(name)
}
}
return fmt.Errorf("unknown subcommand: %s", sub)
}
func (d *Dispatcher) handleModeCommand(args []string) error {
if len(args) < 2 || strings.EqualFold(args[1], "show") {
if d.host.UIEnabled() {
d.host.OpenPanel(event.UIPanelMode)
return nil
}
cfg := d.host.Cfg()
d.host.Notifyf("[mode] input=%s output=%s end=%q hex=%v frame=%d timestamp=%v timefmt=%q forwardTargets=%d plugins=%d",
cfg.InputCode, cfg.OutputCode, cfg.EndStr,
strings.EqualFold(cfg.InputCode, "hex"),
cfg.FrameSize, cfg.TimesTamp, cfg.TimesFmt,
len(d.host.Forward().List()), len(d.host.Plugins().List()),
)
return nil
}
if !strings.EqualFold(args[1], "set") {
return fmt.Errorf("usage: .mode <show|set>")
}
if len(args) < 4 {
return fmt.Errorf("usage: .mode set <in|out|end|frame|timestamp|timefmt> <value>")
}
field := strings.ToLower(args[2])
value := strings.Join(args[3:], " ")
cfg := d.host.Cfg()
switch field {
case "in":
if value == "" {
return fmt.Errorf("input charset must not be empty")
}
cfg.InputCode = value
case "out":
if value == "" {
return fmt.Errorf("output charset must not be empty")
}
cfg.OutputCode = value
case "end":
cfg.EndStr = value
case "frame":
n, err := strconv.Atoi(value)
if err != nil || n <= 0 {
return fmt.Errorf("frame must be a positive integer")
}
cfg.FrameSize = n
case "timestamp":
enabled, ok := parseOnOff(value)
if !ok {
return fmt.Errorf("timestamp value must be on/off")
}
cfg.TimesTamp = enabled
case "timefmt":
if value == "" && cfg.TimesTamp {
return fmt.Errorf("timestamp format must not be empty")
}
cfg.TimesFmt = value
default:
return fmt.Errorf("unknown mode field: %s", field)
}
d.host.Statusf("[mode] %s=%q", field, value)
return nil
}
func parseOnOff(v string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "on", "true", "1", "yes":
return true, true
case "off", "false", "0", "no":
return false, true
default:
return false, false
}
}
+67
View File
@@ -0,0 +1,67 @@
package command
import "strings"
func completeFirstToken(line, token string, cands []string) (string, []string) {
matches := filterPrefix(cands, token)
if len(matches) == 0 {
return line, nil
}
if len(matches) == 1 {
prefix := strings.TrimSuffix(line, token)
return prefix + matches[0] + " ", matches
}
return line, matches
}
func filterPrefix(cands []string, cur string) []string {
if cur == "" {
return append([]string{}, cands...)
}
res := make([]string, 0, len(cands))
for _, c := range cands {
if strings.HasPrefix(strings.ToLower(c), strings.ToLower(cur)) {
res = append(res, c)
}
}
return res
}
func completeForward(args []string) []string {
if len(args) <= 2 {
return []string{"list", "add", "remove", "enable", "disable", "update", "stats"}
}
if len(args) == 3 && args[1] == "add" {
return []string{"tcp", "udp"}
}
if len(args) == 4 && args[1] == "update" {
return []string{"tcp", "udp"}
}
return nil
}
func completePlugin(args []string) []string {
if len(args) <= 2 {
return []string{"list", "load", "unload", "enable", "disable", "reload"}
}
return nil
}
func completeMode(args []string) []string {
if len(args) <= 2 {
return []string{"show", "set"}
}
if len(args) == 3 && args[1] == "set" {
return []string{"in", "out", "end", "frame", "timestamp", "timefmt"}
}
if len(args) == 4 && args[1] == "set" && args[2] == "timestamp" {
return []string{"on", "off"}
}
return nil
}
+217
View File
@@ -0,0 +1,217 @@
package command
import (
"encoding/hex"
"fmt"
"sort"
"strings"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
)
// CommandHost is the minimal interface the command dispatcher needs from its host.
type CommandHost interface {
Close()
Notifyf(format string, args ...any)
Statusf(format string, args ...any)
ShowModal(title, text string)
OpenPanel(panel event.UIPanelKind)
UIEnabled() bool
WriteToSession(data []byte) error
Forward() *forward.Manager
Plugins() *luaplugin.Manager
Cfg() *config.Config
}
type CommandHandler func(args []string) error
type CommandCompleter func(args []string) []string
type RuntimeCommand struct {
Name string
Usage string
Description string
Handler CommandHandler
Completer CommandCompleter
}
type Dispatcher struct {
host CommandHost
commands map[string]*RuntimeCommand
order []string
}
func NewDispatcher(host CommandHost) *Dispatcher {
d := &Dispatcher{
host: host,
commands: make(map[string]*RuntimeCommand),
}
d.registerAll()
return d
}
func (d *Dispatcher) register(cmd RuntimeCommand) {
key := strings.ToLower(cmd.Name)
d.commands[key] = &cmd
d.order = append(d.order, key)
}
func (d *Dispatcher) registerAll() {
d.register(RuntimeCommand{
Name: ".help",
Usage: ".help",
Description: "show command help",
Handler: func(args []string) error {
d.host.ShowModal("Command Help", d.HelpText())
return nil
},
})
d.register(RuntimeCommand{
Name: ".exit",
Usage: ".exit",
Description: "exit local terminal",
Handler: func(args []string) error {
d.host.Statusf("[local] exiting")
d.host.Close()
return nil
},
})
d.register(RuntimeCommand{
Name: ".hex",
Usage: ".hex <hex-data>",
Description: "send raw hex bytes",
Handler: func(args []string) error {
if len(args) < 2 {
return fmt.Errorf("usage: .hex <hex-data>")
}
hexStr := strings.Join(args[1:], "")
b, err := hex.DecodeString(hexStr)
if err != nil {
return err
}
return d.host.WriteToSession(b)
},
})
d.register(RuntimeCommand{
Name: ".forward",
Usage: ".forward <list|add|remove|enable|disable|update|stats>",
Description: "manage forwarding at runtime",
Handler: d.handleForwardCommand,
Completer: completeForward,
})
d.register(RuntimeCommand{
Name: ".plugin",
Usage: ".plugin <list|load|unload|enable|disable|reload>",
Description: "manage lua plugins",
Handler: d.handlePluginCommand,
Completer: completePlugin,
})
d.register(RuntimeCommand{
Name: ".mode",
Usage: ".mode <show|set>",
Description: "show or update runtime terminal mode",
Handler: func(args []string) error {
return d.handleModeCommand(args)
},
Completer: completeMode,
})
}
func (d *Dispatcher) Execute(line string) (bool, error) {
args := strings.Fields(strings.TrimSpace(line))
if len(args) == 0 {
return false, nil
}
if !strings.HasPrefix(args[0], ".") {
return false, nil
}
cmd, ok := d.commands[strings.ToLower(args[0])]
if !ok {
return true, fmt.Errorf("unknown command: %s", args[0])
}
if err := cmd.Handler(args); err != nil {
return true, err
}
return true, nil
}
func (d *Dispatcher) HelpText() string {
keys := make([]string, 0, len(d.order))
for _, k := range d.order {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("Commands:\n")
for _, k := range keys {
cmd := d.commands[k]
b.WriteString(fmt.Sprintf(" %-12s %-40s %s\n", cmd.Name, cmd.Usage, cmd.Description))
}
return b.String()
}
func (d *Dispatcher) Complete(line string) (string, []string) {
trimmed := strings.TrimLeft(line, " ")
if trimmed == "" {
return line, nil
}
args := strings.Fields(trimmed)
endsWithSpace := strings.HasSuffix(line, " ")
if len(args) == 0 {
return line, nil
}
if len(args) == 1 && !endsWithSpace {
return completeFirstToken(line, args[0], d.commandNames())
}
cmdName := strings.ToLower(args[0])
cmd, ok := d.commands[cmdName]
if !ok || cmd.Completer == nil {
return line, nil
}
compArgs := args
if endsWithSpace {
compArgs = append(compArgs, "")
}
cands := cmd.Completer(compArgs)
if len(cands) == 0 {
return line, nil
}
current := compArgs[len(compArgs)-1]
base := strings.TrimSuffix(line, current)
matches := filterPrefix(cands, current)
if len(matches) == 0 {
matches = cands
}
if len(matches) == 1 {
return base + matches[0], matches
}
return line, matches
}
func (d *Dispatcher) commandNames() []string {
names := make([]string, 0, len(d.commands))
for _, cmd := range d.commands {
names = append(names, cmd.Name)
}
sort.Strings(names)
return names
}