This is an automated email from the ASF dual-hosted git repository. sruehl pushed a commit to branch feat/charmbraclet_migration in repository https://gitbox.apache.org/repos/asf/plc4x-extras.git
commit a3ae8106f575f9f4069a594cdb58866fa9e04fdd Author: Sebastian Rühl <[email protected]> AuthorDate: Tue Nov 25 13:48:36 2025 +0100 feat(plc4go): charmbracelet for plc4xpcapanalyzer --- plc4go/tools/plc4xpcapanalyzer/cmd/ui.go | 31 +- plc4go/tools/plc4xpcapanalyzer/ui/actions.go | 28 +- plc4go/tools/plc4xpcapanalyzer/ui/commands.go | 29 +- plc4go/tools/plc4xpcapanalyzer/ui/common.go | 9 + plc4go/tools/plc4xpcapanalyzer/ui/dispatcher.go | 55 ++ plc4go/tools/plc4xpcapanalyzer/ui/log_buffer.go | 63 ++ plc4go/tools/plc4xpcapanalyzer/ui/output.go | 69 ++ plc4go/tools/plc4xpcapanalyzer/ui/program.go | 860 ++++++++++++++++++++++++ 8 files changed, 1130 insertions(+), 14 deletions(-) diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go b/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go index 1e65863..17ea41e 100644 --- a/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go @@ -29,6 +29,8 @@ import ( "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/ui" ) +var legacyUI bool + // uiCmd represents the ui command var uiCmd = &cobra.Command{ Use: "ui [pcapfile]", @@ -48,20 +50,40 @@ TODO: document me }, Run: func(cmd *cobra.Command, args []string) { ui.LoadConfig() - application := ui.SetupApplication() + + if legacyUI { + application := ui.SetupApplication() + ui.InitSubsystem() + ui.LegacyUI = true + if len(args) > 0 { + pcapFile := args[0] + go func() { + if err := ui.OpenFile(pcapFile); err != nil { + log.Error().Err(err).Msg("Error opening argument file") + } + }() + } + + defer ui.Shutdown() + if err := application.Run(); err != nil { + panic(err) + } + return + } + + program := ui.SetupProgram() ui.InitSubsystem() if len(args) > 0 { pcapFile := args[0] go func() { - err := ui.OpenFile(pcapFile) - if err != nil { + if err := ui.OpenFile(pcapFile); err != nil { log.Error().Err(err).Msg("Error opening argument file") } }() } defer ui.Shutdown() - if err := application.Run(); err != nil { + if _, err := program.Run(); err != nil { panic(err) } }, @@ -69,4 +91,5 @@ TODO: document me func init() { rootCmd.AddCommand(uiCmd) + uiCmd.Flags().BoolVar(&legacyUI, "legacy-ui", true, "Use the legacy tview-based UI") } diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/actions.go b/plc4go/tools/plc4xpcapanalyzer/ui/actions.go index be91014..be1f19c 100644 --- a/plc4go/tools/plc4xpcapanalyzer/ui/actions.go +++ b/plc4go/tools/plc4xpcapanalyzer/ui/actions.go @@ -52,7 +52,11 @@ func InitSubsystem() { //With().Caller().Logger(). Output(zerolog.NewConsoleWriter( func(w *zerolog.ConsoleWriter) { - w.Out = tview.ANSIWriter(consoleOutput) + out := consoleOutput + if IsLegacyUI() { + out = tview.ANSIWriter(out) + } + w.Out = out }, func(w *zerolog.ConsoleWriter) { w.FormatFieldValue = func(i interface{}) string { @@ -77,7 +81,9 @@ func InitSubsystem() { // We offset the commands executed with the last commands commandsExecuted = len(config.History.Last10Commands) - outputCommandHistory() + if IsLegacyUI() { + outputCommandHistory() + } for _, driver := range config.AutoRegisterDrivers { log.Info().Str("driver", driver).Msg("Auto register driver") @@ -117,9 +123,16 @@ func OpenFile(pcapFile string) error { } func outputCommandHistory() { - _, _ = fmt.Fprintln(commandOutput, "[#0000ff]Last 10 commands[white]") + if IsLegacyUI() { + _, _ = fmt.Fprintln(commandOutput, "[#0000ff]Last 10 commands[white]") + for i, command := range config.History.Last10Commands { + _, _ = fmt.Fprintf(commandOutput, " [#00ff00]%d[white]: [\"%d\"]%s[\"\"]\n", i, i, tview.Escape(command)) + } + return + } + _, _ = fmt.Fprintln(commandOutput, "Last 10 commands") for i, command := range config.History.Last10Commands { - _, _ = fmt.Fprintf(commandOutput, " [#00ff00]%d[white]: [\"%d\"]%s[\"\"]\n", i, i, tview.Escape(command)) + _, _ = fmt.Fprintf(commandOutput, " %d: %s\n", i, command) } } @@ -146,6 +159,13 @@ func registerDriver(driver string) error { return errors.Errorf("Unknown driver %s", driver) } driverManager.(spi.TransportAware).RegisterTransport(pcap.NewTransport()) + for _, existing := range registeredDriverNames { + if existing == driver { + go driverAdded(driver) + return nil + } + } + registeredDriverNames = append(registeredDriverNames, driver) go driverAdded(driver) return nil } diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/commands.go b/plc4go/tools/plc4xpcapanalyzer/ui/commands.go index fe68a6e..79fafb7 100644 --- a/plc4go/tools/plc4xpcapanalyzer/ui/commands.go +++ b/plc4go/tools/plc4xpcapanalyzer/ui/commands.go @@ -62,10 +62,18 @@ var rootCommand = Command{ isDir := dirEntry.IsDir() name := dirEntry.Name() name = strings.TrimPrefix(name, dir) - if isDir { - name = fmt.Sprintf("[#0000ff]%s[white]", name) - } else if strings.HasSuffix(name, ".pcap") || strings.HasSuffix(name, ".pcapng") { - name = fmt.Sprintf("[#00ff00]%s[white]", name) + if IsLegacyUI() { + if isDir { + name = fmt.Sprintf("[#0000ff]%s[white]", name) + } else if strings.HasSuffix(name, ".pcap") || strings.HasSuffix(name, ".pcapng") { + name = fmt.Sprintf("[#00ff00]%s[white]", name) + } + } else { + if isDir { + name = fmt.Sprintf("[dir] %s", name) + } else if strings.HasSuffix(name, ".pcap") || strings.HasSuffix(name, ".pcapng") { + name = fmt.Sprintf("[pcap] %s", name) + } } _, _ = fmt.Fprintf(commandOutput, "%s\n", name) } @@ -171,7 +179,13 @@ var rootCommand = Command{ cliConfig.RootConfigInstance.HideProgressBar = true // disabled as we get this output anyway with the message call back //cliConfig.RootConfigInstance.Verbosity = 4 - return analyzer.AnalyzeWithOutputAndCallback(ctx, pcapFile, protocolType, tview.ANSIWriter(messageOutput), tview.ANSIWriter(messageOutput), func(parsed spi.Message) { + if IsLegacyUI() { + return analyzer.AnalyzeWithOutputAndCallback(ctx, pcapFile, protocolType, tview.ANSIWriter(messageOutput), tview.ANSIWriter(messageOutput), func(parsed spi.Message) { + spiNumberOfMessagesReceived++ + spiMessageReceived(spiNumberOfMessagesReceived, time.Now(), parsed) + }) + } + return analyzer.AnalyzeWithOutputAndCallback(ctx, pcapFile, protocolType, newANSIStrippingWriter(messageOutput), newANSIStrippingWriter(messageOutput), func(parsed spi.Message) { spiNumberOfMessagesReceived++ spiMessageReceived(spiNumberOfMessagesReceived, time.Now(), parsed) }) @@ -198,7 +212,10 @@ var rootCommand = Command{ cliConfig.PcapConfigInstance.Client = config.HostIp cliConfig.RootConfigInstance.HideProgressBar = true cliConfig.RootConfigInstance.Verbosity = 4 - return extractor.ExtractWithOutput(ctx, pcapFile, protocolType, tview.ANSIWriter(messageOutput), tview.ANSIWriter(messageOutput)) + if IsLegacyUI() { + return extractor.ExtractWithOutput(ctx, pcapFile, protocolType, tview.ANSIWriter(messageOutput), tview.ANSIWriter(messageOutput)) + } + return extractor.ExtractWithOutput(ctx, pcapFile, protocolType, newANSIStrippingWriter(messageOutput), newANSIStrippingWriter(messageOutput)) }, parameterSuggestions: func(currentText string) (entries []string) { for _, file := range loadedPcapFiles { diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/common.go b/plc4go/tools/plc4xpcapanalyzer/ui/common.go index ff8cdd4..2d7b8b9 100644 --- a/plc4go/tools/plc4xpcapanalyzer/ui/common.go +++ b/plc4go/tools/plc4xpcapanalyzer/ui/common.go @@ -33,14 +33,23 @@ import ( "github.com/rs/zerolog" ) +var LegacyUI bool + +func IsLegacyUI() bool { + return LegacyUI +} + const protocols = "ads,bacnetip,c-bus,s7" var protocolList = strings.Split(protocols, ",") +var dispatcher = newDispatcher() + var plc4xpcapanalyzerLog = zerolog.Nop() var driverManager plc4go.PlcDriverManager var driverAdded func(string) +var registeredDriverNames []string type loadedPcapFile struct { name string diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/dispatcher.go b/plc4go/tools/plc4xpcapanalyzer/ui/dispatcher.go new file mode 100644 index 0000000..df898f8 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/dispatcher.go @@ -0,0 +1,55 @@ +package ui + +import ( + "sync" + + tea "github.com/charmbracelet/bubbletea" +) + +type messageDispatcher struct { + mu sync.Mutex + program *tea.Program + buffer []tea.Msg + queue chan tea.Msg +} + +func newDispatcher() *messageDispatcher { + d := &messageDispatcher{ + queue: make(chan tea.Msg, 128), + } + go d.run() + return d +} + +func (d *messageDispatcher) setProgram(program *tea.Program) { + d.mu.Lock() + d.program = program + buffered := append([]tea.Msg(nil), d.buffer...) + d.buffer = nil + d.mu.Unlock() + + go func() { + for _, msg := range buffered { + d.queue <- msg + } + }() +} + +func (d *messageDispatcher) send(msg tea.Msg) { + d.queue <- msg +} + +func (d *messageDispatcher) run() { + for msg := range d.queue { + d.mu.Lock() + program := d.program + if program == nil { + d.buffer = append(d.buffer, msg) + d.mu.Unlock() + continue + } + d.mu.Unlock() + + program.Send(msg) + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/log_buffer.go b/plc4go/tools/plc4xpcapanalyzer/ui/log_buffer.go new file mode 100644 index 0000000..f0608d5 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/log_buffer.go @@ -0,0 +1,63 @@ +package ui + +type logBuffer struct { + lines []string + max int +} + +func newLogBuffer(max int) logBuffer { + return logBuffer{lines: []string{""}, max: max} +} + +func (b *logBuffer) append(text string) { + if len(b.lines) == 0 { + b.lines = []string{""} + } + remaining := text + for len(remaining) > 0 { + newline := -1 + for i := 0; i < len(remaining); i++ { + if remaining[i] == '\n' { + newline = i + break + } + } + if newline == -1 { + b.lines[len(b.lines)-1] += remaining + break + } + b.lines[len(b.lines)-1] += remaining[:newline] + b.lines = append(b.lines, "") + remaining = remaining[newline+1:] + } + b.trim() +} + +func (b *logBuffer) clear() { + b.lines = []string{""} +} + +func (b *logBuffer) trim() { + if b.max <= 0 { + return + } + if len(b.lines) <= b.max { + return + } + offset := len(b.lines) - b.max + b.lines = append([]string(nil), b.lines[offset:]...) + if len(b.lines) == 0 { + b.lines = []string{""} + } +} + +func (b logBuffer) String() string { + result := "" + for i, line := range b.lines { + if i > 0 { + result += "\n" + } + result += line + } + return result +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/output.go b/plc4go/tools/plc4xpcapanalyzer/ui/output.go new file mode 100644 index 0000000..0608544 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/output.go @@ -0,0 +1,69 @@ +package ui + +import ( + "io" + "regexp" +) + +type outputTarget int + +const ( + targetCommand outputTarget = iota + targetConsole + targetMessage +) + +type appendOutputMsg struct { + target outputTarget + text string +} + +type clearOutputMsg struct { + target outputTarget +} + +type programWriter struct { + target outputTarget +} + +func (w *programWriter) Write(p []byte) (int, error) { + dispatcher.send(appendOutputMsg{target: w.target, text: string(p)}) + return len(p), nil +} + +func newWriter(target outputTarget) io.Writer { + return &programWriter{target: target} +} + +var ansiSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +type ansiStrippingWriter struct { + target io.Writer +} + +func (w *ansiStrippingWriter) Write(p []byte) (int, error) { + if !ansiSequencePattern.Match(p) { + return w.target.Write(p) + } + clean := ansiSequencePattern.ReplaceAll(p, nil) + n, err := w.target.Write(clean) + // Ensure we report having consumed the original input length when possible. + if err != nil { + // Best effort to translate number of bytes reported back to the original slice length. + return len(p) - (len(clean) - n), err + } + return len(p), nil +} + +func newANSIStrippingWriter(target io.Writer) io.Writer { + if target == nil { + return nil + } + return &ansiStrippingWriter{target: target} +} + +func makeClearFunc(target outputTarget) func() { + return func() { + dispatcher.send(clearOutputMsg{target: target}) + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/program.go b/plc4go/tools/plc4xpcapanalyzer/ui/program.go new file mode 100644 index 0000000..b7d1283 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/program.go @@ -0,0 +1,860 @@ +package ui + +import ( + "context" + "fmt" + "math/rand" + "regexp" + "strconv" + "strings" + "time" + + apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" + "github.com/apache/plc4x/plc4go/spi" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type fileEntry struct { + Name string + Path string +} + +type messageEntry struct { + Number int + Timestamp time.Time + Origin string +} + +type filesUpdatedMsg struct { + Files []fileEntry +} + +type driversUpdatedMsg struct { + Drivers []string +} + +type messageArrivedMsg struct { + Entry messageEntry +} + +type commandExecResultMsg struct { + Command string + Err error + cancelID uint32 +} + +var commandHistoryShortcut = regexp.MustCompile("^[0-9]$") + +var ( + textColor = lipgloss.Color("15") + accentColor = lipgloss.Color("12") + highlightColor = lipgloss.Color("10") + placeholderColor = lipgloss.Color("242") + + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(textColor).Align(lipgloss.Center) + subtitleStyle = lipgloss.NewStyle().Foreground(textColor).Align(lipgloss.Center) + sectionTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(accentColor) + sectionBodyStyle = lipgloss.NewStyle().MarginLeft(2) + suggestionStyle = lipgloss.NewStyle().MarginLeft(2).Foreground(highlightColor) + columnTitleStyle = lipgloss.NewStyle().Bold(true).Align(lipgloss.Center).Foreground(textColor) + panelStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(textColor).Padding(0, 1) + placeholderStyle = lipgloss.NewStyle().MarginLeft(2).Foreground(placeholderColor).Italic(true) + historyIndexStyle = lipgloss.NewStyle().Foreground(highlightColor).Bold(true).MarginLeft(2).Width(2).Align(lipgloss.Right) + historyGapStyle = lipgloss.NewStyle().Foreground(textColor) + historyTextStyle = lipgloss.NewStyle().Foreground(textColor) +) + +type model struct { + width int + height int + + commandLog logBuffer + consoleLog logBuffer + messageLog logBuffer + + files []fileEntry + drivers []string + messages []messageEntry + + commandInput textinput.Model + history []string + historyIndex int + + suggestions []string + suggestionIndex int + + commandViewport viewport.Model + payloadViewport viewport.Model + consoleViewport viewport.Model +} + +func newModel() *model { + input := textinput.New() + input.Prompt = "$ " + input.Placeholder = "" + input.CharLimit = 0 + input.Focus() + + history := append([]string(nil), config.History.Last10Commands...) + + cmd := viewport.New(0, 0) + cmd.Style = lipgloss.NewStyle() + + payload := viewport.New(0, 0) + payload.Style = lipgloss.NewStyle() + + console := viewport.New(0, 0) + console.Style = lipgloss.NewStyle() + + mdl := &model{ + commandLog: newLogBuffer(config.MaxOutputLines), + consoleLog: newLogBuffer(config.MaxConsoleLines), + messageLog: newLogBuffer(config.MaxOutputLines), + files: snapshotFiles(), + drivers: snapshotDrivers(), + commandInput: input, + history: history, + historyIndex: len(history), + suggestions: nil, + suggestionIndex: -1, + commandViewport: cmd, + payloadViewport: payload, + consoleViewport: console, + } + + mdl.updateCommandViewportContent() + mdl.updatePayloadViewportContent() + mdl.updateConsoleViewportContent() + + return mdl +} + +func SetupProgram() *tea.Program { + mdl := newModel() + program := tea.NewProgram(mdl, tea.WithAltScreen(), tea.WithMouseCellMotion()) + dispatcher.setProgram(program) + + commandOutput = newWriter(targetCommand) + commandOutputClear = makeClearFunc(targetCommand) + consoleOutput = newWriter(targetConsole) + consoleOutputClear = makeClearFunc(targetConsole) + messageOutput = newWriter(targetMessage) + messageOutputClear = makeClearFunc(targetMessage) + + loadedPcapFilesChanged = func() { + dispatcher.send(filesUpdatedMsg{Files: snapshotFiles()}) + } + driverAdded = func(driver string) { + dispatcher.send(driversUpdatedMsg{Drivers: snapshotDrivers()}) + } + messageReceived = func(messageNumber int, receiveTime time.Time, message apiModel.PlcMessage) { + dispatcher.send(messageArrivedMsg{Entry: messageEntry{Number: messageNumber, Timestamp: receiveTime, Origin: "api"}}) + fmt.Fprintf(messageOutput, "API message #%d (%s)\n%v\n", messageNumber, receiveTime.Format("15:04:05.999999"), message) + } + spiMessageReceived = func(messageNumber int, receiveTime time.Time, message spi.Message) { + dispatcher.send(messageArrivedMsg{Entry: messageEntry{Number: messageNumber, Timestamp: receiveTime, Origin: "spi"}}) + fmt.Fprintf(messageOutput, "SPI message #%d (%s)\n%v\n", messageNumber, receiveTime.Format("15:04:05.999999"), message) + } + + dispatcher.send(filesUpdatedMsg{Files: snapshotFiles()}) + dispatcher.send(driversUpdatedMsg{Drivers: snapshotDrivers()}) + + return program +} + +func (m *model) Init() tea.Cmd { + return nil +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.updateLayout() + case tea.KeyMsg: + return m.handleKey(msg) + case tea.MouseMsg: + var cmds []tea.Cmd + var cmd tea.Cmd + m.commandViewport, cmd = m.commandViewport.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + m.payloadViewport, cmd = m.payloadViewport.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + m.consoleViewport, cmd = m.consoleViewport.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + case appendOutputMsg: + m.handleAppendOutput(msg) + case clearOutputMsg: + m.handleClearOutput(msg) + case filesUpdatedMsg: + m.files = append([]fileEntry(nil), msg.Files...) + case driversUpdatedMsg: + m.drivers = append([]string(nil), msg.Drivers...) + case messageArrivedMsg: + m.messages = append(m.messages, msg.Entry) + if len(m.messages) > 200 { + m.messages = append([]messageEntry(nil), m.messages[len(m.messages)-200:]...) + } + case commandExecResultMsg: + delete(cancelFunctions, msg.cancelID) + if msg.Err != nil { + fmt.Fprintf(commandOutput, "%s %v\n", time.Now().Format("15:04"), msg.Err) + return m, nil + } + m.commandInput.SetValue("") + m.commandInput.CursorEnd() + m.history = append(m.history, msg.Command) + if len(m.history) > 100 { + m.history = append([]string(nil), m.history[len(m.history)-100:]...) + } + m.historyIndex = len(m.history) + m.updateCommandViewportContent() + m.updateLayout() + } + return m, nil +} + +func (m *model) View() string { + width := m.width + if width <= 0 { + width = 120 + } + header := titleStyle.Width(width).PaddingBottom(1).Render("PLC4X PCAP Analyzer") + footer := subtitleStyle.Width(width).PaddingTop(1).Render("https://github.com/apache/plc4x") + availableHeight := m.height - lipgloss.Height(header) - lipgloss.Height(footer) + if availableHeight < 8 { + availableHeight = m.height / 2 + if availableHeight < 8 { + availableHeight = 8 + } + } + + const gapWidth = 1 + leftWidth, centerWidth, rightWidth := computeColumnWidths(width, gapWidth) + + leftPanel := renderPanel("", []panelSection{ + {title: "Files", body: renderFiles(m.files)}, + {title: "Registered drivers", body: renderDriverList(m.drivers)}, + }, max(leftWidth-2, 0), availableHeight) + + centerPanel := m.renderOutputPanel(max(centerWidth-2, 0), availableHeight) + + rightPanel := m.renderCommandPanel(max(rightWidth-2, 0), availableHeight) + + gap := lipgloss.NewStyle().Width(gapWidth).Render("") + columns := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, gap, centerPanel, gap, rightPanel) + columns = lipgloss.Place(width, availableHeight, lipgloss.Center, lipgloss.Top, columns) + content := lipgloss.JoinVertical(lipgloss.Left, header, columns, footer) + if m.height > 0 { + content = lipgloss.Place(width, m.height, lipgloss.Center, lipgloss.Top, content) + } + return content +} + +func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC, tea.KeyCtrlD: + return m, tea.Quit + case tea.KeyEnter: + return m.submitCommand() + case tea.KeyTab: + return m.completeCommand(false) + case tea.KeyShiftTab: + return m.completeCommand(true) + case tea.KeyPgUp: + m.commandViewport.PageUp() + return m, nil + case tea.KeyPgDown: + m.commandViewport.PageDown() + return m, nil + case tea.KeyHome: + m.commandViewport.GotoTop() + return m, nil + case tea.KeyEnd: + m.commandViewport.GotoBottom() + return m, nil + case tea.KeyUp: + if msg.Alt { + m.commandViewport.ScrollUp(1) + return m, nil + } + m.usePreviousHistory() + return m, nil + case tea.KeyDown: + if msg.Alt { + m.commandViewport.ScrollDown(1) + return m, nil + } + m.useNextHistory() + return m, nil + default: + oldValue := m.commandInput.Value() + var cmd tea.Cmd + m.commandInput, cmd = m.commandInput.Update(msg) + if m.commandInput.Value() != oldValue { + m.suggestions = nil + m.suggestionIndex = -1 + m.updateLayout() + } + return m, cmd + } +} + +func (m *model) submitCommand() (tea.Model, tea.Cmd) { + commandText := strings.TrimSpace(m.commandInput.Value()) + if commandText == "" { + return m, nil + } + if commandText == "quit" { + return m, tea.Quit + } + commandsExecuted++ + resolved, err := resolveHistoryShortcut(commandText) + if err != nil { + fmt.Fprintf(commandOutput, "%s %v\n", time.Now().Format("15:04"), err) + return m, nil + } + fmt.Fprintf(commandOutput, "%s %s\n", time.Now().Format("15:04"), resolved) + m.suggestions = nil + m.suggestionIndex = -1 + m.updateLayout() + ctx, cancelFunc := context.WithCancel(rootContext) + cancelID := rand.Uint32() + cancelFunctions[cancelID] = cancelFunc + return m, executeCommand(resolved, cancelID, ctx, cancelFunc) +} + +func (m *model) completeCommand(reverse bool) (tea.Model, tea.Cmd) { + current := m.commandInput.Value() + if len(m.suggestions) == 0 { + m.suggestions = rootCommand.Completions(current) + m.suggestionIndex = -1 + if len(m.suggestions) == 0 { + return m, nil + } + } + if reverse { + m.suggestionIndex-- + if m.suggestionIndex < 0 { + m.suggestionIndex = len(m.suggestions) - 1 + } + } else { + m.suggestionIndex = (m.suggestionIndex + 1) % len(m.suggestions) + } + completion := m.suggestions[m.suggestionIndex] + m.commandInput.SetValue(completion) + m.commandInput.CursorEnd() + m.updateLayout() + return m, nil +} + +func (m *model) usePreviousHistory() { + if len(m.history) == 0 { + return + } + if m.historyIndex == 0 { + m.commandInput.SetValue(m.history[0]) + m.commandInput.CursorEnd() + return + } + m.historyIndex-- + m.commandInput.SetValue(m.history[m.historyIndex]) + m.commandInput.CursorEnd() +} + +func (m *model) useNextHistory() { + if len(m.history) == 0 { + return + } + if m.historyIndex >= len(m.history)-1 { + m.historyIndex = len(m.history) + m.commandInput.SetValue("") + m.commandInput.CursorEnd() + return + } + m.historyIndex++ + m.commandInput.SetValue(m.history[m.historyIndex]) + m.commandInput.CursorEnd() +} + +func (m *model) handleAppendOutput(msg appendOutputMsg) { + switch msg.target { + case targetCommand: + m.commandLog.append(msg.text) + m.updateCommandViewportContent() + case targetConsole: + m.consoleLog.append(msg.text) + m.updateConsoleViewportContent() + case targetMessage: + m.messageLog.append(msg.text) + m.updatePayloadViewportContent() + } +} + +func (m *model) handleClearOutput(msg clearOutputMsg) { + switch msg.target { + case targetCommand: + m.commandLog.clear() + m.updateCommandViewportContent() + case targetConsole: + m.consoleLog.clear() + m.updateConsoleViewportContent() + case targetMessage: + m.messageLog.clear() + m.updatePayloadViewportContent() + } +} + +func (m *model) renderCommandPanel(width, height int) string { + panel := panelStyle + if width > 0 { + panel = panel.Width(width) + } + if height > 0 { + panel = panel.Height(height) + } + + innerWidth := width + if width > 0 { + frameWidth, _ := panelStyle.GetFrameSize() + innerWidth = max(width-frameWidth, 0) + } + + parts := []string{ + columnTitleStyle.Width(innerWidth).Render("Commands"), + m.commandViewport.View(), + "", + sectionTitleStyle.Render("Command Input"), + sectionBodyStyle.Render(m.commandInput.View()), + } + if len(m.suggestions) > 0 { + suggestions := suggestionStyle.Render(strings.Join(m.suggestions, " ")) + parts = append(parts, "", sectionTitleStyle.Render("Suggestions"), sectionBodyStyle.Render(suggestions)) + } + + body := lipgloss.JoinVertical(lipgloss.Left, parts...) + return panel.Render(body) +} + +func (m *model) renderOutputPanel(width, height int) string { + panel := panelStyle + if width > 0 { + panel = panel.Width(width) + } + if height > 0 { + panel = panel.Height(height) + } + + innerWidth := width + if width > 0 { + frameWidth, _ := panelStyle.GetFrameSize() + innerWidth = max(width-frameWidth, 0) + } + + parts := []string{ + columnTitleStyle.Width(innerWidth).Render("Output"), + m.payloadViewport.View(), + "", + sectionTitleStyle.Render("Console"), + m.consoleViewport.View(), + } + + body := lipgloss.JoinVertical(lipgloss.Left, parts...) + return panel.Render(body) +} + +func (m *model) updateLayout() { + if m.width == 0 && m.height == 0 { + return + } + + width := m.width + if width <= 0 { + width = 120 + } + header := titleStyle.Width(width).Render("PLC4X PCAP Analyzer") + footer := subtitleStyle.Width(width).Render("https://github.com/apache/plc4x") + availableHeight := m.height - lipgloss.Height(header) - lipgloss.Height(footer) + if availableHeight < 8 { + availableHeight = m.height / 2 + if availableHeight < 8 { + availableHeight = 8 + } + } + + const gapWidth = 1 + _, centerWidth, rightWidth := computeColumnWidths(width, gapWidth) + panelFrameWidth, panelFrameHeight := panelStyle.GetFrameSize() + bodyFrameWidth, _ := sectionBodyStyle.GetFrameSize() + + commandPanelWidth := max(rightWidth-2, 0) + commandInnerWidth := commandPanelWidth + if commandPanelWidth > 0 { + commandInnerWidth = max(commandPanelWidth-panelFrameWidth, 0) + } + commandInnerHeight := availableHeight + if availableHeight > 0 { + commandInnerHeight = max(availableHeight-panelFrameHeight, 0) + } + + suggestionsBlock := "" + if len(m.suggestions) > 0 { + suggestionsBlock = suggestionStyle.Render(strings.Join(m.suggestions, " ")) + } + + cmdHeader := columnTitleStyle.Width(commandInnerWidth).Render("Commands") + inputTitle := sectionTitleStyle.Render("Command Input") + inputBody := sectionBodyStyle.Render(m.commandInput.View()) + + commandStaticHeight := lipgloss.Height(cmdHeader) + lipgloss.Height(inputTitle) + lipgloss.Height(inputBody) + 1 + if suggestionsBlock != "" { + suggestionsTitle := sectionTitleStyle.Render("Suggestions") + suggestionsBody := sectionBodyStyle.Render(suggestionsBlock) + commandStaticHeight += 1 + lipgloss.Height(suggestionsTitle) + lipgloss.Height(suggestionsBody) + } + + commandViewportHeight := commandInnerHeight - commandStaticHeight + if commandViewportHeight < 0 { + commandViewportHeight = 0 + } + + commandViewportWidth := commandInnerWidth - bodyFrameWidth + if commandViewportWidth < 0 { + commandViewportWidth = 0 + } + + prevScrollTop := m.commandViewport.YOffset + m.commandViewport.Width = commandViewportWidth + m.commandViewport.Height = commandViewportHeight + m.updateCommandViewportContent() + if prevScrollTop > 0 { + m.commandViewport.SetYOffset(prevScrollTop) + } + + outputPanelWidth := max(centerWidth-2, 0) + outputInnerWidth := outputPanelWidth + if outputPanelWidth > 0 { + outputInnerWidth = max(outputPanelWidth-panelFrameWidth, 0) + } + outputInnerHeight := availableHeight + if availableHeight > 0 { + outputInnerHeight = max(availableHeight-panelFrameHeight, 0) + } + + outputHeader := columnTitleStyle.Width(outputInnerWidth).Render("Output") + consoleTitle := sectionTitleStyle.Render("Console") + fixedHeight := lipgloss.Height(outputHeader) + 1 + lipgloss.Height(consoleTitle) + + const ( + minPayloadHeight = 6 + minConsoleHeight = 4 + preferredConsoleHeight = 10 + ) + + availableForViewports := outputInnerHeight - fixedHeight + if availableForViewports < 0 { + availableForViewports = 0 + } + + var payloadViewportHeight, consoleViewportHeight int + if availableForViewports == 0 { + payloadViewportHeight, consoleViewportHeight = 0, 0 + } else { + payloadViewportHeight = minPayloadHeight + consoleViewportHeight = minConsoleHeight + if payloadViewportHeight+consoleViewportHeight > availableForViewports { + payloadViewportHeight = min(availableForViewports, minPayloadHeight) + if payloadViewportHeight < 0 { + payloadViewportHeight = 0 + } + consoleViewportHeight = availableForViewports - payloadViewportHeight + if consoleViewportHeight < 0 { + consoleViewportHeight = 0 + } + } else { + remaining := availableForViewports - (payloadViewportHeight + consoleViewportHeight) + extraConsoleCapacity := preferredConsoleHeight - minConsoleHeight + if extraConsoleCapacity > 0 { + extra := min(extraConsoleCapacity, remaining) + consoleViewportHeight += extra + remaining -= extra + } + payloadViewportHeight += remaining + } + } + + payloadViewportWidth := outputInnerWidth - bodyFrameWidth + if payloadViewportWidth < 0 { + payloadViewportWidth = 0 + } + consoleViewportWidth := outputInnerWidth - bodyFrameWidth + if consoleViewportWidth < 0 { + consoleViewportWidth = 0 + } + + m.payloadViewport.Width = payloadViewportWidth + m.payloadViewport.Height = payloadViewportHeight + m.consoleViewport.Width = consoleViewportWidth + m.consoleViewport.Height = consoleViewportHeight + + m.updatePayloadViewportContent() + m.updateConsoleViewportContent() +} + +func resolveHistoryShortcut(commandText string) (string, error) { + if !commandHistoryShortcut.MatchString(commandText) { + return commandText, nil + } + index, _ := strconv.Atoi(commandHistoryShortcut.FindString(commandText)) + if index >= len(config.History.Last10Commands) { + return "", fmt.Errorf("no such element %d in command history", index) + } + return config.History.Last10Commands[index], nil +} + +func executeCommand(command string, cancelID uint32, ctx context.Context, cancel context.CancelFunc) tea.Cmd { + return func() tea.Msg { + defer cancel() + err := Execute(ctx, command) + return commandExecResultMsg{Command: command, Err: err, cancelID: cancelID} + } +} + +func renderFiles(entries []fileEntry) string { + if len(entries) == 0 { + return "(none)" + } + rows := make([]string, 0, len(entries)) + for _, entry := range entries { + rows = append(rows, fmt.Sprintf("%s — %s", entry.Name, entry.Path)) + } + return strings.Join(rows, "\n") +} + +func renderDriverList(entries []string) string { + if len(entries) == 0 { + return "(none)" + } + return strings.Join(entries, "\n") +} + +func renderMessages(entries []messageEntry) string { + if len(entries) == 0 { + return "(none)" + } + const maxLines = 10 + start := 0 + if len(entries) > maxLines { + start = len(entries) - maxLines + } + rows := make([]string, 0, len(entries)-start) + for _, entry := range entries[start:] { + rows = append(rows, fmt.Sprintf("#%d [%s] @ %s", entry.Number, strings.ToUpper(entry.Origin), entry.Timestamp.Format("15:04:05"))) + } + return strings.Join(rows, "\n") +} + +func snapshotFiles() []fileEntry { + result := make([]fileEntry, 0, len(loadedPcapFiles)) + for _, f := range loadedPcapFiles { + result = append(result, fileEntry{Name: f.name, Path: f.path}) + } + return result +} + +func snapshotDrivers() []string { + return append([]string(nil), registeredDriverNames...) +} + +func (m *model) updateCommandViewportContent() { + logBody := strings.TrimSuffix(m.commandLog.String(), "\n") + logSection := lipgloss.JoinVertical(lipgloss.Left, + sectionTitleStyle.Render("Command Log"), + renderBodyBlock(logBody), + ) + + historySection := lipgloss.JoinVertical(lipgloss.Left, + sectionTitleStyle.Render("Last 10 commands"), + renderCommandHistoryView(m.history), + ) + + content := lipgloss.JoinVertical(lipgloss.Left, logSection, "", historySection) + atBottom := m.commandViewport.AtBottom() + m.commandViewport.SetContent(content) + if atBottom { + m.commandViewport.GotoBottom() + } +} + +func (m *model) updatePayloadViewportContent() { + segments := make([]string, 0, 2) + messageBody := strings.TrimSuffix(m.messageLog.String(), "\n") + if strings.TrimSpace(messageBody) != "" { + segment := lipgloss.JoinVertical(lipgloss.Left, + sectionTitleStyle.Render("Message Log"), + renderBodyBlock(messageBody), + ) + segments = append(segments, segment) + } + if len(m.messages) > 0 { + recent := lipgloss.JoinVertical(lipgloss.Left, + sectionTitleStyle.Render("Recent Messages"), + renderBodyBlock(renderMessages(m.messages)), + ) + segments = append(segments, recent) + } + if len(segments) == 0 { + segments = append(segments, placeholderStyle.Render("(none)")) + } + content := strings.Join(segments, "\n\n") + atBottom := m.payloadViewport.AtBottom() + m.payloadViewport.SetContent(content) + if atBottom { + m.payloadViewport.GotoBottom() + } +} + +func (m *model) updateConsoleViewportContent() { + consoleBody := strings.TrimSuffix(m.consoleLog.String(), "\n") + content := renderBodyBlock(consoleBody) + atBottom := m.consoleViewport.AtBottom() + m.consoleViewport.SetContent(content) + if atBottom { + m.consoleViewport.GotoBottom() + } +} + +func renderCommandHistoryLines(history []string) []string { + start := len(history) - 10 + if start < 0 { + start = 0 + } + rows := make([]string, 0, len(history)-start) + for idx := start; idx < len(history); idx++ { + index := historyIndexStyle.Render(fmt.Sprintf("%d", idx)) + divider := historyGapStyle.Render(": ") + entry := historyTextStyle.Render(history[idx]) + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, index, divider, entry)) + } + return rows +} + +func renderCommandHistoryView(history []string) string { + if len(history) == 0 { + return placeholderStyle.Render("(none)") + } + rows := renderCommandHistoryLines(history) + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func renderBodyBlock(text string) string { + if strings.TrimSpace(text) == "" { + return placeholderStyle.Render("(none)") + } + lines := strings.Split(text, "\n") + styled := make([]string, len(lines)) + for i, line := range lines { + styled[i] = sectionBodyStyle.Render(line) + } + return lipgloss.JoinVertical(lipgloss.Left, styled...) +} + +type panelSection struct { + title string + body string +} + +func renderPanel(title string, sections []panelSection, width, height int) string { + var parts []string + if strings.TrimSpace(title) != "" { + parts = append(parts, columnTitleStyle.Width(width).Render(title)) + } + for i, section := range sections { + if strings.TrimSpace(section.title) != "" { + parts = append(parts, sectionTitleStyle.Render(section.title)) + } + body := section.body + if strings.TrimSpace(body) == "" { + body = placeholderStyle.Render("(none)") + } else if !containsANSI(body) { + body = sectionBodyStyle.Render(body) + } + parts = append(parts, body) + if i != len(sections)-1 { + parts = append(parts, "") + } + } + if len(sections) == 0 { + parts = append(parts, placeholderStyle.Render("(none)")) + } + inner := strings.Join(parts, "\n") + style := panelStyle + if width > 0 { + style = style.Width(width) + } + if height > 0 { + style = style.Height(height) + } + return style.Render(inner) +} + +func containsANSI(body string) bool { + return strings.Contains(body, "\x1b[") +} + +func computeColumnWidths(total, gap int) (int, int, int) { + if total <= 0 { + total = 120 + } + gapsWidth := gap * 2 + usable := total - gapsWidth + if usable < 60 { + usable = 60 + } + leftMin, centerMin, rightMin := 24, 32, 24 + left := max(leftMin, usable/4) + right := max(rightMin, usable/4) + center := usable - left - right + if center < centerMin { + deficit := centerMin - center + if spare := left - leftMin; spare > 0 { + shift := min(deficit, spare) + left -= shift + deficit -= shift + } + if deficit > 0 { + if spare := right - rightMin; spare > 0 { + shift := min(deficit, spare) + right -= shift + deficit -= shift + } + } + center = usable - left - right + } + return left, center, right +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +}
