perf(tui): cache Detect+Plan results in wizard renderPlan

This commit is contained in:
MDietermann
2026-04-14 08:18:38 +02:00
parent 05dc8aa7f5
commit 83bb622232
2 changed files with 125 additions and 28 deletions

View File

@@ -44,6 +44,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
selectedModules := defaults selectedModules := defaults
parallelismStr := fmt.Sprintf("%d", s.Parallelism) parallelismStr := fmt.Sprintf("%d", s.Parallelism)
var proceed bool var proceed bool
cache := newPlanCache()
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup(huh.NewNote(). huh.NewGroup(huh.NewNote().
@@ -74,7 +75,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
Title("Review plan"). Title("Review plan").
DescriptionFunc(func() string { DescriptionFunc(func() string {
s.Modules = syncSelected(s.Modules, selectedModules) s.Modules = syncSelected(s.Modules, selectedModules)
return renderPlan(ctx, reg, s.SelectedIDs()) return renderPlanCached(ctx, reg, s.SelectedIDs(), cache)
}, &selectedModules)), }, &selectedModules)),
huh.NewGroup(huh.NewConfirm(). huh.NewGroup(huh.NewConfirm().
@@ -115,39 +116,76 @@ func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice
return out return out
} }
// renderPlan runs Detect + Plan for every selected module and renders a // planCache memoises rendered Detect+Plan output per module ID so
// human-readable summary. A failing Detect/Plan is shown inline but does // DescriptionFunc (which fires on every redraw) does not re-run
// not abort the wizard. // potentially expensive Detect/Plan calls. Entries are evicted when a
func renderPlan(ctx context.Context, reg *module.Registry, selectedIDs []string) string { // 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 { if len(selectedIDs) == 0 {
return "No modules selected." return "No modules selected."
} }
var b strings.Builder var b strings.Builder
for _, id := range selectedIDs { 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) m, ok := reg.Get(id)
if !ok { if !ok {
fmt.Fprintf(&b, "• %s — (unknown)\n", id) return fmt.Sprintf("• %s — (unknown)\n", id)
continue
} }
state, err := m.Detect(ctx) state, err := m.Detect(ctx)
if err != nil { if err != nil {
fmt.Fprintf(&b, "• %s — detect failed: %v\n", id, err) return fmt.Sprintf("• %s — detect failed: %v\n", id, err)
continue
} }
plan, err := m.Plan(ctx, state) plan, err := m.Plan(ctx, state)
if err != nil { if err != nil {
fmt.Fprintf(&b, "• %s — plan failed: %v\n", id, err) return fmt.Sprintf("• %s — plan failed: %v\n", id, err)
continue
} }
if plan.IsEmpty() { if plan.IsEmpty() {
fmt.Fprintf(&b, "• %s — already up to date\n", id) return fmt.Sprintf("• %s — already up to date\n", id)
continue
} }
var b strings.Builder
fmt.Fprintf(&b, "• %s\n", id) fmt.Fprintf(&b, "• %s\n", id)
for _, a := range plan.Actions { for _, a := range plan.Actions {
fmt.Fprintf(&b, " - %s\n", a.Description) fmt.Fprintf(&b, " - %s\n", a.Description)
} }
}
return b.String() return b.String()
} }

View File

@@ -31,14 +31,14 @@ func TestSyncSelected(t *testing.T) {
func TestRenderPlanWithHello(t *testing.T) { func TestRenderPlanWithHello(t *testing.T) {
reg := module.NewRegistry() reg := module.NewRegistry()
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"}) _ = 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") { if !strings.Contains(out, "hello") {
t.Fatalf("output missing hello: %q", out) t.Fatalf("output missing hello: %q", out)
} }
} }
func TestRenderPlanEmpty(t *testing.T) { 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) 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) { func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()