diff --git a/README.md b/README.md index c1a4254..7c18174 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Pug automatically loads variables from a .tfvars file. It looks for a file named ### Explorer -The explorer pane a tree of [modules](#module) and [workspaces](#workspace) discovered on your filesystem. From here, terraform commands can be carried out on both modules and workspaces. +The explorer pane shows a tree of [modules](#module) and [workspaces](#workspace) discovered on your filesystem. From here, terraform commands can be carried out on both modules and workspaces. You can select multiple modules or workspaces; you cannot select a combination of the two. Any terraform commands are then carried out on the selection. diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index 470d4d4..5c07f9d 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -7,7 +7,7 @@ import ( "github.com/leg100/pug/internal/resource" ) -const bufferSize = 1024 +const bufferSize = 1024 * 1024 type Logger interface { Debug(msg string, args ...any) diff --git a/internal/tui/border.go b/internal/tui/border.go index e078e68..56ccb6a 100644 --- a/internal/tui/border.go +++ b/internal/tui/border.go @@ -90,8 +90,8 @@ func borderize(content string, active bool, embeddedText map[BorderPosition]stri // Add the corners return style.Render(leftCorner) + s + style.Render(rightCorner) } - // Stack top border onto remaining borders - return lipgloss.JoinVertical(lipgloss.Top, + // Stack top border, content and horizontal borders, and bottom border. + return strings.Join([]string{ buildHorizontalBorder( embeddedText[TopLeftBorder], embeddedText[TopMiddleBorder], @@ -111,5 +111,5 @@ func borderize(content string, active bool, embeddedText map[BorderPosition]stri border.Bottom, border.BottomRight, ), - ) + }, "\n") } diff --git a/internal/tui/explorer/messages.go b/internal/tui/explorer/messages.go index e56227f..8a4ecad 100644 --- a/internal/tui/explorer/messages.go +++ b/internal/tui/explorer/messages.go @@ -1,3 +1,6 @@ package explorer -type builtTreeMsg *tree +type builtTreeMsg struct { + tree *tree + rendered string +} diff --git a/internal/tui/explorer/model.go b/internal/tui/explorer/model.go index b450ed5..369e06d 100644 --- a/internal/tui/explorer/model.go +++ b/internal/tui/explorer/model.go @@ -10,7 +10,6 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - lgtree "github.com/charmbracelet/lipgloss/tree" "github.com/leg100/pug/internal" "github.com/leg100/pug/internal/module" "github.com/leg100/pug/internal/resource" @@ -34,16 +33,17 @@ func (mm *Maker) Make(id resource.ID, width, height int) (tui.ChildModel, error) moduleService: mm.ModuleService, workspaceService: mm.WorkspaceService, } - tree := builder.newTree("") filter := textinput.New() filter.Prompt = "Filter: " + tree, lgtree := builder.newTree("") m := &model{ - Helpers: mm.Helpers, - Workdir: mm.Workdir, - treeBuilder: builder, - tree: tree, - tracker: newTracker(tree, height), - filter: filter, + Helpers: mm.Helpers, + Workdir: mm.Workdir, + treeBuilder: builder, + tree: tree, + renderedTree: lgtree, + tracker: newTracker(tree, height), + filter: filter, } m.common = &tui.ActionHandler{ Helpers: mm.Helpers, @@ -61,11 +61,21 @@ type model struct { treeBuilder *treeBuilder tree *tree tracker *tracker + renderedTree string width, height int filter textinput.Model + status buildTreeStatus } -func (m model) Init() tea.Cmd { +type buildTreeStatus int + +const ( + notBuildingTree buildTreeStatus = iota + buildingTree + queueBuildTree +) + +func (m *model) Init() tea.Cmd { return tea.Batch( m.buildTree, reload(true, m.Modules), @@ -136,10 +146,15 @@ func (m *model) Update(msg tea.Msg) tea.Cmd { return m.common.Update(msg) } case builtTreeMsg: - m.tree = (*tree)(msg) + m.tree = msg.tree + m.renderedTree = msg.rendered // TODO: perform this in a cmd m.tracker.reindex(m.tree, m.treeHeight()) - return nil + if m.status == queueBuildTree { + return m.buildTree + } else { + m.status = notBuildingTree + } case resource.Event[*module.Module]: return m.buildTree case resource.Event[*workspace.Workspace]: @@ -209,12 +224,7 @@ func (m model) View() string { Width(m.width - tui.ScrollbarWidth). MaxWidth(m.width - tui.ScrollbarWidth). Inline(true) - to := lgtree.New(). - Enumerator(enumerator). - Indenter(indentor) - m.tree.render(true, to) - s := to.String() - lines := strings.Split(s, "\n") + lines := strings.Split(m.renderedTree, "\n") numVisibleLines := clamp(m.treeHeight(), 0, len(lines)) visibleLines := lines[m.tracker.start : m.tracker.start+numVisibleLines] for i := range visibleLines { @@ -273,9 +283,18 @@ func (m model) BorderText() map[tui.BorderPosition]string { } } -func (m model) buildTree() tea.Msg { - tree := m.treeBuilder.newTree(m.filter.Value()) - return builtTreeMsg(tree) +func (m *model) buildTree() tea.Msg { + switch m.status { + case notBuildingTree: + tree, rendered := m.treeBuilder.newTree(m.filter.Value()) + return builtTreeMsg{ + tree: tree, + rendered: rendered, + } + case buildingTree: + m.status = queueBuildTree + } + return nil } func (m model) filterVisible() bool { diff --git a/internal/tui/explorer/nodes.go b/internal/tui/explorer/nodes.go index d80b12a..201d2b0 100644 --- a/internal/tui/explorer/nodes.go +++ b/internal/tui/explorer/nodes.go @@ -11,9 +11,10 @@ import ( type node interface { fmt.Stringer - // ID uniquely identifies the node ID() any + // Value returns a value for lexicographic sorting + Value() string } type dirNode struct { @@ -26,14 +27,18 @@ func (d dirNode) ID() any { return d.path } -func (d dirNode) String() string { +func (d dirNode) Value() string { if d.root { - return fmt.Sprintf("%s %s", tui.DirIcon, d.path) + return d.path } else { - return fmt.Sprintf("%s %s", tui.DirIcon, filepath.Base(d.path)) + return filepath.Base(d.path) } } +func (d dirNode) String() string { + return fmt.Sprintf("%s %s", tui.DirIcon, d.Value()) +} + type moduleNode struct { id resource.ID path string @@ -43,8 +48,12 @@ func (m moduleNode) ID() any { return m.id } +func (m moduleNode) Value() string { + return filepath.Base(m.path) +} + func (m moduleNode) String() string { - return tui.ModulePathWithIcon(filepath.Base(m.path), false) + return tui.ModulePathWithIcon(m.Value(), false) } type workspaceNode struct { @@ -59,6 +68,10 @@ func (w workspaceNode) ID() any { return w.id } +func (w workspaceNode) Value() string { + return w.name +} + func (w workspaceNode) String() string { name := lipgloss.NewStyle(). Render(w.name) diff --git a/internal/tui/explorer/tree.go b/internal/tui/explorer/tree.go index 5cec3b3..e3ece27 100644 --- a/internal/tui/explorer/tree.go +++ b/internal/tui/explorer/tree.go @@ -37,7 +37,7 @@ type treeBuilderHelpers interface { WorkspaceCost(ws *workspace.Workspace) string } -func (b *treeBuilder) newTree(filter string) *tree { +func (b *treeBuilder) newTree(filter string) (*tree, string) { t := &tree{ value: dirNode{root: true, path: b.wd.PrettyString()}, } @@ -78,7 +78,16 @@ func (b *treeBuilder) newTree(filter string) *tree { modTree.addChild(ws) } } - return t.filter(filter) + // Apply filter if there is one. + filtered := t.filter(filter) + // Now render the corresponding lipgloss tree. We do this here rather than + // in View() because it's quite expensive and thus best kept out of the + // bubble event loop. + to := lgtree.New(). + Enumerator(enumerator). + Indenter(indentor) + filtered.render(true, to) + return filtered, to.String() } func (t *tree) filter(text string) *tree { @@ -133,9 +142,21 @@ func (t *tree) addChild(child node) *tree { } newTree := &tree{value: child} t.children = append(t.children, newTree) - // keep children lexicographically ordered + // keep children ordered slices.SortFunc(t.children, func(a, b *tree) int { - if internal.StripAnsi(a.value.String()) < internal.StripAnsi(b.value.String()) { + // directories come before modules + if _, ok := a.value.(dirNode); ok { + if _, ok := b.value.(moduleNode); ok { + return -1 + } + } + if _, ok := a.value.(moduleNode); ok { + if _, ok := b.value.(dirNode); ok { + return 1 + } + } + // otherwise order lexicographically. + if a.value.Value() < b.value.Value() { return -1 } return 1 diff --git a/internal/tui/explorer/tree_test.go b/internal/tui/explorer/tree_test.go index f744732..fbcf1fe 100644 --- a/internal/tui/explorer/tree_test.go +++ b/internal/tui/explorer/tree_test.go @@ -52,7 +52,7 @@ func TestTree(t *testing.T) { helpers: &fakeTreeBuilderHelpers{}, } - got := builder.newTree("") + got, _ := builder.newTree("") want := &tree{ value: dirNode{path: builder.wd.String(), root: true}, diff --git a/internal/tui/top/model.go b/internal/tui/top/model.go index 7b2f3a6..0292f5b 100644 --- a/internal/tui/top/model.go +++ b/internal/tui/top/model.go @@ -361,7 +361,7 @@ func (m model) View() string { Width(m.width). Render(footer), ) - return lipgloss.JoinVertical(lipgloss.Top, components...) + return strings.Join(components, "\n") } var ( diff --git a/internal/tui/workspace/resource.go b/internal/tui/workspace/resource.go index 83570be..ea4fd5a 100644 --- a/internal/tui/workspace/resource.go +++ b/internal/tui/workspace/resource.go @@ -125,19 +125,17 @@ func (m *resourceModel) View() string { } func (m *resourceModel) BorderText() map[tui.BorderPosition]string { - var tainted string + topLeft := fmt.Sprintf("%s %s", + tui.Bold.Render("resource"), + m.resource, + ) if m.resource.Tainted { - tainted = lipgloss.NewStyle(). + topLeft += lipgloss.NewStyle(). Foreground(tui.Red). - Render("(tainted)") + Render(" (tainted)") } return map[tui.BorderPosition]string{ - tui.TopLeftBorder: fmt.Sprintf( - "%s %s %s", - tui.Bold.Render("resource"), - m.resource, - tainted, - ), + tui.TopLeftBorder: topLeft, } }