From ee0e827b783276c64a1d1a9881d30b416c132740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Mon, 19 Jan 2026 16:12:51 +0100 Subject: [PATCH] feat: Redesign Call Detail view with split layout and scrollable viewport --- internal/tui/model.go | 97 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 61ccfeb..577beb2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -75,9 +76,11 @@ type Model struct { // Data stores callFlowStore *sip.CallFlowStore + // Call flow analysis // Call flow analysis selectedFlow int flowList list.Model + viewport viewport.Model // File browser for pcap import fileBrowser FileBrowserModel @@ -229,6 +232,10 @@ func NewModel() Model { nm = config.NewNetworkMap() } + // Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry + vp := viewport.New(0, 0) + vp.YPosition = 0 + return Model{ currentView: ViewDashboard, subView: SubViewNone, @@ -237,6 +244,7 @@ func NewModel() Model { lastPackets: make([]string, 0, 50), sshConfig: NewSSHConfigModel(), nodeInput: createNodeInputs(), + viewport: vp, styles: defaultStyles(), } } @@ -320,6 +328,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height + // Update viewport size for Call Detail view + // Top nav is ~1 line, Status bar ~1 line. + // Layout is vertical join of nav, content, status. + // Available height for content = Height - 2 (approx). + headerHeight := 2 // Nav + margin + footerHeight := 1 // Status bar + contentHeight := m.height - headerHeight - footerHeight + + if contentHeight < 0 { + contentHeight = 0 + } + + m.viewport.Width = m.width / 2 // Right pane width + m.viewport.Height = contentHeight + case PacketMsg: if msg.Packet != nil { m.packetCount++ @@ -513,12 +536,17 @@ func (m *Model) updateSubView(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateNodeInput(msg) case SubViewCallDetail: + var cmd tea.Cmd if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "esc" || keyMsg.String() == "q" { m.subView = SubViewNone + return m, nil } } - return m, nil + + // Update viewport + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd } return m, nil @@ -745,23 +773,31 @@ func (m Model) renderCallDetail() string { } flow := flows[m.selectedFlow] - var b strings.Builder - b.WriteString(m.styles.Title.Render("📞 Call Detail")) - b.WriteString("\n\n") - b.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID)) - b.WriteString(fmt.Sprintf("From: %s\n", flow.From)) - b.WriteString(fmt.Sprintf("To: %s\n", flow.To)) - b.WriteString(fmt.Sprintf("State: %s\n", flow.State)) + // Split Widths + totalWidth := m.width + // Subtract borders/padding if any + innerW := totalWidth - 4 + leftW := innerW / 2 + rightW := innerW - leftW + + // --- Left Pane (Details) --- + var left strings.Builder + left.WriteString(m.styles.Title.Render("📞 Call Detail")) + left.WriteString("\n\n") + left.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID)) + left.WriteString(fmt.Sprintf("From: %s\n", flow.From)) + left.WriteString(fmt.Sprintf("To: %s\n", flow.To)) + left.WriteString(fmt.Sprintf("State: %s\n", flow.State)) // Calculate and display duration duration := flow.EndTime.Sub(flow.StartTime) - b.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond))) + left.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond))) - b.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets))) + left.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets))) // Network Summary Section - b.WriteString("Network Layer:\n") + left.WriteString("Network Layer:\n") // Find first packet to get initial IPs if len(flow.Packets) > 0 { first := flow.Packets[0] @@ -779,12 +815,16 @@ func (m Model) renderCallDetail() string { dstLabel = m.styleForNode(node).Render(dstLabel) } - b.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort)) - b.WriteString(fmt.Sprintf(" Destination: %s (%s:%d)\n", dstLabel, first.DestIP, first.DestPort)) + left.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort)) + left.WriteString(fmt.Sprintf(" Destination: %s (%s:%d)\n", dstLabel, first.DestIP, first.DestPort)) } - b.WriteString("\n") + left.WriteString("\n\n") + left.WriteString(m.styles.Help.Render("Esc: Back • ↑/↓: Scroll Flow")) + + // --- Right Pane (Transaction Flow) --- + var right strings.Builder + // right.WriteString("Transaction Flow:\n\n") // Removed header to save space or added to viewport content - b.WriteString("Transaction Flow:\n") for i, pkt := range flow.Packets { arrow := "→" arrowStyle := m.styles.ArrowOut @@ -829,7 +869,7 @@ func (m Model) renderCallDetail() string { ts := pkt.Timestamp.Format("15:04:05.000") // Clean packet info line (Timestamp + Arrow + Method/Status) - b.WriteString(fmt.Sprintf(" %d. [%s] %s %s\n", + right.WriteString(fmt.Sprintf("%d. [%s] %s %s\n", i+1, ts, arrowStyle.Render(arrow), @@ -844,15 +884,32 @@ func (m Model) renderCallDetail() string { node := m.networkMap.FindByIP(mediaIP) label = m.styleForNode(node).Render(label) } - b.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label)) + right.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label)) } } } - b.WriteString("\n") - b.WriteString(m.styles.Help.Render("Press Esc to go back")) + // Set content to viewport + m.viewport.SetContent(right.String()) - return m.styles.Box.Render(b.String()) + // Ensure viewport size is correct (it might need update on resize msg, but being safe here) + // We cheat a bit by updating width here during render if needed, but ideally handled in Update or Layout + // But since this is a subview render, we just assume Update handled size or use current m.height + // Note: You can't mutate model in Render. So we rely on Update setting viewport size. + // However, we need to enforce sizing or styling on the rendered string. + + leftStyle := lipgloss.NewStyle().Width(leftW).Padding(1, 2) + rightStyle := lipgloss.NewStyle().Width(rightW).Padding(0, 1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(lipgloss.Color("#44475A")) // Left border + + // Render left pane + leftRendered := leftStyle.Render(left.String()) + + // Render viewport (Right pane) + // We style the viewport string itself or wrapper? + // The viewport.View() returns the string. + rightRendered := rightStyle.Render(m.viewport.View()) + + return lipgloss.JoinHorizontal(lipgloss.Top, leftRendered, rightRendered) } func (m Model) renderNav() string {