refactor(picker): support move destination modes
- commit
- ffcb75d7604f58a469d0c2dbd2c72195cd6d77c4
- author
- brice · · 2026-07-28 02:06 UTC
- parent
- 707a1cd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +955 -107
M internal/domain/types.go +7 -1
| @@ -92,5 +92,11 @@ type Rect struct { | |||
| 92 | 92 | // SessionID uniquely identifies a session. | |
| 93 | 93 | type SessionID string | |
| 94 | 94 | ||
| 95 | - | // TabID uniquely identifies a tab within a session. | |
| 95 | + | // TabID identifies a tab by its session-local, user-facing ID. | |
| 96 | 96 | type TabID string | |
| 97 | + | ||
| 98 | + | // TabStableID identifies a tab independently of its mutable session position. | |
| 99 | + | type TabStableID string | |
| 100 | + | ||
| 101 | + | // PaneStableID identifies a pane independently of its layout-local PaneID. | |
| 102 | + | type PaneStableID string | |
| @@ -92,5 +92,11 @@ type Rect struct { | |||||
| 92 | // SessionID uniquely identifies a session. | 92 | // SessionID uniquely identifies a session. | ||
| 93 | type SessionID string | 93 | type SessionID string | ||
| 94 | 94 | ||||
| 95 | - | // TabID uniquely identifies a tab within a session. | 95 | + | // TabID identifies a tab by its session-local, user-facing ID. |
| 96 | type TabID string | 96 | type TabID string | ||
| 97 | + | ||||
| 98 | + | // TabStableID identifies a tab independently of its mutable session position. | |||
| 99 | + | type TabStableID string | |||
| 100 | + | ||||
| 101 | + | // PaneStableID identifies a pane independently of its layout-local PaneID. | |||
| 102 | + | type PaneStableID string | |||
M internal/usecase/daemon/overlay_runtime.go +4 -0
| @@ -18,6 +18,8 @@ type overlayRuntime struct { | |||
| 18 | 18 | ||
| 19 | 19 | pickerMu sync.Mutex | |
| 20 | 20 | picker *picker.Model | |
| 21 | + | pickerIntent pickerIntent | |
| 22 | + | pickerSource moveSourceLocator | |
| 21 | 23 | pickerPreview *tab | |
| 22 | 24 | pickerPreviewSession *session | |
| 23 | 25 | pickerPreviewGeneration uint64 | |
| @@ -255,6 +257,8 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool { | |||
| 255 | 257 | previewSession := rt.pickerPreviewSession | |
| 256 | 258 | previewGeneration := rt.pickerPreviewGeneration | |
| 257 | 259 | rt.picker = nil | |
| 260 | + | rt.pickerIntent = pickerNavigate | |
| 261 | + | rt.pickerSource = moveSourceLocator{} | |
| 258 | 262 | rt.pickerPending = nil | |
| 259 | 263 | rt.pickerESC.stop() | |
| 260 | 264 | rt.pickerPreviewGeneration++ | |
| @@ -18,6 +18,8 @@ type overlayRuntime struct { | |||||
| 18 | 18 | ||||
| 19 | pickerMu sync.Mutex | 19 | pickerMu sync.Mutex | ||
| 20 | picker *picker.Model | 20 | picker *picker.Model | ||
| 21 | + | pickerIntent pickerIntent | |||
| 22 | + | pickerSource moveSourceLocator | |||
| 21 | pickerPreview *tab | 23 | pickerPreview *tab | ||
| 22 | pickerPreviewSession *session | 24 | pickerPreviewSession *session | ||
| 23 | pickerPreviewGeneration uint64 | 25 | pickerPreviewGeneration uint64 | ||
| @@ -255,6 +257,8 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool { | |||||
| 255 | previewSession := rt.pickerPreviewSession | 257 | previewSession := rt.pickerPreviewSession | ||
| 256 | previewGeneration := rt.pickerPreviewGeneration | 258 | previewGeneration := rt.pickerPreviewGeneration | ||
| 257 | rt.picker = nil | 259 | rt.picker = nil | ||
| 260 | + | rt.pickerIntent = pickerNavigate | |||
| 261 | + | rt.pickerSource = moveSourceLocator{} | |||
| 258 | rt.pickerPending = nil | 262 | rt.pickerPending = nil | ||
| 259 | rt.pickerESC.stop() | 263 | rt.pickerESC.stop() | ||
| 260 | rt.pickerPreviewGeneration++ | 264 | rt.pickerPreviewGeneration++ | ||
M internal/usecase/daemon/picker.go +169 -31
| @@ -14,20 +14,73 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |
| 17 | + | var ( | |
| 18 | + | pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |
| 19 | + | errNoMoveDestination = errors.New("no eligible move destination") | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | type pickerIntent uint8 | |
| 18 | 23 | ||
| 24 | + | const ( | |
| 25 | + | pickerNavigate pickerIntent = iota | |
| 26 | + | pickerMovePane | |
| 27 | + | pickerMoveTab | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | type moveSessionLocator struct { | |
| 31 | + | ID domain.SessionID | |
| 32 | + | Incarnation domain.IncarnationID | |
| 33 | + | Name string | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | type moveSourceLocator struct { | |
| 37 | + | Session moveSessionLocator | |
| 38 | + | TabID domain.TabStableID | |
| 39 | + | PaneID domain.PaneStableID | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // enterPicker preserves the existing navigation entry point. Navigation always | |
| 43 | + | // publishes its model, including an empty one; only move entry can fail for a | |
| 44 | + | // missing destination. | |
| 19 | 45 | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |
| 20 | - | views, curTab := d.pickerViews(sess) | |
| 21 | - | ac.overlays.pickerMu.Lock() | |
| 22 | - | ac.overlays.picker = picker.New(views, sess.id, curTab) | |
| 23 | - | ac.overlays.pickerPending = nil | |
| 24 | - | ac.overlays.pickerPreview = nil | |
| 25 | - | ac.overlays.pickerMu.Unlock() | |
| 46 | + | model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 47 | + | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // enterPickerForIntent returns errNoMoveDestination only for move intents. | |
| 51 | + | func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error { | |
| 52 | + | model := d.newPickerModel(sess, intent, source, picker.SourceFilter{}) | |
| 53 | + | if intent != pickerNavigate { | |
| 54 | + | if _, ok := model.Selected(); !ok { | |
| 55 | + | return errNoMoveDestination | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | d.publishPicker(sess, ac, model, intent, source) | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) { | |
| 64 | + | rt := ac.overlays | |
| 65 | + | rt.pickerMu.Lock() | |
| 66 | + | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | |
| 67 | + | rt.pickerPreviewGeneration++ | |
| 68 | + | rt.pickerPreviewSession = nil | |
| 69 | + | rt.pickerPreview = nil | |
| 70 | + | rt.picker = model | |
| 71 | + | rt.pickerIntent = intent | |
| 72 | + | rt.pickerSource = source | |
| 73 | + | rt.pickerPending = nil | |
| 74 | + | rt.pickerESC.stop() | |
| 75 | + | rt.pickerMu.Unlock() | |
| 76 | + | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 26 | 77 | d.registerPreviewForSelection(ac) | |
| 27 | 78 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 28 | 79 | } | |
| 29 | 80 | ||
| 30 | - | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |
| 81 | + | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | |
| 82 | + | // knows nothing about picker intent; picker.New owns all destination policy. | |
| 83 | + | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) { | |
| 31 | 84 | d.mu.Lock() | |
| 32 | 85 | sessions := d.sessionsSnapshotLocked() | |
| 33 | 86 | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| @@ -46,33 +99,37 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 46 | 99 | ||
| 47 | 100 | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | |
| 48 | 101 | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | |
| 49 | - | curTab := 0 | |
| 102 | + | var current picker.SourceFilter | |
| 50 | 103 | for _, s := range sessions { | |
| 51 | 104 | s.mu.Lock() | |
| 52 | - | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | |
| 105 | + | view := picker.SessionView{ | |
| 106 | + | ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active, | |
| 107 | + | Tabs: make([]picker.TabEntry, 0, len(s.tabs)), | |
| 108 | + | } | |
| 53 | 109 | if !s.ephemeral { | |
| 54 | 110 | createdAt := s.createdAt | |
| 55 | - | view.TargetName = s.name | |
| 56 | 111 | view.ExpectedCreatedAt = &createdAt | |
| 57 | 112 | } | |
| 58 | 113 | sessionAttention := false | |
| 59 | 114 | for i, tb := range s.tabs { | |
| 60 | 115 | name := tabDisplayName(tb, i) | |
| 61 | - | view.Tabs[i] = picker.TabEntry{ | |
| 116 | + | tabID := domain.TabStableID(tb.stableID) | |
| 117 | + | view.Tabs = append(view.Tabs, picker.TabEntry{ | |
| 118 | + | TabID: tabID, | |
| 62 | 119 | Name: name, | |
| 63 | 120 | Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)), | |
| 64 | 121 | Attention: tb.attention, | |
| 65 | - | } | |
| 122 | + | }) | |
| 66 | 123 | if tb.attention { | |
| 67 | 124 | sessionAttention = true | |
| 68 | 125 | } | |
| 126 | + | if s == cur && i == s.active { | |
| 127 | + | current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID} | |
| 128 | + | } | |
| 69 | 129 | } | |
| 70 | 130 | if sessionAttention { | |
| 71 | 131 | view.Name = attentionSuffix(view.Name) | |
| 72 | 132 | } | |
| 73 | - | if s == cur { | |
| 74 | - | curTab = s.active | |
| 75 | - | } | |
| 76 | 133 | s.mu.Unlock() | |
| 77 | 134 | views = append(views, view) | |
| 78 | 135 | } | |
| @@ -80,6 +137,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 80 | 137 | createdAt := s.createdAt | |
| 81 | 138 | views = append(views, picker.SessionView{ | |
| 82 | 139 | ID: domain.SessionID("stopped:" + s.name), | |
| 140 | + | Incarnation: s.incarnation, | |
| 83 | 141 | Name: s.name, | |
| 84 | 142 | TargetName: s.name, | |
| 85 | 143 | Tabs: []picker.TabEntry{{}}, | |
| @@ -87,7 +145,32 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 87 | 145 | ExpectedCreatedAt: &createdAt, | |
| 88 | 146 | }) | |
| 89 | 147 | } | |
| 90 | - | return views, curTab | |
| 148 | + | return views, current | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | |
| 152 | + | views, attachedCurrent := d.pickerViews(cur) | |
| 153 | + | if intent == pickerNavigate { | |
| 154 | + | current = attachedCurrent | |
| 155 | + | } | |
| 156 | + | return picker.New(views, picker.SelectionConfig{ | |
| 157 | + | Mode: pickerSelectionMode(intent), | |
| 158 | + | Current: current, | |
| 159 | + | Source: picker.SourceFilter{ | |
| 160 | + | Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID, | |
| 161 | + | }, | |
| 162 | + | }) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func pickerSelectionMode(intent pickerIntent) picker.SelectionMode { | |
| 166 | + | switch intent { | |
| 167 | + | case pickerMovePane: | |
| 168 | + | return picker.SelectMovePaneTab | |
| 169 | + | case pickerMoveTab: | |
| 170 | + | return picker.SelectMoveTabSession | |
| 171 | + | default: | |
| 172 | + | return picker.SelectNavigationTab | |
| 173 | + | } | |
| 91 | 174 | } | |
| 92 | 175 | ||
| 93 | 176 | func attentionSuffix(label string) string { | |
| @@ -107,6 +190,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 107 | 190 | active: func() bool { return rt.picker != nil }, | |
| 108 | 191 | closeLocked: func() { | |
| 109 | 192 | rt.picker = nil | |
| 193 | + | rt.pickerIntent = pickerNavigate | |
| 194 | + | rt.pickerSource = moveSourceLocator{} | |
| 110 | 195 | generation = rt.pickerPreviewGeneration | |
| 111 | 196 | }, | |
| 112 | 197 | afterClose: func() { | |
| @@ -206,6 +291,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 206 | 291 | ac.overlays.pickerMu.Lock() | |
| 207 | 292 | var target picker.Target | |
| 208 | 293 | var ok bool | |
| 294 | + | intent := ac.overlays.pickerIntent | |
| 209 | 295 | if ac.overlays.picker != nil { | |
| 210 | 296 | target, ok = ac.overlays.picker.Selected() | |
| 211 | 297 | } | |
| @@ -219,15 +305,14 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 219 | 305 | if !ok { | |
| 220 | 306 | return | |
| 221 | 307 | } | |
| 222 | - | next := d.tabByTarget(target) | |
| 223 | - | targetSess := d.sessionByID(target.Session) | |
| 308 | + | targetSess, next := d.previewTarget(target, intent) | |
| 224 | 309 | if next == nil || targetSess == nil { | |
| 225 | 310 | return | |
| 226 | 311 | } | |
| 227 | 312 | ac.overlays.pickerMu.Lock() | |
| 228 | 313 | // Selection may have changed while the target was resolved. | |
| 229 | 314 | selected, stillSelected := ac.overlays.picker.Selected() | |
| 230 | - | valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target | |
| 315 | + | valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target) | |
| 231 | 316 | if valid { | |
| 232 | 317 | ac.overlays.pickerPreview = next | |
| 233 | 318 | ac.overlays.pickerPreviewSession = targetSess | |
| @@ -339,6 +424,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 339 | 424 | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 340 | 425 | ac.overlays.pickerMu.Lock() | |
| 341 | 426 | ac.overlays.picker = nil | |
| 427 | + | ac.overlays.pickerIntent = pickerNavigate | |
| 428 | + | ac.overlays.pickerSource = moveSourceLocator{} | |
| 342 | 429 | ac.overlays.pickerPending = nil | |
| 343 | 430 | ac.overlays.pickerESC.stop() | |
| 344 | 431 | generation := ac.overlays.pickerPreviewGeneration | |
| @@ -346,19 +433,48 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 346 | 433 | d.clearPreviewGeneration(ac, generation) | |
| 347 | 434 | } | |
| 348 | 435 | ||
| 349 | - | func (d *Daemon) tabByTarget(target picker.Target) *tab { | |
| 436 | + | func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) { | |
| 350 | 437 | d.mu.Lock() | |
| 351 | 438 | sess := d.sessions[target.Session] | |
| 352 | - | d.mu.Unlock() | |
| 353 | 439 | if sess == nil { | |
| 354 | - | return nil | |
| 440 | + | d.mu.Unlock() | |
| 441 | + | return nil, nil | |
| 355 | 442 | } | |
| 356 | 443 | sess.mu.Lock() | |
| 444 | + | d.mu.Unlock() | |
| 357 | 445 | defer sess.mu.Unlock() | |
| 446 | + | if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) { | |
| 447 | + | return nil, nil | |
| 448 | + | } | |
| 449 | + | if intent == pickerMoveTab { | |
| 450 | + | if sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 451 | + | return nil, nil | |
| 452 | + | } | |
| 453 | + | return sess, sess.tabs[sess.active] | |
| 454 | + | } | |
| 455 | + | if target.TabID != "" { | |
| 456 | + | for _, tb := range sess.tabs { | |
| 457 | + | if domain.TabStableID(tb.stableID) == target.TabID { | |
| 458 | + | return sess, tb | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | return nil, nil | |
| 462 | + | } | |
| 358 | 463 | if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) { | |
| 359 | - | return nil | |
| 464 | + | return nil, nil | |
| 360 | 465 | } | |
| 361 | - | return sess.tabs[target.TabIndex] | |
| 466 | + | return sess, sess.tabs[target.TabIndex] | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func pickerTargetsEqual(left, right picker.Target) bool { | |
| 470 | + | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | |
| 471 | + | left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped { | |
| 472 | + | return false | |
| 473 | + | } | |
| 474 | + | if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil { | |
| 475 | + | return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil | |
| 476 | + | } | |
| 477 | + | return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt | |
| 362 | 478 | } | |
| 363 | 479 | ||
| 364 | 480 | func (d *Daemon) sessionByID(id domain.SessionID) *session { | |
| @@ -770,13 +886,35 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||
| 770 | 886 | if sess == nil { | |
| 771 | 887 | return | |
| 772 | 888 | } | |
| 773 | - | views, curTab := d.pickerViews(sess) | |
| 774 | - | ac.overlays.pickerMu.Lock() | |
| 775 | - | if ac.overlays.picker != nil { | |
| 776 | - | ac.overlays.picker = picker.New(views, sess.id, curTab) | |
| 889 | + | rt := ac.overlays | |
| 890 | + | rt.pickerMu.Lock() | |
| 891 | + | if rt.picker == nil { | |
| 892 | + | rt.pickerMu.Unlock() | |
| 893 | + | return | |
| 894 | + | } | |
| 895 | + | intent, source := rt.pickerIntent, rt.pickerSource | |
| 896 | + | current := picker.SourceFilter{} | |
| 897 | + | if intent != pickerNavigate { | |
| 898 | + | selected, _ := rt.picker.Selected() | |
| 899 | + | current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID} | |
| 900 | + | } | |
| 901 | + | rt.pickerMu.Unlock() | |
| 902 | + | model := d.newPickerModel(sess, intent, source, current) | |
| 903 | + | if intent != pickerNavigate { | |
| 904 | + | if _, ok := model.Selected(); !ok { | |
| 905 | + | d.closePicker(ac) | |
| 906 | + | return | |
| 907 | + | } | |
| 908 | + | } | |
| 909 | + | rt.pickerMu.Lock() | |
| 910 | + | updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source | |
| 911 | + | if updated { | |
| 912 | + | rt.picker = model | |
| 913 | + | } | |
| 914 | + | rt.pickerMu.Unlock() | |
| 915 | + | if updated { | |
| 916 | + | d.registerPreviewForSelection(ac) | |
| 777 | 917 | } | |
| 778 | - | ac.overlays.pickerMu.Unlock() | |
| 779 | - | d.registerPreviewForSelection(ac) | |
| 780 | 918 | } | |
| 781 | 919 | ||
| 782 | 920 | func snapshotPickerPreview(tb *tab) picker.Preview { | |
| @@ -14,20 +14,73 @@ import ( | |||||
| 14 | "github.com/bnema/vev/pkg/renderer" | 14 | "github.com/bnema/vev/pkg/renderer" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | 17 | + | var ( |
| 18 | + | pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |||
| 19 | + | errNoMoveDestination = errors.New("no eligible move destination") | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | type pickerIntent uint8 | |||
| 18 | 23 | ||||
| 24 | + | const ( | |||
| 25 | + | pickerNavigate pickerIntent = iota | |||
| 26 | + | pickerMovePane | |||
| 27 | + | pickerMoveTab | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | type moveSessionLocator struct { | |||
| 31 | + | ID domain.SessionID | |||
| 32 | + | Incarnation domain.IncarnationID | |||
| 33 | + | Name string | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | type moveSourceLocator struct { | |||
| 37 | + | Session moveSessionLocator | |||
| 38 | + | TabID domain.TabStableID | |||
| 39 | + | PaneID domain.PaneStableID | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // enterPicker preserves the existing navigation entry point. Navigation always | |||
| 43 | + | // publishes its model, including an empty one; only move entry can fail for a | |||
| 44 | + | // missing destination. | |||
| 19 | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | 45 | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | ||
| 20 | - | views, curTab := d.pickerViews(sess) | 46 | + | model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) |
| 21 | - | ac.overlays.pickerMu.Lock() | 47 | + | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) |
| 22 | - | ac.overlays.picker = picker.New(views, sess.id, curTab) | 48 | + | } |
| 23 | - | ac.overlays.pickerPending = nil | 49 | + | |
| 24 | - | ac.overlays.pickerPreview = nil | 50 | + | // enterPickerForIntent returns errNoMoveDestination only for move intents. |
| 25 | - | ac.overlays.pickerMu.Unlock() | 51 | + | func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error { |
| 52 | + | model := d.newPickerModel(sess, intent, source, picker.SourceFilter{}) | |||
| 53 | + | if intent != pickerNavigate { | |||
| 54 | + | if _, ok := model.Selected(); !ok { | |||
| 55 | + | return errNoMoveDestination | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | d.publishPicker(sess, ac, model, intent, source) | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) { | |||
| 64 | + | rt := ac.overlays | |||
| 65 | + | rt.pickerMu.Lock() | |||
| 66 | + | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | |||
| 67 | + | rt.pickerPreviewGeneration++ | |||
| 68 | + | rt.pickerPreviewSession = nil | |||
| 69 | + | rt.pickerPreview = nil | |||
| 70 | + | rt.picker = model | |||
| 71 | + | rt.pickerIntent = intent | |||
| 72 | + | rt.pickerSource = source | |||
| 73 | + | rt.pickerPending = nil | |||
| 74 | + | rt.pickerESC.stop() | |||
| 75 | + | rt.pickerMu.Unlock() | |||
| 76 | + | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |||
| 26 | d.registerPreviewForSelection(ac) | 77 | d.registerPreviewForSelection(ac) | ||
| 27 | d.invalidateRender(sess, ac, true, "picker.go") | 78 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 28 | } | 79 | } | ||
| 29 | 80 | ||||
| 30 | - | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | 81 | + | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally |
| 82 | + | // knows nothing about picker intent; picker.New owns all destination policy. | |||
| 83 | + | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) { | |||
| 31 | d.mu.Lock() | 84 | d.mu.Lock() | ||
| 32 | sessions := d.sessionsSnapshotLocked() | 85 | sessions := d.sessionsSnapshotLocked() | ||
| 33 | stopped := make([]stoppedSession, 0, len(d.stopped)) | 86 | stopped := make([]stoppedSession, 0, len(d.stopped)) | ||
| @@ -46,33 +99,37 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 46 | 99 | ||||
| 47 | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | 100 | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | ||
| 48 | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | 101 | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | ||
| 49 | - | curTab := 0 | 102 | + | var current picker.SourceFilter |
| 50 | for _, s := range sessions { | 103 | for _, s := range sessions { | ||
| 51 | s.mu.Lock() | 104 | s.mu.Lock() | ||
| 52 | - | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | 105 | + | view := picker.SessionView{ |
| 106 | + | ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active, | |||
| 107 | + | Tabs: make([]picker.TabEntry, 0, len(s.tabs)), | |||
| 108 | + | } | |||
| 53 | if !s.ephemeral { | 109 | if !s.ephemeral { | ||
| 54 | createdAt := s.createdAt | 110 | createdAt := s.createdAt | ||
| 55 | - | view.TargetName = s.name | |||
| 56 | view.ExpectedCreatedAt = &createdAt | 111 | view.ExpectedCreatedAt = &createdAt | ||
| 57 | } | 112 | } | ||
| 58 | sessionAttention := false | 113 | sessionAttention := false | ||
| 59 | for i, tb := range s.tabs { | 114 | for i, tb := range s.tabs { | ||
| 60 | name := tabDisplayName(tb, i) | 115 | name := tabDisplayName(tb, i) | ||
| 61 | - | view.Tabs[i] = picker.TabEntry{ | 116 | + | tabID := domain.TabStableID(tb.stableID) |
| 117 | + | view.Tabs = append(view.Tabs, picker.TabEntry{ | |||
| 118 | + | TabID: tabID, | |||
| 62 | Name: name, | 119 | Name: name, | ||
| 63 | Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)), | 120 | Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)), | ||
| 64 | Attention: tb.attention, | 121 | Attention: tb.attention, | ||
| 65 | - | } | 122 | + | }) |
| 66 | if tb.attention { | 123 | if tb.attention { | ||
| 67 | sessionAttention = true | 124 | sessionAttention = true | ||
| 68 | } | 125 | } | ||
| 126 | + | if s == cur && i == s.active { | |||
| 127 | + | current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID} | |||
| 128 | + | } | |||
| 69 | } | 129 | } | ||
| 70 | if sessionAttention { | 130 | if sessionAttention { | ||
| 71 | view.Name = attentionSuffix(view.Name) | 131 | view.Name = attentionSuffix(view.Name) | ||
| 72 | } | 132 | } | ||
| 73 | - | if s == cur { | |||
| 74 | - | curTab = s.active | |||
| 75 | - | } | |||
| 76 | s.mu.Unlock() | 133 | s.mu.Unlock() | ||
| 77 | views = append(views, view) | 134 | views = append(views, view) | ||
| 78 | } | 135 | } | ||
| @@ -80,6 +137,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 80 | createdAt := s.createdAt | 137 | createdAt := s.createdAt | ||
| 81 | views = append(views, picker.SessionView{ | 138 | views = append(views, picker.SessionView{ | ||
| 82 | ID: domain.SessionID("stopped:" + s.name), | 139 | ID: domain.SessionID("stopped:" + s.name), | ||
| 140 | + | Incarnation: s.incarnation, | |||
| 83 | Name: s.name, | 141 | Name: s.name, | ||
| 84 | TargetName: s.name, | 142 | TargetName: s.name, | ||
| 85 | Tabs: []picker.TabEntry{{}}, | 143 | Tabs: []picker.TabEntry{{}}, | ||
| @@ -87,7 +145,32 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 87 | ExpectedCreatedAt: &createdAt, | 145 | ExpectedCreatedAt: &createdAt, | ||
| 88 | }) | 146 | }) | ||
| 89 | } | 147 | } | ||
| 90 | - | return views, curTab | 148 | + | return views, current |
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | |||
| 152 | + | views, attachedCurrent := d.pickerViews(cur) | |||
| 153 | + | if intent == pickerNavigate { | |||
| 154 | + | current = attachedCurrent | |||
| 155 | + | } | |||
| 156 | + | return picker.New(views, picker.SelectionConfig{ | |||
| 157 | + | Mode: pickerSelectionMode(intent), | |||
| 158 | + | Current: current, | |||
| 159 | + | Source: picker.SourceFilter{ | |||
| 160 | + | Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID, | |||
| 161 | + | }, | |||
| 162 | + | }) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func pickerSelectionMode(intent pickerIntent) picker.SelectionMode { | |||
| 166 | + | switch intent { | |||
| 167 | + | case pickerMovePane: | |||
| 168 | + | return picker.SelectMovePaneTab | |||
| 169 | + | case pickerMoveTab: | |||
| 170 | + | return picker.SelectMoveTabSession | |||
| 171 | + | default: | |||
| 172 | + | return picker.SelectNavigationTab | |||
| 173 | + | } | |||
| 91 | } | 174 | } | ||
| 92 | 175 | ||||
| 93 | func attentionSuffix(label string) string { | 176 | func attentionSuffix(label string) string { | ||
| @@ -107,6 +190,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||||
| 107 | active: func() bool { return rt.picker != nil }, | 190 | active: func() bool { return rt.picker != nil }, | ||
| 108 | closeLocked: func() { | 191 | closeLocked: func() { | ||
| 109 | rt.picker = nil | 192 | rt.picker = nil | ||
| 193 | + | rt.pickerIntent = pickerNavigate | |||
| 194 | + | rt.pickerSource = moveSourceLocator{} | |||
| 110 | generation = rt.pickerPreviewGeneration | 195 | generation = rt.pickerPreviewGeneration | ||
| 111 | }, | 196 | }, | ||
| 112 | afterClose: func() { | 197 | afterClose: func() { | ||
| @@ -206,6 +291,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 206 | ac.overlays.pickerMu.Lock() | 291 | ac.overlays.pickerMu.Lock() | ||
| 207 | var target picker.Target | 292 | var target picker.Target | ||
| 208 | var ok bool | 293 | var ok bool | ||
| 294 | + | intent := ac.overlays.pickerIntent | |||
| 209 | if ac.overlays.picker != nil { | 295 | if ac.overlays.picker != nil { | ||
| 210 | target, ok = ac.overlays.picker.Selected() | 296 | target, ok = ac.overlays.picker.Selected() | ||
| 211 | } | 297 | } | ||
| @@ -219,15 +305,14 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 219 | if !ok { | 305 | if !ok { | ||
| 220 | return | 306 | return | ||
| 221 | } | 307 | } | ||
| 222 | - | next := d.tabByTarget(target) | 308 | + | targetSess, next := d.previewTarget(target, intent) |
| 223 | - | targetSess := d.sessionByID(target.Session) | |||
| 224 | if next == nil || targetSess == nil { | 309 | if next == nil || targetSess == nil { | ||
| 225 | return | 310 | return | ||
| 226 | } | 311 | } | ||
| 227 | ac.overlays.pickerMu.Lock() | 312 | ac.overlays.pickerMu.Lock() | ||
| 228 | // Selection may have changed while the target was resolved. | 313 | // Selection may have changed while the target was resolved. | ||
| 229 | selected, stillSelected := ac.overlays.picker.Selected() | 314 | selected, stillSelected := ac.overlays.picker.Selected() | ||
| 230 | - | valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target | 315 | + | valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target) |
| 231 | if valid { | 316 | if valid { | ||
| 232 | ac.overlays.pickerPreview = next | 317 | ac.overlays.pickerPreview = next | ||
| 233 | ac.overlays.pickerPreviewSession = targetSess | 318 | ac.overlays.pickerPreviewSession = targetSess | ||
| @@ -339,6 +424,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 339 | func (d *Daemon) closePicker(ac *attachedClient) { | 424 | func (d *Daemon) closePicker(ac *attachedClient) { | ||
| 340 | ac.overlays.pickerMu.Lock() | 425 | ac.overlays.pickerMu.Lock() | ||
| 341 | ac.overlays.picker = nil | 426 | ac.overlays.picker = nil | ||
| 427 | + | ac.overlays.pickerIntent = pickerNavigate | |||
| 428 | + | ac.overlays.pickerSource = moveSourceLocator{} | |||
| 342 | ac.overlays.pickerPending = nil | 429 | ac.overlays.pickerPending = nil | ||
| 343 | ac.overlays.pickerESC.stop() | 430 | ac.overlays.pickerESC.stop() | ||
| 344 | generation := ac.overlays.pickerPreviewGeneration | 431 | generation := ac.overlays.pickerPreviewGeneration | ||
| @@ -346,19 +433,48 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||||
| 346 | d.clearPreviewGeneration(ac, generation) | 433 | d.clearPreviewGeneration(ac, generation) | ||
| 347 | } | 434 | } | ||
| 348 | 435 | ||||
| 349 | - | func (d *Daemon) tabByTarget(target picker.Target) *tab { | 436 | + | func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) { |
| 350 | d.mu.Lock() | 437 | d.mu.Lock() | ||
| 351 | sess := d.sessions[target.Session] | 438 | sess := d.sessions[target.Session] | ||
| 352 | - | d.mu.Unlock() | |||
| 353 | if sess == nil { | 439 | if sess == nil { | ||
| 354 | - | return nil | 440 | + | d.mu.Unlock() |
| 441 | + | return nil, nil | |||
| 355 | } | 442 | } | ||
| 356 | sess.mu.Lock() | 443 | sess.mu.Lock() | ||
| 444 | + | d.mu.Unlock() | |||
| 357 | defer sess.mu.Unlock() | 445 | defer sess.mu.Unlock() | ||
| 446 | + | if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) { | |||
| 447 | + | return nil, nil | |||
| 448 | + | } | |||
| 449 | + | if intent == pickerMoveTab { | |||
| 450 | + | if sess.active < 0 || sess.active >= len(sess.tabs) { | |||
| 451 | + | return nil, nil | |||
| 452 | + | } | |||
| 453 | + | return sess, sess.tabs[sess.active] | |||
| 454 | + | } | |||
| 455 | + | if target.TabID != "" { | |||
| 456 | + | for _, tb := range sess.tabs { | |||
| 457 | + | if domain.TabStableID(tb.stableID) == target.TabID { | |||
| 458 | + | return sess, tb | |||
| 459 | + | } | |||
| 460 | + | } | |||
| 461 | + | return nil, nil | |||
| 462 | + | } | |||
| 358 | if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) { | 463 | if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) { | ||
| 359 | - | return nil | 464 | + | return nil, nil |
| 360 | } | 465 | } | ||
| 361 | - | return sess.tabs[target.TabIndex] | 466 | + | return sess, sess.tabs[target.TabIndex] |
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | func pickerTargetsEqual(left, right picker.Target) bool { | |||
| 470 | + | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | |||
| 471 | + | left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped { | |||
| 472 | + | return false | |||
| 473 | + | } | |||
| 474 | + | if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil { | |||
| 475 | + | return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil | |||
| 476 | + | } | |||
| 477 | + | return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt | |||
| 362 | } | 478 | } | ||
| 363 | 479 | ||||
| 364 | func (d *Daemon) sessionByID(id domain.SessionID) *session { | 480 | func (d *Daemon) sessionByID(id domain.SessionID) *session { | ||
| @@ -770,13 +886,35 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||||
| 770 | if sess == nil { | 886 | if sess == nil { | ||
| 771 | return | 887 | return | ||
| 772 | } | 888 | } | ||
| 773 | - | views, curTab := d.pickerViews(sess) | 889 | + | rt := ac.overlays |
| 774 | - | ac.overlays.pickerMu.Lock() | 890 | + | rt.pickerMu.Lock() |
| 775 | - | if ac.overlays.picker != nil { | 891 | + | if rt.picker == nil { |
| 776 | - | ac.overlays.picker = picker.New(views, sess.id, curTab) | 892 | + | rt.pickerMu.Unlock() |
| 893 | + | return | |||
| 894 | + | } | |||
| 895 | + | intent, source := rt.pickerIntent, rt.pickerSource | |||
| 896 | + | current := picker.SourceFilter{} | |||
| 897 | + | if intent != pickerNavigate { | |||
| 898 | + | selected, _ := rt.picker.Selected() | |||
| 899 | + | current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID} | |||
| 900 | + | } | |||
| 901 | + | rt.pickerMu.Unlock() | |||
| 902 | + | model := d.newPickerModel(sess, intent, source, current) | |||
| 903 | + | if intent != pickerNavigate { | |||
| 904 | + | if _, ok := model.Selected(); !ok { | |||
| 905 | + | d.closePicker(ac) | |||
| 906 | + | return | |||
| 907 | + | } | |||
| 908 | + | } | |||
| 909 | + | rt.pickerMu.Lock() | |||
| 910 | + | updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source | |||
| 911 | + | if updated { | |||
| 912 | + | rt.picker = model | |||
| 913 | + | } | |||
| 914 | + | rt.pickerMu.Unlock() | |||
| 915 | + | if updated { | |||
| 916 | + | d.registerPreviewForSelection(ac) | |||
| 777 | } | 917 | } | ||
| 778 | - | ac.overlays.pickerMu.Unlock() | |||
| 779 | - | d.registerPreviewForSelection(ac) | |||
| 780 | } | 918 | } | ||
| 781 | 919 | ||||
| 782 | func snapshotPickerPreview(tb *tab) picker.Preview { | 920 | func snapshotPickerPreview(tb *tab) picker.Preview { | ||
M internal/usecase/daemon/picker_test.go +366 -8
| @@ -23,6 +23,11 @@ import ( | |||
| 23 | 23 | "github.com/bnema/vev/pkg/vt" | |
| 24 | 24 | ) | |
| 25 | 25 | ||
| 26 | + | var ( | |
| 27 | + | _ domain.TabStableID = moveSourceLocator{}.TabID | |
| 28 | + | _ domain.PaneStableID = moveSourceLocator{}.PaneID | |
| 29 | + | ) | |
| 30 | + | ||
| 26 | 31 | // --- test doubles ----------------------------------------------------------- | |
| 27 | 32 | ||
| 28 | 33 | type refusingSnapshotDeleteRepository struct { | |
| @@ -74,16 +79,16 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 74 | 79 | d.sessions[current.id] = current | |
| 75 | 80 | d.sessions[ringing.id] = ringing | |
| 76 | 81 | ||
| 77 | - | views, curTab := d.pickerViews(current) | |
| 82 | + | views, currentSelection := d.pickerViews(current) | |
| 78 | 83 | ||
| 79 | - | require.Equal(t, 0, curTab) | |
| 84 | + | require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection) | |
| 80 | 85 | require.Len(t, views, 2) | |
| 81 | 86 | require.Equal(t, "alpha", views[0].Name) | |
| 82 | 87 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | |
| 83 | 88 | require.Equal(t, "beta ", views[1].Name) | |
| 84 | 89 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | |
| 85 | 90 | ||
| 86 | - | model := picker.New(views, current.id, 0) | |
| 91 | + | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: current.id, TabID: views[0].Tabs[0].TabID}}) | |
| 87 | 92 | model.Down() | |
| 88 | 93 | model.Down() | |
| 89 | 94 | target, ok := model.Selected() | |
| @@ -130,6 +135,294 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 130 | 135 | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | |
| 131 | 136 | } | |
| 132 | 137 | ||
| 138 | + | func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | |
| 139 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 140 | + | source := &session{ | |
| 141 | + | id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0, | |
| 142 | + | tabs: []*tab{ | |
| 143 | + | {stableID: "source-tab", panes: map[layout.PaneID]*pane{}}, | |
| 144 | + | {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}}, | |
| 145 | + | }, | |
| 146 | + | } | |
| 147 | + | destination := &session{ | |
| 148 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 149 | + | tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}, | |
| 150 | + | } | |
| 151 | + | d.sessions[source.id] = source | |
| 152 | + | d.sessions[destination.id] = destination | |
| 153 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}} | |
| 154 | + | sourceLocator := moveSourceLocator{ | |
| 155 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 156 | + | TabID: "source-tab", | |
| 157 | + | PaneID: "source-pane", | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | views, current := d.pickerViews(source) | |
| 161 | + | require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles") | |
| 162 | + | require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current) | |
| 163 | + | var sourceView picker.SessionView | |
| 164 | + | for _, view := range views { | |
| 165 | + | if view.ID == source.id { | |
| 166 | + | sourceView = view | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs, | |
| 170 | + | "daemon capture does not remove the move source") | |
| 171 | + | ||
| 172 | + | paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{ | |
| 173 | + | Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab", | |
| 174 | + | }) | |
| 175 | + | paneTarget, ok := paneModel.Selected() | |
| 176 | + | require.True(t, ok) | |
| 177 | + | require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID) | |
| 178 | + | ||
| 179 | + | tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{}) | |
| 180 | + | tabTarget, ok := tabModel.Selected() | |
| 181 | + | require.True(t, ok) | |
| 182 | + | require.Equal(t, destination.id, tabTarget.Session) | |
| 183 | + | require.Equal(t, destination.incarnation, tabTarget.Incarnation) | |
| 184 | + | require.Equal(t, destination.name, tabTarget.Name) | |
| 185 | + | require.Equal(t, -1, tabTarget.TabIndex) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestPickerMoveWithoutDestinationDoesNotPublishOverlay(t *testing.T) { | |
| 189 | + | d, sess, ac, _, releases := newManualTabSession(t, 1) | |
| 190 | + | defer releases[0]() | |
| 191 | + | sess.mu.Lock() | |
| 192 | + | sess.incarnation = domain.IncarnationID{1} | |
| 193 | + | tb := sess.tabs[0] | |
| 194 | + | sess.mu.Unlock() | |
| 195 | + | tb.mu.Lock() | |
| 196 | + | tabID := tb.stableID | |
| 197 | + | paneID := tb.focusedPane().stableID | |
| 198 | + | tb.mu.Unlock() | |
| 199 | + | source := moveSourceLocator{ | |
| 200 | + | Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name}, | |
| 201 | + | TabID: domain.TabStableID(tabID), PaneID: domain.PaneStableID(paneID), | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | for _, intent := range []pickerIntent{pickerMovePane, pickerMoveTab} { | |
| 205 | + | err := d.enterPickerForIntent(sess, ac, intent, source) | |
| 206 | + | ||
| 207 | + | require.ErrorIs(t, err, errNoMoveDestination) | |
| 208 | + | require.False(t, ac.overlays.pickerActive()) | |
| 209 | + | ac.overlays.pickerMu.Lock() | |
| 210 | + | require.Nil(t, ac.overlays.pickerPreview) | |
| 211 | + | require.Nil(t, ac.overlays.pickerPreviewSession) | |
| 212 | + | require.Zero(t, ac.overlays.pickerPreviewGeneration, "failed entry must not publish or retire a picker generation") | |
| 213 | + | ac.overlays.pickerMu.Unlock() | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) { | |
| 218 | + | p1, release1 := newBlockingPTY(t) | |
| 219 | + | p2, release2 := newBlockingPTY(t) | |
| 220 | + | defer release1() | |
| 221 | + | defer release2() | |
| 222 | + | d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 223 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "z-source", domain.IncarnationID{1} | |
| 224 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 225 | + | d.sessions[sourceSession.id] = sourceSession | |
| 226 | + | targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel) | |
| 227 | + | targetTab.stableID = "destination-tab" | |
| 228 | + | targetSession := &session{ | |
| 229 | + | id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 230 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}, | |
| 231 | + | } | |
| 232 | + | d.sessions[targetSession.id] = targetSession | |
| 233 | + | sourceTab := sourceSession.tabs[0] | |
| 234 | + | sourceTab.mu.Lock() | |
| 235 | + | sourceTab.stableID = "source-tab" | |
| 236 | + | sourcePaneID := sourceTab.focusedPane().stableID | |
| 237 | + | sourceTab.mu.Unlock() | |
| 238 | + | source := moveSourceLocator{ | |
| 239 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| 240 | + | TabID: "source-tab", PaneID: domain.PaneStableID(sourcePaneID), | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source)) | |
| 244 | + | ac.overlays.pickerMu.Lock() | |
| 245 | + | storedIntent, storedSource := ac.overlays.pickerIntent, ac.overlays.pickerSource | |
| 246 | + | selected, ok := ac.overlays.picker.Selected() | |
| 247 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 248 | + | require.Same(t, targetTab, ac.overlays.pickerPreview) | |
| 249 | + | require.Same(t, targetSession, ac.overlays.pickerPreviewSession) | |
| 250 | + | ac.overlays.pickerMu.Unlock() | |
| 251 | + | require.Equal(t, pickerMovePane, storedIntent) | |
| 252 | + | require.Equal(t, source, storedSource) | |
| 253 | + | require.True(t, ok) | |
| 254 | + | require.Equal(t, targetSession.id, selected.Session) | |
| 255 | + | require.Equal(t, targetSession.incarnation, selected.Incarnation) | |
| 256 | + | require.Equal(t, targetSession.name, selected.Name) | |
| 257 | + | require.Equal(t, domain.TabStableID(targetTab.stableID), selected.TabID) | |
| 258 | + | require.Equal(t, 0, selected.TabIndex) | |
| 259 | + | require.Nil(t, selected.ExpectedCreatedAt) | |
| 260 | + | rc := targetSession.renderCoordinator() | |
| 261 | + | require.NotNil(t, rc) | |
| 262 | + | rc.mu.Lock() | |
| 263 | + | subscription, subscribed := rc.previewWakes[ac] | |
| 264 | + | rc.mu.Unlock() | |
| 265 | + | require.True(t, subscribed) | |
| 266 | + | require.Equal(t, generation, subscription.generation) | |
| 267 | + | ||
| 268 | + | d.enterPicker(sourceSession, ac) | |
| 269 | + | ||
| 270 | + | rc.mu.Lock() | |
| 271 | + | _, subscribed = rc.previewWakes[ac] | |
| 272 | + | rc.mu.Unlock() | |
| 273 | + | require.False(t, subscribed, "picker replacement must retire the prior cross-session preview subscription") | |
| 274 | + | ac.overlays.pickerMu.Lock() | |
| 275 | + | require.NotNil(t, ac.overlays.picker) | |
| 276 | + | require.Equal(t, pickerNavigate, ac.overlays.pickerIntent) | |
| 277 | + | require.Equal(t, moveSourceLocator{}, ac.overlays.pickerSource) | |
| 278 | + | ac.overlays.pickerMu.Unlock() | |
| 279 | + | ||
| 280 | + | d.closePicker(ac) | |
| 281 | + | ac.overlays.pickerMu.Lock() | |
| 282 | + | require.Nil(t, ac.overlays.picker) | |
| 283 | + | require.Nil(t, ac.overlays.pickerPreview) | |
| 284 | + | ac.overlays.pickerMu.Unlock() | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing.T) { | |
| 288 | + | p1, release1 := newBlockingPTY(t) | |
| 289 | + | p2, release2 := newBlockingPTY(t) | |
| 290 | + | p3, release3 := newBlockingPTY(t) | |
| 291 | + | defer release1() | |
| 292 | + | defer release2() | |
| 293 | + | defer release3() | |
| 294 | + | d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 295 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |
| 296 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 297 | + | d.sessions[sourceSession.id] = sourceSession | |
| 298 | + | target := &session{ | |
| 299 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1, | |
| 300 | + | ctx: sourceSession.ctx, cancel: func() {}, | |
| 301 | + | tabs: []*tab{ | |
| 302 | + | newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel), | |
| 303 | + | newTestTabWithContext(p3, sourceSession.ctx, sourceSession.cancel), | |
| 304 | + | }, | |
| 305 | + | } | |
| 306 | + | target.tabs[0].stableID, target.tabs[1].stableID = "first", "active" | |
| 307 | + | d.sessions[target.id] = target | |
| 308 | + | source := moveSourceLocator{ | |
| 309 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| 310 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source)) | |
| 314 | + | ||
| 315 | + | ac.overlays.pickerMu.Lock() | |
| 316 | + | selected, ok := ac.overlays.picker.Selected() | |
| 317 | + | require.Same(t, target.tabs[1], ac.overlays.pickerPreview) | |
| 318 | + | ac.overlays.pickerMu.Unlock() | |
| 319 | + | require.True(t, ok) | |
| 320 | + | require.Equal(t, target.id, selected.Session) | |
| 321 | + | require.Equal(t, target.incarnation, selected.Incarnation) | |
| 322 | + | require.Equal(t, target.name, selected.Name) | |
| 323 | + | require.Equal(t, -1, selected.TabIndex) | |
| 324 | + | require.Nil(t, selected.ExpectedCreatedAt) | |
| 325 | + | require.Equal(t, 1, activeTabIndex(target), "preview must not mutate destination focus") | |
| 326 | + | d.closePicker(ac) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |
| 330 | + | t.Run("pane destination stable tab", func(t *testing.T) { | |
| 331 | + | d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1) | |
| 332 | + | defer releaseAll(sourceReleases) | |
| 333 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |
| 334 | + | sourceSession.tabs[0].stableID = "source-tab" | |
| 335 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 336 | + | d.sessions[sourceSession.id] = sourceSession | |
| 337 | + | ||
| 338 | + | firstPTY, releaseFirst := newBlockingPTY(t) | |
| 339 | + | defer releaseFirst() | |
| 340 | + | secondPTY, releaseSecond := newBlockingPTY(t) | |
| 341 | + | defer releaseSecond() | |
| 342 | + | firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel) | |
| 343 | + | secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel) | |
| 344 | + | firstTab.stableID, secondTab.stableID = "first", "selected" | |
| 345 | + | destination := &session{ | |
| 346 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 347 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}, | |
| 348 | + | } | |
| 349 | + | d.sessions[destination.id] = destination | |
| 350 | + | source := moveSourceLocator{ | |
| 351 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| 352 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source)) | |
| 356 | + | ac.overlays.pickerMu.Lock() | |
| 357 | + | ac.overlays.picker.Down() | |
| 358 | + | before, ok := ac.overlays.picker.Selected() | |
| 359 | + | ac.overlays.pickerMu.Unlock() | |
| 360 | + | require.True(t, ok) | |
| 361 | + | require.Equal(t, domain.TabStableID("selected"), before.TabID) | |
| 362 | + | ||
| 363 | + | destination.mu.Lock() | |
| 364 | + | destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0] | |
| 365 | + | destination.mu.Unlock() | |
| 366 | + | d.refreshPicker(ac) | |
| 367 | + | ||
| 368 | + | ac.overlays.pickerMu.Lock() | |
| 369 | + | after, ok := ac.overlays.picker.Selected() | |
| 370 | + | ac.overlays.pickerMu.Unlock() | |
| 371 | + | require.True(t, ok) | |
| 372 | + | require.Equal(t, before.Session, after.Session) | |
| 373 | + | require.Equal(t, before.Incarnation, after.Incarnation) | |
| 374 | + | require.Equal(t, before.TabID, after.TabID) | |
| 375 | + | require.Equal(t, 0, after.TabIndex, "mutable index follows the preserved stable tab") | |
| 376 | + | d.closePicker(ac) | |
| 377 | + | }) | |
| 378 | + | ||
| 379 | + | t.Run("tab destination session", func(t *testing.T) { | |
| 380 | + | d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1) | |
| 381 | + | defer releaseAll(sourceReleases) | |
| 382 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |
| 383 | + | sourceSession.tabs[0].stableID = "source-tab" | |
| 384 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 385 | + | d.sessions[sourceSession.id] = sourceSession | |
| 386 | + | ||
| 387 | + | releases := make([]func(), 0, 2) | |
| 388 | + | for i, destination := range []struct { | |
| 389 | + | id domain.SessionID | |
| 390 | + | name string | |
| 391 | + | }{{id: "first", name: "0-first"}, {id: "selected", name: "1-selected"}} { | |
| 392 | + | pty, release := newBlockingPTY(t) | |
| 393 | + | releases = append(releases, release) | |
| 394 | + | tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel) | |
| 395 | + | tb.stableID = string(destination.id) + "-tab" | |
| 396 | + | d.sessions[destination.id] = &session{ | |
| 397 | + | id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true, | |
| 398 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}, | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | defer releaseAll(releases) | |
| 402 | + | source := moveSourceLocator{ | |
| 403 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| 404 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source)) | |
| 408 | + | ac.overlays.pickerMu.Lock() | |
| 409 | + | ac.overlays.picker.Down() | |
| 410 | + | before, ok := ac.overlays.picker.Selected() | |
| 411 | + | ac.overlays.pickerMu.Unlock() | |
| 412 | + | require.True(t, ok) | |
| 413 | + | require.Equal(t, domain.SessionID("selected"), before.Session) | |
| 414 | + | ||
| 415 | + | d.refreshPicker(ac) | |
| 416 | + | ||
| 417 | + | ac.overlays.pickerMu.Lock() | |
| 418 | + | after, ok := ac.overlays.picker.Selected() | |
| 419 | + | ac.overlays.pickerMu.Unlock() | |
| 420 | + | require.True(t, ok) | |
| 421 | + | require.Equal(t, before, after) | |
| 422 | + | d.closePicker(ac) | |
| 423 | + | }) | |
| 424 | + | } | |
| 425 | + | ||
| 133 | 426 | func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |
| 134 | 427 | p, release := newBlockingPTY(t) | |
| 135 | 428 | defer release() | |
| @@ -147,7 +440,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 147 | 440 | break | |
| 148 | 441 | } | |
| 149 | 442 | } | |
| 150 | - | model := picker.New([]picker.SessionView{originalView}, original.id, 0) | |
| 443 | + | model := picker.New([]picker.SessionView{originalView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: original.id, TabID: originalView.Tabs[0].TabID}}) | |
| 151 | 444 | target, ok := model.Selected() | |
| 152 | 445 | require.True(t, ok) | |
| 153 | 446 | ||
| @@ -193,7 +486,10 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |||
| 193 | 486 | views, _ := d.pickerViews(sess) | |
| 194 | 487 | ||
| 195 | 488 | require.Len(t, views, 1) | |
| 196 | - | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs) | |
| 489 | + | require.Equal(t, []picker.TabEntry{ | |
| 490 | + | {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"}, | |
| 491 | + | {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)", Attention: true}, | |
| 492 | + | }, views[0].Tabs) | |
| 197 | 493 | } | |
| 198 | 494 | ||
| 199 | 495 | func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |
| @@ -217,7 +513,10 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |||
| 217 | 513 | views, _ := d.pickerViews(sess) | |
| 218 | 514 | ||
| 219 | 515 | require.Len(t, views, 1) | |
| 220 | - | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs) | |
| 516 | + | require.Equal(t, []picker.TabEntry{ | |
| 517 | + | {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"}, | |
| 518 | + | {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)"}, | |
| 519 | + | }, views[0].Tabs) | |
| 221 | 520 | } | |
| 222 | 521 | ||
| 223 | 522 | func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | |
| @@ -879,7 +1178,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||
| 879 | 1178 | pickerModel: picker.New([]picker.SessionView{ | |
| 880 | 1179 | {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0}, | |
| 881 | 1180 | {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |
| 882 | - | }, "selected", 0), | |
| 1181 | + | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}), | |
| 883 | 1182 | } | |
| 884 | 1183 | ||
| 885 | 1184 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| @@ -908,7 +1207,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||
| 908 | 1207 | } | |
| 909 | 1208 | ||
| 910 | 1209 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | |
| 911 | - | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 1210 | + | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 912 | 1211 | state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles} | |
| 913 | 1212 | cases := []struct { | |
| 914 | 1213 | name string | |
| @@ -1160,6 +1459,48 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t | |||
| 1160 | 1459 | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | |
| 1161 | 1460 | } | |
| 1162 | 1461 | ||
| 1462 | + | func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) { | |
| 1463 | + | d, current, ac, _, currentReleases := newManualTabSession(t, 2) | |
| 1464 | + | defer releaseAll(currentReleases) | |
| 1465 | + | current.id, current.name, current.active = "current", "z-current", 1 | |
| 1466 | + | current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active" | |
| 1467 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 1468 | + | d.sessions[current.id] = current | |
| 1469 | + | ||
| 1470 | + | otherPTY, releaseOther := newBlockingPTY(t) | |
| 1471 | + | defer releaseOther() | |
| 1472 | + | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | |
| 1473 | + | otherTab.stableID = "other-tab" | |
| 1474 | + | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1475 | + | d.sessions[other.id] = other | |
| 1476 | + | ||
| 1477 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1478 | + | defer releaseTarget() | |
| 1479 | + | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |
| 1480 | + | targetTab.stableID = "target-tab" | |
| 1481 | + | target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1482 | + | d.sessions[target.id] = target | |
| 1483 | + | ||
| 1484 | + | d.enterPicker(current, ac) | |
| 1485 | + | d.handlePickerInput(ac, []byte("k")) | |
| 1486 | + | d.handlePickerInput(ac, []byte("k")) | |
| 1487 | + | ac.overlays.pickerMu.Lock() | |
| 1488 | + | selected, ok := ac.overlays.picker.Selected() | |
| 1489 | + | ac.overlays.pickerMu.Unlock() | |
| 1490 | + | require.True(t, ok) | |
| 1491 | + | require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session") | |
| 1492 | + | ||
| 1493 | + | d.handlePickerInput(ac, []byte("x")) | |
| 1494 | + | ||
| 1495 | + | ac.overlays.pickerMu.Lock() | |
| 1496 | + | selected, ok = ac.overlays.picker.Selected() | |
| 1497 | + | ac.overlays.pickerMu.Unlock() | |
| 1498 | + | require.True(t, ok) | |
| 1499 | + | require.Equal(t, current.id, selected.Session) | |
| 1500 | + | require.Equal(t, domain.TabStableID("current-active"), selected.TabID) | |
| 1501 | + | require.Equal(t, 1, selected.TabIndex) | |
| 1502 | + | } | |
| 1503 | + | ||
| 1163 | 1504 | func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) { | |
| 1164 | 1505 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 1165 | 1506 | defer releaseAll(releases) | |
| @@ -1206,6 +1547,12 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1206 | 1547 | p, release := newBlockingPTY(t) | |
| 1207 | 1548 | defer release() | |
| 1208 | 1549 | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1550 | + | otherPTY, releaseOther := newBlockingPTY(t) | |
| 1551 | + | defer releaseOther() | |
| 1552 | + | otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel) | |
| 1553 | + | otherTab.stableID = "other-tab" | |
| 1554 | + | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1555 | + | d.sessions[other.id] = other | |
| 1209 | 1556 | cause := errors.New("delete failed") | |
| 1210 | 1557 | store, state := newMockStore(t) | |
| 1211 | 1558 | WithStore(t, store)(d) | |
| @@ -1236,4 +1583,15 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1236 | 1583 | d.mu.Unlock() | |
| 1237 | 1584 | require.True(t, retained, "failed deletion must retain the reserved name") | |
| 1238 | 1585 | require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore") | |
| 1586 | + | ||
| 1587 | + | from.mu.Lock() | |
| 1588 | + | activeTab := from.tabs[from.active] | |
| 1589 | + | activeTabID := activeTab.stableID | |
| 1590 | + | from.mu.Unlock() | |
| 1591 | + | ac.overlays.pickerMu.Lock() | |
| 1592 | + | selected, selectedOK := ac.overlays.picker.Selected() | |
| 1593 | + | ac.overlays.pickerMu.Unlock() | |
| 1594 | + | require.True(t, selectedOK) | |
| 1595 | + | require.Equal(t, from.id, selected.Session, "failed x refresh resets navigation to the attached session") | |
| 1596 | + | require.Equal(t, domain.TabStableID(activeTabID), selected.TabID, "failed x refresh follows the attached session's stable active tab") | |
| 1239 | 1597 | } | |
| @@ -23,6 +23,11 @@ import ( | |||||
| 23 | "github.com/bnema/vev/pkg/vt" | 23 | "github.com/bnema/vev/pkg/vt" | ||
| 24 | ) | 24 | ) | ||
| 25 | 25 | ||||
| 26 | + | var ( | |||
| 27 | + | _ domain.TabStableID = moveSourceLocator{}.TabID | |||
| 28 | + | _ domain.PaneStableID = moveSourceLocator{}.PaneID | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 26 | // --- test doubles ----------------------------------------------------------- | 31 | // --- test doubles ----------------------------------------------------------- | ||
| 27 | 32 | ||||
| 28 | type refusingSnapshotDeleteRepository struct { | 33 | type refusingSnapshotDeleteRepository struct { | ||
| @@ -74,16 +79,16 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||||
| 74 | d.sessions[current.id] = current | 79 | d.sessions[current.id] = current | ||
| 75 | d.sessions[ringing.id] = ringing | 80 | d.sessions[ringing.id] = ringing | ||
| 76 | 81 | ||||
| 77 | - | views, curTab := d.pickerViews(current) | 82 | + | views, currentSelection := d.pickerViews(current) |
| 78 | 83 | ||||
| 79 | - | require.Equal(t, 0, curTab) | 84 | + | require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection) |
| 80 | require.Len(t, views, 2) | 85 | require.Len(t, views, 2) | ||
| 81 | require.Equal(t, "alpha", views[0].Name) | 86 | require.Equal(t, "alpha", views[0].Name) | ||
| 82 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | 87 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | ||
| 83 | require.Equal(t, "beta ", views[1].Name) | 88 | require.Equal(t, "beta ", views[1].Name) | ||
| 84 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | 89 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | ||
| 85 | 90 | ||||
| 86 | - | model := picker.New(views, current.id, 0) | 91 | + | model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: current.id, TabID: views[0].Tabs[0].TabID}}) |
| 87 | model.Down() | 92 | model.Down() | ||
| 88 | model.Down() | 93 | model.Down() | ||
| 89 | target, ok := model.Selected() | 94 | target, ok := model.Selected() | ||
| @@ -130,6 +135,294 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||||
| 130 | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | 135 | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | ||
| 131 | } | 136 | } | ||
| 132 | 137 | ||||
| 138 | + | func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | |||
| 139 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 140 | + | source := &session{ | |||
| 141 | + | id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0, | |||
| 142 | + | tabs: []*tab{ | |||
| 143 | + | {stableID: "source-tab", panes: map[layout.PaneID]*pane{}}, | |||
| 144 | + | {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}}, | |||
| 145 | + | }, | |||
| 146 | + | } | |||
| 147 | + | destination := &session{ | |||
| 148 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |||
| 149 | + | tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}, | |||
| 150 | + | } | |||
| 151 | + | d.sessions[source.id] = source | |||
| 152 | + | d.sessions[destination.id] = destination | |||
| 153 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}} | |||
| 154 | + | sourceLocator := moveSourceLocator{ | |||
| 155 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 156 | + | TabID: "source-tab", | |||
| 157 | + | PaneID: "source-pane", | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | views, current := d.pickerViews(source) | |||
| 161 | + | require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles") | |||
| 162 | + | require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current) | |||
| 163 | + | var sourceView picker.SessionView | |||
| 164 | + | for _, view := range views { | |||
| 165 | + | if view.ID == source.id { | |||
| 166 | + | sourceView = view | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs, | |||
| 170 | + | "daemon capture does not remove the move source") | |||
| 171 | + | ||||
| 172 | + | paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{ | |||
| 173 | + | Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab", | |||
| 174 | + | }) | |||
| 175 | + | paneTarget, ok := paneModel.Selected() | |||
| 176 | + | require.True(t, ok) | |||
| 177 | + | require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID) | |||
| 178 | + | ||||
| 179 | + | tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{}) | |||
| 180 | + | tabTarget, ok := tabModel.Selected() | |||
| 181 | + | require.True(t, ok) | |||
| 182 | + | require.Equal(t, destination.id, tabTarget.Session) | |||
| 183 | + | require.Equal(t, destination.incarnation, tabTarget.Incarnation) | |||
| 184 | + | require.Equal(t, destination.name, tabTarget.Name) | |||
| 185 | + | require.Equal(t, -1, tabTarget.TabIndex) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestPickerMoveWithoutDestinationDoesNotPublishOverlay(t *testing.T) { | |||
| 189 | + | d, sess, ac, _, releases := newManualTabSession(t, 1) | |||
| 190 | + | defer releases[0]() | |||
| 191 | + | sess.mu.Lock() | |||
| 192 | + | sess.incarnation = domain.IncarnationID{1} | |||
| 193 | + | tb := sess.tabs[0] | |||
| 194 | + | sess.mu.Unlock() | |||
| 195 | + | tb.mu.Lock() | |||
| 196 | + | tabID := tb.stableID | |||
| 197 | + | paneID := tb.focusedPane().stableID | |||
| 198 | + | tb.mu.Unlock() | |||
| 199 | + | source := moveSourceLocator{ | |||
| 200 | + | Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name}, | |||
| 201 | + | TabID: domain.TabStableID(tabID), PaneID: domain.PaneStableID(paneID), | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | for _, intent := range []pickerIntent{pickerMovePane, pickerMoveTab} { | |||
| 205 | + | err := d.enterPickerForIntent(sess, ac, intent, source) | |||
| 206 | + | ||||
| 207 | + | require.ErrorIs(t, err, errNoMoveDestination) | |||
| 208 | + | require.False(t, ac.overlays.pickerActive()) | |||
| 209 | + | ac.overlays.pickerMu.Lock() | |||
| 210 | + | require.Nil(t, ac.overlays.pickerPreview) | |||
| 211 | + | require.Nil(t, ac.overlays.pickerPreviewSession) | |||
| 212 | + | require.Zero(t, ac.overlays.pickerPreviewGeneration, "failed entry must not publish or retire a picker generation") | |||
| 213 | + | ac.overlays.pickerMu.Unlock() | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) { | |||
| 218 | + | p1, release1 := newBlockingPTY(t) | |||
| 219 | + | p2, release2 := newBlockingPTY(t) | |||
| 220 | + | defer release1() | |||
| 221 | + | defer release2() | |||
| 222 | + | d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1) | |||
| 223 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "z-source", domain.IncarnationID{1} | |||
| 224 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 225 | + | d.sessions[sourceSession.id] = sourceSession | |||
| 226 | + | targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel) | |||
| 227 | + | targetTab.stableID = "destination-tab" | |||
| 228 | + | targetSession := &session{ | |||
| 229 | + | id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |||
| 230 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}, | |||
| 231 | + | } | |||
| 232 | + | d.sessions[targetSession.id] = targetSession | |||
| 233 | + | sourceTab := sourceSession.tabs[0] | |||
| 234 | + | sourceTab.mu.Lock() | |||
| 235 | + | sourceTab.stableID = "source-tab" | |||
| 236 | + | sourcePaneID := sourceTab.focusedPane().stableID | |||
| 237 | + | sourceTab.mu.Unlock() | |||
| 238 | + | source := moveSourceLocator{ | |||
| 239 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |||
| 240 | + | TabID: "source-tab", PaneID: domain.PaneStableID(sourcePaneID), | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source)) | |||
| 244 | + | ac.overlays.pickerMu.Lock() | |||
| 245 | + | storedIntent, storedSource := ac.overlays.pickerIntent, ac.overlays.pickerSource | |||
| 246 | + | selected, ok := ac.overlays.picker.Selected() | |||
| 247 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 248 | + | require.Same(t, targetTab, ac.overlays.pickerPreview) | |||
| 249 | + | require.Same(t, targetSession, ac.overlays.pickerPreviewSession) | |||
| 250 | + | ac.overlays.pickerMu.Unlock() | |||
| 251 | + | require.Equal(t, pickerMovePane, storedIntent) | |||
| 252 | + | require.Equal(t, source, storedSource) | |||
| 253 | + | require.True(t, ok) | |||
| 254 | + | require.Equal(t, targetSession.id, selected.Session) | |||
| 255 | + | require.Equal(t, targetSession.incarnation, selected.Incarnation) | |||
| 256 | + | require.Equal(t, targetSession.name, selected.Name) | |||
| 257 | + | require.Equal(t, domain.TabStableID(targetTab.stableID), selected.TabID) | |||
| 258 | + | require.Equal(t, 0, selected.TabIndex) | |||
| 259 | + | require.Nil(t, selected.ExpectedCreatedAt) | |||
| 260 | + | rc := targetSession.renderCoordinator() | |||
| 261 | + | require.NotNil(t, rc) | |||
| 262 | + | rc.mu.Lock() | |||
| 263 | + | subscription, subscribed := rc.previewWakes[ac] | |||
| 264 | + | rc.mu.Unlock() | |||
| 265 | + | require.True(t, subscribed) | |||
| 266 | + | require.Equal(t, generation, subscription.generation) | |||
| 267 | + | ||||
| 268 | + | d.enterPicker(sourceSession, ac) | |||
| 269 | + | ||||
| 270 | + | rc.mu.Lock() | |||
| 271 | + | _, subscribed = rc.previewWakes[ac] | |||
| 272 | + | rc.mu.Unlock() | |||
| 273 | + | require.False(t, subscribed, "picker replacement must retire the prior cross-session preview subscription") | |||
| 274 | + | ac.overlays.pickerMu.Lock() | |||
| 275 | + | require.NotNil(t, ac.overlays.picker) | |||
| 276 | + | require.Equal(t, pickerNavigate, ac.overlays.pickerIntent) | |||
| 277 | + | require.Equal(t, moveSourceLocator{}, ac.overlays.pickerSource) | |||
| 278 | + | ac.overlays.pickerMu.Unlock() | |||
| 279 | + | ||||
| 280 | + | d.closePicker(ac) | |||
| 281 | + | ac.overlays.pickerMu.Lock() | |||
| 282 | + | require.Nil(t, ac.overlays.picker) | |||
| 283 | + | require.Nil(t, ac.overlays.pickerPreview) | |||
| 284 | + | ac.overlays.pickerMu.Unlock() | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing.T) { | |||
| 288 | + | p1, release1 := newBlockingPTY(t) | |||
| 289 | + | p2, release2 := newBlockingPTY(t) | |||
| 290 | + | p3, release3 := newBlockingPTY(t) | |||
| 291 | + | defer release1() | |||
| 292 | + | defer release2() | |||
| 293 | + | defer release3() | |||
| 294 | + | d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1) | |||
| 295 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |||
| 296 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 297 | + | d.sessions[sourceSession.id] = sourceSession | |||
| 298 | + | target := &session{ | |||
| 299 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1, | |||
| 300 | + | ctx: sourceSession.ctx, cancel: func() {}, | |||
| 301 | + | tabs: []*tab{ | |||
| 302 | + | newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel), | |||
| 303 | + | newTestTabWithContext(p3, sourceSession.ctx, sourceSession.cancel), | |||
| 304 | + | }, | |||
| 305 | + | } | |||
| 306 | + | target.tabs[0].stableID, target.tabs[1].stableID = "first", "active" | |||
| 307 | + | d.sessions[target.id] = target | |||
| 308 | + | source := moveSourceLocator{ | |||
| 309 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |||
| 310 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source)) | |||
| 314 | + | ||||
| 315 | + | ac.overlays.pickerMu.Lock() | |||
| 316 | + | selected, ok := ac.overlays.picker.Selected() | |||
| 317 | + | require.Same(t, target.tabs[1], ac.overlays.pickerPreview) | |||
| 318 | + | ac.overlays.pickerMu.Unlock() | |||
| 319 | + | require.True(t, ok) | |||
| 320 | + | require.Equal(t, target.id, selected.Session) | |||
| 321 | + | require.Equal(t, target.incarnation, selected.Incarnation) | |||
| 322 | + | require.Equal(t, target.name, selected.Name) | |||
| 323 | + | require.Equal(t, -1, selected.TabIndex) | |||
| 324 | + | require.Nil(t, selected.ExpectedCreatedAt) | |||
| 325 | + | require.Equal(t, 1, activeTabIndex(target), "preview must not mutate destination focus") | |||
| 326 | + | d.closePicker(ac) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||
| 330 | + | t.Run("pane destination stable tab", func(t *testing.T) { | |||
| 331 | + | d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1) | |||
| 332 | + | defer releaseAll(sourceReleases) | |||
| 333 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |||
| 334 | + | sourceSession.tabs[0].stableID = "source-tab" | |||
| 335 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 336 | + | d.sessions[sourceSession.id] = sourceSession | |||
| 337 | + | ||||
| 338 | + | firstPTY, releaseFirst := newBlockingPTY(t) | |||
| 339 | + | defer releaseFirst() | |||
| 340 | + | secondPTY, releaseSecond := newBlockingPTY(t) | |||
| 341 | + | defer releaseSecond() | |||
| 342 | + | firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel) | |||
| 343 | + | secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel) | |||
| 344 | + | firstTab.stableID, secondTab.stableID = "first", "selected" | |||
| 345 | + | destination := &session{ | |||
| 346 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |||
| 347 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}, | |||
| 348 | + | } | |||
| 349 | + | d.sessions[destination.id] = destination | |||
| 350 | + | source := moveSourceLocator{ | |||
| 351 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |||
| 352 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source)) | |||
| 356 | + | ac.overlays.pickerMu.Lock() | |||
| 357 | + | ac.overlays.picker.Down() | |||
| 358 | + | before, ok := ac.overlays.picker.Selected() | |||
| 359 | + | ac.overlays.pickerMu.Unlock() | |||
| 360 | + | require.True(t, ok) | |||
| 361 | + | require.Equal(t, domain.TabStableID("selected"), before.TabID) | |||
| 362 | + | ||||
| 363 | + | destination.mu.Lock() | |||
| 364 | + | destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0] | |||
| 365 | + | destination.mu.Unlock() | |||
| 366 | + | d.refreshPicker(ac) | |||
| 367 | + | ||||
| 368 | + | ac.overlays.pickerMu.Lock() | |||
| 369 | + | after, ok := ac.overlays.picker.Selected() | |||
| 370 | + | ac.overlays.pickerMu.Unlock() | |||
| 371 | + | require.True(t, ok) | |||
| 372 | + | require.Equal(t, before.Session, after.Session) | |||
| 373 | + | require.Equal(t, before.Incarnation, after.Incarnation) | |||
| 374 | + | require.Equal(t, before.TabID, after.TabID) | |||
| 375 | + | require.Equal(t, 0, after.TabIndex, "mutable index follows the preserved stable tab") | |||
| 376 | + | d.closePicker(ac) | |||
| 377 | + | }) | |||
| 378 | + | ||||
| 379 | + | t.Run("tab destination session", func(t *testing.T) { | |||
| 380 | + | d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1) | |||
| 381 | + | defer releaseAll(sourceReleases) | |||
| 382 | + | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |||
| 383 | + | sourceSession.tabs[0].stableID = "source-tab" | |||
| 384 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 385 | + | d.sessions[sourceSession.id] = sourceSession | |||
| 386 | + | ||||
| 387 | + | releases := make([]func(), 0, 2) | |||
| 388 | + | for i, destination := range []struct { | |||
| 389 | + | id domain.SessionID | |||
| 390 | + | name string | |||
| 391 | + | }{{id: "first", name: "0-first"}, {id: "selected", name: "1-selected"}} { | |||
| 392 | + | pty, release := newBlockingPTY(t) | |||
| 393 | + | releases = append(releases, release) | |||
| 394 | + | tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel) | |||
| 395 | + | tb.stableID = string(destination.id) + "-tab" | |||
| 396 | + | d.sessions[destination.id] = &session{ | |||
| 397 | + | id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true, | |||
| 398 | + | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}, | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | defer releaseAll(releases) | |||
| 402 | + | source := moveSourceLocator{ | |||
| 403 | + | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |||
| 404 | + | TabID: domain.TabStableID(sourceSession.tabs[0].stableID), | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source)) | |||
| 408 | + | ac.overlays.pickerMu.Lock() | |||
| 409 | + | ac.overlays.picker.Down() | |||
| 410 | + | before, ok := ac.overlays.picker.Selected() | |||
| 411 | + | ac.overlays.pickerMu.Unlock() | |||
| 412 | + | require.True(t, ok) | |||
| 413 | + | require.Equal(t, domain.SessionID("selected"), before.Session) | |||
| 414 | + | ||||
| 415 | + | d.refreshPicker(ac) | |||
| 416 | + | ||||
| 417 | + | ac.overlays.pickerMu.Lock() | |||
| 418 | + | after, ok := ac.overlays.picker.Selected() | |||
| 419 | + | ac.overlays.pickerMu.Unlock() | |||
| 420 | + | require.True(t, ok) | |||
| 421 | + | require.Equal(t, before, after) | |||
| 422 | + | d.closePicker(ac) | |||
| 423 | + | }) | |||
| 424 | + | } | |||
| 425 | + | ||||
| 133 | func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | 426 | func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | ||
| 134 | p, release := newBlockingPTY(t) | 427 | p, release := newBlockingPTY(t) | ||
| 135 | defer release() | 428 | defer release() | ||
| @@ -147,7 +440,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||||
| 147 | break | 440 | break | ||
| 148 | } | 441 | } | ||
| 149 | } | 442 | } | ||
| 150 | - | model := picker.New([]picker.SessionView{originalView}, original.id, 0) | 443 | + | model := picker.New([]picker.SessionView{originalView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: original.id, TabID: originalView.Tabs[0].TabID}}) |
| 151 | target, ok := model.Selected() | 444 | target, ok := model.Selected() | ||
| 152 | require.True(t, ok) | 445 | require.True(t, ok) | ||
| 153 | 446 | ||||
| @@ -193,7 +486,10 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |||||
| 193 | views, _ := d.pickerViews(sess) | 486 | views, _ := d.pickerViews(sess) | ||
| 194 | 487 | ||||
| 195 | require.Len(t, views, 1) | 488 | require.Len(t, views, 1) | ||
| 196 | - | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs) | 489 | + | require.Equal(t, []picker.TabEntry{ |
| 490 | + | {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"}, | |||
| 491 | + | {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)", Attention: true}, | |||
| 492 | + | }, views[0].Tabs) | |||
| 197 | } | 493 | } | ||
| 198 | 494 | ||||
| 199 | func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | 495 | func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | ||
| @@ -217,7 +513,10 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) { | |||||
| 217 | views, _ := d.pickerViews(sess) | 513 | views, _ := d.pickerViews(sess) | ||
| 218 | 514 | ||||
| 219 | require.Len(t, views, 1) | 515 | require.Len(t, views, 1) | ||
| 220 | - | require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs) | 516 | + | require.Equal(t, []picker.TabEntry{ |
| 517 | + | {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"}, | |||
| 518 | + | {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)"}, | |||
| 519 | + | }, views[0].Tabs) | |||
| 221 | } | 520 | } | ||
| 222 | 521 | ||||
| 223 | func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | 522 | func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | ||
| @@ -879,7 +1178,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||||
| 879 | pickerModel: picker.New([]picker.SessionView{ | 1178 | pickerModel: picker.New([]picker.SessionView{ | ||
| 880 | {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0}, | 1179 | {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0}, | ||
| 881 | {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | 1180 | {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | ||
| 882 | - | }, "selected", 0), | 1181 | + | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}), |
| 883 | } | 1182 | } | ||
| 884 | 1183 | ||||
| 885 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | 1184 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | ||
| @@ -908,7 +1207,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||||
| 908 | } | 1207 | } | ||
| 909 | 1208 | ||||
| 910 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | 1209 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | ||
| 911 | - | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | 1210 | + | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) |
| 912 | state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles} | 1211 | state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles} | ||
| 913 | cases := []struct { | 1212 | cases := []struct { | ||
| 914 | name string | 1213 | name string | ||
| @@ -1160,6 +1459,48 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t | |||||
| 1160 | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | 1459 | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | ||
| 1161 | } | 1460 | } | ||
| 1162 | 1461 | ||||
| 1462 | + | func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) { | |||
| 1463 | + | d, current, ac, _, currentReleases := newManualTabSession(t, 2) | |||
| 1464 | + | defer releaseAll(currentReleases) | |||
| 1465 | + | current.id, current.name, current.active = "current", "z-current", 1 | |||
| 1466 | + | current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active" | |||
| 1467 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 1468 | + | d.sessions[current.id] = current | |||
| 1469 | + | ||||
| 1470 | + | otherPTY, releaseOther := newBlockingPTY(t) | |||
| 1471 | + | defer releaseOther() | |||
| 1472 | + | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | |||
| 1473 | + | otherTab.stableID = "other-tab" | |||
| 1474 | + | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |||
| 1475 | + | d.sessions[other.id] = other | |||
| 1476 | + | ||||
| 1477 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |||
| 1478 | + | defer releaseTarget() | |||
| 1479 | + | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |||
| 1480 | + | targetTab.stableID = "target-tab" | |||
| 1481 | + | target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |||
| 1482 | + | d.sessions[target.id] = target | |||
| 1483 | + | ||||
| 1484 | + | d.enterPicker(current, ac) | |||
| 1485 | + | d.handlePickerInput(ac, []byte("k")) | |||
| 1486 | + | d.handlePickerInput(ac, []byte("k")) | |||
| 1487 | + | ac.overlays.pickerMu.Lock() | |||
| 1488 | + | selected, ok := ac.overlays.picker.Selected() | |||
| 1489 | + | ac.overlays.pickerMu.Unlock() | |||
| 1490 | + | require.True(t, ok) | |||
| 1491 | + | require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session") | |||
| 1492 | + | ||||
| 1493 | + | d.handlePickerInput(ac, []byte("x")) | |||
| 1494 | + | ||||
| 1495 | + | ac.overlays.pickerMu.Lock() | |||
| 1496 | + | selected, ok = ac.overlays.picker.Selected() | |||
| 1497 | + | ac.overlays.pickerMu.Unlock() | |||
| 1498 | + | require.True(t, ok) | |||
| 1499 | + | require.Equal(t, current.id, selected.Session) | |||
| 1500 | + | require.Equal(t, domain.TabStableID("current-active"), selected.TabID) | |||
| 1501 | + | require.Equal(t, 1, selected.TabIndex) | |||
| 1502 | + | } | |||
| 1503 | + | ||||
| 1163 | func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) { | 1504 | func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) { | ||
| 1164 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | 1505 | d, from, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 1165 | defer releaseAll(releases) | 1506 | defer releaseAll(releases) | ||
| @@ -1206,6 +1547,12 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||||
| 1206 | p, release := newBlockingPTY(t) | 1547 | p, release := newBlockingPTY(t) | ||
| 1207 | defer release() | 1548 | defer release() | ||
| 1208 | d, from, ac, sends := newManualSessionWithPTYs(t, p) | 1549 | d, from, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1550 | + | otherPTY, releaseOther := newBlockingPTY(t) | |||
| 1551 | + | defer releaseOther() | |||
| 1552 | + | otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel) | |||
| 1553 | + | otherTab.stableID = "other-tab" | |||
| 1554 | + | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |||
| 1555 | + | d.sessions[other.id] = other | |||
| 1209 | cause := errors.New("delete failed") | 1556 | cause := errors.New("delete failed") | ||
| 1210 | store, state := newMockStore(t) | 1557 | store, state := newMockStore(t) | ||
| 1211 | WithStore(t, store)(d) | 1558 | WithStore(t, store)(d) | ||
| @@ -1236,4 +1583,15 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||||
| 1236 | d.mu.Unlock() | 1583 | d.mu.Unlock() | ||
| 1237 | require.True(t, retained, "failed deletion must retain the reserved name") | 1584 | require.True(t, retained, "failed deletion must retain the reserved name") | ||
| 1238 | require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore") | 1585 | require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore") | ||
| 1586 | + | ||||
| 1587 | + | from.mu.Lock() | |||
| 1588 | + | activeTab := from.tabs[from.active] | |||
| 1589 | + | activeTabID := activeTab.stableID | |||
| 1590 | + | from.mu.Unlock() | |||
| 1591 | + | ac.overlays.pickerMu.Lock() | |||
| 1592 | + | selected, selectedOK := ac.overlays.picker.Selected() | |||
| 1593 | + | ac.overlays.pickerMu.Unlock() | |||
| 1594 | + | require.True(t, selectedOK) | |||
| 1595 | + | require.Equal(t, from.id, selected.Session, "failed x refresh resets navigation to the attached session") | |||
| 1596 | + | require.Equal(t, domain.TabStableID(activeTabID), selected.TabID, "failed x refresh follows the attached session's stable active tab") | |||
| 1239 | } | 1597 | } | ||
M internal/usecase/picker/model.go +172 -41
| @@ -17,6 +17,7 @@ const ( | |||
| 17 | 17 | ||
| 18 | 18 | type SessionView struct { | |
| 19 | 19 | ID domain.SessionID | |
| 20 | + | Incarnation domain.IncarnationID | |
| 20 | 21 | Name string | |
| 21 | 22 | TargetName string | |
| 22 | 23 | Tabs []TabEntry | |
| @@ -27,9 +28,33 @@ type SessionView struct { | |||
| 27 | 28 | ||
| 28 | 29 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | |
| 29 | 30 | type TabEntry struct { | |
| 30 | - | Name string // tab display name | |
| 31 | - | Detail string // " (paneTitle)" or "", drawn muted | |
| 32 | - | Attention bool // draw the attention marker right after Name, before Detail | |
| 31 | + | TabID domain.TabStableID // stable tab identity; independent of its current index | |
| 32 | + | Name string // tab display name | |
| 33 | + | Detail string // " (paneTitle)" or "", drawn muted | |
| 34 | + | Attention bool // draw the attention marker right after Name, before Detail | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type SelectionMode uint8 | |
| 38 | + | ||
| 39 | + | const ( | |
| 40 | + | SelectNavigationTab SelectionMode = iota | |
| 41 | + | SelectMovePaneTab | |
| 42 | + | SelectMoveTabSession | |
| 43 | + | ) | |
| 44 | + | ||
| 45 | + | type SourceFilter struct { | |
| 46 | + | Session domain.SessionID | |
| 47 | + | Incarnation domain.IncarnationID | |
| 48 | + | TabID domain.TabStableID | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // SelectionConfig describes which rows are selectable, which stable target | |
| 52 | + | // should remain selected, and which source must not be offered as a move | |
| 53 | + | // destination. | |
| 54 | + | type SelectionConfig struct { | |
| 55 | + | Mode SelectionMode | |
| 56 | + | Current SourceFilter | |
| 57 | + | Source SourceFilter | |
| 33 | 58 | } | |
| 34 | 59 | ||
| 35 | 60 | // RenderStyles are the styles Render uses to draw list rows. The zero value | |
| @@ -59,10 +84,12 @@ func defaultRenderStyles() RenderStyles { | |||
| 59 | 84 | } | |
| 60 | 85 | ||
| 61 | 86 | type Target struct { | |
| 62 | - | Session domain.SessionID | |
| 63 | - | Name string | |
| 64 | - | TabIndex int | |
| 65 | - | Stopped bool | |
| 87 | + | Session domain.SessionID | |
| 88 | + | Incarnation domain.IncarnationID | |
| 89 | + | Name string | |
| 90 | + | TabID domain.TabStableID | |
| 91 | + | TabIndex int | |
| 92 | + | Stopped bool | |
| 66 | 93 | // ExpectedCreatedAt optionally pins this target to a particular named | |
| 67 | 94 | // session lifecycle. Callers that obtain a snapshot outside the daemon use | |
| 68 | 95 | // it to reject a same-name replacement at commit time. | |
| @@ -88,44 +115,62 @@ type Layout struct { | |||
| 88 | 115 | } | |
| 89 | 116 | ||
| 90 | 117 | type Model struct { | |
| 118 | + | mode SelectionMode | |
| 91 | 119 | rows []row | |
| 92 | 120 | selected int | |
| 93 | 121 | } | |
| 94 | 122 | ||
| 123 | + | type rowKind uint8 | |
| 124 | + | ||
| 125 | + | const ( | |
| 126 | + | rowSession rowKind = iota | |
| 127 | + | rowTab | |
| 128 | + | ) | |
| 129 | + | ||
| 130 | + | func (k rowKind) rendersAsHeader() bool { | |
| 131 | + | return k == rowSession | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (k rowKind) selectable(mode SelectionMode) bool { | |
| 135 | + | switch mode { | |
| 136 | + | case SelectNavigationTab, SelectMovePaneTab: | |
| 137 | + | return k == rowTab | |
| 138 | + | case SelectMoveTabSession: | |
| 139 | + | return k == rowSession | |
| 140 | + | default: | |
| 141 | + | return false | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 95 | 145 | type row struct { | |
| 96 | - | header bool | |
| 97 | - | dispName string // display name segment; bold on truecolor | |
| 98 | - | detail string // " (paneTitle)" segment, muted; "" for headers | |
| 99 | - | attention bool // draw the attention marker right after the name, before detail; tab rows only | |
| 100 | - | session domain.SessionID | |
| 146 | + | kind rowKind | |
| 147 | + | dispName string // display name segment; bold on truecolor | |
| 148 | + | detail string // " (paneTitle)" segment, muted; "" for session rows | |
| 149 | + | attention bool // draw the attention marker right after the name, before detail; tab rows only | |
| 150 | + | session domain.SessionID | |
| 151 | + | incarnation domain.IncarnationID | |
| 101 | 152 | // targetName is the named-session lookup name threaded into Target; | |
| 102 | 153 | // distinct from dispName, which is what gets drawn. | |
| 103 | - | targetName string | |
| 104 | - | tabIndex int | |
| 105 | - | stopped bool | |
| 106 | - | expectedCreatedAt *int64 | |
| 154 | + | targetName string | |
| 155 | + | tabID domain.TabStableID | |
| 156 | + | tabIndex int | |
| 157 | + | stopped bool | |
| 158 | + | expectedCreatedAt int64 | |
| 159 | + | hasExpectedCreatedAt bool | |
| 107 | 160 | } | |
| 108 | 161 | ||
| 109 | - | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |
| 110 | - | m := &Model{selected: -1} | |
| 162 | + | func New(sessions []SessionView, config SelectionConfig) *Model { | |
| 163 | + | m := &Model{mode: config.Mode, selected: -1} | |
| 111 | 164 | activeSelection := -1 | |
| 112 | 165 | for _, session := range sessions { | |
| 113 | - | targetName := session.TargetName | |
| 114 | - | if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) { | |
| 115 | - | targetName = session.Name | |
| 116 | - | } | |
| 117 | - | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) | |
| 118 | - | active := session.Active | |
| 119 | - | if active < 0 || active >= len(session.Tabs) { | |
| 120 | - | active = 0 | |
| 121 | - | } | |
| 122 | - | for i, tab := range session.Tabs { | |
| 166 | + | sessionRows := rowsForSession(session, config) | |
| 167 | + | for _, pickerRow := range sessionRows { | |
| 123 | 168 | idx := len(m.rows) | |
| 124 | - | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) | |
| 125 | - | if session.ID == cur && i == curTab { | |
| 169 | + | m.rows = append(m.rows, pickerRow) | |
| 170 | + | if pickerRow.kind.selectable(config.Mode) && selectionMatches(pickerRow, config.Current, config.Mode) { | |
| 126 | 171 | m.selected = idx | |
| 127 | 172 | } | |
| 128 | - | if activeSelection < 0 && i == active { | |
| 173 | + | if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) { | |
| 129 | 174 | activeSelection = idx | |
| 130 | 175 | } | |
| 131 | 176 | } | |
| @@ -134,7 +179,7 @@ func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |||
| 134 | 179 | m.selected = activeSelection | |
| 135 | 180 | } | |
| 136 | 181 | if m.selected < 0 { | |
| 137 | - | m.selected = m.firstLeaf() | |
| 182 | + | m.selected = m.firstSelectable() | |
| 138 | 183 | } | |
| 139 | 184 | if m.selected < 0 && len(m.rows) > 0 { | |
| 140 | 185 | m.selected = 0 | |
| @@ -142,6 +187,81 @@ func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |||
| 142 | 187 | return m | |
| 143 | 188 | } | |
| 144 | 189 | ||
| 190 | + | // rowsForSession is the sole owner of mode-specific destination eligibility. | |
| 191 | + | // The daemon supplies canonical lifecycle/tab snapshots without prefiltering. | |
| 192 | + | func rowsForSession(session SessionView, config SelectionConfig) []row { | |
| 193 | + | if config.Mode != SelectNavigationTab && session.Stopped { | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | if config.Mode == SelectMoveTabSession && (sourceMatchesSession(config.Source, session) || len(session.Tabs) == 0) { | |
| 197 | + | return nil | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | targetName := session.TargetName | |
| 201 | + | if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil { | |
| 202 | + | targetName = "" | |
| 203 | + | } else if targetName == "" { | |
| 204 | + | targetName = session.Name | |
| 205 | + | } | |
| 206 | + | expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt) | |
| 207 | + | common := row{ | |
| 208 | + | session: session.ID, incarnation: session.Incarnation, targetName: targetName, | |
| 209 | + | stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt, | |
| 210 | + | hasExpectedCreatedAt: hasExpectedCreatedAt, | |
| 211 | + | } | |
| 212 | + | header := common | |
| 213 | + | header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1 | |
| 214 | + | rows := []row{header} | |
| 215 | + | for i, tab := range session.Tabs { | |
| 216 | + | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | |
| 217 | + | continue | |
| 218 | + | } | |
| 219 | + | tabRow := common | |
| 220 | + | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | |
| 221 | + | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | |
| 222 | + | rows = append(rows, tabRow) | |
| 223 | + | } | |
| 224 | + | if config.Mode == SelectMovePaneTab && len(rows) == 1 { | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | return rows | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func sourceMatchesSession(source SourceFilter, session SessionView) bool { | |
| 231 | + | if source.Session != session.ID { | |
| 232 | + | return false | |
| 233 | + | } | |
| 234 | + | return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == session.Incarnation | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func sourceMatchesTab(source SourceFilter, session SessionView, tab TabEntry) bool { | |
| 238 | + | return sourceMatchesSession(source, session) && source.TabID == tab.TabID | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func normalizedActive(session SessionView) int { | |
| 242 | + | if session.Active < 0 || session.Active >= len(session.Tabs) { | |
| 243 | + | return 0 | |
| 244 | + | } | |
| 245 | + | return session.Active | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) bool { | |
| 249 | + | if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) { | |
| 250 | + | return false | |
| 251 | + | } | |
| 252 | + | if mode == SelectMoveTabSession { | |
| 253 | + | return pickerRow.kind == rowSession | |
| 254 | + | } | |
| 255 | + | return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func int64Value(value *int64) (int64, bool) { | |
| 259 | + | if value == nil { | |
| 260 | + | return 0, false | |
| 261 | + | } | |
| 262 | + | return *value, true | |
| 263 | + | } | |
| 264 | + | ||
| 145 | 265 | func ChooseLayout(inner domain.Size) Layout { | |
| 146 | 266 | if inner.Cols <= 0 || inner.Rows <= 0 { | |
| 147 | 267 | return Layout{} | |
| @@ -184,10 +304,13 @@ func (m *Model) Selected() (Target, bool) { | |||
| 184 | 304 | return Target{}, false | |
| 185 | 305 | } | |
| 186 | 306 | r := m.rows[m.selected] | |
| 187 | - | if r.header { | |
| 307 | + | if !r.kind.selectable(m.mode) { | |
| 188 | 308 | return Target{}, false | |
| 189 | 309 | } | |
| 190 | - | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true | |
| 310 | + | return Target{ | |
| 311 | + | Session: r.session, Incarnation: r.incarnation, Name: r.targetName, TabID: r.tabID, | |
| 312 | + | TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(), | |
| 313 | + | }, true | |
| 191 | 314 | } | |
| 192 | 315 | ||
| 193 | 316 | func (m *Model) Clone() *Model { | |
| @@ -222,27 +345,35 @@ func (m *Model) move(delta int) { | |||
| 222 | 345 | if m == nil || len(m.rows) == 0 { | |
| 223 | 346 | return | |
| 224 | 347 | } | |
| 225 | - | if m.selected < 0 || m.selected >= len(m.rows) || m.rows[m.selected].header { | |
| 226 | - | m.selected = m.firstLeaf() | |
| 348 | + | if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].kind.selectable(m.mode) { | |
| 349 | + | m.selected = m.firstSelectable() | |
| 227 | 350 | return | |
| 228 | 351 | } | |
| 229 | 352 | for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta { | |
| 230 | - | if !m.rows[i].header { | |
| 353 | + | if m.rows[i].kind.selectable(m.mode) { | |
| 231 | 354 | m.selected = i | |
| 232 | 355 | return | |
| 233 | 356 | } | |
| 234 | 357 | } | |
| 235 | 358 | } | |
| 236 | 359 | ||
| 237 | - | func (m *Model) firstLeaf() int { | |
| 360 | + | func (m *Model) firstSelectable() int { | |
| 238 | 361 | for i, r := range m.rows { | |
| 239 | - | if !r.header { | |
| 362 | + | if r.kind.selectable(m.mode) { | |
| 240 | 363 | return i | |
| 241 | 364 | } | |
| 242 | 365 | } | |
| 243 | 366 | return -1 | |
| 244 | 367 | } | |
| 245 | 368 | ||
| 369 | + | func (r row) expectedCreatedAtPointer() *int64 { | |
| 370 | + | if !r.hasExpectedCreatedAt { | |
| 371 | + | return nil | |
| 372 | + | } | |
| 373 | + | value := r.expectedCreatedAt | |
| 374 | + | return &value | |
| 375 | + | } | |
| 376 | + | ||
| 246 | 377 | // renderList draws each visible row as up to three segments: a name segment | |
| 247 | 378 | // (bold when styles came from a truecolor theme), a base-styled attention | |
| 248 | 379 | // marker right after the name, and a muted detail segment (tab rows only) — | |
| @@ -268,13 +399,13 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 268 | 399 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |
| 269 | 400 | ||
| 270 | 401 | name := r.dispName | |
| 271 | - | if !r.header { | |
| 402 | + | if !r.kind.rendersAsHeader() { | |
| 272 | 403 | name = " " + name | |
| 273 | 404 | } | |
| 274 | 405 | name = ui.TruncateText(name, rect.Width) | |
| 275 | 406 | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) | |
| 276 | 407 | ||
| 277 | - | if r.header { | |
| 408 | + | if r.kind.rendersAsHeader() { | |
| 278 | 409 | if r.stopped { | |
| 279 | 410 | ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base) | |
| 280 | 411 | } | |
| @@ -17,6 +17,7 @@ const ( | |||||
| 17 | 17 | ||||
| 18 | type SessionView struct { | 18 | type SessionView struct { | ||
| 19 | ID domain.SessionID | 19 | ID domain.SessionID | ||
| 20 | + | Incarnation domain.IncarnationID | |||
| 20 | Name string | 21 | Name string | ||
| 21 | TargetName string | 22 | TargetName string | ||
| 22 | Tabs []TabEntry | 23 | Tabs []TabEntry | ||
| @@ -27,9 +28,33 @@ type SessionView struct { | |||||
| 27 | 28 | ||||
| 28 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | 29 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | ||
| 29 | type TabEntry struct { | 30 | type TabEntry struct { | ||
| 30 | - | Name string // tab display name | 31 | + | TabID domain.TabStableID // stable tab identity; independent of its current index |
| 31 | - | Detail string // " (paneTitle)" or "", drawn muted | 32 | + | Name string // tab display name |
| 32 | - | Attention bool // draw the attention marker right after Name, before Detail | 33 | + | Detail string // " (paneTitle)" or "", drawn muted |
| 34 | + | Attention bool // draw the attention marker right after Name, before Detail | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | type SelectionMode uint8 | |||
| 38 | + | ||||
| 39 | + | const ( | |||
| 40 | + | SelectNavigationTab SelectionMode = iota | |||
| 41 | + | SelectMovePaneTab | |||
| 42 | + | SelectMoveTabSession | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | type SourceFilter struct { | |||
| 46 | + | Session domain.SessionID | |||
| 47 | + | Incarnation domain.IncarnationID | |||
| 48 | + | TabID domain.TabStableID | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // SelectionConfig describes which rows are selectable, which stable target | |||
| 52 | + | // should remain selected, and which source must not be offered as a move | |||
| 53 | + | // destination. | |||
| 54 | + | type SelectionConfig struct { | |||
| 55 | + | Mode SelectionMode | |||
| 56 | + | Current SourceFilter | |||
| 57 | + | Source SourceFilter | |||
| 33 | } | 58 | } | ||
| 34 | 59 | ||||
| 35 | // RenderStyles are the styles Render uses to draw list rows. The zero value | 60 | // RenderStyles are the styles Render uses to draw list rows. The zero value | ||
| @@ -59,10 +84,12 @@ func defaultRenderStyles() RenderStyles { | |||||
| 59 | } | 84 | } | ||
| 60 | 85 | ||||
| 61 | type Target struct { | 86 | type Target struct { | ||
| 62 | - | Session domain.SessionID | 87 | + | Session domain.SessionID |
| 63 | - | Name string | 88 | + | Incarnation domain.IncarnationID |
| 64 | - | TabIndex int | 89 | + | Name string |
| 65 | - | Stopped bool | 90 | + | TabID domain.TabStableID |
| 91 | + | TabIndex int | |||
| 92 | + | Stopped bool | |||
| 66 | // ExpectedCreatedAt optionally pins this target to a particular named | 93 | // ExpectedCreatedAt optionally pins this target to a particular named | ||
| 67 | // session lifecycle. Callers that obtain a snapshot outside the daemon use | 94 | // session lifecycle. Callers that obtain a snapshot outside the daemon use | ||
| 68 | // it to reject a same-name replacement at commit time. | 95 | // it to reject a same-name replacement at commit time. | ||
| @@ -88,44 +115,62 @@ type Layout struct { | |||||
| 88 | } | 115 | } | ||
| 89 | 116 | ||||
| 90 | type Model struct { | 117 | type Model struct { | ||
| 118 | + | mode SelectionMode | |||
| 91 | rows []row | 119 | rows []row | ||
| 92 | selected int | 120 | selected int | ||
| 93 | } | 121 | } | ||
| 94 | 122 | ||||
| 123 | + | type rowKind uint8 | |||
| 124 | + | ||||
| 125 | + | const ( | |||
| 126 | + | rowSession rowKind = iota | |||
| 127 | + | rowTab | |||
| 128 | + | ) | |||
| 129 | + | ||||
| 130 | + | func (k rowKind) rendersAsHeader() bool { | |||
| 131 | + | return k == rowSession | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (k rowKind) selectable(mode SelectionMode) bool { | |||
| 135 | + | switch mode { | |||
| 136 | + | case SelectNavigationTab, SelectMovePaneTab: | |||
| 137 | + | return k == rowTab | |||
| 138 | + | case SelectMoveTabSession: | |||
| 139 | + | return k == rowSession | |||
| 140 | + | default: | |||
| 141 | + | return false | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 95 | type row struct { | 145 | type row struct { | ||
| 96 | - | header bool | 146 | + | kind rowKind |
| 97 | - | dispName string // display name segment; bold on truecolor | 147 | + | dispName string // display name segment; bold on truecolor |
| 98 | - | detail string // " (paneTitle)" segment, muted; "" for headers | 148 | + | detail string // " (paneTitle)" segment, muted; "" for session rows |
| 99 | - | attention bool // draw the attention marker right after the name, before detail; tab rows only | 149 | + | attention bool // draw the attention marker right after the name, before detail; tab rows only |
| 100 | - | session domain.SessionID | 150 | + | session domain.SessionID |
| 151 | + | incarnation domain.IncarnationID | |||
| 101 | // targetName is the named-session lookup name threaded into Target; | 152 | // targetName is the named-session lookup name threaded into Target; | ||
| 102 | // distinct from dispName, which is what gets drawn. | 153 | // distinct from dispName, which is what gets drawn. | ||
| 103 | - | targetName string | 154 | + | targetName string |
| 104 | - | tabIndex int | 155 | + | tabID domain.TabStableID |
| 105 | - | stopped bool | 156 | + | tabIndex int |
| 106 | - | expectedCreatedAt *int64 | 157 | + | stopped bool |
| 158 | + | expectedCreatedAt int64 | |||
| 159 | + | hasExpectedCreatedAt bool | |||
| 107 | } | 160 | } | ||
| 108 | 161 | ||||
| 109 | - | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | 162 | + | func New(sessions []SessionView, config SelectionConfig) *Model { |
| 110 | - | m := &Model{selected: -1} | 163 | + | m := &Model{mode: config.Mode, selected: -1} |
| 111 | activeSelection := -1 | 164 | activeSelection := -1 | ||
| 112 | for _, session := range sessions { | 165 | for _, session := range sessions { | ||
| 113 | - | targetName := session.TargetName | 166 | + | sessionRows := rowsForSession(session, config) |
| 114 | - | if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) { | 167 | + | for _, pickerRow := range sessionRows { |
| 115 | - | targetName = session.Name | |||
| 116 | - | } | |||
| 117 | - | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) | |||
| 118 | - | active := session.Active | |||
| 119 | - | if active < 0 || active >= len(session.Tabs) { | |||
| 120 | - | active = 0 | |||
| 121 | - | } | |||
| 122 | - | for i, tab := range session.Tabs { | |||
| 123 | idx := len(m.rows) | 168 | idx := len(m.rows) | ||
| 124 | - | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) | 169 | + | m.rows = append(m.rows, pickerRow) |
| 125 | - | if session.ID == cur && i == curTab { | 170 | + | if pickerRow.kind.selectable(config.Mode) && selectionMatches(pickerRow, config.Current, config.Mode) { |
| 126 | m.selected = idx | 171 | m.selected = idx | ||
| 127 | } | 172 | } | ||
| 128 | - | if activeSelection < 0 && i == active { | 173 | + | if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) { |
| 129 | activeSelection = idx | 174 | activeSelection = idx | ||
| 130 | } | 175 | } | ||
| 131 | } | 176 | } | ||
| @@ -134,7 +179,7 @@ func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |||||
| 134 | m.selected = activeSelection | 179 | m.selected = activeSelection | ||
| 135 | } | 180 | } | ||
| 136 | if m.selected < 0 { | 181 | if m.selected < 0 { | ||
| 137 | - | m.selected = m.firstLeaf() | 182 | + | m.selected = m.firstSelectable() |
| 138 | } | 183 | } | ||
| 139 | if m.selected < 0 && len(m.rows) > 0 { | 184 | if m.selected < 0 && len(m.rows) > 0 { | ||
| 140 | m.selected = 0 | 185 | m.selected = 0 | ||
| @@ -142,6 +187,81 @@ func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |||||
| 142 | return m | 187 | return m | ||
| 143 | } | 188 | } | ||
| 144 | 189 | ||||
| 190 | + | // rowsForSession is the sole owner of mode-specific destination eligibility. | |||
| 191 | + | // The daemon supplies canonical lifecycle/tab snapshots without prefiltering. | |||
| 192 | + | func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 193 | + | if config.Mode != SelectNavigationTab && session.Stopped { | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | if config.Mode == SelectMoveTabSession && (sourceMatchesSession(config.Source, session) || len(session.Tabs) == 0) { | |||
| 197 | + | return nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | targetName := session.TargetName | |||
| 201 | + | if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil { | |||
| 202 | + | targetName = "" | |||
| 203 | + | } else if targetName == "" { | |||
| 204 | + | targetName = session.Name | |||
| 205 | + | } | |||
| 206 | + | expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt) | |||
| 207 | + | common := row{ | |||
| 208 | + | session: session.ID, incarnation: session.Incarnation, targetName: targetName, | |||
| 209 | + | stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt, | |||
| 210 | + | hasExpectedCreatedAt: hasExpectedCreatedAt, | |||
| 211 | + | } | |||
| 212 | + | header := common | |||
| 213 | + | header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1 | |||
| 214 | + | rows := []row{header} | |||
| 215 | + | for i, tab := range session.Tabs { | |||
| 216 | + | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | |||
| 217 | + | continue | |||
| 218 | + | } | |||
| 219 | + | tabRow := common | |||
| 220 | + | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | |||
| 221 | + | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | |||
| 222 | + | rows = append(rows, tabRow) | |||
| 223 | + | } | |||
| 224 | + | if config.Mode == SelectMovePaneTab && len(rows) == 1 { | |||
| 225 | + | return nil | |||
| 226 | + | } | |||
| 227 | + | return rows | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func sourceMatchesSession(source SourceFilter, session SessionView) bool { | |||
| 231 | + | if source.Session != session.ID { | |||
| 232 | + | return false | |||
| 233 | + | } | |||
| 234 | + | return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == session.Incarnation | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func sourceMatchesTab(source SourceFilter, session SessionView, tab TabEntry) bool { | |||
| 238 | + | return sourceMatchesSession(source, session) && source.TabID == tab.TabID | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func normalizedActive(session SessionView) int { | |||
| 242 | + | if session.Active < 0 || session.Active >= len(session.Tabs) { | |||
| 243 | + | return 0 | |||
| 244 | + | } | |||
| 245 | + | return session.Active | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) bool { | |||
| 249 | + | if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) { | |||
| 250 | + | return false | |||
| 251 | + | } | |||
| 252 | + | if mode == SelectMoveTabSession { | |||
| 253 | + | return pickerRow.kind == rowSession | |||
| 254 | + | } | |||
| 255 | + | return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func int64Value(value *int64) (int64, bool) { | |||
| 259 | + | if value == nil { | |||
| 260 | + | return 0, false | |||
| 261 | + | } | |||
| 262 | + | return *value, true | |||
| 263 | + | } | |||
| 264 | + | ||||
| 145 | func ChooseLayout(inner domain.Size) Layout { | 265 | func ChooseLayout(inner domain.Size) Layout { | ||
| 146 | if inner.Cols <= 0 || inner.Rows <= 0 { | 266 | if inner.Cols <= 0 || inner.Rows <= 0 { | ||
| 147 | return Layout{} | 267 | return Layout{} | ||
| @@ -184,10 +304,13 @@ func (m *Model) Selected() (Target, bool) { | |||||
| 184 | return Target{}, false | 304 | return Target{}, false | ||
| 185 | } | 305 | } | ||
| 186 | r := m.rows[m.selected] | 306 | r := m.rows[m.selected] | ||
| 187 | - | if r.header { | 307 | + | if !r.kind.selectable(m.mode) { |
| 188 | return Target{}, false | 308 | return Target{}, false | ||
| 189 | } | 309 | } | ||
| 190 | - | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true | 310 | + | return Target{ |
| 311 | + | Session: r.session, Incarnation: r.incarnation, Name: r.targetName, TabID: r.tabID, | |||
| 312 | + | TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(), | |||
| 313 | + | }, true | |||
| 191 | } | 314 | } | ||
| 192 | 315 | ||||
| 193 | func (m *Model) Clone() *Model { | 316 | func (m *Model) Clone() *Model { | ||
| @@ -222,27 +345,35 @@ func (m *Model) move(delta int) { | |||||
| 222 | if m == nil || len(m.rows) == 0 { | 345 | if m == nil || len(m.rows) == 0 { | ||
| 223 | return | 346 | return | ||
| 224 | } | 347 | } | ||
| 225 | - | if m.selected < 0 || m.selected >= len(m.rows) || m.rows[m.selected].header { | 348 | + | if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].kind.selectable(m.mode) { |
| 226 | - | m.selected = m.firstLeaf() | 349 | + | m.selected = m.firstSelectable() |
| 227 | return | 350 | return | ||
| 228 | } | 351 | } | ||
| 229 | for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta { | 352 | for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta { | ||
| 230 | - | if !m.rows[i].header { | 353 | + | if m.rows[i].kind.selectable(m.mode) { |
| 231 | m.selected = i | 354 | m.selected = i | ||
| 232 | return | 355 | return | ||
| 233 | } | 356 | } | ||
| 234 | } | 357 | } | ||
| 235 | } | 358 | } | ||
| 236 | 359 | ||||
| 237 | - | func (m *Model) firstLeaf() int { | 360 | + | func (m *Model) firstSelectable() int { |
| 238 | for i, r := range m.rows { | 361 | for i, r := range m.rows { | ||
| 239 | - | if !r.header { | 362 | + | if r.kind.selectable(m.mode) { |
| 240 | return i | 363 | return i | ||
| 241 | } | 364 | } | ||
| 242 | } | 365 | } | ||
| 243 | return -1 | 366 | return -1 | ||
| 244 | } | 367 | } | ||
| 245 | 368 | ||||
| 369 | + | func (r row) expectedCreatedAtPointer() *int64 { | |||
| 370 | + | if !r.hasExpectedCreatedAt { | |||
| 371 | + | return nil | |||
| 372 | + | } | |||
| 373 | + | value := r.expectedCreatedAt | |||
| 374 | + | return &value | |||
| 375 | + | } | |||
| 376 | + | ||||
| 246 | // renderList draws each visible row as up to three segments: a name segment | 377 | // renderList draws each visible row as up to three segments: a name segment | ||
| 247 | // (bold when styles came from a truecolor theme), a base-styled attention | 378 | // (bold when styles came from a truecolor theme), a base-styled attention | ||
| 248 | // marker right after the name, and a muted detail segment (tab rows only) — | 379 | // marker right after the name, and a muted detail segment (tab rows only) — | ||
| @@ -268,13 +399,13 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 268 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | 399 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 269 | 400 | ||||
| 270 | name := r.dispName | 401 | name := r.dispName | ||
| 271 | - | if !r.header { | 402 | + | if !r.kind.rendersAsHeader() { |
| 272 | name = " " + name | 403 | name = " " + name | ||
| 273 | } | 404 | } | ||
| 274 | name = ui.TruncateText(name, rect.Width) | 405 | name = ui.TruncateText(name, rect.Width) | ||
| 275 | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) | 406 | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) | ||
| 276 | 407 | ||||
| 277 | - | if r.header { | 408 | + | if r.kind.rendersAsHeader() { |
| 278 | if r.stopped { | 409 | if r.stopped { | ||
| 279 | ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base) | 410 | ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base) | ||
| 280 | } | 411 | } | ||
M internal/usecase/picker/model_test.go +237 -26
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "reflect" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| @@ -10,15 +11,26 @@ import ( | |||
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 14 | + | var ( | |
| 15 | + | _ domain.TabStableID = TabEntry{}.TabID | |
| 16 | + | _ domain.TabStableID = Target{}.TabID | |
| 17 | + | _ domain.TabStableID = SourceFilter{}.TabID | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestStableIdentityTypesAreDistinct(t *testing.T) { | |
| 21 | + | require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.PaneStableID]()) | |
| 22 | + | require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.TabID]()) | |
| 23 | + | } | |
| 24 | + | ||
| 13 | 25 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |
| 14 | 26 | m := New([]SessionView{ | |
| 15 | - | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0}, | |
| 16 | - | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0}, | |
| 17 | - | }, "s1", 1) | |
| 27 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "shell", Name: "shell"}, {TabID: "logs", Name: "logs"}}, Active: 0}, | |
| 28 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "api", Name: "api"}}, Active: 0}, | |
| 29 | + | }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "logs"}}) | |
| 18 | 30 | ||
| 19 | 31 | got, ok := m.Selected() | |
| 20 | 32 | require.True(t, ok) | |
| 21 | - | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |
| 33 | + | require.Equal(t, Target{Session: "s1", TabID: "logs", TabIndex: 1}, got) | |
| 22 | 34 | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}) | |
| 23 | 35 | require.Equal(t, 'o', frame.At(0, 0).Rune) | |
| 24 | 36 | require.Equal(t, ' ', frame.At(0, 2).Rune) | |
| @@ -27,12 +39,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |||
| 27 | 39 | } | |
| 28 | 40 | ||
| 29 | 41 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |
| 30 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0) | |
| 42 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}}) | |
| 31 | 43 | got, ok := m.Selected() | |
| 32 | 44 | require.True(t, ok) | |
| 33 | 45 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |
| 34 | 46 | ||
| 35 | - | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0) | |
| 47 | + | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}}) | |
| 36 | 48 | got, ok = m.Selected() | |
| 37 | 49 | require.True(t, ok) | |
| 38 | 50 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | |
| @@ -42,7 +54,7 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |||
| 42 | 54 | m := New([]SessionView{ | |
| 43 | 55 | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0}, | |
| 44 | 56 | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0}, | |
| 45 | - | }, "s1", 0) | |
| 57 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |
| 46 | 58 | ||
| 47 | 59 | m.Up() | |
| 48 | 60 | got, ok := m.Selected() | |
| @@ -66,6 +78,205 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |||
| 66 | 78 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header") | |
| 67 | 79 | } | |
| 68 | 80 | ||
| 81 | + | func TestModelConstructsTargetsForSelectionModes(t *testing.T) { | |
| 82 | + | incarnationOne := domain.IncarnationID{1} | |
| 83 | + | incarnationTwo := domain.IncarnationID{2} | |
| 84 | + | sessions := []SessionView{ | |
| 85 | + | {ID: "s1", Name: "one", TargetName: "one", Incarnation: incarnationOne, Tabs: []TabEntry{{TabID: "t1", Name: "shell"}, {TabID: "t2", Name: "logs"}}, Active: 1}, | |
| 86 | + | {ID: "s2", Name: "two", TargetName: "two", Incarnation: incarnationTwo, Tabs: []TabEntry{{TabID: "t3", Name: "api"}}, Active: 0}, | |
| 87 | + | } | |
| 88 | + | tests := []struct { | |
| 89 | + | name string | |
| 90 | + | config SelectionConfig | |
| 91 | + | want Target | |
| 92 | + | }{ | |
| 93 | + | { | |
| 94 | + | name: "navigation selects tab row", | |
| 95 | + | config: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "t2"}}, | |
| 96 | + | want: Target{Session: "s1", Incarnation: incarnationOne, TabID: "t2", TabIndex: 1}, | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | name: "move pane selects eligible tab row", | |
| 100 | + | config: SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}, Source: SourceFilter{Session: "s1", TabID: "t2"}}, | |
| 101 | + | want: Target{Session: "s1", Incarnation: incarnationOne, Name: "one", TabID: "t1", TabIndex: 0}, | |
| 102 | + | }, | |
| 103 | + | { | |
| 104 | + | name: "move tab selects destination session header", | |
| 105 | + | config: SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s2"}, Source: SourceFilter{Session: "s1"}}, | |
| 106 | + | want: Target{Session: "s2", Incarnation: incarnationTwo, Name: "two", TabIndex: -1}, | |
| 107 | + | }, | |
| 108 | + | } | |
| 109 | + | for _, tt := range tests { | |
| 110 | + | t.Run(tt.name, func(t *testing.T) { | |
| 111 | + | model := New(sessions, tt.config) | |
| 112 | + | ||
| 113 | + | got, ok := model.Selected() | |
| 114 | + | require.True(t, ok) | |
| 115 | + | require.Equal(t, tt.want, got) | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestModelMoveModesApplyExactDestinationFiltering(t *testing.T) { | |
| 121 | + | incarnation := domain.IncarnationID{1} | |
| 122 | + | sessions := []SessionView{ | |
| 123 | + | {ID: "source", Incarnation: incarnation, Name: "source", Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "other-tab", Name: "other"}}}, | |
| 124 | + | {ID: "only-source", Incarnation: incarnation, Name: "only-source", Tabs: []TabEntry{{TabID: "only-tab", Name: "only"}}}, | |
| 125 | + | {ID: "destination", Incarnation: incarnation, Name: "destination", Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}}, | |
| 126 | + | {ID: "empty", Incarnation: incarnation, Name: "empty"}, | |
| 127 | + | {ID: "stopped", Incarnation: incarnation, Name: "stopped", Stopped: true, Tabs: []TabEntry{{TabID: "stopped-tab", Name: "stopped"}}}, | |
| 128 | + | } | |
| 129 | + | type rowIdentity struct { | |
| 130 | + | kind rowKind | |
| 131 | + | session domain.SessionID | |
| 132 | + | tab domain.TabStableID | |
| 133 | + | } | |
| 134 | + | identities := func(model *Model) []rowIdentity { | |
| 135 | + | got := make([]rowIdentity, 0, len(model.rows)) | |
| 136 | + | for _, pickerRow := range model.rows { | |
| 137 | + | got = append(got, rowIdentity{kind: pickerRow.kind, session: pickerRow.session, tab: pickerRow.tabID}) | |
| 138 | + | } | |
| 139 | + | return got | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{ | |
| 143 | + | Session: "only-source", Incarnation: incarnation, TabID: "only-tab", | |
| 144 | + | }}) | |
| 145 | + | require.Equal(t, []rowIdentity{ | |
| 146 | + | {kind: rowSession, session: "source"}, | |
| 147 | + | {kind: rowTab, session: "source", tab: "source-tab"}, | |
| 148 | + | {kind: rowTab, session: "source", tab: "other-tab"}, | |
| 149 | + | {kind: rowSession, session: "destination"}, | |
| 150 | + | {kind: rowTab, session: "destination", tab: "destination-tab"}, | |
| 151 | + | }, identities(paneModel)) | |
| 152 | + | ||
| 153 | + | tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{ | |
| 154 | + | Session: "source", Incarnation: incarnation, | |
| 155 | + | }}) | |
| 156 | + | require.Equal(t, []rowIdentity{ | |
| 157 | + | {kind: rowSession, session: "only-source"}, | |
| 158 | + | {kind: rowTab, session: "only-source", tab: "only-tab"}, | |
| 159 | + | {kind: rowSession, session: "destination"}, | |
| 160 | + | {kind: rowTab, session: "destination", tab: "destination-tab"}, | |
| 161 | + | }, identities(tabModel)) | |
| 162 | + | ||
| 163 | + | replacementIncarnation := domain.IncarnationID{2} | |
| 164 | + | replacementModel := New([]SessionView{ | |
| 165 | + | {ID: "same", Incarnation: incarnation, Tabs: []TabEntry{{TabID: "same-tab"}}}, | |
| 166 | + | {ID: "same", Incarnation: replacementIncarnation, Tabs: []TabEntry{{TabID: "same-tab"}}}, | |
| 167 | + | }, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{ | |
| 168 | + | Session: "same", Incarnation: incarnation, TabID: "same-tab", | |
| 169 | + | }}) | |
| 170 | + | require.Len(t, replacementModel.rows, 2, "only the exact source lifecycle is filtered") | |
| 171 | + | require.Equal(t, replacementIncarnation, replacementModel.rows[0].incarnation) | |
| 172 | + | require.Equal(t, replacementIncarnation, replacementModel.rows[1].incarnation) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestRowKindDefinesRenderingAndSelectability(t *testing.T) { | |
| 176 | + | tests := []struct { | |
| 177 | + | name string | |
| 178 | + | kind rowKind | |
| 179 | + | mode SelectionMode | |
| 180 | + | header bool | |
| 181 | + | selectable bool | |
| 182 | + | }{ | |
| 183 | + | {name: "navigation session", kind: rowSession, mode: SelectNavigationTab, header: true}, | |
| 184 | + | {name: "navigation tab", kind: rowTab, mode: SelectNavigationTab, selectable: true}, | |
| 185 | + | {name: "move pane session", kind: rowSession, mode: SelectMovePaneTab, header: true}, | |
| 186 | + | {name: "move pane tab", kind: rowTab, mode: SelectMovePaneTab, selectable: true}, | |
| 187 | + | {name: "move tab session", kind: rowSession, mode: SelectMoveTabSession, header: true, selectable: true}, | |
| 188 | + | {name: "move tab tab", kind: rowTab, mode: SelectMoveTabSession}, | |
| 189 | + | } | |
| 190 | + | for _, tt := range tests { | |
| 191 | + | t.Run(tt.name, func(t *testing.T) { | |
| 192 | + | require.Equal(t, tt.header, tt.kind.rendersAsHeader()) | |
| 193 | + | require.Equal(t, tt.selectable, tt.kind.selectable(tt.mode)) | |
| 194 | + | }) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestModelMovePaneFallsBackToFirstEligibleTab(t *testing.T) { | |
| 199 | + | model := New([]SessionView{ | |
| 200 | + | {ID: "source", Name: "source", Active: 0, Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "sibling", Name: "sibling"}}}, | |
| 201 | + | {ID: "destination", Name: "destination", Active: 0, Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}}, | |
| 202 | + | }, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{Session: "source", TabID: "source-tab"}}) | |
| 203 | + | ||
| 204 | + | got, ok := model.Selected() | |
| 205 | + | require.True(t, ok) | |
| 206 | + | require.Equal(t, domain.TabStableID("sibling"), got.TabID) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestModelMoveNavigationSkipsRowsNotSelectableForMode(t *testing.T) { | |
| 210 | + | sessions := []SessionView{ | |
| 211 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "t1", Name: "one"}}}, | |
| 212 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "t2", Name: "two"}}}, | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}}) | |
| 216 | + | paneModel.Down() | |
| 217 | + | got, ok := paneModel.Selected() | |
| 218 | + | require.True(t, ok) | |
| 219 | + | require.Equal(t, domain.TabStableID("t2"), got.TabID, "move-pane navigation skips the second session header") | |
| 220 | + | ||
| 221 | + | tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s1"}}) | |
| 222 | + | tabModel.Down() | |
| 223 | + | got, ok = tabModel.Selected() | |
| 224 | + | require.True(t, ok) | |
| 225 | + | require.Equal(t, domain.SessionID("s2"), got.Session, "move-tab navigation skips tab rows") | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestModelSelectedKeepsImmutableLifecycleAndStableTabAcrossRefresh(t *testing.T) { | |
| 229 | + | createdAt := int64(41) | |
| 230 | + | views := []SessionView{{ | |
| 231 | + | ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &createdAt, | |
| 232 | + | Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "stable", Name: "stable"}}, Active: 1, | |
| 233 | + | }} | |
| 234 | + | model := New(views, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "same-id", TabID: "stable"}}) | |
| 235 | + | ||
| 236 | + | views[0].Incarnation = domain.IncarnationID{9} | |
| 237 | + | views[0].TargetName = "replacement" | |
| 238 | + | views[0].Tabs[1].TabID = "replacement-tab" | |
| 239 | + | createdAt = 99 | |
| 240 | + | got, ok := model.Selected() | |
| 241 | + | require.True(t, ok) | |
| 242 | + | require.Equal(t, Target{ | |
| 243 | + | Session: "same-id", Incarnation: domain.IncarnationID{1}, Name: "same-name", TabID: "stable", TabIndex: 1, | |
| 244 | + | ExpectedCreatedAt: int64Pointer(41), | |
| 245 | + | }, got, "model target remains bound to the lifecycle captured during construction") | |
| 246 | + | ||
| 247 | + | refreshed := New([]SessionView{{ | |
| 248 | + | ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1}, | |
| 249 | + | Tabs: []TabEntry{{TabID: "stable", Name: "stable"}, {TabID: "first", Name: "first"}}, Active: 1, | |
| 250 | + | }}, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: got.Session, TabID: got.TabID}}) | |
| 251 | + | got, ok = refreshed.Selected() | |
| 252 | + | require.True(t, ok) | |
| 253 | + | require.Equal(t, domain.TabStableID("stable"), got.TabID) | |
| 254 | + | require.Equal(t, 0, got.TabIndex, "mutable index follows the stable tab after refresh") | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestModelClonePreservesModeAndImmutableTarget(t *testing.T) { | |
| 258 | + | createdAt := int64(7) | |
| 259 | + | model := New([]SessionView{{ | |
| 260 | + | ID: "s", Name: "session", TargetName: "session", Incarnation: domain.IncarnationID{3}, ExpectedCreatedAt: &createdAt, | |
| 261 | + | Tabs: []TabEntry{{TabID: "tab", Name: "tab"}}, | |
| 262 | + | }}, SelectionConfig{Mode: SelectMovePaneTab}) | |
| 263 | + | clone := model.Clone() | |
| 264 | + | ||
| 265 | + | require.Equal(t, SelectMovePaneTab, clone.mode) | |
| 266 | + | got, ok := clone.Selected() | |
| 267 | + | require.True(t, ok) | |
| 268 | + | *got.ExpectedCreatedAt = 100 | |
| 269 | + | gotAgain, ok := clone.Selected() | |
| 270 | + | require.True(t, ok) | |
| 271 | + | require.Equal(t, int64(7), *gotAgain.ExpectedCreatedAt, "returned targets cannot mutate the cloned model locator") | |
| 272 | + | require.Equal(t, domain.TabStableID("tab"), gotAgain.TabID) | |
| 273 | + | require.Equal(t, domain.IncarnationID{3}, gotAgain.Incarnation) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func int64Pointer(value int64) *int64 { | |
| 277 | + | return new(value) | |
| 278 | + | } | |
| 279 | + | ||
| 69 | 280 | func TestChooseLayoutResponsiveBoundaries(t *testing.T) { | |
| 70 | 281 | tests := []struct { | |
| 71 | 282 | name string | |
| @@ -113,7 +324,7 @@ func TestChooseLayoutResponsiveBoundaries(t *testing.T) { | |||
| 113 | 324 | } | |
| 114 | 325 | ||
| 115 | 326 | func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | |
| 116 | - | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 327 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 117 | 328 | separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim} | |
| 118 | 329 | styles := RenderStyles{Separator: separator} | |
| 119 | 330 | tests := []struct { | |
| @@ -140,7 +351,7 @@ func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | |||
| 140 | 351 | } | |
| 141 | 352 | ||
| 142 | 353 | func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | |
| 143 | - | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 354 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 144 | 355 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}}) | |
| 145 | 356 | ||
| 146 | 357 | require.Equal(t, ' ', frame.At(22, 10).Rune) | |
| @@ -148,7 +359,7 @@ func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | |||
| 148 | 359 | } | |
| 149 | 360 | ||
| 150 | 361 | func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | |
| 151 | - | m := New(nil, "", 0) | |
| 362 | + | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | |
| 152 | 363 | preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")} | |
| 153 | 364 | ||
| 154 | 365 | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |
| @@ -157,7 +368,7 @@ func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | |||
| 157 | 368 | } | |
| 158 | 369 | ||
| 159 | 370 | func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | |
| 160 | - | m := New(nil, "", 0) | |
| 371 | + | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | |
| 161 | 372 | preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")} | |
| 162 | 373 | ||
| 163 | 374 | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |
| @@ -166,7 +377,7 @@ func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | |||
| 166 | 377 | } | |
| 167 | 378 | ||
| 168 | 379 | func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) { | |
| 169 | - | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, "s", 0) | |
| 380 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 170 | 381 | frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{}) | |
| 171 | 382 | ||
| 172 | 383 | require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune) | |
| @@ -186,17 +397,17 @@ func previewRows(width int, labels string) [][]renderer.Cell { | |||
| 186 | 397 | ||
| 187 | 398 | func TestSelectedMapping(t *testing.T) { | |
| 188 | 399 | m := New([]SessionView{ | |
| 189 | - | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | |
| 190 | - | {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0}, | |
| 191 | - | }, "beta", 1) | |
| 400 | + | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}}, Active: 0}, | |
| 401 | + | {ID: "beta", Name: "beta", Tabs: []TabEntry{{TabID: "two", Name: "two"}, {TabID: "three", Name: "three"}}, Active: 0}, | |
| 402 | + | }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "beta", TabID: "three"}}) | |
| 192 | 403 | ||
| 193 | 404 | got, ok := m.Selected() | |
| 194 | 405 | require.True(t, ok) | |
| 195 | - | require.Equal(t, Target{Session: "beta", TabIndex: 1}, got) | |
| 406 | + | require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got) | |
| 196 | 407 | } | |
| 197 | 408 | ||
| 198 | 409 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |
| 199 | - | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0) | |
| 410 | + | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 200 | 411 | got, ok := m.Selected() | |
| 201 | 412 | require.True(t, ok) | |
| 202 | 413 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | |
| @@ -206,7 +417,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||
| 206 | 417 | } | |
| 207 | 418 | ||
| 208 | 419 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| 209 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | |
| 420 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 210 | 421 | preview := Preview{ | |
| 211 | 422 | Width: 25, | |
| 212 | 423 | Height: 1, | |
| @@ -226,7 +437,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||
| 226 | 437 | } | |
| 227 | 438 | ||
| 228 | 439 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |
| 229 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, "s1", 0) | |
| 440 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 230 | 441 | for range 5 { | |
| 231 | 442 | m.Down() | |
| 232 | 443 | } | |
| @@ -238,7 +449,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||
| 238 | 449 | } | |
| 239 | 450 | ||
| 240 | 451 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |
| 241 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) | |
| 452 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 242 | 453 | ||
| 243 | 454 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 244 | 455 | ||
| @@ -249,7 +460,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||
| 249 | 460 | } | |
| 250 | 461 | ||
| 251 | 462 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| 252 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | |
| 463 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 253 | 464 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | |
| 254 | 465 | ||
| 255 | 466 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | |
| @@ -273,7 +484,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |||
| 273 | 484 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 274 | 485 | {Name: "alpha", Detail: " (running)"}, | |
| 275 | 486 | {Name: "beta", Detail: " (idle)", Attention: true}, | |
| 276 | - | }, Active: 0}}, "s1", 0) | |
| 487 | + | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 277 | 488 | ||
| 278 | 489 | frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles) | |
| 279 | 490 | ||
| @@ -303,7 +514,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |||
| 303 | 514 | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |
| 304 | 515 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 305 | 516 | {Name: "short-name", Detail: " (a very long detail text)"}, | |
| 306 | - | }, Active: 0}}, "s1", 0) | |
| 517 | + | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 307 | 518 | ||
| 308 | 519 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 309 | 520 | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | |
| @@ -320,7 +531,7 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||
| 320 | 531 | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |
| 321 | 532 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | |
| 322 | 533 | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | |
| 323 | - | }, Active: 0}}, "s1", 0) | |
| 534 | + | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 324 | 535 | ||
| 325 | 536 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 326 | 537 | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | |
| @@ -335,7 +546,7 @@ func cell(r rune) renderer.Cell { | |||
| 335 | 546 | } | |
| 336 | 547 | ||
| 337 | 548 | func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | |
| 338 | - | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 549 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 339 | 550 | background := renderer.Style{Foreground: 1, Background: 2} | |
| 340 | 551 | base := renderer.Style{Foreground: 3, Background: 4} | |
| 341 | 552 | selection := renderer.Style{Foreground: 5, Background: 6} | |
| @@ -383,7 +594,7 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) { | |||
| 383 | 594 | model := New([]SessionView{ | |
| 384 | 595 | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | |
| 385 | 596 | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |
| 386 | - | }, "selected", 0) | |
| 597 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |
| 387 | 598 | for _, tt := range tests { | |
| 388 | 599 | t.Run(tt.name, func(t *testing.T) { | |
| 389 | 600 | styles := themeui.Resolve(tt.theme, tt.policy).Styles | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package picker | 1 | package picker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "reflect" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| @@ -10,15 +11,26 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 14 | + | var ( | |||
| 15 | + | _ domain.TabStableID = TabEntry{}.TabID | |||
| 16 | + | _ domain.TabStableID = Target{}.TabID | |||
| 17 | + | _ domain.TabStableID = SourceFilter{}.TabID | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestStableIdentityTypesAreDistinct(t *testing.T) { | |||
| 21 | + | require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.PaneStableID]()) | |||
| 22 | + | require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.TabID]()) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 13 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | 25 | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | ||
| 14 | m := New([]SessionView{ | 26 | m := New([]SessionView{ | ||
| 15 | - | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0}, | 27 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "shell", Name: "shell"}, {TabID: "logs", Name: "logs"}}, Active: 0}, |
| 16 | - | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0}, | 28 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "api", Name: "api"}}, Active: 0}, |
| 17 | - | }, "s1", 1) | 29 | + | }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "logs"}}) |
| 18 | 30 | ||||
| 19 | got, ok := m.Selected() | 31 | got, ok := m.Selected() | ||
| 20 | require.True(t, ok) | 32 | require.True(t, ok) | ||
| 21 | - | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | 33 | + | require.Equal(t, Target{Session: "s1", TabID: "logs", TabIndex: 1}, got) |
| 22 | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}) | 34 | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}) | ||
| 23 | require.Equal(t, 'o', frame.At(0, 0).Rune) | 35 | require.Equal(t, 'o', frame.At(0, 0).Rune) | ||
| 24 | require.Equal(t, ' ', frame.At(0, 2).Rune) | 36 | require.Equal(t, ' ', frame.At(0, 2).Rune) | ||
| @@ -27,12 +39,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |||||
| 27 | } | 39 | } | ||
| 28 | 40 | ||||
| 29 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | 41 | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | ||
| 30 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0) | 42 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}}) |
| 31 | got, ok := m.Selected() | 43 | got, ok := m.Selected() | ||
| 32 | require.True(t, ok) | 44 | require.True(t, ok) | ||
| 33 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | 45 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | ||
| 34 | 46 | ||||
| 35 | - | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0) | 47 | + | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}}) |
| 36 | got, ok = m.Selected() | 48 | got, ok = m.Selected() | ||
| 37 | require.True(t, ok) | 49 | require.True(t, ok) | ||
| 38 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | 50 | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | ||
| @@ -42,7 +54,7 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |||||
| 42 | m := New([]SessionView{ | 54 | m := New([]SessionView{ | ||
| 43 | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0}, | 55 | {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0}, | ||
| 44 | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0}, | 56 | {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0}, | ||
| 45 | - | }, "s1", 0) | 57 | + | }, SelectionConfig{Mode: SelectNavigationTab}) |
| 46 | 58 | ||||
| 47 | m.Up() | 59 | m.Up() | ||
| 48 | got, ok := m.Selected() | 60 | got, ok := m.Selected() | ||
| @@ -66,6 +78,205 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |||||
| 66 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header") | 78 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header") | ||
| 67 | } | 79 | } | ||
| 68 | 80 | ||||
| 81 | + | func TestModelConstructsTargetsForSelectionModes(t *testing.T) { | |||
| 82 | + | incarnationOne := domain.IncarnationID{1} | |||
| 83 | + | incarnationTwo := domain.IncarnationID{2} | |||
| 84 | + | sessions := []SessionView{ | |||
| 85 | + | {ID: "s1", Name: "one", TargetName: "one", Incarnation: incarnationOne, Tabs: []TabEntry{{TabID: "t1", Name: "shell"}, {TabID: "t2", Name: "logs"}}, Active: 1}, | |||
| 86 | + | {ID: "s2", Name: "two", TargetName: "two", Incarnation: incarnationTwo, Tabs: []TabEntry{{TabID: "t3", Name: "api"}}, Active: 0}, | |||
| 87 | + | } | |||
| 88 | + | tests := []struct { | |||
| 89 | + | name string | |||
| 90 | + | config SelectionConfig | |||
| 91 | + | want Target | |||
| 92 | + | }{ | |||
| 93 | + | { | |||
| 94 | + | name: "navigation selects tab row", | |||
| 95 | + | config: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "t2"}}, | |||
| 96 | + | want: Target{Session: "s1", Incarnation: incarnationOne, TabID: "t2", TabIndex: 1}, | |||
| 97 | + | }, | |||
| 98 | + | { | |||
| 99 | + | name: "move pane selects eligible tab row", | |||
| 100 | + | config: SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}, Source: SourceFilter{Session: "s1", TabID: "t2"}}, | |||
| 101 | + | want: Target{Session: "s1", Incarnation: incarnationOne, Name: "one", TabID: "t1", TabIndex: 0}, | |||
| 102 | + | }, | |||
| 103 | + | { | |||
| 104 | + | name: "move tab selects destination session header", | |||
| 105 | + | config: SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s2"}, Source: SourceFilter{Session: "s1"}}, | |||
| 106 | + | want: Target{Session: "s2", Incarnation: incarnationTwo, Name: "two", TabIndex: -1}, | |||
| 107 | + | }, | |||
| 108 | + | } | |||
| 109 | + | for _, tt := range tests { | |||
| 110 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 111 | + | model := New(sessions, tt.config) | |||
| 112 | + | ||||
| 113 | + | got, ok := model.Selected() | |||
| 114 | + | require.True(t, ok) | |||
| 115 | + | require.Equal(t, tt.want, got) | |||
| 116 | + | }) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestModelMoveModesApplyExactDestinationFiltering(t *testing.T) { | |||
| 121 | + | incarnation := domain.IncarnationID{1} | |||
| 122 | + | sessions := []SessionView{ | |||
| 123 | + | {ID: "source", Incarnation: incarnation, Name: "source", Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "other-tab", Name: "other"}}}, | |||
| 124 | + | {ID: "only-source", Incarnation: incarnation, Name: "only-source", Tabs: []TabEntry{{TabID: "only-tab", Name: "only"}}}, | |||
| 125 | + | {ID: "destination", Incarnation: incarnation, Name: "destination", Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}}, | |||
| 126 | + | {ID: "empty", Incarnation: incarnation, Name: "empty"}, | |||
| 127 | + | {ID: "stopped", Incarnation: incarnation, Name: "stopped", Stopped: true, Tabs: []TabEntry{{TabID: "stopped-tab", Name: "stopped"}}}, | |||
| 128 | + | } | |||
| 129 | + | type rowIdentity struct { | |||
| 130 | + | kind rowKind | |||
| 131 | + | session domain.SessionID | |||
| 132 | + | tab domain.TabStableID | |||
| 133 | + | } | |||
| 134 | + | identities := func(model *Model) []rowIdentity { | |||
| 135 | + | got := make([]rowIdentity, 0, len(model.rows)) | |||
| 136 | + | for _, pickerRow := range model.rows { | |||
| 137 | + | got = append(got, rowIdentity{kind: pickerRow.kind, session: pickerRow.session, tab: pickerRow.tabID}) | |||
| 138 | + | } | |||
| 139 | + | return got | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{ | |||
| 143 | + | Session: "only-source", Incarnation: incarnation, TabID: "only-tab", | |||
| 144 | + | }}) | |||
| 145 | + | require.Equal(t, []rowIdentity{ | |||
| 146 | + | {kind: rowSession, session: "source"}, | |||
| 147 | + | {kind: rowTab, session: "source", tab: "source-tab"}, | |||
| 148 | + | {kind: rowTab, session: "source", tab: "other-tab"}, | |||
| 149 | + | {kind: rowSession, session: "destination"}, | |||
| 150 | + | {kind: rowTab, session: "destination", tab: "destination-tab"}, | |||
| 151 | + | }, identities(paneModel)) | |||
| 152 | + | ||||
| 153 | + | tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{ | |||
| 154 | + | Session: "source", Incarnation: incarnation, | |||
| 155 | + | }}) | |||
| 156 | + | require.Equal(t, []rowIdentity{ | |||
| 157 | + | {kind: rowSession, session: "only-source"}, | |||
| 158 | + | {kind: rowTab, session: "only-source", tab: "only-tab"}, | |||
| 159 | + | {kind: rowSession, session: "destination"}, | |||
| 160 | + | {kind: rowTab, session: "destination", tab: "destination-tab"}, | |||
| 161 | + | }, identities(tabModel)) | |||
| 162 | + | ||||
| 163 | + | replacementIncarnation := domain.IncarnationID{2} | |||
| 164 | + | replacementModel := New([]SessionView{ | |||
| 165 | + | {ID: "same", Incarnation: incarnation, Tabs: []TabEntry{{TabID: "same-tab"}}}, | |||
| 166 | + | {ID: "same", Incarnation: replacementIncarnation, Tabs: []TabEntry{{TabID: "same-tab"}}}, | |||
| 167 | + | }, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{ | |||
| 168 | + | Session: "same", Incarnation: incarnation, TabID: "same-tab", | |||
| 169 | + | }}) | |||
| 170 | + | require.Len(t, replacementModel.rows, 2, "only the exact source lifecycle is filtered") | |||
| 171 | + | require.Equal(t, replacementIncarnation, replacementModel.rows[0].incarnation) | |||
| 172 | + | require.Equal(t, replacementIncarnation, replacementModel.rows[1].incarnation) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestRowKindDefinesRenderingAndSelectability(t *testing.T) { | |||
| 176 | + | tests := []struct { | |||
| 177 | + | name string | |||
| 178 | + | kind rowKind | |||
| 179 | + | mode SelectionMode | |||
| 180 | + | header bool | |||
| 181 | + | selectable bool | |||
| 182 | + | }{ | |||
| 183 | + | {name: "navigation session", kind: rowSession, mode: SelectNavigationTab, header: true}, | |||
| 184 | + | {name: "navigation tab", kind: rowTab, mode: SelectNavigationTab, selectable: true}, | |||
| 185 | + | {name: "move pane session", kind: rowSession, mode: SelectMovePaneTab, header: true}, | |||
| 186 | + | {name: "move pane tab", kind: rowTab, mode: SelectMovePaneTab, selectable: true}, | |||
| 187 | + | {name: "move tab session", kind: rowSession, mode: SelectMoveTabSession, header: true, selectable: true}, | |||
| 188 | + | {name: "move tab tab", kind: rowTab, mode: SelectMoveTabSession}, | |||
| 189 | + | } | |||
| 190 | + | for _, tt := range tests { | |||
| 191 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 192 | + | require.Equal(t, tt.header, tt.kind.rendersAsHeader()) | |||
| 193 | + | require.Equal(t, tt.selectable, tt.kind.selectable(tt.mode)) | |||
| 194 | + | }) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestModelMovePaneFallsBackToFirstEligibleTab(t *testing.T) { | |||
| 199 | + | model := New([]SessionView{ | |||
| 200 | + | {ID: "source", Name: "source", Active: 0, Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "sibling", Name: "sibling"}}}, | |||
| 201 | + | {ID: "destination", Name: "destination", Active: 0, Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}}, | |||
| 202 | + | }, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{Session: "source", TabID: "source-tab"}}) | |||
| 203 | + | ||||
| 204 | + | got, ok := model.Selected() | |||
| 205 | + | require.True(t, ok) | |||
| 206 | + | require.Equal(t, domain.TabStableID("sibling"), got.TabID) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestModelMoveNavigationSkipsRowsNotSelectableForMode(t *testing.T) { | |||
| 210 | + | sessions := []SessionView{ | |||
| 211 | + | {ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "t1", Name: "one"}}}, | |||
| 212 | + | {ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "t2", Name: "two"}}}, | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}}) | |||
| 216 | + | paneModel.Down() | |||
| 217 | + | got, ok := paneModel.Selected() | |||
| 218 | + | require.True(t, ok) | |||
| 219 | + | require.Equal(t, domain.TabStableID("t2"), got.TabID, "move-pane navigation skips the second session header") | |||
| 220 | + | ||||
| 221 | + | tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s1"}}) | |||
| 222 | + | tabModel.Down() | |||
| 223 | + | got, ok = tabModel.Selected() | |||
| 224 | + | require.True(t, ok) | |||
| 225 | + | require.Equal(t, domain.SessionID("s2"), got.Session, "move-tab navigation skips tab rows") | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestModelSelectedKeepsImmutableLifecycleAndStableTabAcrossRefresh(t *testing.T) { | |||
| 229 | + | createdAt := int64(41) | |||
| 230 | + | views := []SessionView{{ | |||
| 231 | + | ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &createdAt, | |||
| 232 | + | Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "stable", Name: "stable"}}, Active: 1, | |||
| 233 | + | }} | |||
| 234 | + | model := New(views, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "same-id", TabID: "stable"}}) | |||
| 235 | + | ||||
| 236 | + | views[0].Incarnation = domain.IncarnationID{9} | |||
| 237 | + | views[0].TargetName = "replacement" | |||
| 238 | + | views[0].Tabs[1].TabID = "replacement-tab" | |||
| 239 | + | createdAt = 99 | |||
| 240 | + | got, ok := model.Selected() | |||
| 241 | + | require.True(t, ok) | |||
| 242 | + | require.Equal(t, Target{ | |||
| 243 | + | Session: "same-id", Incarnation: domain.IncarnationID{1}, Name: "same-name", TabID: "stable", TabIndex: 1, | |||
| 244 | + | ExpectedCreatedAt: int64Pointer(41), | |||
| 245 | + | }, got, "model target remains bound to the lifecycle captured during construction") | |||
| 246 | + | ||||
| 247 | + | refreshed := New([]SessionView{{ | |||
| 248 | + | ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1}, | |||
| 249 | + | Tabs: []TabEntry{{TabID: "stable", Name: "stable"}, {TabID: "first", Name: "first"}}, Active: 1, | |||
| 250 | + | }}, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: got.Session, TabID: got.TabID}}) | |||
| 251 | + | got, ok = refreshed.Selected() | |||
| 252 | + | require.True(t, ok) | |||
| 253 | + | require.Equal(t, domain.TabStableID("stable"), got.TabID) | |||
| 254 | + | require.Equal(t, 0, got.TabIndex, "mutable index follows the stable tab after refresh") | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func TestModelClonePreservesModeAndImmutableTarget(t *testing.T) { | |||
| 258 | + | createdAt := int64(7) | |||
| 259 | + | model := New([]SessionView{{ | |||
| 260 | + | ID: "s", Name: "session", TargetName: "session", Incarnation: domain.IncarnationID{3}, ExpectedCreatedAt: &createdAt, | |||
| 261 | + | Tabs: []TabEntry{{TabID: "tab", Name: "tab"}}, | |||
| 262 | + | }}, SelectionConfig{Mode: SelectMovePaneTab}) | |||
| 263 | + | clone := model.Clone() | |||
| 264 | + | ||||
| 265 | + | require.Equal(t, SelectMovePaneTab, clone.mode) | |||
| 266 | + | got, ok := clone.Selected() | |||
| 267 | + | require.True(t, ok) | |||
| 268 | + | *got.ExpectedCreatedAt = 100 | |||
| 269 | + | gotAgain, ok := clone.Selected() | |||
| 270 | + | require.True(t, ok) | |||
| 271 | + | require.Equal(t, int64(7), *gotAgain.ExpectedCreatedAt, "returned targets cannot mutate the cloned model locator") | |||
| 272 | + | require.Equal(t, domain.TabStableID("tab"), gotAgain.TabID) | |||
| 273 | + | require.Equal(t, domain.IncarnationID{3}, gotAgain.Incarnation) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func int64Pointer(value int64) *int64 { | |||
| 277 | + | return new(value) | |||
| 278 | + | } | |||
| 279 | + | ||||
| 69 | func TestChooseLayoutResponsiveBoundaries(t *testing.T) { | 280 | func TestChooseLayoutResponsiveBoundaries(t *testing.T) { | ||
| 70 | tests := []struct { | 281 | tests := []struct { | ||
| 71 | name string | 282 | name string | ||
| @@ -113,7 +324,7 @@ func TestChooseLayoutResponsiveBoundaries(t *testing.T) { | |||||
| 113 | } | 324 | } | ||
| 114 | 325 | ||||
| 115 | func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | 326 | func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | ||
| 116 | - | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | 327 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 117 | separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim} | 328 | separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim} | ||
| 118 | styles := RenderStyles{Separator: separator} | 329 | styles := RenderStyles{Separator: separator} | ||
| 119 | tests := []struct { | 330 | tests := []struct { | ||
| @@ -140,7 +351,7 @@ func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | |||||
| 140 | } | 351 | } | ||
| 141 | 352 | ||||
| 142 | func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | 353 | func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | ||
| 143 | - | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | 354 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 144 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}}) | 355 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}}) | ||
| 145 | 356 | ||||
| 146 | require.Equal(t, ' ', frame.At(22, 10).Rune) | 357 | require.Equal(t, ' ', frame.At(22, 10).Rune) | ||
| @@ -148,7 +359,7 @@ func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | |||||
| 148 | } | 359 | } | ||
| 149 | 360 | ||||
| 150 | func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | 361 | func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | ||
| 151 | - | m := New(nil, "", 0) | 362 | + | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) |
| 152 | preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")} | 363 | preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")} | ||
| 153 | 364 | ||||
| 154 | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | 365 | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | ||
| @@ -157,7 +368,7 @@ func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | |||||
| 157 | } | 368 | } | ||
| 158 | 369 | ||||
| 159 | func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | 370 | func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | ||
| 160 | - | m := New(nil, "", 0) | 371 | + | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) |
| 161 | preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")} | 372 | preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")} | ||
| 162 | 373 | ||||
| 163 | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | 374 | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | ||
| @@ -166,7 +377,7 @@ func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | |||||
| 166 | } | 377 | } | ||
| 167 | 378 | ||||
| 168 | func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) { | 379 | func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) { | ||
| 169 | - | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, "s", 0) | 380 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 170 | frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{}) | 381 | frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{}) | ||
| 171 | 382 | ||||
| 172 | require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune) | 383 | require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune) | ||
| @@ -186,17 +397,17 @@ func previewRows(width int, labels string) [][]renderer.Cell { | |||||
| 186 | 397 | ||||
| 187 | func TestSelectedMapping(t *testing.T) { | 398 | func TestSelectedMapping(t *testing.T) { | ||
| 188 | m := New([]SessionView{ | 399 | m := New([]SessionView{ | ||
| 189 | - | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | 400 | + | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}}, Active: 0}, |
| 190 | - | {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0}, | 401 | + | {ID: "beta", Name: "beta", Tabs: []TabEntry{{TabID: "two", Name: "two"}, {TabID: "three", Name: "three"}}, Active: 0}, |
| 191 | - | }, "beta", 1) | 402 | + | }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "beta", TabID: "three"}}) |
| 192 | 403 | ||||
| 193 | got, ok := m.Selected() | 404 | got, ok := m.Selected() | ||
| 194 | require.True(t, ok) | 405 | require.True(t, ok) | ||
| 195 | - | require.Equal(t, Target{Session: "beta", TabIndex: 1}, got) | 406 | + | require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got) |
| 196 | } | 407 | } | ||
| 197 | 408 | ||||
| 198 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | 409 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | ||
| 199 | - | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0) | 410 | + | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 200 | got, ok := m.Selected() | 411 | got, ok := m.Selected() | ||
| 201 | require.True(t, ok) | 412 | require.True(t, ok) | ||
| 202 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | 413 | require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got) | ||
| @@ -206,7 +417,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||||
| 206 | } | 417 | } | ||
| 207 | 418 | ||||
| 208 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | 419 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | ||
| 209 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | 420 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 210 | preview := Preview{ | 421 | preview := Preview{ | ||
| 211 | Width: 25, | 422 | Width: 25, | ||
| 212 | Height: 1, | 423 | Height: 1, | ||
| @@ -226,7 +437,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||||
| 226 | } | 437 | } | ||
| 227 | 438 | ||||
| 228 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | 439 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | ||
| 229 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, "s1", 0) | 440 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 230 | for range 5 { | 441 | for range 5 { | ||
| 231 | m.Down() | 442 | m.Down() | ||
| 232 | } | 443 | } | ||
| @@ -238,7 +449,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||||
| 238 | } | 449 | } | ||
| 239 | 450 | ||||
| 240 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | 451 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | ||
| 241 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) | 452 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 242 | 453 | ||||
| 243 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | 454 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | ||
| 244 | 455 | ||||
| @@ -249,7 +460,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||||
| 249 | } | 460 | } | ||
| 250 | 461 | ||||
| 251 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | 462 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | ||
| 252 | - | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | 463 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 253 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | 464 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | ||
| 254 | 465 | ||||
| 255 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | 466 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | ||
| @@ -273,7 +484,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |||||
| 273 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | 484 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | ||
| 274 | {Name: "alpha", Detail: " (running)"}, | 485 | {Name: "alpha", Detail: " (running)"}, | ||
| 275 | {Name: "beta", Detail: " (idle)", Attention: true}, | 486 | {Name: "beta", Detail: " (idle)", Attention: true}, | ||
| 276 | - | }, Active: 0}}, "s1", 0) | 487 | + | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 277 | 488 | ||||
| 278 | frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles) | 489 | frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles) | ||
| 279 | 490 | ||||
| @@ -303,7 +514,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) { | |||||
| 303 | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | 514 | func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | ||
| 304 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | 515 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | ||
| 305 | {Name: "short-name", Detail: " (a very long detail text)"}, | 516 | {Name: "short-name", Detail: " (a very long detail text)"}, | ||
| 306 | - | }, Active: 0}}, "s1", 0) | 517 | + | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 307 | 518 | ||||
| 308 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | 519 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | ||
| 309 | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | 520 | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | ||
| @@ -320,7 +531,7 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||||
| 320 | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | 531 | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | ||
| 321 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | 532 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{ | ||
| 322 | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | 533 | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | ||
| 323 | - | }, Active: 0}}, "s1", 0) | 534 | + | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 324 | 535 | ||||
| 325 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | 536 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | ||
| 326 | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | 537 | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | ||
| @@ -335,7 +546,7 @@ func cell(r rune) renderer.Cell { | |||||
| 335 | } | 546 | } | ||
| 336 | 547 | ||||
| 337 | func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | 548 | func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | ||
| 338 | - | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | 549 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 339 | background := renderer.Style{Foreground: 1, Background: 2} | 550 | background := renderer.Style{Foreground: 1, Background: 2} | ||
| 340 | base := renderer.Style{Foreground: 3, Background: 4} | 551 | base := renderer.Style{Foreground: 3, Background: 4} | ||
| 341 | selection := renderer.Style{Foreground: 5, Background: 6} | 552 | selection := renderer.Style{Foreground: 5, Background: 6} | ||
| @@ -383,7 +594,7 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) { | |||||
| 383 | model := New([]SessionView{ | 594 | model := New([]SessionView{ | ||
| 384 | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | 595 | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | ||
| 385 | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | 596 | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | ||
| 386 | - | }, "selected", 0) | 597 | + | }, SelectionConfig{Mode: SelectNavigationTab}) |
| 387 | for _, tt := range tests { | 598 | for _, tt := range tests { | ||
| 388 | t.Run(tt.name, func(t *testing.T) { | 599 | t.Run(tt.name, func(t *testing.T) { | ||
| 389 | styles := themeui.Resolve(tt.theme, tt.policy).Styles | 600 | styles := themeui.Resolve(tt.theme, tt.policy).Styles | ||