feat(cli): add filterable generic selection
All checks were successful
Security Scan / security (push) Successful in 25s
All checks were successful
Security Scan / security (push) Successful in 25s
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
4e258c80b1
commit
96aef54baf
3 changed files with 134 additions and 31 deletions
|
|
@ -135,6 +135,12 @@ choice := cli.Choose("Select a file:", files,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Enable `cli.Filter()` to let users type a substring and narrow the visible choices before selecting a number:
|
||||||
|
|
||||||
|
```go
|
||||||
|
choice := cli.Choose("Select:", items, cli.Filter[Item]())
|
||||||
|
```
|
||||||
|
|
||||||
With a default selection:
|
With a default selection:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,22 @@ func TestChoose_Good_DefaultIndex(t *testing.T) {
|
||||||
assert.Equal(t, "b", val)
|
assert.Equal(t, "b", val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChoose_Good_Filter(t *testing.T) {
|
||||||
|
SetStdin(strings.NewReader("ap\n2\n"))
|
||||||
|
defer SetStdin(nil)
|
||||||
|
|
||||||
|
val := Choose("Pick", []string{"apple", "apricot", "banana"}, Filter[string]())
|
||||||
|
assert.Equal(t, "apricot", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChooseMulti_Good_Filter(t *testing.T) {
|
||||||
|
SetStdin(strings.NewReader("ap\n1 2\n"))
|
||||||
|
defer SetStdin(nil)
|
||||||
|
|
||||||
|
vals := ChooseMulti("Pick", []string{"apple", "apricot", "banana"}, Filter[string]())
|
||||||
|
assert.Equal(t, []string{"apple", "apricot"}, vals)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetStdin_Good_ResetNil(t *testing.T) {
|
func TestSetStdin_Good_ResetNil(t *testing.T) {
|
||||||
original := stdin
|
original := stdin
|
||||||
t.Cleanup(func() { stdin = original })
|
t.Cleanup(func() { stdin = original })
|
||||||
|
|
|
||||||
143
pkg/cli/utils.go
143
pkg/cli/utils.go
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"forge.lthn.ai/core/go-i18n"
|
||||||
"forge.lthn.ai/core/go-log"
|
"forge.lthn.ai/core/go-log"
|
||||||
|
|
@ -261,7 +262,7 @@ type ChooseOption[T any] func(*chooseConfig[T])
|
||||||
type chooseConfig[T any] struct {
|
type chooseConfig[T any] struct {
|
||||||
displayFn func(T) string
|
displayFn func(T) string
|
||||||
defaultN int // 0-based index of default selection
|
defaultN int // 0-based index of default selection
|
||||||
filter bool // Enable fuzzy filtering
|
filter bool // Enable type-to-filter selection
|
||||||
multi bool // Allow multiple selection
|
multi bool // Allow multiple selection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,9 +281,7 @@ func WithDefaultIndex[T any](idx int) ChooseOption[T] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter enables type-to-filter functionality.
|
// Filter enables type-to-filter functionality.
|
||||||
// Users can type to narrow down the list of options.
|
// When enabled, typed text narrows the visible options before selection.
|
||||||
// Note: This is a hint for interactive UIs; the basic CLI Choose
|
|
||||||
// implementation uses numbered selection which doesn't support filtering.
|
|
||||||
func Filter[T any]() ChooseOption[T] {
|
func Filter[T any]() ChooseOption[T] {
|
||||||
return func(c *chooseConfig[T]) {
|
return func(c *chooseConfig[T]) {
|
||||||
c.filter = true
|
c.filter = true
|
||||||
|
|
@ -323,37 +322,48 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
||||||
opt(cfg)
|
opt(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display options
|
reader := newReader()
|
||||||
fmt.Println(prompt)
|
visible := make([]int, len(items))
|
||||||
for i, item := range items {
|
for i := range items {
|
||||||
marker := " "
|
visible[i] = i
|
||||||
if i == cfg.defaultN {
|
|
||||||
marker = "*"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := newReader()
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
fmt.Printf("Enter number [1-%d]: ", len(items))
|
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
|
||||||
|
|
||||||
|
if cfg.filter {
|
||||||
|
fmt.Printf("Enter number [1-%d] or filter: ", len(visible))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Enter number [1-%d]: ", len(visible))
|
||||||
|
}
|
||||||
response, _ := reader.ReadString('\n')
|
response, _ := reader.ReadString('\n')
|
||||||
response = strings.TrimSpace(response)
|
response = strings.TrimSpace(response)
|
||||||
|
|
||||||
// Empty response uses default
|
// Empty response uses default.
|
||||||
if response == "" {
|
if response == "" {
|
||||||
return items[cfg.defaultN]
|
return items[defaultVisibleIndex(visible, cfg.defaultN)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse number
|
|
||||||
var n int
|
var n int
|
||||||
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
|
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
|
||||||
if n >= 1 && n <= len(items) {
|
if n >= 1 && n <= len(visible) {
|
||||||
return items[n-1]
|
return items[visible[n-1]]
|
||||||
}
|
}
|
||||||
|
fmt.Printf("Please enter a number between 1 and %d\n", len(visible))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Please enter a number between 1 and %d\n", len(items))
|
if cfg.filter {
|
||||||
|
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
|
||||||
|
if len(nextVisible) == 0 {
|
||||||
|
fmt.Printf("No matches for %q\n", response)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visible = nextVisible
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Please enter a number between 1 and %d\n", len(visible))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,16 +398,20 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||||
opt(cfg)
|
opt(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display options
|
reader := newReader()
|
||||||
fmt.Println(prompt)
|
visible := make([]int, len(items))
|
||||||
for i, item := range items {
|
for i := range items {
|
||||||
fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item))
|
visible[i] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := newReader()
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
|
||||||
|
|
||||||
|
if cfg.filter {
|
||||||
|
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
||||||
|
}
|
||||||
response, _ := reader.ReadString('\n')
|
response, _ := reader.ReadString('\n')
|
||||||
response = strings.TrimSpace(response)
|
response = strings.TrimSpace(response)
|
||||||
|
|
||||||
|
|
@ -406,9 +420,18 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the selection
|
// Parse the selection.
|
||||||
selected, err := parseMultiSelection(response, len(items))
|
selected, err := parseMultiSelection(response, len(visible))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if cfg.filter && !looksLikeMultiSelectionInput(response) {
|
||||||
|
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
|
||||||
|
if len(nextVisible) == 0 {
|
||||||
|
fmt.Printf("No matches for %q\n", response)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visible = nextVisible
|
||||||
|
continue
|
||||||
|
}
|
||||||
fmt.Printf("Invalid selection: %v\n", err)
|
fmt.Printf("Invalid selection: %v\n", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -416,12 +439,70 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||||
// Build result
|
// Build result
|
||||||
result := make([]T, 0, len(selected))
|
result := make([]T, 0, len(selected))
|
||||||
for _, idx := range selected {
|
for _, idx := range selected {
|
||||||
result = append(result, items[idx])
|
result = append(result, items[visible[idx]])
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
|
||||||
|
fmt.Println(prompt)
|
||||||
|
for i, idx := range visible {
|
||||||
|
marker := " "
|
||||||
|
if defaultN >= 0 && idx == defaultN {
|
||||||
|
marker = "*"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s%d. %s\n", marker, i+1, displayFn(items[idx]))
|
||||||
|
}
|
||||||
|
if filter {
|
||||||
|
fmt.Println(" (type to filter the list)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultVisibleIndex(visible []int, defaultN int) int {
|
||||||
|
if defaultN >= 0 {
|
||||||
|
for _, idx := range visible {
|
||||||
|
if idx == defaultN {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(visible) > 0 {
|
||||||
|
return visible[0]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterVisible[T any](items []T, visible []int, query string, displayFn func(T) string) []int {
|
||||||
|
q := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
if q == "" {
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]int, 0, len(visible))
|
||||||
|
for _, idx := range visible {
|
||||||
|
if strings.Contains(strings.ToLower(displayFn(items[idx])), q) {
|
||||||
|
filtered = append(filtered, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeMultiSelectionInput(input string) bool {
|
||||||
|
hasDigit := false
|
||||||
|
for _, r := range input {
|
||||||
|
switch {
|
||||||
|
case unicode.IsSpace(r), r == '-' || r == ',':
|
||||||
|
continue
|
||||||
|
case unicode.IsDigit(r):
|
||||||
|
hasDigit = true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasDigit
|
||||||
|
}
|
||||||
|
|
||||||
// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5".
|
// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5".
|
||||||
// Returns 0-based indices.
|
// Returns 0-based indices.
|
||||||
func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue