diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-08 22:12:40 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-08 22:12:40 +0200 |
| commit | b8cbe6878e7313589f44ee6922593cdb8825421b (patch) | |
| tree | 13d61da56620a649474ddc9f6b0c576f03e86419 /internal/tui | |
| parent | 0d1492291a3e20665d8a3a6b16d2eb4e13938cee (diff) | |
tui: add process-tab enter filter push
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/dashboard/model.go | 108 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 28 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 2 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 47 |
4 files changed, 184 insertions, 1 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 22d09c3..1ffb6e8 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -1,6 +1,8 @@ package dashboard import ( + "fmt" + "slices" "strings" "time" @@ -261,6 +263,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { cmd = streamTickCmd() } if !handled { + handled, cmd = m.handleEnterKey(msg) + } + if !handled { handled, cmd = m.handleShortcutKey(msg) } if !handled { @@ -269,6 +274,22 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, m.postKeyTransitionCmd(prevActiveTab, cmd) } +func (m Model) handleEnterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + if !key.Matches(msg, m.keys.Enter) { + return false, nil + } + switch m.activeTab { + case TabProcesses: + filter, action, ok := m.selectedProcessFilter() + if !ok { + return false, nil + } + return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} } + default: + return false, nil + } +} + func (m Model) handleHelpToggleKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) { if msg.String() != "H" { return false, m, nil @@ -353,6 +374,93 @@ func (m Model) handleUnhandledKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, flameCmd } +func (m Model) selectedProcessFilter() (globalfilter.Filter, string, bool) { + proc, ok := m.selectedProcessSnapshot() + if !ok || proc.PID == 0 { + return globalfilter.Filter{}, "", false + } + filter := m.globalFilter.Clone() + filter.PID = &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: int64(proc.PID)} + action := fmt.Sprintf("pid=%d", proc.PID) + return filter, action, true +} + +func (m Model) selectedProcessSnapshot() (statsengine.ProcessSnapshot, bool) { + if m.latest == nil { + return statsengine.ProcessSnapshot{}, false + } + rows := m.latest.Processes() + if len(rows) == 0 { + return statsengine.ProcessSnapshot{}, false + } + + switch { + case m.processesVizMode == tabVizModeTreemap: + return indexedProcessSnapshot(sortedProcessSnapshots(rows, m.processesChart.Metric(), maxSyscallTreemapItems), m.processesOffset) + case m.processesVizMode == tabVizModeBubbles: + return indexedProcessSnapshot(sortedProcessSnapshots(rows, m.processesChart.Metric(), bubbleMaxItems), m.processesChart.selected) + default: + return indexedProcessSnapshot(rows, m.processesOffset) + } +} + +func indexedProcessSnapshot(rows []statsengine.ProcessSnapshot, index int) (statsengine.ProcessSnapshot, bool) { + if len(rows) == 0 { + return statsengine.ProcessSnapshot{}, false + } + index = clampOffset(index, len(rows)) + if index < 0 || index >= len(rows) { + return statsengine.ProcessSnapshot{}, false + } + return rows[index], true +} + +func sortedProcessSnapshots(rows []statsengine.ProcessSnapshot, metric bubbleMetric, limit int) []statsengine.ProcessSnapshot { + if len(rows) == 0 { + return nil + } + sorted := slices.Clone(rows) + slices.SortFunc(sorted, func(left, right statsengine.ProcessSnapshot) int { + lv := processMetricValue(left, metric) + rv := processMetricValue(right, metric) + switch { + case lv > rv: + return -1 + case lv < rv: + return 1 + } + llabel := processSelectionLabel(left) + rlabel := processSelectionLabel(right) + switch { + case llabel < rlabel: + return -1 + case llabel > rlabel: + return 1 + default: + return 0 + } + }) + if limit > 0 && len(sorted) > limit { + sorted = sorted[:limit] + } + return sorted +} + +func processMetricValue(proc statsengine.ProcessSnapshot, metric bubbleMetric) uint64 { + if metric == bubbleMetricBytes { + return proc.Bytes + } + return proc.Syscalls +} + +func processSelectionLabel(proc statsengine.ProcessSnapshot) string { + label := fmt.Sprintf("%d", proc.PID) + if comm := strings.TrimSpace(proc.Comm); comm != "" { + label = fmt.Sprintf("%d:%s", proc.PID, comm) + } + return label +} + func (m Model) postKeyTransitionCmd(prevActiveTab Tab, cmd tea.Cmd) tea.Cmd { cmds := make([]tea.Cmd, 0, 4) cmds = append(cmds, cmd) diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index f01a701..cfeb5c8 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -142,6 +142,34 @@ func TestProcessesTabScrollsWithJK(t *testing.T) { } } +func TestProcessesTabEnterEmitsGlobalFilterRequest(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabProcesses + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 111, Comm: "alpha", Syscalls: 9}, + {PID: 222, Comm: "beta", Syscalls: 4}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.processesOffset = 1 + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on processes tab to emit a filter request") + } + msg := cmd() + req, ok := msg.(messages.GlobalFilterRequestedMsg) + if !ok { + t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg) + } + if req.Filter.PID == nil || req.Filter.PID.Value != 222 { + t.Fatalf("expected pid=222 filter, got %+v", req.Filter.PID) + } + if req.Action != "pid=222" { + t.Fatalf("expected action pid=222, got %q", req.Action) + } +} + func TestFilesTabScrollsWithJK(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go index 9988ea4..b71b22c 100644 --- a/internal/tui/dashboard/processes.go +++ b/internal/tui/dashboard/processes.go @@ -43,7 +43,7 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset cursor := clampOffset(offset, len(rows)) tbl.SetCursor(cursor) - out := tbl.View() + fmt.Sprintf("\nRow %d/%d [v:mode] [b:metric]", cursor+1, len(rows)) + out := tbl.View() + fmt.Sprintf("\nRow %d/%d [enter:filter] [v:mode] [b:metric]", cursor+1, len(rows)) if pidFilter > 0 { out += "\n" + "Note: this tab is most useful with All PIDs." } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index ab8b5db..2d5419a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1363,6 +1363,53 @@ func TestDashboardFooterShowsGlobalFilterStack(t *testing.T) { } } +func TestProcessesTabEnterAppliesSelectedProcessAsGlobalFilter(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + stopped := false + m.traceStop = func() { stopped = true } + + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 111, Comm: "alpha", Syscalls: 9}, + {PID: 222, Comm: "beta", Syscalls: 4}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + + next, _ := m.Update(messages.StatsTickMsg{Snap: &snap}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'5'}[0], Text: string([]rune{'5'})}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) + m = next.(Model) + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on processes tab to emit a filter request") + } + + next, cmd = m.Update(cmd()) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected selected process filter to restart tracing") + } + if m.globalFilter.PID == nil || m.globalFilter.PID.Value != 222 { + t.Fatalf("expected selected process pid applied globally, got %+v", m.globalFilter.PID) + } + if len(m.filterStack) != 1 || m.filterStack[0] != "pid=222" { + t.Fatalf("expected pid filter pushed to stack, got %+v", m.filterStack) + } + if !stopped { + t.Fatalf("expected selected process filter to stop the active trace") + } + if !m.attaching { + t.Fatalf("expected selected process filter to restart tracing") + } +} + func TestGlobalFilterApplyPreservesActiveDashboardTab(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard |
