summaryrefslogtreecommitdiff
path: root/internal/tui/tracelifecycle.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-12 22:47:20 +0300
committerPaul Buetow <paul@buetow.org>2026-05-12 22:47:20 +0300
commit35df301fceabfadc8b8a4ae221cc0c2391e233cd (patch)
tree2d5da8031aacd588e31db1aad05c2956412f7566 /internal/tui/tracelifecycle.go
parenta2c067cc49b96968da81031275de9c44c4ba2ee9 (diff)
extract TUI Model god class into focused sub-controllers
Split the 1389-line tui.go Model into three focused sub-controllers that each own a single concern: - filterstack.go (filterStack): owns the filter chain, undo history, and label stack; provides push/pop/rebindProcessFilters API so the Model never manipulates filter slices directly. - tracelifecycle.go (traceLifecycle): owns trace start/stop and the active context.CancelFunc; provides beginCmd/stop API; also houses the recorder helpers (recorderStart/Stop/Active/Status) and the auto-reset cycle logic (nextAutoResetInterval, autoResetCycle). - screenrouter.go (screenRouter): owns the picker-return bookmark (pickerReturn) and the applyWindowSizeToPicker helper so screen transition code in tui.go delegates to it. The Model.Update switch is split into dispatchTypedMsg (framework messages) and dispatchAppMsg (app messages) to keep each helper under the 50-line limit. View is split into viewPickerScreen and viewDashboardScreen for the same reason. All functions are ≤50 lines. go test ./internal/tui/... passes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/tui/tracelifecycle.go')
-rw-r--r--internal/tui/tracelifecycle.go172
1 files changed, 172 insertions, 0 deletions
diff --git a/internal/tui/tracelifecycle.go b/internal/tui/tracelifecycle.go
new file mode 100644
index 0000000..5877cee
--- /dev/null
+++ b/internal/tui/tracelifecycle.go
@@ -0,0 +1,172 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "ior/internal/globalfilter"
+ "ior/internal/parquet"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+// traceLifecycle manages trace start/stop, recording start/stop, and the
+// auto-reset interval cycle. It owns the context.CancelFunc for the running
+// trace so the Model can stop tracing without understanding the context
+// machinery.
+type traceLifecycle struct {
+ startTrace TraceStarter
+ traceStop context.CancelFunc
+}
+
+// newTraceLifecycle creates a traceLifecycle bound to the given starter.
+// If starter is nil, a no-op default is used so the model can operate
+// in tests without a real BPF trace.
+func newTraceLifecycle(starter TraceStarter) traceLifecycle {
+ if starter == nil {
+ starter = defaultTraceStarter
+ }
+ return traceLifecycle{startTrace: starter}
+}
+
+// beginCmd creates a tea.Cmd that runs the trace starter in a goroutine and
+// returns a TracingStartedMsg or TracingErrorMsg. It also cancels any
+// previously running trace and stores the new cancel function.
+func (t *traceLifecycle) beginCmd(runtime *runtimeBindings, filter globalfilter.Filter) tea.Cmd {
+ ctx, cancel := context.WithCancel(context.Background())
+ t.traceStop = cancel
+ ctx = ContextWithRuntimeBindings(ctx, runtime)
+ ctx = ContextWithTraceFilters(ctx, filter)
+ return startTraceCmd(t.startTrace, ctx)
+}
+
+// stop cancels the running trace and clears the cancel function. Safe to call
+// multiple times or when no trace is running.
+func (t *traceLifecycle) stop() {
+ if t.traceStop != nil {
+ t.traceStop()
+ t.traceStop = nil
+ }
+}
+
+// startTraceCmd wraps a TraceStarter in a tea.Cmd that handles context
+// cancellation gracefully (returns nil so the caller does not treat a
+// user-initiated stop as an error).
+func startTraceCmd(starter TraceStarter, ctx context.Context) tea.Cmd {
+ return func() tea.Msg {
+ if err := starter(ctx); err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil
+ }
+ return TracingErrorMsg{Err: err}
+ }
+ return TracingStartedMsg{}
+ }
+}
+
+func defaultTraceStarter(context.Context) error {
+ return nil
+}
+
+// recorderStart opens the parquet recorder at the given path.
+// It calls syncFn (typically syncDashboardFilterState) after the attempt
+// (success or failure) so the status bar stays in sync.
+func recorderStart(recorder *parquet.Recorder, path string, syncFn func()) error {
+ if recorder == nil {
+ return errors.New("recording runtime is unavailable")
+ }
+ err := recorder.Start(path, parquet.StartOptions{Metadata: tuiParquetMetadata()})
+ syncFn()
+ return err
+}
+
+// recorderStop closes the active parquet recorder.
+// Returns nil without error when no recording is active.
+// Calls syncFn after the attempt so the status bar stays in sync.
+func recorderStop(recorder *parquet.Recorder, syncFn func()) error {
+ if recorder == nil {
+ return nil
+ }
+ if !recorder.Status().Active {
+ syncFn()
+ return nil
+ }
+ err := recorder.Stop()
+ syncFn()
+ return err
+}
+
+// recorderActive returns true when the recorder is currently recording.
+func recorderActive(recorder *parquet.Recorder) bool {
+ if recorder == nil {
+ return false
+ }
+ return recorder.Status().Active
+}
+
+// recorderStatus returns the human-readable recording status string shown
+// in the status bar.
+func recorderStatus(recorder *parquet.Recorder) string {
+ if recorder == nil {
+ return "rec: unavailable"
+ }
+ status := recorder.Status()
+ if status.Active {
+ return "rec: " + shortenRecordingPath(status.Path)
+ }
+ if status.LastError != nil {
+ return "rec err: " + status.LastError.Error()
+ }
+ return "rec: off"
+}
+
+func defaultParquetRecordingFilename() string {
+ return fmt.Sprintf("ior-recording-%s.parquet", time.Now().Format("20060102-150405"))
+}
+
+// tuiParquetMetadata delegates to the canonical parquet.NewFileMetadata.
+func tuiParquetMetadata() parquet.FileMetadata {
+ return parquet.NewFileMetadata("tui")
+}
+
+func shortenRecordingPath(path string) string {
+ const maxLen = 36
+ if len(path) <= maxLen {
+ return path
+ }
+ return "..." + path[len(path)-maxLen+3:]
+}
+
+// autoResetCycle is the ordered set of cadences exposed via the `I` hotkey.
+// The first entry (0) disables the timer; the rest are progressively longer
+// to give users a quick way to slow auto-resets down on long traces or turn
+// them off entirely. The cycle wraps so pressing `I` past the last preset
+// returns to off.
+var autoResetCycle = []time.Duration{
+ 0,
+ 10 * time.Second,
+ 30 * time.Second,
+ 60 * time.Second,
+ 2 * time.Minute,
+ 5 * time.Minute,
+}
+
+// nextAutoResetInterval returns the next entry in autoResetCycle after
+// current. If current is not in the cycle (e.g. a custom -resetTimer like
+// 47s), the next entry is the first cycle value strictly greater than current;
+// if there is none, we wrap to 0 (off).
+func nextAutoResetInterval(current time.Duration) time.Duration {
+ for i, d := range autoResetCycle {
+ if d == current {
+ return autoResetCycle[(i+1)%len(autoResetCycle)]
+ }
+ }
+ for _, d := range autoResetCycle {
+ if d > current {
+ return d
+ }
+ }
+ return autoResetCycle[0]
+}