207 lines
6.0 KiB
Go
207 lines
6.0 KiB
Go
package wizard
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/huh"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
// Run presents the six-screen wizard. Returns the final state and whether
|
|
// the user confirmed. A context cancellation returns (state, false, nil).
|
|
func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tui.WizardState, bool, error) {
|
|
s := initial
|
|
|
|
// Honour an already-cancelled context before Huh tries to open /dev/tty.
|
|
if err := ctx.Err(); err != nil {
|
|
return s, false, nil
|
|
}
|
|
|
|
themeOptions := make([]huh.Option[string], 0, len(tui.BuiltinThemes))
|
|
for _, t := range tui.BuiltinThemes {
|
|
themeOptions = append(themeOptions, huh.NewOption(t.Name, t.ID))
|
|
}
|
|
|
|
moduleOptions := make([]huh.Option[string], 0, len(s.Modules))
|
|
defaults := make([]string, 0, len(s.Modules))
|
|
for _, m := range s.Modules {
|
|
label := m.Name
|
|
if m.Description != "" {
|
|
label = fmt.Sprintf("%s — %s", m.Name, m.Description)
|
|
}
|
|
moduleOptions = append(moduleOptions, huh.NewOption(label, m.ID))
|
|
if m.Selected {
|
|
defaults = append(defaults, m.ID)
|
|
}
|
|
}
|
|
|
|
selectedModules := defaults
|
|
parallelismStr := fmt.Sprintf("%d", s.Parallelism)
|
|
var proceed bool
|
|
cache := newPlanCache()
|
|
|
|
form := huh.NewForm(
|
|
huh.NewGroup(huh.NewNote().
|
|
Title("Archduino Installer").
|
|
Description("A TUI Arduino development environment.\n\nPress Enter to continue.")),
|
|
|
|
huh.NewGroup(huh.NewSelect[string]().
|
|
Title("Pick a colorscheme").
|
|
Options(themeOptions...).
|
|
Value(&s.ThemeID)),
|
|
|
|
huh.NewGroup(huh.NewMultiSelect[string]().
|
|
Title("Select modules to install").
|
|
Options(moduleOptions...).
|
|
Value(&selectedModules)),
|
|
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Parallelism (advanced)").
|
|
Description("How many modules to run concurrently. 1 is safe.").
|
|
Value(¶llelismStr),
|
|
huh.NewConfirm().
|
|
Title("Halt on first error?").
|
|
Value(&s.HaltOnError),
|
|
),
|
|
|
|
huh.NewGroup(huh.NewNote().
|
|
Title("Review plan").
|
|
DescriptionFunc(func() string {
|
|
s.Modules = syncSelected(s.Modules, selectedModules)
|
|
return renderPlanCached(ctx, reg, s.SelectedIDs(), cache)
|
|
}, &selectedModules)),
|
|
|
|
huh.NewGroup(huh.NewConfirm().
|
|
Title("Proceed with installation?").
|
|
Value(&proceed)),
|
|
)
|
|
|
|
if err := form.RunWithContext(ctx); err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, huh.ErrUserAborted) {
|
|
return s, false, nil
|
|
}
|
|
return s, false, err
|
|
}
|
|
|
|
// Redundant-but-safe: the DescriptionFunc closure on the plan-review
|
|
// screen already kept s.Modules in sync. The MultiSelect group is
|
|
// frozen by the time we reach here, so this is idempotent. Kept for
|
|
// the case where the form is exited via a path that bypasses the
|
|
// plan-review screen in future revisions.
|
|
s.Modules = syncSelected(s.Modules, selectedModules)
|
|
s.Parallelism = parseIntClamp(parallelismStr, 1)
|
|
s.Confirmed = proceed
|
|
return s, proceed, nil
|
|
}
|
|
|
|
// syncSelected returns a copy of mods with Selected set to whether the ID
|
|
// appears in selected.
|
|
func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice {
|
|
set := map[string]bool{}
|
|
for _, id := range selected {
|
|
set[id] = true
|
|
}
|
|
out := make([]tui.ModuleChoice, len(mods))
|
|
copy(out, mods)
|
|
for i := range out {
|
|
out[i].Selected = set[out[i].ID]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// planCache memoises rendered Detect+Plan output per module ID so
|
|
// DescriptionFunc (which fires on every redraw) does not re-run
|
|
// potentially expensive Detect/Plan calls. Entries are evicted when a
|
|
// module leaves the current selection.
|
|
type planCacheEntry struct{ out string }
|
|
type planCache struct{ entries map[string]planCacheEntry }
|
|
|
|
func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} }
|
|
|
|
func (c *planCache) get(id string) (string, bool) {
|
|
e, ok := c.entries[id]
|
|
return e.out, ok
|
|
}
|
|
func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} }
|
|
func (c *planCache) prune(keepIDs []string) {
|
|
keep := map[string]bool{}
|
|
for _, id := range keepIDs {
|
|
keep[id] = true
|
|
}
|
|
for k := range c.entries {
|
|
if !keep[k] {
|
|
delete(c.entries, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// renderPlanCached is the cached equivalent of running Detect+Plan for
|
|
// every selected module and joining the rendered lines. IDs no longer in
|
|
// the selection are evicted so a deselect+reselect re-runs Detect.
|
|
func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string {
|
|
cache.prune(selectedIDs)
|
|
if len(selectedIDs) == 0 {
|
|
return "No modules selected."
|
|
}
|
|
var b strings.Builder
|
|
for _, id := range selectedIDs {
|
|
if cached, ok := cache.get(id); ok {
|
|
b.WriteString(cached)
|
|
continue
|
|
}
|
|
line := renderOne(ctx, reg, id)
|
|
cache.put(id, line)
|
|
b.WriteString(line)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// renderOne runs Detect+Plan for a single module and returns a rendered
|
|
// line. A failing Detect/Plan is shown inline but does not abort.
|
|
func renderOne(ctx context.Context, reg *module.Registry, id string) string {
|
|
m, ok := reg.Get(id)
|
|
if !ok {
|
|
return fmt.Sprintf("• %s — (unknown)\n", id)
|
|
}
|
|
state, err := m.Detect(ctx)
|
|
if err != nil {
|
|
return fmt.Sprintf("• %s — detect failed: %v\n", id, err)
|
|
}
|
|
plan, err := m.Plan(ctx, state)
|
|
if err != nil {
|
|
return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
|
|
}
|
|
if plan.IsEmpty() {
|
|
return fmt.Sprintf("• %s — already up to date\n", id)
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "• %s\n", id)
|
|
for _, a := range plan.Actions {
|
|
fmt.Fprintf(&b, " - %s\n", a.Description)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// parseIntClamp parses s as a positive int. Empty, non-numeric (including
|
|
// partial matches like "3abc"), negative, zero, or overflowing input all
|
|
// return fallback. strconv.Atoi (not fmt.Sscanf) is used so partial matches
|
|
// are rejected and overflow surfaces as an error.
|
|
func parseIntClamp(s string, fallback int) int {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 1 {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|