diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-12 22:47:20 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-12 22:47:20 +0300 |
| commit | 35df301fceabfadc8b8a4ae221cc0c2391e233cd (patch) | |
| tree | 2d5da8031aacd588e31db1aad05c2956412f7566 /internal/tui/tracelifecycle.go | |
| parent | a2c067cc49b96968da81031275de9c44c4ba2ee9 (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.go | 172 |
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] +} |
