Skip to content

Commit cfb2224

Browse files
BagToadCopilot
authored andcommitted
refactor(huh prompter): custom Field for MultiSelectWithSearch
Replace the OptionsFunc-based MultiSelectWithSearch with a custom huh Field implementation. huh's OptionsFunc runs in a goroutine, causing data races with selection state and stale cache issues that made selections disappear on toggle or search changes. The custom field (multiSelectSearchField) combines a text input and multi-select list in a single field with full control over the update loop. Search runs asynchronously via tea.Cmd when the user presses Enter, with a themed spinner during loading. Selections are stored in a simple map — no goroutine races, no Eval cache, no syncAccessor. Also adds defensive validation for mismatched Keys/Labels slices from searchFunc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f38abbe commit cfb2224

File tree

3 files changed

+498
-155
lines changed

3 files changed

+498
-155
lines changed

internal/prompter/huh_prompter.go

Lines changed: 6 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package prompter
33
import (
44
"fmt"
55
"slices"
6-
"sync"
76

87
"charm.land/huh/v2"
98
"github.com/cli/cli/v2/internal/ghinstance"
@@ -93,162 +92,19 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st
9392
return *result, nil
9493
}
9594

96-
// searchOptionsBinding is used as the OptionsFunc binding for MultiSelectWithSearch.
97-
// By including both the search query and selected values, the binding hash changes
98-
// whenever either changes. This prevents huh's internal Eval cache from serving
99-
// stale option sets that would overwrite the user's current selections.
100-
type searchOptionsBinding struct {
101-
Query *string
102-
Selected *[]string
103-
}
104-
105-
// syncAccessor is a thread-safe huh.Accessor implementation.
106-
// huh calls OptionsFunc from a goroutine while the main event loop
107-
// writes field values via Set(). This accessor synchronizes both
108-
// paths through the same mutex.
109-
type syncAccessor[T any] struct {
110-
mu *sync.Mutex
111-
value T
112-
}
113-
114-
func (a *syncAccessor[T]) Get() T {
115-
a.mu.Lock()
116-
defer a.mu.Unlock()
117-
return a.value
118-
}
119-
120-
func (a *syncAccessor[T]) Set(value T) {
121-
a.mu.Lock()
122-
defer a.mu.Unlock()
123-
a.value = value
124-
}
125-
126-
func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *syncAccessor[[]string]) {
127-
var mu sync.Mutex
128-
129-
queryAccessor := &syncAccessor[string]{mu: &mu}
130-
selectAccessor := &syncAccessor[[]string]{mu: &mu, value: slices.Clone(defaultValues)}
131-
132-
optionKeyLabels := make(map[string]string)
133-
for _, k := range defaultValues {
134-
optionKeyLabels[k] = k
135-
}
136-
137-
// Cache searchFunc results locally keyed by query string.
138-
// This avoids redundant calls when the OptionsFunc binding hash changes
139-
// due to selection changes (not query changes).
140-
searchCacheValid := false
141-
var cachedSearchQuery string
142-
var cachedSearchResult MultiSelectSearchResult
143-
144-
buildOptions := func() []huh.Option[string] {
145-
mu.Lock()
146-
query := queryAccessor.value
147-
needsFetch := !searchCacheValid || query != cachedSearchQuery
148-
mu.Unlock()
149-
150-
if needsFetch {
151-
result := searchFunc(query)
152-
mu.Lock()
153-
cachedSearchResult = result
154-
cachedSearchQuery = query
155-
searchCacheValid = true
156-
mu.Unlock()
157-
}
158-
159-
mu.Lock()
160-
defer mu.Unlock()
161-
162-
selectedValues := selectAccessor.value
163-
result := cachedSearchResult
164-
165-
if result.Err != nil {
166-
return nil
167-
}
168-
for i, k := range result.Keys {
169-
optionKeyLabels[k] = result.Labels[i]
170-
}
171-
172-
var formOptions []huh.Option[string]
173-
seen := make(map[string]bool)
174-
175-
// 1. Currently selected values (persisted across searches).
176-
for _, k := range selectedValues {
177-
if seen[k] {
178-
continue
179-
}
180-
seen[k] = true
181-
l := optionKeyLabels[k]
182-
if l == "" {
183-
l = k
184-
}
185-
formOptions = append(formOptions, huh.NewOption(l, k).Selected(true))
186-
}
187-
188-
// 2. Search results.
189-
for i, k := range result.Keys {
190-
if seen[k] {
191-
continue
192-
}
193-
seen[k] = true
194-
l := result.Labels[i]
195-
if l == "" {
196-
l = k
197-
}
198-
formOptions = append(formOptions, huh.NewOption(l, k))
199-
}
200-
201-
// 3. Persistent options.
202-
for _, k := range persistentValues {
203-
if seen[k] {
204-
continue
205-
}
206-
seen[k] = true
207-
l := optionKeyLabels[k]
208-
if l == "" {
209-
l = k
210-
}
211-
formOptions = append(formOptions, huh.NewOption(l, k))
212-
}
213-
214-
if len(formOptions) == 0 {
215-
formOptions = append(formOptions, huh.NewOption("No results", ""))
216-
}
217-
218-
return formOptions
219-
}
220-
221-
binding := &searchOptionsBinding{
222-
Query: &queryAccessor.value,
223-
Selected: &selectAccessor.value,
224-
}
225-
226-
form := p.newForm(
227-
huh.NewGroup(
228-
huh.NewInput().
229-
Title(searchPrompt).
230-
Placeholder("Type to search, Ctrl+U to clear").
231-
Accessor(queryAccessor),
232-
huh.NewMultiSelect[string]().
233-
Title(prompt).
234-
Options(buildOptions()...).
235-
OptionsFunc(func() []huh.Option[string] {
236-
return buildOptions()
237-
}, binding).
238-
Accessor(selectAccessor).
239-
Limit(0),
240-
),
241-
)
242-
return form, selectAccessor
95+
func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) (*huh.Form, *multiSelectSearchField) {
96+
field := newMultiSelectSearchField(prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
97+
form := p.newForm(huh.NewGroup(field))
98+
return form, field
24399
}
244100

245101
func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
246-
form, accessor := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
102+
form, field := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
247103
err := form.Run()
248104
if err != nil {
249105
return nil, err
250106
}
251-
return accessor.Get(), nil
107+
return field.selectedKeys(), nil
252108
}
253109

254110
func (p *huhPrompter) buildInputForm(prompt, defaultValue string) (*huh.Form, *string) {

internal/prompter/huh_prompter_test.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ func TestHuhPrompterMultiSelectWithSearch(t *testing.T) {
450450
"Select", "Search", tt.defaults, tt.persistent, staticSearchFunc,
451451
)
452452
runForm(t, f, tt.ix)
453-
assert.Equal(t, tt.wantResult, result.Get())
453+
assert.Equal(t, tt.wantResult, result.selectedKeys())
454454
})
455455
}
456456
}
@@ -482,7 +482,7 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) {
482482
tab(), waitForOptions(),
483483
enter(), // submit — result-a should persist
484484
))
485-
assert.Equal(t, []string{"result-a"}, result.Get())
485+
assert.Equal(t, []string{"result-a"}, result.selectedKeys())
486486
})
487487
t.Run("empty search results shows no-results placeholder", func(t *testing.T) {
488488
emptySearchFunc := func(query string) MultiSelectSearchResult {
@@ -492,10 +492,10 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) {
492492
f, result := p.buildMultiSelectWithSearchForm(
493493
"Select", "Search", nil, nil, emptySearchFunc,
494494
)
495-
// With no results, the "No results" placeholder is shown but nothing
496-
// is selected, so submitting returns empty.
495+
// With no results, the "No results" message is shown.
496+
// Toggle does nothing, submitting returns empty.
497497
runForm(t, f, newInteraction(tab(), waitForOptions(), toggle(), enter()))
498-
assert.Equal(t, []string{""}, result.Get())
498+
assert.Equal(t, []string{}, result.selectedKeys())
499499
})
500500
}
501501

@@ -581,3 +581,40 @@ func TestHuhPrompterInputHostname(t *testing.T) {
581581
})
582582
}
583583
}
584+
585+
func TestHuhPrompterMultiSelectWithSearchBackspace(t *testing.T) {
586+
// Simulate real API latency and non-overlapping results.
587+
staticSearchFunc := func(query string) MultiSelectSearchResult {
588+
time.Sleep(100 * time.Millisecond) // simulate API latency
589+
if query == "" {
590+
return MultiSelectSearchResult{
591+
Keys: []string{"alice", "bob"},
592+
Labels: []string{"Alice", "Bob"},
593+
}
594+
}
595+
return MultiSelectSearchResult{
596+
Keys: []string{"frank", "fiona"},
597+
Labels: []string{"Frank", "Fiona"},
598+
}
599+
}
600+
601+
t.Run("selections persist after backspacing search query", func(t *testing.T) {
602+
p := newTestHuhPrompter()
603+
f, result := p.buildMultiSelectWithSearchForm(
604+
"Select", "Search", nil, nil, staticSearchFunc,
605+
)
606+
longWait := interactionStep{delay: 300 * time.Millisecond}
607+
runForm(t, f, newInteraction(
608+
tab(), longWait,
609+
toggle(), // toggle alice
610+
shiftTab(), // back to search input
611+
typeKeys("f"), // type "f"
612+
longWait, // wait for API + OptionsFunc
613+
typeKeys("\x7f"), // backspace to ""
614+
longWait, // wait for cache/API
615+
tab(), longWait,
616+
enter(),
617+
))
618+
assert.Equal(t, []string{"alice"}, result.selectedKeys())
619+
})
620+
}

0 commit comments

Comments
 (0)