diff --git a/cmd/myf5/main.go b/cmd/myf5/main.go index a92b6c2..3dc4e7f 100644 --- a/cmd/myf5/main.go +++ b/cmd/myf5/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "os" "os/signal" @@ -878,62 +879,73 @@ func runInteractiveBrowser(ctx context.Context, client *downloads.Client) { familyLoop: for { - fmt.Println("\nSelect a Product Family:") + famItems := make([]SelectItem, len(meta.Data.ProductFamilies)) for i, f := range meta.Data.ProductFamilies { - fmt.Printf(" %2d) %s\n", i+1, f.GetTitle()) - } - fmt.Print("\nEnter number (or 'q' to quit): ") - input, err := reader.ReadString('\n') + famItems[i] = SelectItem{ + ID: f.Name, + Title: f.GetTitle(), + Value: f, + } + } + selIdx, _, err := SelectMenu(SelectConfig{ + Title: "Select a Product Family:", + Items: famItems, + AllowFilter: true, + AllowBack: false, + Prompt: "Select Family (or 'q' to quit)", + In: os.Stdin, + Out: os.Stdout, + Reader: reader, + }) if err != nil { + if errors.Is(err, ErrSelectQuit) || errors.Is(err, io.EOF) { + return + } if ctx.Err() != nil { fmt.Fprintln(os.Stderr, "\n[CANCELLED] Operation aborted by user.") exitFunc(130) } return } - input = strings.TrimSpace(input) - if input == "q" || input == "exit" { - return - } - idx, err := strconv.Atoi(input) - if err != nil || idx < 1 || idx > len(meta.Data.ProductFamilies) { - fmt.Println("Invalid selection.") - continue - } - selectedFamily := meta.Data.ProductFamilies[idx-1] + selectedFamily := meta.Data.ProductFamilies[selIdx] lineLoop: for { - // Select Line - fmt.Printf("\nSelect a Product Line in %s:\n", selectedFamily.GetTitle()) + lineItems := make([]SelectItem, len(selectedFamily.ProductLines)) for i, l := range selectedFamily.ProductLines { - fmt.Printf(" %2d) %s\n", i+1, l.GetTitle()) + lineItems[i] = SelectItem{ + ID: l.Name, + Title: l.GetTitle(), + Value: l, + } } - fmt.Print("\nEnter number (or 'b' to go back, 'q' to quit): ") - input, err = reader.ReadString('\n') + selIdx, _, err := SelectMenu(SelectConfig{ + Title: fmt.Sprintf("Select a Product Line in %s:", selectedFamily.GetTitle()), + Items: lineItems, + AllowFilter: true, + AllowBack: true, + Prompt: "Select Product Line (or 'b' for back, 'q' to quit)", + In: os.Stdin, + Out: os.Stdout, + Reader: reader, + }) if err != nil { + if errors.Is(err, ErrSelectBack) { + continue familyLoop + } + if errors.Is(err, ErrSelectQuit) || errors.Is(err, io.EOF) { + return + } if ctx.Err() != nil { fmt.Fprintln(os.Stderr, "\n[CANCELLED] Operation aborted by user.") exitFunc(130) } return } - input = strings.TrimSpace(input) - if input == "q" || input == "exit" { - return - } - if input == "b" { - continue familyLoop - } - idx, err = strconv.Atoi(input) - if err != nil || idx < 1 || idx > len(selectedFamily.ProductLines) { - fmt.Println("Invalid selection.") - continue - } - selectedLine := selectedFamily.ProductLines[idx-1] + selectedLine := selectedFamily.ProductLines[selIdx] // Fetch versions - fmt.Printf("Fetching versions for %s...\n", selectedLine.Name) + fmt.Printf("\nFetching versions for %s...\n", selectedLine.Name) vResp, err := client.GetProductVersions(ctx, selectedFamily.Name, selectedLine.Name) if err != nil { if errors.Is(err, context.Canceled) || ctx.Err() != nil { @@ -941,43 +953,50 @@ familyLoop: exitFunc(130) } fmt.Fprintf(os.Stderr, "Error: %v\n", err) - continue + continue lineLoop } versions := vResp.GetVersions() if len(versions) == 0 { fmt.Println("No versions found.") - continue + continue lineLoop } versionLoop: for { - // Select Version - fmt.Printf("\nSelect Release Version in %s:\n", selectedLine.GetTitle()) + verItems := make([]SelectItem, len(versions)) for i, v := range versions { - fmt.Printf(" %2d) %-12s (Released: %s, %d containers)\n", i+1, v.Name, v.ReleaseDate, len(v.Containers)) + desc := fmt.Sprintf("Released: %s, %d containers", v.ReleaseDate, len(v.Containers)) + verItems[i] = SelectItem{ + ID: v.Name, + Title: v.Name, + Description: desc, + Value: v, + } } - fmt.Print("\nEnter number (or 'b' to go back, 'q' to quit): ") - input, err = reader.ReadString('\n') + selIdx, _, err := SelectMenu(SelectConfig{ + Title: fmt.Sprintf("Select Release Version in %s:", selectedLine.GetTitle()), + Items: verItems, + AllowFilter: true, + AllowBack: true, + Prompt: "Select Version (or 'b' for back, 'q' to quit)", + In: os.Stdin, + Out: os.Stdout, + Reader: reader, + }) if err != nil { + if errors.Is(err, ErrSelectBack) { + continue lineLoop + } + if errors.Is(err, ErrSelectQuit) || errors.Is(err, io.EOF) { + return + } if ctx.Err() != nil { fmt.Fprintln(os.Stderr, "\n[CANCELLED] Operation aborted by user.") exitFunc(130) } return } - input = strings.TrimSpace(input) - if input == "q" || input == "exit" { - return - } - if input == "b" { - continue lineLoop - } - idx, err = strconv.Atoi(input) - if err != nil || idx < 1 || idx > len(versions) { - fmt.Println("Invalid selection.") - continue - } - selectedVersion := versions[idx-1] + selectedVersion := versions[selIdx] categoryLoop: for { @@ -1052,47 +1071,60 @@ familyLoop: totalVersionFiles += len(c.Files) } - fmt.Printf("\n=== %s / %s (Version %s) ===\n", selectedFamily.Name, selectedLine.GetTitle(), selectedVersion.Name) - fmt.Printf("Select a Category:\n") - for i, cat := range activeCats { + var catItems []SelectItem + for _, cat := range activeCats { containerPlural := "containers" if len(cat.containers) == 1 { containerPlural = "container" } - fmt.Printf(" %2d) %-38s (%d %s, %d files)\n", - i+1, cat.name, len(cat.containers), containerPlural, cat.totalFiles) + catItems = append(catItems, SelectItem{ + ID: cat.name, + Title: cat.name, + Description: fmt.Sprintf("%d %s, %d files", len(cat.containers), containerPlural, cat.totalFiles), + Value: cat, + }) + } + if len(activeCats) > 1 { + catItems = append(catItems, SelectItem{ + ID: "all", + Title: "All Containers", + Description: fmt.Sprintf("%d total files across all categories", totalVersionFiles), + Value: nil, + }) } - fmt.Printf("\n [Tip: Type 'all' to browse all %d files]\n", totalVersionFiles) - fmt.Print("Enter category number (or 'b' to go back, 'q' to quit): ") - input, err = reader.ReadString('\n') + + _, chosenItem, err := SelectMenu(SelectConfig{ + Title: fmt.Sprintf("=== %s / %s (Version %s) ===\nSelect a Category:", selectedFamily.Name, selectedLine.GetTitle(), selectedVersion.Name), + Items: catItems, + AllowFilter: true, + AllowBack: true, + Prompt: "Select Category (or 'b' for back, 'q' to quit)", + In: os.Stdin, + Out: os.Stdout, + Reader: reader, + }) if err != nil { + if errors.Is(err, ErrSelectBack) { + continue versionLoop + } + if errors.Is(err, ErrSelectQuit) || errors.Is(err, io.EOF) { + return + } if ctx.Err() != nil { fmt.Fprintln(os.Stderr, "\n[CANCELLED] Operation aborted by user.") exitFunc(130) } return } - input = strings.TrimSpace(input) - if input == "q" || input == "exit" { - return - } - if input == "b" { - continue versionLoop - } var targetContainers []downloads.Container var categoryTitle string - if strings.EqualFold(input, "all") { + if chosenItem.ID == "all" { targetContainers = selectedVersion.Containers categoryTitle = fmt.Sprintf("All Containers (%d files)", totalVersionFiles) } else { - catIdx, err := strconv.Atoi(input) - if err != nil || catIdx < 1 || catIdx > len(activeCats) { - fmt.Println("Invalid category selection.") - continue - } - chosenCat := activeCats[catIdx-1] + chosenCat := chosenItem.Value.(catGroup) targetContainers = make([]downloads.Container, len(chosenCat.containers)) copy(targetContainers, chosenCat.containers) categoryTitle = chosenCat.name @@ -1130,7 +1162,7 @@ familyLoop: activeFiles = append(activeFiles, fileRef{containerName: chosen.Name, file: f}) } containerHeader = fmt.Sprintf("%s (%d files)", chosen.Name, len(chosen.Files)) - } else if strings.EqualFold(input, "all") { + } else if chosenItem.ID == "all" { for _, c := range targetContainers { for _, f := range c.Files { activeFiles = append(activeFiles, fileRef{containerName: c.Name, file: f}) @@ -1138,38 +1170,55 @@ familyLoop: } containerHeader = categoryTitle } else { - fmt.Printf("\n=== %s / %s ===\n", selectedVersion.Name, categoryTitle) - fmt.Printf("Select a Container:\n") - for i, c := range targetContainers { + var contItems []SelectItem + for _, c := range targetContainers { typeTag := "" if c.Type != "" { typeTag = fmt.Sprintf("[%s] ", c.Type) } - desc := "" + desc := fmt.Sprintf("%s(%d files)", typeTag, len(c.Files)) if c.Description != "" && c.Description != c.Name { - desc = fmt.Sprintf(" - %s", c.Description) + desc += fmt.Sprintf(" - %s", c.Description) } - fmt.Printf(" %2d) %-35s %s(%d files)%s\n", i+1, c.Name, typeTag, len(c.Files), desc) + contItems = append(contItems, SelectItem{ + ID: c.Name, + Title: c.Name, + Description: desc, + Value: c, + }) } - fmt.Printf("\n [Tip: Type 'all' to show all files in this category]\n") - fmt.Print("Enter container number (or 'b' to go back, 'q' to quit): ") - contInput, err := reader.ReadString('\n') + contItems = append(contItems, SelectItem{ + ID: "all", + Title: "All Containers in Category", + Description: fmt.Sprintf("View all files in %s", categoryTitle), + Value: nil, + }) + + cIdx, cItem, err := SelectMenu(SelectConfig{ + Title: fmt.Sprintf("=== %s / %s ===\nSelect a Container:", selectedVersion.Name, categoryTitle), + Items: contItems, + AllowFilter: true, + AllowBack: true, + Prompt: "Select Container (or 'b' for back, 'q' to quit)", + In: os.Stdin, + Out: os.Stdout, + Reader: reader, + }) if err != nil { + if errors.Is(err, ErrSelectBack) { + continue categoryLoop + } + if errors.Is(err, ErrSelectQuit) || errors.Is(err, io.EOF) { + return + } if ctx.Err() != nil { fmt.Fprintln(os.Stderr, "\n[CANCELLED] Operation aborted by user.") exitFunc(130) } return } - contInput = strings.TrimSpace(contInput) - if contInput == "q" || contInput == "exit" { - return - } - if contInput == "b" { - continue categoryLoop - } - if strings.EqualFold(contInput, "all") { + if cItem.ID == "all" { for _, c := range targetContainers { for _, f := range c.Files { activeFiles = append(activeFiles, fileRef{containerName: c.Name, file: f}) @@ -1177,12 +1226,7 @@ familyLoop: } containerHeader = fmt.Sprintf("%s - All (%d files)", categoryTitle, len(activeFiles)) } else { - cIdx, err := strconv.Atoi(contInput) - if err != nil || cIdx < 1 || cIdx > len(targetContainers) { - fmt.Println("Invalid container selection.") - continue - } - chosen := targetContainers[cIdx-1] + chosen := targetContainers[cIdx] for _, f := range chosen.Files { activeFiles = append(activeFiles, fileRef{containerName: chosen.Name, file: f}) } @@ -1195,84 +1239,57 @@ familyLoop: if len(targetContainers) == 1 { continue categoryLoop } - continue + continue containerLoop } - searchTerm := "" - for { - var displayFiles []fileRef - if searchTerm != "" { - for _, item := range activeFiles { - if strings.Contains(strings.ToLower(item.file.Name), strings.ToLower(searchTerm)) || - strings.Contains(strings.ToLower(item.file.Description), strings.ToLower(searchTerm)) { - displayFiles = append(displayFiles, item) - } - } - } else { - displayFiles = activeFiles + fileItems := make([]SelectItem, len(activeFiles)) + for i, item := range activeFiles { + desc := downloads.FormatBytes(item.file.FileSize) + if item.file.Description != "" { + desc += fmt.Sprintf(" - %s", item.file.Description) } - - fmt.Printf("\n=== %s ===\n", containerHeader) - if searchTerm != "" { - fmt.Printf("[Filter: '%s' - %d matching files (type '/clear' to view all)]\n", searchTerm, len(displayFiles)) + title := item.file.Name + if len(targetContainers) > 1 && strings.Contains(containerHeader, "All") { + title = fmt.Sprintf("[%s] %s", item.containerName, item.file.Name) } - fmt.Printf("Select a File to Download:\n") - for i, item := range displayFiles { - desc := "" - if item.file.Description != "" { - desc = fmt.Sprintf(" - %s", item.file.Description) - } - contLabel := "" - if len(targetContainers) > 1 && strings.Contains(containerHeader, "All") { - contLabel = fmt.Sprintf("[%s] ", item.containerName) - } - fmt.Printf(" %2d) %s%-52s (%s)%s\n", - i+1, contLabel, item.file.Name, downloads.FormatBytes(item.file.FileSize), desc) + fileItems[i] = SelectItem{ + ID: item.file.Name, + Title: title, + Description: desc, + Value: item, } + } - fmt.Print("\nEnter file number, or '/' to filter (or 'b' to go back, 'q' to quit): ") - fileInput, err := reader.ReadString('\n') + fileLoop: + for { + fIdx, _, err := SelectMenu(SelectConfig{ + Title: fmt.Sprintf("=== %s ===\nSelect a File to Download:", containerHeader), + Items: fileItems, + AllowFilter: true, + AllowBack: true, + Prompt: "Select File (or 'b' for back, 'q' to quit)", + In: os.Stdin, + Out: os.Stdout, + Reader: reader, + }) if err != nil { + if errors.Is(err, ErrSelectBack) { + if len(targetContainers) == 1 { + continue categoryLoop + } + continue containerLoop + } + if errors.Is(err, ErrSelectQuit) || errors.Is(err, io.EOF) { + return + } if ctx.Err() != nil { fmt.Fprintln(os.Stderr, "\n[CANCELLED] Operation aborted by user.") exitFunc(130) } return } - fileInput = strings.TrimSpace(fileInput) - if fileInput == "q" || fileInput == "exit" { - return - } - if fileInput == "b" { - if searchTerm != "" { - searchTerm = "" - continue - } - if len(targetContainers) == 1 { - continue categoryLoop - } - continue containerLoop - } - if fileInput == "/clear" || fileInput == "/all" { - searchTerm = "" - continue - } - if strings.HasPrefix(fileInput, "/") { - searchTerm = strings.TrimPrefix(fileInput, "/") - continue - } - - fIdx, err := strconv.Atoi(fileInput) - if err != nil || fIdx < 1 || fIdx > len(displayFiles) { - if !strings.ContainsAny(fileInput, "0123456789") && len(fileInput) >= 2 { - searchTerm = fileInput - continue - } - fmt.Println("Invalid file selection.") - continue - } - selectedTarget := displayFiles[fIdx-1] + selectedTarget := activeFiles[fIdx] // Fetch download links fmt.Printf("\nFetching signed CDN link for %s...\n", selectedTarget.file.Name) @@ -1283,12 +1300,12 @@ familyLoop: exitFunc(130) } fmt.Fprintf(os.Stderr, "Error: %v\n", err) - continue + continue fileLoop } links := linkResp.GetLinks() if len(links) == 0 { fmt.Println("No download link available.") - continue + continue fileLoop } link := links[0] diff --git a/cmd/myf5/selector.go b/cmd/myf5/selector.go new file mode 100644 index 0000000..1ce294b --- /dev/null +++ b/cmd/myf5/selector.go @@ -0,0 +1,713 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + "unicode/utf8" + + "golang.org/x/term" +) + +var ( + // ErrSelectBack is returned when the user presses 'b' or Esc to navigate back. + ErrSelectBack = errors.New("selection cancelled: back requested") + + // ErrSelectQuit is returned when the user presses 'q' or Ctrl+C to abort. + ErrSelectQuit = errors.New("selection aborted: quit requested") +) + +// SelectItem represents an individual entry in a selection list. +type SelectItem struct { + ID string // Optional ID or key (e.g. "1", "BIG-IP") + Title string // Primary display label + Description string // Optional description or secondary info + Value interface{} // Underlying payload +} + +// SelectConfig configures the interactive selection menu. +type SelectConfig struct { + Title string // Header title displayed above items + Items []SelectItem // List of items to select from + DefaultIndex int // Initial selected index (0-based) + MaxVisible int // Max visible rows in viewport (default: 10 or auto-sized) + AllowFilter bool // Enable '/' live keyword filtering + AllowBack bool // Allow 'b' or Esc to navigate back + BackLabel string // Label for back option (default: "Back") + Prompt string // Optional custom prompt text + Reader *bufio.Reader // Fallback reader for non-TTY mode + In io.Reader // Input source (default: os.Stdin) + Out io.Writer // Output sink (default: os.Stdout) +} + +// SelectMenu presents an interactive selection list. +// In terminal TTY mode, it provides arrow-key (↑/↓, j/k) navigation, scrolling viewports +// for long lists, scroll indicators (▲/▼), live keyword filtering ('/'), direct number jumping, +// and Enter to confirm. +// In non-TTY environments (pipes, scripts, automated tests), it falls back seamlessly to +// a standard numbered prompt. +func SelectMenu(cfg SelectConfig) (int, SelectItem, error) { + if len(cfg.Items) == 0 { + return -1, SelectItem{}, errors.New("cannot display selection menu with empty items") + } + + // Normalize defaults + if cfg.In == nil { + cfg.In = os.Stdin + } + if cfg.Out == nil { + cfg.Out = os.Stdout + } + if cfg.Reader == nil { + cfg.Reader = bufio.NewReader(cfg.In) + } + if cfg.DefaultIndex < 0 || cfg.DefaultIndex >= len(cfg.Items) { + cfg.DefaultIndex = 0 + } + + // Detect if both input and output are interactive TTYs + inFile, inIsFile := cfg.In.(*os.File) + outFile, outIsFile := cfg.Out.(*os.File) + if !inIsFile || !outIsFile || !term.IsTerminal(int(inFile.Fd())) || !term.IsTerminal(int(outFile.Fd())) { + return selectFallback(cfg) + } + + inFd := int(inFile.Fd()) + outFd := int(outFile.Fd()) + + // Put terminal in raw mode for single-keystroke reading and ANSI rendering + oldState, err := term.MakeRaw(inFd) + if err != nil { + return selectFallback(cfg) + } + defer func() { + _ = term.Restore(inFd, oldState) + }() + + // Hide cursor during interactive menu navigation; ensure cursor is always restored on exit + fmt.Fprint(cfg.Out, "\033[?25l") + defer func() { + fmt.Fprint(cfg.Out, "\033[?25h") + }() + + return runInteractiveLoop(cfg, inFd, outFd) +} + +// visibleItem links a visible/filtered item back to its original index in cfg.Items. +type visibleItem struct { + originalIndex int + item SelectItem +} + +// runInteractiveLoop executes the raw-mode event loop for arrow navigation and scrolling. +func runInteractiveLoop(cfg SelectConfig, inFd, outFd int) (int, SelectItem, error) { + // Query terminal dimensions + termWidth, termHeight, err := term.GetSize(outFd) + if err != nil || termWidth <= 0 { + termWidth = 80 + } + if err != nil || termHeight <= 0 { + termHeight = 24 + } + + // Calculate viewport height + maxVisible := cfg.MaxVisible + if maxVisible <= 0 { + maxVisible = 10 + } + if termHeight > 8 && maxVisible > termHeight-7 { + maxVisible = termHeight - 7 + } + if maxVisible < 3 { + maxVisible = 3 + } + + // Build initial visible items list + visible := make([]visibleItem, len(cfg.Items)) + for i, it := range cfg.Items { + visible[i] = visibleItem{originalIndex: i, item: it} + } + + selectedIndex := cfg.DefaultIndex + if selectedIndex >= len(visible) { + selectedIndex = 0 + } + topIndex := 0 + if selectedIndex >= maxVisible { + topIndex = selectedIndex - maxVisible + 1 + } + + filterMode := false + filterQuery := "" + numBuffer := "" + numBufferTime := time.Time{} + lastLineCount := 0 + + bufReader := cfg.Reader + if bufReader == nil { + bufReader = bufio.NewReader(cfg.In) + } + + updateFilter := func() { + if strings.TrimSpace(filterQuery) == "" { + visible = make([]visibleItem, len(cfg.Items)) + for i, it := range cfg.Items { + visible[i] = visibleItem{originalIndex: i, item: it} + } + } else { + q := strings.ToLower(filterQuery) + visible = nil + for i, it := range cfg.Items { + if strings.Contains(strings.ToLower(it.Title), q) || + strings.Contains(strings.ToLower(it.Description), q) || + strings.Contains(strings.ToLower(it.ID), q) { + visible = append(visible, visibleItem{originalIndex: i, item: it}) + } + } + } + selectedIndex = 0 + topIndex = 0 + } + + clampScroll := func() { + if len(visible) == 0 { + selectedIndex = 0 + topIndex = 0 + return + } + if selectedIndex < 0 { + selectedIndex = 0 + } + if selectedIndex >= len(visible) { + selectedIndex = len(visible) - 1 + } + if selectedIndex < topIndex { + topIndex = selectedIndex + } + if selectedIndex >= topIndex+maxVisible { + topIndex = selectedIndex - maxVisible + 1 + } + if topIndex < 0 { + topIndex = 0 + } + maxTop := len(visible) - maxVisible + if maxTop < 0 { + maxTop = 0 + } + if topIndex > maxTop { + topIndex = maxTop + } + } + + for { + clampScroll() + + // Erase previous frame if any + if lastLineCount > 0 { + fmt.Fprintf(cfg.Out, "\033[%dA\033[0J", lastLineCount) + } + + // Render current frame + currentLines := 0 + + // 1. Header / Title + if cfg.Title != "" { + titleText := truncateRunes(cfg.Title, termWidth-1) + fmt.Fprintf(cfg.Out, "\033[1m%s\033[0m\r\n", titleText) + currentLines++ + } + + // 2. Filter Bar / Help Hints + if filterMode { + hint := fmt.Sprintf(" [Filter: %s_] (%d/%d matches) - Esc to clear, Enter to select", + filterQuery, len(visible), len(cfg.Items)) + fmt.Fprintf(cfg.Out, "\033[1;33m%s\033[0m\r\n", truncateRunes(hint, termWidth-1)) + currentLines++ + } else if filterQuery != "" { + hint := fmt.Sprintf(" [Filter: %s] (%d/%d matches) - '/' to edit, Esc to clear", + filterQuery, len(visible), len(cfg.Items)) + fmt.Fprintf(cfg.Out, "\033[33m%s\033[0m\r\n", truncateRunes(hint, termWidth-1)) + currentLines++ + } else { + help := " (Use ↑/↓ or j/k to navigate, Enter to select, '/' to filter, 'b' back, 'q' quit)" + if !cfg.AllowBack { + help = " (Use ↑/↓ or j/k to navigate, Enter to select, '/' to filter, 'q' quit)" + } + fmt.Fprintf(cfg.Out, "\033[2m%s\033[0m\r\n", truncateRunes(help, termWidth-1)) + currentLines++ + } + + // 3. Scroll Up Indicator + if topIndex > 0 { + upHint := fmt.Sprintf(" ▲ (%d more above)", topIndex) + fmt.Fprintf(cfg.Out, "\033[2m%s\033[0m\r\n", truncateRunes(upHint, termWidth-1)) + currentLines++ + } + + // 4. Viewport Items + if len(visible) == 0 { + emptyHint := " (No matching items)" + fmt.Fprintf(cfg.Out, "\033[33m%s\033[0m\r\n", truncateRunes(emptyHint, termWidth-1)) + currentLines++ + } else { + end := topIndex + maxVisible + if end > len(visible) { + end = len(visible) + } + for i := topIndex; i < end; i++ { + vi := visible[i] + itemNum := vi.originalIndex + 1 + desc := "" + if vi.item.Description != "" { + desc = " - " + vi.item.Description + } + + if i == selectedIndex { + line := fmt.Sprintf(" \033[1;36m❯ %2d) %s%s\033[0m", itemNum, vi.item.Title, desc) + plainLen := utf8.RuneCountInString(fmt.Sprintf(" ❯ %2d) %s%s", itemNum, vi.item.Title, desc)) + if plainLen > termWidth-1 { + truncatedTitle := truncateRunes(vi.item.Title+desc, termWidth-10) + line = fmt.Sprintf(" \033[1;36m❯ %2d) %s\033[0m", itemNum, truncatedTitle) + } + fmt.Fprintf(cfg.Out, "%s\r\n", line) + } else { + plain := fmt.Sprintf(" %2d) %s%s", itemNum, vi.item.Title, desc) + if utf8.RuneCountInString(plain) > termWidth-1 { + truncatedTitle := truncateRunes(vi.item.Title+desc, termWidth-10) + plain = fmt.Sprintf(" %2d) %s", itemNum, truncatedTitle) + } + fmt.Fprintf(cfg.Out, "%s\r\n", plain) + } + currentLines++ + } + } + + // 5. Scroll Down Indicator + remainingBelow := len(visible) - (topIndex + maxVisible) + if remainingBelow > 0 { + downHint := fmt.Sprintf(" ▼ (%d more below)", remainingBelow) + fmt.Fprintf(cfg.Out, "\033[2m%s\033[0m\r\n", truncateRunes(downHint, termWidth-1)) + currentLines++ + } + + // 6. Status line + if len(visible) > 0 { + status := fmt.Sprintf(" [Item %d of %d]", selectedIndex+1, len(visible)) + fmt.Fprintf(cfg.Out, "\033[2m%s\033[0m\r\n", truncateRunes(status, termWidth-1)) + currentLines++ + } + + lastLineCount = currentLines + + // Read and process key event + key, err := readKey(bufReader) + if err != nil { + if errors.Is(err, io.EOF) { + return -1, SelectItem{}, ErrSelectQuit + } + return -1, SelectItem{}, err + } + + // Handle key actions + switch key.Type { + case KeyEnter: + if len(visible) == 0 { + continue + } + // Clear interactive menu and display clean confirmation + if lastLineCount > 0 { + fmt.Fprintf(cfg.Out, "\033[%dA\033[0J", lastLineCount) + } + chosen := visible[selectedIndex] + fmt.Fprintf(cfg.Out, "\033[1;32m✓\033[0m \033[1mSelected:\033[0m %s\r\n", chosen.item.Title) + return chosen.originalIndex, chosen.item, nil + + case KeyUp: + if selectedIndex > 0 { + selectedIndex-- + } + + case KeyDown: + if selectedIndex < len(visible)-1 { + selectedIndex++ + } + + case KeyPageUp: + selectedIndex -= maxVisible + if selectedIndex < 0 { + selectedIndex = 0 + } + + case KeyPageDown: + selectedIndex += maxVisible + if selectedIndex >= len(visible) { + selectedIndex = len(visible) - 1 + } + + case KeyHome: + selectedIndex = 0 + topIndex = 0 + + case KeyEnd: + if len(visible) > 0 { + selectedIndex = len(visible) - 1 + } + + case KeyBack: + if filterMode { + filterMode = false + filterQuery = "" + updateFilter() + continue + } + if filterQuery != "" { + filterQuery = "" + updateFilter() + continue + } + if cfg.AllowBack { + if lastLineCount > 0 { + fmt.Fprintf(cfg.Out, "\033[%dA\033[0J", lastLineCount) + } + return -1, SelectItem{}, ErrSelectBack + } + + case KeyQuit: + if lastLineCount > 0 { + fmt.Fprintf(cfg.Out, "\033[%dA\033[0J", lastLineCount) + } + return -1, SelectItem{}, ErrSelectQuit + + case KeyBackspace: + if filterMode && len(filterQuery) > 0 { + r := []rune(filterQuery) + filterQuery = string(r[:len(r)-1]) + updateFilter() + } + + case KeySearch: + if cfg.AllowFilter && !filterMode { + filterMode = true + } + + case KeyRune: + if filterMode { + filterQuery += string(key.Rune) + updateFilter() + continue + } + + // Normal mode key bindings + switch key.Rune { + case 'k', 'K': + if selectedIndex > 0 { + selectedIndex-- + } + case 'j', 'J': + if selectedIndex < len(visible)-1 { + selectedIndex++ + } + case 'g': + selectedIndex = 0 + topIndex = 0 + case 'G': + if len(visible) > 0 { + selectedIndex = len(visible) - 1 + } + case 'b', 'B': + if cfg.AllowBack { + if lastLineCount > 0 { + fmt.Fprintf(cfg.Out, "\033[%dA\033[0J", lastLineCount) + } + return -1, SelectItem{}, ErrSelectBack + } + case 'q', 'Q': + if lastLineCount > 0 { + fmt.Fprintf(cfg.Out, "\033[%dA\033[0J", lastLineCount) + } + return -1, SelectItem{}, ErrSelectQuit + case '/': + if cfg.AllowFilter { + filterMode = true + } + default: + // Number jumping in normal mode (1-9) + if key.Rune >= '0' && key.Rune <= '9' { + if time.Since(numBufferTime) > 800*time.Millisecond { + numBuffer = "" + } + numBuffer += string(key.Rune) + numBufferTime = time.Now() + if val, pErr := strconv.Atoi(numBuffer); pErr == nil && val >= 1 && val <= len(visible) { + selectedIndex = val - 1 + } + } + } + } + } +} + +// selectFallback handles non-TTY environments (pipes, redirected input, automated tests). +func selectFallback(cfg SelectConfig) (int, SelectItem, error) { + for { + if cfg.Title != "" { + fmt.Fprintln(cfg.Out, cfg.Title) + } + for i, it := range cfg.Items { + desc := "" + if it.Description != "" { + desc = " - " + it.Description + } + fmt.Fprintf(cfg.Out, " %2d) %s%s\n", i+1, it.Title, desc) + } + + prompt := cfg.Prompt + if prompt == "" { + if cfg.AllowBack { + prompt = fmt.Sprintf("Enter selection [1-%d] (or 'b' to go back, 'q' to quit)", len(cfg.Items)) + } else { + prompt = fmt.Sprintf("Enter selection [1-%d] (or 'q' to quit)", len(cfg.Items)) + } + } + + defStr := "" + if cfg.DefaultIndex >= 0 && cfg.DefaultIndex < len(cfg.Items) { + defStr = fmt.Sprintf("%d", cfg.DefaultIndex+1) + fmt.Fprintf(cfg.Out, "\n%s [%s]: ", prompt, defStr) + } else { + fmt.Fprintf(cfg.Out, "\n%s: ", prompt) + } + + line, err := cfg.Reader.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) && strings.TrimSpace(line) != "" { + // Process trailing line before EOF + } else { + return -1, SelectItem{}, err + } + } + + line = strings.TrimSpace(line) + if line == "" && defStr != "" { + line = defStr + } + + if (line == "b" || line == ":b" || strings.EqualFold(line, "back")) && cfg.AllowBack { + return -1, SelectItem{}, ErrSelectBack + } + if line == "q" || line == ":q" || strings.EqualFold(line, "quit") || strings.EqualFold(line, "exit") { + return -1, SelectItem{}, ErrSelectQuit + } + + idx, aErr := strconv.Atoi(line) + if aErr == nil && idx >= 1 && idx <= len(cfg.Items) { + return idx - 1, cfg.Items[idx-1], nil + } + + // Also allow matching by title or ID directly + matched := false + for i, it := range cfg.Items { + if strings.EqualFold(it.Title, line) || (it.ID != "" && strings.EqualFold(it.ID, line)) { + return i, it, nil + } + } + + if !matched { + fmt.Fprintln(cfg.Out, "Invalid selection. Please choose a valid item number.") + } + } +} + +// KeyType enumerates categorized terminal key events. +type KeyType int + +const ( + KeyNone KeyType = iota + KeyUp + KeyDown + KeyLeft + KeyRight + KeyPageUp + KeyPageDown + KeyHome + KeyEnd + KeyEnter + KeyBack + KeyQuit + KeyBackspace + KeyTab + KeySearch + KeyRune +) + +// KeyEvent represents a decoded keyboard event. +type KeyEvent struct { + Type KeyType + Rune rune +} + +// readKey decodes single keypresses and ANSI escape sequences from a *bufio.Reader. +func readKey(r *bufio.Reader) (KeyEvent, error) { + b, err := r.ReadByte() + if err != nil { + return KeyEvent{Type: KeyQuit}, err + } + + // Single control bytes + switch b { + case 3, 4: // Ctrl+C, Ctrl+D + return KeyEvent{Type: KeyQuit}, nil + case 10, 13: // Enter (\n, \r) + return KeyEvent{Type: KeyEnter}, nil + case 8, 127: // Backspace + return KeyEvent{Type: KeyBackspace}, nil + case 9: // Tab + return KeyEvent{Type: KeyTab}, nil + case 27: // Escape or ANSI escape sequence + if r.Buffered() == 0 { + return KeyEvent{Type: KeyBack}, nil + } + peek, _ := r.Peek(1) + if len(peek) == 0 || (peek[0] != '[' && peek[0] != 'O') { + return KeyEvent{Type: KeyBack}, nil + } + // Consume '[' or 'O' + _, _ = r.ReadByte() + // Read sequence identifier byte + b3, err := r.ReadByte() + if err != nil { + return KeyEvent{Type: KeyBack}, nil + } + switch b3 { + case 'A': + return KeyEvent{Type: KeyUp}, nil + case 'B': + return KeyEvent{Type: KeyDown}, nil + case 'C': + return KeyEvent{Type: KeyRight}, nil + case 'D': + return KeyEvent{Type: KeyLeft}, nil + case 'H': + return KeyEvent{Type: KeyHome}, nil + case 'F': + return KeyEvent{Type: KeyEnd}, nil + case '1', '7': + if r.Buffered() > 0 { + if tilde, _ := r.Peek(1); len(tilde) > 0 && tilde[0] == '~' { + _, _ = r.ReadByte() + } + } + return KeyEvent{Type: KeyHome}, nil + case '4', '8': + if r.Buffered() > 0 { + if tilde, _ := r.Peek(1); len(tilde) > 0 && tilde[0] == '~' { + _, _ = r.ReadByte() + } + } + return KeyEvent{Type: KeyEnd}, nil + case '5': + if r.Buffered() > 0 { + if tilde, _ := r.Peek(1); len(tilde) > 0 && tilde[0] == '~' { + _, _ = r.ReadByte() + } + } + return KeyEvent{Type: KeyPageUp}, nil + case '6': + if r.Buffered() > 0 { + if tilde, _ := r.Peek(1); len(tilde) > 0 && tilde[0] == '~' { + _, _ = r.ReadByte() + } + } + return KeyEvent{Type: KeyPageDown}, nil + case '3': + if r.Buffered() > 0 { + if tilde, _ := r.Peek(1); len(tilde) > 0 && tilde[0] == '~' { + _, _ = r.ReadByte() + } + } + return KeyEvent{Type: KeyBackspace}, nil // Delete + } + return KeyEvent{Type: KeyBack}, nil + } + + // ASCII printable or UTF-8 + if b < 0x80 { + return KeyEvent{Type: KeyRune, Rune: rune(b)}, nil + } + + // Multi-byte UTF-8 rune decoding + _ = r.UnreadByte() + rChar, _, err := r.ReadRune() + if err != nil { + return KeyEvent{Type: KeyNone}, err + } + return KeyEvent{Type: KeyRune, Rune: rChar}, nil +} + +// truncateRunes clamps string length to maxRunes, appending "..." if exceeded. +func truncateRunes(s string, maxRunes int) string { + if maxRunes <= 3 { + return s + } + runes := []rune(s) + if len(runes) > maxRunes { + return string(runes[:maxRunes-3]) + "..." + } + return s +} + +// selectNavField provides a wizard-compatible selection prompt with navigation support. +// In terminal TTY mode, it provides arrow-key selection; in non-TTY mode, it falls back +// to a numbered prompt compatible with automated test harnesses. +func selectNavField(reader *bufio.Reader, in io.Reader, out io.Writer, title string, prompt string, items []SelectItem, defChoice string) (string, error) { + if in == nil { + in = os.Stdin + } + if out == nil { + out = os.Stdout + } + if reader == nil { + reader = bufio.NewReader(in) + } + + defIdx := -1 + for i, it := range items { + if it.ID == defChoice || strings.EqualFold(it.Title, defChoice) { + defIdx = i + break + } + } + if defIdx < 0 { + if num, err := strconv.Atoi(defChoice); err == nil && num >= 1 && num <= len(items) { + defIdx = num - 1 + } + } + + cfg := SelectConfig{ + Title: title, + Items: items, + DefaultIndex: defIdx, + AllowBack: true, + Prompt: prompt, + Reader: reader, + In: in, + Out: out, + } + + _, item, err := SelectMenu(cfg) + if err != nil { + if errors.Is(err, ErrSelectBack) { + return "", ErrWizardBack + } + return "", err + } + if item.ID != "" { + return item.ID, nil + } + return item.Title, nil +} diff --git a/cmd/myf5/selector_test.go b/cmd/myf5/selector_test.go new file mode 100644 index 0000000..637005f --- /dev/null +++ b/cmd/myf5/selector_test.go @@ -0,0 +1,396 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "strings" + "testing" +) + +func TestSelectFallback_BasicSelection(t *testing.T) { + items := []SelectItem{ + {ID: "1", Title: "Option Alpha", Description: "First option"}, + {ID: "2", Title: "Option Beta", Description: "Second option"}, + {ID: "3", Title: "Option Gamma", Description: "Third option"}, + } + + // 1. Select by number "2" + in := strings.NewReader("2\n") + var out bytes.Buffer + cfg := SelectConfig{ + Title: "Choose an option", + Items: items, + DefaultIndex: 0, + AllowBack: true, + In: in, + Out: &out, + Reader: bufio.NewReader(in), + } + + idx, chosen, err := SelectMenu(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx != 1 || chosen.Title != "Option Beta" { + t.Errorf("expected index 1 (Option Beta), got %d (%s)", idx, chosen.Title) + } + if !strings.Contains(out.String(), "Choose an option") { + t.Errorf("expected title in output") + } + + // 2. Select default by pressing Enter (empty input) + in2 := strings.NewReader("\n") + var out2 bytes.Buffer + cfg2 := SelectConfig{ + Title: "Choose default", + Items: items, + DefaultIndex: 2, // Gamma + AllowBack: true, + In: in2, + Out: &out2, + Reader: bufio.NewReader(in2), + } + idx2, chosen2, err2 := SelectMenu(cfg2) + if err2 != nil { + t.Fatalf("unexpected error on default: %v", err2) + } + if idx2 != 2 || chosen2.Title != "Option Gamma" { + t.Errorf("expected index 2 (Option Gamma), got %d (%s)", idx2, chosen2.Title) + } + + // 3. Retry on invalid input then choose "1" + in3 := strings.NewReader("99\nfoo\n1\n") + var out3 bytes.Buffer + cfg3 := SelectConfig{ + Title: "Retry test", + Items: items, + AllowBack: false, + In: in3, + Out: &out3, + Reader: bufio.NewReader(in3), + } + idx3, chosen3, err3 := SelectMenu(cfg3) + if err3 != nil { + t.Fatalf("unexpected error on retry: %v", err3) + } + if idx3 != 0 || chosen3.Title != "Option Alpha" { + t.Errorf("expected index 0 (Option Alpha), got %d (%s)", idx3, chosen3.Title) + } + if !strings.Contains(out3.String(), "Invalid selection") { + t.Errorf("expected invalid selection notice in output") + } +} + +func TestSelectFallback_BackAndQuit(t *testing.T) { + items := []SelectItem{ + {ID: "1", Title: "First"}, + {ID: "2", Title: "Second"}, + } + + // Back with 'b' + inBack := strings.NewReader("b\n") + var outBack bytes.Buffer + cfgBack := SelectConfig{ + Items: items, + AllowBack: true, + In: inBack, + Out: &outBack, + Reader: bufio.NewReader(inBack), + } + _, _, errBack := SelectMenu(cfgBack) + if !errors.Is(errBack, ErrSelectBack) { + t.Errorf("expected ErrSelectBack, got %v", errBack) + } + + // Quit with 'q' + inQuit := strings.NewReader("q\n") + var outQuit bytes.Buffer + cfgQuit := SelectConfig{ + Items: items, + AllowBack: true, + In: inQuit, + Out: &outQuit, + Reader: bufio.NewReader(inQuit), + } + _, _, errQuit := SelectMenu(cfgQuit) + if !errors.Is(errQuit, ErrSelectQuit) { + t.Errorf("expected ErrSelectQuit, got %v", errQuit) + } +} + +func TestReadKey_Decoding(t *testing.T) { + tests := []struct { + name string + input []byte + expected KeyType + expRune rune + }{ + {"Up Arrow CSI", []byte{0x1b, '[', 'A'}, KeyUp, 0}, + {"Down Arrow CSI", []byte{0x1b, '[', 'B'}, KeyDown, 0}, + {"Right Arrow CSI", []byte{0x1b, '[', 'C'}, KeyRight, 0}, + {"Left Arrow CSI", []byte{0x1b, '[', 'D'}, KeyLeft, 0}, + {"Home CSI", []byte{0x1b, '[', 'H'}, KeyHome, 0}, + {"End CSI", []byte{0x1b, '[', 'F'}, KeyEnd, 0}, + {"PageUp CSI", []byte{0x1b, '[', '5', '~'}, KeyPageUp, 0}, + {"PageDown CSI", []byte{0x1b, '[', '6', '~'}, KeyPageDown, 0}, + {"Up Arrow SS3", []byte{0x1b, 'O', 'A'}, KeyUp, 0}, + {"Down Arrow SS3", []byte{0x1b, 'O', 'B'}, KeyDown, 0}, + {"Enter CR", []byte{'\r'}, KeyEnter, 0}, + {"Enter LF", []byte{'\n'}, KeyEnter, 0}, + {"Ctrl-C", []byte{3}, KeyQuit, 0}, + {"Ctrl-D", []byte{4}, KeyQuit, 0}, + {"Backspace DEL", []byte{127}, KeyBackspace, 0}, + {"Backspace BS", []byte{8}, KeyBackspace, 0}, + {"Tab", []byte{9}, KeyTab, 0}, + {"Standalone Escape", []byte{0x1b}, KeyBack, 0}, + {"Plain Rune 'k'", []byte{'k'}, KeyRune, 'k'}, + {"Plain Rune 'j'", []byte{'j'}, KeyRune, 'j'}, + {"Plain Rune '/'", []byte{'/'}, KeyRune, '/'}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := bufio.NewReader(bytes.NewReader(tc.input)) + key, err := readKey(r) + if err != nil { + t.Fatalf("unexpected readKey error: %v", err) + } + if key.Type != tc.expected { + t.Errorf("expected KeyType %v, got %v", tc.expected, key.Type) + } + if tc.expRune != 0 && key.Rune != tc.expRune { + t.Errorf("expected rune %c, got %c", tc.expRune, key.Rune) + } + }) + } +} + +func TestTruncateRunes(t *testing.T) { + s := "BIG-IP 17.5.1-0.0.12.iso" + if got := truncateRunes(s, 30); got != s { + t.Errorf("expected %q, got %q", s, got) + } + if got := truncateRunes(s, 10); got != "BIG-IP ..." { + t.Errorf("expected 'BIG-IP ...', got %q", got) + } +} + +func TestInteractiveLoop_ArrowNavigation(t *testing.T) { + items := []SelectItem{ + {ID: "1", Title: "First"}, + {ID: "2", Title: "Second"}, + {ID: "3", Title: "Third"}, + {ID: "4", Title: "Fourth"}, + } + + // Press Down arrow twice, then Enter + // Sequence: \x1b[B, \x1b[B, \r + keySeq := []byte{ + 0x1b, '[', 'B', // Down -> item 2 + 0x1b, '[', 'B', // Down -> item 3 + '\r', // Enter + } + in := bytes.NewReader(keySeq) + var out bytes.Buffer + + cfg := SelectConfig{ + Title: "Select One", + Items: items, + DefaultIndex: 0, + In: in, + Out: &out, + } + + idx, chosen, err := runInteractiveLoop(cfg, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx != 2 || chosen.Title != "Third" { + t.Errorf("expected index 2 ('Third'), got %d (%s)", idx, chosen.Title) + } + + // Test j/k navigation: j (down), j (down), k (up), Enter + keySeqJK := []byte{'j', 'j', 'k', '\n'} + inJK := bytes.NewReader(keySeqJK) + var outJK bytes.Buffer + + cfgJK := SelectConfig{ + Items: items, + DefaultIndex: 0, + In: inJK, + Out: &outJK, + } + idxJK, chosenJK, errJK := runInteractiveLoop(cfgJK, 0, 0) + if errJK != nil { + t.Fatalf("unexpected error on j/k: %v", errJK) + } + if idxJK != 1 || chosenJK.Title != "Second" { + t.Errorf("expected index 1 ('Second'), got %d (%s)", idxJK, chosenJK.Title) + } +} + +func TestInteractiveLoop_ScrollingAndViewport(t *testing.T) { + // Create 25 items + var items []SelectItem + for i := 1; i <= 25; i++ { + items = append(items, SelectItem{ + ID: string(rune('A' + i - 1)), + Title: "Item " + string(rune('A'+i-1)), + }) + } + + // Navigate with PageDown and Enter + // Sequence: \x1b[6~ (PageDown), \r (Enter) + keySeq := []byte{ + 0x1b, '[', '6', '~', // PageDown -> jumps by maxVisible + '\r', + } + in := bytes.NewReader(keySeq) + var out bytes.Buffer + + cfg := SelectConfig{ + Title: "Scroll Test", + Items: items, + DefaultIndex: 0, + MaxVisible: 5, + In: in, + Out: &out, + } + + idx, _, err := runInteractiveLoop(cfg, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx < 5 { + t.Errorf("expected selection after PageDown to be >= 5, got %d", idx) + } + + // Output should contain scroll indicators + outStr := out.String() + if !strings.Contains(outStr, "more below") { + t.Errorf("expected 'more below' scroll indicator in output") + } +} + +func TestInteractiveLoop_FilterAndSearch(t *testing.T) { + items := []SelectItem{ + {ID: "iso", Title: "BIG-IP 17.5.1 Base ISO"}, + {ID: "hf", Title: "BIG-IP 17.5.1 Hotfix 1"}, + {ID: "sig", Title: "Security Attack Signatures Update"}, + {ID: "qkview", Title: "iHealth QKView Diagnostic Tool"}, + } + + // Sequence: + // '/' (start filter), 's', 'i', 'g' (type "sig"), \r (Enter) + keySeq := []byte{ + '/', 's', 'i', 'g', '\r', + } + in := bytes.NewReader(keySeq) + var out bytes.Buffer + + cfg := SelectConfig{ + Title: "Filter Test", + Items: items, + AllowFilter: true, + In: in, + Out: &out, + } + + idx, chosen, err := runInteractiveLoop(cfg, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx != 2 || chosen.ID != "sig" { + t.Errorf("expected filtered item 2 ('sig'), got %d (%s)", idx, chosen.Title) + } +} + +func TestInteractiveLoop_HomeEnd(t *testing.T) { + items := []SelectItem{ + {ID: "1", Title: "First"}, + {ID: "2", Title: "Second"}, + {ID: "3", Title: "Third"}, + {ID: "4", Title: "Fourth"}, + } + + // End (\x1b[F), then Enter (\r) -> should select last item (Fourth) + keySeq := []byte{0x1b, '[', 'F', '\r'} + in := bytes.NewReader(keySeq) + var out bytes.Buffer + + cfg := SelectConfig{ + Items: items, + DefaultIndex: 0, + In: in, + Out: &out, + } + idx, chosen, err := runInteractiveLoop(cfg, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx != 3 || chosen.Title != "Fourth" { + t.Errorf("expected last item ('Fourth'), got %d (%s)", idx, chosen.Title) + } +} + +func TestInteractiveLoop_NumberJumping(t *testing.T) { + items := []SelectItem{ + {ID: "1", Title: "Item 1"}, + {ID: "2", Title: "Item 2"}, + {ID: "3", Title: "Item 3"}, + } + + // Press '2', then Enter (\r) -> jumps directly to Item 2 + keySeq := []byte{'2', '\r'} + in := bytes.NewReader(keySeq) + var out bytes.Buffer + + cfg := SelectConfig{ + Items: items, + DefaultIndex: 0, + In: in, + Out: &out, + } + idx, chosen, err := runInteractiveLoop(cfg, 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if idx != 1 || chosen.Title != "Item 2" { + t.Errorf("expected index 1 ('Item 2'), got %d (%s)", idx, chosen.Title) + } +} + +func TestInteractiveLoop_BackAndQuit(t *testing.T) { + items := []SelectItem{ + {ID: "1", Title: "Item 1"}, + } + + // 'b' -> ErrSelectBack + inBack := bytes.NewReader([]byte{'b'}) + var outBack bytes.Buffer + cfgBack := SelectConfig{ + Items: items, + AllowBack: true, + In: inBack, + Out: &outBack, + } + _, _, errBack := runInteractiveLoop(cfgBack, 0, 0) + if !errors.Is(errBack, ErrSelectBack) { + t.Errorf("expected ErrSelectBack, got %v", errBack) + } + + // 'q' -> ErrSelectQuit + inQuit := bytes.NewReader([]byte{'q'}) + var outQuit bytes.Buffer + cfgQuit := SelectConfig{ + Items: items, + AllowBack: true, + In: inQuit, + Out: &outQuit, + } + _, _, errQuit := runInteractiveLoop(cfgQuit, 0, 0) + if !errors.Is(errQuit, ErrSelectQuit) { + t.Errorf("expected ErrSelectQuit, got %v", errQuit) + } +} diff --git a/cmd/myf5/wizard.go b/cmd/myf5/wizard.go index 6b6a820..1ea3e13 100644 --- a/cmd/myf5/wizard.go +++ b/cmd/myf5/wizard.go @@ -388,12 +388,24 @@ func editWizardField(reader *bufio.Reader, out io.Writer, fieldNum int, opts *Ca opts.SerialNumber, err = promptRequiredNavField(reader, out, "Serial Number or RegKey (last 7 chars)", opts.SerialNumber) } case 5: - fmt.Fprintln(out, "Select Case Priority:") - fmt.Fprintln(out, " 1) 1 - Site Down (Critical: production network down)") - fmt.Fprintln(out, " 2) 2 - Site At Risk (High: redundant pair failed)") - fmt.Fprintln(out, " 3) 3 - Performance Degraded (Medium: degraded throughput)") - fmt.Fprintln(out, " 4) 4 - General Assistance (Low: non-urgent questions/config)") - prioChoice, pErr := promptNavField(reader, out, "Select Priority [1-4]", "4") + prioItems := []SelectItem{ + {ID: "1", Title: "1 - Site Down", Description: "Critical: production network down / business halt"}, + {ID: "2", Title: "2 - Site At Risk", Description: "High: redundant pair failed / imminent outage"}, + {ID: "3", Title: "3 - Performance Degraded", Description: "Medium: degraded throughput / viable workaround"}, + {ID: "4", Title: "4 - General Assistance", Description: "Low: questions / non-urgent config help"}, + } + defPrio := "4" + switch opts.Priority { + case cases.PrioritySiteDown: + defPrio = "1" + case cases.PrioritySiteAtRisk: + defPrio = "2" + case cases.PriorityPerformanceDegraded: + defPrio = "3" + case cases.PriorityGeneralAssistance: + defPrio = "4" + } + prioChoice, pErr := selectNavField(reader, nil, out, "Select Case Priority:", "Select Priority [1-4]", prioItems, defPrio) if pErr != nil { if errors.Is(pErr, ErrWizardBack) { return nil @@ -415,13 +427,27 @@ func editWizardField(reader *bufio.Reader, out io.Writer, fieldNum int, opts *Ca } } case 6: - fmt.Fprintln(out, "Select Reason for Contact:") - fmt.Fprintln(out, " 1) General Problem") - fmt.Fprintln(out, " 2) Configuration Assistance") - fmt.Fprintln(out, " 3) Software Bug") - fmt.Fprintln(out, " 4) Hardware Issue") - fmt.Fprintln(out, " 5) Upgrade Assistance") - reasonChoice, rErr := promptNavField(reader, out, "Select Reason [1-5]", "1") + reasonItems := []SelectItem{ + {ID: "1", Title: "General Problem"}, + {ID: "2", Title: "Configuration Assistance"}, + {ID: "3", Title: "Software Bug / Issue"}, + {ID: "4", Title: "Hardware Issue"}, + {ID: "5", Title: "Upgrade Assistance"}, + } + defReason := "1" + switch opts.ReasonForContact { + case "General Problem": + defReason = "1" + case "Configuration Assistance": + defReason = "2" + case "Software Bug": + defReason = "3" + case "Hardware Issue": + defReason = "4" + case "Upgrade Assistance": + defReason = "5" + } + reasonChoice, rErr := selectNavField(reader, nil, out, "Select Reason for Contact:", "Select Reason [1-5]", reasonItems, defReason) if rErr != nil { if errors.Is(rErr, ErrWizardBack) { return nil @@ -552,12 +578,12 @@ func runStep1AutoDetection(ctx context.Context, reader *bufio.Reader, out io.Wri fmt.Fprintln(out, " - Serial Number or License Registration Key") fmt.Fprintln(out, " - Hostname and provisioned modules") fmt.Fprintln(out) - fmt.Fprintln(out, "How would you like to proceed?") - fmt.Fprintln(out, " 1) Connect to a live BIG-IP / BIG-IQ host (via REST/SSH)") - fmt.Fprintln(out, " 2) Inspect a local QKView archive (.qkview file)") - fmt.Fprintln(out, " 3) Skip auto-detection (enter details manually)") - - detectChoice, err := promptNavField(reader, out, "Select option [1-3]", "3") + detectItems := []SelectItem{ + {ID: "1", Title: "Connect to a live BIG-IP / BIG-IQ host (via REST/SSH)"}, + {ID: "2", Title: "Inspect a local QKView archive (.qkview file)"}, + {ID: "3", Title: "Skip auto-detection (enter details manually)"}, + } + detectChoice, err := selectNavField(reader, nil, out, "How would you like to proceed?", "Select option [1-3]", detectItems, "3") if err != nil { if errors.Is(err, ErrWizardBack) { return 1, nil @@ -742,11 +768,12 @@ func runStep3Details(reader *bufio.Reader, out io.Writer, opts *CaseWizardOption for field <= 4 { switch field { case 1: - fmt.Fprintln(out, "Select Case Priority:") - fmt.Fprintln(out, " 1) 1 - Site Down (Critical: production network down / business halt)") - fmt.Fprintln(out, " 2) 2 - Site At Risk (High: redundant pair failed / imminent outage)") - fmt.Fprintln(out, " 3) 3 - Performance Degraded (Medium: degraded throughput / viable workaround)") - fmt.Fprintln(out, " 4) 4 - General Assistance (Low: questions / non-urgent config help)") + prioItems := []SelectItem{ + {ID: "1", Title: "1 - Site Down", Description: "Critical: production network down / business halt"}, + {ID: "2", Title: "2 - Site At Risk", Description: "High: redundant pair failed / imminent outage"}, + {ID: "3", Title: "3 - Performance Degraded", Description: "Medium: degraded throughput / viable workaround"}, + {ID: "4", Title: "4 - General Assistance", Description: "Low: questions / non-urgent config help"}, + } defPrio := "4" switch opts.Priority { @@ -760,7 +787,7 @@ func runStep3Details(reader *bufio.Reader, out io.Writer, opts *CaseWizardOption defPrio = "4" } - prioChoice, err := promptNavField(reader, out, "Select Priority [1-4]", defPrio) + prioChoice, err := selectNavField(reader, nil, out, "Select Case Priority:", "Select Priority [1-4]", prioItems, defPrio) if errors.Is(err, ErrWizardBack) { return 2, 4, nil // go back to Step 2, field 4 (Entitlement) } @@ -783,12 +810,13 @@ func runStep3Details(reader *bufio.Reader, out io.Writer, opts *CaseWizardOption } field = 2 case 2: - fmt.Fprintln(out, "\nSelect Reason for Contact:") - fmt.Fprintln(out, " 1) General Problem") - fmt.Fprintln(out, " 2) Configuration Assistance") - fmt.Fprintln(out, " 3) Software Bug / Issue") - fmt.Fprintln(out, " 4) Hardware Issue") - fmt.Fprintln(out, " 5) Upgrade Assistance") + reasonItems := []SelectItem{ + {ID: "1", Title: "General Problem"}, + {ID: "2", Title: "Configuration Assistance"}, + {ID: "3", Title: "Software Bug / Issue"}, + {ID: "4", Title: "Hardware Issue"}, + {ID: "5", Title: "Upgrade Assistance"}, + } defReason := "1" switch opts.ReasonForContact { @@ -804,7 +832,7 @@ func runStep3Details(reader *bufio.Reader, out io.Writer, opts *CaseWizardOption defReason = "5" } - reasonChoice, err := promptNavField(reader, out, "Select Reason [1-5]", defReason) + reasonChoice, err := selectNavField(reader, nil, out, "Select Reason for Contact:", "Select Reason [1-5]", reasonItems, defReason) if errors.Is(err, ErrWizardBack) { field = 1 // go back to Priority continue