Merge pull request #149 from bnema/refactor/session-view
refactor(daemon): sessionView snapshot + sessionCapabilities seam (phase 0)
11 files · +367 -91
M internal/usecase/daemon/control.go +10 -8
| @@ -575,17 +575,19 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) { | |||
| 575 | 575 | sessions = append(sessions, sess) | |
| 576 | 576 | } | |
| 577 | 577 | e.d.mu.Unlock() | |
| 578 | - | var active *session | |
| 578 | + | snaps := make([]sessionView, 0, len(sessions)) | |
| 579 | 579 | for _, sess := range sessions { | |
| 580 | - | if active == nil || sess.mruAt.Load() > active.mruAt.Load() { | |
| 581 | - | active = sess | |
| 580 | + | snaps = append(snaps, sess.snapshotView(viewOptions{})) | |
| 581 | + | } | |
| 582 | + | activeIdx := -1 | |
| 583 | + | for i, snap := range snaps { | |
| 584 | + | if activeIdx == -1 || snap.mruAt > snaps[activeIdx].mruAt { | |
| 585 | + | activeIdx = i | |
| 582 | 586 | } | |
| 583 | 587 | } | |
| 584 | - | rows := make([]row, 0, len(sessions)) | |
| 585 | - | for _, sess := range sessions { | |
| 586 | - | sess.mu.Lock() | |
| 587 | - | rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active}) | |
| 588 | - | sess.mu.Unlock() | |
| 588 | + | rows := make([]row, 0, len(snaps)) | |
| 589 | + | for i, snap := range snaps { | |
| 590 | + | rows = append(rows, row{Name: snap.name, Ephemeral: snap.ephemeral, Tabs: snap.tabCount, Attached: snap.attached, Active: i == activeIdx}) | |
| 589 | 591 | } | |
| 590 | 592 | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | |
| 591 | 593 | if asJSON { | |
| @@ -575,17 +575,19 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) { | |||||
| 575 | sessions = append(sessions, sess) | 575 | sessions = append(sessions, sess) | ||
| 576 | } | 576 | } | ||
| 577 | e.d.mu.Unlock() | 577 | e.d.mu.Unlock() | ||
| 578 | - | var active *session | 578 | + | snaps := make([]sessionView, 0, len(sessions)) |
| 579 | for _, sess := range sessions { | 579 | for _, sess := range sessions { | ||
| 580 | - | if active == nil || sess.mruAt.Load() > active.mruAt.Load() { | 580 | + | snaps = append(snaps, sess.snapshotView(viewOptions{})) |
| 581 | - | active = sess | 581 | + | } |
| 582 | + | activeIdx := -1 | |||
| 583 | + | for i, snap := range snaps { | |||
| 584 | + | if activeIdx == -1 || snap.mruAt > snaps[activeIdx].mruAt { | |||
| 585 | + | activeIdx = i | |||
| 582 | } | 586 | } | ||
| 583 | } | 587 | } | ||
| 584 | - | rows := make([]row, 0, len(sessions)) | 588 | + | rows := make([]row, 0, len(snaps)) |
| 585 | - | for _, sess := range sessions { | 589 | + | for i, snap := range snaps { |
| 586 | - | sess.mu.Lock() | 590 | + | rows = append(rows, row{Name: snap.name, Ephemeral: snap.ephemeral, Tabs: snap.tabCount, Attached: snap.attached, Active: i == activeIdx}) |
| 587 | - | rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active}) | |||
| 588 | - | sess.mu.Unlock() | |||
| 589 | } | 591 | } | ||
| 590 | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | 592 | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | ||
| 591 | if asJSON { | 593 | if asJSON { | ||
M internal/usecase/daemon/move_picker.go +3 -0
| @@ -57,6 +57,9 @@ type moveSourceLocator struct { | |||
| 57 | 57 | ||
| 58 | 58 | // enterPickerForIntent returns errNoMoveDestination only for move intents. | |
| 59 | 59 | func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error { | |
| 60 | + | if intent != pickerNavigate && !sess.capabilities().yieldsMoves() { | |
| 61 | + | return errSessionCannotYieldMoves | |
| 62 | + | } | |
| 60 | 63 | model := d.newPickerModel(sess, intent, source, picker.SourceFilter{}) | |
| 61 | 64 | if intent != pickerNavigate { | |
| 62 | 65 | if _, ok := model.Selected(); !ok { | |
| @@ -57,6 +57,9 @@ type moveSourceLocator struct { | |||||
| 57 | 57 | ||||
| 58 | // enterPickerForIntent returns errNoMoveDestination only for move intents. | 58 | // enterPickerForIntent returns errNoMoveDestination only for move intents. | ||
| 59 | func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error { | 59 | func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error { | ||
| 60 | + | if intent != pickerNavigate && !sess.capabilities().yieldsMoves() { | |||
| 61 | + | return errSessionCannotYieldMoves | |||
| 62 | + | } | |||
| 60 | model := d.newPickerModel(sess, intent, source, picker.SourceFilter{}) | 63 | model := d.newPickerModel(sess, intent, source, picker.SourceFilter{}) | ||
| 61 | if intent != pickerNavigate { | 64 | if intent != pickerNavigate { | ||
| 62 | if _, ok := model.Selected(); !ok { | 65 | if _, ok := model.Selected(); !ok { | ||
M internal/usecase/daemon/palette.go +3 -6
| @@ -74,12 +74,9 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||
| 74 | 74 | if candidate == current { | |
| 75 | 75 | continue | |
| 76 | 76 | } | |
| 77 | - | candidate.mu.Lock() | |
| 78 | - | name, createdAt, ephemeral := candidate.name, candidate.createdAt, candidate.ephemeral | |
| 79 | - | id := candidate.id | |
| 80 | - | candidate.mu.Unlock() | |
| 81 | - | if name != "" && !ephemeral { | |
| 82 | - | active = append(active, palette.NewActiveSessionResult(name, time.Unix(0, createdAt), id)) | |
| 77 | + | snap := candidate.snapshotView(viewOptions{}) | |
| 78 | + | if snap.name != "" && !snap.ephemeral { | |
| 79 | + | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id)) | |
| 83 | 80 | } | |
| 84 | 81 | } | |
| 85 | 82 | sort.Slice(active, func(i, j int) bool { | |
| @@ -74,12 +74,9 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||||
| 74 | if candidate == current { | 74 | if candidate == current { | ||
| 75 | continue | 75 | continue | ||
| 76 | } | 76 | } | ||
| 77 | - | candidate.mu.Lock() | 77 | + | snap := candidate.snapshotView(viewOptions{}) |
| 78 | - | name, createdAt, ephemeral := candidate.name, candidate.createdAt, candidate.ephemeral | 78 | + | if snap.name != "" && !snap.ephemeral { |
| 79 | - | id := candidate.id | 79 | + | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id)) |
| 80 | - | candidate.mu.Unlock() | |||
| 81 | - | if name != "" && !ephemeral { | |||
| 82 | - | active = append(active, palette.NewActiveSessionResult(name, time.Unix(0, createdAt), id)) | |||
| 83 | } | 80 | } | ||
| 84 | } | 81 | } | ||
| 85 | sort.Slice(active, func(i, j int) bool { | 82 | sort.Slice(active, func(i, j int) bool { | ||
M internal/usecase/daemon/picker.go +28 -62
| @@ -87,28 +87,30 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | 89 | d.mu.Unlock() | |
| 90 | - | // Snapshot mruAt, name, and ephemeral once: comparators must not observe | |
| 91 | - | // concurrent touchMRU updates or a renameSession mutation (which mutates | |
| 92 | - | // name/ephemeral under sess.mu) mid-sort. | |
| 93 | - | type pickerSortSnapshot struct { | |
| 94 | - | mruAt uint64 | |
| 95 | - | name string | |
| 96 | - | ephemeral bool | |
| 97 | - | } | |
| 98 | - | snap := make(map[*session]pickerSortSnapshot, len(sessions)) | |
| 90 | + | ||
| 91 | + | for _, s := range sessions { | |
| 92 | + | d.refreshSessionFocusedTitles(s) | |
| 93 | + | } | |
| 94 | + | opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle} | |
| 95 | + | ||
| 96 | + | // One snapshot per session: sorting and view building read the same | |
| 97 | + | // capture, so comparators cannot observe a concurrent touchMRU or | |
| 98 | + | // renameSession mid-sort (the invariant previously enforced by the | |
| 99 | + | // pickerSortSnapshot map). | |
| 100 | + | snaps := make([]sessionView, 0, len(sessions)) | |
| 101 | + | var current picker.SourceFilter | |
| 99 | 102 | for _, s := range sessions { | |
| 100 | - | mruAt := s.mruAt.Load() | |
| 101 | - | s.mu.Lock() | |
| 102 | - | name := s.name | |
| 103 | - | ephemeral := s.ephemeral | |
| 104 | - | s.mu.Unlock() | |
| 105 | - | snap[s] = pickerSortSnapshot{mruAt: mruAt, name: name, ephemeral: ephemeral} | |
| 106 | - | } | |
| 107 | - | sort.Slice(sessions, func(i, j int) bool { | |
| 108 | - | if snap[sessions[i]].mruAt != snap[sessions[j]].mruAt { | |
| 109 | - | return snap[sessions[i]].mruAt > snap[sessions[j]].mruAt | |
| 110 | - | } | |
| 111 | - | return snap[sessions[i]].name < snap[sessions[j]].name | |
| 103 | + | snap := s.snapshotView(opts) | |
| 104 | + | if s == cur && snap.active >= 0 && snap.active < len(snap.tabs) { | |
| 105 | + | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id} | |
| 106 | + | } | |
| 107 | + | snaps = append(snaps, snap) | |
| 108 | + | } | |
| 109 | + | sort.Slice(snaps, func(i, j int) bool { | |
| 110 | + | if snaps[i].mruAt != snaps[j].mruAt { | |
| 111 | + | return snaps[i].mruAt > snaps[j].mruAt | |
| 112 | + | } | |
| 113 | + | return snaps[i].name < snaps[j].name | |
| 112 | 114 | }) | |
| 113 | 115 | sort.Slice(stopped, func(i, j int) bool { | |
| 114 | 116 | if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq { | |
| @@ -119,50 +121,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||
| 119 | 121 | if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped { | |
| 120 | 122 | // Stable partition: named sessions first, ephemeral after, each keeping | |
| 121 | 123 | // its MRU order from the sort above. | |
| 122 | - | sort.SliceStable(sessions, func(i, j int) bool { | |
| 123 | - | return !snap[sessions[i]].ephemeral && snap[sessions[j]].ephemeral | |
| 124 | + | sort.SliceStable(snaps, func(i, j int) bool { | |
| 125 | + | return !snaps[i].ephemeral && snaps[j].ephemeral | |
| 124 | 126 | }) | |
| 125 | 127 | } | |
| 126 | 128 | ||
| 127 | - | for _, s := range sessions { | |
| 128 | - | d.refreshSessionFocusedTitles(s) | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | |
| 132 | - | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | |
| 133 | - | var current picker.SourceFilter | |
| 134 | - | for _, s := range sessions { | |
| 135 | - | s.mu.Lock() | |
| 136 | - | view := picker.SessionView{ | |
| 137 | - | ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active, | |
| 138 | - | Tabs: make([]picker.TabEntry, 0, len(s.tabs)), | |
| 139 | - | } | |
| 140 | - | if !s.ephemeral { | |
| 141 | - | createdAt := s.createdAt | |
| 142 | - | view.ExpectedCreatedAt = &createdAt | |
| 143 | - | } | |
| 144 | - | sessionAttention := false | |
| 145 | - | for i, tb := range s.tabs { | |
| 146 | - | name := tabDisplayName(tb, i) | |
| 147 | - | tabID := domain.TabStableID(tb.stableID) | |
| 148 | - | view.Tabs = append(view.Tabs, picker.TabEntry{ | |
| 149 | - | TabID: tabID, | |
| 150 | - | Name: name, | |
| 151 | - | Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)), | |
| 152 | - | Attention: tb.attention, | |
| 153 | - | }) | |
| 154 | - | if tb.attention { | |
| 155 | - | sessionAttention = true | |
| 156 | - | } | |
| 157 | - | if s == cur && i == s.active { | |
| 158 | - | current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID} | |
| 159 | - | } | |
| 160 | - | } | |
| 161 | - | if sessionAttention { | |
| 162 | - | view.Name = attentionSuffix(view.Name) | |
| 163 | - | } | |
| 164 | - | s.mu.Unlock() | |
| 165 | - | views = append(views, view) | |
| 129 | + | views := make([]picker.SessionView, 0, len(snaps)+len(stopped)) | |
| 130 | + | for _, snap := range snaps { | |
| 131 | + | views = append(views, snap.pickerView()) | |
| 166 | 132 | } | |
| 167 | 133 | for _, s := range stopped { | |
| 168 | 134 | createdAt := s.createdAt | |
| @@ -87,28 +87,30 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||||
| 87 | } | 87 | } | ||
| 88 | } | 88 | } | ||
| 89 | d.mu.Unlock() | 89 | d.mu.Unlock() | ||
| 90 | - | // Snapshot mruAt, name, and ephemeral once: comparators must not observe | 90 | + | |
| 91 | - | // concurrent touchMRU updates or a renameSession mutation (which mutates | 91 | + | for _, s := range sessions { |
| 92 | - | // name/ephemeral under sess.mu) mid-sort. | 92 | + | d.refreshSessionFocusedTitles(s) |
| 93 | - | type pickerSortSnapshot struct { | 93 | + | } |
| 94 | - | mruAt uint64 | 94 | + | opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle} |
| 95 | - | name string | 95 | + | |
| 96 | - | ephemeral bool | 96 | + | // One snapshot per session: sorting and view building read the same |
| 97 | - | } | 97 | + | // capture, so comparators cannot observe a concurrent touchMRU or |
| 98 | - | snap := make(map[*session]pickerSortSnapshot, len(sessions)) | 98 | + | // renameSession mid-sort (the invariant previously enforced by the |
| 99 | + | // pickerSortSnapshot map). | |||
| 100 | + | snaps := make([]sessionView, 0, len(sessions)) | |||
| 101 | + | var current picker.SourceFilter | |||
| 99 | for _, s := range sessions { | 102 | for _, s := range sessions { | ||
| 100 | - | mruAt := s.mruAt.Load() | 103 | + | snap := s.snapshotView(opts) |
| 101 | - | s.mu.Lock() | 104 | + | if s == cur && snap.active >= 0 && snap.active < len(snap.tabs) { |
| 102 | - | name := s.name | 105 | + | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id} |
| 103 | - | ephemeral := s.ephemeral | 106 | + | } |
| 104 | - | s.mu.Unlock() | 107 | + | snaps = append(snaps, snap) |
| 105 | - | snap[s] = pickerSortSnapshot{mruAt: mruAt, name: name, ephemeral: ephemeral} | 108 | + | } |
| 106 | - | } | 109 | + | sort.Slice(snaps, func(i, j int) bool { |
| 107 | - | sort.Slice(sessions, func(i, j int) bool { | 110 | + | if snaps[i].mruAt != snaps[j].mruAt { |
| 108 | - | if snap[sessions[i]].mruAt != snap[sessions[j]].mruAt { | 111 | + | return snaps[i].mruAt > snaps[j].mruAt |
| 109 | - | return snap[sessions[i]].mruAt > snap[sessions[j]].mruAt | 112 | + | } |
| 110 | - | } | 113 | + | return snaps[i].name < snaps[j].name |
| 111 | - | return snap[sessions[i]].name < snap[sessions[j]].name | |||
| 112 | }) | 114 | }) | ||
| 113 | sort.Slice(stopped, func(i, j int) bool { | 115 | sort.Slice(stopped, func(i, j int) bool { | ||
| 114 | if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq { | 116 | if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq { | ||
| @@ -119,50 +121,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||||
| 119 | if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped { | 121 | if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped { | ||
| 120 | // Stable partition: named sessions first, ephemeral after, each keeping | 122 | // Stable partition: named sessions first, ephemeral after, each keeping | ||
| 121 | // its MRU order from the sort above. | 123 | // its MRU order from the sort above. | ||
| 122 | - | sort.SliceStable(sessions, func(i, j int) bool { | 124 | + | sort.SliceStable(snaps, func(i, j int) bool { |
| 123 | - | return !snap[sessions[i]].ephemeral && snap[sessions[j]].ephemeral | 125 | + | return !snaps[i].ephemeral && snaps[j].ephemeral |
| 124 | }) | 126 | }) | ||
| 125 | } | 127 | } | ||
| 126 | 128 | ||||
| 127 | - | for _, s := range sessions { | 129 | + | views := make([]picker.SessionView, 0, len(snaps)+len(stopped)) |
| 128 | - | d.refreshSessionFocusedTitles(s) | 130 | + | for _, snap := range snaps { |
| 129 | - | } | 131 | + | views = append(views, snap.pickerView()) |
| 130 | - | ||||
| 131 | - | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | |||
| 132 | - | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | |||
| 133 | - | var current picker.SourceFilter | |||
| 134 | - | for _, s := range sessions { | |||
| 135 | - | s.mu.Lock() | |||
| 136 | - | view := picker.SessionView{ | |||
| 137 | - | ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active, | |||
| 138 | - | Tabs: make([]picker.TabEntry, 0, len(s.tabs)), | |||
| 139 | - | } | |||
| 140 | - | if !s.ephemeral { | |||
| 141 | - | createdAt := s.createdAt | |||
| 142 | - | view.ExpectedCreatedAt = &createdAt | |||
| 143 | - | } | |||
| 144 | - | sessionAttention := false | |||
| 145 | - | for i, tb := range s.tabs { | |||
| 146 | - | name := tabDisplayName(tb, i) | |||
| 147 | - | tabID := domain.TabStableID(tb.stableID) | |||
| 148 | - | view.Tabs = append(view.Tabs, picker.TabEntry{ | |||
| 149 | - | TabID: tabID, | |||
| 150 | - | Name: name, | |||
| 151 | - | Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)), | |||
| 152 | - | Attention: tb.attention, | |||
| 153 | - | }) | |||
| 154 | - | if tb.attention { | |||
| 155 | - | sessionAttention = true | |||
| 156 | - | } | |||
| 157 | - | if s == cur && i == s.active { | |||
| 158 | - | current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID} | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | if sessionAttention { | |||
| 162 | - | view.Name = attentionSuffix(view.Name) | |||
| 163 | - | } | |||
| 164 | - | s.mu.Unlock() | |||
| 165 | - | views = append(views, view) | |||
| 166 | } | 132 | } | ||
| 167 | for _, s := range stopped { | 133 | for _, s := range stopped { | ||
| 168 | createdAt := s.createdAt | 134 | createdAt := s.createdAt | ||
M internal/usecase/daemon/recent_sessions.go +3 -15
| @@ -27,23 +27,11 @@ func (d *Daemon) recentSessions(current *session) []recentSession { | |||
| 27 | 27 | if sess == current { | |
| 28 | 28 | continue | |
| 29 | 29 | } | |
| 30 | - | sess.mu.Lock() | |
| 31 | - | if sess.ephemeral { | |
| 32 | - | sess.mu.Unlock() | |
| 30 | + | snap := sess.snapshotView(viewOptions{}) | |
| 31 | + | if snap.ephemeral { | |
| 33 | 32 | continue | |
| 34 | 33 | } | |
| 35 | - | entry := recentSession{ | |
| 36 | - | id: sess.id, | |
| 37 | - | name: sess.name, | |
| 38 | - | mruAt: sess.mruAt.Load(), | |
| 39 | - | } | |
| 40 | - | for _, tb := range sess.tabs { | |
| 41 | - | if tb.attention { | |
| 42 | - | entry.attention = true | |
| 43 | - | break | |
| 44 | - | } | |
| 45 | - | } | |
| 46 | - | sess.mu.Unlock() | |
| 34 | + | entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention} | |
| 47 | 35 | recent = append(recent, entry) | |
| 48 | 36 | } | |
| 49 | 37 | d.mu.Unlock() | |
| @@ -27,23 +27,11 @@ func (d *Daemon) recentSessions(current *session) []recentSession { | |||||
| 27 | if sess == current { | 27 | if sess == current { | ||
| 28 | continue | 28 | continue | ||
| 29 | } | 29 | } | ||
| 30 | - | sess.mu.Lock() | 30 | + | snap := sess.snapshotView(viewOptions{}) |
| 31 | - | if sess.ephemeral { | 31 | + | if snap.ephemeral { |
| 32 | - | sess.mu.Unlock() | |||
| 33 | continue | 32 | continue | ||
| 34 | } | 33 | } | ||
| 35 | - | entry := recentSession{ | 34 | + | entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention} |
| 36 | - | id: sess.id, | |||
| 37 | - | name: sess.name, | |||
| 38 | - | mruAt: sess.mruAt.Load(), | |||
| 39 | - | } | |||
| 40 | - | for _, tb := range sess.tabs { | |||
| 41 | - | if tb.attention { | |||
| 42 | - | entry.attention = true | |||
| 43 | - | break | |||
| 44 | - | } | |||
| 45 | - | } | |||
| 46 | - | sess.mu.Unlock() | |||
| 47 | recent = append(recent, entry) | 35 | recent = append(recent, entry) | ||
| 48 | } | 36 | } | ||
| 49 | d.mu.Unlock() | 37 | d.mu.Unlock() | ||
M internal/usecase/daemon/session.go +2 -0
| @@ -32,6 +32,8 @@ type session struct { | |||
| 32 | 32 | id domain.SessionID | |
| 33 | 33 | name string | |
| 34 | 34 | ephemeral bool | |
| 35 | + | // caps is immutable after construction; see sessionCapabilities. | |
| 36 | + | caps sessionCapabilities | |
| 35 | 37 | ||
| 36 | 38 | ctx context.Context | |
| 37 | 39 | cancel context.CancelFunc | |
| @@ -32,6 +32,8 @@ type session struct { | |||||
| 32 | id domain.SessionID | 32 | id domain.SessionID | ||
| 33 | name string | 33 | name string | ||
| 34 | ephemeral bool | 34 | ephemeral bool | ||
| 35 | + | // caps is immutable after construction; see sessionCapabilities. | |||
| 36 | + | caps sessionCapabilities | |||
| 35 | 37 | ||||
| 36 | ctx context.Context | 38 | ctx context.Context | ||
| 37 | cancel context.CancelFunc | 39 | cancel context.CancelFunc | ||
A internal/usecase/daemon/session_capabilities.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "errors" | |
| 4 | + | ||
| 5 | + | // errSessionCannotYieldMoves rejects move-picker entry for sessions whose | |
| 6 | + | // tabs and panes cannot leave the session (no local session sets this today; | |
| 7 | + | // proxy sessions will). | |
| 8 | + | var errSessionCannotYieldMoves = errors.New("session does not support moving tabs or panes") | |
| 9 | + | ||
| 10 | + | // sessionCapabilities declares what cross-session operations a session | |
| 11 | + | // supports. Fields are negated so the zero value describes a fully capable | |
| 12 | + | // local session — literal &session{...} constructions (tests, restore paths) | |
| 13 | + | // keep today's behavior without opting in. | |
| 14 | + | // | |
| 15 | + | // Set once at construction, never mutated: reading needs no lock. | |
| 16 | + | type sessionCapabilities struct { | |
| 17 | + | cannotAcceptMoves bool | |
| 18 | + | cannotYieldMoves bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (c sessionCapabilities) yieldsMoves() bool { return !c.cannotYieldMoves } | |
| 22 | + | ||
| 23 | + | func (s *session) capabilities() sessionCapabilities { return s.caps } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "errors" | |||
| 4 | + | ||||
| 5 | + | // errSessionCannotYieldMoves rejects move-picker entry for sessions whose | |||
| 6 | + | // tabs and panes cannot leave the session (no local session sets this today; | |||
| 7 | + | // proxy sessions will). | |||
| 8 | + | var errSessionCannotYieldMoves = errors.New("session does not support moving tabs or panes") | |||
| 9 | + | ||||
| 10 | + | // sessionCapabilities declares what cross-session operations a session | |||
| 11 | + | // supports. Fields are negated so the zero value describes a fully capable | |||
| 12 | + | // local session — literal &session{...} constructions (tests, restore paths) | |||
| 13 | + | // keep today's behavior without opting in. | |||
| 14 | + | // | |||
| 15 | + | // Set once at construction, never mutated: reading needs no lock. | |||
| 16 | + | type sessionCapabilities struct { | |||
| 17 | + | cannotAcceptMoves bool | |||
| 18 | + | cannotYieldMoves bool | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (c sessionCapabilities) yieldsMoves() bool { return !c.cannotYieldMoves } | |||
| 22 | + | ||||
| 23 | + | func (s *session) capabilities() sessionCapabilities { return s.caps } | |||
A internal/usecase/daemon/session_capabilities_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) { | |
| 11 | + | s := &session{id: domain.SessionID("s1"), name: "alpha"} | |
| 12 | + | caps := s.capabilities() | |
| 13 | + | if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() { | |
| 14 | + | t.Fatalf("zero-value capabilities = %+v, want fully capable", caps) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) { | |
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | caps sessionCapabilities | |
| 22 | + | want bool | |
| 23 | + | }{ | |
| 24 | + | {name: "default local", caps: sessionCapabilities{}, want: false}, | |
| 25 | + | {name: "restricted proxy", caps: sessionCapabilities{cannotAcceptMoves: true}, want: true}, | |
| 26 | + | } | |
| 27 | + | for _, tt := range tests { | |
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |
| 29 | + | s := &session{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps} | |
| 30 | + | if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want { | |
| 31 | + | t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) { | |
| 38 | + | d, sess, ac, _ := newManualSessionWithPTYs(t) | |
| 39 | + | sess.caps = sessionCapabilities{cannotYieldMoves: true} | |
| 40 | + | ||
| 41 | + | tests := []struct { | |
| 42 | + | name string | |
| 43 | + | intent pickerIntent | |
| 44 | + | wantErr error | |
| 45 | + | }{ | |
| 46 | + | {name: "pickerMoveTab", intent: pickerMoveTab, wantErr: errSessionCannotYieldMoves}, | |
| 47 | + | {name: "pickerNavigate", intent: pickerNavigate, wantErr: nil}, | |
| 48 | + | } | |
| 49 | + | for _, tt := range tests { | |
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |
| 51 | + | err := d.enterPickerForIntent(sess, ac, tt.intent, moveSourceLocator{}) | |
| 52 | + | if !errors.Is(err, tt.wantErr) { | |
| 53 | + | t.Fatalf("enterPickerForIntent error = %v, want %v", err, tt.wantErr) | |
| 54 | + | } | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) { | |||
| 11 | + | s := &session{id: domain.SessionID("s1"), name: "alpha"} | |||
| 12 | + | caps := s.capabilities() | |||
| 13 | + | if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() { | |||
| 14 | + | t.Fatalf("zero-value capabilities = %+v, want fully capable", caps) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) { | |||
| 19 | + | tests := []struct { | |||
| 20 | + | name string | |||
| 21 | + | caps sessionCapabilities | |||
| 22 | + | want bool | |||
| 23 | + | }{ | |||
| 24 | + | {name: "default local", caps: sessionCapabilities{}, want: false}, | |||
| 25 | + | {name: "restricted proxy", caps: sessionCapabilities{cannotAcceptMoves: true}, want: true}, | |||
| 26 | + | } | |||
| 27 | + | for _, tt := range tests { | |||
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 29 | + | s := &session{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps} | |||
| 30 | + | if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want { | |||
| 31 | + | t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want) | |||
| 32 | + | } | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) { | |||
| 38 | + | d, sess, ac, _ := newManualSessionWithPTYs(t) | |||
| 39 | + | sess.caps = sessionCapabilities{cannotYieldMoves: true} | |||
| 40 | + | ||||
| 41 | + | tests := []struct { | |||
| 42 | + | name string | |||
| 43 | + | intent pickerIntent | |||
| 44 | + | wantErr error | |||
| 45 | + | }{ | |||
| 46 | + | {name: "pickerMoveTab", intent: pickerMoveTab, wantErr: errSessionCannotYieldMoves}, | |||
| 47 | + | {name: "pickerNavigate", intent: pickerNavigate, wantErr: nil}, | |||
| 48 | + | } | |||
| 49 | + | for _, tt := range tests { | |||
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 51 | + | err := d.enterPickerForIntent(sess, ac, tt.intent, moveSourceLocator{}) | |||
| 52 | + | if !errors.Is(err, tt.wantErr) { | |||
| 53 | + | t.Fatalf("enterPickerForIntent error = %v, want %v", err, tt.wantErr) | |||
| 54 | + | } | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
A internal/usecase/daemon/session_view.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // viewOptions controls optional (costly) fields captured by snapshotView. | |
| 9 | + | type viewOptions struct { | |
| 10 | + | tabDetails bool | |
| 11 | + | focusedTitles bool | |
| 12 | + | terminalTitle bool | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // tabView is an immutable, value-only description of one tab. | |
| 16 | + | type tabView struct { | |
| 17 | + | id domain.TabStableID | |
| 18 | + | name string | |
| 19 | + | focusedTitle string | |
| 20 | + | attention bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // sessionView is an immutable, value-only description of a session. It | |
| 24 | + | // deliberately retains no live pointers, mirroring recentSession | |
| 25 | + | // (recent_sessions.go). Listing paths (picker, MRU bar, palette, | |
| 26 | + | // list-sessions) read local session state through snapshotView; proxy rows in | |
| 27 | + | // later phases still come from registry/discovery, not this type alone. | |
| 28 | + | type sessionView struct { | |
| 29 | + | id domain.SessionID | |
| 30 | + | incarnation domain.IncarnationID | |
| 31 | + | name string | |
| 32 | + | ephemeral bool | |
| 33 | + | createdAt int64 | |
| 34 | + | active int | |
| 35 | + | mruAt uint64 | |
| 36 | + | attached bool | |
| 37 | + | tabCount int | |
| 38 | + | hasAttention bool | |
| 39 | + | tabs []tabView | |
| 40 | + | cannotAcceptMoves bool | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // snapshotView reads session fields under s.mu and samples the independently | |
| 44 | + | // atomic mruAt while that lock is held. tabCount and hasAttention are always | |
| 45 | + | // filled; tabs is allocated only when opts.tabDetails is true (non-nil empty | |
| 46 | + | // slice when there are no tabs). Never call while holding a pane lock. | |
| 47 | + | func (s *session) snapshotView(opts viewOptions) sessionView { | |
| 48 | + | if opts.focusedTitles && !opts.tabDetails { | |
| 49 | + | opts.tabDetails = true | |
| 50 | + | } | |
| 51 | + | s.mu.Lock() | |
| 52 | + | defer s.mu.Unlock() | |
| 53 | + | view := sessionView{ | |
| 54 | + | id: s.id, | |
| 55 | + | incarnation: s.incarnation, | |
| 56 | + | name: s.name, | |
| 57 | + | ephemeral: s.ephemeral, | |
| 58 | + | createdAt: s.createdAt, | |
| 59 | + | active: s.active, | |
| 60 | + | mruAt: s.mruAt.Load(), | |
| 61 | + | attached: s.client != nil, | |
| 62 | + | tabCount: len(s.tabs), | |
| 63 | + | cannotAcceptMoves: s.capabilities().cannotAcceptMoves, | |
| 64 | + | } | |
| 65 | + | if opts.tabDetails { | |
| 66 | + | view.tabs = make([]tabView, 0, len(s.tabs)) | |
| 67 | + | } | |
| 68 | + | for i, tb := range s.tabs { | |
| 69 | + | if tb.attention { | |
| 70 | + | view.hasAttention = true | |
| 71 | + | if !opts.tabDetails { | |
| 72 | + | break | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | if !opts.tabDetails { | |
| 76 | + | continue | |
| 77 | + | } | |
| 78 | + | entry := tabView{ | |
| 79 | + | id: domain.TabStableID(tb.stableID), | |
| 80 | + | name: tabDisplayName(tb, i), | |
| 81 | + | attention: tb.attention, | |
| 82 | + | } | |
| 83 | + | if opts.focusedTitles { | |
| 84 | + | entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle) | |
| 85 | + | } | |
| 86 | + | view.tabs = append(view.tabs, entry) | |
| 87 | + | } | |
| 88 | + | return view | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // pickerView renders this snapshot as the picker's value type. Field-for-field | |
| 92 | + | // equivalent to the inline construction previously in pickerViews. | |
| 93 | + | func (view sessionView) pickerView() picker.SessionView { | |
| 94 | + | out := picker.SessionView{ | |
| 95 | + | ID: view.id, | |
| 96 | + | Incarnation: view.incarnation, | |
| 97 | + | Name: view.name, | |
| 98 | + | TargetName: view.name, | |
| 99 | + | Active: view.active, | |
| 100 | + | Tabs: make([]picker.TabEntry, 0, len(view.tabs)), | |
| 101 | + | CannotAcceptMoves: view.cannotAcceptMoves, | |
| 102 | + | } | |
| 103 | + | if !view.ephemeral { | |
| 104 | + | createdAt := view.createdAt | |
| 105 | + | out.ExpectedCreatedAt = &createdAt | |
| 106 | + | } | |
| 107 | + | attention := false | |
| 108 | + | for _, tb := range view.tabs { | |
| 109 | + | out.Tabs = append(out.Tabs, picker.TabEntry{ | |
| 110 | + | TabID: tb.id, | |
| 111 | + | Name: tb.name, | |
| 112 | + | Detail: tabTitleDetail(tb.name, tb.focusedTitle), | |
| 113 | + | Attention: tb.attention, | |
| 114 | + | }) | |
| 115 | + | if tb.attention { | |
| 116 | + | attention = true | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | if attention { | |
| 120 | + | out.Name = attentionSuffix(out.Name) | |
| 121 | + | } | |
| 122 | + | return out | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // viewOptions controls optional (costly) fields captured by snapshotView. | |||
| 9 | + | type viewOptions struct { | |||
| 10 | + | tabDetails bool | |||
| 11 | + | focusedTitles bool | |||
| 12 | + | terminalTitle bool | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // tabView is an immutable, value-only description of one tab. | |||
| 16 | + | type tabView struct { | |||
| 17 | + | id domain.TabStableID | |||
| 18 | + | name string | |||
| 19 | + | focusedTitle string | |||
| 20 | + | attention bool | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // sessionView is an immutable, value-only description of a session. It | |||
| 24 | + | // deliberately retains no live pointers, mirroring recentSession | |||
| 25 | + | // (recent_sessions.go). Listing paths (picker, MRU bar, palette, | |||
| 26 | + | // list-sessions) read local session state through snapshotView; proxy rows in | |||
| 27 | + | // later phases still come from registry/discovery, not this type alone. | |||
| 28 | + | type sessionView struct { | |||
| 29 | + | id domain.SessionID | |||
| 30 | + | incarnation domain.IncarnationID | |||
| 31 | + | name string | |||
| 32 | + | ephemeral bool | |||
| 33 | + | createdAt int64 | |||
| 34 | + | active int | |||
| 35 | + | mruAt uint64 | |||
| 36 | + | attached bool | |||
| 37 | + | tabCount int | |||
| 38 | + | hasAttention bool | |||
| 39 | + | tabs []tabView | |||
| 40 | + | cannotAcceptMoves bool | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // snapshotView reads session fields under s.mu and samples the independently | |||
| 44 | + | // atomic mruAt while that lock is held. tabCount and hasAttention are always | |||
| 45 | + | // filled; tabs is allocated only when opts.tabDetails is true (non-nil empty | |||
| 46 | + | // slice when there are no tabs). Never call while holding a pane lock. | |||
| 47 | + | func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 48 | + | if opts.focusedTitles && !opts.tabDetails { | |||
| 49 | + | opts.tabDetails = true | |||
| 50 | + | } | |||
| 51 | + | s.mu.Lock() | |||
| 52 | + | defer s.mu.Unlock() | |||
| 53 | + | view := sessionView{ | |||
| 54 | + | id: s.id, | |||
| 55 | + | incarnation: s.incarnation, | |||
| 56 | + | name: s.name, | |||
| 57 | + | ephemeral: s.ephemeral, | |||
| 58 | + | createdAt: s.createdAt, | |||
| 59 | + | active: s.active, | |||
| 60 | + | mruAt: s.mruAt.Load(), | |||
| 61 | + | attached: s.client != nil, | |||
| 62 | + | tabCount: len(s.tabs), | |||
| 63 | + | cannotAcceptMoves: s.capabilities().cannotAcceptMoves, | |||
| 64 | + | } | |||
| 65 | + | if opts.tabDetails { | |||
| 66 | + | view.tabs = make([]tabView, 0, len(s.tabs)) | |||
| 67 | + | } | |||
| 68 | + | for i, tb := range s.tabs { | |||
| 69 | + | if tb.attention { | |||
| 70 | + | view.hasAttention = true | |||
| 71 | + | if !opts.tabDetails { | |||
| 72 | + | break | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | if !opts.tabDetails { | |||
| 76 | + | continue | |||
| 77 | + | } | |||
| 78 | + | entry := tabView{ | |||
| 79 | + | id: domain.TabStableID(tb.stableID), | |||
| 80 | + | name: tabDisplayName(tb, i), | |||
| 81 | + | attention: tb.attention, | |||
| 82 | + | } | |||
| 83 | + | if opts.focusedTitles { | |||
| 84 | + | entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle) | |||
| 85 | + | } | |||
| 86 | + | view.tabs = append(view.tabs, entry) | |||
| 87 | + | } | |||
| 88 | + | return view | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // pickerView renders this snapshot as the picker's value type. Field-for-field | |||
| 92 | + | // equivalent to the inline construction previously in pickerViews. | |||
| 93 | + | func (view sessionView) pickerView() picker.SessionView { | |||
| 94 | + | out := picker.SessionView{ | |||
| 95 | + | ID: view.id, | |||
| 96 | + | Incarnation: view.incarnation, | |||
| 97 | + | Name: view.name, | |||
| 98 | + | TargetName: view.name, | |||
| 99 | + | Active: view.active, | |||
| 100 | + | Tabs: make([]picker.TabEntry, 0, len(view.tabs)), | |||
| 101 | + | CannotAcceptMoves: view.cannotAcceptMoves, | |||
| 102 | + | } | |||
| 103 | + | if !view.ephemeral { | |||
| 104 | + | createdAt := view.createdAt | |||
| 105 | + | out.ExpectedCreatedAt = &createdAt | |||
| 106 | + | } | |||
| 107 | + | attention := false | |||
| 108 | + | for _, tb := range view.tabs { | |||
| 109 | + | out.Tabs = append(out.Tabs, picker.TabEntry{ | |||
| 110 | + | TabID: tb.id, | |||
| 111 | + | Name: tb.name, | |||
| 112 | + | Detail: tabTitleDetail(tb.name, tb.focusedTitle), | |||
| 113 | + | Attention: tb.attention, | |||
| 114 | + | }) | |||
| 115 | + | if tb.attention { | |||
| 116 | + | attention = true | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | if attention { | |||
| 120 | + | out.Name = attentionSuffix(out.Name) | |||
| 121 | + | } | |||
| 122 | + | return out | |||
| 123 | + | } | |||
A internal/usecase/daemon/session_view_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | build func() *session | |
| 14 | + | opts viewOptions | |
| 15 | + | want sessionView | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "named session with attention tab", | |
| 19 | + | build: func() *session { | |
| 20 | + | s := &session{ | |
| 21 | + | id: domain.SessionID("s1"), | |
| 22 | + | incarnation: domain.IncarnationID{1}, | |
| 23 | + | name: "alpha", | |
| 24 | + | createdAt: 42, | |
| 25 | + | active: 1, | |
| 26 | + | client: &attachedClient{}, | |
| 27 | + | tabs: []*tab{ | |
| 28 | + | {stableID: "t1", name: "build"}, | |
| 29 | + | {stableID: "t2", name: "logs", attention: true}, | |
| 30 | + | }, | |
| 31 | + | } | |
| 32 | + | s.mruAt.Store(7) | |
| 33 | + | return s | |
| 34 | + | }, | |
| 35 | + | opts: viewOptions{tabDetails: true}, | |
| 36 | + | want: sessionView{ | |
| 37 | + | id: domain.SessionID("s1"), | |
| 38 | + | incarnation: domain.IncarnationID{1}, | |
| 39 | + | name: "alpha", | |
| 40 | + | createdAt: 42, | |
| 41 | + | active: 1, | |
| 42 | + | mruAt: 7, | |
| 43 | + | attached: true, | |
| 44 | + | tabCount: 2, | |
| 45 | + | hasAttention: true, | |
| 46 | + | tabs: []tabView{ | |
| 47 | + | {id: domain.TabStableID("t1"), name: "build"}, | |
| 48 | + | {id: domain.TabStableID("t2"), name: "logs", attention: true}, | |
| 49 | + | }, | |
| 50 | + | }, | |
| 51 | + | }, | |
| 52 | + | { | |
| 53 | + | name: "ephemeral detached session without tabs", | |
| 54 | + | build: func() *session { | |
| 55 | + | return &session{id: domain.SessionID("s2"), name: "9", ephemeral: true} | |
| 56 | + | }, | |
| 57 | + | opts: viewOptions{}, | |
| 58 | + | want: sessionView{ | |
| 59 | + | id: domain.SessionID("s2"), | |
| 60 | + | name: "9", | |
| 61 | + | ephemeral: true, | |
| 62 | + | tabCount: 0, | |
| 63 | + | }, | |
| 64 | + | }, | |
| 65 | + | } | |
| 66 | + | for _, tt := range tests { | |
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |
| 68 | + | got := tt.build().snapshotView(tt.opts) | |
| 69 | + | if !reflect.DeepEqual(got, tt.want) { | |
| 70 | + | t.Fatalf("snapshotView() = %+v, want %+v", got, tt.want) | |
| 71 | + | } | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestSnapshotViewFocusedTitlesOption(t *testing.T) { | |
| 77 | + | s := &session{ | |
| 78 | + | id: domain.SessionID("s1"), | |
| 79 | + | name: "alpha", | |
| 80 | + | tabs: []*tab{{stableID: "t1", name: "build"}}, | |
| 81 | + | } | |
| 82 | + | off := s.snapshotView(viewOptions{tabDetails: true}) | |
| 83 | + | if off.tabs[0].focusedTitle != "" { | |
| 84 | + | t.Fatalf("focusedTitle without focusedTitles option = %q, want empty", off.tabs[0].focusedTitle) | |
| 85 | + | } | |
| 86 | + | // A pane-less tab has no focused pane: the option must be exercised | |
| 87 | + | // without panicking and still yield an empty title. | |
| 88 | + | on := s.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: true}) | |
| 89 | + | if on.tabs[0].focusedTitle != "" { | |
| 90 | + | t.Fatalf("focusedTitle for pane-less tab = %q, want empty", on.tabs[0].focusedTitle) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestSnapshotViewAggregatesWithoutTabDetails(t *testing.T) { | |
| 95 | + | s := &session{tabs: []*tab{ | |
| 96 | + | {stableID: "t1", name: "build"}, | |
| 97 | + | {stableID: "t2", name: "logs", attention: true}, | |
| 98 | + | }} | |
| 99 | + | got := s.snapshotView(viewOptions{}) | |
| 100 | + | if got.tabCount != 2 || !got.hasAttention { | |
| 101 | + | t.Fatalf("aggregates = (tabCount %d, hasAttention %t), want (2, true)", got.tabCount, got.hasAttention) | |
| 102 | + | } | |
| 103 | + | if got.tabs != nil { | |
| 104 | + | t.Fatalf("tabs without tabDetails = %#v, want nil", got.tabs) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | empty := (&session{}).snapshotView(viewOptions{tabDetails: true}) | |
| 108 | + | if empty.tabCount != 0 || empty.tabs == nil || len(empty.tabs) != 0 { | |
| 109 | + | t.Fatalf("empty detailed snapshot = (tabCount %d, tabs %#v), want (0, non-nil empty)", empty.tabCount, empty.tabs) | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | build func() *session | |||
| 14 | + | opts viewOptions | |||
| 15 | + | want sessionView | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "named session with attention tab", | |||
| 19 | + | build: func() *session { | |||
| 20 | + | s := &session{ | |||
| 21 | + | id: domain.SessionID("s1"), | |||
| 22 | + | incarnation: domain.IncarnationID{1}, | |||
| 23 | + | name: "alpha", | |||
| 24 | + | createdAt: 42, | |||
| 25 | + | active: 1, | |||
| 26 | + | client: &attachedClient{}, | |||
| 27 | + | tabs: []*tab{ | |||
| 28 | + | {stableID: "t1", name: "build"}, | |||
| 29 | + | {stableID: "t2", name: "logs", attention: true}, | |||
| 30 | + | }, | |||
| 31 | + | } | |||
| 32 | + | s.mruAt.Store(7) | |||
| 33 | + | return s | |||
| 34 | + | }, | |||
| 35 | + | opts: viewOptions{tabDetails: true}, | |||
| 36 | + | want: sessionView{ | |||
| 37 | + | id: domain.SessionID("s1"), | |||
| 38 | + | incarnation: domain.IncarnationID{1}, | |||
| 39 | + | name: "alpha", | |||
| 40 | + | createdAt: 42, | |||
| 41 | + | active: 1, | |||
| 42 | + | mruAt: 7, | |||
| 43 | + | attached: true, | |||
| 44 | + | tabCount: 2, | |||
| 45 | + | hasAttention: true, | |||
| 46 | + | tabs: []tabView{ | |||
| 47 | + | {id: domain.TabStableID("t1"), name: "build"}, | |||
| 48 | + | {id: domain.TabStableID("t2"), name: "logs", attention: true}, | |||
| 49 | + | }, | |||
| 50 | + | }, | |||
| 51 | + | }, | |||
| 52 | + | { | |||
| 53 | + | name: "ephemeral detached session without tabs", | |||
| 54 | + | build: func() *session { | |||
| 55 | + | return &session{id: domain.SessionID("s2"), name: "9", ephemeral: true} | |||
| 56 | + | }, | |||
| 57 | + | opts: viewOptions{}, | |||
| 58 | + | want: sessionView{ | |||
| 59 | + | id: domain.SessionID("s2"), | |||
| 60 | + | name: "9", | |||
| 61 | + | ephemeral: true, | |||
| 62 | + | tabCount: 0, | |||
| 63 | + | }, | |||
| 64 | + | }, | |||
| 65 | + | } | |||
| 66 | + | for _, tt := range tests { | |||
| 67 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 68 | + | got := tt.build().snapshotView(tt.opts) | |||
| 69 | + | if !reflect.DeepEqual(got, tt.want) { | |||
| 70 | + | t.Fatalf("snapshotView() = %+v, want %+v", got, tt.want) | |||
| 71 | + | } | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestSnapshotViewFocusedTitlesOption(t *testing.T) { | |||
| 77 | + | s := &session{ | |||
| 78 | + | id: domain.SessionID("s1"), | |||
| 79 | + | name: "alpha", | |||
| 80 | + | tabs: []*tab{{stableID: "t1", name: "build"}}, | |||
| 81 | + | } | |||
| 82 | + | off := s.snapshotView(viewOptions{tabDetails: true}) | |||
| 83 | + | if off.tabs[0].focusedTitle != "" { | |||
| 84 | + | t.Fatalf("focusedTitle without focusedTitles option = %q, want empty", off.tabs[0].focusedTitle) | |||
| 85 | + | } | |||
| 86 | + | // A pane-less tab has no focused pane: the option must be exercised | |||
| 87 | + | // without panicking and still yield an empty title. | |||
| 88 | + | on := s.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: true}) | |||
| 89 | + | if on.tabs[0].focusedTitle != "" { | |||
| 90 | + | t.Fatalf("focusedTitle for pane-less tab = %q, want empty", on.tabs[0].focusedTitle) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestSnapshotViewAggregatesWithoutTabDetails(t *testing.T) { | |||
| 95 | + | s := &session{tabs: []*tab{ | |||
| 96 | + | {stableID: "t1", name: "build"}, | |||
| 97 | + | {stableID: "t2", name: "logs", attention: true}, | |||
| 98 | + | }} | |||
| 99 | + | got := s.snapshotView(viewOptions{}) | |||
| 100 | + | if got.tabCount != 2 || !got.hasAttention { | |||
| 101 | + | t.Fatalf("aggregates = (tabCount %d, hasAttention %t), want (2, true)", got.tabCount, got.hasAttention) | |||
| 102 | + | } | |||
| 103 | + | if got.tabs != nil { | |||
| 104 | + | t.Fatalf("tabs without tabDetails = %#v, want nil", got.tabs) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | empty := (&session{}).snapshotView(viewOptions{tabDetails: true}) | |||
| 108 | + | if empty.tabCount != 0 || empty.tabs == nil || len(empty.tabs) != 0 { | |||
| 109 | + | t.Fatalf("empty detailed snapshot = (tabCount %d, tabs %#v), want (0, non-nil empty)", empty.tabCount, empty.tabs) | |||
| 110 | + | } | |||
| 111 | + | } | |||
M internal/usecase/picker/model.go +4 -0
| @@ -24,6 +24,10 @@ type SessionView struct { | |||
| 24 | 24 | Active int | |
| 25 | 25 | Stopped bool | |
| 26 | 26 | ExpectedCreatedAt *int64 | |
| 27 | + | // CannotAcceptMoves reports whether this session cannot receive a moved tab | |
| 28 | + | // or pane. False for ordinary local (and stopped) sessions; true for | |
| 29 | + | // restricted proxy sessions in phase 4. Populated, not yet consumed. | |
| 30 | + | CannotAcceptMoves bool | |
| 27 | 31 | } | |
| 28 | 32 | ||
| 29 | 33 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | |
| @@ -24,6 +24,10 @@ type SessionView struct { | |||||
| 24 | Active int | 24 | Active int | ||
| 25 | Stopped bool | 25 | Stopped bool | ||
| 26 | ExpectedCreatedAt *int64 | 26 | ExpectedCreatedAt *int64 | ||
| 27 | + | // CannotAcceptMoves reports whether this session cannot receive a moved tab | |||
| 28 | + | // or pane. False for ordinary local (and stopped) sessions; true for | |||
| 29 | + | // restricted proxy sessions in phase 4. Populated, not yet consumed. | |||
| 30 | + | CannotAcceptMoves bool | |||
| 27 | } | 31 | } | ||
| 28 | 32 | ||||
| 29 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | 33 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | ||