diff --git a/internal/adapters/git/worktree_test.go b/internal/adapters/git/worktree_test.go index a72c979..235c5c1 100644 --- a/internal/adapters/git/worktree_test.go +++ b/internal/adapters/git/worktree_test.go @@ -75,8 +75,12 @@ func TestGetWorktreeForBranch_WorktreeExists(t *testing.T) { result, err := getWorktreeForBranch(repoPath, "feature-branch") + // Resolve symlinks on both sides: macOS /var/folders is a symlink to /private/var/folders + expectedPath, _ := filepath.EvalSymlinks(worktreePath) + actualPath, _ := filepath.EvalSymlinks(result) + assert.NoError(t, err) - assert.Equal(t, worktreePath, result, "should return existing worktree path") + assert.Equal(t, expectedPath, actualPath, "should return existing worktree path") } func TestGetWorktreeForBranch_SkipsMainDirectory(t *testing.T) { diff --git a/internal/ui/session_list.go b/internal/ui/session_list.go index cd495ff..f3cf935 100644 --- a/internal/ui/session_list.go +++ b/internal/ui/session_list.go @@ -26,10 +26,10 @@ import ( const escTimeout = 500 * time.Millisecond // Messages for SessionList (exported for Model integration) -type checkStateMsg struct{} // Triggers periodic state file check; also used by Model for token chart refresh +type checkStateMsg struct{ gen int } // Triggers periodic state file check; also used by Model for token chart refresh type clearSessionListErrorMsg struct{} // Clear transient error after display period -type hideTipMsg struct{} // Time to hide the current tip -type showTipMsg struct{} // Time to show a new random tip +type hideTipMsg struct{ gen int } // Time to hide the current tip +type showTipMsg struct{ gen int } // Time to show a new random tip // SessionItem implements list.Item and list.DefaultItem type SessionItem struct { @@ -238,6 +238,7 @@ type SessionList struct { escPressTime time.Time fetchingGitStats bool // Prevent concurrent fetches gitService *services.GitService // Git operations service + pollGen int // Generation counter for poll timers; guards against stale checkStateMsg height int keys KeyMap list list.Model @@ -247,6 +248,7 @@ type SessionList struct { statusConfig *config.StatusConfig timestampConfig *config.TimestampColorConfig timestampMode TimestampMode + tipGen int // Generation counter for tip timers; guards against stale showTipMsg/hideTipMsg tipsConfig TipsConfig // Tips display configuration tmuxStatusPosition string width int @@ -304,14 +306,19 @@ func NewSessionList(sessionService *services.SessionService, gitService *service } } -// Init starts the session list component, including auto-refresh polling +// Init starts the session list component, including auto-refresh polling. +// Each call increments the generation counters so stale timer messages from +// previous Init() calls are silently discarded (generation counter pattern). func (sl *SessionList) Init() tea.Cmd { - cmds := []tea.Cmd{pollStateCmd()} + sl.pollGen++ + cmds := []tea.Cmd{pollStateCmd(sl.pollGen)} // Schedule hide for the initial tip (already shown at startup) if sl.tipsConfig.Enabled && sl.currentTip != nil { + sl.tipGen++ + gen := sl.tipGen cmds = append(cmds, tea.Tick(time.Duration(sl.tipsConfig.DisplayDurationSeconds)*time.Second, func(time.Time) tea.Msg { - return hideTipMsg{} + return hideTipMsg{gen: gen} })) } @@ -365,20 +372,25 @@ func (sl *SessionList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return sl, nil case checkStateMsg: + // Discard stale messages from previous Init() generations + if msg.gen != sl.pollGen { + return sl, nil + } + // This message is sent by the poll timer every 2 seconds // We schedule exactly ONE new poll at the end to maintain the loop // Skip refresh when user is actively filtering to prevent flickering if sl.list.FilterState() == list.Filtering { // Still schedule next poll to maintain the loop - return sl, pollStateCmd() + return sl, pollStateCmd(sl.pollGen) } // Auto-refresh: Check if state has changed (showArchived=false - TUI never shows archived) newState, err := sl.sessionService.LoadState(context.Background(), false) if err != nil { // Continue polling even on error - return sl, pollStateCmd() + return sl, pollStateCmd(sl.pollGen) } // Preserve GitStats cache from old state @@ -403,31 +415,45 @@ func (sl *SessionList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { gitStatsCmd := sl.requestGitStatsForVisible() // Schedule next poll to maintain the 2-second loop (exactly one poll) - return sl, tea.Batch(cmd, pollStateCmd(), gitStatsCmd) + return sl, tea.Batch(cmd, pollStateCmd(sl.pollGen), gitStatsCmd) case showTipMsg: + // Discard stale messages from previous tip generations + if msg.gen != sl.tipGen { + return sl, nil + } // Don't show tip if there's an error - reschedule for later if sl.err != nil { + sl.tipGen++ + gen := sl.tipGen return sl, tea.Tick(time.Duration(sl.tipsConfig.ShowIntervalSeconds)*time.Second, func(time.Time) tea.Msg { - return showTipMsg{} + return showTipMsg{gen: gen} }) } // Time to show a new random tip allTips := GetTips() if len(allTips) > 0 { sl.currentTip = &allTips[rand.Intn(len(allTips))] + sl.tipGen++ + gen := sl.tipGen return sl, tea.Tick(time.Duration(sl.tipsConfig.DisplayDurationSeconds)*time.Second, func(time.Time) tea.Msg { - return hideTipMsg{} + return hideTipMsg{gen: gen} }) } return sl, nil case hideTipMsg: + // Discard stale messages from previous tip generations + if msg.gen != sl.tipGen { + return sl, nil + } // Hide the current tip and schedule the next one sl.currentTip = nil if sl.tipsConfig.Enabled { + sl.tipGen++ + gen := sl.tipGen return sl, tea.Tick(time.Duration(sl.tipsConfig.ShowIntervalSeconds)*time.Second, func(time.Time) tea.Msg { - return showTipMsg{} + return showTipMsg{gen: gen} }) } return sl, nil @@ -732,10 +758,12 @@ func (sl *SessionList) RefreshFromState() tea.Cmd { return sl.list.SetItems(items) } -// pollStateCmd returns a command that waits 2 seconds then sends checkStateMsg -func pollStateCmd() tea.Cmd { +// pollStateCmd returns a command that waits 2 seconds then sends checkStateMsg. +// The gen parameter is embedded in the message so stale timers from previous +// Init() calls are detected and discarded by the handler. +func pollStateCmd(gen int) tea.Cmd { return tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return checkStateMsg{} + return checkStateMsg{gen: gen} }) } @@ -1209,7 +1237,8 @@ func (sl *SessionList) cycleSessionStatus(sessionName string) tea.Cmd { logging.Logger.Info("Cycled session status", "session", sessionName, "from", currentStr, "to", nextStr) // Refresh list immediately to show new status + gen := sl.pollGen return func() tea.Msg { - return checkStateMsg{} + return checkStateMsg{gen: gen} } } diff --git a/internal/ui/session_list_test.go b/internal/ui/session_list_test.go new file mode 100644 index 0000000..336cf02 --- /dev/null +++ b/internal/ui/session_list_test.go @@ -0,0 +1,120 @@ +package ui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestSessionList_InitIncrementsGeneration verifies that each Init() call +// increments pollGen so that stale checkStateMsg from a previous generation +// are discarded by the handler. +func TestSessionList_InitIncrementsGeneration(t *testing.T) { + sl := &SessionList{ + tipsConfig: TipsConfig{Enabled: false}, + } + + assert.Equal(t, 0, sl.pollGen, "initial pollGen should be 0") + + sl.Init() + assert.Equal(t, 1, sl.pollGen, "after first Init pollGen should be 1") + + sl.Init() + assert.Equal(t, 2, sl.pollGen, "after second Init pollGen should be 2") + + sl.Init() + assert.Equal(t, 3, sl.pollGen, "after third Init pollGen should be 3") +} + +// TestSessionList_StaleCheckStateMsgDiscarded verifies that a checkStateMsg +// carrying an old generation is silently discarded without triggering a refresh +// or scheduling a new poll. +func TestSessionList_StaleCheckStateMsgDiscarded(t *testing.T) { + sl := &SessionList{ + tipsConfig: TipsConfig{Enabled: false}, + } + + // Simulate two Init() cycles (e.g. attach → detach → attach) + sl.Init() + sl.Init() + assert.Equal(t, 2, sl.pollGen) + + // A stale message from generation 1 should be discarded + staleMsg := checkStateMsg{gen: 1} + result, cmd := sl.Update(staleMsg) + + assert.Equal(t, sl, result, "state should be unchanged") + assert.Nil(t, cmd, "no command should be returned for stale message") +} + +// TestSessionList_CurrentGenCheckStateMsgProcessed verifies that a +// checkStateMsg with the current generation is NOT discarded by the guard. +// The handler will still run (and may return an error due to nil services), +// but the key thing is it does not return early with nil cmd. +func TestSessionList_CurrentGenCheckStateMsgProcessed(t *testing.T) { + sl := &SessionList{ + tipsConfig: TipsConfig{Enabled: false}, + } + + sl.Init() + assert.Equal(t, 1, sl.pollGen) + + // A current-generation message should not be silently discarded + currentMsg := checkStateMsg{gen: 1} + + // The handler will attempt LoadState which will panic on nil sessionService — + // we just verify the stale guard does NOT discard it by checking pollGen is + // still valid after the guard check. We use a direct guard simulation here. + assert.Equal(t, currentMsg.gen, sl.pollGen, "current gen message should pass the guard") +} + +// TestSessionList_TipGenIncrementedOnInit verifies that tipGen is also +// incremented when tips are enabled and a tip is active. +func TestSessionList_TipGenIncrementedOnInit(t *testing.T) { + tip := Tip{Format: "test tip"} + sl := &SessionList{ + currentTip: &tip, + tipsConfig: TipsConfig{ + Enabled: true, + DisplayDurationSeconds: 5, + }, + } + + assert.Equal(t, 0, sl.tipGen, "initial tipGen should be 0") + + sl.Init() + assert.Equal(t, 1, sl.pollGen, "pollGen incremented") + assert.Equal(t, 1, sl.tipGen, "tipGen incremented when tip is active") +} + +// TestSessionList_StaleTipMsgsDiscarded verifies that stale showTipMsg and +// hideTipMsg from a previous generation are discarded. +func TestSessionList_StaleTipMsgsDiscarded(t *testing.T) { + tip := Tip{Format: "test tip"} + sl := &SessionList{ + currentTip: &tip, + tipsConfig: TipsConfig{ + Enabled: true, + DisplayDurationSeconds: 5, + ShowIntervalSeconds: 30, + }, + } + + // Two Init cycles + sl.Init() + sl.Init() + assert.Equal(t, 2, sl.tipGen) + + // Stale hideTipMsg from generation 1 + staleHide := hideTipMsg{gen: 1} + result, cmd := sl.Update(staleHide) + assert.Equal(t, sl, result) + assert.Nil(t, cmd, "stale hideTipMsg should be discarded") + assert.NotNil(t, sl.currentTip, "tip should still be shown (stale message was discarded)") + + // Stale showTipMsg from generation 1 + staleShow := showTipMsg{gen: 1} + result, cmd = sl.Update(staleShow) + assert.Equal(t, sl, result) + assert.Nil(t, cmd, "stale showTipMsg should be discarded") +}