Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 97 additions & 12 deletions internal/ui/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ const (
// clearOnCompactCooldown - minimum time between /clear sends for the same session
// Prevents repeated /clear if context fills up again quickly
clearOnCompactCooldown = 60 * time.Second

// attach-return grace periods keep the main menu responsive right after tea.Exec returns.
attachReturnHotDuration = 1200 * time.Millisecond
attachReturnRefreshDelay = 350 * time.Millisecond
attachReturnPreviewGrace = 1500 * time.Millisecond
)

// UI spacing constants (2-char grid system)
Expand Down Expand Up @@ -450,6 +455,13 @@ type uiState struct {
StatusFilter string `json:"status_filter,omitempty"`
}

type selectedItemIdentity struct {
groupPath string
sessionID string
windowSessionID string
windowIndex int
}

func (h *Home) reloadHotkeysFromConfig() {
h.setHotkeys(resolveHotkeys(session.GetHotkeyOverrides()))
}
Expand Down Expand Up @@ -531,6 +543,8 @@ type statusUpdateMsg struct {
attachedWorkDir string // pane_current_path captured after attach returns
} // Triggers immediate status update without reloading

type attachReturnRefreshMsg struct{}

// storageChangedMsg signals that state.db was modified externally
type storageChangedMsg struct{}

Expand Down Expand Up @@ -1212,6 +1226,63 @@ func (h *Home) moveCursorToGroup(path string) {
}
}

func (h *Home) captureSelectedItemIdentity() selectedItemIdentity {
if h.cursor < 0 || h.cursor >= len(h.flatItems) {
return selectedItemIdentity{windowIndex: -1}
}

item := h.flatItems[h.cursor]
identity := selectedItemIdentity{windowIndex: -1}
switch item.Type {
case session.ItemTypeGroup:
identity.groupPath = item.Path
case session.ItemTypeSession:
if item.Session != nil {
identity.sessionID = item.Session.ID
}
case session.ItemTypeWindow:
identity.windowSessionID = item.WindowSessionID
identity.windowIndex = item.WindowIndex
}
return identity
}

func (h *Home) restoreSelectedItemIdentity(identity selectedItemIdentity) bool {
for i, item := range h.flatItems {
switch {
case identity.windowSessionID != "" && item.Type == session.ItemTypeWindow && item.WindowSessionID == identity.windowSessionID && item.WindowIndex == identity.windowIndex:
h.cursor = i
return true
case identity.sessionID != "" && item.Type == session.ItemTypeSession && item.Session != nil && item.Session.ID == identity.sessionID:
h.cursor = i
return true
case identity.groupPath != "" && item.Type == session.ItemTypeGroup && item.Path == identity.groupPath:
h.cursor = i
return true
}
}

if identity.windowSessionID != "" {
for i, item := range h.flatItems {
if item.Type == session.ItemTypeSession && item.Session != nil && item.Session.ID == identity.windowSessionID {
h.cursor = i
return true
}
}
}

return false
}

func (h *Home) rebuildFlatItemsPreservingSelection(identity selectedItemIdentity) {
h.rebuildFlatItems()
if !h.restoreSelectedItemIdentity(identity) && len(h.flatItems) > 0 {
h.cursor = min(h.cursor, len(h.flatItems)-1)
h.cursor = max(h.cursor, 0)
}
h.syncViewport()
}

// rebuildFlatItems rebuilds the flattened view from group tree
func (h *Home) rebuildFlatItems() {
h.jumpMode = false
Expand Down Expand Up @@ -2274,6 +2345,17 @@ func (h *Home) markNavigationActivity() {
h.navigationHotUntil.Store(now.Add(900 * time.Millisecond).UnixNano())
}

func (h *Home) beginAttachReturnGrace(now time.Time) {
h.lastAttachReturn = now
h.lastNavigationTime = now
h.isNavigating = true
h.navigationHotUntil.Store(now.Add(attachReturnHotDuration).UnixNano())
}

func (h *Home) shouldSuppressPreviewRefresh(now time.Time) bool {
return !h.lastAttachReturn.IsZero() && now.Sub(h.lastAttachReturn) < attachReturnPreviewGrace
}

// getInstanceByID returns the instance with the given ID using O(1) map lookup
// Returns nil if not found. Caller must hold instancesMu if accessing from background goroutine.
func (h *Home) getInstanceByID(id string) *session.Instance {
Expand Down Expand Up @@ -3681,17 +3763,11 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case statusUpdateMsg:
// Clear attach flag - we've returned from the attached session
h.isAttaching.Store(false) // Atomic store for thread safety
h.lastAttachReturn = time.Now()

// Refresh window cache and rebuild flat items to reflect window changes
// (user may have opened/closed tmux windows while attached)
tmux.RefreshSessionCache()
h.rebuildFlatItems()
now := time.Now()
h.beginAttachReturnGrace(now)

// Trigger status update on attach return to reflect current state
// Acknowledgment was already done on attach (if session was waiting),
// so this just refreshes the display with current busy indicator state.
h.triggerStatusUpdate()
selectedBefore := h.captureSelectedItemIdentity()
h.rebuildFlatItemsPreservingSelection(selectedBefore)

// Cursor sync: if user switched sessions via notification bar during attach,
// move cursor to the session they were last viewing
Expand Down Expand Up @@ -3747,7 +3823,16 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Re-enable mouse mode after returning from tea.Exec.
// tmux detach-client sends terminal reset sequences that disable mouse reporting,
// and Bubble Tea doesn't re-enable it automatically after exec returns.
return h, tea.EnableMouseCellMotion
return h, tea.Batch(
tea.EnableMouseCellMotion,
tea.Tick(attachReturnRefreshDelay, func(time.Time) tea.Msg { return attachReturnRefreshMsg{} }),
)

case attachReturnRefreshMsg:
selectedBefore := h.captureSelectedItemIdentity()
tmux.RefreshSessionCache()
h.rebuildFlatItemsPreservingSelection(selectedBefore)
return h, nil

case previewDebounceMsg:
// PERFORMANCE: Debounce period elapsed - check if this fetch is still relevant
Expand Down Expand Up @@ -4119,7 +4204,7 @@ func (h *Home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
const previewCacheTTL = 2 * time.Second
var previewCmd tea.Cmd
selectedInst, selectedKey, selectedWinIdx := h.selectedPreviewTarget()
if selectedInst != nil {
if selectedInst != nil && !h.shouldSuppressPreviewRefresh(time.Now()) {
h.previewCacheMu.Lock()
cachedTime, hasCached := h.previewCacheTime[selectedKey]
cacheExpired := !hasCached || time.Since(cachedTime) > previewCacheTTL
Expand Down
88 changes: 88 additions & 0 deletions internal/ui/home_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2349,3 +2349,91 @@ func TestScopedGroupPaths(t *testing.T) {
}
}
}

func TestStatusUpdateMsg_PreservesSelectedSessionAcrossRebuild(t *testing.T) {
h := newAttachReturnTestHome()
s1 := session.NewInstanceWithGroup("first", "/tmp/first", "work")
s1.ID = "s1"
s2 := session.NewInstanceWithGroup("second", "/tmp/second", "work")
s2.ID = "s2"
setAttachReturnTestInstances(h, []*session.Instance{s1, s2})

h.groupTree = session.NewGroupTree([]*session.Instance{s2, s1})

model, _ := h.Update(statusUpdateMsg{})
home := model.(*Home)

if got := selectedSessionID(home); got != s2.ID {
t.Fatalf("selected session = %q, want %q", got, s2.ID)
}
}

func TestStatusUpdateMsg_FollowsNotificationSwitchSession(t *testing.T) {
h := newAttachReturnTestHome()
s1 := session.NewInstanceWithGroup("first", "/tmp/first", "work")
s1.ID = "s1"
s2 := session.NewInstanceWithGroup("second", "/tmp/second", "work")
s2.ID = "s2"
setAttachReturnTestInstances(h, []*session.Instance{s1, s2})

h.lastNotifSwitchID = s1.ID
h.groupTree = session.NewGroupTree([]*session.Instance{s2, s1})

model, _ := h.Update(statusUpdateMsg{})
home := model.(*Home)

if got := selectedSessionID(home); got != s1.ID {
t.Fatalf("selected session = %q, want switched session %q", got, s1.ID)
}
if home.lastNotifSwitchID != "" {
t.Fatalf("lastNotifSwitchID = %q, want cleared", home.lastNotifSwitchID)
}
}

func TestAttachReturnGraceSuppressesPreviewRefresh(t *testing.T) {
h := NewHome()
now := time.Now()
h.beginAttachReturnGrace(now)

if !h.shouldSuppressPreviewRefresh(now.Add(attachReturnPreviewGrace / 2)) {
t.Fatal("expected preview refresh suppression during attach-return grace period")
}
if h.shouldSuppressPreviewRefresh(now.Add(attachReturnPreviewGrace + 100*time.Millisecond)) {
t.Fatal("expected preview refresh suppression to expire after grace period")
}
if hotUntil := time.Unix(0, h.navigationHotUntil.Load()); !hotUntil.After(now) {
t.Fatal("expected navigation hot window after attach return")
}
}

func newAttachReturnTestHome() *Home {
h := NewHome()
h.width = 100
h.height = 30
h.initialLoading = false
return h
}

func setAttachReturnTestInstances(h *Home, instances []*session.Instance) {
h.instancesMu.Lock()
h.instances = instances
h.instanceByID = make(map[string]*session.Instance, len(instances))
for _, inst := range instances {
h.instanceByID[inst.ID] = inst
}
h.instancesMu.Unlock()
h.groupTree = session.NewGroupTree(instances)
h.rebuildFlatItems()
h.moveCursorToSession(instances[len(instances)-1].ID)
}

func selectedSessionID(h *Home) string {
if h.cursor < 0 || h.cursor >= len(h.flatItems) {
return ""
}
item := h.flatItems[h.cursor]
if item.Type == session.ItemTypeSession && item.Session != nil {
return item.Session.ID
}
return ""
}
Loading