summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/tui/dashboard/model.go37
-rw-r--r--internal/tui/dashboard/model_test.go137
-rw-r--r--internal/tui/tui.go19
-rw-r--r--internal/tui/tui_test.go52
4 files changed, 235 insertions, 10 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 0437f05..478e735 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -916,9 +916,11 @@ func (m *Model) resetBaselineCmd() tea.Cmd {
// autoResetTickCmd returns a command that fires an autoResetTickMsg after
// the current auto-reset interval. Returns nil when the timer is disabled
-// (interval <= 0), so callers can compose it without extra branching.
+// (interval <= 0) or while the dashboard is blurred, so callers can
+// compose it without extra branching. SetFocused re-arms the tick when
+// focus returns.
func (m Model) autoResetTickCmd() tea.Cmd {
- if m.autoResetEvery <= 0 {
+ if m.autoResetEvery <= 0 || !m.focused {
return nil
}
gen := m.autoResetGen
@@ -930,9 +932,11 @@ func (m Model) autoResetTickCmd() tea.Cmd {
// handleAutoResetTick fires the same reset path as the `r` key (live trie
// + stats engine) and re-arms the timer for the next tick. Stale ticks
// from a previous cadence are dropped via the generation counter so that
-// changing the interval does not double-fire.
+// changing the interval does not double-fire. While the dashboard is
+// blurred the tick is also dropped without re-arming; SetFocused will
+// arm a fresh tick on focus regain.
func (m Model) handleAutoResetTick(msg autoResetTickMsg) (tea.Model, tea.Cmd) {
- if msg.generation != m.autoResetGen || m.autoResetEvery <= 0 {
+ if msg.generation != m.autoResetGen || m.autoResetEvery <= 0 || !m.focused {
return m, nil
}
resetCmd := m.resetBaselineCmd()
@@ -1048,9 +1052,24 @@ func (m *Model) SetDarkMode(isDark bool) {
m.processesChart.SetDarkMode(isDark)
}
-// SetFocused controls whether periodic refresh ticks are processed.
-func (m *Model) SetFocused(focused bool) {
+// SetFocused controls whether periodic refresh ticks are processed and
+// returns a tea.Cmd that arms a fresh auto-reset tick when focus returns
+// (or nil otherwise). The auto-reset generation counter is bumped on
+// every focus change so any in-flight tick scheduled before a blur is
+// dropped when it eventually arrives — the tick payload's generation
+// will no longer match. Without bumping, a tick that was already in
+// flight when blur occurred could fire moments after the user re-focuses
+// and surprise them with a reset.
+func (m *Model) SetFocused(focused bool) tea.Cmd {
+ if m.focused == focused {
+ return nil
+ }
m.focused = focused
+ m.autoResetGen++
+ if !focused {
+ return nil
+ }
+ return m.autoResetTickCmd()
}
// SnapshotCmd returns a command that fetches and emits a fresh dashboard snapshot.
@@ -1106,10 +1125,16 @@ func (m Model) filterSummary() string {
// autoResetStatus is the human-readable label for the current
// auto-reset cadence shown in the dashboard chrome. "off" when the
// timer is disabled, otherwise the configured interval (e.g. "30s").
+// When the timer is enabled but the TUI has lost focus, a "(paused)"
+// suffix is appended so the user knows the timer will not fire until
+// focus returns. Disabled timers stay "off" regardless of focus.
func (m Model) autoResetStatus() string {
if m.autoResetEvery <= 0 {
return "auto-reset: off"
}
+ if !m.focused {
+ return "auto-reset: " + m.autoResetEvery.String() + " (paused)"
+ }
return "auto-reset: " + m.autoResetEvery.String()
}
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 8c73230..2fb59f5 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"testing"
+ "time"
coreflamegraph "ior/internal/flamegraph"
"ior/internal/statsengine"
@@ -1587,3 +1588,139 @@ func TestTranslateFlamegraphMsgLeavesNonMouseUnchanged(t *testing.T) {
t.Fatalf("expected non-mouse message to remain unchanged, got %T", translated)
}
}
+
+// TestAutoResetTickIgnoredWhileBlurred drives the same tick the
+// tea.Tick scheduler would deliver after the cadence elapses, but
+// from a blurred state. The expected behavior is: no reset fires
+// (no Reset() call on the engine), and no new tick is re-armed —
+// SetFocused will arm a fresh one when focus returns.
+func TestAutoResetTickIgnoredWhileBlurred(t *testing.T) {
+ engine := &fakeResettableSnapshotSource{}
+ m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap())
+ if cmd := m.SetAutoResetInterval(50 * time.Millisecond); cmd == nil {
+ t.Fatalf("SetAutoResetInterval should return a tick command for a positive interval")
+ }
+ gen := m.autoResetGen
+
+ // Simulate blur. The returned cmd must be nil (no rearm).
+ if cmd := m.SetFocused(false); cmd != nil {
+ t.Fatalf("SetFocused(false) should not return a tick command, got %v", cmd)
+ }
+ if m.autoResetGen == gen {
+ t.Fatalf("SetFocused(false) should bump autoResetGen so in-flight ticks are dropped")
+ }
+
+ // Deliver the in-flight tick that was scheduled before the blur. It
+ // carries the pre-blur generation, so it must be silently dropped.
+ staleTick := autoResetTickMsg{generation: gen}
+ next, cmd := m.Update(staleTick)
+ m = next.(Model)
+ if cmd != nil {
+ t.Fatalf("blurred dashboard should not re-arm the timer on a stale tick, got %v", cmd)
+ }
+ if engine.resetCount != 0 {
+ t.Fatalf("blurred dashboard should not reset the engine, got resetCount=%d", engine.resetCount)
+ }
+
+ // Even a tick crafted with the current generation must not fire
+ // while blurred — handleAutoResetTick gates on m.focused.
+ currentTick := autoResetTickMsg{generation: m.autoResetGen}
+ next, cmd = m.Update(currentTick)
+ m = next.(Model)
+ if cmd != nil {
+ t.Fatalf("blurred dashboard must not re-arm even on a current-gen tick, got %v", cmd)
+ }
+ if engine.resetCount != 0 {
+ t.Fatalf("blurred dashboard must not reset on a current-gen tick, got resetCount=%d", engine.resetCount)
+ }
+}
+
+// TestAutoResetTickResumesOnFocusRegain checks that focus regain arms
+// a fresh tick at the configured cadence, and that the next tick fires
+// the reset path. We deliver the tick by direct injection (the same
+// payload tea.Tick would deliver) rather than waiting on real time.
+func TestAutoResetTickResumesOnFocusRegain(t *testing.T) {
+ engine := &fakeResettableSnapshotSource{}
+ m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap())
+ m.SetAutoResetInterval(50 * time.Millisecond)
+ m.SetFocused(false)
+
+ // Focus regain must return a non-nil tick cmd because the timer is
+ // still configured, and bump the generation again.
+ preGen := m.autoResetGen
+ cmd := m.SetFocused(true)
+ if cmd == nil {
+ t.Fatalf("SetFocused(true) should return a fresh tick cmd when timer is enabled")
+ }
+ if m.autoResetGen == preGen {
+ t.Fatalf("SetFocused(true) should bump autoResetGen to invalidate any leftover ticks")
+ }
+
+ // Deliver a tick at the post-regain generation: the reset must fire
+ // and a fresh tick must be re-armed for the next interval.
+ tick := autoResetTickMsg{generation: m.autoResetGen}
+ next, cmd := m.Update(tick)
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("focused dashboard should re-arm timer and emit reset cmd, got nil")
+ }
+ if engine.resetCount != 1 {
+ t.Fatalf("focused tick should reset engine exactly once, got resetCount=%d", engine.resetCount)
+ }
+}
+
+// TestSetFocusedNoOpWhenStateUnchanged guards against accidental
+// generation churn when focus messages arrive without an actual state
+// change (e.g. a duplicate FocusMsg). Bumping the generation in that
+// case would silently invalidate a healthy in-flight tick.
+func TestSetFocusedNoOpWhenStateUnchanged(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.SetAutoResetInterval(50 * time.Millisecond)
+ gen := m.autoResetGen
+
+ if cmd := m.SetFocused(true); cmd != nil {
+ t.Fatalf("SetFocused(true) on already-focused model should be a no-op, got %v", cmd)
+ }
+ if m.autoResetGen != gen {
+ t.Fatalf("autoResetGen should not change on no-op focus call, was %d now %d", gen, m.autoResetGen)
+ }
+}
+
+// TestSetFocusedReturnsNilWhenTimerDisabled is the corner case where
+// focus returns but the user has the auto-reset timer turned off. No
+// tick should be armed (it would never fire anyway).
+func TestSetFocusedReturnsNilWhenTimerDisabled(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ // Timer disabled by default.
+ m.SetFocused(false)
+ if cmd := m.SetFocused(true); cmd != nil {
+ t.Fatalf("SetFocused(true) with disabled timer should return nil, got %v", cmd)
+ }
+}
+
+// TestAutoResetStatusAddsPausedSuffixWhenBlurred locks in the chrome
+// label contract: enabled+focused -> "auto-reset: 30s",
+// enabled+blurred -> "auto-reset: 30s (paused)", disabled stays "off"
+// regardless of focus.
+func TestAutoResetStatusAddsPausedSuffixWhenBlurred(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.SetAutoResetInterval(30 * time.Second)
+ if got, want := m.autoResetStatus(), "auto-reset: 30s"; got != want {
+ t.Fatalf("focused enabled status = %q, want %q", got, want)
+ }
+
+ m.SetFocused(false)
+ if got, want := m.autoResetStatus(), "auto-reset: 30s (paused)"; got != want {
+ t.Fatalf("blurred enabled status = %q, want %q", got, want)
+ }
+
+ m.SetAutoResetInterval(0)
+ if got, want := m.autoResetStatus(), "auto-reset: off"; got != want {
+ t.Fatalf("blurred disabled status = %q, want %q", got, want)
+ }
+
+ m.SetFocused(true)
+ if got, want := m.autoResetStatus(), "auto-reset: off"; got != want {
+ t.Fatalf("focused disabled status = %q, want %q", got, want)
+ }
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index e1d5581..63321ac 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -481,13 +481,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.FocusMsg:
m.focused = true
- m.dashboard.SetFocused(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, nil
+ return m, focusCmd
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
case tea.KeyPressMsg:
@@ -666,12 +675,14 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
// 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.
+// 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,
- 15 * time.Second,
+ 10 * time.Second,
30 * time.Second,
60 * time.Second,
+ 2 * time.Minute,
5 * time.Minute,
}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 27bf041..b8d6108 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -2130,3 +2130,55 @@ func TestGlobalHelpOverlayFitsStandardTerminal(t *testing.T) {
t.Fatalf("expected overlay to include bubble dashboard hotkeys")
}
}
+
+// TestNextAutoResetIntervalCyclesThroughPresets walks the full preset
+// sequence (off -> 10s -> 30s -> 60s -> 2m -> 5m -> off) to lock in the
+// user-facing behavior of the `I` hotkey. The cycle wraps so the user
+// can press `I` repeatedly without overshooting into surprise states.
+func TestNextAutoResetIntervalCyclesThroughPresets(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ in time.Duration
+ want time.Duration
+ }{
+ {"off->10s", 0, 10 * time.Second},
+ {"10s->30s", 10 * time.Second, 30 * time.Second},
+ {"30s->60s", 30 * time.Second, 60 * time.Second},
+ {"60s->2m", 60 * time.Second, 2 * time.Minute},
+ {"2m->5m", 2 * time.Minute, 5 * time.Minute},
+ {"5m->off", 5 * time.Minute, 0},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := nextAutoResetInterval(tc.in)
+ if got != tc.want {
+ t.Fatalf("nextAutoResetInterval(%s) = %s, want %s", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestNextAutoResetIntervalAdvancesCustomValueToNextPreset covers the
+// custom-value passthrough: when the user passes a -resetTimer value
+// that is not in the preset cycle (e.g. 47s), pressing `I` should jump
+// to the smallest preset strictly greater than the current cadence
+// rather than restarting from the top of the cycle.
+func TestNextAutoResetIntervalAdvancesCustomValueToNextPreset(t *testing.T) {
+ t.Parallel()
+ if got := nextAutoResetInterval(47 * time.Second); got != 60*time.Second {
+ t.Fatalf("nextAutoResetInterval(47s) = %s, want 60s", got)
+ }
+ if got := nextAutoResetInterval(90 * time.Second); got != 2*time.Minute {
+ t.Fatalf("nextAutoResetInterval(90s) = %s, want 2m", got)
+ }
+ if got := nextAutoResetInterval(3 * time.Minute); got != 5*time.Minute {
+ t.Fatalf("nextAutoResetInterval(3m) = %s, want 5m", got)
+ }
+ // A custom value larger than every preset wraps to off.
+ if got := nextAutoResetInterval(10 * time.Minute); got != 0 {
+ t.Fatalf("nextAutoResetInterval(10m) = %s, want 0 (off)", got)
+ }
+}