mirror of
https://github.com/jixishi/SerialTerminalForWindowsTerminal.git
synced 2026-06-15 16:42:46 +00:00
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:
@@ -1,7 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/termapp"
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/console"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile | log.Lmsgprefix)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
termapp.Run()
|
console.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
package termapp
|
// Package app provides the core application coordinator.
|
||||||
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -13,16 +14,23 @@ import (
|
|||||||
|
|
||||||
appconfig "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config"
|
appconfig "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
||||||
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/charset"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/charset"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
||||||
|
|
||||||
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/command"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// App is the central coordinator for the serial terminal application.
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg *Config
|
cfg *appconfig.Config
|
||||||
|
sess *session.SerialSession
|
||||||
|
out io.Writer
|
||||||
|
|
||||||
forward *forward.Manager
|
forward *forward.Manager
|
||||||
plugins *luaplugin.Manager
|
plugins *luaplugin.Manager
|
||||||
dispatcher *CommandDispatcher
|
dispatcher *command.Dispatcher
|
||||||
|
|
||||||
uiEvents chan event.UIEvent
|
uiEvents chan event.UIEvent
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
@@ -35,7 +43,10 @@ type App struct {
|
|||||||
logFile *os.File
|
logFile *os.File
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(cfg *Config) (*App, error) {
|
var _ command.CommandHost = (*App)(nil)
|
||||||
|
|
||||||
|
// New creates a new App with the given configuration, session, and output writer.
|
||||||
|
func New(cfg *appconfig.Config, sess *session.SerialSession, out io.Writer) (*App, error) {
|
||||||
f, err := appconfig.OpenLogFile(cfg)
|
f, err := appconfig.OpenLogFile(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -43,6 +54,8 @@ func NewApp(cfg *Config) (*App, error) {
|
|||||||
|
|
||||||
a := &App{
|
a := &App{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
sess: sess,
|
||||||
|
out: out,
|
||||||
plugins: luaplugin.NewManager(),
|
plugins: luaplugin.NewManager(),
|
||||||
uiEvents: make(chan event.UIEvent, 512),
|
uiEvents: make(chan event.UIEvent, 512),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
@@ -52,13 +65,32 @@ func NewApp(cfg *Config) (*App, error) {
|
|||||||
|
|
||||||
a.forward = forward.NewManager(a.writeRawToSession, a.Notifyf)
|
a.forward = forward.NewManager(a.writeRawToSession, a.Notifyf)
|
||||||
a.forward.SetInboundReporter(a.reportForwardIngress)
|
a.forward.SetInboundReporter(a.reportForwardIngress)
|
||||||
a.dispatcher = NewCommandDispatcher(a)
|
a.dispatcher = command.NewDispatcher(a)
|
||||||
if err = a.loadDefaultDemoPlugin(); err != nil {
|
if err = a.loadDefaultDemoPlugin(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- command.CommandHost implementation ---
|
||||||
|
|
||||||
|
func (a *App) Cfg() *appconfig.Config { return a.cfg }
|
||||||
|
func (a *App) Forward() *forward.Manager { return a.forward }
|
||||||
|
func (a *App) Plugins() *luaplugin.Manager { return a.plugins }
|
||||||
|
func (a *App) WriteToSession(data []byte) error { return a.writeToSession(data) }
|
||||||
|
|
||||||
|
// --- exported accessors for TUI / console ---
|
||||||
|
|
||||||
|
func (a *App) UIEvents() <-chan event.UIEvent { return a.uiEvents }
|
||||||
|
func (a *App) WaitDone() <-chan struct{} { return a.done }
|
||||||
|
func (a *App) SendCtrl(letter byte) error { return a.sendCtrl(letter) }
|
||||||
|
func (a *App) HandleLine(line string) { a.handleLine(line) }
|
||||||
|
func (a *App) Dispatcher() *command.Dispatcher { return a.dispatcher }
|
||||||
|
func (a *App) StartOutputLoop() { a.startOutputLoop() }
|
||||||
|
func (a *App) LoadConfiguredForwards() { a.loadConfiguredForwards() }
|
||||||
|
func (a *App) Sess() *session.SerialSession { return a.sess }
|
||||||
|
func (a *App) Out() io.Writer { return a.out }
|
||||||
|
|
||||||
func (a *App) loadDefaultDemoPlugin() error {
|
func (a *App) loadDefaultDemoPlugin() error {
|
||||||
demoPath := filepath.Join("plugins", "demo.lua")
|
demoPath := filepath.Join("plugins", "demo.lua")
|
||||||
if _, err := os.Stat(demoPath); err != nil {
|
if _, err := os.Stat(demoPath); err != nil {
|
||||||
@@ -107,14 +139,14 @@ func (a *App) emit(ev event.UIEvent) {
|
|||||||
if !a.UIEnabled() {
|
if !a.UIEnabled() {
|
||||||
switch ev.Kind {
|
switch ev.Kind {
|
||||||
case event.UIEventOutput:
|
case event.UIEventOutput:
|
||||||
_, _ = io.WriteString(out, ev.Text)
|
_, _ = io.WriteString(a.out, ev.Text)
|
||||||
case event.UIEventStatus:
|
case event.UIEventStatus:
|
||||||
_, _ = io.WriteString(out, ev.Text)
|
_, _ = io.WriteString(a.out, ev.Text)
|
||||||
if !strings.HasSuffix(ev.Text, "\n") {
|
if !strings.HasSuffix(ev.Text, "\n") {
|
||||||
_, _ = io.WriteString(out, "\n")
|
_, _ = io.WriteString(a.out, "\n")
|
||||||
}
|
}
|
||||||
case event.UIEventModal:
|
case event.UIEventModal:
|
||||||
_, _ = io.WriteString(out, "\n["+ev.Title+"]\n"+ev.Text+"\n")
|
_, _ = io.WriteString(a.out, "\n["+ev.Title+"]\n"+ev.Text+"\n")
|
||||||
}
|
}
|
||||||
if ev.Kind == event.UIEventOutput {
|
if ev.Kind == event.UIEventOutput {
|
||||||
a.appendLog(ev.Text)
|
a.appendLog(ev.Text)
|
||||||
@@ -125,7 +157,6 @@ func (a *App) emit(ev event.UIEvent) {
|
|||||||
select {
|
select {
|
||||||
case a.uiEvents <- ev:
|
case a.uiEvents <- ev:
|
||||||
default:
|
default:
|
||||||
// Keep UI responsive; drop oldest when overloaded.
|
|
||||||
select {
|
select {
|
||||||
case <-a.uiEvents:
|
case <-a.uiEvents:
|
||||||
default:
|
default:
|
||||||
@@ -142,32 +173,22 @@ func (a *App) appendLog(text string) {
|
|||||||
if a.logFile == nil {
|
if a.logFile == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = a.logFile.WriteString(text)
|
_, _ = a.logFile.WriteString(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) isClosed() bool {
|
|
||||||
return a.closedFlag.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) Close() {
|
func (a *App) Close() {
|
||||||
a.closeOnce.Do(func() {
|
a.closeOnce.Do(func() {
|
||||||
a.closedFlag.Store(true)
|
a.closedFlag.Store(true)
|
||||||
close(a.done)
|
close(a.done)
|
||||||
a.forward.Close()
|
a.forward.Close()
|
||||||
a.plugins.Close()
|
a.plugins.Close()
|
||||||
sess.Close()
|
a.sess.Close()
|
||||||
|
|
||||||
if a.logFile != nil {
|
if a.logFile != nil {
|
||||||
_ = a.logFile.Close()
|
_ = a.logFile.Close()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) waitDone() <-chan struct{} {
|
|
||||||
return a.done
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) loadConfiguredForwards() {
|
func (a *App) loadConfiguredForwards() {
|
||||||
for i, mode := range a.cfg.ForWard {
|
for i, mode := range a.cfg.ForWard {
|
||||||
m := forward.Mode(mode)
|
m := forward.Mode(mode)
|
||||||
@@ -192,12 +213,10 @@ func (a *App) reportForwardIngress(id int, chunk []byte) {
|
|||||||
if len(chunk) == 0 {
|
if len(chunk) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.EqualFold(a.cfg.InputCode, "hex") {
|
if strings.EqualFold(a.cfg.InputCode, "hex") {
|
||||||
a.Notifyf("[forward#%d -> serial] % X\n", id, chunk)
|
a.Notifyf("[forward#%d -> serial] % X\n", id, chunk)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
converted, err := charset.ConvertChunk(chunk, a.cfg.InputCode, a.cfg.OutputCode)
|
converted, err := charset.ConvertChunk(chunk, a.cfg.InputCode, a.cfg.OutputCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
converted = bytes.Clone(chunk)
|
converted = bytes.Clone(chunk)
|
||||||
@@ -213,10 +232,9 @@ func (a *App) writeRawToSession(data []byte) error {
|
|||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
a.stdinMu.Lock()
|
a.stdinMu.Lock()
|
||||||
defer a.stdinMu.Unlock()
|
defer a.stdinMu.Unlock()
|
||||||
_, err := sess.StdinPipe.Write(data)
|
_, err := a.sess.StdinPipe.Write(data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +246,6 @@ func (a *App) writeToSession(data []byte) error {
|
|||||||
if len(processed) == 0 {
|
if len(processed) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.writeRawToSession(processed)
|
return a.writeRawToSession(processed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +253,6 @@ func (a *App) sendLine(line string) error {
|
|||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := append([]byte(line), []byte(a.cfg.EndStr)...)
|
payload := append([]byte(line), []byte(a.cfg.EndStr)...)
|
||||||
return a.writeToSession(payload)
|
return a.writeToSession(payload)
|
||||||
}
|
}
|
||||||
@@ -246,7 +262,7 @@ func (a *App) sendCtrl(letter byte) error {
|
|||||||
letter = letter + ('a' - 'A')
|
letter = letter + ('a' - 'A')
|
||||||
}
|
}
|
||||||
control := []byte{letter & 0x1f}
|
control := []byte{letter & 0x1f}
|
||||||
_, err := sess.Port.Write(control)
|
_, err := a.sess.Port.Write(control)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +304,6 @@ func (a *App) startOutputLoop() {
|
|||||||
go a.readHexOutput()
|
go a.readHexOutput()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go a.readTextOutput()
|
go a.readTextOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +315,7 @@ func (a *App) readHexOutput() {
|
|||||||
|
|
||||||
buf := make([]byte, frameSize)
|
buf := make([]byte, frameSize)
|
||||||
for {
|
for {
|
||||||
n, err := sess.StdoutPipe.Read(buf)
|
n, err := a.sess.StdoutPipe.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
chunk := make([]byte, n)
|
chunk := make([]byte, n)
|
||||||
copy(chunk, buf[:n])
|
copy(chunk, buf[:n])
|
||||||
@@ -333,7 +348,7 @@ func (a *App) readHexOutput() {
|
|||||||
func (a *App) readTextOutput() {
|
func (a *App) readTextOutput() {
|
||||||
buf := make([]byte, 4096)
|
buf := make([]byte, 4096)
|
||||||
for {
|
for {
|
||||||
n, err := sess.StdoutPipe.Read(buf)
|
n, err := a.sess.StdoutPipe.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
chunk := make([]byte, n)
|
chunk := make([]byte, n)
|
||||||
copy(chunk, buf[:n])
|
copy(chunk, buf[:n])
|
||||||
@@ -379,7 +394,6 @@ func prefixLines(s, prefix string) string {
|
|||||||
if s == "" || prefix == "" {
|
if s == "" || prefix == "" {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.SplitAfter(s, "\n")
|
lines := strings.SplitAfter(s, "\n")
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,26 +1,25 @@
|
|||||||
package termapp
|
// Package console provides the non-TUI console mode.
|
||||||
|
package console
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
apppkg "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app"
|
||||||
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/flag"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/flag"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session"
|
||||||
"golang.org/x/term"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
// Run parses flags, sets up the session and app, then runs TUI or console mode.
|
||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile | log.Lmsgprefix)
|
|
||||||
flag.Init(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Run() {
|
func Run() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
@@ -29,12 +28,15 @@ func Run() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
flag.Init(cfg)
|
||||||
flag.Normalize()
|
flag.Normalize()
|
||||||
pflag.Parse()
|
flag.Parse()
|
||||||
flag.Ext(cfg)
|
flag.Ext(cfg)
|
||||||
if cfg.PortName == "" {
|
if cfg.PortName == "" {
|
||||||
flag.GetCliFlag(cfg)
|
flag.GetCliFlag(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
ports, err := session.CheckPortAvailability(cfg.PortName)
|
ports, err := session.CheckPortAvailability(cfg.PortName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
@@ -42,27 +44,27 @@ func Run() {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err = session.Open(cfg)
|
sess, err := session.Open(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "open session failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "open session failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := NewApp(cfg)
|
appInst, err := apppkg.New(cfg, sess, os.Stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "create app failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "create app failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer app.Close()
|
defer appInst.Close()
|
||||||
|
|
||||||
app.loadConfiguredForwards()
|
appInst.LoadConfiguredForwards()
|
||||||
app.startOutputLoop()
|
appInst.StartOutputLoop()
|
||||||
|
|
||||||
go forwardInterruptToRemote(app)
|
go forwardInterruptToRemote(appInst)
|
||||||
app.SetUIEnabled(cfg.EnableGUI)
|
appInst.SetUIEnabled(cfg.EnableGUI)
|
||||||
|
|
||||||
if cfg.EnableGUI {
|
if cfg.EnableGUI {
|
||||||
model := newUIModel(app)
|
model := tui.New(appInst)
|
||||||
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithoutSignalHandler())
|
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithoutSignalHandler())
|
||||||
if _, err = p.Run(); err != nil {
|
if _, err = p.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "tui failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "tui failed: %v\n", err)
|
||||||
@@ -71,32 +73,33 @@ func Run() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = runConsole(app); err != nil {
|
if err = RunConsole(appInst); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "console failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "console failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func forwardInterruptToRemote(app *App) {
|
func forwardInterruptToRemote(appInst *apppkg.App) {
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, os.Interrupt)
|
signal.Notify(sigCh, os.Interrupt)
|
||||||
defer signal.Stop(sigCh)
|
defer signal.Stop(sigCh)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-app.waitDone():
|
case <-appInst.WaitDone():
|
||||||
return
|
return
|
||||||
case <-sigCh:
|
case <-sigCh:
|
||||||
if err := app.sendCtrl('c'); err != nil {
|
if err := appInst.SendCtrl('c'); err != nil {
|
||||||
app.Notifyf("[signal] interrupt pass-through failed: %v", err)
|
appInst.Notifyf("[signal] interrupt pass-through failed: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
app.Notifyf("[signal] Ctrl+C forwarded to remote")
|
appInst.Notifyf("[signal] Ctrl+C forwarded to remote")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runConsole(app *App) error {
|
// RunConsole runs the non-TUI console mode.
|
||||||
|
func RunConsole(appInst *apppkg.App) error {
|
||||||
fd := int(os.Stdin.Fd())
|
fd := int(os.Stdin.Fd())
|
||||||
isTerm := term.IsTerminal(fd)
|
isTerm := term.IsTerminal(fd)
|
||||||
var oldState *term.State
|
var oldState *term.State
|
||||||
@@ -107,15 +110,12 @@ func runConsole(app *App) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() { _ = term.Restore(fd, oldState) }()
|
||||||
_ = term.Restore(fd, oldState)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Notifyf("[console] non-gui mode, commands start with '.' at line start\n")
|
appInst.Notifyf("[console] non-gui mode, commands start with '.' at line start\n")
|
||||||
app.Notifyf("[console] Ctrl+<Key> passes through to remote; .exit to exit")
|
appInst.Notifyf("[console] Ctrl+<Key> passes through to remote; .exit to exit")
|
||||||
|
|
||||||
// Read with a larger buffer so multi-byte sequences (arrows, CSI) arrive together.
|
|
||||||
ch := make(chan byte, 1024)
|
ch := make(chan byte, 1024)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -132,6 +132,8 @@ func runConsole(app *App) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
out := appInst.Out()
|
||||||
|
cfg := appInst.Cfg()
|
||||||
lineStart := true
|
lineStart := true
|
||||||
commandMode := false
|
commandMode := false
|
||||||
cmdBuf := make([]byte, 0, 128)
|
cmdBuf := make([]byte, 0, 128)
|
||||||
@@ -147,7 +149,7 @@ func runConsole(app *App) error {
|
|||||||
|
|
||||||
readByte := func() (byte, error) {
|
readByte := func() (byte, error) {
|
||||||
select {
|
select {
|
||||||
case <-app.waitDone():
|
case <-appInst.WaitDone():
|
||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
case rdErr := <-errCh:
|
case rdErr := <-errCh:
|
||||||
return 0, rdErr
|
return 0, rdErr
|
||||||
@@ -156,14 +158,13 @@ func runConsole(app *App) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// flushESC sends a fully-built escape sequence to serial.
|
|
||||||
flushESC := func(seq []byte) bool {
|
flushESC := func(seq []byte) bool {
|
||||||
if isExitHotkeySeq(seq) {
|
if isExitHotkeySeq(seq, cfg) {
|
||||||
app.Close()
|
appInst.Close()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if err = app.writeToSession(seq); err != nil {
|
if err = appInst.WriteToSession(seq); err != nil {
|
||||||
app.Statusf("[send] %v", err)
|
appInst.Statusf("[send] %v", err)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -177,39 +178,32 @@ func runConsole(app *App) error {
|
|||||||
return rdErr
|
return rdErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Escape sequences (VT / CSI) ──
|
|
||||||
if b == 0x1b {
|
if b == 0x1b {
|
||||||
// Try to read the rest without blocking.
|
|
||||||
escBuf := []byte{0x1b}
|
escBuf := []byte{0x1b}
|
||||||
for {
|
for {
|
||||||
nb, ok := tryRead()
|
nb, ok := tryRead()
|
||||||
if !ok {
|
if !ok {
|
||||||
// Standalone ESC — send it now.
|
if err = appInst.WriteToSession([]byte{0x1b}); err != nil {
|
||||||
if err = app.writeToSession([]byte{0x1b}); err != nil {
|
appInst.Statusf("[send] %v", err)
|
||||||
app.Statusf("[send] %v", err)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
escBuf = append(escBuf, nb)
|
escBuf = append(escBuf, nb)
|
||||||
// CSI terminator byte (0x40–0x7E): A–Z, a–z, ~, etc.
|
|
||||||
if nb >= 0x40 && nb <= 0x7e {
|
if nb >= 0x40 && nb <= 0x7e {
|
||||||
if flushESC(escBuf) {
|
if flushESC(escBuf) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Short non-CSI sequence (e.g. ESC c).
|
|
||||||
if len(escBuf) == 2 && escBuf[1] != '[' {
|
if len(escBuf) == 2 && escBuf[1] != '[' {
|
||||||
if flushESC(escBuf) {
|
if flushESC(escBuf) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// CSI parameter bytes (digits, semicolons, etc.) — keep collecting.
|
|
||||||
if len(escBuf) > 16 {
|
if len(escBuf) > 16 {
|
||||||
// Too long, just flush.
|
if err = appInst.WriteToSession(escBuf); err != nil {
|
||||||
if err = app.writeToSession(escBuf); err != nil {
|
appInst.Statusf("[send] %v", err)
|
||||||
app.Statusf("[send] %v", err)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -217,20 +211,18 @@ func runConsole(app *App) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Windows Alt+key: NULL prefix ──
|
|
||||||
if b == 0x00 {
|
if b == 0x00 {
|
||||||
if b2, ok := tryRead(); ok {
|
if b2, ok := tryRead(); ok {
|
||||||
if isAltKeyExit(b2) {
|
if isAltKeyExit(b2, cfg) {
|
||||||
app.Close()
|
appInst.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err = app.writeToSession([]byte{0x00, b2}); err != nil {
|
if err = appInst.WriteToSession([]byte{0x00, b2}); err != nil {
|
||||||
app.Statusf("[send] %v", err)
|
appInst.Statusf("[send] %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No second byte available — send NULL alone.
|
if err = appInst.WriteToSession([]byte{0x00}); err != nil {
|
||||||
if err = app.writeToSession([]byte{0x00}); err != nil {
|
appInst.Statusf("[send] %v", err)
|
||||||
app.Statusf("[send] %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if commandMode {
|
if commandMode {
|
||||||
@@ -239,14 +231,13 @@ func runConsole(app *App) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command mode ──
|
|
||||||
if commandMode {
|
if commandMode {
|
||||||
switch b {
|
switch b {
|
||||||
case '\r', '\n':
|
case '\r', '\n':
|
||||||
echoConsoleNewline()
|
echoConsoleNewline(out)
|
||||||
line := string(cmdBuf)
|
line := string(cmdBuf)
|
||||||
if strings.TrimSpace(line) != "" {
|
if strings.TrimSpace(line) != "" {
|
||||||
app.handleLine(line)
|
appInst.HandleLine(line)
|
||||||
}
|
}
|
||||||
commandMode = false
|
commandMode = false
|
||||||
cmdBuf = cmdBuf[:0]
|
cmdBuf = cmdBuf[:0]
|
||||||
@@ -254,42 +245,41 @@ func runConsole(app *App) error {
|
|||||||
case 0x7f, 0x08:
|
case 0x7f, 0x08:
|
||||||
if len(cmdBuf) > 0 {
|
if len(cmdBuf) > 0 {
|
||||||
cmdBuf = cmdBuf[:len(cmdBuf)-1]
|
cmdBuf = cmdBuf[:len(cmdBuf)-1]
|
||||||
echoConsoleBackspace()
|
echoConsoleBackspace(out)
|
||||||
}
|
}
|
||||||
case 0x09: // Tab — command completion
|
case 0x09:
|
||||||
line, cands := app.dispatcher.Complete(string(cmdBuf))
|
line, cands := appInst.Dispatcher().Complete(string(cmdBuf))
|
||||||
if len(cands) == 1 {
|
if len(cands) == 1 {
|
||||||
cmdBuf = append(cmdBuf[:0], line...)
|
cmdBuf = append(cmdBuf[:0], line...)
|
||||||
echoRedrawCommand(line)
|
echoRedrawCommand(out, line)
|
||||||
} else if len(cands) > 1 {
|
} else if len(cands) > 1 {
|
||||||
echoConsoleNewline()
|
echoConsoleNewline(out)
|
||||||
app.Notifyf("%s", strings.Join(cands, " "))
|
appInst.Notifyf("%s", strings.Join(cands, " "))
|
||||||
echoConsoleByte('.')
|
echoConsoleByte(out, '.')
|
||||||
echoConsoleString(string(cmdBuf[1:]))
|
echoConsoleString(out, string(cmdBuf[1:]))
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
cmdBuf = append(cmdBuf, b)
|
cmdBuf = append(cmdBuf, b)
|
||||||
echoConsoleByte(b)
|
echoConsoleByte(out, b)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Normal mode (sending to remote) ──
|
|
||||||
if lineStart && b == '.' {
|
if lineStart && b == '.' {
|
||||||
commandMode = true
|
commandMode = true
|
||||||
cmdBuf = append(cmdBuf[:0], b)
|
cmdBuf = append(cmdBuf[:0], b)
|
||||||
echoConsoleByte(b)
|
echoConsoleByte(out, b)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if b == '\r' || b == '\n' {
|
if b == '\r' || b == '\n' {
|
||||||
if err = app.writeToSession([]byte(cfg.EndStr)); err != nil {
|
if err = appInst.WriteToSession([]byte(cfg.EndStr)); err != nil {
|
||||||
app.Statusf("[send] %v", err)
|
appInst.Statusf("[send] %v", err)
|
||||||
}
|
}
|
||||||
lineStart = true
|
lineStart = true
|
||||||
} else {
|
} else {
|
||||||
if err = app.writeToSession([]byte{b}); err != nil {
|
if err = appInst.WriteToSession([]byte{b}); err != nil {
|
||||||
app.Statusf("[send] %v", err)
|
appInst.Statusf("[send] %v", err)
|
||||||
}
|
}
|
||||||
lineStart = false
|
lineStart = false
|
||||||
}
|
}
|
||||||
@@ -297,7 +287,6 @@ func runConsole(app *App) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseCSIu(seq []byte) (cp int, mod int, ok bool) {
|
func parseCSIu(seq []byte) (cp int, mod int, ok bool) {
|
||||||
// ESC [ codepoint ; modifier u
|
|
||||||
if len(seq) < 6 {
|
if len(seq) < 6 {
|
||||||
return 0, 0, false
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
@@ -323,20 +312,15 @@ func parseCSIu(seq []byte) (cp int, mod int, ok bool) {
|
|||||||
return cp, mod, true
|
return cp, mod, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isAltKeyExit(b byte) bool {
|
func isAltKeyExit(b byte, cfg *config.Config) bool {
|
||||||
if normalizeHotkeyPrefix(cfg.HotkeyMod) != "ctrl+alt" {
|
if normalizeHotkey(cfg.HotkeyMod) != "ctrl+alt" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// 0x2E = scan code for 'C', 0x03 = Ctrl+C, 0x63 = 'c', 0x43 = 'C'
|
|
||||||
return b == 0x2e || b == 0x03 || b == 0x63 || b == 0x43
|
return b == 0x2e || b == 0x03 || b == 0x63 || b == 0x43
|
||||||
}
|
}
|
||||||
|
|
||||||
func isExitHotkeySeq(seq []byte) bool {
|
func isExitHotkeySeq(seq []byte, cfg *config.Config) bool {
|
||||||
mod := normalizeHotkeyPrefix(cfg.HotkeyMod)
|
mod := normalizeHotkey(cfg.HotkeyMod)
|
||||||
|
|
||||||
// CSI u format: ESC [ codepoint ; modifier u
|
|
||||||
// Only matches when the Ctrl modifier bit (4) is present,
|
|
||||||
// distinguishing Ctrl+Alt+C from Alt+C alone.
|
|
||||||
if cp, cmod, ok := parseCSIu(seq); ok {
|
if cp, cmod, ok := parseCSIu(seq); ok {
|
||||||
if cp != 'c' && cp != 'C' {
|
if cp != 'c' && cp != 'C' {
|
||||||
return false
|
return false
|
||||||
@@ -349,26 +333,19 @@ func isExitHotkeySeq(seq []byte) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func echoConsoleByte(b byte) {
|
func normalizeHotkey(mod string) string {
|
||||||
_, _ = out.Write([]byte{b})
|
mod = strings.ToLower(strings.TrimSpace(mod))
|
||||||
|
if mod != "ctrl+alt" && mod != "ctrl+shift" {
|
||||||
|
mod = "ctrl+alt"
|
||||||
|
}
|
||||||
|
return mod
|
||||||
}
|
}
|
||||||
|
|
||||||
func echoConsoleNewline() {
|
func echoConsoleByte(out io.Writer, b byte) { _, _ = out.Write([]byte{b}) }
|
||||||
_, _ = io.WriteString(out, "\r\n")
|
func echoConsoleNewline(out io.Writer) { _, _ = io.WriteString(out, "\r\n") }
|
||||||
}
|
func echoConsoleBackspace(out io.Writer) { _, _ = io.WriteString(out, "\b \b") }
|
||||||
|
func echoConsoleString(out io.Writer, s string) { _, _ = io.WriteString(out, s) }
|
||||||
func echoConsoleBackspace() {
|
func echoRedrawCommand(out io.Writer, s string) { _, _ = io.WriteString(out, "\r\033[K> "+s) }
|
||||||
_, _ = io.WriteString(out, "\b \b")
|
|
||||||
}
|
|
||||||
|
|
||||||
func echoConsoleString(s string) {
|
|
||||||
_, _ = io.WriteString(out, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func echoRedrawCommand(s string) {
|
|
||||||
_, _ = io.WriteString(out, "\r\033[K> "+s)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//go:build !windows
|
//go:build !windows
|
||||||
|
|
||||||
package termapp
|
package console
|
||||||
|
|
||||||
func enableVTInput(fd int) {}
|
func enableVTInput(fd int) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package console
|
||||||
|
|
||||||
|
import "golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
func enableVTInput(fd int) {
|
||||||
|
var mode uint32
|
||||||
|
if err := windows.GetConsoleMode(windows.Handle(fd), &mode); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT
|
||||||
|
_ = windows.SetConsoleMode(windows.Handle(fd), mode)
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ func Init(cfg *config.Config) {
|
|||||||
_ = pflag.Lookup("time") // mark for NoOptDefVal
|
_ = pflag.Lookup("time") // mark for NoOptDefVal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize converts single-dash long flags (e.g. -port) to double-dash (--port).
|
||||||
|
// Parse wraps pflag.Parse.
|
||||||
|
func Parse() { pflag.Parse() }
|
||||||
|
|
||||||
// Normalize converts single-dash long flags (e.g. -port) to double-dash (--port).
|
// Normalize converts single-dash long flags (e.g. -port) to double-dash (--port).
|
||||||
func Normalize() {
|
func Normalize() {
|
||||||
known := map[string]bool{
|
known := map[string]bool{
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.bug.st/serial"
|
|
||||||
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session"
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPrefixLines(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
in string
|
|
||||||
prefix string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "empty", in: "", prefix: "X ", want: ""},
|
|
||||||
{name: "no-prefix", in: "a\n", prefix: "", want: "a\n"},
|
|
||||||
{name: "single-line", in: "abc", prefix: "T ", want: "T abc"},
|
|
||||||
{name: "multi-line", in: "a\nb\n", prefix: "P ", want: "P a\nP b\n"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := prefixLines(tt.in, tt.prefix)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("%s: prefixLines got=%q want=%q", tt.name, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppUIEvents(t *testing.T) {
|
|
||||||
a := &App{uiEvents: make(chan event.UIEvent, 8)}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
|
|
||||||
a.Notifyf("hello %s", "world")
|
|
||||||
a.Statusf("ok")
|
|
||||||
a.ShowModal("Title", "Body")
|
|
||||||
|
|
||||||
ev1 := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev1.Kind != event.UIEventOutput || ev1.Text != "hello world" {
|
|
||||||
t.Fatalf("unexpected output event: %+v", ev1)
|
|
||||||
}
|
|
||||||
|
|
||||||
ev2 := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev2.Kind != event.UIEventStatus || ev2.Text != "ok" {
|
|
||||||
t.Fatalf("unexpected status event: %+v", ev2)
|
|
||||||
}
|
|
||||||
|
|
||||||
ev3 := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev3.Kind != event.UIEventModal || ev3.Title != "Title" || ev3.Text != "Body" {
|
|
||||||
t.Fatalf("unexpected modal event: %+v", ev3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendLine(t *testing.T) {
|
|
||||||
setupTestPipes()
|
|
||||||
a := &App{
|
|
||||||
cfg: &Config{EndStr: "\r\n"},
|
|
||||||
plugins: luaplugin.NewManager(),
|
|
||||||
uiEvents: make(chan event.UIEvent, 8),
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
|
|
||||||
if err := a.sendLine("hello"); err != nil {
|
|
||||||
t.Fatalf("sendLine failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := a.sendLine(""); err != nil {
|
|
||||||
t.Fatalf("sendLine empty string should be no-op: %v", err)
|
|
||||||
}
|
|
||||||
if err := a.sendLine(" "); err != nil {
|
|
||||||
t.Fatalf("sendLine whitespace should be no-op: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleLine(t *testing.T) {
|
|
||||||
setupTestPipes()
|
|
||||||
a := &App{
|
|
||||||
cfg: &Config{EndStr: "\n", InputCode: "UTF-8", OutputCode: "UTF-8"},
|
|
||||||
plugins: luaplugin.NewManager(),
|
|
||||||
uiEvents: make(chan event.UIEvent, 8),
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
a.forward = forward.NewManager(func([]byte) error { return nil }, func(string, ...any) {})
|
|
||||||
a.dispatcher = NewCommandDispatcher(a)
|
|
||||||
|
|
||||||
a.handleLine("hello")
|
|
||||||
a.handleLine("")
|
|
||||||
a.handleLine(".help")
|
|
||||||
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventModal || ev.Title == "" {
|
|
||||||
t.Fatalf("expected .help modal, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmitNonUI(t *testing.T) {
|
|
||||||
oldOut := out
|
|
||||||
out = io.Discard
|
|
||||||
defer func() { out = oldOut }()
|
|
||||||
|
|
||||||
a := &App{
|
|
||||||
uiEvents: make(chan event.UIEvent, 4),
|
|
||||||
logFile: nil,
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(false)
|
|
||||||
|
|
||||||
a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: "serial data\n"})
|
|
||||||
a.emit(event.UIEvent{Kind: event.UIEventStatus, Text: "status msg"})
|
|
||||||
a.emit(event.UIEvent{Kind: event.UIEventModal, Title: "T", Text: "body"})
|
|
||||||
a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: ""})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmitUISaturation(t *testing.T) {
|
|
||||||
a := &App{
|
|
||||||
uiEvents: make(chan event.UIEvent, 2),
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
|
|
||||||
// Fill channel
|
|
||||||
a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: "a"})
|
|
||||||
a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: "b"})
|
|
||||||
// This should drop oldest and insert newest
|
|
||||||
a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: "c"})
|
|
||||||
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Text != "b" {
|
|
||||||
t.Fatalf("expected b after drop, got %q", ev.Text)
|
|
||||||
}
|
|
||||||
ev = mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Text != "c" {
|
|
||||||
t.Fatalf("expected c, got %q", ev.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppClose(t *testing.T) {
|
|
||||||
a := &App{
|
|
||||||
done: make(chan struct{}),
|
|
||||||
plugins: luaplugin.NewManager(),
|
|
||||||
forward: forward.NewManager(func([]byte) error { return nil }, func(string, ...any) {}),
|
|
||||||
uiEvents: make(chan event.UIEvent, 4),
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
|
|
||||||
a.Close()
|
|
||||||
if !a.isClosed() {
|
|
||||||
t.Fatalf("expected app closed")
|
|
||||||
}
|
|
||||||
// Second close should be safe
|
|
||||||
a.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfiguredForwards(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen failed: %v", err)
|
|
||||||
}
|
|
||||||
defer listener.Close()
|
|
||||||
|
|
||||||
testCfg := &Config{
|
|
||||||
ForWard: []int{int(forward.TCP), int(forward.None), int(forward.UDP)},
|
|
||||||
Address: []string{listener.Addr().String(), "", ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
a := &App{
|
|
||||||
cfg: testCfg,
|
|
||||||
forward: forward.NewManager(func([]byte) error { return nil }, func(string, ...any) {}),
|
|
||||||
uiEvents: make(chan event.UIEvent, 8),
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
|
|
||||||
a.loadConfiguredForwards()
|
|
||||||
// forward.TCP should be added, forward.None skipped, forward.UDP skipped (empty address)
|
|
||||||
items := a.forward.List()
|
|
||||||
if len(items) != 1 || items[0].Mode != "tcp" {
|
|
||||||
t.Fatalf("expected 1 TCP forward, got %+v", items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReportForwardIngress(t *testing.T) {
|
|
||||||
a := &App{
|
|
||||||
cfg: &Config{InputCode: "UTF-8", OutputCode: "UTF-8"},
|
|
||||||
uiEvents: make(chan event.UIEvent, 4),
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
|
|
||||||
a.reportForwardIngress(1, []byte("test"))
|
|
||||||
|
|
||||||
// Hex mode
|
|
||||||
a.cfg.InputCode = "hex"
|
|
||||||
a.reportForwardIngress(2, []byte{0x41, 0x42})
|
|
||||||
|
|
||||||
// Empty chunk
|
|
||||||
a.reportForwardIngress(3, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendCtrl(t *testing.T) {
|
|
||||||
if sess == nil {
|
|
||||||
sess = &session.SerialSession{}
|
|
||||||
}
|
|
||||||
oldSp := sess.Port
|
|
||||||
defer func() { sess.Port = oldSp }()
|
|
||||||
|
|
||||||
// Use a mock serial port
|
|
||||||
sess.Port = &mockSerialPort{}
|
|
||||||
a := &App{
|
|
||||||
cfg: &Config{},
|
|
||||||
uiEvents: make(chan event.UIEvent, 4),
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
|
|
||||||
if err := a.sendCtrl('c'); err != nil {
|
|
||||||
t.Fatalf("sendCtrl('c') failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := a.sendCtrl('C'); err != nil {
|
|
||||||
t.Fatalf("sendCtrl('C') failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := a.sendCtrl('A'); err != nil {
|
|
||||||
t.Fatalf("sendCtrl('A') failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockSerialPort struct{}
|
|
||||||
|
|
||||||
func (m *mockSerialPort) Write(p []byte) (int, error) { return len(p), nil }
|
|
||||||
func (m *mockSerialPort) Read(p []byte) (int, error) { return 0, io.EOF }
|
|
||||||
func (m *mockSerialPort) Close() error { return nil }
|
|
||||||
func (m *mockSerialPort) SetMode(mode *serial.Mode) error { return nil }
|
|
||||||
func (m *mockSerialPort) SetDTR(dtr bool) error { return nil }
|
|
||||||
func (m *mockSerialPort) SetRTS(rts bool) error { return nil }
|
|
||||||
func (m *mockSerialPort) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
|
||||||
return &serial.ModemStatusBits{}, nil
|
|
||||||
}
|
|
||||||
func (m *mockSerialPort) ResetInputBuffer() error { return nil }
|
|
||||||
func (m *mockSerialPort) ResetOutputBuffer() error { return nil }
|
|
||||||
func (m *mockSerialPort) SetReadTimeout(t time.Duration) error { return nil }
|
|
||||||
func (m *mockSerialPort) Break(t time.Duration) error { return nil }
|
|
||||||
func (m *mockSerialPort) Drain() error { return nil }
|
|
||||||
|
|
||||||
func mustReadEvent(t *testing.T, ch <-chan event.UIEvent) event.UIEvent {
|
|
||||||
t.Helper()
|
|
||||||
select {
|
|
||||||
case ev := <-ch:
|
|
||||||
return ev
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatalf("timed out waiting for UI event")
|
|
||||||
return event.UIEvent{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
|
||||||
)
|
|
||||||
|
|
||||||
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 CommandDispatcher struct {
|
|
||||||
app *App
|
|
||||||
commands map[string]*RuntimeCommand
|
|
||||||
order []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCommandDispatcher(app *App) *CommandDispatcher {
|
|
||||||
d := &CommandDispatcher{
|
|
||||||
app: app,
|
|
||||||
commands: make(map[string]*RuntimeCommand),
|
|
||||||
}
|
|
||||||
|
|
||||||
d.registerAll()
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *CommandDispatcher) register(cmd RuntimeCommand) {
|
|
||||||
key := strings.ToLower(cmd.Name)
|
|
||||||
d.commands[key] = &cmd
|
|
||||||
d.order = append(d.order, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *CommandDispatcher) registerAll() {
|
|
||||||
d.register(RuntimeCommand{
|
|
||||||
Name: ".help",
|
|
||||||
Usage: ".help",
|
|
||||||
Description: "show command help",
|
|
||||||
Handler: func(args []string) error {
|
|
||||||
d.app.ShowModal("Command Help", d.HelpText())
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
d.register(RuntimeCommand{
|
|
||||||
Name: ".exit",
|
|
||||||
Usage: ".exit",
|
|
||||||
Description: "exit local terminal",
|
|
||||||
Handler: func(args []string) error {
|
|
||||||
d.app.Statusf("[local] exiting")
|
|
||||||
d.app.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.app.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 *CommandDispatcher) 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 *CommandDispatcher) 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 *CommandDispatcher) 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 *CommandDispatcher) commandNames() []string {
|
|
||||||
names := make([]string, 0, len(d.commands))
|
|
||||||
for _, cmd := range d.commands {
|
|
||||||
names = append(names, cmd.Name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *CommandDispatcher) handleForwardCommand(args []string) error {
|
|
||||||
if len(args) < 2 {
|
|
||||||
if d.app.UIEnabled() {
|
|
||||||
d.app.OpenPanel(event.UIPanelForward)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
args = []string{".forward", "list"}
|
|
||||||
}
|
|
||||||
|
|
||||||
sub := strings.ToLower(args[1])
|
|
||||||
switch sub {
|
|
||||||
case "list", "stats":
|
|
||||||
if d.app.UIEnabled() {
|
|
||||||
d.app.OpenPanel(event.UIPanelForward)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
items := d.app.forward.List()
|
|
||||||
if len(items) == 0 {
|
|
||||||
d.app.Notifyf("[forward] empty")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
d.app.Notifyf("[forward] ID Mode Enabled Connected Address InBytes OutBytes LastError")
|
|
||||||
for _, it := range items {
|
|
||||||
d.app.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.app.forward.Add(mode, args[3])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
d.app.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.app.forward.Remove(id)
|
|
||||||
case "enable":
|
|
||||||
return d.app.forward.Enable(id)
|
|
||||||
case "disable":
|
|
||||||
return d.app.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.app.forward.Update(id, mode, args[4]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
d.app.Statusf("[forward] updated #%d", id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("unknown subcommand: %s", sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *CommandDispatcher) handlePluginCommand(args []string) error {
|
|
||||||
if len(args) < 2 {
|
|
||||||
if d.app.UIEnabled() {
|
|
||||||
d.app.OpenPanel(event.UIPanelPlugin)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
args = []string{".plugin", "list"}
|
|
||||||
}
|
|
||||||
|
|
||||||
sub := strings.ToLower(args[1])
|
|
||||||
switch sub {
|
|
||||||
case "list":
|
|
||||||
if d.app.UIEnabled() {
|
|
||||||
d.app.OpenPanel(event.UIPanelPlugin)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
items := d.app.plugins.List()
|
|
||||||
if len(items) == 0 {
|
|
||||||
d.app.Notifyf("[plugin] empty")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, it := range items {
|
|
||||||
d.app.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.app.plugins.Load(args[2])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
d.app.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.app.plugins.Unload(name)
|
|
||||||
case "enable":
|
|
||||||
return d.app.plugins.Enable(name)
|
|
||||||
case "disable":
|
|
||||||
return d.app.plugins.Disable(name)
|
|
||||||
case "reload":
|
|
||||||
return d.app.plugins.Reload(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("unknown subcommand: %s", sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *CommandDispatcher) handleModeCommand(args []string) error {
|
|
||||||
if len(args) < 2 || strings.EqualFold(args[1], "show") {
|
|
||||||
if d.app.UIEnabled() {
|
|
||||||
d.app.OpenPanel(event.UIPanelMode)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
d.app.Notifyf("[mode] input=%s output=%s end=%q hex=%v frame=%d timestamp=%v timefmt=%q forwardTargets=%d plugins=%d",
|
|
||||||
d.app.cfg.InputCode,
|
|
||||||
d.app.cfg.OutputCode,
|
|
||||||
d.app.cfg.EndStr,
|
|
||||||
strings.EqualFold(d.app.cfg.InputCode, "hex"),
|
|
||||||
d.app.cfg.FrameSize,
|
|
||||||
d.app.cfg.TimesTamp,
|
|
||||||
d.app.cfg.TimesFmt,
|
|
||||||
len(d.app.forward.List()),
|
|
||||||
len(d.app.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:], " ")
|
|
||||||
|
|
||||||
switch field {
|
|
||||||
case "in":
|
|
||||||
if value == "" {
|
|
||||||
return fmt.Errorf("input charset must not be empty")
|
|
||||||
}
|
|
||||||
d.app.cfg.InputCode = value
|
|
||||||
case "out":
|
|
||||||
if value == "" {
|
|
||||||
return fmt.Errorf("output charset must not be empty")
|
|
||||||
}
|
|
||||||
d.app.cfg.OutputCode = value
|
|
||||||
case "end":
|
|
||||||
d.app.cfg.EndStr = value
|
|
||||||
case "frame":
|
|
||||||
n, err := strconv.Atoi(value)
|
|
||||||
if err != nil || n <= 0 {
|
|
||||||
return fmt.Errorf("frame must be a positive integer")
|
|
||||||
}
|
|
||||||
d.app.cfg.FrameSize = n
|
|
||||||
case "timestamp":
|
|
||||||
enabled, ok := parseOnOff(value)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("timestamp value must be on/off")
|
|
||||||
}
|
|
||||||
d.app.cfg.TimesTamp = enabled
|
|
||||||
case "timefmt":
|
|
||||||
if value == "" && d.app.cfg.TimesTamp {
|
|
||||||
return fmt.Errorf("timestamp format must not be empty")
|
|
||||||
}
|
|
||||||
d.app.cfg.TimesFmt = value
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown mode field: %s", field)
|
|
||||||
}
|
|
||||||
|
|
||||||
d.app.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session"
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupTestPipes() {
|
|
||||||
if sess == nil {
|
|
||||||
sess = &session.SerialSession{}
|
|
||||||
}
|
|
||||||
var cr *io.PipeReader
|
|
||||||
cr, sess.StdinPipe = io.Pipe()
|
|
||||||
go func() {
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
_, err := cr.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestAppForCommand() *App {
|
|
||||||
a := &App{
|
|
||||||
cfg: &Config{InputCode: "UTF-8", OutputCode: "UTF-8", EndStr: "\n"},
|
|
||||||
plugins: luaplugin.NewManager(),
|
|
||||||
uiEvents: make(chan event.UIEvent, 32),
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
a.forward = forward.NewManager(func([]byte) error { return nil }, func(string, ...any) {})
|
|
||||||
a.dispatcher = NewCommandDispatcher(a)
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandCompleteRoot(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
line, cands := a.dispatcher.Complete(".")
|
|
||||||
if line != "." {
|
|
||||||
t.Fatalf("expected line unchanged for ambiguous completion, got %q", line)
|
|
||||||
}
|
|
||||||
if len(cands) == 0 {
|
|
||||||
t.Fatalf("expected root command candidates")
|
|
||||||
}
|
|
||||||
for _, c := range cands {
|
|
||||||
if c == ".ctrl" {
|
|
||||||
t.Fatalf(".ctrl should be removed from command set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandCompleteForwardSubcommands(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
_, cands := a.dispatcher.Complete(".forward ")
|
|
||||||
joined := strings.Join(cands, ",")
|
|
||||||
for _, name := range []string{"list", "add", "remove", "enable", "disable", "update", "stats"} {
|
|
||||||
if !strings.Contains(joined, name) {
|
|
||||||
t.Fatalf("missing forward candidate %q in %v", name, cands)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteUnknown(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
handled, err := a.dispatcher.Execute(".unknown")
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("unknown command should be marked handled")
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected unknown command error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteHelpShowsModal(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
handled, err := a.dispatcher.Execute(".help")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".help execute failed handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventModal || ev.Title == "" {
|
|
||||||
t.Fatalf("expected help modal event, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteForwardListShowsPanel(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
handled, err := a.dispatcher.Execute(".forward list")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".forward list execute failed handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelForward {
|
|
||||||
t.Fatalf("expected forward panel event, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecutePluginListShowsPanel(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
if _, err := a.plugins.Load("plugins/demo.lua"); err == nil {
|
|
||||||
_ = a.plugins.Disable("demo")
|
|
||||||
}
|
|
||||||
handled, err := a.dispatcher.Execute(".plugin list")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".plugin list execute failed handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelPlugin {
|
|
||||||
t.Fatalf("expected plugin panel event, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecutePluginWithoutSubcommandShowsPanel(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
handled, err := a.dispatcher.Execute(".plugin")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".plugin execute failed handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelPlugin {
|
|
||||||
t.Fatalf("expected plugin panel event for bare command, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteModeShowsPanel(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
handled, err := a.dispatcher.Execute(".mode show")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".mode execute failed handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelMode {
|
|
||||||
t.Fatalf("expected mode panel event, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteModeSet(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
handled, err := a.dispatcher.Execute(".mode set end \\r\\n")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".mode set end failed handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
if a.cfg.EndStr != "\\r\\n" {
|
|
||||||
t.Fatalf("mode set end not applied, got=%q", a.cfg.EndStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
handled, err = a.dispatcher.Execute(".mode set timestamp on")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".mode set timestamp failed handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
if !a.cfg.TimesTamp {
|
|
||||||
t.Fatalf("mode set timestamp should enable timesTamp")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOnOff(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
in string
|
|
||||||
val bool
|
|
||||||
valid bool
|
|
||||||
}{
|
|
||||||
{in: "on", val: true, valid: true},
|
|
||||||
{in: "true", val: true, valid: true},
|
|
||||||
{in: "1", val: true, valid: true},
|
|
||||||
{in: "yes", val: true, valid: true},
|
|
||||||
{in: "off", val: false, valid: true},
|
|
||||||
{in: "false", val: false, valid: true},
|
|
||||||
{in: "0", val: false, valid: true},
|
|
||||||
{in: "no", val: false, valid: true},
|
|
||||||
{in: "", val: false, valid: false},
|
|
||||||
{in: "maybe", val: false, valid: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got, ok := parseOnOff(tt.in)
|
|
||||||
if ok != tt.valid || got != tt.val {
|
|
||||||
t.Fatalf("parseOnOff(%q) got=(%v,%v) want=(%v,%v)", tt.in, got, ok, tt.val, tt.valid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteForward(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
args []string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{args: []string{".forward"}, want: []string{"list", "add", "remove", "enable", "disable", "update", "stats"}},
|
|
||||||
{args: []string{".forward", ""}, want: []string{"list", "add", "remove", "enable", "disable", "update", "stats"}},
|
|
||||||
{args: []string{".forward", "add", ""}, want: []string{"tcp", "udp"}},
|
|
||||||
{args: []string{".forward", "update", "1", ""}, want: []string{"tcp", "udp"}},
|
|
||||||
{args: []string{".forward", "list", "1"}, want: nil},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := completeForward(tt.args)
|
|
||||||
if !stringSlicesEqual(got, tt.want) {
|
|
||||||
t.Fatalf("completeForward(%v) got=%v want=%v", tt.args, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompletePlugin(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
args []string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{args: []string{".plugin"}, want: []string{"list", "load", "unload", "enable", "disable", "reload"}},
|
|
||||||
{args: []string{".plugin", "load", ""}, want: nil},
|
|
||||||
{args: []string{".plugin", "unload", "demo"}, want: nil},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := completePlugin(tt.args)
|
|
||||||
if !stringSlicesEqual(got, tt.want) {
|
|
||||||
t.Fatalf("completePlugin(%v) got=%v want=%v", tt.args, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteMode(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
args []string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{args: []string{".mode"}, want: []string{"show", "set"}},
|
|
||||||
{args: []string{".mode", "set", ""}, want: []string{"in", "out", "end", "frame", "timestamp", "timefmt"}},
|
|
||||||
{args: []string{".mode", "set", "timestamp", ""}, want: []string{"on", "off"}},
|
|
||||||
{args: []string{".mode", "set", "in", ""}, want: nil},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := completeMode(tt.args)
|
|
||||||
if !stringSlicesEqual(got, tt.want) {
|
|
||||||
t.Fatalf("completeMode(%v) got=%v want=%v", tt.args, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringSlicesEqual(a, b []string) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range a {
|
|
||||||
if a[i] != b[i] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHelpText(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
text := a.dispatcher.HelpText()
|
|
||||||
for _, cmd := range []string{".help", ".exit", ".hex", ".forward", ".plugin", ".mode"} {
|
|
||||||
if !strings.Contains(text, cmd) {
|
|
||||||
t.Fatalf("HelpText missing command %q", cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteHex(t *testing.T) {
|
|
||||||
setupTestPipes()
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
handled, err := a.dispatcher.Execute(".hex 41 42 43")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".hex valid failed handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
handled, err = a.dispatcher.Execute(".hex")
|
|
||||||
if !handled || err == nil {
|
|
||||||
t.Fatalf(".hex no args should error, handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
handled, err = a.dispatcher.Execute(".hex xyz")
|
|
||||||
if !handled || err == nil {
|
|
||||||
t.Fatalf(".hex invalid hex should error, handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteExit(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
a.Close()
|
|
||||||
if !a.isClosed() {
|
|
||||||
t.Fatalf("expected app closed after Close()")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteModeSetAll(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
|
|
||||||
handled, err := a.dispatcher.Execute(".mode set frame 32")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".mode set frame failed: handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
if a.cfg.FrameSize != 32 {
|
|
||||||
t.Fatalf("frameSize not set, got=%d", a.cfg.FrameSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
handled, err = a.dispatcher.Execute(".mode set timefmt 2006")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".mode set timefmt failed: handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
if a.cfg.TimesFmt != "2006" {
|
|
||||||
t.Fatalf("timesFmt not set, got=%q", a.cfg.TimesFmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
handled, err = a.dispatcher.Execute(".mode set out GBK")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".mode set out failed: handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
if a.cfg.OutputCode != "GBK" {
|
|
||||||
t.Fatalf("outputCode not set, got=%q", a.cfg.OutputCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
handled, err = a.dispatcher.Execute(".mode set in GBK")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".mode set in failed: handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
if a.cfg.InputCode != "GBK" {
|
|
||||||
t.Fatalf("inputCode not set, got=%q", a.cfg.InputCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandExecuteModeErrors(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
|
|
||||||
handled, err := a.dispatcher.Execute(".mode")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".mode with no subcommand in UI mode shows panel, handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".mode set")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".mode set with no args should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".mode set frame abc")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".mode set frame with non-int should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".mode set timestamp maybe")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".mode set timestamp with invalid value should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".mode set invalid_field value")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".mode set unknown field should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleForwardCommandErrors(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
|
|
||||||
_, err := a.dispatcher.Execute(".forward add")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward add with no args should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward add badmode 127.0.0.1:1")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward add with invalid mode should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward remove abc")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward remove with non-int ID should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward remove 999")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward remove non-existing should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward enable abc")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward enable with non-int ID should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward disable abc")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward disable with non-int ID should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward update")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward update with no args should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward update 1")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward update with missing addr should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward update 1 badmode 127.0.0.1:1")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward update with invalid mode should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".forward unknown_sub")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".forward unknown subcommand should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleForwardCommandNoUI(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
a.SetUIEnabled(false)
|
|
||||||
|
|
||||||
handled, err := a.dispatcher.Execute(".forward")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".forward in non-UI should default to list, handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
handled, err = a.dispatcher.Execute(".forward list")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".forward list in non-UI failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandlePluginCommandErrors(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
|
|
||||||
_, err := a.dispatcher.Execute(".plugin load")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".plugin load with no path should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".plugin unload")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".plugin unload with no name should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".plugin enable")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".plugin enable with no name should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".plugin disable")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".plugin disable with no name should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".plugin reload")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".plugin reload with no name should error")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = a.dispatcher.Execute(".plugin unknown_sub")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf(".plugin unknown subcommand should error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandlePluginCommandNoUI(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
a.SetUIEnabled(false)
|
|
||||||
|
|
||||||
handled, err := a.dispatcher.Execute(".plugin")
|
|
||||||
if err != nil || !handled {
|
|
||||||
t.Fatalf(".plugin in non-UI should default to list, handled=%v err=%v", handled, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompleteFirstTokenEdgeCases(t *testing.T) {
|
|
||||||
a := newTestAppForCommand()
|
|
||||||
line, cands := a.dispatcher.Complete(".he")
|
|
||||||
if line != ".he" {
|
|
||||||
t.Fatalf("ambiguous completion should not change line, got=%q", line)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, c := range cands {
|
|
||||||
if c == ".help" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("expected .help in completion candidates, got %v", cands)
|
|
||||||
}
|
|
||||||
|
|
||||||
line, cands = a.dispatcher.Complete(".exi")
|
|
||||||
if line != ".exit " || len(cands) != 1 || cands[0] != ".exit" {
|
|
||||||
t.Fatalf("exact completion of .exi failed: line=%q cands=%v", line, cands)
|
|
||||||
}
|
|
||||||
|
|
||||||
line, _ = a.dispatcher.Complete("")
|
|
||||||
if line != "" {
|
|
||||||
t.Fatalf("empty completion should be noop, got=%q", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
appconfig "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config is an alias for appconfig.Config to keep main-package code concise.
|
|
||||||
type Config = appconfig.Config
|
|
||||||
|
|
||||||
var cfg = &Config{}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
appconfig "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config"
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestForwardModeNetworkAndString(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
mode forward.Mode
|
|
||||||
network string
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{mode: forward.None, network: "", name: "none"},
|
|
||||||
{mode: forward.TCP, network: "tcp", name: "tcp"},
|
|
||||||
{mode: forward.UDP, network: "udp", name: "udp"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := tt.mode.Network(); got != tt.network {
|
|
||||||
t.Fatalf("Network() mode=%v got=%q want=%q", tt.mode, got, tt.network)
|
|
||||||
}
|
|
||||||
if got := tt.mode.String(); got != tt.name {
|
|
||||||
t.Fatalf("String() mode=%v got=%q want=%q", tt.mode, got, tt.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseForwardMode(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
mode forward.Mode
|
|
||||||
ok bool
|
|
||||||
}{
|
|
||||||
{input: "tcp", mode: forward.TCP, ok: true},
|
|
||||||
{input: "TCP-C", mode: forward.TCP, ok: true},
|
|
||||||
{input: "1", mode: forward.TCP, ok: true},
|
|
||||||
{input: "udp", mode: forward.UDP, ok: true},
|
|
||||||
{input: " 2 ", mode: forward.UDP, ok: true},
|
|
||||||
{input: "unknown", mode: forward.None, ok: false},
|
|
||||||
{input: "", mode: forward.None, ok: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got, ok := forward.ParseMode(tt.input)
|
|
||||||
if ok != tt.ok || got != tt.mode {
|
|
||||||
t.Fatalf("forward.ParseMode(%q) got=(%v,%v) want=(%v,%v)", tt.input, got, ok, tt.mode, tt.ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenLogFile(t *testing.T) {
|
|
||||||
old := *cfg
|
|
||||||
defer func() { *cfg = old }()
|
|
||||||
|
|
||||||
*cfg = Config{
|
|
||||||
EnableLog: true,
|
|
||||||
PortName: "COM1",
|
|
||||||
LogFilePath: filepath.Join(t.TempDir(), "%s-%s.log"),
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := appconfig.OpenLogFile(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("openLogFile() unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if f == nil {
|
|
||||||
t.Fatalf("openLogFile() got nil file when enableLog=true")
|
|
||||||
}
|
|
||||||
_ = f.Close()
|
|
||||||
|
|
||||||
cfg.EnableLog = false
|
|
||||||
f, err = appconfig.OpenLogFile(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("openLogFile() unexpected error with enableLog=false: %v", err)
|
|
||||||
}
|
|
||||||
if f != nil {
|
|
||||||
t.Fatalf("openLogFile() expected nil file when enableLog=false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseCSIu(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
seq []byte
|
|
||||||
cp int
|
|
||||||
mod int
|
|
||||||
ok bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "ctrl+alt+c lowercase",
|
|
||||||
seq: []byte{0x1b, '[', '9', '9', ';', '6', 'u'},
|
|
||||||
cp: 99, mod: 6, ok: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ctrl+shift+c uppercase",
|
|
||||||
seq: []byte{0x1b, '[', '6', '7', ';', '5', 'u'},
|
|
||||||
cp: 67, mod: 5, ok: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "too short",
|
|
||||||
seq: []byte{0x1b, '[', '9', '9'},
|
|
||||||
cp: 0, mod: 0, ok: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no escape prefix",
|
|
||||||
seq: []byte{'[', '9', '9', ';', '6', 'u'},
|
|
||||||
cp: 0, mod: 0, ok: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no u terminator",
|
|
||||||
seq: []byte{0x1b, '[', '9', '9', ';', '6', 'x'},
|
|
||||||
cp: 0, mod: 0, ok: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bad format no semicolon",
|
|
||||||
seq: []byte{0x1b, '[', '9', '9', '6', 'u'},
|
|
||||||
cp: 0, mod: 0, ok: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty",
|
|
||||||
seq: []byte{},
|
|
||||||
cp: 0, mod: 0, ok: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
cp, mod, ok := parseCSIu(tt.seq)
|
|
||||||
if ok != tt.ok || cp != tt.cp || mod != tt.mod {
|
|
||||||
t.Fatalf("parseCSIu(%v) got=(%d,%d,%v) want=(%d,%d,%v)", tt.seq, cp, mod, ok, tt.cp, tt.mod, tt.ok)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsExitHotkeySeq(t *testing.T) {
|
|
||||||
oldCfg := *cfg
|
|
||||||
defer func() { *cfg = oldCfg }()
|
|
||||||
|
|
||||||
*cfg = Config{HotkeyMod: "ctrl+alt"}
|
|
||||||
|
|
||||||
// CSI u Ctrl+Alt+C (mod=6)
|
|
||||||
if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '6', 'u'}) {
|
|
||||||
t.Fatalf("Ctrl+Alt+C CSI should exit with ctrl+alt config")
|
|
||||||
}
|
|
||||||
// CSI u Ctrl+Alt+Shift+C (mod=7, includes Ctrl+Alt)
|
|
||||||
if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '7', 'u'}) {
|
|
||||||
t.Fatalf("Ctrl+Alt+Shift+C should also exit")
|
|
||||||
}
|
|
||||||
// CSI u Ctrl+Shift+C (mod=5)
|
|
||||||
if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '5', 'u'}) {
|
|
||||||
t.Fatalf("Ctrl+Shift+C should NOT exit with ctrl+alt config")
|
|
||||||
}
|
|
||||||
// CSI for other key
|
|
||||||
if isExitHotkeySeq([]byte{0x1b, '[', '9', '7', ';', '6', 'u'}) {
|
|
||||||
t.Fatalf("Ctrl+Alt+A should not exit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple ESC c (Alt+C) should NOT exit — requires Ctrl modifier
|
|
||||||
if isExitHotkeySeq([]byte{0x1b, 'c'}) {
|
|
||||||
t.Fatalf("Alt+C (ESC c) should NOT exit — Ctrl modifier required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch to ctrl+shift
|
|
||||||
*cfg = Config{HotkeyMod: "ctrl+shift"}
|
|
||||||
|
|
||||||
if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '5', 'u'}) {
|
|
||||||
t.Fatalf("Ctrl+Shift+C should exit with ctrl+shift config")
|
|
||||||
}
|
|
||||||
if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '7', 'u'}) {
|
|
||||||
t.Fatalf("Ctrl+Shift+Alt+C should also exit (includes Ctrl+Shift)")
|
|
||||||
}
|
|
||||||
if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '6', 'u'}) {
|
|
||||||
t.Fatalf("Ctrl+Alt+C should NOT exit with ctrl+shift config")
|
|
||||||
}
|
|
||||||
// Simple ESC c should NOT exit with ctrl+shift
|
|
||||||
if isExitHotkeySeq([]byte{0x1b, 'c'}) {
|
|
||||||
t.Fatalf("ESC c should NOT exit with ctrl+shift config")
|
|
||||||
}
|
|
||||||
// Non-CSI garbage
|
|
||||||
if isExitHotkeySeq([]byte{0x1b, 'x'}) {
|
|
||||||
t.Fatalf("ESC x should not exit")
|
|
||||||
}
|
|
||||||
if isExitHotkeySeq([]byte("hello")) {
|
|
||||||
t.Fatalf("plain bytes should not exit")
|
|
||||||
}
|
|
||||||
|
|
||||||
*cfg = Config{HotkeyMod: "ctrl+alt"}
|
|
||||||
// Ctrl only (mod=4) should not exit (requires Alt too)
|
|
||||||
if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '4', 'u'}) {
|
|
||||||
t.Fatalf("Ctrl+C (without Alt) should not exit")
|
|
||||||
}
|
|
||||||
// Alt only (mod=2) should not exit (requires Ctrl too)
|
|
||||||
if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '2', 'u'}) {
|
|
||||||
t.Fatalf("Alt+C (without Ctrl) should not exit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
func enableVTInput(fd int) {
|
|
||||||
var mode uint32
|
|
||||||
if err := windows.GetConsoleMode(windows.Handle(fd), &mode); err == nil {
|
|
||||||
_ = windows.SetConsoleMode(windows.Handle(fd), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
sess *session.SerialSession
|
|
||||||
out io.Writer = os.Stdout
|
|
||||||
)
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
package termapp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseCtrlKey(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
in string
|
|
||||||
want byte
|
|
||||||
ok bool
|
|
||||||
reason string
|
|
||||||
}{
|
|
||||||
{in: "ctrl+c", want: 'c', ok: true, reason: "plain ctrl"},
|
|
||||||
{in: "ctrl+shift+c", ok: false, reason: "ctrl+shift reserved for local"},
|
|
||||||
{in: "ctrl+enter", ok: false, reason: "non-letter"},
|
|
||||||
{in: "alt+c", ok: false, reason: "wrong modifier"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got, ok := parseCtrlKey(tt.in)
|
|
||||||
if ok != tt.ok || got != tt.want {
|
|
||||||
t.Fatalf("%s parseCtrlKey(%q) got=(%q,%v) want=(%q,%v)", tt.reason, tt.in, got, ok, tt.want, tt.ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderModal(t *testing.T) {
|
|
||||||
modal := renderModal("Title", "line1\nline2", 80)
|
|
||||||
if !strings.Contains(modal, "Title") {
|
|
||||||
t.Fatalf("renderModal missing title: %q", modal)
|
|
||||||
}
|
|
||||||
if !strings.Contains(modal, "line1") || !strings.Contains(modal, "line2") {
|
|
||||||
t.Fatalf("renderModal missing lines: %q", modal)
|
|
||||||
}
|
|
||||||
if !strings.Contains(modal, "╭") || !strings.Contains(modal, "╮") || !strings.Contains(modal, "╰") || !strings.Contains(modal, "╯") {
|
|
||||||
t.Fatalf("renderModal missing box borders: %q", modal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCtrlShiftLocalHelp(t *testing.T) {
|
|
||||||
a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+alt"}}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
m := uiModel{app: a}
|
|
||||||
|
|
||||||
ok := handleLocalHotkey(&m, "ctrl+alt+h")
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected local hotkey to be handled")
|
|
||||||
}
|
|
||||||
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventModal {
|
|
||||||
t.Fatalf("expected modal event, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeHotkeyPrefix(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
in, want string
|
|
||||||
}{
|
|
||||||
{"", "ctrl+alt"},
|
|
||||||
{"ctrl+alt", "ctrl+alt"},
|
|
||||||
{"ctrl+shift", "ctrl+shift"},
|
|
||||||
{"CTRL+ALT", "ctrl+alt"},
|
|
||||||
{" ctrl+SHIFT ", "ctrl+shift"},
|
|
||||||
{"invalid", "ctrl+alt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := normalizeHotkeyPrefix(tt.in)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("normalizeHotkeyPrefix(%q) got=%q want=%q", tt.in, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHotkeyWith(t *testing.T) {
|
|
||||||
got := hotkeyWith("ctrl+alt", "h")
|
|
||||||
if got != "ctrl+alt+h" {
|
|
||||||
t.Fatalf("hotkeyWith ctrl+alt+h got=%q", got)
|
|
||||||
}
|
|
||||||
got = hotkeyWith("ctrl+shift", "c")
|
|
||||||
if got != "ctrl+shift+c" {
|
|
||||||
t.Fatalf("hotkeyWith ctrl+shift+c got=%q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsLocalHotkeyAll(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
key, mod string
|
|
||||||
action string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"ctrl+alt+c", "ctrl+alt", "c", true},
|
|
||||||
{"ctrl+shift+c", "ctrl+shift", "c", true},
|
|
||||||
{"ctrl+alt+c", "ctrl+shift", "c", false},
|
|
||||||
{"ctrl+shift+c", "ctrl+alt", "c", false},
|
|
||||||
{"alt+c", "ctrl+alt", "c", false},
|
|
||||||
{"ctrl+c", "ctrl+alt", "c", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
a := &App{cfg: &Config{HotkeyMod: tt.mod}}
|
|
||||||
m := uiModel{app: a}
|
|
||||||
got := m.isLocalHotkey(tt.key, tt.action)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("isLocalHotkey(%q, %q) hotkeyMod=%q got=%v want=%v", tt.key, tt.action, tt.mod, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCtrlKeyEdgeCases(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
in string
|
|
||||||
want byte
|
|
||||||
ok bool
|
|
||||||
}{
|
|
||||||
{in: "ctrl+z", want: 'z', ok: true},
|
|
||||||
{in: "ctrl+a", want: 'a', ok: true},
|
|
||||||
{in: "ctrl+shift+c", want: 0, ok: false},
|
|
||||||
{in: "ctrl+alt+c", want: 0, ok: false},
|
|
||||||
{in: "ctrl+", want: 0, ok: false},
|
|
||||||
{in: "ctrl+ab", want: 0, ok: false},
|
|
||||||
{in: "ctrl+A", want: 0, ok: false},
|
|
||||||
{in: "ctrl+1", want: 0, ok: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got, ok := parseCtrlKey(tt.in)
|
|
||||||
if ok != tt.ok || got != tt.want {
|
|
||||||
t.Fatalf("parseCtrlKey(%q) got=(%q,%v) want=(%q,%v)", tt.in, got, ok, tt.want, tt.ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderModalLongContent(t *testing.T) {
|
|
||||||
longBody := "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14"
|
|
||||||
modal := renderModal("Title", longBody, 80)
|
|
||||||
if !strings.Contains(modal, "... (press Esc/Enter to close)") {
|
|
||||||
t.Fatalf("long modal should be truncated: %q", modal)
|
|
||||||
}
|
|
||||||
if strings.Contains(modal, "line14") {
|
|
||||||
t.Fatalf("line14 should not appear in truncated modal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderModalEmpty(t *testing.T) {
|
|
||||||
modal := renderModal("", "", 80)
|
|
||||||
if !strings.Contains(modal, "Info") {
|
|
||||||
t.Fatalf("empty title should default to Info: %q", modal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTruncateToWidth(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
in string
|
|
||||||
width int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"hello", 3, "hel"},
|
|
||||||
{"hello", 10, "hello"},
|
|
||||||
{"", 5, ""},
|
|
||||||
{"hello", 0, "hello"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := truncateToWidth(tt.in, tt.width)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("truncateToWidth(%q, %d) got=%q want=%q", tt.in, tt.width, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClampIndex(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
idx, n int
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{2, 5, 2},
|
|
||||||
{-1, 5, 0},
|
|
||||||
{10, 5, 4},
|
|
||||||
{0, 0, 0},
|
|
||||||
{0, 1, 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := clampIndex(tt.idx, tt.n)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("clampIndex(%d, %d) got=%d want=%d", tt.idx, tt.n, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMinInt(t *testing.T) {
|
|
||||||
if got := minInt(1, 2); got != 1 {
|
|
||||||
t.Fatalf("minInt(1,2) got=%d", got)
|
|
||||||
}
|
|
||||||
if got := minInt(5, 3); got != 3 {
|
|
||||||
t.Fatalf("minInt(5,3) got=%d", got)
|
|
||||||
}
|
|
||||||
if got := minInt(0, 0); got != 0 {
|
|
||||||
t.Fatalf("minInt(0,0) got=%d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaxIntFunc(t *testing.T) {
|
|
||||||
if got := maxInt(1, 2); got != 2 {
|
|
||||||
t.Fatalf("maxInt(1,2) got=%d", got)
|
|
||||||
}
|
|
||||||
if got := maxInt(5, 3, 7); got != 7 {
|
|
||||||
t.Fatalf("maxInt(5,3,7) got=%d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleLocalHotkeyForward(t *testing.T) {
|
|
||||||
a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+alt"}}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
m := uiModel{app: a}
|
|
||||||
|
|
||||||
if !handleLocalHotkey(&m, "ctrl+alt+f") {
|
|
||||||
t.Fatalf("expected forward hotkey handled")
|
|
||||||
}
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelForward {
|
|
||||||
t.Fatalf("expected forward panel, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleLocalHotkeyPlugin(t *testing.T) {
|
|
||||||
a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+alt"}}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
m := uiModel{app: a}
|
|
||||||
|
|
||||||
if !handleLocalHotkey(&m, "ctrl+alt+p") {
|
|
||||||
t.Fatalf("expected plugin hotkey handled")
|
|
||||||
}
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelPlugin {
|
|
||||||
t.Fatalf("expected plugin panel, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleLocalHotkeyMode(t *testing.T) {
|
|
||||||
a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+alt"}}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
m := uiModel{app: a}
|
|
||||||
|
|
||||||
if !handleLocalHotkey(&m, "ctrl+alt+m") {
|
|
||||||
t.Fatalf("expected mode hotkey handled")
|
|
||||||
}
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelMode {
|
|
||||||
t.Fatalf("expected mode panel, got %+v", ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleLocalHotkeyUnknown(t *testing.T) {
|
|
||||||
a := &App{cfg: &Config{HotkeyMod: "ctrl+alt"}}
|
|
||||||
m := uiModel{app: a}
|
|
||||||
|
|
||||||
if handleLocalHotkey(&m, "ctrl+alt+x") {
|
|
||||||
t.Fatalf("unknown hotkey should not be handled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleLocalHotkeyCtrlShift(t *testing.T) {
|
|
||||||
a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+shift"}}
|
|
||||||
a.SetUIEnabled(true)
|
|
||||||
m := uiModel{app: a}
|
|
||||||
|
|
||||||
if !handleLocalHotkey(&m, "ctrl+shift+h") {
|
|
||||||
t.Fatalf("expected ctrl+shift+h to be handled")
|
|
||||||
}
|
|
||||||
ev := mustReadEvent(t, a.uiEvents)
|
|
||||||
if ev.Kind != event.UIEventModal {
|
|
||||||
t.Fatalf("expected help modal with ctrl+shift+h")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderPanelModal(t *testing.T) {
|
|
||||||
lines := []panelLine{
|
|
||||||
{text: "Header", selected: false},
|
|
||||||
{text: "Selected Row", selected: true},
|
|
||||||
}
|
|
||||||
out := renderPanelModal("Test Panel", lines, "Footer text", 80)
|
|
||||||
if !strings.Contains(out, "Test Panel") {
|
|
||||||
t.Fatalf("missing title: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "Header") {
|
|
||||||
t.Fatalf("missing header line: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "Selected Row") {
|
|
||||||
t.Fatalf("missing selected line: %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "Footer text") {
|
|
||||||
t.Fatalf("missing footer: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStyleFunctions(t *testing.T) {
|
|
||||||
_ = modalFooterLineStyle()
|
|
||||||
rendered := selectedPanelLineStyle().Render("test")
|
|
||||||
if !strings.Contains(rendered, "test") {
|
|
||||||
t.Fatalf("selectedPanelLineStyle should render text")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package termapp
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -7,28 +7,28 @@ import (
|
|||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleLocalHotkey(m *uiModel, key string) bool {
|
func handleLocalHotkey(m *Model, key string) bool {
|
||||||
if m.isLocalHotkey(key, "h") {
|
if m.isLocalHotkey(key, "h") {
|
||||||
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.HotkeyMod))
|
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))
|
||||||
m.app.ShowModal("Shortcuts", modifier+"+C => local exit\nCtrl+C => remote interrupt\n"+modifier+"+F => forward panel\n"+modifier+"+P => plugin panel\n"+modifier+"+M => mode panel\nF1 => shortcut help")
|
m.App.ShowModal("Shortcuts", modifier+"+C => local exit\nCtrl+C => remote interrupt\n"+modifier+"+F => forward panel\n"+modifier+"+P => plugin panel\n"+modifier+"+M => mode panel\nF1 => shortcut help")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if m.isLocalHotkey(key, "f") {
|
if m.isLocalHotkey(key, "f") {
|
||||||
m.app.OpenPanel(event.UIPanelForward)
|
m.App.OpenPanel(event.UIPanelForward)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if m.isLocalHotkey(key, "p") {
|
if m.isLocalHotkey(key, "p") {
|
||||||
m.app.OpenPanel(event.UIPanelPlugin)
|
m.App.OpenPanel(event.UIPanelPlugin)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if m.isLocalHotkey(key, "m") {
|
if m.isLocalHotkey(key, "m") {
|
||||||
m.app.OpenPanel(event.UIPanelMode)
|
m.App.OpenPanel(event.UIPanelMode)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) isLocalHotkey(key, action string) bool {
|
func (m *Model) isLocalHotkey(key, action string) bool {
|
||||||
parts := strings.Split(strings.ToLower(key), "+")
|
parts := strings.Split(strings.ToLower(key), "+")
|
||||||
if len(parts) < 2 || parts[len(parts)-1] != action {
|
if len(parts) < 2 || parts[len(parts)-1] != action {
|
||||||
return false
|
return false
|
||||||
@@ -48,7 +48,7 @@ func (m *uiModel) isLocalHotkey(key, action string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod := normalizeHotkeyPrefix(m.app.cfg.HotkeyMod)
|
mod := normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)
|
||||||
if mod == "ctrl+shift" {
|
if mod == "ctrl+shift" {
|
||||||
return hasCtrl && hasShift
|
return hasCtrl && hasShift
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func parseCtrlKey(key string) (byte, bool) {
|
|||||||
return ch, true
|
return ch, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) handleViewportKey(msg tea.KeyMsg) bool {
|
func (m *Model) handleViewportKey(msg tea.KeyMsg) bool {
|
||||||
if !m.ready || m.showModal {
|
if !m.ready || m.showModal {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -114,14 +114,14 @@ func (m *uiModel) handleViewportKey(msg tea.KeyMsg) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) resetCompletion() {
|
func (m *Model) resetCompletion() {
|
||||||
m.completionActive = false
|
m.completionActive = false
|
||||||
m.completionBase = ""
|
m.completionBase = ""
|
||||||
m.completionCandidates = nil
|
m.completionCandidates = nil
|
||||||
m.completionIndex = 0
|
m.completionIndex = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) stepCompletion(direction int) {
|
func (m *Model) stepCompletion(direction int) {
|
||||||
if len(m.completionCandidates) == 0 {
|
if len(m.completionCandidates) == 0 {
|
||||||
m.resetCompletion()
|
m.resetCompletion()
|
||||||
return
|
return
|
||||||
@@ -134,7 +134,7 @@ func (m *uiModel) stepCompletion(direction int) {
|
|||||||
m.applyCompletion()
|
m.applyCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) applyCompletion() {
|
func (m *Model) applyCompletion() {
|
||||||
if len(m.completionCandidates) == 0 {
|
if len(m.completionCandidates) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package termapp
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
||||||
@@ -27,8 +29,8 @@ type panelLine struct {
|
|||||||
selected bool
|
selected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type uiModel struct {
|
type Model struct {
|
||||||
app *App
|
App *app.App
|
||||||
|
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
@@ -47,6 +49,7 @@ type uiModel struct {
|
|||||||
|
|
||||||
panelKind event.UIPanelKind
|
panelKind event.UIPanelKind
|
||||||
panelIndex int
|
panelIndex int
|
||||||
|
panelError string
|
||||||
|
|
||||||
forwardItems []forward.Snapshot
|
forwardItems []forward.Snapshot
|
||||||
pluginItems []luaplugin.Snapshot
|
pluginItems []luaplugin.Snapshot
|
||||||
@@ -64,21 +67,19 @@ type uiModel struct {
|
|||||||
completionIndex int
|
completionIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUIModel(app *App) *uiModel {
|
func New(application *app.App) *Model {
|
||||||
in := textinput.New()
|
in := textinput.New()
|
||||||
// bubbles v0.18.0 computes placeholder width using display cells,
|
|
||||||
// which can panic on CJK placeholders. Keep this ASCII-only.
|
|
||||||
in.Placeholder = "Type to send to remote, use .help for commands"
|
in.Placeholder = "Type to send to remote, use .help for commands"
|
||||||
in.Focus()
|
in.Focus()
|
||||||
in.CharLimit = 0
|
in.CharLimit = 0
|
||||||
in.Prompt = "> "
|
in.Prompt = "> "
|
||||||
in.Width = 80
|
in.Width = 80
|
||||||
|
|
||||||
return &uiModel{app: app, input: in, followTail: true}
|
return &Model{App: application, input: in, followTail: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) Init() tea.Cmd {
|
func (m *Model) Init() tea.Cmd {
|
||||||
return tea.Batch(waitUIEvent(m.app.uiEvents), waitDone(m.app.waitDone()), textinput.Blink)
|
return tea.Batch(waitUIEvent(m.App.UIEvents()), waitDone(m.App.WaitDone()), textinput.Blink)
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd {
|
func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd {
|
||||||
@@ -98,7 +99,7 @@ func waitDone(ch <-chan struct{}) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case doneMsg:
|
case doneMsg:
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -120,7 +121,7 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case event.UIEventPanel:
|
case event.UIEventPanel:
|
||||||
m.openPanel(msg.Panel)
|
m.openPanel(msg.Panel)
|
||||||
}
|
}
|
||||||
return m, waitUIEvent(m.app.uiEvents)
|
return m, waitUIEvent(m.App.UIEvents())
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
@@ -156,13 +157,16 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.resetCompletion()
|
m.resetCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.showModal && m.handleModalKey(msg) {
|
if m.showModal {
|
||||||
return m, nil
|
handled, cmd := m.handleModalKey(msg)
|
||||||
|
if handled {
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.isLocalHotkey(keyStr, "c") {
|
if m.isLocalHotkey(keyStr, "c") {
|
||||||
m.app.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.HotkeyMod)))
|
m.App.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)))
|
||||||
m.app.Close()
|
m.App.Close()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,22 +174,21 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some terminals can't encode Ctrl+Alt/Shift+H distinctly and report Ctrl+H.
|
|
||||||
if keyStr == "ctrl+h" {
|
if keyStr == "ctrl+h" {
|
||||||
handleLocalHotkey(m, hotkeyWith(m.app.cfg.HotkeyMod, "h"))
|
handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h"))
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if letter, ok := parseCtrlKey(keyStr); ok {
|
if letter, ok := parseCtrlKey(keyStr); ok {
|
||||||
if err := m.app.sendCtrl(letter); err != nil {
|
if err := m.App.SendCtrl(letter); err != nil {
|
||||||
m.app.Notifyf("[remote] ctrl send failed: %v", err)
|
m.App.Notifyf("[remote] ctrl send failed: %v", err)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch keyStr {
|
switch keyStr {
|
||||||
case "f1":
|
case "f1":
|
||||||
handleLocalHotkey(m, hotkeyWith(m.app.cfg.HotkeyMod, "h"))
|
handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h"))
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "tab", "shift+tab":
|
case "tab", "shift+tab":
|
||||||
@@ -199,7 +202,7 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
line, cands := m.app.dispatcher.Complete(m.input.Value())
|
line, cands := m.App.Dispatcher().Complete(m.input.Value())
|
||||||
m.suggestions = cands
|
m.suggestions = cands
|
||||||
if len(cands) == 0 {
|
if len(cands) == 0 {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -225,7 +228,7 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.input.SetValue("")
|
m.input.SetValue("")
|
||||||
m.suggestions = nil
|
m.suggestions = nil
|
||||||
m.followTail = true
|
m.followTail = true
|
||||||
m.app.handleLine(line)
|
m.App.HandleLine(line)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +238,7 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) View() string {
|
func (m *Model) View() string {
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
return "Initializing..."
|
return "Initializing..."
|
||||||
}
|
}
|
||||||
@@ -246,7 +249,7 @@ func (m *uiModel) View() string {
|
|||||||
} else if len(m.suggestions) == 1 {
|
} else if len(m.suggestions) == 1 {
|
||||||
suggest = "Tab: " + m.suggestions[0]
|
suggest = "Tab: " + m.suggestions[0]
|
||||||
}
|
}
|
||||||
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.HotkeyMod))
|
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))
|
||||||
hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help"
|
hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help"
|
||||||
hotkeys = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("245")).Render(hotkeys)
|
hotkeys = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("245")).Render(hotkeys)
|
||||||
status := m.statusLine
|
status := m.statusLine
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package termapp
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
||||||
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *uiModel) handleModalKey(msg tea.KeyMsg) bool {
|
func (m *Model) handleModalKey(msg tea.KeyMsg) (bool, tea.Cmd) {
|
||||||
keyStr := strings.ToLower(msg.String())
|
keyStr := strings.ToLower(msg.String())
|
||||||
|
|
||||||
if m.promptActive {
|
if m.promptActive {
|
||||||
@@ -17,52 +19,54 @@ func (m *uiModel) handleModalKey(msg tea.KeyMsg) bool {
|
|||||||
}
|
}
|
||||||
if keyStr == "esc" {
|
if keyStr == "esc" {
|
||||||
m.closeModal()
|
m.closeModal()
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
if m.panelKind == event.UIPanelNone {
|
if m.panelKind == event.UIPanelNone {
|
||||||
if keyStr == "enter" {
|
if keyStr == "enter" {
|
||||||
m.closeModal()
|
m.closeModal()
|
||||||
}
|
}
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch m.panelKind {
|
switch m.panelKind {
|
||||||
case event.UIPanelForward:
|
case event.UIPanelForward:
|
||||||
return m.handleForwardPanelKey(keyStr)
|
return m.handleForwardPanelKey(keyStr), nil
|
||||||
case event.UIPanelPlugin:
|
case event.UIPanelPlugin:
|
||||||
return m.handlePluginPanelKey(keyStr)
|
return m.handlePluginPanelKey(keyStr), nil
|
||||||
case event.UIPanelMode:
|
case event.UIPanelMode:
|
||||||
return m.handleModePanelKey(keyStr)
|
return m.handleModePanelKey(keyStr), nil
|
||||||
default:
|
default:
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) closeModal() {
|
func (m *Model) closeModal() {
|
||||||
m.showModal = false
|
m.showModal = false
|
||||||
m.panelKind = event.UIPanelNone
|
m.panelKind = event.UIPanelNone
|
||||||
m.modalTitle = ""
|
m.modalTitle = ""
|
||||||
m.modalBody = ""
|
m.modalBody = ""
|
||||||
m.promptActive = false
|
m.promptActive = false
|
||||||
m.promptSubmit = nil
|
m.promptSubmit = nil
|
||||||
|
m.panelError = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) openPanel(kind event.UIPanelKind) {
|
func (m *Model) openPanel(kind event.UIPanelKind) {
|
||||||
m.showModal = true
|
m.showModal = true
|
||||||
m.panelKind = kind
|
m.panelKind = kind
|
||||||
m.panelIndex = 0
|
m.panelIndex = 0
|
||||||
m.promptActive = false
|
m.promptActive = false
|
||||||
m.promptSubmit = nil
|
m.promptSubmit = nil
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) refreshPanel() {
|
func (m *Model) refreshPanel() {
|
||||||
switch m.panelKind {
|
switch m.panelKind {
|
||||||
case event.UIPanelForward:
|
case event.UIPanelForward:
|
||||||
m.forwardItems = m.app.forward.List()
|
m.forwardItems = m.App.Forward().List()
|
||||||
m.panelIndex = clampIndex(m.panelIndex, len(m.forwardItems))
|
m.panelIndex = clampIndex(m.panelIndex, len(m.forwardItems))
|
||||||
case event.UIPanelPlugin:
|
case event.UIPanelPlugin:
|
||||||
m.pluginItems = m.app.plugins.List()
|
m.pluginItems = m.App.Plugins().List()
|
||||||
m.panelIndex = clampIndex(m.panelIndex, len(m.pluginItems))
|
m.panelIndex = clampIndex(m.panelIndex, len(m.pluginItems))
|
||||||
case event.UIPanelMode:
|
case event.UIPanelMode:
|
||||||
m.modeItems = m.buildModeItems()
|
m.modeItems = m.buildModeItems()
|
||||||
@@ -70,18 +74,19 @@ func (m *uiModel) refreshPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) buildModeItems() []modeItem {
|
func (m *Model) buildModeItems() []modeItem {
|
||||||
|
cfg := m.App.Cfg()
|
||||||
return []modeItem{
|
return []modeItem{
|
||||||
{"in", "Input Charset", m.app.cfg.InputCode, m.app.cfg.InputCode},
|
{"in", "Input Charset", cfg.InputCode, cfg.InputCode},
|
||||||
{"out", "Output Charset", m.app.cfg.OutputCode, m.app.cfg.OutputCode},
|
{"out", "Output Charset", cfg.OutputCode, cfg.OutputCode},
|
||||||
{"end", "Line End", fmt.Sprintf("%q", m.app.cfg.EndStr), m.app.cfg.EndStr},
|
{"end", "Line End", fmt.Sprintf("%q", cfg.EndStr), cfg.EndStr},
|
||||||
{"frame", "Hex Frame Size", fmt.Sprintf("%d", m.app.cfg.FrameSize), fmt.Sprintf("%d", m.app.cfg.FrameSize)},
|
{"frame", "Hex Frame Size", fmt.Sprintf("%d", cfg.FrameSize), fmt.Sprintf("%d", cfg.FrameSize)},
|
||||||
{"timestamp", "Timestamp", fmt.Sprintf("%v", m.app.cfg.TimesTamp), fmt.Sprintf("%v", m.app.cfg.TimesTamp)},
|
{"timestamp", "Timestamp", fmt.Sprintf("%v", cfg.TimesTamp), fmt.Sprintf("%v", cfg.TimesTamp)},
|
||||||
{"timefmt", "Timestamp Format", m.app.cfg.TimesFmt, m.app.cfg.TimesFmt},
|
{"timefmt", "Timestamp Format", cfg.TimesFmt, cfg.TimesFmt},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) handleForwardPanelKey(key string) bool {
|
func (m *Model) handleForwardPanelKey(key string) bool {
|
||||||
switch key {
|
switch key {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.panelIndex > 0 {
|
if m.panelIndex > 0 {
|
||||||
@@ -94,17 +99,27 @@ func (m *uiModel) handleForwardPanelKey(key string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
case "r":
|
case "r":
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
return true
|
return true
|
||||||
case "a":
|
case "a":
|
||||||
m.startPrompt("Add Forward", "tcp 127.0.0.1:12345", "", func(v string) {
|
m.startPrompt("Add Forward", "tcp 127.0.0.1:12345", "", func(v string) {
|
||||||
parts := strings.Fields(v)
|
parts := strings.Fields(v)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
m.app.Statusf("[forward] usage: <tcp|udp> <address>")
|
m.panelError = "usage: <tcp|udp> <address>"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.app.handleLine(fmt.Sprintf(".forward add %s %s", parts[0], parts[1]))
|
mode, ok := forward.ParseMode(parts[0])
|
||||||
|
if !ok {
|
||||||
|
m.panelError = "unknown mode: " + parts[0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := m.App.Forward().Add(mode, parts[1]); err != nil {
|
||||||
|
m.panelError = err.Error()
|
||||||
|
} else {
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -116,25 +131,47 @@ func (m *uiModel) handleForwardPanelKey(key string) bool {
|
|||||||
switch key {
|
switch key {
|
||||||
case "enter":
|
case "enter":
|
||||||
if sel.Enabled {
|
if sel.Enabled {
|
||||||
m.app.handleLine(fmt.Sprintf(".forward disable %d", sel.ID))
|
if err := m.App.Forward().Disable(sel.ID); err != nil {
|
||||||
} else {
|
m.panelError = err.Error()
|
||||||
m.app.handleLine(fmt.Sprintf(".forward enable %d", sel.ID))
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if err := m.App.Forward().Enable(sel.ID); err != nil {
|
||||||
|
m.panelError = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
return true
|
return true
|
||||||
case "d", "delete", "backspace":
|
case "d", "delete":
|
||||||
m.app.handleLine(fmt.Sprintf(".forward remove %d", sel.ID))
|
m.startPrompt("Remove Forward #"+fmt.Sprint(sel.ID), "type 'y' to confirm", "", func(v string) {
|
||||||
|
if strings.TrimSpace(strings.ToLower(v)) == "y" {
|
||||||
|
if err := m.App.Forward().Remove(sel.ID); err != nil {
|
||||||
|
m.panelError = err.Error()
|
||||||
|
} else {
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
case "u":
|
case "u":
|
||||||
m.startPrompt("Update Forward #"+fmt.Sprint(sel.ID), "tcp 127.0.0.1:12345", fmt.Sprintf("%s %s", sel.Mode, sel.Address), func(v string) {
|
m.startPrompt("Update Forward #"+fmt.Sprint(sel.ID), "tcp 127.0.0.1:12345", fmt.Sprintf("%s %s", sel.Mode, sel.Address), func(v string) {
|
||||||
parts := strings.Fields(v)
|
parts := strings.Fields(v)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
m.app.Statusf("[forward] usage: <tcp|udp> <address>")
|
m.panelError = "usage: <tcp|udp> <address>"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.app.handleLine(fmt.Sprintf(".forward update %d %s %s", sel.ID, parts[0], parts[1]))
|
mode, ok := forward.ParseMode(parts[0])
|
||||||
|
if !ok {
|
||||||
|
m.panelError = "unknown mode: " + parts[0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.App.Forward().Update(sel.ID, mode, parts[1]); err != nil {
|
||||||
|
m.panelError = err.Error()
|
||||||
|
} else {
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
@@ -142,7 +179,7 @@ func (m *uiModel) handleForwardPanelKey(key string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) handlePluginPanelKey(key string) bool {
|
func (m *Model) handlePluginPanelKey(key string) bool {
|
||||||
switch key {
|
switch key {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.panelIndex > 0 {
|
if m.panelIndex > 0 {
|
||||||
@@ -155,17 +192,22 @@ func (m *uiModel) handlePluginPanelKey(key string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
case "r":
|
case "r":
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
return true
|
return true
|
||||||
case "l":
|
case "l":
|
||||||
m.startPrompt("Load Plugin", "./plugins/demo.lua", "", func(v string) {
|
m.startPrompt("Load Plugin", "./plugins/demo.lua", "", func(v string) {
|
||||||
path := strings.TrimSpace(v)
|
path := strings.TrimSpace(v)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
m.app.Statusf("[plugin] load path is empty")
|
m.panelError = "load path is empty"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.app.handleLine(fmt.Sprintf(".plugin load %s", path))
|
if _, err := m.App.Plugins().Load(path); err != nil {
|
||||||
|
m.panelError = err.Error()
|
||||||
|
} else {
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -177,26 +219,39 @@ func (m *uiModel) handlePluginPanelKey(key string) bool {
|
|||||||
switch key {
|
switch key {
|
||||||
case "enter":
|
case "enter":
|
||||||
if sel.Enabled {
|
if sel.Enabled {
|
||||||
m.app.handleLine(fmt.Sprintf(".plugin disable %s", sel.Name))
|
_ = m.App.Plugins().Disable(sel.Name)
|
||||||
} else {
|
} else {
|
||||||
m.app.handleLine(fmt.Sprintf(".plugin enable %s", sel.Name))
|
_ = m.App.Plugins().Enable(sel.Name)
|
||||||
}
|
}
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
return true
|
return true
|
||||||
case "u":
|
case "u":
|
||||||
m.app.handleLine(fmt.Sprintf(".plugin reload %s", sel.Name))
|
if err := m.App.Plugins().Reload(sel.Name); err != nil {
|
||||||
|
m.panelError = err.Error()
|
||||||
|
} else {
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
case "d", "delete", "backspace":
|
case "d", "delete":
|
||||||
m.app.handleLine(fmt.Sprintf(".plugin unload %s", sel.Name))
|
m.startPrompt("Unload Plugin "+sel.Name, "type 'y' to confirm", "", func(v string) {
|
||||||
|
if strings.TrimSpace(strings.ToLower(v)) == "y" {
|
||||||
|
if err := m.App.Plugins().Unload(sel.Name); err != nil {
|
||||||
|
m.panelError = err.Error()
|
||||||
|
} else {
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) handleModePanelKey(key string) bool {
|
func (m *Model) handleModePanelKey(key string) bool {
|
||||||
switch key {
|
switch key {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.panelIndex > 0 {
|
if m.panelIndex > 0 {
|
||||||
@@ -209,6 +264,7 @@ func (m *uiModel) handleModePanelKey(key string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
case "r":
|
case "r":
|
||||||
|
m.panelError = ""
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -217,21 +273,27 @@ func (m *uiModel) handleModePanelKey(key string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sel := m.modeItems[m.panelIndex]
|
sel := m.modeItems[m.panelIndex]
|
||||||
|
cfg := m.App.Cfg()
|
||||||
switch key {
|
switch key {
|
||||||
case " ":
|
case " ":
|
||||||
if sel.key == "timestamp" {
|
if sel.key == "timestamp" {
|
||||||
if m.app.cfg.TimesTamp {
|
cfg.TimesTamp = !cfg.TimesTamp
|
||||||
m.app.handleLine(".mode set timestamp off")
|
|
||||||
} else {
|
|
||||||
m.app.handleLine(".mode set timestamp on")
|
|
||||||
}
|
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
case "enter", "e":
|
case "enter", "e":
|
||||||
|
hint := "enter value"
|
||||||
|
switch sel.key {
|
||||||
|
case "timestamp":
|
||||||
|
hint = "on/off"
|
||||||
|
case "frame":
|
||||||
|
hint = "positive integer"
|
||||||
|
case "in", "out":
|
||||||
|
hint = "charset name (e.g. utf-8, gbk)"
|
||||||
|
}
|
||||||
initial := sel.rawValue
|
initial := sel.rawValue
|
||||||
m.startPrompt("Edit Mode: "+sel.label, "new value", initial, func(v string) {
|
m.startPrompt("Edit Mode: "+sel.label, hint, initial, func(v string) {
|
||||||
m.app.handleLine(fmt.Sprintf(".mode set %s %s", sel.key, v))
|
m.App.HandleLine(fmt.Sprintf(".mode set %s %s", sel.key, v))
|
||||||
m.refreshPanel()
|
m.refreshPanel()
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
@@ -240,7 +302,7 @@ func (m *uiModel) handleModePanelKey(key string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) startPrompt(title, hint, initial string, submit func(string)) {
|
func (m *Model) startPrompt(title, hint, initial string, submit func(string)) {
|
||||||
in := textinput.New()
|
in := textinput.New()
|
||||||
in.Prompt = "> "
|
in.Prompt = "> "
|
||||||
in.Placeholder = hint
|
in.Placeholder = hint
|
||||||
@@ -256,13 +318,13 @@ func (m *uiModel) startPrompt(title, hint, initial string, submit func(string))
|
|||||||
m.promptSubmit = submit
|
m.promptSubmit = submit
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) handlePromptKey(msg tea.KeyMsg) bool {
|
func (m *Model) handlePromptKey(msg tea.KeyMsg) (bool, tea.Cmd) {
|
||||||
key := strings.ToLower(msg.String())
|
key := strings.ToLower(msg.String())
|
||||||
switch key {
|
switch key {
|
||||||
case "esc":
|
case "esc":
|
||||||
m.promptActive = false
|
m.promptActive = false
|
||||||
m.promptSubmit = nil
|
m.promptSubmit = nil
|
||||||
return true
|
return true, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
value := strings.TrimSpace(m.promptInput.Value())
|
value := strings.TrimSpace(m.promptInput.Value())
|
||||||
submit := m.promptSubmit
|
submit := m.promptSubmit
|
||||||
@@ -271,16 +333,15 @@ func (m *uiModel) handlePromptKey(msg tea.KeyMsg) bool {
|
|||||||
if submit != nil {
|
if submit != nil {
|
||||||
submit(value)
|
submit(value)
|
||||||
}
|
}
|
||||||
return true
|
return true, nil
|
||||||
default:
|
default:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.promptInput, cmd = m.promptInput.Update(msg)
|
m.promptInput, cmd = m.promptInput.Update(msg)
|
||||||
_ = cmd
|
return true, cmd
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) renderPanel() string {
|
func (m *Model) renderPanel() string {
|
||||||
switch m.panelKind {
|
switch m.panelKind {
|
||||||
case event.UIPanelForward:
|
case event.UIPanelForward:
|
||||||
return m.renderForwardPanel()
|
return m.renderForwardPanel()
|
||||||
@@ -293,21 +354,24 @@ func (m *uiModel) renderPanel() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) renderForwardPanel() string {
|
func (m *Model) renderForwardPanel() string {
|
||||||
lines := make([]panelLine, 0, len(m.forwardItems)+2)
|
lines := make([]panelLine, 0, len(m.forwardItems)+3)
|
||||||
if len(m.forwardItems) == 0 {
|
if len(m.forwardItems) == 0 {
|
||||||
lines = append(lines, panelLine{text: "No forwarding targets. Press 'a' to add one."})
|
lines = append(lines, panelLine{text: "No forwarding targets. Press 'a' to add one."})
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, panelLine{text: "ID Mode Enabled Connected Address InBytes OutBytes"})
|
lines = append(lines, panelLine{text: "ID Mode Enabled Connected Address"})
|
||||||
for i, it := range m.forwardItems {
|
for i, it := range m.forwardItems {
|
||||||
lines = append(lines, panelLine{text: fmt.Sprintf("%-3d %-5s %-7v %-9v %-22s %-7d %-8d", it.ID, it.Mode, it.Enabled, it.Connected, it.Address, it.ReadBytes, it.WriteByte), selected: i == m.panelIndex})
|
lines = append(lines, panelLine{text: fmt.Sprintf("%-3d %-5s %-7v %-9v %s", it.ID, it.Mode, it.Enabled, it.Connected, it.Address), selected: i == m.panelIndex})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return renderPanelModal("Forward Panel", lines, "Up/Down select | Enter toggle enable | a add | u update | d remove | r refresh | Esc close", m.availableModalWidth())
|
if m.panelError != "" {
|
||||||
|
lines = append(lines, panelLine{text: "ERROR: " + m.panelError})
|
||||||
|
}
|
||||||
|
return renderPanelModal("Forward Panel", lines, "Up/Down select | Enter toggle | a add | u update | d remove | r refresh | Esc close", m.availableModalWidth())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) renderPluginPanel() string {
|
func (m *Model) renderPluginPanel() string {
|
||||||
lines := make([]panelLine, 0, len(m.pluginItems)+2)
|
lines := make([]panelLine, 0, len(m.pluginItems)+3)
|
||||||
if len(m.pluginItems) == 0 {
|
if len(m.pluginItems) == 0 {
|
||||||
lines = append(lines, panelLine{text: "No plugins loaded. Press 'l' to load one."})
|
lines = append(lines, panelLine{text: "No plugins loaded. Press 'l' to load one."})
|
||||||
} else {
|
} else {
|
||||||
@@ -316,14 +380,21 @@ func (m *uiModel) renderPluginPanel() string {
|
|||||||
lines = append(lines, panelLine{text: fmt.Sprintf("%-20s %-7v %s", it.Name, it.Enabled, it.Path), selected: i == m.panelIndex})
|
lines = append(lines, panelLine{text: fmt.Sprintf("%-20s %-7v %s", it.Name, it.Enabled, it.Path), selected: i == m.panelIndex})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return renderPanelModal("Plugin Panel", lines, "Up/Down select | Enter toggle enable | l load | u reload | d unload | r refresh | Esc close", m.availableModalWidth())
|
if m.panelError != "" {
|
||||||
|
lines = append(lines, panelLine{text: "ERROR: " + m.panelError})
|
||||||
|
}
|
||||||
|
return renderPanelModal("Plugin Panel", lines, "Up/Down select | Enter toggle | l load | u reload | d unload | r refresh | Esc close", m.availableModalWidth())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) renderModePanel() string {
|
func (m *Model) renderModePanel() string {
|
||||||
lines := make([]panelLine, 0, len(m.modeItems)+2)
|
lines := make([]panelLine, 0, len(m.modeItems)+3)
|
||||||
lines = append(lines, panelLine{text: "Field Value"})
|
lines = append(lines, panelLine{text: "Field Value"})
|
||||||
for i, it := range m.modeItems {
|
for i, it := range m.modeItems {
|
||||||
lines = append(lines, panelLine{text: fmt.Sprintf("%-16s %s", it.label, it.value), selected: i == m.panelIndex})
|
lines = append(lines, panelLine{text: fmt.Sprintf("%-16s %s", it.label, it.value), selected: i == m.panelIndex})
|
||||||
}
|
}
|
||||||
return renderPanelModal("Mode Panel", lines, "Up/Down select | Enter edit value | Space toggle timestamp | r refresh | Esc close", m.availableModalWidth())
|
if m.panelError != "" {
|
||||||
|
lines = append(lines, panelLine{text: "ERROR: " + m.panelError})
|
||||||
|
}
|
||||||
|
return renderPanelModal("Mode Panel", lines, "Up/Down select | Enter edit | Space toggle | r refresh | Esc close", m.availableModalWidth())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package termapp
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *uiModel) appendOutput(text string) {
|
func (m *Model) appendOutput(text string) {
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ func (m *uiModel) appendOutput(text string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) renderPrompt() string {
|
func (m *Model) renderPrompt() string {
|
||||||
lines := []boxLine{
|
lines := []boxLine{
|
||||||
{text: m.promptHint, style: modalBodyLineStyle()},
|
{text: m.promptHint, style: modalBodyLineStyle()},
|
||||||
{text: m.promptInput.View(), style: modalBodyLineStyle()},
|
{text: m.promptInput.View(), style: modalBodyLineStyle()},
|
||||||
@@ -80,37 +80,13 @@ func renderCenteredModalContent(width, height int, content string) string {
|
|||||||
if width <= 0 || height <= 0 {
|
if width <= 0 || height <= 0 {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content,
|
||||||
lines := strings.Split(content, "\n")
|
lipgloss.WithWhitespaceChars(" "),
|
||||||
blockWidth := 0
|
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
|
||||||
for _, line := range lines {
|
)
|
||||||
blockWidth = maxInt(blockWidth, lipgloss.Width(line))
|
|
||||||
}
|
|
||||||
blockHeight := len(lines)
|
|
||||||
leftPad := 0
|
|
||||||
if width > blockWidth {
|
|
||||||
leftPad = (width - blockWidth) / 2
|
|
||||||
}
|
|
||||||
topPad := 0
|
|
||||||
if height > blockHeight {
|
|
||||||
topPad = (height - blockHeight) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
for i := 0; i < topPad; i++ {
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
for i, line := range lines {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
b.WriteString(strings.Repeat(" ", leftPad))
|
|
||||||
b.WriteString(line)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *uiModel) availableModalWidth() int {
|
func (m *Model) availableModalWidth() int {
|
||||||
if m.width <= 0 {
|
if m.width <= 0 {
|
||||||
return 100
|
return 100
|
||||||
}
|
}
|
||||||
@@ -134,8 +110,9 @@ func renderBox(title string, lines []boxLine, minWidth, maxWidth int) string {
|
|||||||
contentWidth = maxInt(minWidth, contentWidth)
|
contentWidth = maxInt(minWidth, contentWidth)
|
||||||
contentWidth = minInt(contentWidth, maxWidth)
|
contentWidth = minInt(contentWidth, maxWidth)
|
||||||
|
|
||||||
top := "╭" + strings.Repeat("─", contentWidth+2) + "╮"
|
boxStyle := lipgloss.NewStyle().Background(lipgloss.Color("236"))
|
||||||
bottom := "╰" + strings.Repeat("─", contentWidth+2) + "╯"
|
top := boxStyle.Render("╭" + strings.Repeat("─", contentWidth+2) + "╮")
|
||||||
|
bottom := boxStyle.Render("╰" + strings.Repeat("─", contentWidth+2) + "╯")
|
||||||
|
|
||||||
rows := make([]string, 0, len(lines)+3)
|
rows := make([]string, 0, len(lines)+3)
|
||||||
rows = append(rows, top)
|
rows = append(rows, top)
|
||||||
@@ -150,8 +127,8 @@ func renderBox(title string, lines []boxLine, minWidth, maxWidth int) string {
|
|||||||
func renderBoxRow(contentStyle lipgloss.Style, text string, width int) string {
|
func renderBoxRow(contentStyle lipgloss.Style, text string, width int) string {
|
||||||
visible := truncateToWidth(text, width)
|
visible := truncateToWidth(text, width)
|
||||||
pad := strings.Repeat(" ", maxInt(0, width-lipgloss.Width(visible)))
|
pad := strings.Repeat(" ", maxInt(0, width-lipgloss.Width(visible)))
|
||||||
inner := contentStyle.Render(visible) + pad
|
inner := contentStyle.Render(" " + visible + pad + " ")
|
||||||
return "│ " + inner + " │"
|
return contentStyle.Render("│" + inner + "│")
|
||||||
}
|
}
|
||||||
|
|
||||||
func modalHeaderLineStyle() lipgloss.Style {
|
func modalHeaderLineStyle() lipgloss.Style {
|
||||||
@@ -170,7 +147,6 @@ func selectedPanelLineStyle() lipgloss.Style {
|
|||||||
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("31"))
|
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("31"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func truncateToWidth(s string, width int) string {
|
func truncateToWidth(s string, width int) string {
|
||||||
if width <= 0 || lipgloss.Width(s) <= width {
|
if width <= 0 || lipgloss.Width(s) <= width {
|
||||||
return s
|
return s
|
||||||
Reference in New Issue
Block a user