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/tui.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/tui.go')
| -rw-r--r-- | internal/tui/tui.go | 639 |
1 files changed, 254 insertions, 385 deletions
diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 06fafea..6124fcd 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -2,11 +2,8 @@ package tui import ( "context" - "errors" "fmt" "log" - "strconv" - "strings" "sync" "sync/atomic" "time" @@ -308,23 +305,16 @@ type keyboardState struct { suppressUntil time.Time } -// filterState groups the trace filter chain: the active filter, the undo -// history, and the human-readable label stack used in the status bar. -type filterState struct { - global globalfilter.Filter - history []globalfilter.Filter - stack []string -} - // processState groups PID/TID filter values and the picker navigation // return bookmark used to restore the dashboard after re-selecting a process. type processState struct { - pid int - tid int - pickerReturn *pickerReturnState + pid int + tid int } -// Model is the top-level Bubble Tea model that routes between PID picker and dashboard. +// Model is the top-level Bubble Tea model that routes between PID picker and +// dashboard. It delegates filter management to filterStack, trace lifecycle +// to traceLifecycle, and screen transitions to screenRouter. type Model struct { screen Screen pidPicker pidpicker.Model @@ -347,16 +337,19 @@ type Model struct { spin spinner.Model lastErr error - startTrace TraceStarter - traceStop context.CancelFunc - - kb keyboardState - filter filterState - proc processState + // tracer owns trace start/stop and the active context.CancelFunc. + tracer traceLifecycle + // filters owns the filter chain, undo history, and label stack. + filters filterStack + // router owns screen-transition state (pending picker return). + router screenRouter + proc processState exportEnabled bool isDark bool focused bool + + kb keyboardState } type pickerReturnState struct { @@ -384,42 +377,32 @@ func newModelWithRuntimeConfig(initialPID int, startupFilter globalfilter.Filter common.ApplyPalette(true) syncStylesFromCommon() - spin := spinner.New() - spin.Spinner = spinner.MiniDot - if startTrace == nil { - startTrace = defaultTraceStarter - } keys := Keys if !exportEnabled { keys.Export = key.NewBinding() } - runtime := newRuntimeBindings() - dashboard := dashboardui.NewModelWithConfig(lateBoundDashboardSource{runtime: runtime}, runtime.eventStreamSource(), 1000, keys) - dashboard.SetDarkMode(true) - pidFilter := selectedPIDFilter(startupPidFilter) - if initialPID > 0 { - pidFilter = selectedPIDFilter(initialPID) - } - tidFilter := selectedPIDFilter(startupTidFilter) - if initialPID > 0 { - tidFilter = -1 - } - dashboard.SetPidFilter(pidFilter) + rt := newRuntimeBindings() + pidFilter, tidFilter := resolveStartupPIDFilters(initialPID, startupPidFilter, startupTidFilter) + dashboard := newDashboardWithRuntime(rt, pidFilter, keys) + + spin := spinner.New() + spin.Spinner = spinner.MiniDot model := Model{ screen: ScreenPIDPicker, pidPicker: pidpicker.New().SetDarkMode(true), dashboard: dashboard, exporter: tuiexport.NewModel(), - probeModal: probes.NewModel(runtime.currentProbeManager()).SetDarkMode(true), + probeModal: probes.NewModel(rt.currentProbeManager()).SetDarkMode(true), filterModal: tracefilterui.NewModel().SetDarkMode(true), recordModal: newRecordingModal().SetDarkMode(true), - runtime: runtime, + runtime: rt, keys: keys, spin: spin, - startTrace: startTrace, - filter: filterState{global: startupFilter.Clone()}, + tracer: newTraceLifecycle(startTrace), + filters: newFilterStack(startupFilter), + router: newScreenRouter(), exportEnabled: exportEnabled, isDark: true, focused: true, @@ -434,6 +417,28 @@ func newModelWithRuntimeConfig(initialPID int, startupFilter globalfilter.Filter return model } +// resolveStartupPIDFilters computes the effective pid/tid filter values from +// the startup arguments. When initialPID is provided it overrides the config +// PID filter and forces tid to -1 (no TID filter). +func resolveStartupPIDFilters(initialPID, startupPidFilter, startupTidFilter int) (pid, tid int) { + pid = selectedPIDFilter(startupPidFilter) + tid = selectedPIDFilter(startupTidFilter) + if initialPID > 0 { + pid = selectedPIDFilter(initialPID) + tid = -1 + } + return pid, tid +} + +// newDashboardWithRuntime creates a dark-mode dashboard bound to the given +// runtime and pre-configured with the initial PID filter. +func newDashboardWithRuntime(rt *runtimeBindings, pidFilter int, keys KeyMap) dashboardui.Model { + dashboard := dashboardui.NewModelWithConfig(lateBoundDashboardSource{runtime: rt}, rt.eventStreamSource(), 1000, keys) + dashboard.SetDarkMode(true) + dashboard.SetPidFilter(pidFilter) + return dashboard +} + // Init initializes the active child model and optional tracing startup command. func (m Model) Init() tea.Cmd { sizeCmd := initialWindowSizeCmd() @@ -458,93 +463,141 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } msg = normalizedMsg + if next, cmd, handled := m.dispatchTypedMsg(msg); handled { + return next, cmd + } + if next, cmd, handled := m.dispatchAppMsg(msg); handled { + return next, cmd + } + + if next, cmd, handled := m.handleModalDispatch(msg); handled { + return next, cmd + } + + return m.updateActiveModel(msg) +} + +// dispatchTypedMsg handles all typed message cases that require no modal check. +// Returns (model, cmd, true) when the message was consumed, or (_, _, false) +// to fall through to modal dispatch and then active-model routing. +func (m Model) dispatchTypedMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - return m.updateActiveModel(msg) + next, cmd := m.updateActiveModel(msg) + return next, cmd, true case tea.BackgroundColorMsg: m.applyTheme(msg.IsDark()) - return m, nil + return m, nil, true case tea.KeyboardEnhancementsMsg: m.kb.enhancements = msg m.kb.enhancementsKnown = true if msg.SupportsKeyDisambiguation() { log.Printf("tui: keyboard enhancements enabled (flags=%d, eventTypes=%t)", msg.Flags, msg.SupportsEventTypes()) } - return m, nil + return m, nil, true case tea.FocusMsg: - m.focused = true - // SetFocused returns a tea.Cmd that arms a fresh auto-reset tick - // when focus returns (or nil if the timer is disabled). It also - // bumps the dashboard's autoResetGen so any tick that was scheduled - // before the blur and is still in flight is dropped on arrival. - focusCmd := m.dashboard.SetFocused(true) - if m.screen == ScreenDashboard && !m.attaching { - // Init() arms its own auto-reset tick at the post-bump - // generation, so discard focusCmd here to avoid two - // concurrently-live ticks racing the cadence. - return m, tea.Batch(m.dashboard.Init(), m.dashboard.SnapshotCmd()) - } - return m, focusCmd + next, cmd := m.handleFocusMsg() + return next, cmd, true case tea.BlurMsg: m.focused = false // SetFocused returns nil on blur but still bumps autoResetGen so // that any in-flight tick scheduled before the blur is ignored. m.dashboard.SetFocused(false) - return m, nil + return m, nil, true case tea.KeyPressMsg: if next, cmd, handled := m.handleGlobalKeyPress(msg); handled { - return next, cmd + return next, cmd, true } + return m, nil, false + } + return m, nil, false +} + +// dispatchAppMsg handles application-level message types (export, probe, trace, +// filter) that are not tea framework messages. +// It is called after dispatchTypedMsg returns unhandled for non-framework types. +func (m Model) dispatchAppMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { case tuiexport.RequestMsg: - return m, runExportCmd(m.exportEnabled, msg.Option, m.dashboard) + return m, runExportCmd(m.exportEnabled, msg.Option, m.dashboard), true case tuiexport.CompletedMsg: var cmd tea.Cmd m.exporter, cmd = m.exporter.Update(msg) - return m, cmd + return m, cmd, true case tuiexport.FailedMsg: var cmd tea.Cmd m.exporter, cmd = m.exporter.Update(msg) - return m, cmd + return m, cmd, true case probes.ProbeToggledMsg: - var cmd tea.Cmd - m.probeModal, cmd = m.probeModal.Update(msg) - if snap := m.runtime.resetDashboardSnapshotSource(); snap != nil { - next, dashboardCmd := m.dashboard.Update(messages.StatsTickMsg{Snap: snap}) - m.dashboard = next.(dashboardui.Model) - return m, tea.Batch(dashboardCmd, cmd) - } - return m, cmd + next, cmd := m.handleProbeToggledMsg(msg) + return next, cmd, true case PidSelectedMsg: - return m.handlePidSelected(msg) + next, cmd := m.handlePidSelected(msg) + return next, cmd, true case TidSelectedMsg: - return m.handleTidSelected(msg) + next, cmd := m.handleTidSelected(msg) + return next, cmd, true case TracingStartedMsg: - m.attaching = false - m.dashboard.SetStreamSource(m.runtime.eventStreamSource()) - m.dashboard.SetLiveTrie(m.runtime.liveTrie()) - m.dashboard.SetGlobalFilter(m.filter.global) - m.syncDashboardFilterState() - width, height := common.EffectiveViewport(m.width, m.height) - next, sizeCmd := m.dashboard.Update(tea.WindowSizeMsg{Width: width, Height: height}) - m.dashboard = next.(dashboardui.Model) - return m, tea.Batch(sizeCmd, m.dashboard.Init(), m.dashboard.SnapshotCmd()) + next, cmd := m.handleTracingStarted() + return next, cmd, true case TracingErrorMsg: m.attaching = false m.lastErr = msg.Err - return m, nil + return m, nil, true case messages.GlobalFilterRequestedMsg: - return m.applyGlobalFilter(msg.Filter, msg.Action) + next, cmd := m.applyGlobalFilter(msg.Filter, msg.Action) + return next, cmd, true case messages.GlobalFilterUndoRequestedMsg: - return m.undoGlobalFilter() + next, cmd := m.undoGlobalFilter() + return next, cmd, true } + return m, nil, false +} - if next, cmd, handled := m.handleModalDispatch(msg); handled { - return next, cmd +// handleFocusMsg restores focus and re-arms the dashboard's auto-reset tick. +func (m Model) handleFocusMsg() (tea.Model, tea.Cmd) { + m.focused = true + // SetFocused returns a tea.Cmd that arms a fresh auto-reset tick + // when focus returns (or nil if the timer is disabled). It also + // bumps the dashboard's autoResetGen so any tick that was scheduled + // before the blur and is still in flight is dropped on arrival. + focusCmd := m.dashboard.SetFocused(true) + if m.screen == ScreenDashboard && !m.attaching { + // Init() arms its own auto-reset tick at the post-bump + // generation, so discard focusCmd here to avoid two + // concurrently-live ticks racing the cadence. + return m, tea.Batch(m.dashboard.Init(), m.dashboard.SnapshotCmd()) } + return m, focusCmd +} - return m.updateActiveModel(msg) +// handleProbeToggledMsg resets the dashboard aggregates after a probe toggle +// so the new probe set is reflected immediately. +func (m Model) handleProbeToggledMsg(msg probes.ProbeToggledMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.probeModal, cmd = m.probeModal.Update(msg) + if snap := m.runtime.resetDashboardSnapshotSource(); snap != nil { + next, dashboardCmd := m.dashboard.Update(messages.StatsTickMsg{Snap: snap}) + m.dashboard = next.(dashboardui.Model) + return m, tea.Batch(dashboardCmd, cmd) + } + return m, cmd +} + +// handleTracingStarted wires live sources into the dashboard once the trace +// starter confirms the trace is running. +func (m Model) handleTracingStarted() (tea.Model, tea.Cmd) { + m.attaching = false + m.dashboard.SetStreamSource(m.runtime.eventStreamSource()) + m.dashboard.SetLiveTrie(m.runtime.liveTrie()) + m.dashboard.SetGlobalFilter(m.filters.current()) + m.syncDashboardFilterState() + width, height := common.EffectiveViewport(m.width, m.height) + next, sizeCmd := m.dashboard.Update(tea.WindowSizeMsg{Width: width, Height: height}) + m.dashboard = next.(dashboardui.Model) + return m, tea.Batch(sizeCmd, m.dashboard.Init(), m.dashboard.SnapshotCmd()) } func (m *Model) keyNormalizer(msg tea.Msg) (tea.Msg, bool) { @@ -564,7 +617,7 @@ func (m Model) canHandleDashboardShortcut(msg tea.KeyPressMsg) bool { func (m Model) shouldCancelPickerToDashboard(msg tea.KeyPressMsg) bool { return m.screen == ScreenPIDPicker && - m.proc.pickerReturn != nil && + m.router.hasPendingReturn() && (isEscKey(msg) || key.Matches(msg, m.keys.Quit)) } @@ -617,12 +670,12 @@ func (m Model) handleHelpOverlayKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cm // so modals close before the user needs to press q again. func (m Model) handleQuitKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { if m.canHandleDashboardShortcut(msg) { - if err := m.stopRecording(); err != nil { + if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil { m.lastErr = err return m, nil, true } m.quitting = true - m.stopTrace() + m.tracer.stop() return m, tea.Quit, true } if m.shouldRouteQuitToEsc(msg) { @@ -666,21 +719,14 @@ func (m Model) handleDashboardShortcutKeys(msg tea.KeyPressMsg) (tea.Model, tea. return m, nil, true } if key.Matches(msg, m.keys.Record) { - if m.recordingActive() { - if err := m.stopRecording(); err != nil { - m.lastErr = err - } - return m, nil, true - } - m.recordModal = m.recordModal.Open(defaultParquetRecordingFilename()) - return m, nil, true + return m.handleRecordKey() } if key.Matches(msg, m.keys.Probes) { m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark).Open() return m, nil, true } if key.Matches(msg, m.keys.Filter) { - m.filterModal = m.filterModal.Open(m.filter.global) + m.filterModal = m.filterModal.Open(m.filters.current()) return m, nil, true } if key.Matches(msg, m.keys.FilterUndo) { @@ -702,36 +748,17 @@ func (m Model) handleDashboardShortcutKeys(msg tea.KeyPressMsg) (tea.Model, tea. return m, nil, false } -// 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. the user passed 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 +// handleRecordKey either stops an active recording or opens the record modal +// to start a new one. +func (m Model) handleRecordKey() (tea.Model, tea.Cmd, bool) { + if recorderActive(m.runtime.Recorder()) { + if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil { + m.lastErr = err } + return m, nil, true } - return autoResetCycle[0] + m.recordModal = m.recordModal.Open(defaultParquetRecordingFilename()) + return m, nil, true } // cycleAutoResetInterval advances the dashboard's auto-reset cadence to @@ -788,7 +815,7 @@ func (m Model) updateRecordModal(msg tea.Msg) (tea.Model, tea.Cmd) { if !submit { return m, dashboardCmd } - if err := m.startRecording(path); err != nil { + if err := recorderStart(m.runtime.Recorder(), path, m.syncDashboardFilterState); err != nil { m.recordModal = m.recordModal.SetError(err) return m, dashboardCmd } @@ -836,52 +863,55 @@ func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) { } } +// handlePidSelected stops any running trace, resets buffers, and starts a new +// trace for the newly selected PID. func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) { pid := selectedPIDFilter(msg.Pid) - if err := m.stopRecording(); err != nil { + if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil { m.lastErr = err return m, nil } - m.stopTrace() + m.tracer.stop() m.runtime.resetStreamBuffer() m.setProcessFilters(pid, -1) - m.proc.pickerReturn = nil + m.router.pickerReturn = nil m.screen = ScreenDashboard m.attaching = true m.lastErr = nil return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) } +// handleTidSelected stops any running trace, resets buffers, and starts a new +// trace filtered to the selected TID within the current (or provided) PID. func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) { tid := selectedPIDFilter(msg.Tid) pid := m.proc.pid if msg.Pid > 0 { pid = msg.Pid } - if err := m.stopRecording(); err != nil { + if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil { m.lastErr = err return m, nil } - m.stopTrace() + m.tracer.stop() m.runtime.resetStreamBuffer() m.setProcessFilters(pid, tid) - m.proc.pickerReturn = nil + m.router.pickerReturn = nil m.screen = ScreenDashboard m.attaching = true m.lastErr = nil return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) } +// reselectPID saves a return bookmark and switches to the PID picker so the +// user can choose a different process without losing dashboard state. func (m Model) reselectPID() (tea.Model, tea.Cmd) { - if err := m.stopRecording(); err != nil { + if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil { m.lastErr = err return m, nil } - m.proc.pickerReturn = &pickerReturnState{ - pidFilter: m.proc.pid, - tidFilter: m.proc.tid, - } - m.stopTrace() + m.router.savePendingReturn(m.proc.pid, m.proc.tid) + m.tracer.stop() m.screen = ScreenPIDPicker m.attaching = false m.lastErr = nil @@ -890,29 +920,21 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) { m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark) m.recordModal = newRecordingModal().SetDarkMode(m.isDark) m.pidPicker = pidpicker.New().SetDarkMode(m.isDark) - var sizeCmd tea.Cmd - if m.width > 0 && m.height > 0 { - msg := tea.WindowSizeMsg{Width: m.width, Height: m.height} - next, cmd := m.pidPicker.Update(msg) - m.pidPicker = next.(pidpicker.Model) - sizeCmd = cmd - } + m.pidPicker, sizeCmd = applyWindowSizeToPicker(m.pidPicker, m.width, m.height) return m, tea.Batch(sizeCmd, m.pidPicker.Init()) } +// reselectTID saves a return bookmark and switches to the TID picker within +// the current PID so the user can narrow tracing to a specific thread. func (m Model) reselectTID() (tea.Model, tea.Cmd) { pid := m.proc.pid - - if err := m.stopRecording(); err != nil { + if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil { m.lastErr = err return m, nil } - m.proc.pickerReturn = &pickerReturnState{ - pidFilter: m.proc.pid, - tidFilter: m.proc.tid, - } - m.stopTrace() + m.router.savePendingReturn(m.proc.pid, m.proc.tid) + m.tracer.stop() m.screen = ScreenPIDPicker m.attaching = false m.lastErr = nil @@ -921,14 +943,8 @@ func (m Model) reselectTID() (tea.Model, tea.Cmd) { m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark) m.recordModal = newRecordingModal().SetDarkMode(m.isDark) m.pidPicker = pidpicker.NewTIDWithKeys(pid, pidpicker.DefaultKeyMap()).SetDarkMode(m.isDark) - var sizeCmd tea.Cmd - if m.width > 0 && m.height > 0 { - msg := tea.WindowSizeMsg{Width: m.width, Height: m.height} - next, cmd := m.pidPicker.Update(msg) - m.pidPicker = next.(pidpicker.Model) - sizeCmd = cmd - } + m.pidPicker, sizeCmd = applyWindowSizeToPicker(m.pidPicker, m.width, m.height) return m, tea.Batch(sizeCmd, m.pidPicker.Init()) } @@ -939,17 +955,20 @@ func selectedPIDFilter(pid int) int { return pid } +// cancelPickerToDashboard restores the dashboard when the user presses Esc +// while in the picker after a reselectPID/reselectTID navigation. func (m Model) cancelPickerToDashboard() (tea.Model, tea.Cmd) { - if m.proc.pickerReturn == nil { + returnState, ok := m.router.takePendingReturn() + if !ok { return m, nil } - if err := m.stopRecording(); err != nil { + if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil { m.lastErr = err + // Restore the pending return state since we didn't complete the transition. + m.router.pickerReturn = &returnState return m, nil } - returnState := *m.proc.pickerReturn - m.proc.pickerReturn = nil - m.stopTrace() + m.tracer.stop() m.setProcessFilters(returnState.pidFilter, returnState.tidFilter) m.screen = ScreenDashboard m.attaching = true @@ -957,28 +976,10 @@ func (m Model) cancelPickerToDashboard() (tea.Model, tea.Cmd) { return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) } +// beginTraceCmd creates a tea.Cmd that starts the trace with the current +// runtime bindings and active filter. It cancels any previously running trace. func (m *Model) beginTraceCmd() tea.Cmd { - ctx, cancel := context.WithCancel(context.Background()) - m.traceStop = cancel - ctx = ContextWithRuntimeBindings(ctx, m.runtime) - ctx = ContextWithTraceFilters(ctx, m.filter.global) - return startTraceCmd(m.startTrace, ctx) -} - -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 + return m.tracer.beginCmd(m.runtime, m.filters.current()) } // filterFromConfig delegates to the canonical Config.TraceFilter method. @@ -986,49 +987,39 @@ func filterFromConfig(cfg flags.Config) globalfilter.Filter { return cfg.TraceFilter() } +// setProcessFilters updates the proc pid/tid, rebinds filter process constraints, +// and synchronises the dashboard filter display. func (m *Model) setProcessFilters(pid, tid int) { m.proc.pid = pid m.proc.tid = tid - m.filter.global = applyProcessFilters(m.filter.global, pid, tid) - for i := range m.filter.history { - m.filter.history[i] = applyProcessFilters(m.filter.history[i], pid, tid) - } + m.filters.rebindProcessFilters(pid, tid) m.syncDashboardFilterState() } +// setGlobalFilter directly replaces the active filter, extracts the new +// PID/TID values, and synchronises the dashboard. func (m *Model) setGlobalFilter(filter globalfilter.Filter) { - m.filter.global = filter.Clone() - // EqValue returns (0, false) when no equality filter is set; - // selectedPIDFilter maps non-positive values to -1 ("no filter"). - pid, _ := m.filter.global.PID.EqValue() - tid, _ := m.filter.global.TID.EqValue() - m.proc.pid = selectedPIDFilter(pid) - m.proc.tid = selectedPIDFilter(tid) + m.filters.setGlobal(filter) + m.proc.pid = m.filters.pidFromFilter() + m.proc.tid = m.filters.tidFromFilter() m.syncDashboardFilterState() } +// syncDashboardFilterState pushes all filter-related state (PID, global +// filter, label stack, recording status) into the dashboard model so the +// status bar stays consistent. func (m *Model) syncDashboardFilterState() { m.dashboard.SetPidFilter(m.proc.pid) - m.dashboard.SetGlobalFilter(m.filter.global) - m.dashboard.SetFilterStack(m.filter.stack) - m.dashboard.SetRecordingStatus(m.recordingStatus()) -} - -func applyProcessFilters(filter globalfilter.Filter, pid, tid int) globalfilter.Filter { - out := filter.Clone() - out.PID = globalfilter.NewEqFilter(int64(pid)) - out.TID = globalfilter.NewEqFilter(int64(tid)) - return out + m.dashboard.SetGlobalFilter(m.filters.current()) + m.dashboard.SetFilterStack(m.filters.labelStack()) + m.dashboard.SetRecordingStatus(recorderStatus(m.runtime.Recorder())) } +// applyGlobalFilter pushes a new filter onto the filter stack, applies it +// in-place when possible, or falls back to a full trace restart. func (m Model) applyGlobalFilter(filter globalfilter.Filter, action string) (tea.Model, tea.Cmd) { - nextFilter := filter.Clone() - changed := !m.filter.global.Equal(nextFilter) - if changed { - m.filter.history = append(m.filter.history, m.filter.global.Clone()) - m.filter.stack = append(m.filter.stack, globalFilterActionLabel(m.filter.global, nextFilter, action)) - } - m.setGlobalFilter(nextFilter) + changed := m.filters.push(filter, action) + m.setGlobalFilter(m.filters.current()) if !changed || m.screen != ScreenDashboard { return m, nil } @@ -1039,7 +1030,7 @@ func (m Model) applyGlobalFilter(filter globalfilter.Filter, action string) (tea // aggregates so the displayed counts reflect the new filter going // forward. The BPF probes stay attached, so the user no longer sees // the multi-second 'Attaching tracepoints' overlay on filter changes. - if m.runtime.applyLiveFilter(nextFilter) { + if m.runtime.applyLiveFilter(m.filters.current()) { m.dashboard.PrepareForTraceRestart() // PrepareForTraceRestart nils the dashboard's live-trie reference // because the full-restart path expects TracingStartedMsg to @@ -1054,22 +1045,20 @@ func (m Model) applyGlobalFilter(filter globalfilter.Filter, action string) (tea // Fallback: no trace currently running (e.g. first invocation), so // restart the pipeline so the new filter takes effect on the next // trace start. - m.stopTrace() + m.tracer.stop() m.dashboard.PrepareForTraceRestart() m.attaching = true m.lastErr = nil return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) } +// undoGlobalFilter pops the filter stack and re-applies the previous filter, +// using the same in-place swap or restart logic as applyGlobalFilter. func (m Model) undoGlobalFilter() (tea.Model, tea.Cmd) { - if len(m.filter.history) == 0 { + prev, ok := m.filters.pop() + if !ok { return m, nil } - prev := m.filter.history[len(m.filter.history)-1] - m.filter.history = m.filter.history[:len(m.filter.history)-1] - if len(m.filter.stack) > 0 { - m.filter.stack = m.filter.stack[:len(m.filter.stack)-1] - } m.setGlobalFilter(prev) if m.screen != ScreenDashboard { return m, nil @@ -1084,87 +1073,23 @@ func (m Model) undoGlobalFilter() (tea.Model, tea.Cmd) { return m, nil } - m.stopTrace() + m.tracer.stop() m.dashboard.PrepareForTraceRestart() m.attaching = true m.lastErr = nil return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) } -func globalFilterActionLabel(prev, next globalfilter.Filter, action string) string { - if strings.TrimSpace(action) != "" { - return action - } - parts := make([]string, 0, 10) - if prev.ErrorsOnly != next.ErrorsOnly { - if next.ErrorsOnly { - parts = append(parts, "errors") - } else { - parts = append(parts, "clear errors") - } - } - parts = appendStringFilterChange(parts, "syscall", prev.Syscall, next.Syscall) - parts = appendStringFilterChange(parts, "comm", prev.Comm, next.Comm) - parts = appendStringFilterChange(parts, "file", prev.File, next.File) - parts = appendNumericFilterChange(parts, "pid", prev.PID, next.PID, false) - parts = appendNumericFilterChange(parts, "tid", prev.TID, next.TID, false) - parts = appendNumericFilterChange(parts, "fd", prev.FD, next.FD, false) - parts = appendNumericFilterChange(parts, "latency", prev.LatencyNs, next.LatencyNs, true) - parts = appendNumericFilterChange(parts, "gap", prev.GapNs, next.GapNs, true) - parts = appendNumericFilterChange(parts, "bytes", prev.Bytes, next.Bytes, false) - parts = appendNumericFilterChange(parts, "ret", prev.RetVal, next.RetVal, false) - if len(parts) == 0 { - return next.Summary() - } - return strings.Join(parts, " ") -} - -func appendStringFilterChange(parts []string, name string, prev, next *globalfilter.StringFilter) []string { - if sameStringFilter(prev, next) { - return parts - } - if next == nil || strings.TrimSpace(next.Pattern) == "" { - return append(parts, "clear "+name) - } - return append(parts, fmt.Sprintf("%s~%s", name, strings.TrimSpace(next.Pattern))) -} - -func appendNumericFilterChange(parts []string, name string, prev, next *globalfilter.NumericFilter, duration bool) []string { - if sameNumericFilter(prev, next) { - return parts - } - if next == nil { - return append(parts, "clear "+name) - } - value := strconv.FormatInt(next.Value, 10) - if duration { - value = time.Duration(next.Value).String() - } - return append(parts, fmt.Sprintf("%s%s%s", name, globalfilter.CompareOpSymbol(next.Op), value)) -} - -func sameStringFilter(a, b *globalfilter.StringFilter) bool { - if a == nil || strings.TrimSpace(a.Pattern) == "" { - return b == nil || strings.TrimSpace(b.Pattern) == "" - } - if b == nil { - return false - } - return strings.TrimSpace(a.Pattern) == strings.TrimSpace(b.Pattern) -} - -func sameNumericFilter(a, b *globalfilter.NumericFilter) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return a.Op == b.Op && a.Value == b.Value +// startRecording opens the parquet recorder at path and syncs dashboard status. +// Tests and the Model's record-modal handler call this method. +func (m *Model) startRecording(path string) error { + return recorderStart(m.runtime.Recorder(), path, m.syncDashboardFilterState) } -func (m *Model) stopTrace() { - if m.traceStop != nil { - m.traceStop() - m.traceStop = nil - } +// stopRecording closes an active parquet recorder and syncs dashboard status. +// Tests and the quit/reselect paths call this method. +func (m *Model) stopRecording() error { + return recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState) } func (m *Model) applyTheme(isDark bool) { @@ -1217,31 +1142,42 @@ func (m Model) View() tea.View { switch m.screen { case ScreenPIDPicker: - base := m.pidPicker.View().Content - if m.exporter.Visible() { - return altScreenView(placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base), title) - } - return altScreenView(placeToViewport(width, height, base), title) + return m.viewPickerScreen(width, height, title) case ScreenDashboard: - base := m.dashboard.View().Content - if m.filterModal.Visible() { - return altScreenView(placeToViewport(width, height, m.filterModal.View(width, height)), title) - } - if m.recordModal.Visible() { - return altScreenView(placeToViewport(width, height, m.recordModal.View(width, height)), title) - } - if m.probeModal.Visible() { - return altScreenView(placeToViewport(width, height, m.probeModal.View(width, height)), title) - } - if m.exporter.Visible() { - return altScreenView(placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base), title) - } - return altScreenView(placeToViewport(width, height, base), title) + return m.viewDashboardScreen(width, height, title) default: return altScreenView("", title) } } +// viewPickerScreen renders the PID picker screen with optional export overlay. +func (m Model) viewPickerScreen(width, height int, title string) tea.View { + base := m.pidPicker.View().Content + if m.exporter.Visible() { + return altScreenView(placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base), title) + } + return altScreenView(placeToViewport(width, height, base), title) +} + +// viewDashboardScreen renders the dashboard screen with the appropriate modal +// overlay (filter, record, probes, export) if one is active. +func (m Model) viewDashboardScreen(width, height int, title string) tea.View { + base := m.dashboard.View().Content + if m.filterModal.Visible() { + return altScreenView(placeToViewport(width, height, m.filterModal.View(width, height)), title) + } + if m.recordModal.Visible() { + return altScreenView(placeToViewport(width, height, m.recordModal.View(width, height)), title) + } + if m.probeModal.Visible() { + return altScreenView(placeToViewport(width, height, m.probeModal.View(width, height)), title) + } + if m.exporter.Visible() { + return altScreenView(placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base), title) + } + return altScreenView(placeToViewport(width, height, base), title) +} + func isHelpOverlayOpenKey(msg tea.KeyPressMsg) bool { return msg.String() == "H" } @@ -1261,7 +1197,7 @@ func isHelpOverlayQuitKey(msg tea.KeyPressMsg) bool { func runExportCmd(exportEnabled bool, option tuiexport.Option, dashboard dashboardui.Model) tea.Cmd { return func() tea.Msg { if !exportEnabled { - return tuiexport.FailedMsg{Err: errors.New("tui export is disabled by -tuiExport=false")} + return tuiexport.FailedMsg{Err: fmt.Errorf("tui export is disabled by -tuiExport=false")} } switch option { case tuiexport.OptionCSV: @@ -1271,78 +1207,11 @@ func runExportCmd(exportEnabled bool, option tuiexport.Option, dashboard dashboa } return tuiexport.CompletedMsg{Path: path} default: - return tuiexport.FailedMsg{Err: errors.New("unknown export option")} + return tuiexport.FailedMsg{Err: fmt.Errorf("unknown export option")} } } } -func (m *Model) startRecording(path string) error { - recorder := m.runtime.Recorder() - if recorder == nil { - return errors.New("recording runtime is unavailable") - } - if err := recorder.Start(path, parquet.StartOptions{Metadata: tuiParquetMetadata()}); err != nil { - m.syncDashboardFilterState() - return err - } - m.syncDashboardFilterState() - return nil -} - -func (m *Model) stopRecording() error { - recorder := m.runtime.Recorder() - if recorder == nil { - return nil - } - if !m.recordingActive() { - m.syncDashboardFilterState() - return nil - } - err := recorder.Stop() - m.syncDashboardFilterState() - return err -} - -func (m Model) recordingActive() bool { - recorder := m.runtime.Recorder() - if recorder == nil { - return false - } - return recorder.Status().Active -} - -func (m Model) recordingStatus() string { - recorder := m.runtime.Recorder() - 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:] -} - type lateBoundDashboardSource struct { runtime *runtimeBindings } |
