summaryrefslogtreecommitdiff
path: root/internal/tui/eventstream/model.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-26 23:33:55 +0200
committerPaul Buetow <paul@buetow.org>2026-02-26 23:33:55 +0200
commit4ca34f040203c8e31603bbb39fd38632b68067d8 (patch)
treeeed81b39e169eb6d0cd7d2eca6b338c7c0914ba4 /internal/tui/eventstream/model.go
parente5cb5db2292ae84680935767d455a777125e0fe9 (diff)
tui: add paused stream CSV export and foreground editor open
Diffstat (limited to 'internal/tui/eventstream/model.go')
-rw-r--r--internal/tui/eventstream/model.go90
1 files changed, 89 insertions, 1 deletions
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
index 3258954..7f67702 100644
--- a/internal/tui/eventstream/model.go
+++ b/internal/tui/eventstream/model.go
@@ -42,6 +42,11 @@ type Model struct {
fdTraceView fdTraceViewState
filterStack []Filter
filterActionStack []string
+ exportModal ExportModal
+ lastExportPath string
+ pendingOpenPath string
+ statusMessage string
+ exportDir string
width int
height int
@@ -59,9 +64,11 @@ func NewModel(source *RingBuffer) Model {
return Model{
source: source,
filterModal: NewFilterModal(),
+ exportModal: NewExportModal(),
autoScroll: true,
selectedIdx: -1,
selectedCol: 0,
+ exportDir: ".",
}
}
@@ -87,12 +94,36 @@ func (m Model) FilterModalVisible() bool {
return m.filterModal.Visible()
}
+// ExportModalVisible reports whether the stream export modal is currently open.
+func (m Model) ExportModalVisible() bool {
+ return m.exportModal.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.exportModal.Visible() {
+ 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
+ }
if m.filterModal.Visible() {
wasVisible := m.filterModal.Visible()
m.filterModal = m.filterModal.Update(keyMsgFromString(keyStr))
@@ -150,6 +181,38 @@ func (m *Model) HandleKey(keyStr string) bool {
return m.applyFilterFromSelectedCell()
}
return false
+ 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 {
@@ -308,18 +371,24 @@ func (m *Model) View(width, height int) string {
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.paused && m.selectedIdx >= 0 {
- status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | enter:add-filter esc:undo(%d) 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 | 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))
}
out := base + "\n" + status
if len(m.filterActionStack) > 0 {
out += "\n" + "Stack: " + strings.Join(m.filterActionStack, " | ")
}
+ if m.statusMessage != "" {
+ out += "\n" + m.statusMessage
+ }
if m.filterModal.Visible() {
// While editing filters, show a dedicated modal screen to avoid
// visual mixing with the live stream table underneath.
return m.filterModal.View(width, height)
}
+ if m.exportModal.Visible() {
+ return m.exportModal.View(width, height)
+ }
return out
}
@@ -720,6 +789,25 @@ func (m *Model) setFilterForTest(f Filter) {
m.filter = f
}
+func (m *Model) setExportDirForTest(dir string) {
+ m.exportDir = dir
+}
+
+// ConsumeOpenEditorRequest returns the pending editor-open path once.
+func (m *Model) ConsumeOpenEditorRequest() (string, bool) {
+ if m.pendingOpenPath == "" {
+ return "", false
+ }
+ path := m.pendingOpenPath
+ m.pendingOpenPath = ""
+ return path, true
+}
+
+// SetStatusMessage updates the stream footer status line.
+func (m *Model) SetStatusMessage(message string) {
+ m.statusMessage = message
+}
+
func (m *Model) dumpVisibleForTest() string {
rows := make([]string, 0, len(m.filtered))
for _, ev := range m.filtered {