summaryrefslogtreecommitdiff
path: root/internal/tui/eventstream
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-13 20:04:48 +0300
committerPaul Buetow <paul@buetow.org>2026-05-13 20:04:48 +0300
commit251894cf3375812564ecf28392179b395cdda9c7 (patch)
tree83c3609ab591702e29a375923670e7622a33b5c7 /internal/tui/eventstream
parent78ea9e22e596255c5e23ce445d80641870674ca9 (diff)
refactor: break down functions exceeding 50 lines into smaller helpers
Split 22 production files across the codebase — event loop, TUI models, probe manager, dashboard, export, flag parsing, code generation, and ioworkload scenarios — so that no function body exceeds 50 lines. Each extracted helper carries its own comment explaining its role. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/tui/eventstream')
-rw-r--r--internal/tui/eventstream/export.go129
-rw-r--r--internal/tui/eventstream/model.go390
2 files changed, 293 insertions, 226 deletions
diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go
index 1aa4313..46e3a23 100644
--- a/internal/tui/eventstream/export.go
+++ b/internal/tui/eventstream/export.go
@@ -31,50 +31,14 @@ func shellSplit(s string) []string {
ch := s[i]
switch {
case ch == '\'':
- // Single-quote: copy until the matching closing quote verbatim.
inToken = true
- i++
- for i < len(s) && s[i] != '\'' {
- current.WriteByte(s[i])
- i++
- }
- // Skip closing quote if present; if missing we just fall through.
- if i < len(s) {
- i++ // consume the closing '
- }
-
+ i = consumeSingleQuoted(s, i+1, &current)
case ch == '"':
- // Double-quote: process backslash escapes for \" and \\.
inToken = true
- i++
- for i < len(s) && s[i] != '"' {
- if s[i] == '\\' && i+1 < len(s) {
- next := s[i+1]
- if next == '"' || next == '\\' {
- current.WriteByte(next)
- i += 2
- continue
- }
- }
- current.WriteByte(s[i])
- i++
- }
- if i < len(s) {
- i++ // consume the closing "
- }
-
+ i = consumeDoubleQuoted(s, i+1, &current)
case ch == '\\':
- // Backslash outside quotes: escape the next character.
inToken = true
- if i+1 < len(s) {
- current.WriteByte(s[i+1])
- i += 2
- } else {
- // Trailing backslash: keep it.
- current.WriteByte('\\')
- i++
- }
-
+ i = consumeBackslash(s, i, &current)
case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r':
// Whitespace: flush current token if any.
if inToken {
@@ -83,7 +47,6 @@ func shellSplit(s string) []string {
inToken = false
}
i++
-
default:
inToken = true
current.WriteByte(ch)
@@ -97,6 +60,55 @@ func shellSplit(s string) []string {
return tokens
}
+// consumeSingleQuoted copies characters verbatim from s starting at i until
+// the closing single-quote (or end-of-string). Returns the index after the
+// closing quote.
+func consumeSingleQuoted(s string, i int, out *strings.Builder) int {
+ for i < len(s) && s[i] != '\'' {
+ out.WriteByte(s[i])
+ i++
+ }
+ if i < len(s) {
+ i++ // consume the closing '
+ }
+ return i
+}
+
+// consumeDoubleQuoted copies characters from s starting at i until the
+// closing double-quote, processing \" and \\ escape sequences. Returns the
+// index after the closing quote.
+func consumeDoubleQuoted(s string, i int, out *strings.Builder) int {
+ for i < len(s) && s[i] != '"' {
+ if s[i] == '\\' && i+1 < len(s) {
+ next := s[i+1]
+ if next == '"' || next == '\\' {
+ out.WriteByte(next)
+ i += 2
+ continue
+ }
+ }
+ out.WriteByte(s[i])
+ i++
+ }
+ if i < len(s) {
+ i++ // consume the closing "
+ }
+ return i
+}
+
+// consumeBackslash handles a backslash outside any quoted context: if a next
+// character exists it is treated as escaped; a trailing backslash is kept as-is.
+// i must point at the backslash character. Returns the index after consumed bytes.
+func consumeBackslash(s string, i int, out *strings.Builder) int {
+ if i+1 < len(s) {
+ out.WriteByte(s[i+1])
+ return i + 2
+ }
+ // Trailing backslash: keep it.
+ out.WriteByte('\\')
+ return i + 1
+}
+
func defaultStreamExportFilename() string {
return fmt.Sprintf("ior-stream-%s.csv", time.Now().Format("20060102-150405"))
}
@@ -122,6 +134,8 @@ func exportSnapshotToCSV(source Source, filter Filter, exportDir, filename strin
return exportRowsToCSV(rows, exportDir, name)
}
+// exportRowsToCSV writes rows to a CSV file under exportDir with the given
+// filename (which is validated and sanitised by ensureCSVFilename).
func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, error) {
name, err := ensureCSVFilename(filename)
if err != nil {
@@ -136,6 +150,7 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
if err != nil {
return "", err
}
+ // closeFile is idempotent; fail wraps any write error with a best-effort close.
closed := false
closeFile := func() error {
if closed {
@@ -151,11 +166,26 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
return "", baseErr
}
- w := csv.NewWriter(f)
+ if err := writeStreamCSV(csv.NewWriter(f), rows, fail); err != nil {
+ return "", err
+ }
+ if err := closeFile(); err != nil {
+ return "", err
+ }
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return path, nil
+ }
+ return absPath, nil
+}
+// writeStreamCSV writes the CSV header and all event rows to w, calling fail
+// on the first write error to close the underlying file before returning.
+func writeStreamCSV(w *csv.Writer, rows []StreamEvent, fail func(error) (string, error)) error {
header := []string{"seq", "time_ns", "gap_ns", "latency_ns", "comm", "pid", "tid", "syscall", "fd", "ret", "bytes", "file", "error"}
if err := w.Write(header); err != nil {
- return fail(err)
+ _, err = fail(err)
+ return err
}
for i := range rows {
ev := rows[i]
@@ -175,21 +205,16 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
fmt.Sprintf("%t", ev.IsError),
}
if err := w.Write(record); err != nil {
- return fail(err)
+ _, err = fail(err)
+ return err
}
}
w.Flush()
if err := w.Error(); err != nil {
- return fail(err)
- }
- if err := closeFile(); err != nil {
- return "", err
+ _, err = fail(err)
+ return err
}
- absPath, err := filepath.Abs(path)
- if err != nil {
- return path, nil
- }
- return absPath, nil
+ return nil
}
// ensureCSVFilename validates and normalises a user-supplied export filename.
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
index a8f399c..55b4f6e 100644
--- a/internal/tui/eventstream/model.go
+++ b/internal/tui/eventstream/model.go
@@ -174,72 +174,136 @@ func (m Model) Paused() bool {
return m.paused
}
+// HandleKey dispatches keyStr to the active modal or live/paused stream handlers.
+// It returns true if the key was consumed, false if the caller should handle it.
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())
+ return m.handleSearchModalKey(keyStr)
}
if m.exportModal.Visible() {
- m.statusMessage = ""
- var (
- filename string
- submit bool
- )
- m.exportModal, filename, submit = m.exportModal.Update(keyMsgFromString(keyStr))
- if !submit {
- return true
+ return m.handleExportModalKey(keyStr)
+ }
+ if m.fdTraceView.visible {
+ return m.handleFDTraceKey(keyStr)
+ }
+ return m.handleStreamKey(keyStr)
+}
+
+// handleSearchModalKey routes a key press while the search modal is open.
+func (m *Model) handleSearchModalKey(keyStr string) bool {
+ 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())
+}
+
+// handleExportModalKey routes a key press while the export modal is open.
+func (m *Model) handleExportModalKey(keyStr string) bool {
+ m.statusMessage = ""
+ var (
+ filename string
+ submit bool
+ )
+ m.exportModal, filename, submit = m.exportModal.Update(keyMsgFromString(keyStr))
+ if !submit {
+ return true
+ }
+ path, err := m.exportFilteredToCSV(filename)
+ if err != nil {
+ m.statusMessage = fmt.Sprintf("Export failed: %v", err)
+ return true
+ }
+ m.lastExportPath = path
+ m.statusMessage = "Exported: " + path
+ return true
+}
+
+// handleFDTraceKey routes a key press while the FD-trace overlay is visible.
+func (m *Model) handleFDTraceKey(keyStr string) bool {
+ switch keyStr {
+ case "enter", " ", "space":
+ return true
+ case "j", "down":
+ m.scrollFDTraceByLines(1)
+ return true
+ case "k", "up":
+ m.scrollFDTraceByLines(-1)
+ return true
+ case "left", "h":
+ return true
+ case "right", "l":
+ return true
+ case "pgdown", "pgdn", "pagedown":
+ m.scrollFDTraceByLines(m.pageStep())
+ return true
+ case "pgup", "pageup":
+ m.scrollFDTraceByLines(-m.pageStep())
+ return true
+ case "g":
+ m.fdTraceView.offset = 0
+ return true
+ case "G":
+ m.fdTraceView.offset = m.maxFDTraceOffset()
+ return true
+ case "esc", "q":
+ m.fdTraceView.visible = false
+ m.fdTraceView.events = nil
+ m.fdTraceView.offset = 0
+ return true
+ default:
+ return false
+ }
+}
+
+// handleStreamExportKey handles x/X/E export shortcuts while the stream is paused.
+func (m *Model) handleStreamExportKey(keyStr string) (bool, bool) {
+ switch keyStr {
+ case "x":
+ if !m.paused {
+ return false, true
}
- path, err := m.exportFilteredToCSV(filename)
+ m.statusMessage = ""
+ path, err := m.exportFilteredToCSV(defaultStreamExportFilename())
if err != nil {
m.statusMessage = fmt.Sprintf("Export failed: %v", err)
- return true
+ return true, true
}
m.lastExportPath = path
m.statusMessage = "Exported: " + path
- return true
- }
- if m.fdTraceView.visible {
- switch keyStr {
- case "enter", " ", "space":
- return true
- case "j", "down":
- m.scrollFDTraceByLines(1)
- return true
- case "k", "up":
- m.scrollFDTraceByLines(-1)
- return true
- case "left", "h":
- return true
- case "right", "l":
- return true
- case "pgdown", "pgdn", "pagedown":
- m.scrollFDTraceByLines(m.pageStep())
- return true
- case "pgup", "pageup":
- m.scrollFDTraceByLines(-m.pageStep())
- return true
- case "g":
- m.fdTraceView.offset = 0
- return true
- case "G":
- m.fdTraceView.offset = m.maxFDTraceOffset()
- return true
- case "esc", "q":
- m.fdTraceView.visible = false
- m.fdTraceView.events = nil
- m.fdTraceView.offset = 0
- return true
- default:
- return false
+ return true, true
+ case "X":
+ if !m.paused {
+ return false, true
}
+ m.statusMessage = ""
+ m.exportModal = m.exportModal.Open(defaultStreamExportFilename())
+ return true, true
+ case "E":
+ if !m.paused {
+ return false, true
+ }
+ m.statusMessage = ""
+ if m.lastExportPath == "" {
+ m.statusMessage = "No stream export yet"
+ return true, true
+ }
+ m.pendingOpenPath = m.lastExportPath
+ m.statusMessage = "Opening in editor: " + m.lastExportPath
+ return true, true
+ }
+ return false, false
+}
+
+// handleStreamKey handles keys for the main live/paused stream table.
+func (m *Model) handleStreamKey(keyStr string) bool {
+ if consumed, handled := m.handleStreamExportKey(keyStr); handled {
+ return consumed
}
switch keyStr {
@@ -276,112 +340,80 @@ func (m *Model) HandleKey(keyStr string) bool {
return false
}
return m.jumpSearch(-m.searchDirection)
- case "x":
- if !m.paused {
- return false
- }
- m.statusMessage = ""
- path, err := m.exportFilteredToCSV(defaultStreamExportFilename())
- if err != nil {
- m.statusMessage = fmt.Sprintf("Export failed: %v", err)
- return true
- }
- m.lastExportPath = path
- m.statusMessage = "Exported: " + path
- return true
- case "X":
- if !m.paused {
- return false
- }
- m.statusMessage = ""
- m.exportModal = m.exportModal.Open(defaultStreamExportFilename())
- return true
- case "E":
- if !m.paused {
- return false
- }
- m.statusMessage = ""
- if m.lastExportPath == "" {
- m.statusMessage = "No stream export yet"
- return true
- }
- m.pendingOpenPath = m.lastExportPath
- m.statusMessage = "Opening in editor: " + m.lastExportPath
- return true
case " ", "space":
- m.paused = !m.paused
- if !m.paused {
- // Resuming should return to live-tail behavior immediately.
- m.autoScroll = true
- m.selectedIdx = -1
- m.Refresh()
- } else {
- m.ensureSelection()
- m.ensureSelectedCol()
- m.centerSelection()
- }
- return true
- case "G":
- if m.paused {
- return m.handlePausedTableNavigation("G")
- } else {
- m.autoScroll = true
- m.viewport.GotoBottom()
- m.scrollOffset = clamp(m.viewport.YOffset(), 0, m.maxScrollOffset())
- }
- return true
- case "g":
- if m.paused {
- return m.handlePausedTableNavigation("g")
- } else {
- m.autoScroll = false
- m.viewport.GotoTop()
- m.scrollOffset = 0
- }
- return true
- case "j", "down":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- } else {
- m.handleViewportUpdate(keyMsgFromString("down"))
- }
- return true
- case "k", "up":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- } else {
- m.handleViewportUpdate(keyMsgFromString("up"))
- }
- return true
- case "left", "h":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- }
- return m.handleViewportUpdate(keyMsgFromString("left"))
- case "right", "l":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- }
- return m.handleViewportUpdate(keyMsgFromString("right"))
- case "pgdown", "pgdn", "pagedown":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- } else {
- m.handleViewportUpdate(keyMsgFromString("pgdown"))
- }
- return true
- case "pgup", "pageup":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- } else {
- m.handleViewportUpdate(keyMsgFromString("pgup"))
- }
- return true
+ return m.handleSpaceKey()
+ case "G", "g", "j", "down", "k", "up", "left", "h", "right", "l",
+ "pgdown", "pgdn", "pagedown", "pgup", "pageup":
+ return m.handleNavigationKey(keyStr)
default:
return false
}
}
+// handleSpaceKey toggles the paused/live state of the stream.
+func (m *Model) handleSpaceKey() bool {
+ m.paused = !m.paused
+ if !m.paused {
+ // Resuming returns to live-tail behavior immediately.
+ m.autoScroll = true
+ m.selectedIdx = -1
+ m.Refresh()
+ } else {
+ m.ensureSelection()
+ m.ensureSelectedCol()
+ m.centerSelection()
+ }
+ return true
+}
+
+// handleNavigationKey dispatches scroll/cursor navigation in live and paused
+// modes. It delegates g/G (goto edges) to handleGotoKey and directional keys
+// (arrows, hjkl, page up/down) to handleDirectionalKey.
+func (m *Model) handleNavigationKey(keyStr string) bool {
+ switch keyStr {
+ case "G", "g":
+ return m.handleGotoKey(keyStr)
+ case "j", "down", "k", "up", "left", "h", "right", "l",
+ "pgdown", "pgdn", "pagedown", "pgup", "pageup":
+ return m.handleDirectionalKey(keyStr)
+ default:
+ return false
+ }
+}
+
+// handleGotoKey handles g (top) and G (bottom) in both live and paused modes.
+func (m *Model) handleGotoKey(keyStr string) bool {
+ if m.paused {
+ return m.handlePausedTableNavigation(keyStr)
+ }
+ if keyStr == "G" {
+ m.autoScroll = true
+ m.viewport.GotoBottom()
+ m.scrollOffset = clamp(m.viewport.YOffset(), 0, m.maxScrollOffset())
+ } else {
+ m.autoScroll = false
+ m.viewport.GotoTop()
+ m.scrollOffset = 0
+ }
+ return true
+}
+
+// handleDirectionalKey handles arrow/hjkl/page keys in both live and paused modes.
+func (m *Model) handleDirectionalKey(keyStr string) bool {
+ if m.paused {
+ return m.handlePausedTableNavigation(keyStr)
+ }
+ // Map multi-word key names to canonical viewport key strings.
+ vpKey := keyStr
+ switch keyStr {
+ case "pgdown", "pgdn", "pagedown":
+ vpKey = "pgdown"
+ case "pgup", "pageup":
+ vpKey = "pgup"
+ }
+ return m.handleViewportUpdate(keyMsgFromString(vpKey))
+}
+
// HandleTeaKey handles stream keys based on Bubble Tea key message types first,
// then falls back to string matching for rune-driven shortcuts.
func (m *Model) HandleTeaKey(msg tea.KeyPressMsg) bool {
@@ -447,6 +479,8 @@ func (m *Model) handleViewportUpdate(msg tea.KeyPressMsg) bool {
return true
}
+// View renders the stream table (or FD-trace overlay) for the given dimensions.
+// It also renders any open modal on top of the base view.
func (m *Model) View(width, height int) string {
if width <= 0 {
width = 100
@@ -463,6 +497,24 @@ func (m *Model) View(width, height int) string {
return m.viewFDTrace(width)
}
+ base, start := m.renderStreamBase(width)
+
+ // Modals overlay the full view regardless of footer visibility.
+ if m.exportModal.Visible() {
+ return m.exportModal.View(width, height)
+ }
+ if m.searchModal.Visible() {
+ return m.searchModal.View(width, height)
+ }
+ if !m.showFooter {
+ return base
+ }
+ return m.appendStreamFooter(base, start)
+}
+
+// renderStreamBase computes the visible row slice and renders the stream table.
+// It returns the rendered string and the start index used for the status line.
+func (m *Model) renderStreamBase(width int) (string, int) {
rows := m.visibleRows()
start := clamp(m.viewport.YOffset(), 0, m.maxScrollOffset())
m.scrollOffset = start
@@ -475,30 +527,27 @@ func (m *Model) View(width, height int) string {
if m.paused && m.selectedIdx >= start && m.selectedIdx < end {
selectedVisibleIdx = m.selectedIdx - start
}
-
bufferLen := 0
if m.source != nil {
bufferLen = m.source.Len()
}
-
selectedCol := -1
if m.paused && selectedVisibleIdx >= 0 {
selectedCol = m.selectedCol
}
base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, m.filterStack, visible, selectedVisibleIdx, selectedCol)
- if !m.showFooter {
- if m.exportModal.Visible() {
- return m.exportModal.View(width, height)
- }
- if m.searchModal.Visible() {
- return m.searchModal.View(width, height)
- }
- return base
- }
+ return base, start
+}
+// appendStreamFooter appends the status line (and optional status message) to
+// the rendered table string using a Builder to minimise allocations.
+func (m *Model) appendStreamFooter(base string, start int) string {
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 push-filter | Esc/F undo", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount)
+ status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Enter push-filter | Esc/F undo",
+ rowNumber(start, len(m.filtered)), len(m.filtered),
+ rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered),
+ m.selectedCol+1, streamColumnCount)
}
// Use a Builder to avoid a redundant allocation for the optional status-message
// line appended conditionally on every render call.
@@ -510,13 +559,6 @@ func (m *Model) View(width, height int) string {
b.WriteString("\n")
b.WriteString(m.statusMessage)
}
-
- if m.exportModal.Visible() {
- return m.exportModal.View(width, height)
- }
- if m.searchModal.Visible() {
- return m.searchModal.View(width, height)
- }
return b.String()
}