diff --git a/internal/tui/wizard/wizard.go b/internal/tui/wizard/wizard.go index a6242fa..a3ec35a 100644 --- a/internal/tui/wizard/wizard.go +++ b/internal/tui/wizard/wizard.go @@ -44,6 +44,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu selectedModules := defaults parallelismStr := fmt.Sprintf("%d", s.Parallelism) var proceed bool + cache := newPlanCache() form := huh.NewForm( huh.NewGroup(huh.NewNote(). @@ -74,7 +75,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu Title("Review plan"). DescriptionFunc(func() string { s.Modules = syncSelected(s.Modules, selectedModules) - return renderPlan(ctx, reg, s.SelectedIDs()) + return renderPlanCached(ctx, reg, s.SelectedIDs(), cache) }, &selectedModules)), huh.NewGroup(huh.NewConfirm(). @@ -115,38 +116,75 @@ func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice return out } -// renderPlan runs Detect + Plan for every selected module and renders a -// human-readable summary. A failing Detect/Plan is shown inline but does -// not abort the wizard. -func renderPlan(ctx context.Context, reg *module.Registry, selectedIDs []string) string { +// 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 { - m, ok := reg.Get(id) - if !ok { - fmt.Fprintf(&b, "• %s — (unknown)\n", id) + if cached, ok := cache.get(id); ok { + b.WriteString(cached) continue } - state, err := m.Detect(ctx) - if err != nil { - fmt.Fprintf(&b, "• %s — detect failed: %v\n", id, err) - continue - } - plan, err := m.Plan(ctx, state) - if err != nil { - fmt.Fprintf(&b, "• %s — plan failed: %v\n", id, err) - continue - } - if plan.IsEmpty() { - fmt.Fprintf(&b, "• %s — already up to date\n", id) - continue - } - fmt.Fprintf(&b, "• %s\n", id) - for _, a := range plan.Actions { - fmt.Fprintf(&b, " - %s\n", a.Description) - } + 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() } diff --git a/internal/tui/wizard/wizard_test.go b/internal/tui/wizard/wizard_test.go index f2a886f..9607955 100644 --- a/internal/tui/wizard/wizard_test.go +++ b/internal/tui/wizard/wizard_test.go @@ -31,14 +31,14 @@ func TestSyncSelected(t *testing.T) { func TestRenderPlanWithHello(t *testing.T) { reg := module.NewRegistry() _ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"}) - out := renderPlan(context.Background(), reg, []string{"hello"}) + out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache()) if !strings.Contains(out, "hello") { t.Fatalf("output missing hello: %q", out) } } func TestRenderPlanEmpty(t *testing.T) { - if got := renderPlan(context.Background(), module.NewRegistry(), nil); got != "No modules selected." { + if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache()); got != "No modules selected." { t.Fatalf("got %q", got) } } @@ -63,6 +63,65 @@ func TestParseIntClamp(t *testing.T) { } } +func TestRenderPlanCachesDetectAndPlan(t *testing.T) { + var detects, plans int + m := countingModule{ + meta: module.Meta{ID: "count", Name: "Count"}, + onDetect: func() { detects++ }, + onPlan: func() { plans++ }, + } + reg := module.NewRegistry() + if err := reg.Register(m); err != nil { + t.Fatal(err) + } + cache := newPlanCache() + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + if detects != 1 || plans != 1 { + t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans) + } +} + +func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) { + var detects int + m := countingModule{ + meta: module.Meta{ID: "count", Name: "Count"}, + onDetect: func() { detects++ }, + } + reg := module.NewRegistry() + _ = reg.Register(m) + cache := newPlanCache() + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + if detects != 2 { + t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects) + } +} + +type countingModule struct { + meta module.Meta + onDetect func() + onPlan func() +} + +func (c countingModule) Meta() module.Meta { return c.meta } +func (c countingModule) Detect(context.Context) (module.State, error) { + if c.onDetect != nil { + c.onDetect() + } + return nil, nil +} +func (c countingModule) Plan(context.Context, module.State) (module.Plan, error) { + if c.onPlan != nil { + c.onPlan() + } + return module.Plan{}, nil +} +func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (c countingModule) Verify(context.Context) error { return nil } + func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel()