diff --git a/internal/cli/commands/catalog/catalog_redesign.go b/internal/cli/commands/catalog/catalog_redesign.go index d13b09cf4e..a72445a6a0 100644 --- a/internal/cli/commands/catalog/catalog_redesign.go +++ b/internal/cli/commands/catalog/catalog_redesign.go @@ -3,11 +3,14 @@ package catalog import ( "context" "fmt" + "runtime" - "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" + "golang.org/x/sync/errgroup" + + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/redesign" "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/services/catalog" - "github.com/gruntwork-io/terragrunt/internal/util" + "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" @@ -25,49 +28,117 @@ func runRedesign(ctx context.Context, l log.Logger, opts *options.TerragruntOpti return runDefault(ctx, l, opts, repoURL) } - return tui.RunRedesign(ctx, l, opts, func(ctx context.Context, status tui.StatusFunc) (catalog.CatalogService, error) { - status("Scanning terragrunt.hcl files for module sources...") + return redesign.RunRedesign( + ctx, l, opts, + func( + ctx context.Context, status redesign.StatusFunc, moduleCh chan<- *module.Module, + ) (catalog.CatalogService, error) { + svc := catalog.NewCatalogService(opts) + + onModule := func(mod *module.Module) { + select { + case moduleCh <- mod: + case <-ctx.Done(): + } + } + + urlCh := make(chan string, 10) //nolint:mnd + + g, gctx := errgroup.WithContext(ctx) + + g.Go(func() error { + return discoverCatalogConfigURLs(gctx, l, opts, urlCh) + }) + + g.Go(func() error { + return discoverSourceFileURLs(gctx, l, opts, urlCh) + }) + + go func() { + _ = g.Wait() - // Create parsing context for source discovery and catalog config - ctx, pctx := configbridge.NewParsingContext(ctx, l, opts) + close(urlCh) + }() - // Discover source URLs from terraform.source in terragrunt.hcl files - discoveredURLs, err := DiscoverSourceURLs(ctx, l, pctx) - if err != nil { - l.Warnf("Failed to discover source URLs: %v", err) - } + status("Discovering catalog sources...") - // Also read catalog config if it exists - catalogCfg, catalogErr := config.ReadCatalogConfig(ctx, l, pctx) - if catalogErr != nil { - l.Debugf("No catalog config found: %v", catalogErr) - } + maxWorkers := max(1, min(opts.Parallelism, runtime.GOMAXPROCS(0))) - // Merge: catalog URLs first, then discovered URLs - var allURLs []string - if catalogCfg != nil { - allURLs = append(allURLs, catalogCfg.URLs...) - } + loaders, loadCtx := errgroup.WithContext(gctx) + loaders.SetLimit(maxWorkers) - allURLs = append(allURLs, discoveredURLs...) - allURLs = util.RemoveDuplicates(allURLs) + seen := make(map[string]struct{}) - if len(allURLs) == 0 { - return nil, nil - } + for repoURL := range urlCh { + if _, ok := seen[repoURL]; ok { + continue + } - status(fmt.Sprintf("Found %d source(s), cloning repositories...", len(allURLs))) + seen[repoURL] = struct{}{} - // Load modules from all discovered repos - svc := catalog.NewCatalogService(opts) - svc.WithRepoURLs(allURLs) + loaders.Go(func() error { + if err := svc.LoadStreamingURL(loadCtx, l, repoURL, onModule); err != nil { + // Individual repo failures are non-critical — warn and + // continue so remaining repos can still load. + l.Warnf("Error loading %s: %v", repoURL, err) + } + + return nil + }) + } + + if err := loaders.Wait(); err != nil { + return nil, fmt.Errorf("loading modules: %w", err) + } + + if err := g.Wait(); err != nil { + return nil, fmt.Errorf("discovering sources: %w", err) + } + + if len(svc.Modules()) == 0 { + return nil, nil + } + + return svc, nil + }) +} - if err := svc.Load(ctx, l); err != nil { - return svc, err - } +// discoverCatalogConfigURLs reads catalog URLs from the root config and +// sends each to urlCh. +func discoverCatalogConfigURLs(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, urlCh chan<- string) error { + _, pctx := configbridge.NewParsingContext(ctx, l, opts) - status(fmt.Sprintf("Found %d module(s), loading catalog...", len(svc.Modules()))) + catalogCfg, err := config.ReadCatalogConfig(ctx, l, pctx) + if err != nil { + l.Debugf("No catalog config found: %v", err) + return nil + } + + if catalogCfg == nil { + return nil + } + + for _, u := range catalogCfg.URLs { + urlCh <- u + } + + return nil +} + +// discoverSourceFileURLs walks terragrunt.hcl files, extracts +// terraform.source URLs, and sends each repo URL to urlCh. +func discoverSourceFileURLs(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, urlCh chan<- string) error { + ctx, pctx := configbridge.NewParsingContext(ctx, l, opts) + + urls, err := redesign.DiscoverSourceURLs(ctx, l, pctx) + if err != nil { + l.Warnf("Failed to discover source URLs: %v", err) + return nil + } + + for _, u := range urls { + urlCh <- u + } - return svc, nil - }) + return nil } diff --git a/internal/cli/commands/catalog/tui/delegate.go b/internal/cli/commands/catalog/tui/delegate.go index b4534ac82b..305d980661 100644 --- a/internal/cli/commands/catalog/tui/delegate.go +++ b/internal/cli/commands/catalog/tui/delegate.go @@ -14,7 +14,7 @@ const ( selectedDescBorderForegroundColorDark = "#63C5DA" ) -func newItemDelegate(keys *delegateKeyMap) list.DefaultDelegate { +func NewItemDelegate(keys *DelegateKeyMap) list.DefaultDelegate { d := list.NewDefaultDelegate() d.Styles.SelectedTitle = d.Styles.SelectedTitle. @@ -25,7 +25,7 @@ func newItemDelegate(keys *delegateKeyMap) list.DefaultDelegate { Foreground(lipgloss.Color(selectedDescForegroundColorDark)). BorderForeground(lipgloss.Color(selectedDescBorderForegroundColorDark)) - help := []key.Binding{keys.choose, keys.scaffold} + help := []key.Binding{keys.Choose, keys.Scaffold} d.ShortHelpFunc = func() []key.Binding { return help diff --git a/internal/cli/commands/catalog/tui/keys.go b/internal/cli/commands/catalog/tui/keys.go index e2bdc9d91d..af2fda2688 100644 --- a/internal/cli/commands/catalog/tui/keys.go +++ b/internal/cli/commands/catalog/tui/keys.go @@ -7,8 +7,8 @@ import ( "charm.land/bubbles/v2/viewport" ) -// newListKeyMap returns a set of keybindings for the list view. -func newListKeyMap() list.KeyMap { +// NewListKeyMap returns a set of keybindings for the list view. +func NewListKeyMap() list.KeyMap { return list.KeyMap{ // Browsing. CursorUp: key.NewBinding( @@ -73,51 +73,51 @@ func newListKeyMap() list.KeyMap { } } -type delegateKeyMap struct { - choose key.Binding - scaffold key.Binding +type DelegateKeyMap struct { + Choose key.Binding + Scaffold key.Binding } -// Additional short help entries. This satisfies the help.KeyMap interface and +// ShortHelp returns additional short help entries. This satisfies the help.KeyMap interface and // is entirely optional. -func (d delegateKeyMap) ShortHelp() []key.Binding { //nolint:gocritic +func (d DelegateKeyMap) ShortHelp() []key.Binding { //nolint:gocritic return []key.Binding{ - d.choose, - d.scaffold, + d.Choose, + d.Scaffold, } } -// Additional full help entries. This satisfies the help.KeyMap interface and +// FullHelp returns additional full help entries. This satisfies the help.KeyMap interface and // is entirely optional. -func (d delegateKeyMap) FullHelp() [][]key.Binding { //nolint:gocritic +func (d DelegateKeyMap) FullHelp() [][]key.Binding { //nolint:gocritic return [][]key.Binding{ { - d.choose, - d.scaffold, + d.Choose, + d.Scaffold, }, } } -// newDelegateKeyMap returns a set of keybindings. -func newDelegateKeyMap() *delegateKeyMap { - return &delegateKeyMap{ - choose: key.NewBinding( +// NewDelegateKeyMap returns a set of keybindings. +func NewDelegateKeyMap() *DelegateKeyMap { + return &DelegateKeyMap{ + Choose: key.NewBinding( key.WithKeys("enter", "ctrl-j"), key.WithHelp("enter/ctrl-j", "choose"), ), - scaffold: key.NewBinding( + Scaffold: key.NewBinding( key.WithKeys("S", "s"), key.WithHelp("S", "Scaffold"), ), } } -// pagerKeyMap returns a set of keybindings for the pager. It satisfies to the +// PagerKeyMap returns a set of keybindings for the pager. It satisfies to the // help.KeyMap interface, which is used to render the menu. -type pagerKeyMap struct { +type PagerKeyMap struct { viewport.KeyMap - help help.Model + HelpModel help.Model // Button navigation Navigation key.Binding @@ -143,7 +143,7 @@ type pagerKeyMap struct { // ShortHelp returns keybindings to be shown in the mini help view. It's part // of the key.Map interface. -func (keys pagerKeyMap) ShortHelp() []key.Binding { //nolint:gocritic +func (keys PagerKeyMap) ShortHelp() []key.Binding { //nolint:gocritic return []key.Binding{ keys.Up, keys.Down, @@ -158,7 +158,7 @@ func (keys pagerKeyMap) ShortHelp() []key.Binding { //nolint:gocritic // FullHelp returns keybindings for the expanded help view. It's part of the // key.Map interface. -func (keys pagerKeyMap) FullHelp() [][]key.Binding { //nolint:gocritic +func (keys PagerKeyMap) FullHelp() [][]key.Binding { //nolint:gocritic return [][]key.Binding{ {keys.Up, keys.Down, keys.PageDown, keys.PageUp}, // first column {keys.Navigation, keys.NavigationBack, keys.Choose, keys.Scaffold}, // second column @@ -166,9 +166,9 @@ func (keys pagerKeyMap) FullHelp() [][]key.Binding { //nolint:gocritic } } -// newPagerKeyMap returns a set of keybindings for the pager view. -func newPagerKeyMap() pagerKeyMap { - return pagerKeyMap{ +// NewPagerKeyMap returns a set of keybindings for the pager view. +func NewPagerKeyMap() PagerKeyMap { + return PagerKeyMap{ KeyMap: viewport.KeyMap{ HalfPageUp: key.NewBinding( key.WithDisabled(), @@ -193,7 +193,7 @@ func newPagerKeyMap() pagerKeyMap { key.WithHelp("h/←/pgup/alt+v", "page up"), ), }, - help: help.New(), + HelpModel: help.New(), Navigation: key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "navigation"), diff --git a/internal/cli/commands/catalog/tui/model.go b/internal/cli/commands/catalog/tui/model.go index d47435deba..881a55a3a6 100644 --- a/internal/cli/commands/catalog/tui/model.go +++ b/internal/cli/commands/catalog/tui/model.go @@ -1,6 +1,9 @@ package tui import ( + "sort" + "strings" + "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" @@ -51,14 +54,14 @@ func (b button) String() string { type Model struct { List list.Model logger log.Logger - terragruntOptions *options.TerragruntOptions SVC catalog.CatalogService + terragruntOptions *options.TerragruntOptions selectedModule *module.Module - delegateKeys *delegateKeyMap + delegateKeys *DelegateKeyMap buttonBar *buttonbar.ButtonBar - currentPagerButtons []button - pagerKeys pagerKeyMap + pagerKeys PagerKeyMap listKeys list.KeyMap + currentPagerButtons []button viewport viewport.Model activeButton button State sessionState @@ -68,34 +71,33 @@ type Model struct { } func NewModel(l log.Logger, opts *options.TerragruntOptions, svc catalog.CatalogService) Model { - var ( - modules = svc.Modules() - items = make([]list.Item, 0, len(modules)) - listKeys = newListKeyMap() - delegateKeys = newDelegateKeyMap() - pagerKeys = newPagerKeyMap() - ) - - // Make the initial list of items - for _, module := range modules { - items = append(items, module) + modules := svc.Modules() + items := make([]list.Item, 0, len(modules)) + + for _, mod := range modules { + items = append(items, mod) } - // Setup the list - delegate := newItemDelegate(delegateKeys) - list := list.New(items, delegate, 0, 0) - list.KeyMap = listKeys - list.SetFilteringEnabled(true) - list.Title = title - list.Styles.Title = lipgloss.NewStyle(). + sort.Slice(items, func(i, j int) bool { + return strings.ToLower(items[i].(*module.Module).Title()) < strings.ToLower(items[j].(*module.Module).Title()) + }) + + listKeys := NewListKeyMap() + delegateKeys := NewDelegateKeyMap() + pagerKeys := NewPagerKeyMap() + + delegate := NewItemDelegate(delegateKeys) + lst := list.New(items, delegate, 0, 0) + lst.KeyMap = listKeys + lst.SetFilteringEnabled(true) + lst.Title = title + lst.Styles.Title = lipgloss.NewStyle(). Foreground(lipgloss.Color(titleForegroundColor)). Background(lipgloss.Color(titleBackgroundColor)). Padding(0, 1) - // Setup the markdown viewer vp := viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)) - // Setup the button bar bs := make([]string, len(availableButtons)) for i, b := range availableButtons { bs[i] = b.String() @@ -104,7 +106,7 @@ func NewModel(l log.Logger, opts *options.TerragruntOptions, svc catalog.Catalog bb := buttonbar.New(bs) return Model{ - List: list, + List: lst, listKeys: listKeys, delegateKeys: delegateKeys, viewport: vp, @@ -118,7 +120,5 @@ func NewModel(l log.Logger, opts *options.TerragruntOptions, svc catalog.Catalog // Init implements bubbletea.Model.Init func (m Model) Init() tea.Cmd { //nolint:gocritic - return tea.Batch( - m.buttonBar.Init(), - ) + return m.buttonBar.Init() } diff --git a/internal/cli/commands/catalog/tui/redesign/model.go b/internal/cli/commands/catalog/tui/redesign/model.go new file mode 100644 index 0000000000..981b16c9ca --- /dev/null +++ b/internal/cli/commands/catalog/tui/redesign/model.go @@ -0,0 +1,228 @@ +// Package redesign implements the redesigned catalog TUI experience with +// streaming module discovery and a welcome loading screen. +package redesign + +import ( + "sort" + "strings" + + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/components/buttonbar" + "github.com/gruntwork-io/terragrunt/internal/services/catalog" + "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" + "github.com/gruntwork-io/terragrunt/pkg/log" + "github.com/gruntwork-io/terragrunt/pkg/options" +) + +// sessionState keeps track of the view we are currently on. +type sessionState int + +// button is a button in the buttonbar component. +type button int + +const ( + title = "List of Modules" + + titleForegroundColor = "#A8ACB1" + titleBackgroundColor = "#1D252F" +) + +const ( + ListState sessionState = iota + PagerState + ScaffoldState +) + +const ( + scaffoldBtn button = iota + viewSourceBtn +) + +var ( + availableButtons = []button{scaffoldBtn, viewSourceBtn} +) + +func (b button) String() string { + return []string{ + "Scaffold", + "View Source in Browser", + }[b] +} + +type Model struct { + List list.Model + logger log.Logger + SVC catalog.CatalogService + terragruntOptions *options.TerragruntOptions + selectedModule *module.Module + delegateKeys *tui.DelegateKeyMap + buttonBar *buttonbar.ButtonBar + moduleCh chan *module.Module + pagerKeys tui.PagerKeyMap + listKeys list.KeyMap + currentPagerButtons []button + viewport viewport.Model + activeButton button + State sessionState + height int + width int + ready bool + loading bool + userNavigated bool +} + +func NewModel(l log.Logger, opts *options.TerragruntOptions, svc catalog.CatalogService) Model { + modules := svc.Modules() + items := make([]list.Item, 0, len(modules)) + + for _, mod := range modules { + items = append(items, mod) + } + + sort.Slice(items, func(i, j int) bool { + return strings.ToLower(items[i].(*module.Module).Title()) < strings.ToLower(items[j].(*module.Module).Title()) + }) + + return newModelWithItems(l, opts, svc, items, nil) +} + +// NewModelStreaming creates a Model with a single initial module and a channel +// for receiving additional modules as they are discovered. +func NewModelStreaming(l log.Logger, opts *options.TerragruntOptions, initial *module.Module, moduleCh chan *module.Module) Model { + items := []list.Item{initial} + + m := newModelWithItems(l, opts, nil, items, moduleCh) + m.loading = true + + return m +} + +func newModelWithItems(l log.Logger, opts *options.TerragruntOptions, svc catalog.CatalogService, items []list.Item, moduleCh chan *module.Module) Model { + listKeys := tui.NewListKeyMap() + delegateKeys := tui.NewDelegateKeyMap() + pagerKeys := tui.NewPagerKeyMap() + + delegate := tui.NewItemDelegate(delegateKeys) + lst := list.New(items, delegate, 0, 0) + lst.KeyMap = listKeys + lst.SetFilteringEnabled(true) + lst.Title = title + lst.Styles.Title = lipgloss.NewStyle(). + Foreground(lipgloss.Color(titleForegroundColor)). + Background(lipgloss.Color(titleBackgroundColor)). + Padding(0, 1) + + vp := viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)) + + bs := make([]string, len(availableButtons)) + for i, b := range availableButtons { + bs[i] = b.String() + } + + bb := buttonbar.New(bs) + + return Model{ + List: lst, + listKeys: listKeys, + delegateKeys: delegateKeys, + viewport: vp, + buttonBar: bb, + pagerKeys: pagerKeys, + terragruntOptions: opts, + SVC: svc, + logger: l, + moduleCh: moduleCh, + } +} + +// insertModuleSorted inserts a module into the list in alphabetical order, +// skipping duplicates. If the user has started navigating, the cursor stays +// on the currently selected item. Otherwise it stays at the top of the list. +func (m *Model) insertModuleSorted(mod *module.Module) tea.Cmd { + if mod == nil { + return nil + } + + items := m.List.Items() + modTitle := mod.Title() + + // Binary search finds the insertion point by title for sort order. + insertIdx := sort.Search(len(items), func(i int) bool { + if existing, ok := items[i].(*module.Module); ok { + return strings.ToLower(existing.Title()) >= strings.ToLower(modTitle) + } + + return false + }) + + // De-duplicate by source path, not title, so distinct modules that + // share a display name are not collapsed. + if isDuplicate(items, mod.TerraformSourcePath()) { + return nil + } + + currentIdx := m.List.Index() + + cmd := m.List.InsertItem(insertIdx, mod) + + if m.userNavigated { + // Preserve cursor: if we inserted before or at the current + // selection, shift the cursor forward so it stays on the same item. + if insertIdx <= currentIdx { + m.List.Select(currentIdx + 1) + } + } else { + // User hasn't navigated yet — keep cursor at the top. + m.List.Select(0) + } + + return cmd +} + +// isDuplicate reports whether any item in the list has the same source path +// as sourcePath. This uses the stable TerraformSourcePath identity rather +// than the display title, so distinct modules that share a title are not +// incorrectly collapsed. +func isDuplicate(items []list.Item, sourcePath string) bool { + for _, item := range items { + if existing, ok := item.(*module.Module); ok { + if existing.TerraformSourcePath() == sourcePath { + return true + } + } + } + + return false +} + +func (m Model) listenForModule() tea.Cmd { //nolint:gocritic + ch := m.moduleCh + if ch == nil { + return nil + } + + return func() tea.Msg { + mod, ok := <-ch + if !ok { + return nil + } + + return moduleMsg{module: mod} + } +} + +// Init implements bubbletea.Model.Init +func (m Model) Init() tea.Cmd { //nolint:gocritic + cmds := []tea.Cmd{m.buttonBar.Init()} + + if m.moduleCh != nil { + cmds = append(cmds, m.listenForModule()) + } + + return tea.Batch(cmds...) +} diff --git a/internal/cli/commands/catalog/tui/redesign/model_test.go b/internal/cli/commands/catalog/tui/redesign/model_test.go new file mode 100644 index 0000000000..c43ef6bb0d --- /dev/null +++ b/internal/cli/commands/catalog/tui/redesign/model_test.go @@ -0,0 +1,223 @@ +package redesign_test + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/redesign" + "github.com/gruntwork-io/terragrunt/internal/services/catalog" + "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" + "github.com/gruntwork-io/terragrunt/pkg/config" + "github.com/gruntwork-io/terragrunt/pkg/log" + "github.com/gruntwork-io/terragrunt/pkg/options" + "github.com/gruntwork-io/terragrunt/test/helpers" + "github.com/gruntwork-io/terragrunt/test/helpers/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// runModel starts a tea.Program with the given model, sends messages via +// the interact callback, and returns the final model once the program exits. +func runModel(t *testing.T, m redesign.Model, width, height int, interact func(p *tea.Program)) redesign.Model { //nolint:gocritic + t.Helper() + + var out bytes.Buffer + + pr, pw, err := os.Pipe() + require.NoError(t, err) + + defer pr.Close() + defer pw.Close() + + p := tea.NewProgram(m, + tea.WithInput(pr), + tea.WithOutput(&out), + tea.WithWindowSize(width, height), + tea.WithColorProfile(colorprofile.TrueColor), + ) + + done := make(chan tea.Model, 1) + + go func() { + finalModel, err := p.Run() + assert.NoError(t, err) + + done <- finalModel + }() + + time.Sleep(50 * time.Millisecond) + + interact(p) + + select { + case fm := <-done: + return fm.(redesign.Model) + case <-time.After(10 * time.Second): + p.Kill() + t.Fatal("program did not exit within timeout") + + return redesign.Model{} + } +} + +// createMockCatalogService creates a mock catalog service with test modules for testing +func createMockCatalogService(t *testing.T, opts *options.TerragruntOptions) catalog.CatalogService { + t.Helper() + + mockNewRepo := func(ctx context.Context, logger log.Logger, repoOpts module.RepoOpts) (*module.Repo, error) { + repoURL := repoOpts.CloneURL + dummyRepoDir := filepath.Join(helpers.TmpDirWOSymlinks(t), strings.ReplaceAll(repoURL, "github.com/gruntwork-io/", "")) + + require.NoError(t, os.MkdirAll(dummyRepoDir, 0755), "MkdirAll %s", dummyRepoDir) + + gitDir := filepath.Join(dummyRepoDir, ".git") + require.NoError(t, os.MkdirAll(gitDir, 0755), "MkdirAll %s", gitDir) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "config"), fmt.Appendf(nil, `[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +[remote "origin"] + url = %s + fetch = +refs/heads/*:refs/remotes/origin/* +[branch "main"] + remote = origin + merge = refs/heads/main +`, repoURL), 0644), "WriteFile %s/config", gitDir) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644), "WriteFile %s/HEAD", gitDir) + + refsDir := filepath.Join(gitDir, "refs") + headsDir := filepath.Join(refsDir, "heads") + remotesDir := filepath.Join(refsDir, "remotes", "origin") + + require.NoError(t, os.MkdirAll(headsDir, 0755), "MkdirAll %s", headsDir) + require.NoError(t, os.MkdirAll(remotesDir, 0755), "MkdirAll %s", remotesDir) + + fakeCommitHash := "1234567890abcdef1234567890abcdef12345678" + require.NoError(t, os.WriteFile(filepath.Join(headsDir, "main"), []byte(fakeCommitHash+"\n"), 0644), "WriteFile %s/main", headsDir) + require.NoError(t, os.WriteFile(filepath.Join(remotesDir, "main"), []byte(fakeCommitHash+"\n"), 0644), "WriteFile %s/main", remotesDir) + + switch repoURL { + case "github.com/gruntwork-io/test-repo-1": + readme1Path := filepath.Join(dummyRepoDir, "README.md") + require.NoError(t, os.WriteFile(readme1Path, []byte("# AWS VPC Module\nThis module creates a VPC in AWS with all the necessary components."), 0644), "WriteFile %s", readme1Path) + + mainTF1 := filepath.Join(dummyRepoDir, "main.tf") + require.NoError(t, os.WriteFile(mainTF1, []byte("# VPC terraform configuration"), 0644), "WriteFile %s", mainTF1) + case "github.com/gruntwork-io/test-repo-2": + readme2Path := filepath.Join(dummyRepoDir, "README.md") + require.NoError(t, os.WriteFile(readme2Path, []byte("# AWS EKS Module\nThis module creates an EKS cluster in AWS."), 0644), "WriteFile %s", readme2Path) + + mainTF2 := filepath.Join(dummyRepoDir, "main.tf") + require.NoError(t, os.WriteFile(mainTF2, []byte("# EKS terraform configuration"), 0644), "WriteFile %s", mainTF2) + default: + return nil, fmt.Errorf("unexpected repoURL in mock: %s", repoURL) + } + + repoOpts.CloneURL = dummyRepoDir + + return module.NewRepo(ctx, logger, repoOpts) + } + + tmpDir := helpers.TmpDirWOSymlinks(t) + rootFile := filepath.Join(tmpDir, "root.hcl") + err := os.WriteFile(rootFile, []byte(`catalog { + urls = [ + "github.com/gruntwork-io/test-repo-1", + "github.com/gruntwork-io/test-repo-2", + ] +}`), 0600) + require.NoError(t, err) + + unitDir := filepath.Join(tmpDir, "unit") + require.NoError(t, os.MkdirAll(unitDir, 0755), "MkdirAll %s", unitDir) + opts.TerragruntConfigPath = filepath.Join(unitDir, "terragrunt.hcl") + opts.ScaffoldRootFileName = config.RecommendedParentConfigName + + svc := catalog.NewCatalogService(opts).WithNewRepoFunc(mockNewRepo) + + ctx := t.Context() + l := logger.CreateLogger() + err = svc.Load(ctx, l) + require.NoError(t, err) + + return svc +} + +// TestModelStreamingInsertsSorted verifies that modules sent via moduleMsg +// are inserted in alphabetical order in the list. +func TestModelStreamingInsertsSorted(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + l := logger.CreateLogger() + svc := createMockCatalogService(t, opts) + modules := svc.Modules() + require.GreaterOrEqual(t, len(modules), 2, "need at least 2 modules") + + // Start with the last module alphabetically + moduleCh := make(chan *module.Module, len(modules)) + m := redesign.NewModelStreaming(l, opts, modules[len(modules)-1], moduleCh) + + finalModel := runModel(t, m, 120, 40, func(p *tea.Program) { + // Send the remaining modules in reverse order + for i := len(modules) - 2; i >= 0; i-- { + p.Send(redesign.ModuleMsg(modules[i])) + time.Sleep(50 * time.Millisecond) + } + + time.Sleep(100 * time.Millisecond) + + p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"}) + }) + + assert.Equal(t, redesign.ListState, finalModel.State) + items := finalModel.List.Items() + assert.Len(t, items, len(modules), "all modules should be in the list") + + // Verify sorted order (case-insensitive, matching the sort in model.go) + for i := 1; i < len(items); i++ { + prev := strings.ToLower(items[i-1].(*module.Module).Title()) + curr := strings.ToLower(items[i].(*module.Module).Title()) + assert.LessOrEqual(t, prev, curr, "modules should be in alphabetical order: %q should come before %q", prev, curr) + } +} + +// TestModelStreamingDeduplicates verifies that sending the same module +// twice does not result in a duplicate entry in the list. +func TestModelStreamingDeduplicates(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + l := logger.CreateLogger() + svc := createMockCatalogService(t, opts) + modules := svc.Modules() + require.NotEmpty(t, modules) + + moduleCh := make(chan *module.Module, len(modules)) + m := redesign.NewModelStreaming(l, opts, modules[0], moduleCh) + + finalModel := runModel(t, m, 120, 40, func(p *tea.Program) { + // Send the same module again — should be deduplicated + p.Send(redesign.ModuleMsg(modules[0])) + time.Sleep(100 * time.Millisecond) + + p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"}) + }) + + assert.Equal(t, redesign.ListState, finalModel.State) + assert.Len(t, finalModel.List.Items(), 1, "duplicate module should not appear twice") +} diff --git a/internal/cli/commands/catalog/source_discovery.go b/internal/cli/commands/catalog/tui/redesign/source_discovery.go similarity index 99% rename from internal/cli/commands/catalog/source_discovery.go rename to internal/cli/commands/catalog/tui/redesign/source_discovery.go index 062b730076..60735d40e8 100644 --- a/internal/cli/commands/catalog/source_discovery.go +++ b/internal/cli/commands/catalog/tui/redesign/source_discovery.go @@ -1,4 +1,4 @@ -package catalog +package redesign import ( "context" diff --git a/internal/cli/commands/catalog/source_discovery_test.go b/internal/cli/commands/catalog/tui/redesign/source_discovery_test.go similarity index 80% rename from internal/cli/commands/catalog/source_discovery_test.go rename to internal/cli/commands/catalog/tui/redesign/source_discovery_test.go index 06a43ae195..070628d433 100644 --- a/internal/cli/commands/catalog/source_discovery_test.go +++ b/internal/cli/commands/catalog/tui/redesign/source_discovery_test.go @@ -1,11 +1,11 @@ -package catalog_test +package redesign_test import ( "os" "path/filepath" "testing" - "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog" + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/redesign" "github.com/gruntwork-io/terragrunt/internal/strict/controls" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers" @@ -17,50 +17,50 @@ import ( func TestExtractRepoURL_Simple(t *testing.T) { t.Parallel() - result := catalog.ExtractRepoURL("github.com/gruntwork-io/terraform-aws-vpc") + result := redesign.ExtractRepoURL("github.com/gruntwork-io/terraform-aws-vpc") assert.Equal(t, "github.com/gruntwork-io/terraform-aws-vpc", result) } func TestExtractRepoURL_WithSubdirAndRef(t *testing.T) { t.Parallel() - result := catalog.ExtractRepoURL("github.com/gruntwork-io/terraform-aws-vpc//modules/vpc-app?ref=v0.26.0") + result := redesign.ExtractRepoURL("github.com/gruntwork-io/terraform-aws-vpc//modules/vpc-app?ref=v0.26.0") assert.Equal(t, "github.com/gruntwork-io/terraform-aws-vpc", result) } func TestExtractRepoURL_GitPrefix(t *testing.T) { t.Parallel() - result := catalog.ExtractRepoURL("git::https://github.com/gruntwork-io/terraform-aws-vpc.git") + result := redesign.ExtractRepoURL("git::https://github.com/gruntwork-io/terraform-aws-vpc.git") assert.Equal(t, "https://github.com/gruntwork-io/terraform-aws-vpc.git", result) } func TestExtractRepoURL_GitPrefixWithSubdir(t *testing.T) { t.Parallel() - result := catalog.ExtractRepoURL("git::https://github.com/gruntwork-io/terraform-aws-vpc.git//modules/vpc?ref=v1.0.0") + result := redesign.ExtractRepoURL("git::https://github.com/gruntwork-io/terraform-aws-vpc.git//modules/vpc?ref=v1.0.0") assert.Equal(t, "https://github.com/gruntwork-io/terraform-aws-vpc.git", result) } func TestExtractRepoURL_LocalPath(t *testing.T) { t.Parallel() - assert.Empty(t, catalog.ExtractRepoURL("../modules/vpc")) - assert.Empty(t, catalog.ExtractRepoURL("./modules/vpc")) - assert.Empty(t, catalog.ExtractRepoURL("/absolute/path/to/modules")) + assert.Empty(t, redesign.ExtractRepoURL("../modules/vpc")) + assert.Empty(t, redesign.ExtractRepoURL("./modules/vpc")) + assert.Empty(t, redesign.ExtractRepoURL("/absolute/path/to/modules")) } func TestExtractRepoURL_Registry(t *testing.T) { t.Parallel() - result := catalog.ExtractRepoURL("tfr:///terraform-aws-modules/vpc/aws?version=3.5.0") + result := redesign.ExtractRepoURL("tfr:///terraform-aws-modules/vpc/aws?version=3.5.0") assert.Empty(t, result) } func TestExtractRepoURL_S3Prefix(t *testing.T) { t.Parallel() - result := catalog.ExtractRepoURL("s3::https://s3-eu-west-1.amazonaws.com/bucket/module.zip") + result := redesign.ExtractRepoURL("s3::https://s3-eu-west-1.amazonaws.com/bucket/module.zip") assert.Equal(t, "https://s3-eu-west-1.amazonaws.com/bucket/module.zip", result) } @@ -125,7 +125,7 @@ inputs = { ctx, pctx := config.NewParsingContext(t.Context(), l, config.WithStrictControls(controls.New())) pctx.RootWorkingDir = tmpDir - urls, err := catalog.DiscoverSourceURLs(ctx, l, pctx) + urls, err := redesign.DiscoverSourceURLs(ctx, l, pctx) require.NoError(t, err) // Should have 2 unique repo URLs (repo-a deduplicated, repo-b, interpolated and no-source skipped) @@ -143,7 +143,7 @@ func TestDiscoverSourceURLs_EmptyDir(t *testing.T) { ctx, pctx := config.NewParsingContext(t.Context(), l, config.WithStrictControls(controls.New())) pctx.RootWorkingDir = tmpDir - urls, err := catalog.DiscoverSourceURLs(ctx, l, pctx) + urls, err := redesign.DiscoverSourceURLs(ctx, l, pctx) require.NoError(t, err) assert.Empty(t, urls) } diff --git a/internal/cli/commands/catalog/tui/redesign/update.go b/internal/cli/commands/catalog/tui/redesign/update.go new file mode 100644 index 0000000000..40e8b077be --- /dev/null +++ b/internal/cli/commands/catalog/tui/redesign/update.go @@ -0,0 +1,274 @@ +package redesign + +import ( + "fmt" + "os" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + "github.com/pkg/browser" + + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/command" + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/components/buttonbar" + "github.com/gruntwork-io/terragrunt/internal/services/catalog" + "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" + "github.com/gruntwork-io/terragrunt/pkg/log" +) + +func updateList(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.KeyPressMsg: + m.userNavigated = true + + // Don't match any of the keys below if we're actively filtering. + if m.List.FilterState() == list.Filtering { + break + } + + switch { + case key.Matches(msg, m.delegateKeys.Choose, m.delegateKeys.Scaffold): + if selectedModule, ok := m.List.SelectedItem().(*module.Module); ok { + switch { + case key.Matches(msg, m.delegateKeys.Choose): + // prepare the viewport + var content string + + if selectedModule.IsMarkDown() { + style := "dark" + if !lipgloss.HasDarkBackground(os.Stdin, os.Stdout) { + style = "light" + } + + renderer, err := glamour.NewTermRenderer( + glamour.WithStandardStyle(style), + glamour.WithWordWrap(m.width), + ) + if err != nil { + return m, rendererErrCmd(err) + } + + md, err := renderer.Render(selectedModule.Content(false)) + if err != nil { + return m, rendererErrCmd(err) + } + + content = md + } else { + content = selectedModule.Content(true) + } + + m.viewport.SetContent(content) + + // Dynamically create button bar based on module URL + var pagerButtons []button + + buttonNames := []string{} + + // Always add scaffold button + pagerButtons = append(pagerButtons, scaffoldBtn) + buttonNames = append(buttonNames, scaffoldBtn.String()) + + if selectedModule.URL() != "" { + pagerButtons = append(pagerButtons, viewSourceBtn) + buttonNames = append(buttonNames, viewSourceBtn.String()) + } + + m.currentPagerButtons = pagerButtons + m.buttonBar = buttonbar.New(buttonNames) + // Ensure the button bar is initialized + cmds = append(cmds, m.buttonBar.Init()) + + // advance state + m.selectedModule = selectedModule + m.State = PagerState + + return m, tea.Batch(cmds...) + case key.Matches(msg, m.delegateKeys.Scaffold): + if m.SVC == nil { + return m, nil + } + + m.State = ScaffoldState + + return m, scaffoldModuleCmd(m.logger, m, m.SVC, selectedModule) + } + } else { + break + } + + case key.Matches(msg, m.listKeys.Quit): + // because we're on the first screen, we simply quit at this point + return m, tea.Quit + } + } + + // Handle keyboard and mouse events for the list + m.List, cmd = m.List.Update(msg) + + // Append any commands from button bar initialization + if len(cmds) > 0 { + return m, tea.Batch(append([]tea.Cmd{cmd}, cmds...)...) + } + + return m, cmd +} + +func updatePager(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.KeyPressMsg: + bbModel, barCmd := m.buttonBar.Update(msg) + if newButtonBar, ok := bbModel.(*buttonbar.ButtonBar); ok { + m.buttonBar = newButtonBar + } + + if barCmd != nil { + cmds = append(cmds, barCmd) + } + + switch { + case key.Matches(msg, m.pagerKeys.Choose): + // Choose changes the action depending on the active button + // m.activeButton is set by ActiveBtnMsg, which is mapped from m.currentPagerButtons + currentAction := m.activeButton + + switch currentAction { + case scaffoldBtn: + if m.SVC == nil { + return m, nil + } + + m.State = ScaffoldState + + return m, scaffoldModuleCmd(m.logger, m, m.SVC, m.selectedModule) + case viewSourceBtn: + if m.selectedModule.URL() != "" { + if err := browser.OpenURL(m.selectedModule.URL()); err != nil { + m.viewport.SetContent(fmt.Sprintf("could not open url in browser: %s. got error: %s", m.selectedModule.URL(), err)) + } + } + default: + m.logger.Warnf("Unknown button pressed: %s", currentAction) + } + + case key.Matches(msg, m.pagerKeys.Scaffold): + if m.SVC == nil { + return m, nil + } + + m.State = ScaffoldState + + return m, scaffoldModuleCmd(m.logger, m, m.SVC, m.selectedModule) + + case key.Matches(msg, m.pagerKeys.Quit): + // because we're on the second screen, we need to go back + m.State = ListState + return m, nil + } + case buttonbar.ActiveBtnMsg: + // Map the index from buttonbar.ActiveBtnMsg to the actual button type + if int(msg) >= 0 && int(msg) < len(m.currentPagerButtons) { + m.activeButton = m.currentPagerButtons[int(msg)] + } + } + + // Handle keyboard and mouse events in the viewport + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// Update handles all TUI interactions and implements bubbletea.Model.Update. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocritic + switch msg := msg.(type) { + case moduleMsg: + cmd := m.insertModuleSorted(msg.module) + + return m, tea.Batch(cmd, m.listenForModule()) + case DiscoveryCompleteMsg: + m.SVC = msg.Svc + m.loading = false + + if msg.Err != nil { + m.logger.Warnf("Discovery error: %v", msg.Err) + } + + return m, nil + case tea.WindowSizeMsg: + h, v := AppStyle.GetFrameSize() + m.List.SetSize(msg.Width-h, msg.Height-v) + m.width = msg.Width + m.height = msg.Height + + if !m.ready { + // Since this program is using the full size of the viewport we + // need to wait until we've received the window dimensions before + // we can initialize the viewport. The initial dimensions come in + // quickly, though asynchronously, which is why we wait for them + // here. + m.viewport = viewport.New(viewport.WithWidth(msg.Width), viewport.WithHeight(msg.Height-v-lipgloss.Height(m.footerView()))) + m.ready = true + } else { + m.viewport.SetWidth(msg.Width) + m.viewport.SetHeight(msg.Height - v - lipgloss.Height(m.footerView())) + } + + case scaffoldFinishedMsg: + if msg.err != nil { + return m, tea.Batch(tea.Printf("error scaffolding module: %s", msg.err.Error()), tea.Quit) + } + + return m, tea.Quit + + case rendererErrMsg: + m.viewport.SetContent("there was an error rendering markdown: " + msg.err.Error()) + // ensure we show the viewport + m.State = PagerState + } + + // Hand off the message and model to the appropriate update function for the + // appropriate view based on the current state. + switch m.State { + case ListState: + return updateList(msg, m) + case PagerState: + return updatePager(msg, m) + case ScaffoldState: + // if we're on the scaffold state, we do nothing and wait for the + // scaffoldFinishedMsg message. This prevents further input. + return m, nil + } + + return m, nil +} + +type rendererErrMsg struct{ err error } + +func rendererErrCmd(err error) tea.Cmd { + return func() tea.Msg { + return rendererErrMsg{err} + } +} + +type scaffoldFinishedMsg struct{ err error } + +// Return a tea.Cmd that will scaffold the given module. +func scaffoldModuleCmd(l log.Logger, m Model, svc catalog.CatalogService, module *module.Module) tea.Cmd { //nolint:gocritic + return tea.Exec(command.NewScaffold(l, m.terragruntOptions, svc, module), func(err error) tea.Msg { + return scaffoldFinishedMsg{err} + }) +} diff --git a/internal/cli/commands/catalog/tui/redesign/view.go b/internal/cli/commands/catalog/tui/redesign/view.go new file mode 100644 index 0000000000..a922950f00 --- /dev/null +++ b/internal/cli/commands/catalog/tui/redesign/view.go @@ -0,0 +1,67 @@ +package redesign + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +var ( + AppStyle = lipgloss.NewStyle().Padding(1, 2) //nolint:mnd + infoPositionStyle = lipgloss.NewStyle().Padding(0, 1).BorderStyle(lipgloss.HiddenBorder()) + infoLineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1D252F")) + infoHelp = lipgloss.NewStyle().Padding(2, 0, 0, 2) //nolint:mnd +) + +// View is the main view, which just calls the appropriate sub-view and returns a View representation of the TUI +// based on the application's state. +func (m Model) View() tea.View { //nolint:gocritic + var s string + + switch m.State { + case ListState: + s = m.listView() + case PagerState: + s = m.pagerView() + case ScaffoldState: + default: + s = "" + } + + v := tea.NewView(s) + v.AltScreen = true + + return v +} + +func (m Model) listView() string { //nolint:gocritic + if m.loading { + m.List.Title = title + " (loading...)" + } else { + m.List.Title = title + } + + return m.List.View() +} + +func (m Model) pagerView() string { //nolint:gocritic + return lipgloss.JoinVertical(lipgloss.Left, m.viewport.View(), m.footerView()) +} + +func (m Model) footerView() string { //nolint:gocritic + var percent float64 = 100 + + info := infoPositionStyle.Render(fmt.Sprintf("%2.f%%", m.viewport.ScrollPercent()*percent)) + + line := strings.Repeat("─", max(0, m.viewport.Width()-lipgloss.Width(info))) + line = infoLineStyle.Render(line) + + info = lipgloss.JoinHorizontal(lipgloss.Center, line, info) + + // button bar and key help + pagerKeys := infoHelp.Render(lipgloss.JoinVertical(lipgloss.Left, m.buttonBar.View().Content, "\n", m.pagerKeys.HelpModel.View(m.pagerKeys))) + + return lipgloss.JoinVertical(lipgloss.Left, info, pagerKeys) +} diff --git a/internal/cli/commands/catalog/tui/redesign/view_test.go b/internal/cli/commands/catalog/tui/redesign/view_test.go new file mode 100644 index 0000000000..a920fb1a41 --- /dev/null +++ b/internal/cli/commands/catalog/tui/redesign/view_test.go @@ -0,0 +1,382 @@ +package redesign_test + +import ( + "context" + "strings" + "testing" + "testing/synctest" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/redesign" + "github.com/gruntwork-io/terragrunt/internal/services/catalog" + "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" + "github.com/gruntwork-io/terragrunt/pkg/options" + "github.com/gruntwork-io/terragrunt/test/helpers/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Welcome Loading View --- + +func TestWelcomeLoadingView_RendersSpinnerAndStatus(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + l := logger.CreateLogger() + + m := redesign.NewWelcomeModel(t.Context(), l, opts, blockingLoad) + m = updateModel(m, windowSize).(redesign.WelcomeModel) + + view := m.View() + content := stripANSI(view.Content) + + assert.True(t, view.AltScreen, "welcome loading view should use alt screen") + assert.Contains(t, content, "Terragrunt Catalog", "should render title") + assert.Contains(t, content, "Discovering modules from your infrastructure...", "should render default status text") +} + +func TestWelcomeLoadingView_StatusTextUpdates(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + l := logger.CreateLogger() + + m := redesign.NewWelcomeModel(t.Context(), l, opts, blockingLoad) + m = updateModel(m, windowSize).(redesign.WelcomeModel) + + // Verify initial status + content := stripANSI(m.View().Content) + assert.Contains(t, content, "Discovering modules from your infrastructure...") + + // Send a status update + m = updateModel(m, redesign.StatusUpdateMsg("Loading terraform-aws-vpc...")).(redesign.WelcomeModel) + + content = stripANSI(m.View().Content) + assert.Contains(t, content, "Loading terraform-aws-vpc...", "status text should update after StatusUpdateMsg") + assert.NotContains(t, content, "Discovering modules from your infrastructure...", "old status text should be replaced") +} + +// --- Welcome No-Sources View --- + +func TestWelcomeNoSourcesView_RendersHelpText(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + l := logger.CreateLogger() + + noSourcesLoad := func(_ context.Context, _ redesign.StatusFunc, _ chan<- *module.Module) (catalog.CatalogService, error) { + return nil, nil + } + + m := redesign.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) + m = updateModel(m, windowSize).(redesign.WelcomeModel) + + // Simulate discovery completing with no modules + m = updateModel(m, redesign.DiscoveryCompleteMsg{Svc: nil, Err: nil}).(redesign.WelcomeModel) + + view := m.View() + content := stripANSI(view.Content) + + assert.True(t, view.AltScreen, "no-sources view should use alt screen") + assert.Contains(t, content, "Terragrunt Catalog", "should render title") + assert.Contains(t, content, "No module sources were discovered", "should explain no sources found") + assert.Contains(t, content, "catalog {", "should show catalog block example") + assert.Contains(t, content, "urls =", "should show urls attribute in example") + assert.Contains(t, content, "terraform.source", "should mention terraform.source as alternative") + assert.Contains(t, content, "h: open docs in browser", "should show docs key hint") + assert.Contains(t, content, "q/esc: exit", "should show quit key hint") +} + +// --- Module List View --- + +func TestModuleListView_LoadingTitle(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + svc := createMockCatalogService(t, opts) + l := logger.CreateLogger() + modules := svc.Modules() + require.NotEmpty(t, modules) + + moduleCh := make(chan *module.Module, 10) + m := redesign.NewModelStreaming(l, opts, modules[0], moduleCh) + + updated, _ := m.Update(windowSize) + m = updated.(redesign.Model) + + // Should show loading indicator + content := stripANSI(m.View().Content) + assert.Contains(t, content, "List of Modules (loading...)", "streaming model should show loading indicator") + + // Send discoveryComplete to clear loading state + updated, _ = m.Update(redesign.DiscoveryCompleteMsg{Svc: svc, Err: nil}) + m = updated.(redesign.Model) + + content = stripANSI(m.View().Content) + assert.Contains(t, content, "List of Modules", "should still have title") + assert.NotContains(t, content, "(loading...)", "loading indicator should be gone after discovery completes") +} + +// --- synctest: Streaming Flow --- + +func TestWelcomeStreamingFlow_Synctest(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + l := logger.CreateLogger() + svc := createMockCatalogService(t, opts) + modules := svc.Modules() + require.GreaterOrEqual(t, len(modules), 2, "need at least 2 modules") + + streamingLoad := func(_ context.Context, status redesign.StatusFunc, moduleCh chan<- *module.Module) (catalog.CatalogService, error) { + status("Discovering catalog sources...") + + for _, mod := range modules { + time.Sleep(100 * time.Millisecond) + + moduleCh <- mod + } + + return svc, nil + } + + var m tea.Model = redesign.NewWelcomeModel(t.Context(), l, opts, streamingLoad) + + // Set window size + m = updateModel(m, windowSize) + + // Initially should be welcome model showing loading + _, isWelcome := m.(redesign.WelcomeModel) + assert.True(t, isWelcome, "should start as WelcomeModel") + + content := stripANSI(m.View().Content) + assert.Contains(t, content, "Terragrunt Catalog", "should show title while loading") + + // Execute Init() to start discovery and spinners + cmd := m.Init() + + // Collect the first message from Init commands + msgCh := make(chan tea.Msg, 10) + + go func() { + if cmd != nil { + msg := cmd() + if msg != nil { + msgCh <- msg + } + } + }() + + // Advance fake time past the first module's Sleep(100ms) + time.Sleep(150 * time.Millisecond) + + // Drain available messages and feed them to the model + draining := true + for draining { + select { + case msg := <-msgCh: + var nextCmd tea.Cmd + + m, nextCmd = m.Update(msg) + + if nextCmd != nil { + go func() { + result := nextCmd() + if result != nil { + msgCh <- result + } + }() + } + default: + draining = false + } + } + + // Advance more time for remaining modules and discovery completion + time.Sleep(500 * time.Millisecond) + + // Drain remaining messages + draining = true + for draining { + select { + case msg := <-msgCh: + var nextCmd tea.Cmd + + m, nextCmd = m.Update(msg) + + if nextCmd != nil { + go func() { + result := nextCmd() + if result != nil { + msgCh <- result + } + }() + } + default: + draining = false + } + } + + // After streaming completes, should have transitioned to Model + listModel, isList := m.(redesign.Model) + if isList { + assert.Equal(t, redesign.ListState, listModel.State, "should be in list state") + + items := listModel.List.Items() + assert.GreaterOrEqual(t, len(items), 1, "should have at least one module in the list") + + // Verify alphabetical order if multiple items present + for i := 1; i < len(items); i++ { + prev := items[i-1].(*module.Module).Title() + curr := items[i].(*module.Module).Title() + assert.LessOrEqual(t, strings.ToLower(prev), strings.ToLower(curr), + "modules should be in alphabetical order: %q should come before %q", prev, curr) + } + } + }) +} + +// --- synctest: Spinner Animation --- + +func TestWelcomeLoadingSpinner_Synctest(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + l := logger.CreateLogger() + + slowLoad := func(ctx context.Context, _ redesign.StatusFunc, _ chan<- *module.Module) (catalog.CatalogService, error) { + // Block for a long time (fake time) so we stay in loading state + select { + case <-time.After(10 * time.Second): + case <-ctx.Done(): + } + + return nil, nil + } + + var m tea.Model = redesign.NewWelcomeModel(t.Context(), l, opts, slowLoad) + + m = updateModel(m, windowSize) + + // Execute Init() to get the initial command batch (includes spinner.Tick) + cmd := m.Init() + + msgCh := make(chan tea.Msg, 10) + + go func() { + if cmd != nil { + msg := cmd() + if msg != nil { + msgCh <- msg + } + } + }() + + // The spinner.Dot FPS is 100ms. Advance fake time past that. + time.Sleep(150 * time.Millisecond) + + // Drain and process messages — the spinner tick should have fired + draining := true + for draining { + select { + case msg := <-msgCh: + var nextCmd tea.Cmd + + m, nextCmd = m.Update(msg) + + if nextCmd != nil { + go func() { + result := nextCmd() + if result != nil { + msgCh <- result + } + }() + } + default: + draining = false + } + } + + // After processing a spinner tick, the view should still render + // the loading screen with spinner frame and status text + view := m.View() + content := stripANSI(view.Content) + + assert.Contains(t, content, "Terragrunt Catalog", "should still show title") + assert.Contains(t, content, "Discovering modules from your infrastructure...", "should still show status") + + // Verify the view contains a spinner frame character (Dot spinner frames) + dotFrames := []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} + hasSpinnerFrame := false + + for _, frame := range dotFrames { + if strings.Contains(view.Content, frame) { + hasSpinnerFrame = true + + break + } + } + + assert.True(t, hasSpinnerFrame, "loading view should contain a spinner frame character") + }) +} + +// windowSize is a convenience WindowSizeMsg used across view tests. +var windowSize = tea.WindowSizeMsg{Width: 120, Height: 40} + +// blockingLoad is a LoadFunc that blocks until the context is cancelled, +// keeping the WelcomeModel in the loading state. +func blockingLoad(ctx context.Context, _ redesign.StatusFunc, _ chan<- *module.Module) (catalog.CatalogService, error) { + <-ctx.Done() + + return nil, nil +} + +// updateModel sends a message to a tea.Model and returns the updated model, +// discarding commands. +func updateModel(m tea.Model, msg tea.Msg) tea.Model { + updated, _ := m.Update(msg) + + return updated +} + +// stripANSI removes ANSI escape sequences from a string so assertions +// can match on plain text. +func stripANSI(s string) string { + var out strings.Builder + + for i := 0; i < len(s); i++ { + if s[i] == '\x1b' { + // Skip until we hit a letter (the terminator of the escape sequence). + for i < len(s) && !isLetter(s[i]) { + i++ + } + + continue + } + + out.WriteByte(s[i]) + } + + return out.String() +} + +func isLetter(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} diff --git a/internal/cli/commands/catalog/tui/welcome.go b/internal/cli/commands/catalog/tui/redesign/welcome.go similarity index 60% rename from internal/cli/commands/catalog/tui/welcome.go rename to internal/cli/commands/catalog/tui/redesign/welcome.go index 1dcbaadd92..64d7963db4 100644 --- a/internal/cli/commands/catalog/tui/welcome.go +++ b/internal/cli/commands/catalog/tui/redesign/welcome.go @@ -1,4 +1,4 @@ -package tui +package redesign import ( "context" @@ -10,6 +10,7 @@ import ( "github.com/pkg/browser" "github.com/gruntwork-io/terragrunt/internal/services/catalog" + "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -20,9 +21,10 @@ const welcomeDocsURL = "https://docs.terragrunt.com/features/catalog/" type StatusFunc func(msg string) // LoadFunc performs source discovery and module loading in the background. -// It calls status with human-readable progress updates. -// It returns a ready CatalogService, or nil if no sources were found. -type LoadFunc func(ctx context.Context, status StatusFunc) (catalog.CatalogService, error) +// It calls status with human-readable progress updates and sends each +// discovered module on moduleCh as it is found. It returns a ready +// CatalogService (for scaffolding), or nil if no sources were found. +type LoadFunc func(ctx context.Context, status StatusFunc, moduleCh chan<- *module.Module) (catalog.CatalogService, error) // OpenURLFunc opens a URL in the user's browser. Injected so tests can // substitute a no-op or a recording stub. @@ -33,16 +35,27 @@ type welcomeState int const ( welcomeLoading welcomeState = iota welcomeNoSources + welcomeDiscoveryError ) -// discoveryCompleteMsg is sent when background discovery finishes. -type discoveryCompleteMsg struct { - svc catalog.CatalogService - err error +// DiscoveryCompleteMsg is sent when background discovery finishes. +type DiscoveryCompleteMsg struct { + Svc catalog.CatalogService + Err error } -// statusUpdateMsg carries a progress update from the LoadFunc. -type statusUpdateMsg string +// moduleMsg carries a single newly-discovered module from the LoadFunc. +type moduleMsg struct { + module *module.Module +} + +// ModuleMsg creates a moduleMsg for testing. +func ModuleMsg(mod *module.Module) tea.Msg { + return moduleMsg{module: mod} +} + +// StatusUpdateMsg carries a progress update from the LoadFunc. +type StatusUpdateMsg string const statusChannelSize = 10 @@ -67,17 +80,19 @@ var ( // discovery runs in the background, then either transitions to the module // list TUI or settles into a "no sources found" help screen. type WelcomeModel struct { - ctx context.Context - logger log.Logger - opts *options.TerragruntOptions - loadFunc LoadFunc - openURL OpenURLFunc - statusCh chan string - statusText string - spinner spinner.Model - state welcomeState - width int - height int + ctx context.Context + logger log.Logger + lastDiscoveryErr error + moduleCh chan *module.Module + openURL OpenURLFunc + statusCh chan string + loadFunc LoadFunc + opts *options.TerragruntOptions + statusText string + spinner spinner.Model + state welcomeState + width int + height int } // NewWelcomeModel creates a WelcomeModel that immediately begins discovery. @@ -93,6 +108,7 @@ func NewWelcomeModel(ctx context.Context, l log.Logger, opts *options.Terragrunt loadFunc: loadFunc, openURL: browser.OpenURL, statusCh: make(chan string, statusChannelSize), + moduleCh: make(chan *module.Module, statusChannelSize), spinner: s, statusText: "Discovering modules from your infrastructure...", state: welcomeLoading, @@ -108,25 +124,27 @@ func (m WelcomeModel) WithOpenURL(fn OpenURLFunc) WelcomeModel { //nolint:gocrit // Init implements tea.Model. It starts the spinner and kicks off discovery. func (m WelcomeModel) Init() tea.Cmd { //nolint:gocritic - return tea.Batch(m.spinner.Tick, m.startDiscovery(), m.listenForStatus()) + return tea.Batch(m.spinner.Tick, m.startDiscovery(), m.listenForStatus(), m.listenForModule()) } func (m WelcomeModel) startDiscovery() tea.Cmd { //nolint:gocritic ctx := m.ctx loadFunc := m.loadFunc - ch := m.statusCh + statusCh := m.statusCh + moduleCh := m.moduleCh return func() tea.Msg { - defer close(ch) + defer close(statusCh) + defer close(moduleCh) svc, err := loadFunc(ctx, func(msg string) { select { - case ch <- msg: + case statusCh <- msg: default: } - }) + }, moduleCh) - return discoveryCompleteMsg{svc: svc, err: err} + return DiscoveryCompleteMsg{Svc: svc, Err: err} } } @@ -139,16 +157,31 @@ func (m WelcomeModel) listenForStatus() tea.Cmd { //nolint:gocritic return nil } - return statusUpdateMsg(status) + return StatusUpdateMsg(status) + } +} + +func (m WelcomeModel) listenForModule() tea.Cmd { //nolint:gocritic + ch := m.moduleCh + + return func() tea.Msg { + mod, ok := <-ch + if !ok { + return nil + } + + return moduleMsg{module: mod} } } // Update implements tea.Model. func (m WelcomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocritic switch msg := msg.(type) { - case discoveryCompleteMsg: + case DiscoveryCompleteMsg: return m.handleDiscoveryComplete(msg) - case statusUpdateMsg: + case moduleMsg: + return m.handleModuleMsg(msg) + case StatusUpdateMsg: m.statusText = string(msg) return m, m.listenForStatus() @@ -177,25 +210,48 @@ func (m WelcomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocrit return m, nil } -func (m WelcomeModel) handleDiscoveryComplete(msg discoveryCompleteMsg) (tea.Model, tea.Cmd) { //nolint:gocritic - if msg.err != nil { - m.logger.Warnf("Discovery error: %v", msg.err) +func (m WelcomeModel) handleModuleMsg(msg moduleMsg) (tea.Model, tea.Cmd) { //nolint:gocritic + // First module: transition to the catalog list immediately + newModel := NewModelStreaming(m.logger, m.opts, msg.module, m.moduleCh) + width, height := m.width, m.height + + initCmds := []tea.Cmd{newModel.Init()} + if width > 0 && height > 0 { + initCmds = append(initCmds, func() tea.Msg { + return tea.WindowSizeMsg{Width: width, Height: height} + }) + } + + return newModel, tea.Batch(initCmds...) +} + +func (m WelcomeModel) handleDiscoveryComplete(msg DiscoveryCompleteMsg) (tea.Model, tea.Cmd) { //nolint:gocritic + if msg.Err != nil { + m.logger.Warnf("Discovery error: %v", msg.Err) + m.lastDiscoveryErr = msg.Err + m.state = welcomeDiscoveryError + + return m, nil } - if msg.svc != nil && len(msg.svc.Modules()) > 0 { - // Transition to the module list TUI - newModel := NewModel(m.logger, m.opts, msg.svc) + // Defensive: if we're still on the loading screen but the service has + // modules, transition to the catalog list. This shouldn't normally + // happen since moduleMsg transitions first. + if msg.Svc != nil && len(msg.Svc.Modules()) > 0 { + newModel := NewModel(m.logger, m.opts, msg.Svc) width, height := m.width, m.height - return newModel, tea.Batch( - newModel.Init(), - // Forward current dimensions so the new model sizes itself - func() tea.Msg { + initCmds := []tea.Cmd{newModel.Init()} + if width > 0 && height > 0 { + initCmds = append(initCmds, func() tea.Msg { return tea.WindowSizeMsg{Width: width, Height: height} - }, - ) + }) + } + + return newModel, tea.Batch(initCmds...) } + // No modules were ever discovered — show the welcome screen. m.state = welcomeNoSources return m, nil @@ -210,6 +266,8 @@ func (m WelcomeModel) View() tea.View { //nolint:gocritic content = m.loadingView() case welcomeNoSources: content = m.noSourcesView() + case welcomeDiscoveryError: + content = m.discoveryErrorView() } if m.width > 0 && m.height > 0 { @@ -257,6 +315,29 @@ func (m WelcomeModel) noSourcesView() string { //nolint:gocritic return lipgloss.JoinVertical(lipgloss.Center, title, body) } +func (m WelcomeModel) discoveryErrorView() string { //nolint:gocritic + title := welcomeTitleStyle.Render(" Terragrunt Catalog ") + + errMsg := "unknown error" + if m.lastDiscoveryErr != nil { + errMsg = m.lastDiscoveryErr.Error() + } + + body := welcomeBodyStyle.Render(lipgloss.JoinVertical(lipgloss.Left, + "", + "An error occurred while discovering catalog sources:", + "", + welcomeCodeStyle.Render(" "+errMsg), + "", + "Please check your network connection, authentication, and", + "catalog configuration, then try again.", + "", + welcomeHintStyle.Render("q/esc: exit"), + )) + + return lipgloss.JoinVertical(lipgloss.Center, title, body) +} + // RunRedesign launches the redesigned catalog experience. It shows a loading // screen immediately while discovery runs in the background, then transitions // to the module list if modules are found. diff --git a/internal/cli/commands/catalog/tui/welcome_test.go b/internal/cli/commands/catalog/tui/redesign/welcome_test.go similarity index 64% rename from internal/cli/commands/catalog/tui/welcome_test.go rename to internal/cli/commands/catalog/tui/redesign/welcome_test.go index bdcffa4f54..9ab66faabf 100644 --- a/internal/cli/commands/catalog/tui/welcome_test.go +++ b/internal/cli/commands/catalog/tui/redesign/welcome_test.go @@ -1,17 +1,19 @@ -package tui_test +package redesign_test import ( "bytes" "context" "os" + "strings" "testing" "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/colorprofile" - "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/redesign" "github.com/gruntwork-io/terragrunt/internal/services/catalog" + "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" @@ -28,11 +30,11 @@ func TestWelcomeLoadingScreen_NoSources(t *testing.T) { l := logger.CreateLogger() - noSourcesLoad := func(_ context.Context, _ tui.StatusFunc) (catalog.CatalogService, error) { + noSourcesLoad := func(_ context.Context, _ redesign.StatusFunc, _ chan<- *module.Module) (catalog.CatalogService, error) { return nil, nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) + m := redesign.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) finalModel := runTeaModel(t, m, 120, 40, func(p *tea.Program) { // Wait for discovery to complete and settle into no-sources view @@ -42,7 +44,7 @@ func TestWelcomeLoadingScreen_NoSources(t *testing.T) { }) // Should still be a WelcomeModel (no transition to module list) - _, isWelcome := finalModel.(tui.WelcomeModel) + _, isWelcome := finalModel.(redesign.WelcomeModel) assert.True(t, isWelcome, "should remain on welcome screen when no sources found") } @@ -58,11 +60,15 @@ func TestWelcomeLoadingScreen_TransitionsToModuleList(t *testing.T) { l := logger.CreateLogger() svc := createMockCatalogService(t, opts) - withModulesLoad := func(_ context.Context, _ tui.StatusFunc) (catalog.CatalogService, error) { + withModulesLoad := func(_ context.Context, _ redesign.StatusFunc, moduleCh chan<- *module.Module) (catalog.CatalogService, error) { + for _, mod := range svc.Modules() { + moduleCh <- mod + } + return svc, nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, withModulesLoad) + m := redesign.NewWelcomeModel(t.Context(), l, opts, withModulesLoad) finalModel := runTeaModel(t, m, 120, 40, func(p *tea.Program) { // Wait for discovery and transition to module list @@ -72,9 +78,11 @@ func TestWelcomeLoadingScreen_TransitionsToModuleList(t *testing.T) { p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"}) }) - listModel, isList := finalModel.(tui.Model) + listModel, isList := finalModel.(redesign.Model) require.True(t, isList, "should transition to module list when modules found") - assert.Equal(t, tui.ListState, listModel.State) + assert.Equal(t, redesign.ListState, listModel.State) + assert.Len(t, listModel.List.Items(), 2, "should have 2 modules in the list") + require.NotNil(t, listModel.SVC, "SVC should be set after discovery completes") assert.Len(t, listModel.SVC.Modules(), 2, "should have 2 test modules") } @@ -89,11 +97,15 @@ func TestWelcomeLoadingScreen_ModuleListNavigation(t *testing.T) { l := logger.CreateLogger() svc := createMockCatalogService(t, opts) - withModulesLoad := func(_ context.Context, _ tui.StatusFunc) (catalog.CatalogService, error) { + withModulesLoad := func(_ context.Context, _ redesign.StatusFunc, moduleCh chan<- *module.Module) (catalog.CatalogService, error) { + for _, mod := range svc.Modules() { + moduleCh <- mod + } + return svc, nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, withModulesLoad) + m := redesign.NewWelcomeModel(t.Context(), l, opts, withModulesLoad) finalModel := runTeaModel(t, m, 120, 40, func(p *tea.Program) { // Wait for discovery and transition to module list @@ -111,9 +123,9 @@ func TestWelcomeLoadingScreen_ModuleListNavigation(t *testing.T) { p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"}) }) - listModel, isList := finalModel.(tui.Model) + listModel, isList := finalModel.(redesign.Model) require.True(t, isList, "should be on module list after navigating back") - assert.Equal(t, tui.ListState, listModel.State) + assert.Equal(t, redesign.ListState, listModel.State) } // TestWelcomeLoadingScreen_QuitDuringLoading verifies that pressing q @@ -126,7 +138,7 @@ func TestWelcomeLoadingScreen_QuitDuringLoading(t *testing.T) { l := logger.CreateLogger() - slowLoad := func(ctx context.Context, _ tui.StatusFunc) (catalog.CatalogService, error) { + slowLoad := func(ctx context.Context, _ redesign.StatusFunc, _ chan<- *module.Module) (catalog.CatalogService, error) { // Simulate slow discovery select { case <-time.After(5 * time.Second): @@ -136,7 +148,7 @@ func TestWelcomeLoadingScreen_QuitDuringLoading(t *testing.T) { return nil, nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, slowLoad) + m := redesign.NewWelcomeModel(t.Context(), l, opts, slowLoad) finalModel := runTeaModel(t, m, 120, 40, func(p *tea.Program) { // Quit immediately while still loading @@ -144,7 +156,7 @@ func TestWelcomeLoadingScreen_QuitDuringLoading(t *testing.T) { }) // Should still be a WelcomeModel (loading was interrupted) - _, isWelcome := finalModel.(tui.WelcomeModel) + _, isWelcome := finalModel.(redesign.WelcomeModel) assert.True(t, isWelcome, "should exit as WelcomeModel when quit during loading") } @@ -158,13 +170,13 @@ func TestWelcomeNoSourcesScreen_HelpKeyOpensDocs(t *testing.T) { l := logger.CreateLogger() - noSourcesLoad := func(_ context.Context, _ tui.StatusFunc) (catalog.CatalogService, error) { + noSourcesLoad := func(_ context.Context, _ redesign.StatusFunc, _ chan<- *module.Module) (catalog.CatalogService, error) { return nil, nil } var openedURL string - m := tui.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad). + m := redesign.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad). WithOpenURL(func(url string) error { openedURL = url return nil @@ -182,7 +194,7 @@ func TestWelcomeNoSourcesScreen_HelpKeyOpensDocs(t *testing.T) { p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"}) }) - _, isWelcome := finalModel.(tui.WelcomeModel) + _, isWelcome := finalModel.(redesign.WelcomeModel) assert.True(t, isWelcome, "should remain on welcome screen after pressing h") assert.Equal(t, "https://docs.terragrunt.com/features/catalog/", openedURL, "should have opened docs URL") } @@ -197,11 +209,11 @@ func TestWelcomeNoSourcesScreen_UnhandledKey(t *testing.T) { l := logger.CreateLogger() - noSourcesLoad := func(_ context.Context, _ tui.StatusFunc) (catalog.CatalogService, error) { + noSourcesLoad := func(_ context.Context, _ redesign.StatusFunc, _ chan<- *module.Module) (catalog.CatalogService, error) { return nil, nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) + m := redesign.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) finalModel := runTeaModel(t, m, 120, 40, func(p *tea.Program) { // Wait for discovery to complete @@ -215,10 +227,59 @@ func TestWelcomeNoSourcesScreen_UnhandledKey(t *testing.T) { p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"}) }) - _, isWelcome := finalModel.(tui.WelcomeModel) + _, isWelcome := finalModel.(redesign.WelcomeModel) assert.True(t, isWelcome, "should remain on welcome screen after pressing unhandled key") } +// TestWelcomeStreamingModules verifies that modules stream into the list +// one at a time, ending up in sorted order. +func TestWelcomeStreamingModules(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + l := logger.CreateLogger() + svc := createMockCatalogService(t, opts) + + modules := svc.Modules() + require.GreaterOrEqual(t, len(modules), 2, "need at least 2 modules for streaming test") + + streamingLoad := func(_ context.Context, _ redesign.StatusFunc, moduleCh chan<- *module.Module) (catalog.CatalogService, error) { + // Stream modules one at a time with a small delay to simulate real discovery + for _, mod := range modules { + moduleCh <- mod + + time.Sleep(20 * time.Millisecond) + } + + return svc, nil + } + + m := redesign.NewWelcomeModel(t.Context(), l, opts, streamingLoad) + + finalModel := runTeaModel(t, m, 120, 40, func(p *tea.Program) { + // Wait for all modules to stream in + time.Sleep(500 * time.Millisecond) + + // Quit + p.Send(tea.KeyPressMsg{Code: 'q', Text: "q"}) + }) + + listModel, isList := finalModel.(redesign.Model) + require.True(t, isList, "should transition to module list") + assert.Equal(t, redesign.ListState, listModel.State) + assert.Len(t, listModel.List.Items(), len(modules), "all streamed modules should appear in list") + + // Verify alphabetical order (case-insensitive, matching the sort in model.go) + items := listModel.List.Items() + for i := 1; i < len(items); i++ { + prev := strings.ToLower(items[i-1].(*module.Module).Title()) + curr := strings.ToLower(items[i].(*module.Module).Title()) + assert.LessOrEqual(t, prev, curr, "modules should be in alphabetical order") + } +} + // runTeaModel starts a tea.Program with any tea.Model, sends messages via // the interact callback, and returns the final tea.Model once the program // exits. Unlike runModel, this accepts and returns the tea.Model interface diff --git a/internal/cli/commands/catalog/tui/update.go b/internal/cli/commands/catalog/tui/update.go index cfb60398b4..8dd8e15ee8 100644 --- a/internal/cli/commands/catalog/tui/update.go +++ b/internal/cli/commands/catalog/tui/update.go @@ -33,10 +33,10 @@ func updateList(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic } switch { - case key.Matches(msg, m.delegateKeys.choose, m.delegateKeys.scaffold): + case key.Matches(msg, m.delegateKeys.Choose, m.delegateKeys.Scaffold): if selectedModule, ok := m.List.SelectedItem().(*module.Module); ok { switch { - case key.Matches(msg, m.delegateKeys.choose): + case key.Matches(msg, m.delegateKeys.Choose): // prepare the viewport var content string @@ -88,8 +88,13 @@ func updateList(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic // advance state m.selectedModule = selectedModule m.State = PagerState - case key.Matches(msg, m.delegateKeys.scaffold): + case key.Matches(msg, m.delegateKeys.Scaffold): + if m.SVC == nil { + return m, nil + } + m.State = ScaffoldState + return m, scaffoldModuleCmd(m.logger, m, m.SVC, selectedModule) } } else { @@ -138,7 +143,12 @@ func updatePager(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic switch currentAction { case scaffoldBtn: + if m.SVC == nil { + return m, nil + } + m.State = ScaffoldState + return m, scaffoldModuleCmd(m.logger, m, m.SVC, m.selectedModule) case viewSourceBtn: if m.selectedModule.URL() != "" { @@ -151,7 +161,12 @@ func updatePager(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic } case key.Matches(msg, m.pagerKeys.Scaffold): + if m.SVC == nil { + return m, nil + } + m.State = ScaffoldState + return m, scaffoldModuleCmd(m.logger, m, m.SVC, m.selectedModule) case key.Matches(msg, m.pagerKeys.Quit): @@ -177,7 +192,7 @@ func updatePager(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { //nolint:gocritic func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocritic switch msg := msg.(type) { case tea.WindowSizeMsg: - h, v := appStyle.GetFrameSize() + h, v := AppStyle.GetFrameSize() m.List.SetSize(msg.Width-h, msg.Height-v) m.width = msg.Width m.height = msg.Height diff --git a/internal/cli/commands/catalog/tui/view.go b/internal/cli/commands/catalog/tui/view.go index 7d2aeb590f..ef816dcdf4 100644 --- a/internal/cli/commands/catalog/tui/view.go +++ b/internal/cli/commands/catalog/tui/view.go @@ -9,7 +9,7 @@ import ( ) var ( - appStyle = lipgloss.NewStyle().Padding(1, 2) //nolint:mnd + AppStyle = lipgloss.NewStyle().Padding(1, 2) //nolint:mnd infoPositionStyle = lipgloss.NewStyle().Padding(0, 1).BorderStyle(lipgloss.HiddenBorder()) infoLineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1D252")) infoHelp = lipgloss.NewStyle().Padding(2, 0, 0, 2) //nolint:mnd @@ -55,7 +55,7 @@ func (m Model) footerView() string { //nolint:gocritic info = lipgloss.JoinHorizontal(lipgloss.Center, line, info) // button bar and key help - pagerKeys := infoHelp.Render(lipgloss.JoinVertical(lipgloss.Left, m.buttonBar.View().Content, "\n", m.pagerKeys.help.View(m.pagerKeys))) + pagerKeys := infoHelp.Render(lipgloss.JoinVertical(lipgloss.Left, m.buttonBar.View().Content, "\n", m.pagerKeys.HelpModel.View(m.pagerKeys))) return lipgloss.JoinVertical(lipgloss.Left, info, pagerKeys) } diff --git a/internal/cli/commands/catalog/tui/view_test.go b/internal/cli/commands/catalog/tui/view_test.go new file mode 100644 index 0000000000..e4ab60d37a --- /dev/null +++ b/internal/cli/commands/catalog/tui/view_test.go @@ -0,0 +1,96 @@ +package tui_test + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" + "github.com/gruntwork-io/terragrunt/pkg/options" + "github.com/gruntwork-io/terragrunt/test/helpers/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Module List View --- + +func TestModuleListView_RendersTitle(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + svc := createMockCatalogService(t, opts) + l := logger.CreateLogger() + + m := tui.NewModel(l, opts, svc) + updated, _ := m.Update(windowSize) + m = updated.(tui.Model) + + view := m.View() + content := stripANSI(view.Content) + + assert.True(t, view.AltScreen, "list view should use alt screen") + assert.Contains(t, content, "List of Modules", "should render list title") + assert.NotContains(t, content, "(loading...)", "non-streaming model should not show loading indicator") +} + +// --- Module Pager View --- + +func TestModulePagerView_RendersFooterAndButtons(t *testing.T) { + t.Parallel() + + opts, err := options.NewTerragruntOptionsForTest("") + require.NoError(t, err) + + svc := createMockCatalogService(t, opts) + l := logger.CreateLogger() + + m := tui.NewModel(l, opts, svc) + + // Set window size first + updated, _ := m.Update(windowSize) + m = updated.(tui.Model) + + // Press Enter to select first module and transition to PagerState + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(tui.Model) + + assert.Equal(t, tui.PagerState, m.State, "should be in pager state after pressing Enter") + + view := m.View() + content := stripANSI(view.Content) + + assert.True(t, view.AltScreen, "pager view should use alt screen") + assert.Contains(t, content, "%", "pager should show scroll percentage") + assert.Contains(t, content, "Scaffold", "pager should show Scaffold button") +} + +// windowSize is a convenience WindowSizeMsg used across view tests. +var windowSize = tea.WindowSizeMsg{Width: 120, Height: 40} + +// stripANSI removes ANSI escape sequences from a string so assertions +// can match on plain text. +func stripANSI(s string) string { + var out strings.Builder + + for i := 0; i < len(s); i++ { + if s[i] == '\x1b' { + // Skip until we hit a letter (the terminator of the escape sequence). + for i < len(s) && !isLetter(s[i]) { + i++ + } + + continue + } + + out.WriteByte(s[i]) + } + + return out.String() +} + +func isLetter(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} diff --git a/internal/services/catalog/catalog.go b/internal/services/catalog/catalog.go index ded7936364..968e1b8e75 100644 --- a/internal/services/catalog/catalog.go +++ b/internal/services/catalog/catalog.go @@ -12,6 +12,8 @@ import ( "fmt" "os" "path/filepath" + "slices" + "sync" "github.com/gruntwork-io/terragrunt/internal/cli/commands/scaffold" "github.com/gruntwork-io/terragrunt/internal/configbridge" @@ -28,6 +30,9 @@ import ( // This allows for mocking in tests. type NewRepoFunc func(ctx context.Context, l log.Logger, opts module.RepoOpts) (*module.Repo, error) +// ModuleFunc is called for each module discovered during streaming load. +type ModuleFunc func(mod *module.Module) + const ( // tempDirFormat is used to create unique temporary directory names for catalog repositories. // It uses a hexadecimal representation of a SHA1 hash of the repo URL. @@ -58,6 +63,11 @@ type CatalogService interface { // WithRepoURLs allows setting multiple repository URLs directly. // When set, these URLs take precedence over both WithRepoURL and catalog config. WithRepoURLs(urls []string) CatalogService + + // LoadStreamingURL clones a single repository and streams its modules + // via onModule. Modules are accumulated internally so Modules() + // returns the complete set across all LoadStreamingURL calls. + LoadStreamingURL(ctx context.Context, l log.Logger, repoURL string, onModule ModuleFunc) error } // catalogServiceImpl is the concrete implementation of CatalogService. @@ -68,6 +78,7 @@ type catalogServiceImpl struct { repoURL string repoURLs []string modules module.Modules + mu sync.Mutex } // NewCatalogService creates a new instance of catalogServiceImpl with default settings. @@ -189,7 +200,9 @@ func (s *catalogServiceImpl) Load(ctx context.Context, l log.Logger) error { allModules = append(allModules, repoModules...) } + s.mu.Lock() s.modules = allModules + s.mu.Unlock() if len(errs) > 0 { return errors.Errorf("failed to find modules in some repositories: %v", errs) @@ -202,8 +215,59 @@ func (s *catalogServiceImpl) Load(ctx context.Context, l log.Logger) error { return nil } +// LoadStreamingURL clones a single repository and streams its modules +// via onModule. Modules are accumulated internally so Modules() +// returns the complete set across all LoadStreamingURL calls. +func (s *catalogServiceImpl) LoadStreamingURL(ctx context.Context, l log.Logger, repoURL string, onModule ModuleFunc) error { + if repoURL == "" { + l.Warnf("Empty repository URL encountered, skipping.") + return nil + } + + walkWithSymlinks := s.opts.Experiments.Evaluate(experiment.Symlinks) + allowCAS := s.opts.Experiments.Evaluate(experiment.CAS) + slowReporting := s.opts.Experiments.Evaluate(experiment.SlowTaskReporting) + + encodedRepoURL := util.EncodeBase64Sha1(repoURL) + tempPath := filepath.Join(os.TempDir(), fmt.Sprintf(tempDirFormat, encodedRepoURL)) + + l.Debugf("Processing repository %s in temporary path %s", repoURL, tempPath) + + repo, err := s.newRepo(ctx, l, module.RepoOpts{ + CloneURL: repoURL, + Path: tempPath, + WalkWithSymlinks: walkWithSymlinks, + AllowCAS: allowCAS, + SlowReporting: slowReporting, + RootWorkingDir: s.opts.RootWorkingDir, + }) + if err != nil { + return errors.Errorf("failed to initialize repository %s: %w", repoURL, err) + } + + repoModules, err := repo.FindModules(ctx) + if err != nil { + return errors.Errorf("failed to find modules in repository %s: %w", repoURL, err) + } + + l.Debugf("Found %d module(s) in repository %q", len(repoModules), repoURL) + + s.mu.Lock() + s.modules = append(s.modules, repoModules...) + s.mu.Unlock() + + for _, mod := range repoModules { + onModule(mod) + } + + return nil +} + func (s *catalogServiceImpl) Modules() module.Modules { - return s.modules + s.mu.Lock() + defer s.mu.Unlock() + + return slices.Clone(s.modules) } func (s *catalogServiceImpl) Scaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, module *module.Module) error {