summaryrefslogtreecommitdiff
path: root/internal/tui/export
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 09:12:27 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 09:12:27 +0200
commitceb5c392d363f9f6afccd310b0a7a7efb14bb4e3 (patch)
tree6aec30553232197d40fdc58521c8b499585ff309 /internal/tui/export
parentc774072685c4768ec796c5f61a8140f9f673db8c (diff)
tui: add export modal with csv snapshot export
Diffstat (limited to 'internal/tui/export')
-rw-r--r--internal/tui/export/model.go173
-rw-r--r--internal/tui/export/model_test.go72
2 files changed, 245 insertions, 0 deletions
diff --git a/internal/tui/export/model.go b/internal/tui/export/model.go
new file mode 100644
index 0000000..1e875e5
--- /dev/null
+++ b/internal/tui/export/model.go
@@ -0,0 +1,173 @@
+package export
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Option is a selectable export target.
+type Option int
+
+const (
+ OptionFlamegraph Option = iota
+ OptionCSV
+ OptionCancel
+)
+
+var optionLabels = []string{
+ "CSV snapshot",
+ "Cancel",
+}
+
+var optionValues = []Option{
+ OptionCSV,
+ OptionCancel,
+}
+
+// RequestMsg asks the parent model to perform an export.
+type RequestMsg struct {
+ Option Option
+}
+
+// CompletedMsg reports a finished export with output path.
+type CompletedMsg struct {
+ Path string
+}
+
+// FailedMsg reports an export error.
+type FailedMsg struct {
+ Err error
+}
+
+// Model is the export modal state machine.
+type Model struct {
+ visible bool
+ selected int
+ exporting bool
+ status string
+}
+
+// NewModel creates a closed export modal.
+func NewModel() Model {
+ return Model{}
+}
+
+func (m Model) Visible() bool { return m.visible }
+
+func (m Model) Open() Model {
+ m.visible = true
+ m.selected = 0
+ m.exporting = false
+ m.status = ""
+ return m
+}
+
+func (m Model) Close() Model {
+ m.visible = false
+ m.exporting = false
+ m.status = ""
+ return m
+}
+
+// Update handles modal key navigation and export completion messages.
+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ if !m.visible {
+ return m, nil
+ }
+ if m.exporting {
+ if msg.String() == "esc" {
+ return m.Close(), nil
+ }
+ return m, nil
+ }
+
+ switch msg.String() {
+ case "esc":
+ return m.Close(), nil
+ case "up", "k":
+ if m.selected > 0 {
+ m.selected--
+ }
+ return m, nil
+ case "down", "j":
+ if m.selected < len(optionValues)-1 {
+ m.selected++
+ }
+ return m, nil
+ case "enter":
+ option := optionValues[m.selected]
+ if option == OptionCancel {
+ return m.Close(), nil
+ }
+ m.exporting = true
+ m.status = fmt.Sprintf("Exporting %s...", optionLabels[m.selected])
+ return m, func() tea.Msg { return RequestMsg{Option: option} }
+ }
+ case CompletedMsg:
+ m.exporting = false
+ if msg.Path == "" {
+ msg.Path = "done"
+ }
+ m.status = "Exported: " + msg.Path
+ return m, nil
+ case FailedMsg:
+ m.exporting = false
+ if msg.Err == nil {
+ msg.Err = errors.New("unknown export failure")
+ }
+ m.status = "Export failed: " + msg.Err.Error()
+ return m, nil
+ }
+
+ return m, nil
+}
+
+// View renders a centered modal overlay.
+func (m Model) View(width, height int) string {
+ if !m.visible {
+ return ""
+ }
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 24
+ }
+
+ modalWidth := 48
+ if width < modalWidth+4 {
+ modalWidth = width - 4
+ if modalWidth < 30 {
+ modalWidth = 30
+ }
+ }
+
+ lines := []string{"Export"}
+ for i, label := range optionLabels {
+ prefix := " "
+ if i == m.selected && !m.exporting {
+ prefix = "> "
+ }
+ lines = append(lines, prefix+label)
+ }
+ if m.status != "" {
+ lines = append(lines, "", m.status)
+ }
+ if !m.exporting {
+ lines = append(lines, "", "Enter confirm • 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/export/model_test.go b/internal/tui/export/model_test.go
new file mode 100644
index 0000000..a97cd8b
--- /dev/null
+++ b/internal/tui/export/model_test.go
@@ -0,0 +1,72 @@
+package export
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestOpenAndClose(t *testing.T) {
+ m := NewModel().Open()
+ if !m.Visible() {
+ t.Fatalf("expected modal to be visible after Open")
+ }
+ m = m.Close()
+ if m.Visible() {
+ t.Fatalf("expected modal to be hidden after Close")
+ }
+}
+
+func TestEnterEmitsRequest(t *testing.T) {
+ m := NewModel().Open()
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if cmd == nil {
+ t.Fatalf("expected request command on enter")
+ }
+ if !next.exporting {
+ t.Fatalf("expected exporting state after enter")
+ }
+ req, ok := cmd().(RequestMsg)
+ if !ok {
+ t.Fatalf("expected RequestMsg from enter command")
+ }
+ if req.Option != OptionCSV {
+ t.Fatalf("expected CSV as default export option, got %v", req.Option)
+ }
+}
+
+func TestCancelOptionCloses(t *testing.T) {
+ m := NewModel().Open()
+ m.selected = len(optionValues) - 1
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if cmd != nil {
+ t.Fatalf("expected no command when selecting cancel")
+ }
+ if next.Visible() {
+ t.Fatalf("expected modal to close on cancel option")
+ }
+}
+
+func TestStatusMessages(t *testing.T) {
+ m := NewModel().Open()
+ m.exporting = true
+
+ next, _ := m.Update(CompletedMsg{Path: "out.csv"})
+ if next.exporting {
+ t.Fatalf("expected exporting=false after completion")
+ }
+ if !strings.Contains(next.status, "out.csv") {
+ t.Fatalf("expected completion path in status")
+ }
+
+ next.exporting = true
+ next, _ = next.Update(FailedMsg{Err: errors.New("boom")})
+ if next.exporting {
+ t.Fatalf("expected exporting=false after failure")
+ }
+ if !strings.Contains(next.status, "boom") {
+ t.Fatalf("expected failure reason in status")
+ }
+}