fix(palette): validate session picker targets
- commit
- f4d7e2751a3f46abff31e0f0502cf65eac434564
- author
- brice · · 2026-07-15 08:48 UTC
- parent
- 5033bbe
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +138 -48
M internal/usecase/daemon/picker.go +3 -2
| @@ -48,9 +48,10 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 48 | 48 | curTab := 0 | |
| 49 | 49 | for _, s := range sessions { | |
| 50 | 50 | s.mu.Lock() | |
| 51 | - | view := picker.SessionView{ID: s.id, Name: s.name, TargetName: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | |
| 51 | + | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | |
| 52 | 52 | if !s.ephemeral { | |
| 53 | 53 | createdAt := s.createdAt | |
| 54 | + | view.TargetName = s.name | |
| 54 | 55 | view.ExpectedCreatedAt = &createdAt | |
| 55 | 56 | } | |
| 56 | 57 | sessionAttention := false | |
| @@ -468,7 +469,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 468 | 469 | if targetSess == from { | |
| 469 | 470 | targetSess.mu.Lock() | |
| 470 | 471 | defer targetSess.mu.Unlock() | |
| 471 | - | if !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 472 | + | if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 472 | 473 | return nil, nil, nil, false | |
| 473 | 474 | } | |
| 474 | 475 | targetSess.active = target.TabIndex | |
| @@ -48,9 +48,10 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 48 | curTab := 0 | 48 | curTab := 0 | ||
| 49 | for _, s := range sessions { | 49 | for _, s := range sessions { | ||
| 50 | s.mu.Lock() | 50 | s.mu.Lock() | ||
| 51 | - | view := picker.SessionView{ID: s.id, Name: s.name, TargetName: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | 51 | + | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} |
| 52 | if !s.ephemeral { | 52 | if !s.ephemeral { | ||
| 53 | createdAt := s.createdAt | 53 | createdAt := s.createdAt | ||
| 54 | + | view.TargetName = s.name | |||
| 54 | view.ExpectedCreatedAt = &createdAt | 55 | view.ExpectedCreatedAt = &createdAt | ||
| 55 | } | 56 | } | ||
| 56 | sessionAttention := false | 57 | sessionAttention := false | ||
| @@ -468,7 +469,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 468 | if targetSess == from { | 469 | if targetSess == from { | ||
| 469 | targetSess.mu.Lock() | 470 | targetSess.mu.Lock() | ||
| 470 | defer targetSess.mu.Unlock() | 471 | defer targetSess.mu.Unlock() | ||
| 471 | - | if !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | 472 | + | if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { |
| 472 | return nil, nil, nil, false | 473 | return nil, nil, nil, false | ||
| 473 | } | 474 | } | ||
| 474 | targetSess.active = target.TabIndex | 475 | targetSess.active = target.TabIndex | ||
M internal/usecase/daemon/picker_test.go +41 -0
| @@ -94,6 +94,47 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 94 | 94 | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | + | func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |
| 98 | + | p, release := newBlockingPTY(t) | |
| 99 | + | defer release() | |
| 100 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 101 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 102 | + | defer cancel() | |
| 103 | + | original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 104 | + | d.sessions[original.id] = original | |
| 105 | + | ||
| 106 | + | views, _ := d.pickerViews(current) | |
| 107 | + | var originalView picker.SessionView | |
| 108 | + | for _, view := range views { | |
| 109 | + | if view.ID == original.id { | |
| 110 | + | originalView = view | |
| 111 | + | break | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | model := picker.New([]picker.SessionView{originalView}, original.id, 0) | |
| 115 | + | target, ok := model.Selected() | |
| 116 | + | require.True(t, ok) | |
| 117 | + | ||
| 118 | + | delete(d.sessions, original.id) | |
| 119 | + | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 120 | + | d.sessions[replacement.id] = replacement | |
| 121 | + | ||
| 122 | + | require.False(t, d.switchToTarget(current, ac, target)) | |
| 123 | + | require.Same(t, current, ac.currentSession()) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |
| 127 | + | p1, release1 := newBlockingPTY(t) | |
| 128 | + | p2, release2 := newBlockingPTY(t) | |
| 129 | + | defer release1() | |
| 130 | + | defer release2() | |
| 131 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 132 | + | current.client = &attachedClient{} | |
| 133 | + | ||
| 134 | + | require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | |
| 135 | + | require.Equal(t, 0, current.active) | |
| 136 | + | } | |
| 137 | + | ||
| 97 | 138 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |
| 98 | 139 | p1, releasePTY1 := newBlockingPTY(t) | |
| 99 | 140 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -94,6 +94,47 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||||
| 94 | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | 94 | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | ||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | + | func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 98 | + | p, release := newBlockingPTY(t) | |||
| 99 | + | defer release() | |||
| 100 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 101 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |||
| 102 | + | defer cancel() | |||
| 103 | + | original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |||
| 104 | + | d.sessions[original.id] = original | |||
| 105 | + | ||||
| 106 | + | views, _ := d.pickerViews(current) | |||
| 107 | + | var originalView picker.SessionView | |||
| 108 | + | for _, view := range views { | |||
| 109 | + | if view.ID == original.id { | |||
| 110 | + | originalView = view | |||
| 111 | + | break | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | model := picker.New([]picker.SessionView{originalView}, original.id, 0) | |||
| 115 | + | target, ok := model.Selected() | |||
| 116 | + | require.True(t, ok) | |||
| 117 | + | ||||
| 118 | + | delete(d.sessions, original.id) | |||
| 119 | + | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |||
| 120 | + | d.sessions[replacement.id] = replacement | |||
| 121 | + | ||||
| 122 | + | require.False(t, d.switchToTarget(current, ac, target)) | |||
| 123 | + | require.Same(t, current, ac.currentSession()) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |||
| 127 | + | p1, release1 := newBlockingPTY(t) | |||
| 128 | + | p2, release2 := newBlockingPTY(t) | |||
| 129 | + | defer release1() | |||
| 130 | + | defer release2() | |||
| 131 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |||
| 132 | + | current.client = &attachedClient{} | |||
| 133 | + | ||||
| 134 | + | require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | |||
| 135 | + | require.Equal(t, 0, current.active) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 97 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | 138 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | ||
| 98 | p1, releasePTY1 := newBlockingPTY(t) | 139 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 99 | p2, releasePTY2 := newBlockingPTY(t) | 140 | p2, releasePTY2 := newBlockingPTY(t) | ||
M internal/usecase/palette/fuzzy_test.go +67 -43
| @@ -78,53 +78,77 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||
| 78 | 78 | require.Equal(t, []string{"B", "A"}, codes(matches)) | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | - | func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T) { | |
| 81 | + | func TestFuzzyOrdersMixedResults(t *testing.T) { | |
| 82 | 82 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |
| 83 | - | results := []Result{ | |
| 84 | - | NewStoppedSessionResult("work", created), | |
| 85 | - | NewCommandResult(cmd("WORK", "", "Create a workspace")), | |
| 86 | - | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | |
| 87 | - | NewCommandResult(cmd("WQORRK", "", "")), | |
| 88 | - | NewCommandResult(cmd("ZZZ", "", "work tools")), | |
| 83 | + | tests := []struct { | |
| 84 | + | name string | |
| 85 | + | results []Result | |
| 86 | + | query string | |
| 87 | + | wantText []string | |
| 88 | + | wantKinds []ResultKind | |
| 89 | + | wantPositions [][]int | |
| 90 | + | wantActiveIDs map[int]domain.SessionID | |
| 91 | + | }{ | |
| 92 | + | { | |
| 93 | + | name: "command shortcode precedes sessions and description matches", | |
| 94 | + | results: []Result{ | |
| 95 | + | NewStoppedSessionResult("work", created), | |
| 96 | + | NewCommandResult(cmd("WORK", "", "Create a workspace")), | |
| 97 | + | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | |
| 98 | + | NewCommandResult(cmd("WQORRK", "", "")), | |
| 99 | + | NewCommandResult(cmd("ZZZ", "", "work tools")), | |
| 100 | + | }, | |
| 101 | + | query: "work", | |
| 102 | + | wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, | |
| 103 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand}, | |
| 104 | + | wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil}, | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "equivalent sessions sort by normalized text", | |
| 108 | + | results: []Result{ | |
| 109 | + | NewStoppedSessionResult("aBravo", time.Time{}), | |
| 110 | + | NewStoppedSessionResult("aAlpha", time.Time{}), | |
| 111 | + | }, | |
| 112 | + | query: "a", | |
| 113 | + | wantText: []string{"aAlpha", "aBravo"}, | |
| 114 | + | wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession}, | |
| 115 | + | wantPositions: [][]int{{0}, {0}}, | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | name: "kind then normalized text then stable order", | |
| 119 | + | results: []Result{ | |
| 120 | + | NewStoppedSessionResult("aBravo", time.Time{}), | |
| 121 | + | NewStoppedSessionResult("aAlpha", time.Time{}), | |
| 122 | + | NewActiveSessionResult("aZulu", time.Time{}, "z"), | |
| 123 | + | NewActiveSessionResult("aEcho", time.Time{}, "e"), | |
| 124 | + | NewActiveSessionResult("aEcho", time.Time{}, "e2"), | |
| 125 | + | NewCommandResult(cmd("AX", "", "")), | |
| 126 | + | }, | |
| 127 | + | query: "a", | |
| 128 | + | wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, | |
| 129 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession}, | |
| 130 | + | wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}}, | |
| 131 | + | wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"}, | |
| 132 | + | }, | |
| 89 | 133 | } | |
| 90 | 134 | ||
| 91 | - | matches := Fuzzy(results, "work") | |
| 92 | - | require.Equal(t, []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, matchSearchText(matches)) | |
| 93 | - | require.Equal(t, []ResultKind{ | |
| 94 | - | ResultKindCommand, | |
| 95 | - | ResultKindActiveSession, | |
| 96 | - | ResultKindStoppedSession, | |
| 97 | - | ResultKindCommand, | |
| 98 | - | ResultKindCommand, | |
| 99 | - | }, matchKinds(matches)) | |
| 100 | - | require.Equal(t, []int{0, 1, 2, 3}, matches[0].Positions) | |
| 101 | - | require.Equal(t, []int{0, 1, 2, 3}, matches[1].Positions) | |
| 102 | - | require.Empty(t, matches[4].Positions) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | func TestFuzzySortsEquivalentSessionMatchesByNormalizedText(t *testing.T) { | |
| 106 | - | created := time.Time{} | |
| 107 | - | matches := Fuzzy([]Result{ | |
| 108 | - | NewStoppedSessionResult("aBravo", created), | |
| 109 | - | NewStoppedSessionResult("aAlpha", created), | |
| 110 | - | }, "a") | |
| 111 | - | ||
| 112 | - | require.Equal(t, []string{"aAlpha", "aBravo"}, matchSearchText(matches)) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) { | |
| 116 | - | created := time.Time{} | |
| 117 | - | results := []Result{ | |
| 118 | - | NewStoppedSessionResult("aBravo", created), | |
| 119 | - | NewStoppedSessionResult("aAlpha", created), | |
| 120 | - | NewActiveSessionResult("aZulu", created, "z"), | |
| 121 | - | NewActiveSessionResult("aEcho", created, "e"), | |
| 122 | - | NewActiveSessionResult("aEcho", created, "e2"), | |
| 123 | - | NewCommandResult(cmd("AX", "", "")), | |
| 135 | + | for _, tt := range tests { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | matches := Fuzzy(tt.results, tt.query) | |
| 138 | + | require.Equal(t, tt.wantText, matchSearchText(matches)) | |
| 139 | + | require.Equal(t, tt.wantKinds, matchKinds(matches)) | |
| 140 | + | positions := make([][]int, len(matches)) | |
| 141 | + | for i := range matches { | |
| 142 | + | positions[i] = matches[i].Positions | |
| 143 | + | } | |
| 144 | + | require.Equal(t, tt.wantPositions, positions) | |
| 145 | + | for index, wantID := range tt.wantActiveIDs { | |
| 146 | + | gotID, ok := matches[index].Result.SessionID() | |
| 147 | + | require.True(t, ok) | |
| 148 | + | require.Equal(t, wantID, gotID) | |
| 149 | + | } | |
| 150 | + | }) | |
| 124 | 151 | } | |
| 125 | - | ||
| 126 | - | matches := Fuzzy(results, "a") | |
| 127 | - | require.Equal(t, []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, matchSearchText(matches)) | |
| 128 | 152 | } | |
| 129 | 153 | ||
| 130 | 154 | func matchSearchText(matches []Match) []string { | |
| @@ -78,53 +78,77 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||||
| 78 | require.Equal(t, []string{"B", "A"}, codes(matches)) | 78 | require.Equal(t, []string{"B", "A"}, codes(matches)) | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | - | func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T) { | 81 | + | func TestFuzzyOrdersMixedResults(t *testing.T) { |
| 82 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | 82 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | ||
| 83 | - | results := []Result{ | 83 | + | tests := []struct { |
| 84 | - | NewStoppedSessionResult("work", created), | 84 | + | name string |
| 85 | - | NewCommandResult(cmd("WORK", "", "Create a workspace")), | 85 | + | results []Result |
| 86 | - | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | 86 | + | query string |
| 87 | - | NewCommandResult(cmd("WQORRK", "", "")), | 87 | + | wantText []string |
| 88 | - | NewCommandResult(cmd("ZZZ", "", "work tools")), | 88 | + | wantKinds []ResultKind |
| 89 | + | wantPositions [][]int | |||
| 90 | + | wantActiveIDs map[int]domain.SessionID | |||
| 91 | + | }{ | |||
| 92 | + | { | |||
| 93 | + | name: "command shortcode precedes sessions and description matches", | |||
| 94 | + | results: []Result{ | |||
| 95 | + | NewStoppedSessionResult("work", created), | |||
| 96 | + | NewCommandResult(cmd("WORK", "", "Create a workspace")), | |||
| 97 | + | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | |||
| 98 | + | NewCommandResult(cmd("WQORRK", "", "")), | |||
| 99 | + | NewCommandResult(cmd("ZZZ", "", "work tools")), | |||
| 100 | + | }, | |||
| 101 | + | query: "work", | |||
| 102 | + | wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, | |||
| 103 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand}, | |||
| 104 | + | wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil}, | |||
| 105 | + | }, | |||
| 106 | + | { | |||
| 107 | + | name: "equivalent sessions sort by normalized text", | |||
| 108 | + | results: []Result{ | |||
| 109 | + | NewStoppedSessionResult("aBravo", time.Time{}), | |||
| 110 | + | NewStoppedSessionResult("aAlpha", time.Time{}), | |||
| 111 | + | }, | |||
| 112 | + | query: "a", | |||
| 113 | + | wantText: []string{"aAlpha", "aBravo"}, | |||
| 114 | + | wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession}, | |||
| 115 | + | wantPositions: [][]int{{0}, {0}}, | |||
| 116 | + | }, | |||
| 117 | + | { | |||
| 118 | + | name: "kind then normalized text then stable order", | |||
| 119 | + | results: []Result{ | |||
| 120 | + | NewStoppedSessionResult("aBravo", time.Time{}), | |||
| 121 | + | NewStoppedSessionResult("aAlpha", time.Time{}), | |||
| 122 | + | NewActiveSessionResult("aZulu", time.Time{}, "z"), | |||
| 123 | + | NewActiveSessionResult("aEcho", time.Time{}, "e"), | |||
| 124 | + | NewActiveSessionResult("aEcho", time.Time{}, "e2"), | |||
| 125 | + | NewCommandResult(cmd("AX", "", "")), | |||
| 126 | + | }, | |||
| 127 | + | query: "a", | |||
| 128 | + | wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, | |||
| 129 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession}, | |||
| 130 | + | wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}}, | |||
| 131 | + | wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"}, | |||
| 132 | + | }, | |||
| 89 | } | 133 | } | ||
| 90 | 134 | ||||
| 91 | - | matches := Fuzzy(results, "work") | 135 | + | for _, tt := range tests { |
| 92 | - | require.Equal(t, []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, matchSearchText(matches)) | 136 | + | t.Run(tt.name, func(t *testing.T) { |
| 93 | - | require.Equal(t, []ResultKind{ | 137 | + | matches := Fuzzy(tt.results, tt.query) |
| 94 | - | ResultKindCommand, | 138 | + | require.Equal(t, tt.wantText, matchSearchText(matches)) |
| 95 | - | ResultKindActiveSession, | 139 | + | require.Equal(t, tt.wantKinds, matchKinds(matches)) |
| 96 | - | ResultKindStoppedSession, | 140 | + | positions := make([][]int, len(matches)) |
| 97 | - | ResultKindCommand, | 141 | + | for i := range matches { |
| 98 | - | ResultKindCommand, | 142 | + | positions[i] = matches[i].Positions |
| 99 | - | }, matchKinds(matches)) | 143 | + | } |
| 100 | - | require.Equal(t, []int{0, 1, 2, 3}, matches[0].Positions) | 144 | + | require.Equal(t, tt.wantPositions, positions) |
| 101 | - | require.Equal(t, []int{0, 1, 2, 3}, matches[1].Positions) | 145 | + | for index, wantID := range tt.wantActiveIDs { |
| 102 | - | require.Empty(t, matches[4].Positions) | 146 | + | gotID, ok := matches[index].Result.SessionID() |
| 103 | - | } | 147 | + | require.True(t, ok) |
| 104 | - | 148 | + | require.Equal(t, wantID, gotID) | |
| 105 | - | func TestFuzzySortsEquivalentSessionMatchesByNormalizedText(t *testing.T) { | 149 | + | } |
| 106 | - | created := time.Time{} | 150 | + | }) |
| 107 | - | matches := Fuzzy([]Result{ | |||
| 108 | - | NewStoppedSessionResult("aBravo", created), | |||
| 109 | - | NewStoppedSessionResult("aAlpha", created), | |||
| 110 | - | }, "a") | |||
| 111 | - | ||||
| 112 | - | require.Equal(t, []string{"aAlpha", "aBravo"}, matchSearchText(matches)) | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) { | |||
| 116 | - | created := time.Time{} | |||
| 117 | - | results := []Result{ | |||
| 118 | - | NewStoppedSessionResult("aBravo", created), | |||
| 119 | - | NewStoppedSessionResult("aAlpha", created), | |||
| 120 | - | NewActiveSessionResult("aZulu", created, "z"), | |||
| 121 | - | NewActiveSessionResult("aEcho", created, "e"), | |||
| 122 | - | NewActiveSessionResult("aEcho", created, "e2"), | |||
| 123 | - | NewCommandResult(cmd("AX", "", "")), | |||
| 124 | } | 151 | } | ||
| 125 | - | ||||
| 126 | - | matches := Fuzzy(results, "a") | |||
| 127 | - | require.Equal(t, []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, matchSearchText(matches)) | |||
| 128 | } | 152 | } | ||
| 129 | 153 | ||||
| 130 | func matchSearchText(matches []Match) []string { | 154 | func matchSearchText(matches []Match) []string { | ||
M internal/usecase/palette/model.go +1 -1
| @@ -313,7 +313,7 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des | |||
| 313 | 313 | ||
| 314 | 314 | func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) { | |
| 315 | 315 | x, nextHighlight := 0, 0 | |
| 316 | - | prefixWidth := utf8.RuneCountInString(sessionDisplayPrefix) | |
| 316 | + | prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix()) | |
| 317 | 317 | for _, r := range match.Result.DisplayText() { | |
| 318 | 318 | cellStyle := style | |
| 319 | 319 | if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x { | |
| @@ -313,7 +313,7 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des | |||||
| 313 | 313 | ||||
| 314 | func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) { | 314 | func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) { | ||
| 315 | x, nextHighlight := 0, 0 | 315 | x, nextHighlight := 0, 0 | ||
| 316 | - | prefixWidth := utf8.RuneCountInString(sessionDisplayPrefix) | 316 | + | prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix()) |
| 317 | for _, r := range match.Result.DisplayText() { | 317 | for _, r := range match.Result.DisplayText() { | ||
| 318 | cellStyle := style | 318 | cellStyle := style | ||
| 319 | if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x { | 319 | if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x { | ||
M internal/usecase/palette/model_test.go +13 -0
| @@ -366,6 +366,19 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||
| 366 | 366 | require.Equal(t, "Switch to session work ", frameRow(frame, 1)) | |
| 367 | 367 | } | |
| 368 | 368 | ||
| 369 | + | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |
| 370 | + | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))}) | |
| 371 | + | m.Insert('w') | |
| 372 | + | m.Insert('k') | |
| 373 | + | ||
| 374 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |
| 375 | + | ||
| 376 | + | require.Equal(t, "Resume session work ", frameRow(frame, 1)) | |
| 377 | + | require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted") | |
| 378 | + | require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted") | |
| 379 | + | require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted") | |
| 380 | + | } | |
| 381 | + | ||
| 369 | 382 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | |
| 370 | 383 | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | |
| 371 | 384 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | |
| @@ -366,6 +366,19 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||||
| 366 | require.Equal(t, "Switch to session work ", frameRow(frame, 1)) | 366 | require.Equal(t, "Switch to session work ", frameRow(frame, 1)) | ||
| 367 | } | 367 | } | ||
| 368 | 368 | ||||
| 369 | + | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |||
| 370 | + | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))}) | |||
| 371 | + | m.Insert('w') | |||
| 372 | + | m.Insert('k') | |||
| 373 | + | ||||
| 374 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |||
| 375 | + | ||||
| 376 | + | require.Equal(t, "Resume session work ", frameRow(frame, 1)) | |||
| 377 | + | require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted") | |||
| 378 | + | require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted") | |||
| 379 | + | require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted") | |||
| 380 | + | } | |||
| 381 | + | ||||
| 369 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | 382 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | ||
| 370 | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | 383 | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | ||
| 371 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | 384 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | ||
M internal/usecase/palette/result.go +12 -2
| @@ -57,7 +57,14 @@ func (r Result) DisplayText() string { | |||
| 57 | 57 | if r.kind == ResultKindCommand { | |
| 58 | 58 | return r.command.Code | |
| 59 | 59 | } | |
| 60 | - | return sessionDisplayPrefix + r.session.name | |
| 60 | + | return r.sessionDisplayPrefix() + r.session.name | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (r Result) sessionDisplayPrefix() string { | |
| 64 | + | if r.kind == ResultKindStoppedSession { | |
| 65 | + | return stoppedSessionDisplayPrefix | |
| 66 | + | } | |
| 67 | + | return activeSessionDisplayPrefix | |
| 61 | 68 | } | |
| 62 | 69 | ||
| 63 | 70 | func (r Result) SearchText() string { | |
| @@ -87,7 +94,10 @@ func (r Result) SessionID() (domain.SessionID, bool) { | |||
| 87 | 94 | return r.session.sessionID, r.kind == ResultKindActiveSession | |
| 88 | 95 | } | |
| 89 | 96 | ||
| 90 | - | const sessionDisplayPrefix = "Switch to session " | |
| 97 | + | const ( | |
| 98 | + | activeSessionDisplayPrefix = "Switch to session " | |
| 99 | + | stoppedSessionDisplayPrefix = "Resume session " | |
| 100 | + | ) | |
| 91 | 101 | ||
| 92 | 102 | // CommandResults converts static commands to immutable palette targets. | |
| 93 | 103 | func CommandResults(commands []command.Command) []Result { | |
| @@ -57,7 +57,14 @@ func (r Result) DisplayText() string { | |||||
| 57 | if r.kind == ResultKindCommand { | 57 | if r.kind == ResultKindCommand { | ||
| 58 | return r.command.Code | 58 | return r.command.Code | ||
| 59 | } | 59 | } | ||
| 60 | - | return sessionDisplayPrefix + r.session.name | 60 | + | return r.sessionDisplayPrefix() + r.session.name |
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (r Result) sessionDisplayPrefix() string { | |||
| 64 | + | if r.kind == ResultKindStoppedSession { | |||
| 65 | + | return stoppedSessionDisplayPrefix | |||
| 66 | + | } | |||
| 67 | + | return activeSessionDisplayPrefix | |||
| 61 | } | 68 | } | ||
| 62 | 69 | ||||
| 63 | func (r Result) SearchText() string { | 70 | func (r Result) SearchText() string { | ||
| @@ -87,7 +94,10 @@ func (r Result) SessionID() (domain.SessionID, bool) { | |||||
| 87 | return r.session.sessionID, r.kind == ResultKindActiveSession | 94 | return r.session.sessionID, r.kind == ResultKindActiveSession | ||
| 88 | } | 95 | } | ||
| 89 | 96 | ||||
| 90 | - | const sessionDisplayPrefix = "Switch to session " | 97 | + | const ( |
| 98 | + | activeSessionDisplayPrefix = "Switch to session " | |||
| 99 | + | stoppedSessionDisplayPrefix = "Resume session " | |||
| 100 | + | ) | |||
| 91 | 101 | ||||
| 92 | // CommandResults converts static commands to immutable palette targets. | 102 | // CommandResults converts static commands to immutable palette targets. | ||
| 93 | func CommandResults(commands []command.Command) []Result { | 103 | func CommandResults(commands []command.Command) []Result { | ||
M internal/usecase/palette/result_test.go +1 -0
| @@ -42,6 +42,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 42 | 42 | createdAt, ok = stopped.SessionCreatedAt() | |
| 43 | 43 | require.True(t, ok) | |
| 44 | 44 | require.Equal(t, created, createdAt) | |
| 45 | + | require.Equal(t, "Resume session archive", stopped.DisplayText()) | |
| 45 | 46 | _, ok = stopped.SessionID() | |
| 46 | 47 | require.False(t, ok) | |
| 47 | 48 | } | |
| @@ -42,6 +42,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 42 | createdAt, ok = stopped.SessionCreatedAt() | 42 | createdAt, ok = stopped.SessionCreatedAt() | ||
| 43 | require.True(t, ok) | 43 | require.True(t, ok) | ||
| 44 | require.Equal(t, created, createdAt) | 44 | require.Equal(t, created, createdAt) | ||
| 45 | + | require.Equal(t, "Resume session archive", stopped.DisplayText()) | |||
| 45 | _, ok = stopped.SessionID() | 46 | _, ok = stopped.SessionID() | ||
| 46 | require.False(t, ok) | 47 | require.False(t, ok) | ||
| 47 | } | 48 | } | ||