summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/ior.go18
-rw-r--r--internal/ior_mode_test.go25
-rw-r--r--internal/tui/common/keys.go6
-rw-r--r--internal/tui/dashboard/model.go40
-rw-r--r--internal/tui/eventstream/export.go107
-rw-r--r--internal/tui/eventstream/export_test.go37
-rw-r--r--internal/tui/eventstream/exportmodal.go105
-rw-r--r--internal/tui/eventstream/model.go90
-rw-r--r--internal/tui/eventstream/model_test.go111
9 files changed, 526 insertions, 13 deletions
diff --git a/internal/ior.go b/internal/ior.go
index a910fc0..a19d2f7 100644
--- a/internal/ior.go
+++ b/internal/ior.go
@@ -272,9 +272,17 @@ func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, con
origPrintCb(ep)
}
}
- duration := time.Duration(flags.Get().Duration) * time.Second
- logln("Probing for", duration)
- ctx, cancel := context.WithTimeout(parentCtx, duration)
+ cfg := flags.Get()
+ ctx := parentCtx
+ cancel := func() {}
+ if shouldAutoStopByDuration(cfg) {
+ duration := time.Duration(cfg.Duration) * time.Second
+ logln("Probing for", duration)
+ ctx, cancel = context.WithTimeout(parentCtx, duration)
+ } else {
+ logln("Probing until stopped...")
+ ctx, cancel = context.WithCancel(parentCtx)
+ }
defer cancel()
signalCh := make(chan os.Signal, 1)
@@ -318,3 +326,7 @@ func signalTraceStarted(started chan<- struct{}) {
}
close(started)
}
+
+func shouldAutoStopByDuration(cfg flags.Flags) bool {
+ return cfg.PlainMode || cfg.FlamegraphEnable || cfg.PprofEnable
+}
diff --git a/internal/ior_mode_test.go b/internal/ior_mode_test.go
index 84ff651..42b0a48 100644
--- a/internal/ior_mode_test.go
+++ b/internal/ior_mode_test.go
@@ -36,6 +36,31 @@ func TestShouldRunTraceMode(t *testing.T) {
}
}
+func TestShouldAutoStopByDuration(t *testing.T) {
+ base := flags.Flags{}
+ if shouldAutoStopByDuration(base) {
+ t.Fatalf("expected default TUI mode not to auto-stop by duration")
+ }
+
+ withPlain := base
+ withPlain.PlainMode = true
+ if !shouldAutoStopByDuration(withPlain) {
+ t.Fatalf("expected plain mode to auto-stop by duration")
+ }
+
+ withFlamegraph := base
+ withFlamegraph.FlamegraphEnable = true
+ if !shouldAutoStopByDuration(withFlamegraph) {
+ t.Fatalf("expected flamegraph mode to auto-stop by duration")
+ }
+
+ withPprof := base
+ withPprof.PprofEnable = true
+ if !shouldAutoStopByDuration(withPprof) {
+ t.Fatalf("expected pprof mode to auto-stop by duration")
+ }
+}
+
func TestDispatchRunUsesTraceModeWhenRequested(t *testing.T) {
origRunTrace := runTraceFn
origRunTUI := runTUIFn
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index 30bc848..f25886b 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -68,6 +68,9 @@ func (k KeyMap) DashboardStatusHelp() []key.Binding {
helpTextBinding("h/l", "stream col"),
helpTextBinding("j/k", "scroll"),
helpTextBinding("up/down", "scroll"),
+ helpTextBinding("x", "stream export"),
+ helpTextBinding("X", "stream export as"),
+ helpTextBinding("E", "stream open last"),
)
return bindings
}
@@ -88,6 +91,9 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding {
helpTextBinding("h/l", "stream col"),
helpTextBinding("j/k", "scroll"),
helpTextBinding("up/down", "scroll"),
+ helpTextBinding("x", "stream export"),
+ helpTextBinding("X", "stream export as"),
+ helpTextBinding("E", "stream open last"),
},
}
}
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index fae6a1b..0e485d5 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -23,6 +23,9 @@ type SnapshotSource interface {
type refreshTickMsg struct{}
type streamTickMsg struct{}
+type streamEditorDoneMsg struct {
+ err error
+}
// Model is the dashboard tab framework model.
type Model struct {
@@ -99,6 +102,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
+ case streamEditorDoneMsg:
+ if msg.err != nil {
+ m.streamModel.SetStatusMessage("Open failed: " + msg.err.Error())
+ }
+ return m, nil
}
return m, nil
}
@@ -107,7 +115,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
prevActiveTab := m.activeTab
var cmd tea.Cmd
keyStr := msg.String()
- handled := m.handleScrollKey(msg)
+ handled, scrollCmd := m.handleScrollKey(msg)
+ if scrollCmd != nil {
+ cmd = scrollCmd
+ }
if handled && m.activeTab == TabStream && (keyStr == " " || keyStr == "space") && !m.streamModel.Paused() {
cmd = streamTickCmd()
}
@@ -164,24 +175,35 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, cmd
}
-func (m *Model) handleScrollKey(msg tea.KeyMsg) bool {
+func (m *Model) handleScrollKey(msg tea.KeyMsg) (bool, tea.Cmd) {
keyStr := msg.String()
switch m.activeTab {
case TabSyscalls:
- return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows())
+ return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()), nil
case TabFiles:
if m.filesDirGrouped {
- return scrollOffset(keyStr, &m.filesDirOffset, m.maxFilesDirRows())
+ return scrollOffset(keyStr, &m.filesDirOffset, m.maxFilesDirRows()), nil
}
- return scrollOffset(keyStr, &m.filesOffset, m.maxFilesRows())
+ return scrollOffset(keyStr, &m.filesOffset, m.maxFilesRows()), nil
case TabProcesses:
- return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows())
+ return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()), nil
case TabStream:
streamWidth, streamHeight := streamViewport(m.width, m.height)
m.streamModel.SetViewport(streamWidth, streamHeight)
- return m.streamModel.HandleTeaKey(msg)
+ handled := m.streamModel.HandleTeaKey(msg)
+ if path, ok := m.streamModel.ConsumeOpenEditorRequest(); ok {
+ editorCmd, err := eventstream.EditorCommandForPath(path)
+ if err != nil {
+ m.streamModel.SetStatusMessage("Open failed: " + err.Error())
+ return true, nil
+ }
+ return true, tea.ExecProcess(editorCmd, func(err error) tea.Msg {
+ return streamEditorDoneMsg{err: err}
+ })
+ }
+ return handled, nil
default:
- return false
+ return false, nil
}
}
@@ -245,7 +267,7 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot {
// BlocksGlobalShortcuts reports whether modal UI in the active tab should
// suppress top-level shortcuts (for example global export key handling).
func (m Model) BlocksGlobalShortcuts() bool {
- return m.activeTab == TabStream && m.streamModel.FilterModalVisible()
+ return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible())
}
// SetStreamSource updates the live stream source used by the stream tab.
diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go
new file mode 100644
index 0000000..679fddb
--- /dev/null
+++ b/internal/tui/eventstream/export.go
@@ -0,0 +1,107 @@
+package eventstream
+
+import (
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+func defaultStreamExportFilename() string {
+ return fmt.Sprintf("ior-stream-%s.csv", time.Now().Format("20060102-150405"))
+}
+
+func ensureCSVFilename(name string) (string, error) {
+ clean := strings.TrimSpace(name)
+ if clean == "" {
+ return "", errors.New("filename cannot be empty")
+ }
+ if strings.HasSuffix(strings.ToLower(clean), ".csv") {
+ return clean, nil
+ }
+ return clean + ".csv", nil
+}
+
+func (m *Model) exportFilteredToCSV(filename string) (string, error) {
+ name, err := ensureCSVFilename(filename)
+ if err != nil {
+ return "", err
+ }
+ path := name
+ if m.exportDir != "" {
+ path = filepath.Join(m.exportDir, name)
+ }
+
+ f, err := os.Create(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+
+ w := csv.NewWriter(f)
+ defer w.Flush()
+
+ 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 "", err
+ }
+ for i := range m.filtered {
+ ev := m.filtered[i]
+ record := []string{
+ fmt.Sprintf("%d", ev.Seq),
+ fmt.Sprintf("%d", ev.TimeNs),
+ fmt.Sprintf("%d", ev.GapNs),
+ fmt.Sprintf("%d", ev.DurationNs),
+ ev.Comm,
+ fmt.Sprintf("%d", ev.PID),
+ fmt.Sprintf("%d", ev.TID),
+ ev.Syscall,
+ fmt.Sprintf("%d", ev.FD),
+ fmt.Sprintf("%d", ev.RetVal),
+ fmt.Sprintf("%d", ev.Bytes),
+ ev.FileName,
+ fmt.Sprintf("%t", ev.IsError),
+ }
+ if err := w.Write(record); err != nil {
+ return "", err
+ }
+ }
+ if err := w.Error(); err != nil {
+ return "", err
+ }
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return path, nil
+ }
+ return absPath, nil
+}
+
+// EditorCommandForPath builds an editor command for the given path.
+func EditorCommandForPath(path string) (*exec.Cmd, error) {
+ parts, _, err := resolveEditorCommand()
+ if err != nil {
+ return nil, err
+ }
+ args := append(parts[1:], path)
+ return exec.Command(parts[0], args...), nil
+}
+
+func resolveEditorCommand() ([]string, string, error) {
+ candidates := []string{"SUDO_EDITOR", "VISUAL", "EDITOR"}
+ for _, key := range candidates {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ continue
+ }
+ parts := strings.Fields(value)
+ if len(parts) == 0 {
+ continue
+ }
+ return parts, key, nil
+ }
+ return []string{"vi"}, "fallback", nil
+}
diff --git a/internal/tui/eventstream/export_test.go b/internal/tui/eventstream/export_test.go
new file mode 100644
index 0000000..e60e932
--- /dev/null
+++ b/internal/tui/eventstream/export_test.go
@@ -0,0 +1,37 @@
+package eventstream
+
+import "testing"
+
+func TestResolveEditorCommandPrefersSudoEditor(t *testing.T) {
+ t.Setenv("SUDO_EDITOR", "nano")
+ t.Setenv("VISUAL", "vim")
+ t.Setenv("EDITOR", "nvim")
+
+ parts, source, err := resolveEditorCommand()
+ if err != nil {
+ t.Fatalf("resolve editor: %v", err)
+ }
+ if source != "SUDO_EDITOR" {
+ t.Fatalf("expected SUDO_EDITOR source, got %q", source)
+ }
+ if len(parts) != 1 || parts[0] != "nano" {
+ t.Fatalf("expected nano command, got %#v", parts)
+ }
+}
+
+func TestResolveEditorCommandFallsBackToVi(t *testing.T) {
+ t.Setenv("SUDO_EDITOR", "")
+ t.Setenv("VISUAL", "")
+ t.Setenv("EDITOR", "")
+
+ parts, source, err := resolveEditorCommand()
+ if err != nil {
+ t.Fatalf("resolve editor: %v", err)
+ }
+ if source != "fallback" {
+ t.Fatalf("expected fallback source, got %q", source)
+ }
+ if len(parts) != 1 || parts[0] != "vi" {
+ t.Fatalf("expected vi fallback, got %#v", parts)
+ }
+}
diff --git a/internal/tui/eventstream/exportmodal.go b/internal/tui/eventstream/exportmodal.go
new file mode 100644
index 0000000..cf020f7
--- /dev/null
+++ b/internal/tui/eventstream/exportmodal.go
@@ -0,0 +1,105 @@
+package eventstream
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type ExportModal struct {
+ visible bool
+ textInput textinput.Model
+ err string
+}
+
+func NewExportModal() ExportModal {
+ input := textinput.New()
+ input.Prompt = ""
+ input.CharLimit = 0
+ input.Width = 44
+ return ExportModal{textInput: input}
+}
+
+func (m ExportModal) Visible() bool {
+ return m.visible
+}
+
+func (m ExportModal) Open(defaultName string) ExportModal {
+ m.visible = true
+ m.err = ""
+ m.textInput.SetValue(defaultName)
+ m.textInput.CursorEnd()
+ m.textInput.Focus()
+ return m
+}
+
+func (m ExportModal) Close() ExportModal {
+ m.visible = false
+ m.err = ""
+ m.textInput.Blur()
+ return m
+}
+
+// Update returns updated modal, submitted filename, and whether submit occurred.
+func (m ExportModal) Update(msg tea.Msg) (ExportModal, 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":
+ filename := strings.TrimSpace(m.textInput.Value())
+ if filename == "" {
+ m.err = "filename is required"
+ return m, "", false
+ }
+ return m.Close(), filename, true
+ }
+ }
+ var cmd tea.Cmd
+ m.textInput, cmd = m.textInput.Update(msg)
+ _ = cmd
+ return m, "", false
+}
+
+func (m ExportModal) View(width, height int) string {
+ if !m.visible {
+ return ""
+ }
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 24
+ }
+ modalWidth := 74
+ if width < modalWidth+4 {
+ modalWidth = width - 4
+ if modalWidth < 44 {
+ modalWidth = 44
+ }
+ }
+
+ lines := []string{
+ "Export Stream CSV",
+ "",
+ "Filename:",
+ m.textInput.View(),
+ }
+ if m.err != "" {
+ lines = append(lines, "Error: "+m.err)
+ }
+ lines = append(lines, "", "Enter save • 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)
+}
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 {
diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go
index d55da61..74bccb6 100644
--- a/internal/tui/eventstream/model_test.go
+++ b/internal/tui/eventstream/model_test.go
@@ -1,6 +1,8 @@
package eventstream
import (
+ "encoding/csv"
+ "os"
"strings"
"testing"
)
@@ -583,3 +585,112 @@ func TestPausedEnterCanFilterByFDColumn(t *testing.T) {
t.Fatalf("expected 3 rows with fd=3, got %d", len(m.filtered))
}
}
+
+func TestPausedQuickExportWritesFilteredRows(t *testing.T) {
+ rb := NewRingBuffer()
+ rb.Push(StreamEvent{Seq: 1, Comm: "firefox", PID: 10, TID: 100, Syscall: "read", FileName: "/a"})
+ rb.Push(StreamEvent{Seq: 2, Comm: "bash", PID: 11, TID: 200, Syscall: "write", FileName: "/b"})
+ rb.Push(StreamEvent{Seq: 3, Comm: "firefox", PID: 12, TID: 300, Syscall: "open", FileName: "/c"})
+
+ m := NewModel(rb)
+ m.height = 20
+ m.setExportDirForTest(t.TempDir())
+ m.Refresh()
+ if !m.HandleKey("space") {
+ t.Fatalf("space should pause")
+ }
+
+ m.selectedIdx = 0
+ m.selectedCol = streamColComm
+ if !m.HandleKey("enter") {
+ t.Fatalf("enter should apply comm filter")
+ }
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected 2 filtered rows before export, got %d", len(m.filtered))
+ }
+
+ if !m.HandleKey("x") {
+ t.Fatalf("x should quick-export while paused")
+ }
+ if m.lastExportPath == "" {
+ t.Fatalf("expected last export path to be set")
+ }
+ records := readCSVRecords(t, m.lastExportPath)
+ if len(records) != 3 {
+ t.Fatalf("expected header + 2 rows in export, got %d records", len(records))
+ }
+ if records[1][4] != "firefox" || records[2][4] != "firefox" {
+ t.Fatalf("expected only firefox rows exported, got %q and %q", records[1][4], records[2][4])
+ }
+}
+
+func TestPausedExportAsModalSavesWithProvidedFilename(t *testing.T) {
+ rb := NewRingBuffer()
+ rb.Push(StreamEvent{Seq: 1, Comm: "proc", PID: 1, TID: 1, Syscall: "read"})
+ m := NewModel(rb)
+ m.height = 20
+ m.setExportDirForTest(t.TempDir())
+ m.Refresh()
+ _ = m.HandleKey("space")
+
+ if !m.HandleKey("X") {
+ t.Fatalf("X should open export modal while paused")
+ }
+ if !m.exportModal.Visible() {
+ t.Fatalf("expected export modal visible")
+ }
+ // Replace default value fully and submit.
+ m.exportModal = m.exportModal.Open("custom-name")
+ if !m.HandleKey("enter") {
+ t.Fatalf("enter should submit export modal")
+ }
+ if m.exportModal.Visible() {
+ t.Fatalf("expected export modal closed after submit")
+ }
+ if !strings.HasSuffix(m.lastExportPath, "custom-name.csv") {
+ t.Fatalf("expected custom-name.csv export path, got %q", m.lastExportPath)
+ }
+ if _, err := os.Stat(m.lastExportPath); err != nil {
+ t.Fatalf("expected exported file to exist: %v", err)
+ }
+}
+
+func TestPausedOpenLastExportQueuesRequest(t *testing.T) {
+ rb := NewRingBuffer()
+ rb.Push(StreamEvent{Seq: 1, Comm: "proc", PID: 1, TID: 1, Syscall: "read"})
+ m := NewModel(rb)
+ m.height = 20
+ m.setExportDirForTest(t.TempDir())
+ m.Refresh()
+ _ = m.HandleKey("space")
+ _ = m.HandleKey("x")
+
+ if !m.HandleKey("E") {
+ t.Fatalf("E should queue opening last export while paused")
+ }
+ path, ok := m.ConsumeOpenEditorRequest()
+ if !ok {
+ t.Fatalf("expected queued open-editor request")
+ }
+ if path != m.lastExportPath {
+ t.Fatalf("expected opened path %q, got %q", m.lastExportPath, path)
+ }
+ if _, ok := m.ConsumeOpenEditorRequest(); ok {
+ t.Fatalf("expected request to be consumed once")
+ }
+}
+
+func readCSVRecords(t *testing.T, path string) [][]string {
+ t.Helper()
+ f, err := os.Open(path)
+ if err != nil {
+ t.Fatalf("open csv: %v", err)
+ }
+ defer f.Close()
+ r := csv.NewReader(f)
+ records, err := r.ReadAll()
+ if err != nil {
+ t.Fatalf("read csv: %v", err)
+ }
+ return records
+}