From b7a5bede8706472534d2f15667cbf500ffb48e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Mon, 19 Jan 2026 23:16:02 +0100 Subject: [PATCH] refactor: Use viewport for Analysis interactions to enable proper scrolling --- internal/tui/model.go | 222 ++++++++++++++++++++---------------------- 1 file changed, 104 insertions(+), 118 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index bd43139..98059ae 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -84,6 +84,7 @@ type Model struct { selectedPacketIndex int flowList list.Model viewport viewport.Model + analysisViewport viewport.Model // Packet Details View detailsViewport viewport.Model @@ -240,20 +241,30 @@ func NewModel() Model { } // Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry + // Viewport for Transaction Flow vp := viewport.New(0, 0) vp.YPosition = 0 + // Viewport for Analysis List + avp := viewport.New(0, 0) + avp.YPosition = 0 + + // Viewport for Packet Details + dvp := viewport.New(0, 0) + return Model{ - currentView: ViewDashboard, - subView: SubViewNone, - networkMap: nm, - callFlowStore: sip.NewCallFlowStore(), - lastPackets: make([]string, 0, 50), - sshConfig: NewSSHConfigModel(), - nodeInput: createNodeInputs(), - viewport: vp, - detailsViewport: viewport.New(0, 0), - styles: defaultStyles(), + currentView: ViewDashboard, + subView: SubViewNone, + networkMap: nm, + callFlowStore: sip.NewCallFlowStore(), + lastPackets: make([]string, 0, 50), + sshConfig: NewSSHConfigModel(), + nodeInput: createNodeInputs(), + viewport: vp, + analysisViewport: avp, + detailsViewport: dvp, + fileBrowser: NewFileBrowser(".", ".pcap"), + styles: defaultStyles(), } } @@ -324,6 +335,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If we are in Call Detail view, we might need to update the viewport content dynamically! if m.subView == SubViewCallDetail { m.updateCallDetailView() + } else if m.currentView == ViewAnalysis { + m.updateAnalysisView() } } @@ -432,6 +445,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.subView == SubViewCallDetail { m.updateCallDetailView() } + + // Update Analysis Viewport size + // Header (Title+Counts) ~2 lines, Footer (Help) ~2 lines -> ~4 lines overhead + analysisHeight := m.height - 4 + if analysisHeight < 0 { + analysisHeight = 0 + } + m.analysisViewport.Width = m.width + m.analysisViewport.Height = analysisHeight + + if m.currentView == ViewAnalysis { + m.updateAnalysisView() + } } return m, tea.Batch(cmds...) @@ -485,52 +511,27 @@ func (m *Model) handleViewKeys(msg tea.KeyMsg) tea.Cmd { case "up", "k": if m.selectedFlow > 0 { m.selectedFlow-- - if m.selectedFlow < m.analysisOffset { - m.analysisOffset = m.selectedFlow - } + m.updateAnalysisView() } case "down", "j": flows := m.callFlowStore.GetSortedFlows() if m.selectedFlow < len(flows)-1 { m.selectedFlow++ - // Check if we need to scroll down - // Visible height calculation (approximate) - headerHeight := 4 // Title + Call count + padding - footerHeight := 2 // Help text - availableHeight := m.height - headerHeight - footerHeight - if availableHeight < 5 { - availableHeight = 5 - } - - if m.selectedFlow >= m.analysisOffset+availableHeight { - m.analysisOffset++ - } + m.updateAnalysisView() } case "pgup": m.selectedFlow -= 10 if m.selectedFlow < 0 { m.selectedFlow = 0 } - if m.selectedFlow < m.analysisOffset { - m.analysisOffset = m.selectedFlow - } + m.updateAnalysisView() case "pgdown": flows := m.callFlowStore.GetSortedFlows() m.selectedFlow += 10 if m.selectedFlow >= len(flows) { m.selectedFlow = len(flows) - 1 } - - headerHeight := 4 - footerHeight := 2 - availableHeight := m.height - headerHeight - footerHeight - if availableHeight < 5 { - availableHeight = 5 - } - - if m.selectedFlow >= m.analysisOffset+availableHeight { - m.analysisOffset = m.selectedFlow - availableHeight + 1 - } + m.updateAnalysisView() case "enter": m.subView = SubViewCallDetail m.updateCallDetailView() @@ -1247,85 +1248,6 @@ func (m Model) viewCapture() string { return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...)) } -func (m Model) viewAnalysis() string { - title := m.styles.Title.Render("πŸ“Š Analysis") - - flows := m.callFlowStore.GetSortedFlows() - - if len(flows) == 0 { - return lipgloss.JoinVertical(lipgloss.Left, title, - "No calls captured yet.", - "", - "Start capturing on the Capture tab to see call flows here.", - "", - m.styles.Help.Render("Press 2 to go to Capture")) - } - - var lines []string - // Header is handled in return statement now - // lines = append(lines, fmt.Sprintf("Calls: %d", len(flows))) - // lines = append(lines, "") - - for i, flow := range flows { - prefix := " " - style := m.styles.Inactive - if i == m.selectedFlow { - prefix = "β–Ά " - style = m.styles.Active - } - - stateIcon := "β—‹" - switch flow.State { - case sip.CallStateRinging: - stateIcon = "◐" - case sip.CallStateConnected: - stateIcon = "●" - case sip.CallStateTerminated: - stateIcon = "β—―" - case sip.CallStateFailed: - stateIcon = "βœ•" - } - - summary := fmt.Sprintf("%s%s %s β†’ %s [%d pkts]", - prefix, stateIcon, - truncate(extractUser(flow.From), 15), - truncate(extractUser(flow.To), 15), - len(flow.Packets)) - - lines = append(lines, style.Render(summary)) - } - - // Apply Viewport/Scrolling - headerHeight := 4 // Title + Call count + padding - footerHeight := 2 // Help text - availableHeight := m.height - headerHeight - footerHeight - if availableHeight < 5 { - availableHeight = 5 - } - - // Sanity check offset - if m.analysisOffset > len(lines)-1 { - m.analysisOffset = len(lines) - 1 - } - if m.analysisOffset < 0 { - m.analysisOffset = 0 - } - - end := m.analysisOffset + availableHeight - if end > len(lines) { - end = len(lines) - } - - visibleLines := lines[m.analysisOffset:end] - - return lipgloss.JoinVertical(lipgloss.Left, title, - fmt.Sprintf("Calls: %d (Showing %d-%d)", len(flows), m.analysisOffset+1, end), - "", - lipgloss.JoinVertical(lipgloss.Left, visibleLines...), - "", - m.styles.Help.Render("↑/↓ select β€’ Enter details β€’ q quit")) -} - func (m Model) viewNetworkMap() string { title := m.styles.Title.Render("πŸ—ΊοΈ Network Map") @@ -1541,6 +1463,70 @@ func (m *Model) updateCallDetailView() { m.viewport.SetContent(right.String()) } +func (m *Model) updateAnalysisView() { + flows := m.callFlowStore.GetSortedFlows() + + if len(flows) == 0 { + m.analysisViewport.SetContent("No calls captured yet.\n\nStart capturing on the Capture tab to see call flows here.") + return + } + + var lines []string + for i, flow := range flows { + prefix := " " + style := m.styles.Inactive + if i == m.selectedFlow { + prefix = "> " + style = m.styles.Active + } + + stateIcon := "β—‹" + switch flow.State { + case sip.CallStateRinging: + stateIcon = "◐" + case sip.CallStateConnected: + stateIcon = "●" + case sip.CallStateTerminated: + stateIcon = "β—―" + case sip.CallStateFailed: + stateIcon = "βœ•" + } + + summary := fmt.Sprintf("%s%s %s β†’ %s [%d pkts]", + prefix, stateIcon, + truncate(extractUser(flow.From), 15), + truncate(extractUser(flow.To), 15), + len(flow.Packets)) + + lines = append(lines, style.Render(summary)) + } + + m.analysisViewport.SetContent(strings.Join(lines, "\n")) + + // Sync viewport scrolling + // Similar to transaction flow: keep selected line visible + if m.selectedFlow < m.analysisViewport.YOffset { + m.analysisViewport.SetYOffset(m.selectedFlow) + } else if m.selectedFlow >= m.analysisViewport.YOffset+m.analysisViewport.Height { + m.analysisViewport.SetYOffset(m.selectedFlow - m.analysisViewport.Height + 1) + } +} + +func (m Model) viewAnalysis() string { + title := m.styles.Title.Render("πŸ“Š Analysis") + flows := m.callFlowStore.GetSortedFlows() + + countStr := fmt.Sprintf("Calls: %d", len(flows)) + + return lipgloss.JoinVertical(lipgloss.Left, + title, + countStr, + "", + m.analysisViewport.View(), + "", + m.styles.Help.Render("↑/↓ select β€’ Enter details β€’ q quit")) +} + func (m *Model) exportCallToLog(flow *sip.CallFlow, filename string) error { f, err := os.Create(filename) if err != nil {