diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-27 00:00:48 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-27 00:00:48 +0200 |
| commit | 62e9fc030a7ad7c6522c2db1010609441818b0a9 (patch) | |
| tree | ca9e7715ee2223f3454bd1f83cffdf46bef79606 /internal/tui/eventstream | |
| parent | 34e70c9cd76b0231cfff3910bb24708624d7c72d (diff) | |
tui: add stream regex search and unify help visibility
Diffstat (limited to 'internal/tui/eventstream')
| -rw-r--r-- | internal/tui/eventstream/model.go | 157 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 60 | ||||
| -rw-r--r-- | internal/tui/eventstream/searchmodal.go | 121 |
3 files changed, 336 insertions, 2 deletions
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 7f67702..d9c4ee3 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -2,6 +2,8 @@ package eventstream import ( "fmt" + "regexp" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -43,6 +45,10 @@ type Model struct { filterStack []Filter filterActionStack []string exportModal ExportModal + searchModal SearchModal + searchPattern string + searchRegex *regexp.Regexp + searchDirection SearchDirection lastExportPath string pendingOpenPath string statusMessage string @@ -50,6 +56,8 @@ type Model struct { width int height int + + showFooter bool } type fdTraceViewState struct { @@ -65,10 +73,12 @@ func NewModel(source *RingBuffer) Model { source: source, filterModal: NewFilterModal(), exportModal: NewExportModal(), + searchModal: NewSearchModal(), autoScroll: true, selectedIdx: -1, selectedCol: 0, exportDir: ".", + showFooter: true, } } @@ -83,6 +93,11 @@ func (m *Model) SetViewport(width, height int) { } } +// SetFooterVisible controls whether stream footer/status lines are shown. +func (m *Model) SetFooterVisible(visible bool) { + m.showFooter = visible +} + // SetSource updates the backing ring buffer and refreshes visible rows. func (m *Model) SetSource(source *RingBuffer) { m.source = source @@ -99,12 +114,29 @@ func (m Model) ExportModalVisible() bool { return m.exportModal.Visible() } +// SearchModalVisible reports whether the stream search modal is currently open. +func (m Model) SearchModalVisible() bool { + return m.searchModal.Visible() +} + // Paused reports whether stream refresh is currently paused. func (m Model) Paused() bool { return m.paused } func (m *Model) HandleKey(keyStr string) bool { + if m.searchModal.Visible() { + m.statusMessage = "" + var ( + term string + submit bool + ) + m.searchModal, term, submit = m.searchModal.Update(keyMsgFromString(keyStr)) + if !submit { + return true + } + return m.submitSearch(term, m.searchModal.Direction()) + } if m.exportModal.Visible() { m.statusMessage = "" var ( @@ -176,11 +208,27 @@ func (m *Model) HandleKey(keyStr string) bool { } switch keyStr { + case "/": + m.openSearch(SearchForward) + return true + case "?": + m.openSearch(SearchBackward) + return true case "enter": if m.paused { return m.applyFilterFromSelectedCell() } return false + case "n": + if m.searchRegex == nil { + return false + } + return m.jumpSearch(m.searchDirection) + case "N": + if m.searchRegex == nil { + return false + } + return m.jumpSearch(-m.searchDirection) case "x": if !m.paused { return false @@ -369,9 +417,22 @@ func (m *Model) View(width, height int) string { selectedCol = m.selectedCol } base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol) - status := fmt.Sprintf("Row %d/%d | space:pause f:filter G:tail g:top c:clear j/k:scroll", rowNumber(start, len(m.filtered)), len(m.filtered)) + if !m.showFooter { + if m.filterModal.Visible() { + return m.filterModal.View(width, height) + } + if m.exportModal.Visible() { + return m.exportModal.View(width, height) + } + if m.searchModal.Visible() { + return m.searchModal.View(width, height) + } + return base + } + + status := fmt.Sprintf("Row %d/%d", rowNumber(start, len(m.filtered)), len(m.filtered)) if m.paused && m.selectedIdx >= 0 { - status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | enter:add-filter esc:undo(%d) x:export X:export-as E:open-last space:pause f:filter G:tail g:top c:clear j/k:row h/l:col", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount, len(m.filterStack)) + status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Filters %d", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount, len(m.filterStack)) } out := base + "\n" + status if len(m.filterActionStack) > 0 { @@ -389,6 +450,9 @@ func (m *Model) View(width, height int) string { if m.exportModal.Visible() { return m.exportModal.View(width, height) } + if m.searchModal.Visible() { + return m.searchModal.View(width, height) + } return out } @@ -808,6 +872,95 @@ func (m *Model) SetStatusMessage(message string) { m.statusMessage = message } +func (m *Model) openSearch(direction SearchDirection) { + m.paused = true + m.ensureSelection() + m.ensureSelectedCol() + m.centerSelection() + m.searchModal = m.searchModal.Open(direction, m.searchPattern) +} + +func (m *Model) submitSearch(term string, direction SearchDirection) bool { + re, err := regexp.Compile(term) + if err != nil { + m.statusMessage = fmt.Sprintf("Invalid regex: %v", err) + return true + } + m.searchPattern = term + m.searchRegex = re + m.searchDirection = direction + return m.jumpSearch(direction) +} + +func (m *Model) jumpSearch(direction SearchDirection) bool { + if m.searchRegex == nil { + return false + } + if len(m.filtered) == 0 { + m.statusMessage = "Search: no rows" + return true + } + start := m.selectedIdx + if start < 0 || start >= len(m.filtered) { + if direction == SearchForward { + start = -1 + } else { + start = len(m.filtered) + } + } + next := m.findMatch(start, direction) + if next < 0 { + m.statusMessage = fmt.Sprintf("No match: %q", m.searchPattern) + return true + } + m.moveSelectionTo(next) + prefix := "/" + if direction == SearchBackward { + prefix = "?" + } + m.statusMessage = fmt.Sprintf("%s%s @ row %d/%d", prefix, m.searchPattern, next+1, len(m.filtered)) + return true +} + +func (m *Model) findMatch(start int, direction SearchDirection) int { + n := len(m.filtered) + if n == 0 { + return -1 + } + step := int(direction) + for offset := 1; offset <= n; offset++ { + idx := (start + step*offset + n) % n + if streamEventMatchesRegex(m.filtered[idx], m.searchRegex) { + return idx + } + } + return -1 +} + +func streamEventMatchesRegex(ev StreamEvent, re *regexp.Regexp) bool { + if re == nil { + return false + } + if re.MatchString(ev.Syscall) || re.MatchString(ev.Comm) || re.MatchString(ev.FileName) { + return true + } + if re.MatchString(strconv.FormatUint(ev.Seq, 10)) || + re.MatchString(strconv.FormatUint(ev.TimeNs, 10)) || + re.MatchString(strconv.FormatUint(uint64(ev.PID), 10)) || + re.MatchString(strconv.FormatUint(uint64(ev.TID), 10)) || + re.MatchString(strconv.FormatInt(int64(ev.FD), 10)) || + re.MatchString(strconv.FormatInt(ev.RetVal, 10)) || + re.MatchString(strconv.FormatUint(ev.Bytes, 10)) || + re.MatchString(strconv.FormatUint(ev.GapNs, 10)) || + re.MatchString(strconv.FormatUint(ev.DurationNs, 10)) { + return true + } + if ev.IsError && re.MatchString("error") { + return true + } + return false +} + func (m *Model) dumpVisibleForTest() string { rows := make([]string, 0, len(m.filtered)) for _, ev := range m.filtered { diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index 74bccb6..3925d26 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -680,6 +680,66 @@ func TestPausedOpenLastExportQueuesRequest(t *testing.T) { } } +func TestRegexSearchForwardBackwardAndRepeat(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, Comm: "alpha", PID: 10, TID: 100, Syscall: "read", FileName: "/tmp/a"}) + rb.Push(StreamEvent{Seq: 2, Comm: "beta", PID: 11, TID: 110, Syscall: "write", FileName: "/tmp/b"}) + rb.Push(StreamEvent{Seq: 3, Comm: "gamma", PID: 12, TID: 120, Syscall: "open", FileName: "/tmp/c"}) + rb.Push(StreamEvent{Seq: 4, Comm: "beta", PID: 13, TID: 130, Syscall: "close", FileName: "/tmp/d"}) + + m := NewModel(rb) + m.height = 20 + m.Refresh() + _ = m.HandleKey("space") + m.moveSelectionTo(0) + + if !m.HandleKey("/") { + t.Fatalf("/ should open search modal") + } + if !m.searchModal.Visible() { + t.Fatalf("expected search modal visible") + } + if !m.HandleKey("b") || !m.HandleKey("e") || !m.HandleKey("t") || !m.HandleKey("a") { + t.Fatalf("expected term typing keys handled") + } + if !m.HandleKey("enter") { + t.Fatalf("enter should submit search") + } + if m.selectedIdx != 1 { + t.Fatalf("expected first forward beta hit at idx 1, got %d", m.selectedIdx) + } + if m.searchDirection != SearchForward { + t.Fatalf("expected search direction forward") + } + + if !m.HandleKey("n") { + t.Fatalf("n should jump to next hit") + } + if m.selectedIdx != 3 { + t.Fatalf("expected next forward beta hit at idx 3, got %d", m.selectedIdx) + } + + if !m.HandleKey("N") { + t.Fatalf("N should jump opposite direction") + } + if m.selectedIdx != 1 { + t.Fatalf("expected opposite-direction beta hit at idx 1, got %d", m.selectedIdx) + } + + if !m.HandleKey("?") { + t.Fatalf("? should open backward search modal") + } + if !m.HandleKey("enter") { + t.Fatalf("enter should submit backward search") + } + if m.selectedIdx != 3 { + t.Fatalf("expected backward beta hit at idx 3, got %d", m.selectedIdx) + } + if m.searchDirection != SearchBackward { + t.Fatalf("expected search direction backward") + } +} + func readCSVRecords(t *testing.T, path string) [][]string { t.Helper() f, err := os.Open(path) diff --git a/internal/tui/eventstream/searchmodal.go b/internal/tui/eventstream/searchmodal.go new file mode 100644 index 0000000..f744d00 --- /dev/null +++ b/internal/tui/eventstream/searchmodal.go @@ -0,0 +1,121 @@ +package eventstream + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type SearchDirection int + +const ( + SearchForward SearchDirection = 1 + SearchBackward SearchDirection = -1 +) + +type SearchModal struct { + visible bool + textInput textinput.Model + err string + direction SearchDirection +} + +func NewSearchModal() SearchModal { + input := textinput.New() + input.Prompt = "" + input.CharLimit = 0 + input.Width = 44 + return SearchModal{textInput: input, direction: SearchForward} +} + +func (m SearchModal) Visible() bool { + return m.visible +} + +func (m SearchModal) Direction() SearchDirection { + return m.direction +} + +func (m SearchModal) Open(direction SearchDirection, defaultTerm string) SearchModal { + m.visible = true + m.err = "" + m.direction = direction + m.textInput.SetValue(defaultTerm) + m.textInput.CursorEnd() + m.textInput.Focus() + return m +} + +func (m SearchModal) Close() SearchModal { + m.visible = false + m.err = "" + m.textInput.Blur() + return m +} + +// Update returns updated modal, submitted term, and whether submit occurred. +func (m SearchModal) Update(msg tea.Msg) (SearchModal, string, bool) { + if !m.visible { + return m, "", false + } + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "esc": + return m.Close(), "", false + case "enter": + term := strings.TrimSpace(m.textInput.Value()) + if term == "" { + m.err = "search term is required" + return m, "", false + } + return m.Close(), term, true + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + _ = cmd + return m, "", false +} + +func (m SearchModal) View(width, height int) string { + if !m.visible { + return "" + } + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + modalWidth := 58 + if width < modalWidth+4 { + modalWidth = width - 4 + if modalWidth < 40 { + modalWidth = 40 + } + } + + prefix := "/" + if m.direction == SearchBackward { + prefix = "?" + } + lines := []string{ + "Regex Search", + "", + "Pattern:", + prefix + m.textInput.View(), + } + if m.err != "" { + lines = append(lines, "Error: "+m.err) + } + lines = append(lines, "", "Enter search • Esc cancel") + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(1, 2). + Width(modalWidth). + Render(strings.Join(lines, "\n")) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} |
