From 8cb03a849d7ac7901a9508d532b89b7d574f1f1e Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 5 May 2025 10:52:03 -1000 Subject: [PATCH 01/18] alternate selection viewport and modal --- .vscode/launch.json | 11 ++ pkg/tui/drew/model_auth.go | 1 + pkg/tui/drew/model_main.go | 192 ++++++++++++++++++++++++++++ pkg/tui/drew/model_org_selection.go | 167 ++++++++++++++++++++++++ pkg/tui/tui.go | 51 ++++---- 5 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 pkg/tui/drew/model_auth.go create mode 100644 pkg/tui/drew/model_main.go create mode 100644 pkg/tui/drew/model_org_selection.go diff --git a/.vscode/launch.json b/.vscode/launch.json index 80a4b962..50461d5f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -311,6 +311,17 @@ "--model", "llama2", ], + }, + { + "name": "create", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/main.go", + "args": [ + "create", + "test-workspace", + ], } ] } \ No newline at end of file diff --git a/pkg/tui/drew/model_auth.go b/pkg/tui/drew/model_auth.go new file mode 100644 index 00000000..c529c7bc --- /dev/null +++ b/pkg/tui/drew/model_auth.go @@ -0,0 +1 @@ +package drew \ No newline at end of file diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go new file mode 100644 index 00000000..4ab69a6f --- /dev/null +++ b/pkg/tui/drew/model_main.go @@ -0,0 +1,192 @@ +package drew + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + lipgloss "github.com/charmbracelet/lipgloss" +) + +var ( + keywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Background(lipgloss.Color("235")) + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + + titleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "β”œ" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + // b.Left = "─" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + footerStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "β”œ" + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("204")). + Border(lipgloss.NormalBorder()). + BorderTop(true).BorderBottom(false).BorderLeft(false).BorderRight(false). + BorderForeground(lipgloss.Color("241")). + Height(1) + }() +) + +type MainModel struct { + // General states + quitting bool + suspending bool + + // General viewport + viewport viewport.Model + + // Org list Modal + renderOrgPickList bool + orgSelection *OrgSelection +} + +func (m *MainModel) View() string { + if m.quitting { + return "Quitting..." + } + if m.suspending { + return "" + } + + var content string + if m.renderOrgPickList { + // We are rendering the org pick list modal, which should be centered in the viewport + // TODO: figure out how to render this "on top" of the viewport, rather than replacing it + h := m.orgSelection.Height() + w := m.orgSelection.Width() + marginTop := (m.viewport.Height / 2) - (h / 2) + marginLeft := (m.viewport.Width / 2) - (w / 2) + marginBottom := m.viewport.Height - marginTop - h - 2 + content = lipgloss.NewStyle(). + Height(h). + Width(w). + MarginTop(marginTop). + MarginLeft(marginLeft). + MarginBottom(marginBottom). + Border(lipgloss.RoundedBorder()). + Render(m.orgSelection.View()) + } else { + content = m.viewport.View() + } + + /** + * Render the main view, which is always: + * + * [header] + * [content] + * [footer] + */ + return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView()) +} + +func (m *MainModel) headerView() string { + titleStr := "NVIDIA Brev πŸ€™" + if m.orgSelection.Selection() != nil { + titleStr = titleStr + " | " + m.orgSelection.Selection().Title() + } + title := titleStyle.Render(titleStr) + line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) + return lipgloss.JoinHorizontal(lipgloss.Center, title, line) +} + +func (m *MainModel) footerView() string { + help := helpStyle.Render("q/esc: exit β€’ o: select org") + return footerStyle.Width(m.viewport.Width).Render( + help, + ) +} + +func (m *MainModel) Init() tea.Cmd { + m.orgSelection = NewOrgSelection() + + return nil +} + +func (m *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle messages that are common to any view mode + switch msg := msg.(type) { + case tea.ResumeMsg: + + // Allow for resuming the program, if it was suspended to the background + m.suspending = false + return m, nil + case tea.QuitMsg: + + // Handle quitting the program + return m, tea.Quit + case tea.KeyMsg: + switch msg.String() { + + // Mark the program as having been suspended + case "ctrl+z": + m.suspending = true + return m, tea.Suspend + + // Allow for quitting, even when the org list modal is open + case "ctrl+c": + return m, tea.Quit + } + case tea.WindowSizeMsg: + + // Update the model's viewport on window size change + m.onWindowSizeChange(msg) + } + + var cmd tea.Cmd + + if m.renderOrgPickList { + // We are currently rendering the org pick list modal -- handle messages from and for its model + switch msg := msg.(type) { + case CloseOrgSelectionMsg: + // Close the org pick list modal without further processing + m.renderOrgPickList = false + return m, nil + case OrgSelectionErrorMsg: + // If there was an error fetching the orgs, quit the program + return m, tea.Quit // TODO: display the error or retry? + default: + // By default, pass the message to the org pick list model + cmd = m.orgSelection.Update(msg) + return m, cmd + } + } else { + // We are not rendering the org pick list modal -- handle messages from and for the viewport + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "q": + // Quit the program + return m, tea.Quit + case "o": + // Indicate that we want to render the org pick list modal, and trigger the fetching of orgs + m.renderOrgPickList = true + cmd = m.orgSelection.FetchOrgs() + + return m, cmd + } + default: + // By default, pass the message to the viewport + m.viewport, cmd = m.viewport.Update(msg) + } + return m, tea.Batch(cmd) + } +} + +func (m *MainModel) onWindowSizeChange(msg tea.WindowSizeMsg) { + headerHeight := lipgloss.Height(m.headerView()) + footerHeight := lipgloss.Height(m.footerView()) + verticalMarginHeight := headerHeight + footerHeight + + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - verticalMarginHeight +} diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go new file mode 100644 index 00000000..a9d0a1ea --- /dev/null +++ b/pkg/tui/drew/model_org_selection.go @@ -0,0 +1,167 @@ +package drew + +import ( + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// NewOrgSelection creates a new organization pick list model. +func NewOrgSelection() *OrgSelection { + // Create a custom delegate that doesn't quit on escape + delegate := list.NewDefaultDelegate() + + // Create a new list with no data yet + l := list.New([]list.Item{}, delegate, 50, 30) + + // Style the organization pick list + l.Title = "Select Organization" + l.SetShowStatusBar(true) + l.SetStatusBarItemName("organization", "organizations") + l.SetFilteringEnabled(false) + l.SetShowHelp(true) + l.DisableQuitKeybindings() + l.SetSpinner(spinner.Points) + + return &OrgSelection{orgPickListModel: l} +} + +// OrgSelection is a model that represents the organization pick list. Note that this is not a complete +// charmbracelet/bubbles/list.Model, but rather a wrapper around it that adds some additional functionality +// while allowing for simplified use of the wrapped list.Model. +type OrgSelection struct { + orgPickListModel list.Model + orgSelected *orgListItem +} + +// Selection returns the currently selected organization. +func (o *OrgSelection) Selection() *orgListItem { + return o.orgSelected +} + +// Width returns the width of the organization pick list. +func (o *OrgSelection) Width() int { + return o.orgPickListModel.Width() +} + +// Height returns the height of the organization pick list. +func (o *OrgSelection) Height() int { + return o.orgPickListModel.Height() +} + +type orgListItem struct { + title, desc string +} + +func (i orgListItem) Title() string { return i.title } +func (i orgListItem) Description() string { return i.desc } +func (i orgListItem) FilterValue() string { return i.title } + +type ( + // OrgSelectionErrorMsg is a message that indicates an error occurred while fetching organizations. + OrgSelectionErrorMsg struct{ err error } + + // CloseOrgSelectionMsg is a message that indicates the organization pick list should be closed. + CloseOrgSelectionMsg struct{} +) + +func errorCmd(err error) tea.Cmd { return func() tea.Msg { return OrgSelectionErrorMsg{err} } } +func closeCmd() tea.Cmd { return func() tea.Msg { return CloseOrgSelectionMsg{} } } + +func (o *OrgSelection) View() string { + return o.orgPickListModel.View() +} + +func (o *OrgSelection) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + + switch msg := msg.(type) { + case spinner.TickMsg: + // The org pick list spinner is still running, so we need to update the org pick list model to render the next frame + o.orgPickListModel, cmd = o.orgPickListModel.Update(msg) + return cmd + + case fetchOrgsMsg: + // The orgs have been fetched, so we need to update the org pick list model + if msg.err != nil { + return errorCmd(msg.err) + } + + // Insert the orgs into the org pick list model + pickListItems := make([]list.Item, len(msg.organizations)) + for i, org := range msg.organizations { + pickListItems[i] = orgListItem{title: org.Name, desc: org.Description} + } + + // Update the org pick list model with the new items + updatePickListCmd := o.orgPickListModel.SetItems(pickListItems) + o.orgPickListModel.StopSpinner() + + return updatePickListCmd + + case tea.KeyMsg: + switch msg.String() { + + // Close the org list + case "esc", "o", "q": + return closeCmd() + + // Select an org + case "enter": + if selected, ok := o.orgPickListModel.SelectedItem().(orgListItem); ok { + o.orgSelected = &selected + return closeCmd() + } + + // For all other key events, pass them to the org pick list model + default: + o.orgPickListModel, cmd = o.orgPickListModel.Update(msg) + } + } + + return cmd +} + +// FetchOrgs fetches the organizations and updates the org pick list model. This function automatically +// starts the spinner and returns a command that will update the org pick list model when the organizations +// are fetched. The returned command should be used to render the next frame for the spinner, and should +// also be used to update the org pick list model when the organizations are fetched. +func (o *OrgSelection) FetchOrgs() tea.Cmd { + startSpinnerCmd := o.orgPickListModel.StartSpinner() + fetchOrgsCmd := cmdFetchOrgs() + return tea.Batch(startSpinnerCmd, fetchOrgsCmd) +} + +type organization struct { + Name string + Description string +} + +type fetchOrgsMsg struct { + organizations []organization + err error +} + +func cmdFetchOrgs() tea.Cmd { + return func() tea.Msg { + // simulate loading + time.Sleep(time.Second * 3) + + return fetchOrgsMsg{organizations: []organization{ + {Name: "Organization 1", Description: "First organization"}, + {Name: "Organization 2", Description: "Second organization"}, + {Name: "Organization 3", Description: "Third organization"}, + {Name: "Organization 4", Description: "Fourth organization"}, + {Name: "Organization 5", Description: "Fifth organization"}, + {Name: "Organization 6", Description: "Sixth organization"}, + {Name: "Organization 7", Description: "Seventh organization"}, + {Name: "Organization 8", Description: "Eighth organization"}, + {Name: "Organization 9", Description: "Ninth organization"}, + {Name: "Organization 10", Description: "Tenth organization"}, + {Name: "Organization 11", Description: "Eleventh organization"}, + {Name: "Organization 12", Description: "Twelfth organization"}, + }, err: nil} + } +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index e7b9f64b..2e876404 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -8,6 +8,7 @@ import ( "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/brevdev/brev-cli/pkg/tui/drew" "github.com/brevdev/brev-cli/pkg/tui/messages" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" @@ -27,8 +28,8 @@ const ( // Style definitions var ( logoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(nvidiaGreen)). - Align(lipgloss.Center) + Foreground(lipgloss.Color(nvidiaGreen)). + Align(lipgloss.Center) activeTabBorder = lipgloss.Border{ Top: "─", @@ -58,8 +59,8 @@ var ( Padding(0, 1) activeTab = tab.Copy(). - Border(activeTabBorder, true). - Foreground(lipgloss.Color(nvidiaGreen)) + Border(activeTabBorder, true). + Foreground(lipgloss.Color(nvidiaGreen)) tabGap = tab.Copy(). BorderTop(false). @@ -70,21 +71,21 @@ var ( ) type model struct { - tabs []string - activeTab int - spinner spinner.Model - loading bool - loadingProgress int - ready bool - width int - height int - store *store.AuthHTTPStore - terminal *terminal.Terminal - listModel listModel - createModel createModel - workspaces []entity.Workspace - instanceTypes *store.InstanceTypeResponse - err error + tabs []string + activeTab int + spinner spinner.Model + loading bool + loadingProgress int + ready bool + width int + height int + store *store.AuthHTTPStore + terminal *terminal.Terminal + listModel listModel + createModel createModel + workspaces []entity.Workspace + instanceTypes *store.InstanceTypeResponse + err error } type workspacesLoadedMsg struct { @@ -249,14 +250,14 @@ func (m model) View() string { s.WriteString("\n\n") loadingStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(nvidiaGreen)) - + var loadingText string if m.workspaces == nil { loadingText = "Fetching Your Instances" } else { loadingText = "Entering TUI" } - + s.WriteString(loadingStyle.Render( fmt.Sprintf("%s %s%s", m.spinner.View(), loadingText, strings.Repeat(".", m.loadingProgress)), )) @@ -315,7 +316,11 @@ func max(a, b int) int { } func RunMainTUI(s *store.AuthHTTPStore, t *terminal.Terminal) error { - p := tea.NewProgram(initialModel(s, t), tea.WithAltScreen()) + // p := tea.NewProgram(initialModel(s, t), + // tea.WithAltScreen(), + // tea.WithMouseCellMotion(), + // ) + p := tea.NewProgram(&drew.MainModel{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) _, err := p.Run() return err -} \ No newline at end of file +} From ed049ec66c406125263dc7bc11f88fcbe5ef7ad6 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 5 May 2025 11:25:49 -1000 Subject: [PATCH 02/18] pulsating selection color --- pkg/tui/drew/model_org_selection.go | 102 +++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go index a9d0a1ea..32cf5fcc 100644 --- a/pkg/tui/drew/model_org_selection.go +++ b/pkg/tui/drew/model_org_selection.go @@ -1,17 +1,29 @@ package drew import ( + "fmt" + "io" "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + nvidiaGreenRGB = [3]int{118, 185, 0} + lightGrayRGB = [3]int{204, 204, 204} ) // NewOrgSelection creates a new organization pick list model. func NewOrgSelection() *OrgSelection { // Create a custom delegate that doesn't quit on escape - delegate := list.NewDefaultDelegate() + orgSelection := &OrgSelection{} + + delegate := orgListStyleDelegate{getPulseStep: func() int { + return orgSelection.orgPulseStep + }} // Create a new list with no data yet l := list.New([]list.Item{}, delegate, 50, 30) @@ -25,7 +37,8 @@ func NewOrgSelection() *OrgSelection { l.DisableQuitKeybindings() l.SetSpinner(spinner.Points) - return &OrgSelection{orgPickListModel: l} + orgSelection.orgPickListModel = l + return orgSelection } // OrgSelection is a model that represents the organization pick list. Note that this is not a complete @@ -34,6 +47,7 @@ func NewOrgSelection() *OrgSelection { type OrgSelection struct { orgPickListModel list.Model orgSelected *orgListItem + orgPulseStep int } // Selection returns the currently selected organization. @@ -59,6 +73,61 @@ func (i orgListItem) Title() string { return i.title } func (i orgListItem) Description() string { return i.desc } func (i orgListItem) FilterValue() string { return i.title } +type orgListStyleDelegate struct { + getPulseStep func() int +} + +func (d orgListStyleDelegate) Height() int { return 1 } +func (d orgListStyleDelegate) Spacing() int { return 1 } +func (d orgListStyleDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } + +type tickMsg time.Time + +func tick() tea.Cmd { + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (d orgListStyleDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + item, ok := listItem.(orgListItem) + if !ok { + return + } + + maxStep := 30 + step := d.getPulseStep() % maxStep + + var t float64 + if step < maxStep/2 { + t = float64(step) / float64(maxStep/2) + } else { + t = float64(maxStep-step) / float64(maxStep/2) + } + color := lerpColor(lightGrayRGB, nvidiaGreenRGB, t) + + selectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Padding(0, 1). + Margin(0, 1). + Border(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color(color)) + + unselectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")). + Padding(0, 1). + Margin(0, 1) + + var renderedItem string + if index == m.Index() { + renderedItem = selectedStyle.Render(item.Title()) + } else { + renderedItem = unselectedStyle.Render(item.Title()) + } + + fmt.Fprintf(w, "%s", renderedItem) //nolint: errcheck +} + type ( // OrgSelectionErrorMsg is a message that indicates an error occurred while fetching organizations. OrgSelectionErrorMsg struct{ err error } @@ -78,6 +147,10 @@ func (o *OrgSelection) Update(msg tea.Msg) tea.Cmd { var cmd tea.Cmd switch msg := msg.(type) { + case tickMsg: + o.orgPulseStep++ + return tick() + case spinner.TickMsg: // The org pick list spinner is still running, so we need to update the org pick list model to render the next frame o.orgPickListModel, cmd = o.orgPickListModel.Update(msg) @@ -129,9 +202,16 @@ func (o *OrgSelection) Update(msg tea.Msg) tea.Cmd { // are fetched. The returned command should be used to render the next frame for the spinner, and should // also be used to update the org pick list model when the organizations are fetched. func (o *OrgSelection) FetchOrgs() tea.Cmd { + // Start the spinner startSpinnerCmd := o.orgPickListModel.StartSpinner() + + // Fetch the organizations fetchOrgsCmd := cmdFetchOrgs() - return tea.Batch(startSpinnerCmd, fetchOrgsCmd) + + // Tick the pulse step + tickCmd := tick() + + return tea.Batch(startSpinnerCmd, fetchOrgsCmd, tickCmd) } type organization struct { @@ -165,3 +245,19 @@ func cmdFetchOrgs() tea.Cmd { }, err: nil} } } + +func lerpColor(from, to [3]int, t float64) string { + clamp := func(v int) int { + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return v + } + r := clamp(int(float64(from[0])*(1-t) + float64(to[0])*t)) + g := clamp(int(float64(from[1])*(1-t) + float64(to[1])*t)) + b := clamp(int(float64(from[2])*(1-t) + float64(to[2])*t)) + return fmt.Sprintf("#%02X%02X%02X", r, g, b) +} From fc8a124dfd58be48e7f19e80609b49cd5cd37709 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 5 May 2025 14:26:58 -1000 Subject: [PATCH 03/18] env list --- pkg/tui/drew/model_auth.go | 4 +- pkg/tui/drew/model_env_selection.go | 220 ++++++++++++++++++++++++++++ pkg/tui/drew/model_main.go | 99 +++++++------ pkg/tui/drew/model_org_selection.go | 202 ++++++++++--------------- pkg/tui/drew/styles.go | 18 +++ 5 files changed, 376 insertions(+), 167 deletions(-) create mode 100644 pkg/tui/drew/model_env_selection.go create mode 100644 pkg/tui/drew/styles.go diff --git a/pkg/tui/drew/model_auth.go b/pkg/tui/drew/model_auth.go index c529c7bc..940825e0 100644 --- a/pkg/tui/drew/model_auth.go +++ b/pkg/tui/drew/model_auth.go @@ -1 +1,3 @@ -package drew \ No newline at end of file +package drew + + diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go new file mode 100644 index 00000000..b058466f --- /dev/null +++ b/pkg/tui/drew/model_env_selection.go @@ -0,0 +1,220 @@ +package drew + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + lipgloss "github.com/charmbracelet/lipgloss" +) + +// NewEnvSelection creates a new environment pick list model. +func NewEnvSelection() *EnvSelection { + envSelection := &EnvSelection{} + + columns := []table.Column{ + {Title: "ID", Width: 10}, + {Title: "Name", Width: 20}, + {Title: "Description", Width: 30}, + } + rows := []table.Row{} + + envTable := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + envTable.SetStyles(table.Styles{ + Header: lipgloss.NewStyle(). + Foreground(textColorNormalTitle). + Bold(true). + Padding(0, 0, 0, 2), + Selected: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(borderColorSelected). + Foreground(textColorSelectedTitle). + Padding(0, 0, 0, 1), + Cell: table.DefaultStyles().Cell, + }) + + envSpinner := spinner.New() + envSpinner.Spinner = spinner.Points + envSpinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#64748b")) + + envSelection.loadingSpinner = envSpinner + envSelection.envTable = envTable + return envSelection +} + +// EnvSelection is a model that represents the environment pick list. Note that this is not a complete +// charmbracelet/bubbles/list.Model, but rather a wrapper around it that adds some additional functionality +// while allowing for simplified use of the wrapped list. +type EnvSelection struct { + envTable table.Model + envSelected *environment + + showLoadingSpinner bool + loadingSpinner spinner.Model +} + +// Selection returns the currently selected environment. +func (e *EnvSelection) Selection() *environment { + return e.envSelected +} + +// Width returns the width of the organization pick list. +func (e *EnvSelection) Width() int { + return e.envTable.Width() +} + +func (e *EnvSelection) SetWidth(width int) { + e.envTable.SetWidth(width) +} + +// Height returns the height of the organization pick list. +func (e *EnvSelection) Height() int { + return e.envTable.Height() +} + +func (e *EnvSelection) SetHeight(height int) { + e.envTable.SetHeight(height) +} + +type ( + // EnvSelectionErrorMsg is a message that indicates an error occurred while fetching environments. + EnvSelectionErrorMsg struct{ err error } +) + +func envSelectionErrorCmd(err error) tea.Cmd { + return func() tea.Msg { return EnvSelectionErrorMsg{err} } +} + +func (e *EnvSelection) View() string { + if e.showLoadingSpinner { + spinner := fmt.Sprintf("Loading environments %s", e.loadingSpinner.View()) + + // Create a vertically centered spinner box with full height + loadingBox := lipgloss.NewStyle(). + Height(e.envTable.Height()). // Match the table height + Width(e.envTable.Width()). // Match the table width + Align(lipgloss.Center). + AlignVertical(lipgloss.Center). + Render(spinner) + + return loadingBox + } + + selected := e.envTable.SelectedRow() + + left := lipgloss.NewStyle(). + Width(e.envTable.Width() / 2). + Render(e.envTable.View()) + + right := lipgloss.NewStyle(). + Width(e.envTable.Width()/2). + Padding(1, 0, 0, 1). + Border(lipgloss.RoundedBorder()). + Render(renderEnvDetails(selected)) + + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} + +func renderEnvDetails(selected []string) string { + if len(selected) == 0 { + return "" + } + return fmt.Sprintf("ID: %s\nName: %s\nDescription: %s", selected[0], selected[1], selected[2]) +} + +func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + + switch msg := msg.(type) { + + case fetchEnvsMsg: + // The orgs have been fetched, so we need to update the org pick list model + + // Disable the loading spinner + e.showLoadingSpinner = false + + if msg.err != nil { + return envSelectionErrorCmd(msg.err) + } + + // Insert the orgs into the org pick list model + envTableItems := make([]table.Row, len(msg.environments)) + for i, env := range msg.environments { + envTableItems[i] = table.Row{env.ID, env.Name, env.Description} + } + + // Update the env pick list model with the new items + e.envTable.SetRows(envTableItems) + + return nil + + case tea.KeyMsg: + // Pass the key event to the env pick list model + e.envTable, cmd = e.envTable.Update(msg) + return cmd + + default: + // If the loading spinner is enabled, update it + if e.showLoadingSpinner { + e.loadingSpinner, cmd = e.loadingSpinner.Update(msg) + return cmd + } + return nil + } +} + +// FetchEnvs fetches the environments and updates the env pick list model. This function automatically +// starts the spinner and returns a command that will update the env pick list model when the environments +// are fetched. The returned command should be used to render the next frame for the spinner, and should +// also be used to update the env pick list model when the environments are fetched. +func (e *EnvSelection) FetchEnvs(organizationID string) tea.Cmd { + e.envTable.SetRows([]table.Row{}) + + // Fetch the organizations + fetchEnvsCmd := cmdFetchEnvs(organizationID) + + // Start the spinner + e.showLoadingSpinner = true + spinnerCmd := e.loadingSpinner.Tick + + return tea.Batch(fetchEnvsCmd, spinnerCmd) +} + +type environment struct { + ID string + Name string + Description string +} + +type fetchEnvsMsg struct { + environments []environment + err error +} + +func cmdFetchEnvs(organizationID string) tea.Cmd { + return func() tea.Msg { + // simulate loading + time.Sleep(time.Second * 2) + + return fetchEnvsMsg{environments: []environment{ + {ID: "1", Name: "Environment 1", Description: "First environment"}, + {ID: "2", Name: "Environment 2", Description: "Second environment"}, + {ID: "3", Name: "Environment 3", Description: "Third environment"}, + {ID: "4", Name: "Environment 4", Description: "Fourth environment"}, + {ID: "5", Name: "Environment 5", Description: "Fifth environment"}, + {ID: "6", Name: "Environment 6", Description: "Sixth environment"}, + {ID: "7", Name: "Environment 7", Description: "Seventh environment"}, + {ID: "8", Name: "Environment 8", Description: "Eighth environment"}, + {ID: "9", Name: "Environment 9", Description: "Ninth environment"}, + {ID: "10", Name: "Environment 10", Description: "Tenth environment"}, + {ID: "11", Name: "Environment 11", Description: "Eleventh environment"}, + {ID: "12", Name: "Environment 12", Description: "Twelfth environment"}, + }, err: nil} + } +} diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index 4ab69a6f..63d9fb9a 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" lipgloss "github.com/charmbracelet/lipgloss" ) @@ -43,11 +42,12 @@ type MainModel struct { suspending bool // General viewport - viewport viewport.Model + // viewport viewport.Model // Org list Modal renderOrgPickList bool orgSelection *OrgSelection + envSelection *EnvSelection } func (m *MainModel) View() string { @@ -62,21 +62,24 @@ func (m *MainModel) View() string { if m.renderOrgPickList { // We are rendering the org pick list modal, which should be centered in the viewport // TODO: figure out how to render this "on top" of the viewport, rather than replacing it - h := m.orgSelection.Height() - w := m.orgSelection.Width() - marginTop := (m.viewport.Height / 2) - (h / 2) - marginLeft := (m.viewport.Width / 2) - (w / 2) - marginBottom := m.viewport.Height - marginTop - h - 2 + // h := m.orgSelection.Height() + // w := m.orgSelection.Width() + // marginTop := (m.envSelection.Height() / 2) - (h / 2) + // marginLeft := (m.envSelection.Width() / 2) - (w / 2) + // marginBottom := m.envSelection.Height() - marginTop - h - 2 content = lipgloss.NewStyle(). - Height(h). - Width(w). - MarginTop(marginTop). - MarginLeft(marginLeft). - MarginBottom(marginBottom). - Border(lipgloss.RoundedBorder()). - Render(m.orgSelection.View()) + Align(lipgloss.Center). + AlignVertical(lipgloss.Center). + Height(m.envSelection.Height()). // match background height + Width(m.envSelection.Width()). // match background width + Render( + lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(1, 2). + Render(m.orgSelection.View()), + ) } else { - content = m.viewport.View() + content = m.envSelection.View() } /** @@ -95,20 +98,20 @@ func (m *MainModel) headerView() string { titleStr = titleStr + " | " + m.orgSelection.Selection().Title() } title := titleStyle.Render(titleStr) - line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) + line := strings.Repeat("─", max(0, m.envSelection.Width()-lipgloss.Width(title))) return lipgloss.JoinHorizontal(lipgloss.Center, title, line) } func (m *MainModel) footerView() string { help := helpStyle.Render("q/esc: exit β€’ o: select org") - return footerStyle.Width(m.viewport.Width).Render( + return footerStyle.Width(m.envSelection.Width()).Render( help, ) } func (m *MainModel) Init() tea.Cmd { m.orgSelection = NewOrgSelection() - + m.envSelection = NewEnvSelection() return nil } @@ -140,53 +143,65 @@ func (m *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update the model's viewport on window size change m.onWindowSizeChange(msg) + return m, nil } - var cmd tea.Cmd - if m.renderOrgPickList { // We are currently rendering the org pick list modal -- handle messages from and for its model switch msg := msg.(type) { case CloseOrgSelectionMsg: + // Close the org pick list modal without further processing m.renderOrgPickList = false + if m.orgSelection.Selection() != nil { + cmd := m.envSelection.FetchEnvs(m.orgSelection.Selection().Organization.ID) + return m, cmd + } return m, nil case OrgSelectionErrorMsg: + // If there was an error fetching the orgs, quit the program return m, tea.Quit // TODO: display the error or retry? default: + // By default, pass the message to the org pick list model - cmd = m.orgSelection.Update(msg) + cmd := m.orgSelection.Update(msg) return m, cmd } - } else { - // We are not rendering the org pick list modal -- handle messages from and for the viewport - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc", "q": - // Quit the program - return m, tea.Quit - case "o": - // Indicate that we want to render the org pick list modal, and trigger the fetching of orgs - m.renderOrgPickList = true - cmd = m.orgSelection.FetchOrgs() + } - return m, cmd - } - default: - // By default, pass the message to the viewport - m.viewport, cmd = m.viewport.Update(msg) + // We are not rendering the org pick list modal -- handle messages from and for the viewport + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "q": + // Quit the program + return m, tea.Quit + case "o": + // Indicate that we want to render the org pick list modal, and trigger the fetching of orgs + m.renderOrgPickList = true + cmd := m.orgSelection.FetchOrgs() + return m, cmd } - return m, tea.Batch(cmd) } + + // By default, pass the message to the env selection model + cmd := m.envSelection.Update(msg) + return m, cmd } func (m *MainModel) onWindowSizeChange(msg tea.WindowSizeMsg) { headerHeight := lipgloss.Height(m.headerView()) footerHeight := lipgloss.Height(m.footerView()) - verticalMarginHeight := headerHeight + footerHeight + contentHeight := msg.Height - headerHeight - footerHeight + + // Resize the envSelection (background content) + m.envSelection.SetWidth(msg.Width) + m.envSelection.SetHeight(contentHeight) + + // Resize the orgSelection modal (centered overlay) + // You can set a fixed width or clamp it as needed - m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - verticalMarginHeight + m.orgSelection.SetWidth(min(msg.Width, 30)) + m.orgSelection.SetHeight(min(contentHeight-4, 30)) } diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go index 32cf5fcc..50afa5b9 100644 --- a/pkg/tui/drew/model_org_selection.go +++ b/pkg/tui/drew/model_org_selection.go @@ -1,8 +1,6 @@ package drew import ( - "fmt" - "io" "time" "github.com/charmbracelet/bubbles/list" @@ -11,33 +9,54 @@ import ( "github.com/charmbracelet/lipgloss" ) -var ( - nvidiaGreenRGB = [3]int{118, 185, 0} - lightGrayRGB = [3]int{204, 204, 204} -) - // NewOrgSelection creates a new organization pick list model. func NewOrgSelection() *OrgSelection { - // Create a custom delegate that doesn't quit on escape orgSelection := &OrgSelection{} - delegate := orgListStyleDelegate{getPulseStep: func() int { - return orgSelection.orgPulseStep - }} + delegate := list.NewDefaultDelegate() + delegate.Styles.NormalTitle = lipgloss.NewStyle(). + Foreground(textColorNormalTitle). + Padding(0, 0, 0, 2) + + delegate.Styles.NormalDesc = delegate.Styles.NormalTitle. + Foreground(textColorNormalDescription) + + delegate.Styles.SelectedTitle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(borderColorSelected). + Foreground(textColorSelectedTitle). + Padding(0, 0, 0, 1) + + delegate.Styles.SelectedDesc = delegate.Styles.SelectedTitle. + Foreground(textColorSelectedDescription) + + delegate.Styles.DimmedTitle = lipgloss.NewStyle(). + Foreground(textColorDimmedTitle). + Padding(0, 0, 0, 2) + + delegate.Styles.DimmedDesc = delegate.Styles.DimmedTitle. + Foreground(textColorDimmedDescription) + + delegate.Styles.FilterMatch = lipgloss.NewStyle().Underline(true) // Create a new list with no data yet - l := list.New([]list.Item{}, delegate, 50, 30) + list := list.New([]list.Item{}, delegate, 0, 0) // Style the organization pick list - l.Title = "Select Organization" - l.SetShowStatusBar(true) - l.SetStatusBarItemName("organization", "organizations") - l.SetFilteringEnabled(false) - l.SetShowHelp(true) - l.DisableQuitKeybindings() - l.SetSpinner(spinner.Points) - - orgSelection.orgPickListModel = l + list.Title = "Select Organization" + list.Styles.Title = lipgloss.NewStyle(). + Background(backgroundColorHeader). + Foreground(textColorHeader). + Bold(true) + + list.SetShowStatusBar(false) + list.SetStatusBarItemName("organization", "organizations") + list.SetFilteringEnabled(false) + list.SetShowHelp(true) + list.DisableQuitKeybindings() + list.SetSpinner(spinner.Points) + + orgSelection.orgPickListModel = list return orgSelection } @@ -47,7 +66,14 @@ func NewOrgSelection() *OrgSelection { type OrgSelection struct { orgPickListModel list.Model orgSelected *orgListItem - orgPulseStep int +} + +func (o *OrgSelection) SetHeight(height int) { + o.orgPickListModel.SetHeight(height) +} + +func (o *OrgSelection) SetWidth(width int) { + o.orgPickListModel.SetWidth(width) } // Selection returns the currently selected organization. @@ -66,67 +92,12 @@ func (o *OrgSelection) Height() int { } type orgListItem struct { - title, desc string + Organization organization } -func (i orgListItem) Title() string { return i.title } -func (i orgListItem) Description() string { return i.desc } -func (i orgListItem) FilterValue() string { return i.title } - -type orgListStyleDelegate struct { - getPulseStep func() int -} - -func (d orgListStyleDelegate) Height() int { return 1 } -func (d orgListStyleDelegate) Spacing() int { return 1 } -func (d orgListStyleDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } - -type tickMsg time.Time - -func tick() tea.Cmd { - return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -func (d orgListStyleDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - item, ok := listItem.(orgListItem) - if !ok { - return - } - - maxStep := 30 - step := d.getPulseStep() % maxStep - - var t float64 - if step < maxStep/2 { - t = float64(step) / float64(maxStep/2) - } else { - t = float64(maxStep-step) / float64(maxStep/2) - } - color := lerpColor(lightGrayRGB, nvidiaGreenRGB, t) - - selectedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Padding(0, 1). - Margin(0, 1). - Border(lipgloss.ThickBorder()). - BorderForeground(lipgloss.Color(color)) - - unselectedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#AAAAAA")). - Padding(0, 1). - Margin(0, 1) - - var renderedItem string - if index == m.Index() { - renderedItem = selectedStyle.Render(item.Title()) - } else { - renderedItem = unselectedStyle.Render(item.Title()) - } - - fmt.Fprintf(w, "%s", renderedItem) //nolint: errcheck -} +func (i orgListItem) Title() string { return i.Organization.Name } +func (i orgListItem) Description() string { return i.Organization.Description } +func (i orgListItem) FilterValue() string { return i.Organization.Name } type ( // OrgSelectionErrorMsg is a message that indicates an error occurred while fetching organizations. @@ -136,8 +107,10 @@ type ( CloseOrgSelectionMsg struct{} ) -func errorCmd(err error) tea.Cmd { return func() tea.Msg { return OrgSelectionErrorMsg{err} } } -func closeCmd() tea.Cmd { return func() tea.Msg { return CloseOrgSelectionMsg{} } } +func orgSelectionErrorCmd(err error) tea.Cmd { + return func() tea.Msg { return OrgSelectionErrorMsg{err} } +} +func orgSelectionCloseCmd() tea.Cmd { return func() tea.Msg { return CloseOrgSelectionMsg{} } } func (o *OrgSelection) View() string { return o.orgPickListModel.View() @@ -147,10 +120,6 @@ func (o *OrgSelection) Update(msg tea.Msg) tea.Cmd { var cmd tea.Cmd switch msg := msg.(type) { - case tickMsg: - o.orgPulseStep++ - return tick() - case spinner.TickMsg: // The org pick list spinner is still running, so we need to update the org pick list model to render the next frame o.orgPickListModel, cmd = o.orgPickListModel.Update(msg) @@ -159,17 +128,20 @@ func (o *OrgSelection) Update(msg tea.Msg) tea.Cmd { case fetchOrgsMsg: // The orgs have been fetched, so we need to update the org pick list model if msg.err != nil { - return errorCmd(msg.err) + return orgSelectionErrorCmd(msg.err) } // Insert the orgs into the org pick list model pickListItems := make([]list.Item, len(msg.organizations)) for i, org := range msg.organizations { - pickListItems[i] = orgListItem{title: org.Name, desc: org.Description} + pickListItems[i] = orgListItem{Organization: org} } // Update the org pick list model with the new items updatePickListCmd := o.orgPickListModel.SetItems(pickListItems) + if len(pickListItems) > 0 { + o.orgPickListModel.SetShowStatusBar(true) + } o.orgPickListModel.StopSpinner() return updatePickListCmd @@ -179,13 +151,13 @@ func (o *OrgSelection) Update(msg tea.Msg) tea.Cmd { // Close the org list case "esc", "o", "q": - return closeCmd() + return orgSelectionCloseCmd() // Select an org case "enter": if selected, ok := o.orgPickListModel.SelectedItem().(orgListItem); ok { o.orgSelected = &selected - return closeCmd() + return orgSelectionCloseCmd() } // For all other key events, pass them to the org pick list model @@ -208,13 +180,11 @@ func (o *OrgSelection) FetchOrgs() tea.Cmd { // Fetch the organizations fetchOrgsCmd := cmdFetchOrgs() - // Tick the pulse step - tickCmd := tick() - - return tea.Batch(startSpinnerCmd, fetchOrgsCmd, tickCmd) + return tea.Batch(startSpinnerCmd, fetchOrgsCmd) } type organization struct { + ID string Name string Description string } @@ -227,37 +197,21 @@ type fetchOrgsMsg struct { func cmdFetchOrgs() tea.Cmd { return func() tea.Msg { // simulate loading - time.Sleep(time.Second * 3) + time.Sleep(time.Second * 2) return fetchOrgsMsg{organizations: []organization{ - {Name: "Organization 1", Description: "First organization"}, - {Name: "Organization 2", Description: "Second organization"}, - {Name: "Organization 3", Description: "Third organization"}, - {Name: "Organization 4", Description: "Fourth organization"}, - {Name: "Organization 5", Description: "Fifth organization"}, - {Name: "Organization 6", Description: "Sixth organization"}, - {Name: "Organization 7", Description: "Seventh organization"}, - {Name: "Organization 8", Description: "Eighth organization"}, - {Name: "Organization 9", Description: "Ninth organization"}, - {Name: "Organization 10", Description: "Tenth organization"}, - {Name: "Organization 11", Description: "Eleventh organization"}, - {Name: "Organization 12", Description: "Twelfth organization"}, + {ID: "1", Name: "Organization 1", Description: "First organization"}, + {ID: "2", Name: "Organization 2", Description: "Second organization"}, + {ID: "3", Name: "Organization 3", Description: "Third organization"}, + {ID: "4", Name: "Organization 4", Description: "Fourth organization"}, + {ID: "5", Name: "Organization 5", Description: "Fifth organization"}, + {ID: "6", Name: "Organization 6", Description: "Sixth organization"}, + {ID: "7", Name: "Organization 7", Description: "Seventh organization"}, + {ID: "8", Name: "Organization 8", Description: "Eighth organization"}, + {ID: "9", Name: "Organization 9", Description: "Ninth organization"}, + {ID: "10", Name: "Organization 10", Description: "Tenth organization"}, + {ID: "11", Name: "Organization 11", Description: "Eleventh organization"}, + {ID: "12", Name: "Organization 12", Description: "Twelfth organization"}, }, err: nil} } } - -func lerpColor(from, to [3]int, t float64) string { - clamp := func(v int) int { - if v < 0 { - return 0 - } - if v > 255 { - return 255 - } - return v - } - r := clamp(int(float64(from[0])*(1-t) + float64(to[0])*t)) - g := clamp(int(float64(from[1])*(1-t) + float64(to[1])*t)) - b := clamp(int(float64(from[2])*(1-t) + float64(to[2])*t)) - return fmt.Sprintf("#%02X%02X%02X", r, g, b) -} diff --git a/pkg/tui/drew/styles.go b/pkg/tui/drew/styles.go new file mode 100644 index 00000000..d0a8747b --- /dev/null +++ b/pkg/tui/drew/styles.go @@ -0,0 +1,18 @@ +package drew + +import lipgloss "github.com/charmbracelet/lipgloss" + +var ( + textColorNormalTitle = lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"} + textColorNormalDescription = lipgloss.AdaptiveColor{Light: "#a0a59f", Dark: "#777777"} + + textColorSelectedTitle = lipgloss.AdaptiveColor{Light: "#7af86f", Dark: "#7af86f"} + textColorSelectedDescription = lipgloss.AdaptiveColor{Light: "#7df86f", Dark: "#58b460"} + borderColorSelected = lipgloss.AdaptiveColor{Light: "#9aff93", Dark: "#58b45e"} + + textColorDimmedTitle = lipgloss.AdaptiveColor{Light: "#9fa59f", Dark: "#777777"} + textColorDimmedDescription = lipgloss.AdaptiveColor{Light: "#b8c2b8", Dark: "#4D4D4D"} + + backgroundColorHeader = lipgloss.AdaptiveColor{Light: "#76b900", Dark: "#76b900"} + textColorHeader = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#000000"} +) From abe130b9c9556e51bec2f6deb4fdd538794b053f Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 5 May 2025 14:30:59 -1000 Subject: [PATCH 04/18] env list --- pkg/tui/drew/model_main.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index 63d9fb9a..7d46e0ea 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -15,7 +15,7 @@ var ( titleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Right = "β”œ" - return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1).Foreground(textColorDimmedDescription) }() infoStyle = func() lipgloss.Style { @@ -195,13 +195,9 @@ func (m *MainModel) onWindowSizeChange(msg tea.WindowSizeMsg) { footerHeight := lipgloss.Height(m.footerView()) contentHeight := msg.Height - headerHeight - footerHeight - // Resize the envSelection (background content) m.envSelection.SetWidth(msg.Width) m.envSelection.SetHeight(contentHeight) - // Resize the orgSelection modal (centered overlay) - // You can set a fixed width or clamp it as needed - m.orgSelection.SetWidth(min(msg.Width, 30)) - m.orgSelection.SetHeight(min(contentHeight-4, 30)) + m.orgSelection.SetHeight(min(contentHeight-4, 30)) // keep a small amount of padding for the height } From 912e10dc8531ffd1899d247f9b351010075d8672 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 5 May 2025 17:32:01 -1000 Subject: [PATCH 05/18] env as table --- pkg/tui/drew/model_main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index 7d46e0ea..989470d5 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -15,7 +15,7 @@ var ( titleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Right = "β”œ" - return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1).Foreground(textColorDimmedDescription) + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) }() infoStyle = func() lipgloss.Style { From a7b29d73b26f20916102d2748d2e09eaac0bab7d Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 5 May 2025 18:04:29 -1000 Subject: [PATCH 06/18] simple pane --- pkg/tui/drew/model_env_selection.go | 164 ++++++++++++++++++---------- 1 file changed, 104 insertions(+), 60 deletions(-) diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go index b058466f..2243a0a2 100644 --- a/pkg/tui/drew/model_env_selection.go +++ b/pkg/tui/drew/model_env_selection.go @@ -4,8 +4,8 @@ import ( "fmt" "time" + "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" lipgloss "github.com/charmbracelet/lipgloss" ) @@ -14,37 +14,47 @@ import ( func NewEnvSelection() *EnvSelection { envSelection := &EnvSelection{} - columns := []table.Column{ - {Title: "ID", Width: 10}, - {Title: "Name", Width: 20}, - {Title: "Description", Width: 30}, - } - rows := []table.Row{} - - envTable := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - envTable.SetStyles(table.Styles{ - Header: lipgloss.NewStyle(). - Foreground(textColorNormalTitle). - Bold(true). - Padding(0, 0, 0, 2), - Selected: lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(borderColorSelected). - Foreground(textColorSelectedTitle). - Padding(0, 0, 0, 1), - Cell: table.DefaultStyles().Cell, - }) + delegate := list.NewDefaultDelegate() + delegate.Styles.NormalTitle = lipgloss.NewStyle(). + Foreground(textColorNormalTitle). + Padding(0, 0, 0, 2) + + delegate.Styles.NormalDesc = delegate.Styles.NormalTitle. + Foreground(textColorNormalDescription) + + delegate.Styles.SelectedTitle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(borderColorSelected). + Foreground(textColorSelectedTitle). + Padding(0, 0, 0, 1) + + delegate.Styles.SelectedDesc = delegate.Styles.SelectedTitle. + Foreground(textColorSelectedDescription) + + delegate.Styles.DimmedTitle = lipgloss.NewStyle(). + Foreground(textColorDimmedTitle). + Padding(0, 0, 0, 2) + + delegate.Styles.DimmedDesc = delegate.Styles.DimmedTitle. + Foreground(textColorDimmedDescription) + + delegate.Styles.FilterMatch = lipgloss.NewStyle().Underline(true) + + list := list.New([]list.Item{}, delegate, 40, 20) + + list.SetShowStatusBar(false) + list.SetShowTitle(false) + list.SetStatusBarItemName("environment", "environments") + list.SetFilteringEnabled(false) + list.SetShowHelp(false) + list.DisableQuitKeybindings() envSpinner := spinner.New() envSpinner.Spinner = spinner.Points envSpinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#64748b")) envSelection.loadingSpinner = envSpinner - envSelection.envTable = envTable + envSelection.envList = list return envSelection } @@ -52,7 +62,7 @@ func NewEnvSelection() *EnvSelection { // charmbracelet/bubbles/list.Model, but rather a wrapper around it that adds some additional functionality // while allowing for simplified use of the wrapped list. type EnvSelection struct { - envTable table.Model + envList list.Model envSelected *environment showLoadingSpinner bool @@ -66,22 +76,32 @@ func (e *EnvSelection) Selection() *environment { // Width returns the width of the organization pick list. func (e *EnvSelection) Width() int { - return e.envTable.Width() + return e.envList.Width() } func (e *EnvSelection) SetWidth(width int) { - e.envTable.SetWidth(width) + e.envList.SetWidth(width) } // Height returns the height of the organization pick list. func (e *EnvSelection) Height() int { - return e.envTable.Height() + return e.envList.Height() } func (e *EnvSelection) SetHeight(height int) { - e.envTable.SetHeight(height) + e.envList.SetHeight(height) +} + +type envListItem struct { + environment environment } +func (e envListItem) Title() string { return fmt.Sprintf("%s", e.environment.Name) } +func (e envListItem) Description() string { + return fmt.Sprintf("%s β€’ %s β€’ %s", e.environment.GPU, e.environment.CPU, e.environment.RAM) +} +func (e envListItem) FilterValue() string { return e.environment.Name } + type ( // EnvSelectionErrorMsg is a message that indicates an error occurred while fetching environments. EnvSelectionErrorMsg struct{ err error } @@ -97,8 +117,8 @@ func (e *EnvSelection) View() string { // Create a vertically centered spinner box with full height loadingBox := lipgloss.NewStyle(). - Height(e.envTable.Height()). // Match the table height - Width(e.envTable.Width()). // Match the table width + Height(e.envList.Height()). // Match the table height + Width(e.envList.Width()). // Match the table width Align(lipgloss.Center). AlignVertical(lipgloss.Center). Render(spinner) @@ -106,14 +126,23 @@ func (e *EnvSelection) View() string { return loadingBox } - selected := e.envTable.SelectedRow() + var selected *environment + if e.envList.SelectedItem() == nil { + selected = nil + } else { + if selectedItem, ok := e.envList.SelectedItem().(envListItem); ok { + selected = &selectedItem.environment + } else { + selected = nil + } + } left := lipgloss.NewStyle(). - Width(e.envTable.Width() / 2). - Render(e.envTable.View()) + Width(e.envList.Width() / 2). + Render(e.envList.View()) right := lipgloss.NewStyle(). - Width(e.envTable.Width()/2). + Width(e.envList.Width()/2). Padding(1, 0, 0, 1). Border(lipgloss.RoundedBorder()). Render(renderEnvDetails(selected)) @@ -121,11 +150,23 @@ func (e *EnvSelection) View() string { return lipgloss.JoinHorizontal(lipgloss.Top, left, right) } -func renderEnvDetails(selected []string) string { - if len(selected) == 0 { +func renderEnvDetails(environment *environment) string { + if environment == nil { return "" } - return fmt.Sprintf("ID: %s\nName: %s\nDescription: %s", selected[0], selected[1], selected[2]) + return lipgloss.NewStyle(). + // Border(lipgloss.RoundedBorder()). + // BorderForeground(lipgloss.Color("#76b900")). + Padding(1, 2). + Width(60). + Render(fmt.Sprintf(` +ID: %s +Name: %s +GPU: %s +CPU: %s vCPU +RAM: %s +Status: %s +`, environment.ID, environment.Name, environment.GPU, environment.CPU, environment.RAM, environment.Status)) } func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { @@ -144,19 +185,19 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { } // Insert the orgs into the org pick list model - envTableItems := make([]table.Row, len(msg.environments)) + envListItems := make([]list.Item, len(msg.environments)) for i, env := range msg.environments { - envTableItems[i] = table.Row{env.ID, env.Name, env.Description} + envListItems[i] = envListItem{environment: env} } // Update the env pick list model with the new items - e.envTable.SetRows(envTableItems) + e.envList.SetItems(envListItems) return nil case tea.KeyMsg: // Pass the key event to the env pick list model - e.envTable, cmd = e.envTable.Update(msg) + e.envList, cmd = e.envList.Update(msg) return cmd default: @@ -174,7 +215,7 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { // are fetched. The returned command should be used to render the next frame for the spinner, and should // also be used to update the env pick list model when the environments are fetched. func (e *EnvSelection) FetchEnvs(organizationID string) tea.Cmd { - e.envTable.SetRows([]table.Row{}) + e.envList.SetItems([]list.Item{}) // Fetch the organizations fetchEnvsCmd := cmdFetchEnvs(organizationID) @@ -187,9 +228,12 @@ func (e *EnvSelection) FetchEnvs(organizationID string) tea.Cmd { } type environment struct { - ID string - Name string - Description string + ID string + Name string + GPU string + CPU string + RAM string + Status string } type fetchEnvsMsg struct { @@ -203,18 +247,18 @@ func cmdFetchEnvs(organizationID string) tea.Cmd { time.Sleep(time.Second * 2) return fetchEnvsMsg{environments: []environment{ - {ID: "1", Name: "Environment 1", Description: "First environment"}, - {ID: "2", Name: "Environment 2", Description: "Second environment"}, - {ID: "3", Name: "Environment 3", Description: "Third environment"}, - {ID: "4", Name: "Environment 4", Description: "Fourth environment"}, - {ID: "5", Name: "Environment 5", Description: "Fifth environment"}, - {ID: "6", Name: "Environment 6", Description: "Sixth environment"}, - {ID: "7", Name: "Environment 7", Description: "Seventh environment"}, - {ID: "8", Name: "Environment 8", Description: "Eighth environment"}, - {ID: "9", Name: "Environment 9", Description: "Ninth environment"}, - {ID: "10", Name: "Environment 10", Description: "Tenth environment"}, - {ID: "11", Name: "Environment 11", Description: "Eleventh environment"}, - {ID: "12", Name: "Environment 12", Description: "Twelfth environment"}, + {ID: "1", Name: "Environment 1", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "2", Name: "Environment 2", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "3", Name: "Environment 3", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "4", Name: "Environment 4", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "5", Name: "Environment 5", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "6", Name: "Environment 6", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "7", Name: "Environment 7", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "8", Name: "Environment 8", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "9", Name: "Environment 9", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "10", Name: "Environment 10", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "11", Name: "Environment 11", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, + {ID: "12", Name: "Environment 12", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, }, err: nil} } } From bee33746c4f9950a5914d313b04cef50b7d3ca34 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 6 May 2025 16:18:19 -1000 Subject: [PATCH 07/18] simplify env selection --- pkg/tui/drew/model_env_selection.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go index 2243a0a2..c9a88a30 100644 --- a/pkg/tui/drew/model_env_selection.go +++ b/pkg/tui/drew/model_env_selection.go @@ -138,12 +138,11 @@ func (e *EnvSelection) View() string { } left := lipgloss.NewStyle(). - Width(e.envList.Width() / 2). + Width(int(float64(e.envList.Width()) * 0.4)). Render(e.envList.View()) right := lipgloss.NewStyle(). - Width(e.envList.Width()/2). - Padding(1, 0, 0, 1). + Width(int(float64(e.envList.Width()) * 0.6)). Border(lipgloss.RoundedBorder()). Render(renderEnvDetails(selected)) @@ -155,8 +154,6 @@ func renderEnvDetails(environment *environment) string { return "" } return lipgloss.NewStyle(). - // Border(lipgloss.RoundedBorder()). - // BorderForeground(lipgloss.Color("#76b900")). Padding(1, 2). Width(60). Render(fmt.Sprintf(` @@ -190,6 +187,9 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { envListItems[i] = envListItem{environment: env} } + if len(envListItems) > 0 { + e.envList.SetShowStatusBar(true) + } // Update the env pick list model with the new items e.envList.SetItems(envListItems) From 961cf325df3ee2efbbeb3909645f9736b08e089a Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sun, 11 May 2025 14:17:57 -0700 Subject: [PATCH 08/18] right hand pane --- pkg/tui/drew/model_env_selection.go | 317 ++++++++++++++++++++++------ pkg/tui/drew/model_main.go | 44 +++- pkg/tui/drew/model_org_selection.go | 46 ++-- 3 files changed, 321 insertions(+), 86 deletions(-) diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go index c9a88a30..e50eea32 100644 --- a/pkg/tui/drew/model_env_selection.go +++ b/pkg/tui/drew/model_env_selection.go @@ -2,12 +2,20 @@ package drew import ( "fmt" + "sort" + "strings" "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" lipgloss "github.com/charmbracelet/lipgloss" + lipgloss_table "github.com/charmbracelet/lipgloss/table" +) + +const ( + envListWidthPercentage = 40.0 + envDetailsWidthPercentage = 60.0 ) // NewEnvSelection creates a new environment pick list model. @@ -41,20 +49,25 @@ func NewEnvSelection() *EnvSelection { delegate.Styles.FilterMatch = lipgloss.NewStyle().Underline(true) list := list.New([]list.Item{}, delegate, 40, 20) - list.SetShowStatusBar(false) list.SetShowTitle(false) list.SetStatusBarItemName("environment", "environments") list.SetFilteringEnabled(false) list.SetShowHelp(false) list.DisableQuitKeybindings() + envSelection.envList = list - envSpinner := spinner.New() - envSpinner.Spinner = spinner.Points - envSpinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#64748b")) + envStatusSpinner := spinner.New( + spinner.WithSpinner(spinner.MiniDot), + ) + envSelection.statusSpinner = envStatusSpinner + envSpinner := spinner.New( + spinner.WithSpinner(spinner.Points), + spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#64748b"))), + ) envSelection.loadingSpinner = envSpinner - envSelection.envList = list + return envSelection } @@ -63,14 +76,18 @@ func NewEnvSelection() *EnvSelection { // while allowing for simplified use of the wrapped list. type EnvSelection struct { envList list.Model - envSelected *environment + envSelected *Environment + + // A spinner model to use when rendering containers or environments + statusSpinner spinner.Model + // A spinner model to use when fetching environments showLoadingSpinner bool loadingSpinner spinner.Model } // Selection returns the currently selected environment. -func (e *EnvSelection) Selection() *environment { +func (e *EnvSelection) Selection() *Environment { return e.envSelected } @@ -93,13 +110,31 @@ func (e *EnvSelection) SetHeight(height int) { } type envListItem struct { - environment environment + envSelection *EnvSelection + environment Environment +} + +func (e envListItem) Title() string { + status := e.environment.Status + spinner := e.envSelection.statusSpinner + + renderedName := e.environment.Name + renderedStatus := status.StatusView(spinner) + + // right-pad the width + width := int(float64(e.envSelection.envList.Width()) * 0.4) + pad := width - lipgloss.Width(renderedName) - lipgloss.Width(renderedStatus) - 3 // 1 to leave us on the same line, 2 for padding + if pad < 1 { + pad = 1 + } + + return fmt.Sprintf("%s%s%s", renderedName, strings.Repeat(" ", pad), renderedStatus) } -func (e envListItem) Title() string { return fmt.Sprintf("%s", e.environment.Name) } func (e envListItem) Description() string { - return fmt.Sprintf("%s β€’ %s β€’ %s", e.environment.GPU, e.environment.CPU, e.environment.RAM) + return fmt.Sprintf("%dx %s (%s) β€’ %s", e.environment.InstanceType.GPUCount, e.environment.InstanceType.GPUModel, e.environment.InstanceType.VRAM, e.environment.InstanceType.Cloud.Name()) } + func (e envListItem) FilterValue() string { return e.environment.Name } type ( @@ -126,7 +161,7 @@ func (e *EnvSelection) View() string { return loadingBox } - var selected *environment + var selected *Environment if e.envList.SelectedItem() == nil { selected = nil } else { @@ -136,34 +171,159 @@ func (e *EnvSelection) View() string { selected = nil } } + envListViewWidth := int(float64(e.envList.Width()) * 0.4) + envDetailsViewWidth := int(float64(e.envList.Width()) * 0.59) - left := lipgloss.NewStyle(). - Width(int(float64(e.envList.Width()) * 0.4)). + envListView := lipgloss.NewStyle(). + Width(envListViewWidth). Render(e.envList.View()) - right := lipgloss.NewStyle(). - Width(int(float64(e.envList.Width()) * 0.6)). + envDetailsView := lipgloss.NewStyle(). + Width(envDetailsViewWidth). Border(lipgloss.RoundedBorder()). - Render(renderEnvDetails(selected)) + Render(e.renderEnvDetails(selected, envDetailsViewWidth)) - return lipgloss.JoinHorizontal(lipgloss.Top, left, right) + return lipgloss.JoinHorizontal(lipgloss.Top, envListView, envDetailsView) } -func renderEnvDetails(environment *environment) string { +func (e *EnvSelection) renderEnvDetails(environment *Environment, width int) string { if environment == nil { return "" } - return lipgloss.NewStyle(). - Padding(1, 2). - Width(60). - Render(fmt.Sprintf(` -ID: %s -Name: %s -GPU: %s -CPU: %s vCPU -RAM: %s -Status: %s -`, environment.ID, environment.Name, environment.GPU, environment.CPU, environment.RAM, environment.Status)) + + basicInfoTable := dataTable(). + Headers(lipgloss.NewStyle().Bold(true).Foreground(textColorSelectedTitle).Render("Environment")). + Width(width). + Rows([][]string{ + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Name"), environment.Name}, + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Status"), environment.Status.StatusView(e.statusSpinner)}, + }...). + Render() + + instanceConfigurationTable := dataTable(). + Width(width). + Headers(lipgloss.NewStyle().Bold(true).Foreground(textColorSelectedTitle).Render("Instance Configuration")). + Rows([][]string{ + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Cloud"), environment.InstanceType.Cloud.Name()}, + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("GPU"), environment.InstanceType.GPUModel}, + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("GPU Count"), fmt.Sprintf("%d", environment.InstanceType.GPUCount)}, + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("VRAM"), environment.InstanceType.VRAM}, + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("CPU"), environment.InstanceType.CPUModel}, + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("CPU Count"), fmt.Sprintf("%d", environment.InstanceType.CPUCount)}, + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("System RAM"), environment.InstanceType.Memory}, + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Storage"), environment.InstanceType.Storage}, + }...). + Render() + + var containersTable string + if environment.Containers == nil { + containersTable = "" + } else { + table := dataTable(). + Width(width). + Headers(lipgloss.NewStyle().Bold(true).Foreground(textColorSelectedTitle).Render("Containers")) + + rows := [][]string{ + // Single header row + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Name"), lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Status")}, + } + for _, container := range environment.Containers { + // New data row + rows = append(rows, []string{container.Name, container.Status.StatusView(e.statusSpinner)}) + } + + // Finalize the table and convert to a string + containersTable = "\n\n\n" + table.Rows(rows...).Render() + } + + var portsTable string + if environment.PortMappings == nil { + portsTable = "" + } else { + table := dataTable(). + Width(width). + Headers(lipgloss.NewStyle().Bold(true).Foreground(textColorSelectedTitle).Render("Public Ports")) + + rows := [][]string{ + // Single header row + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Host Port"), lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Public Port")}, + } + for _, mapping := range environment.PortMappings { + // New data row + rows = append(rows, []string{mapping.HostPort, mapping.PublicPort}) + } + + // Finalize the table and convert to a string + portsTable = "\n\n\n" + table.Rows(rows...).Render() + } + + var tunnelsTable string + if environment.Tunnels == nil { + tunnelsTable = "" + } else { + table := dataTable(). + Width(width). + Headers(lipgloss.NewStyle().Bold(true).Foreground(textColorSelectedTitle).Render("Tunnels")) + + rows := [][]string{ + // Single header row + {lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Host Port"), lipgloss.NewStyle().Foreground(textColorDimmedTitle).Render("Public URL")}, + } + for _, tunnel := range environment.Tunnels { + // New data row + rows = append(rows, []string{tunnel.HostPort, tunnel.PublicURL}) + } + + // Finalize the table and convert to a string + tunnelsTable = "\n\n\n" + table.Rows(rows...).Render() + } + return fmt.Sprintf("%s\n\n\n%s%s%s%s", basicInfoTable, instanceConfigurationTable, portsTable, tunnelsTable, containersTable) +} + +func dataTable() *lipgloss_table.Table { + return lipgloss_table.New(). + Border(lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: " ", + Right: " ", + TopLeft: " ", + TopRight: " ", + BottomLeft: " ", + BottomRight: " ", + MiddleLeft: " ", + MiddleRight: " ", + Middle: " ", + MiddleTop: " ", + MiddleBottom: " ", + }). + BorderRow(true). + BorderColumn(false). + BorderTop(false). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))) +} + +func table() *lipgloss_table.Table { + return lipgloss_table.New(). + Border(lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: " ", + Right: " ", + TopLeft: " ", + TopRight: " ", + BottomLeft: " ", + BottomRight: " ", + MiddleLeft: " ", + MiddleRight: " ", + Middle: " ", + MiddleTop: " ", + MiddleBottom: " ", + }). + BorderRow(true). + BorderColumn(false). + BorderTop(false). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))) } func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { @@ -184,7 +344,7 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { // Insert the orgs into the org pick list model envListItems := make([]list.Item, len(msg.environments)) for i, env := range msg.environments { - envListItems[i] = envListItem{environment: env} + envListItems[i] = envListItem{envSelection: e, environment: env} } if len(envListItems) > 0 { @@ -200,14 +360,18 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { e.envList, cmd = e.envList.Update(msg) return cmd - default: - // If the loading spinner is enabled, update it - if e.showLoadingSpinner { + case spinner.TickMsg: + if msg.ID == e.statusSpinner.ID() { + e.statusSpinner, cmd = e.statusSpinner.Update(msg) + return cmd + } + if msg.ID == e.loadingSpinner.ID() && e.showLoadingSpinner { e.loadingSpinner, cmd = e.loadingSpinner.Update(msg) return cmd } - return nil } + + return cmd } // FetchEnvs fetches the environments and updates the env pick list model. This function automatically @@ -222,43 +386,72 @@ func (e *EnvSelection) FetchEnvs(organizationID string) tea.Cmd { // Start the spinner e.showLoadingSpinner = true - spinnerCmd := e.loadingSpinner.Tick + loadingSpinnerCmd := e.loadingSpinner.Tick - return tea.Batch(fetchEnvsCmd, spinnerCmd) -} + // Start the env status spinner + statusSpinnerCmd := e.statusSpinner.Tick -type environment struct { - ID string - Name string - GPU string - CPU string - RAM string - Status string + return tea.Batch(fetchEnvsCmd, loadingSpinnerCmd, statusSpinnerCmd) } type fetchEnvsMsg struct { - environments []environment + environments []Environment err error } func cmdFetchEnvs(organizationID string) tea.Cmd { return func() tea.Msg { - // simulate loading - time.Sleep(time.Second * 2) - - return fetchEnvsMsg{environments: []environment{ - {ID: "1", Name: "Environment 1", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "2", Name: "Environment 2", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "3", Name: "Environment 3", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "4", Name: "Environment 4", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "5", Name: "Environment 5", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "6", Name: "Environment 6", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "7", Name: "Environment 7", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "8", Name: "Environment 8", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "9", Name: "Environment 9", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "10", Name: "Environment 10", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "11", Name: "Environment 11", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - {ID: "12", Name: "Environment 12", GPU: "NVIDIA A100", CPU: "Intel(R) Xeon(R) CPU @ 2.20GHz", RAM: "128GB", Status: "Running"}, - }, err: nil} + environments := fetchEnvs(organizationID) + + // Sort the environments by status + sort.Slice(environments, func(i, j int) bool { + return environments[i].Status < environments[j].Status + }) + + return fetchEnvsMsg{environments: environments, err: nil} + } +} + +func fetchEnvs(organizationID string) []Environment { + // simulate loading + time.Sleep(time.Second * 1) + + return []Environment{ + {ID: "1", Name: "my-cool-env", InstanceType: Crusoe_1x_a100_40gb, Status: EnvironmentStatusRunning, PortMappings: []PortMapping{{"22", "22"}, {"8080", "80"}}, Tunnels: []Tunnel{{"443", "https://foo.bar.com"}}}, + {ID: "2", Name: "testing-crusoe", InstanceType: Crusoe_2x_a100_40gb, Status: EnvironmentStatusRunning, PortMappings: []PortMapping{{"8080", "80"}, {"9000", "8080"}}}, + {ID: "3", Name: "building-lambda", InstanceType: Lambda_1x_a100_40gb, Status: EnvironmentStatusBuilding, PortMappings: []PortMapping{{"22", "22"}}}, + {ID: "4", Name: "test-error-lambda", InstanceType: Lambda_1x_a100_40gb, Status: EnvironmentStatusError, Containers: []Container{{Name: "jupyter", Image: "jupyter:latest", Status: ContainerStatusError}}}, + {ID: "5", Name: "test-crusoe-running", InstanceType: Crusoe_1x_a100_40gb, Status: EnvironmentStatusRunning}, + {ID: "6", Name: "test-lambda-running", InstanceType: Lambda_2x_a100_40gb, Status: EnvironmentStatusRunning}, + {ID: "7", Name: "test-crusoe-starting", InstanceType: Crusoe_1x_a100_40gb, Status: EnvironmentStatusStarting, Containers: []Container{{Name: "jupyter", Image: "jupyter:latest", Status: ContainerStatusBuilding}}}, + {ID: "8", Name: "my-awesome-gpu", InstanceType: Lambda_2x_a100_40gb, Status: EnvironmentStatusStarting}, + {ID: "9", Name: "my-awesome-gpu-2", InstanceType: Crusoe_1x_a100_40gb, Status: EnvironmentStatusStopped}, + {ID: "10", Name: "my-awesome-gpu-3", InstanceType: Lambda_1x_a100_40gb, Status: EnvironmentStatusStopped}, + {ID: "11", Name: "env-12", InstanceType: Crusoe_1x_a100_40gb, Status: EnvironmentStatusDeleting}, + {ID: "12", Name: "env-13", InstanceType: Lambda_1x_a100_40gb, Status: EnvironmentStatusDeleting}, } } + +var logoSmall = `β–žβ–˜β–—β–žβ–—β–—β–€▐β–€β–€β––β––β–—β–—β–˜β–€β–—β–€▝β–ž +β––β–—β–€β–€β–€β–€β–€β–žβ–€β–€β–€β–žβ–€β–€β––▝β––β–€▝β–— +β–˜β–˜β–—β–—β–€β–—β–€β–—β–€β––▐▐▐▐▐▐▐β–—β––β–ž +▝β––▝β––β–€β–žβ–€▐β–€β–—β–€β–€β–€β–˜β–—β–€β–€β–€β––β–€ +β––β–žβ–€β–€▝β–€β–žβ–žβ–€β–€▝β–€β–€β–žβ–€β–€β–€β–˜β–˜β–ž +▝β–˜β–€β–žβ––β–€β–€β–žβ–€β–€β–€β–€β–˜β–˜β–€▝β–€β–€▝▝` + +var logoLarge = `β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€` diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index 989470d5..ef16f3b9 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -9,8 +9,9 @@ import ( ) var ( - keywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Background(lipgloss.Color("235")) - helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + keywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Background(lipgloss.Color("235")) + helpStyleDark = lipgloss.NewStyle().Foreground(lipgloss.Color("239")) + helpStyleLight = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) titleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() @@ -103,16 +104,38 @@ func (m *MainModel) headerView() string { } func (m *MainModel) footerView() string { - help := helpStyle.Render("q/esc: exit β€’ o: select org") - return footerStyle.Width(m.envSelection.Width()).Render( - help, - ) + helpTextEntries := []string{} + if m.renderOrgPickList { + helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o/q/esc")+" "+helpStyleDark.Render("close window")) + } else if m.orgSelection.Selection() != nil { + helpTextEntries = append(helpTextEntries, helpStyleLight.Render("q/esc")+" "+helpStyleDark.Render("exit")) + helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o")+" "+helpStyleDark.Render("select org")) + helpTextEntries = append(helpTextEntries, helpStyleLight.Render("↑/k")+" "+helpStyleDark.Render("up")) + helpTextEntries = append(helpTextEntries, helpStyleLight.Render("↓/j")+" "+helpStyleDark.Render("down")) + } else { + helpTextEntries = append(helpTextEntries, helpStyleLight.Render("q/esc")+" "+helpStyleDark.Render("exit")) + helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o")+" "+helpStyleDark.Render("select org")) + } + + // Join the help text entries with a " β€’ " separator + helpText := strings.Join(helpTextEntries, helpStyleDark.Render(" β€’ ")) + + return footerStyle.Width(m.envSelection.Width()).Render(helpText) +} + +type initMsg struct{} + +func (m *MainModel) initCmd() tea.Cmd { + return func() tea.Msg { return initMsg{} } } func (m *MainModel) Init() tea.Cmd { m.orgSelection = NewOrgSelection() m.envSelection = NewEnvSelection() - return nil + + // TODO: if not default org is found (read from ~/.brev), submit the init command + cmd := m.initCmd() + return cmd } func (m *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -144,6 +167,13 @@ func (m *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update the model's viewport on window size change m.onWindowSizeChange(msg) return m, nil + + case initMsg: + + // If the program is being initialized, render the org pick list modal and fetch the orgs + m.renderOrgPickList = true + cmd := m.orgSelection.FetchOrgs() + return m, cmd } if m.renderOrgPickList { diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go index 50afa5b9..38ccd4e4 100644 --- a/pkg/tui/drew/model_org_selection.go +++ b/pkg/tui/drew/model_org_selection.go @@ -1,6 +1,7 @@ package drew import ( + "sort" "time" "github.com/charmbracelet/bubbles/list" @@ -196,22 +197,33 @@ type fetchOrgsMsg struct { func cmdFetchOrgs() tea.Cmd { return func() tea.Msg { - // simulate loading - time.Sleep(time.Second * 2) - - return fetchOrgsMsg{organizations: []organization{ - {ID: "1", Name: "Organization 1", Description: "First organization"}, - {ID: "2", Name: "Organization 2", Description: "Second organization"}, - {ID: "3", Name: "Organization 3", Description: "Third organization"}, - {ID: "4", Name: "Organization 4", Description: "Fourth organization"}, - {ID: "5", Name: "Organization 5", Description: "Fifth organization"}, - {ID: "6", Name: "Organization 6", Description: "Sixth organization"}, - {ID: "7", Name: "Organization 7", Description: "Seventh organization"}, - {ID: "8", Name: "Organization 8", Description: "Eighth organization"}, - {ID: "9", Name: "Organization 9", Description: "Ninth organization"}, - {ID: "10", Name: "Organization 10", Description: "Tenth organization"}, - {ID: "11", Name: "Organization 11", Description: "Eleventh organization"}, - {ID: "12", Name: "Organization 12", Description: "Twelfth organization"}, - }, err: nil} + organizations := fetchOrgs() + + // Sort the organizations by ID + sort.Slice(organizations, func(i, j int) bool { + return organizations[i].ID < organizations[j].ID + }) + + return fetchOrgsMsg{organizations: organizations, err: nil} + } +} + +func fetchOrgs() []organization { + // simulate loading + time.Sleep(time.Second * 1) + + return []organization{ + {ID: "1", Name: "Organization 1", Description: "First organization"}, + {ID: "2", Name: "Organization 2", Description: "Second organization"}, + {ID: "3", Name: "Organization 3", Description: "Third organization"}, + {ID: "4", Name: "Organization 4", Description: "Fourth organization"}, + {ID: "5", Name: "Organization 5", Description: "Fifth organization"}, + {ID: "6", Name: "Organization 6", Description: "Sixth organization"}, + {ID: "7", Name: "Organization 7", Description: "Seventh organization"}, + {ID: "8", Name: "Organization 8", Description: "Eighth organization"}, + {ID: "9", Name: "Organization 9", Description: "Ninth organization"}, + {ID: "10", Name: "Organization 10", Description: "Tenth organization"}, + {ID: "11", Name: "Organization 11", Description: "Eleventh organization"}, + {ID: "12", Name: "Organization 12", Description: "Twelfth organization"}, } } From 6fa407cf135671d995421f62499f32fac6e8839c Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sun, 11 May 2025 15:47:08 -0700 Subject: [PATCH 09/18] scrolling --- go.mod | 1 + go.sum | 2 + pkg/tui/drew/model_env_selection.go | 118 +++++++++++++++++++--------- pkg/tui/drew/model_main.go | 19 +++-- 4 files changed, 98 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index c0d74dea..1dc99b1f 100644 --- a/go.mod +++ b/go.mod @@ -121,6 +121,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zhengkyl/pearls v0.1.1 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.19.0 // indirect diff --git a/go.sum b/go.sum index 07f34bd4..a6040c90 100644 --- a/go.sum +++ b/go.sum @@ -521,6 +521,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zhengkyl/pearls v0.1.1 h1:eo4wNFK/ZhUfg9AgjRJ7aCqEdimDB26nJXWG8Y/OLuw= +github.com/zhengkyl/pearls v0.1.1/go.mod h1:ofzYZ2ahVGxLSldoRpgsetRURct7ZVQd+PyD61CoMSg= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go index e50eea32..98cee88f 100644 --- a/pkg/tui/drew/model_env_selection.go +++ b/pkg/tui/drew/model_env_selection.go @@ -6,8 +6,12 @@ import ( "strings" "time" + "github.com/zhengkyl/pearls/scrollbar" + + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" lipgloss "github.com/charmbracelet/lipgloss" lipgloss_table "github.com/charmbracelet/lipgloss/table" @@ -48,7 +52,7 @@ func NewEnvSelection() *EnvSelection { delegate.Styles.FilterMatch = lipgloss.NewStyle().Underline(true) - list := list.New([]list.Item{}, delegate, 40, 20) + list := list.New([]list.Item{}, delegate, 0, 0) list.SetShowStatusBar(false) list.SetShowTitle(false) list.SetStatusBarItemName("environment", "environments") @@ -57,6 +61,23 @@ func NewEnvSelection() *EnvSelection { list.DisableQuitKeybindings() envSelection.envList = list + envSelectedViewport := viewport.New(100, 50) + envSelectedViewport.KeyMap = viewport.KeyMap{ + Up: key.NewBinding( + key.WithKeys("ctrl+k"), + ), + Down: key.NewBinding( + key.WithKeys("ctrl+j"), + ), + HalfPageUp: key.NewBinding( + key.WithKeys("ctrl+u"), + ), + HalfPageDown: key.NewBinding( + key.WithKeys("ctrl+d"), + ), + } + envSelection.envSelectedViewport = envSelectedViewport + envStatusSpinner := spinner.New( spinner.WithSpinner(spinner.MiniDot), ) @@ -75,8 +96,8 @@ func NewEnvSelection() *EnvSelection { // charmbracelet/bubbles/list.Model, but rather a wrapper around it that adds some additional functionality // while allowing for simplified use of the wrapped list. type EnvSelection struct { - envList list.Model - envSelected *Environment + envList list.Model + envSelectedViewport viewport.Model // A spinner model to use when rendering containers or environments statusSpinner spinner.Model @@ -86,9 +107,17 @@ type EnvSelection struct { loadingSpinner spinner.Model } -// Selection returns the currently selected environment. -func (e *EnvSelection) Selection() *Environment { - return e.envSelected +func (e *EnvSelection) HelpTextEntries() [][]string { + return [][]string{ + {"q/esc", "exit"}, + {"o", "select org"}, + {"↑/k", "up"}, + {"↓/j", "down"}, + {"ctrl+k", "details up"}, + {"ctrl+j", "details down"}, + {"ctrl+u", "details page up"}, + {"ctrl+d", "details page down"}, + } } // Width returns the width of the organization pick list. @@ -98,6 +127,7 @@ func (e *EnvSelection) Width() int { func (e *EnvSelection) SetWidth(width int) { e.envList.SetWidth(width) + e.envSelectedViewport.Width = width } // Height returns the height of the organization pick list. @@ -106,7 +136,8 @@ func (e *EnvSelection) Height() int { } func (e *EnvSelection) SetHeight(height int) { - e.envList.SetHeight(height) + e.envList.SetHeight(height + 4) + e.envSelectedViewport.Height = height } type envListItem struct { @@ -171,19 +202,42 @@ func (e *EnvSelection) View() string { selected = nil } } + + // The list view should represent 40% of the total width. envListViewWidth := int(float64(e.envList.Width()) * 0.4) - envDetailsViewWidth := int(float64(e.envList.Width()) * 0.59) + // The details view should represent 60% (59% because of rounding) of the total width. + // Why the "-4"? The scrollbar has limited capabilities for width rendering, so we + // save 4 columns for it... hacky! + envDetailsViewWidth := int(float64(e.envList.Width()-4) * 0.59) + + // Fill the details view with the selected environment details + e.envSelectedViewport.SetContent(e.renderEnvDetails(selected, envDetailsViewWidth)) + + // Render the list view envListView := lipgloss.NewStyle(). Width(envListViewWidth). Render(e.envList.View()) + // Render the details view envDetailsView := lipgloss.NewStyle(). Width(envDetailsViewWidth). Border(lipgloss.RoundedBorder()). - Render(e.renderEnvDetails(selected, envDetailsViewWidth)) + Render(e.envSelectedViewport.View()) + + // Render the scrollbar + scrollbar := scrollbar.New() + scrollbar.Height = e.envSelectedViewport.Height + 2 // +2 because the scrollbar is dumb and wants to preserve 2 rows for itself. Another hack + if e.envSelectedViewport.AtTop() && e.envSelectedViewport.AtBottom() { + scrollbar.NumPos = 0 + scrollbar.Pos = 0 + } else { + scrollbar.NumPos = 30 + scrollbar.Pos = int(e.envSelectedViewport.ScrollPercent() * 30) + } - return lipgloss.JoinHorizontal(lipgloss.Top, envListView, envDetailsView) + // Join the list view, details view, and scrollbar horizontally + return lipgloss.JoinHorizontal(lipgloss.Top, envListView, lipgloss.JoinHorizontal(lipgloss.Right, envDetailsView, scrollbar.View())) } func (e *EnvSelection) renderEnvDetails(environment *Environment, width int) string { @@ -277,6 +331,7 @@ func (e *EnvSelection) renderEnvDetails(environment *Environment, width int) str // Finalize the table and convert to a string tunnelsTable = "\n\n\n" + table.Rows(rows...).Render() } + return fmt.Sprintf("%s\n\n\n%s%s%s%s", basicInfoTable, instanceConfigurationTable, portsTable, tunnelsTable, containersTable) } @@ -303,29 +358,6 @@ func dataTable() *lipgloss_table.Table { BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))) } -func table() *lipgloss_table.Table { - return lipgloss_table.New(). - Border(lipgloss.Border{ - Top: "─", - Bottom: "─", - Left: " ", - Right: " ", - TopLeft: " ", - TopRight: " ", - BottomLeft: " ", - BottomRight: " ", - MiddleLeft: " ", - MiddleRight: " ", - Middle: " ", - MiddleTop: " ", - MiddleBottom: " ", - }). - BorderRow(true). - BorderColumn(false). - BorderTop(false). - BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))) -} - func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { var cmd tea.Cmd @@ -356,9 +388,25 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { return nil case tea.KeyMsg: - // Pass the key event to the env pick list model + // We need to know if the user has changed the selection in the env list. If they have, we then need + // to scroll to the top of the viewport, otherwise the viewport will remember the previous scroll position. + previousSelection := e.envList.SelectedItem().(envListItem).environment.ID + + // Pass the key event to the env pick list model to allow for environment selection + var keyCmds []tea.Cmd e.envList, cmd = e.envList.Update(msg) - return cmd + keyCmds = append(keyCmds, cmd) + + // If the selection has changed, scroll to the top of the viewport + if previousSelection != e.envList.SelectedItem().(envListItem).environment.ID { + e.envSelectedViewport.SetYOffset(0) + } + + // Pass the key event to the env selection viewport to allow for viewport navigation + e.envSelectedViewport, cmd = e.envSelectedViewport.Update(msg) + keyCmds = append(keyCmds, cmd) + + return tea.Batch(keyCmds...) case spinner.TickMsg: if msg.ID == e.statusSpinner.ID() { diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index ef16f3b9..3fab8053 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -108,10 +108,13 @@ func (m *MainModel) footerView() string { if m.renderOrgPickList { helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o/q/esc")+" "+helpStyleDark.Render("close window")) } else if m.orgSelection.Selection() != nil { - helpTextEntries = append(helpTextEntries, helpStyleLight.Render("q/esc")+" "+helpStyleDark.Render("exit")) - helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o")+" "+helpStyleDark.Render("select org")) - helpTextEntries = append(helpTextEntries, helpStyleLight.Render("↑/k")+" "+helpStyleDark.Render("up")) - helpTextEntries = append(helpTextEntries, helpStyleLight.Render("↓/j")+" "+helpStyleDark.Render("down")) + for _, entry := range m.envSelection.HelpTextEntries() { + helpTextEntries = append(helpTextEntries, helpStyleLight.Render(entry[0])+" "+helpStyleDark.Render(entry[1])) + } + // helpTextEntries = append(helpTextEntries, helpStyleLight.Render("q/esc")+" "+helpStyleDark.Render("exit")) + // helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o")+" "+helpStyleDark.Render("select org")) + // helpTextEntries = append(helpTextEntries, helpStyleLight.Render("↑/k")+" "+helpStyleDark.Render("up")) + // helpTextEntries = append(helpTextEntries, helpStyleLight.Render("↓/j")+" "+helpStyleDark.Render("down")) } else { helpTextEntries = append(helpTextEntries, helpStyleLight.Render("q/esc")+" "+helpStyleDark.Render("exit")) helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o")+" "+helpStyleDark.Render("select org")) @@ -133,7 +136,7 @@ func (m *MainModel) Init() tea.Cmd { m.orgSelection = NewOrgSelection() m.envSelection = NewEnvSelection() - // TODO: if not default org is found (read from ~/.brev), submit the init command + // TODO: if not default org is found (read from ~/.brev), submit the init commandΓΈ cmd := m.initCmd() return cmd } @@ -223,11 +226,13 @@ func (m *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *MainModel) onWindowSizeChange(msg tea.WindowSizeMsg) { headerHeight := lipgloss.Height(m.headerView()) footerHeight := lipgloss.Height(m.footerView()) - contentHeight := msg.Height - headerHeight - footerHeight + contentHeight := msg.Height - headerHeight - footerHeight - 4 // 4 is the padding between the header and footer m.envSelection.SetWidth(msg.Width) m.envSelection.SetHeight(contentHeight) + // Clamp the width and height to 30 to shrink its dimensions. Without this, the org selection model defaults to a very wide area, + // then is shrunk to fit the content, which is a jarring experience. m.orgSelection.SetWidth(min(msg.Width, 30)) - m.orgSelection.SetHeight(min(contentHeight-4, 30)) // keep a small amount of padding for the height + m.orgSelection.SetHeight(min(contentHeight, 30)) } From 0692798d6e1151a9c547612d7e47408dba124517 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sun, 11 May 2025 15:47:28 -0700 Subject: [PATCH 10/18] cleanup --- pkg/tui/drew/data_cloud.go | 17 ++++++ pkg/tui/drew/data_container.go | 70 ++++++++++++++++++++++++ pkg/tui/drew/data_environment.go | 85 ++++++++++++++++++++++++++++++ pkg/tui/drew/data_instance_type.go | 85 ++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 pkg/tui/drew/data_cloud.go create mode 100644 pkg/tui/drew/data_container.go create mode 100644 pkg/tui/drew/data_environment.go create mode 100644 pkg/tui/drew/data_instance_type.go diff --git a/pkg/tui/drew/data_cloud.go b/pkg/tui/drew/data_cloud.go new file mode 100644 index 00000000..1965436a --- /dev/null +++ b/pkg/tui/drew/data_cloud.go @@ -0,0 +1,17 @@ +package drew + +type Cloud int + +const ( + CloudCrusoe Cloud = iota + CloudLambda +) + +var cloudName = map[Cloud]string{ + CloudCrusoe: "Crusoe", + CloudLambda: "Lambda", +} + +func (c Cloud) Name() string { + return cloudName[c] +} diff --git a/pkg/tui/drew/data_container.go b/pkg/tui/drew/data_container.go new file mode 100644 index 00000000..f3a74791 --- /dev/null +++ b/pkg/tui/drew/data_container.go @@ -0,0 +1,70 @@ +package drew + +import ( + "github.com/charmbracelet/bubbles/spinner" + lipgloss "github.com/charmbracelet/lipgloss" +) + +type Container struct { + Name string + Image string + Status ContainerStatus +} + +type ContainerStatus int + +const ( + ContainerStatusRunning ContainerStatus = iota + ContainerStatusError + ContainerStatusBuilding + ContainerStatusStarting + ContainerStatusStopping + ContainerStatusStopped + ContainerStatusDeleting +) + +var containerStatuses = map[ContainerStatus]string{ + ContainerStatusRunning: "Running", + ContainerStatusError: "Error", + ContainerStatusBuilding: "Building", + ContainerStatusStarting: "Starting", + ContainerStatusStopping: "Stopping", + ContainerStatusStopped: "Stopped", + ContainerStatusDeleting: "Deleting", +} + +func (s ContainerStatus) Name() string { + return containerStatuses[s] +} + +func (s ContainerStatus) IsTemporary() bool { + return s == ContainerStatusBuilding || s == ContainerStatusStarting || s == ContainerStatusStopping || s == ContainerStatusDeleting +} + +func (s ContainerStatus) StatusView(spinner spinner.Model) string { + var styledName string + + switch s { + case ContainerStatusRunning: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("118")).Render(s.Name()) + case ContainerStatusError: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(s.Name()) + case ContainerStatusBuilding: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Render(s.Name()) + case ContainerStatusStarting: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(s.Name()) + case ContainerStatusStopping: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render(s.Name()) + case ContainerStatusStopped: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Render(s.Name()) + case ContainerStatusDeleting: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render(s.Name()) + default: + styledName = s.Name() + } + + if s.IsTemporary() { + return styledName + " " + spinner.View() + } + return styledName +} diff --git a/pkg/tui/drew/data_environment.go b/pkg/tui/drew/data_environment.go new file mode 100644 index 00000000..bbf3bf33 --- /dev/null +++ b/pkg/tui/drew/data_environment.go @@ -0,0 +1,85 @@ +package drew + +import ( + "github.com/charmbracelet/bubbles/spinner" + lipgloss "github.com/charmbracelet/lipgloss" +) + +type Environment struct { + ID string + Name string + InstanceType InstanceType + Storage string + Status EnvironmentStatus + Containers []Container + PortMappings []PortMapping + Tunnels []Tunnel +} + +type EnvironmentStatus int + +const ( + EnvironmentStatusRunning EnvironmentStatus = iota + EnvironmentStatusError + EnvironmentStatusBuilding + EnvironmentStatusStarting + EnvironmentStatusStopping + EnvironmentStatusStopped + EnvironmentStatusDeleting +) + +var environmentStatusName = map[EnvironmentStatus]string{ + EnvironmentStatusRunning: "Running", + EnvironmentStatusError: "Error", + EnvironmentStatusBuilding: "Building", + EnvironmentStatusStarting: "Starting", + EnvironmentStatusStopping: "Stopping", + EnvironmentStatusStopped: "Stopped", + EnvironmentStatusDeleting: "Deleting", +} + +func (e EnvironmentStatus) Name() string { + return environmentStatusName[e] +} + +func (e EnvironmentStatus) IsTemporary() bool { + return e == EnvironmentStatusBuilding || e == EnvironmentStatusStarting || e == EnvironmentStatusStopping || e == EnvironmentStatusDeleting +} + +func (e EnvironmentStatus) StatusView(spinner spinner.Model) string { + var styledName string + + switch e { + case EnvironmentStatusRunning: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("118")).Render(e.Name()) + case EnvironmentStatusError: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(e.Name()) + case EnvironmentStatusBuilding: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Render(e.Name()) + case EnvironmentStatusStarting: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(e.Name()) + case EnvironmentStatusStopping: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render(e.Name()) + case EnvironmentStatusStopped: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Render(e.Name()) + case EnvironmentStatusDeleting: + styledName = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render(e.Name()) + default: + styledName = e.Name() + } + + if e.IsTemporary() { + return styledName + " " + spinner.View() + } + return styledName +} + +type PortMapping struct { + HostPort string + PublicPort string +} + +type Tunnel struct { + HostPort string + PublicURL string +} diff --git a/pkg/tui/drew/data_instance_type.go b/pkg/tui/drew/data_instance_type.go new file mode 100644 index 00000000..573ed235 --- /dev/null +++ b/pkg/tui/drew/data_instance_type.go @@ -0,0 +1,85 @@ +package drew + +type InstanceType struct { + Cloud Cloud + GPUModel string + GPUCount int + VRAM string + CPUModel string + CPUCount int + Memory string + Storage string +} + +var ( + Crusoe_1x_a100_40gb InstanceType = InstanceType{ + Cloud: CloudCrusoe, + GPUModel: "NVIDIA A100", + GPUCount: 1, + VRAM: "40GB", + CPUModel: "Intel Xeon (Ice Lake)", + CPUCount: 12, + Memory: "120GB", + Storage: "1x960GB", + } + Crusoe_2x_a100_40gb InstanceType = InstanceType{ + Cloud: CloudCrusoe, + GPUModel: "NVIDIA A100", + GPUCount: 2, + VRAM: "40GB", + CPUModel: "Intel Xeon (Ice Lake)", + CPUCount: 24, + Memory: "240GB", + Storage: "2x960GB", + } + Crusoe_4x_a100_40gb InstanceType = InstanceType{ + Cloud: CloudCrusoe, + GPUModel: "NVIDIA A100", + GPUCount: 4, + VRAM: "40GB", + CPUModel: "Intel Xeon (Ice Lake)", + CPUCount: 48, + Memory: "480GB", + Storage: "4x960GB", + } + Crusoe_8x_a100_40gb InstanceType = InstanceType{ + GPUModel: "NVIDIA A100", + GPUCount: 8, + VRAM: "40GB", + CPUModel: "Intel Xeon (Ice Lake)", + CPUCount: 96, + Memory: "960GB", + Storage: "8x960GB", + } + + Lambda_1x_a100_40gb InstanceType = InstanceType{ + Cloud: CloudLambda, + GPUModel: "NVIDIA A100", + GPUCount: 1, + VRAM: "40GB", + CPUModel: "Intel Xeon (Sapphire Rapids)", + CPUCount: 30, + Memory: "225GiB", + Storage: "512GiB", + } + Lambda_2x_a100_40gb InstanceType = InstanceType{ + Cloud: CloudLambda, + GPUModel: "NVIDIA A100", + GPUCount: 2, + VRAM: "40GB", + CPUModel: "Intel Xeon (Sapphire Rapids)", + CPUCount: 60, + Memory: "450GiB", + Storage: "1TiB", + } + Lambda_4x_a100_40gb InstanceType = InstanceType{ + Cloud: CloudLambda, + GPUModel: "NVIDIA A100", + GPUCount: 4, + VRAM: "40GB", + CPUModel: "Intel Xeon (Sapphire Rapids)", + CPUCount: 120, + Memory: "900GiB", + Storage: "1TiB", + } +) From f32e7ff60876ab2329e6d57fb46746849e992ed0 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sun, 11 May 2025 15:49:34 -0700 Subject: [PATCH 11/18] more cleanup --- pkg/tui/drew/model_main.go | 8 +++----- pkg/tui/drew/model_org_selection.go | 6 ++++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index 3fab8053..8a77efa9 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -106,15 +106,13 @@ func (m *MainModel) headerView() string { func (m *MainModel) footerView() string { helpTextEntries := []string{} if m.renderOrgPickList { - helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o/q/esc")+" "+helpStyleDark.Render("close window")) + for _, entry := range m.orgSelection.HelpTextEntries() { + helpTextEntries = append(helpTextEntries, helpStyleLight.Render(entry[0])+" "+helpStyleDark.Render(entry[1])) + } } else if m.orgSelection.Selection() != nil { for _, entry := range m.envSelection.HelpTextEntries() { helpTextEntries = append(helpTextEntries, helpStyleLight.Render(entry[0])+" "+helpStyleDark.Render(entry[1])) } - // helpTextEntries = append(helpTextEntries, helpStyleLight.Render("q/esc")+" "+helpStyleDark.Render("exit")) - // helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o")+" "+helpStyleDark.Render("select org")) - // helpTextEntries = append(helpTextEntries, helpStyleLight.Render("↑/k")+" "+helpStyleDark.Render("up")) - // helpTextEntries = append(helpTextEntries, helpStyleLight.Render("↓/j")+" "+helpStyleDark.Render("down")) } else { helpTextEntries = append(helpTextEntries, helpStyleLight.Render("q/esc")+" "+helpStyleDark.Render("exit")) helpTextEntries = append(helpTextEntries, helpStyleLight.Render("o")+" "+helpStyleDark.Render("select org")) diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go index 38ccd4e4..284c4ae7 100644 --- a/pkg/tui/drew/model_org_selection.go +++ b/pkg/tui/drew/model_org_selection.go @@ -92,6 +92,12 @@ func (o *OrgSelection) Height() int { return o.orgPickListModel.Height() } +func (e *OrgSelection) HelpTextEntries() [][]string { + return [][]string{ + {"o/q/esc", "close window"}, + } +} + type orgListItem struct { Organization organization } From f900be2909ba13d7c4c8cf6fc2eae8e285e6b096 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sun, 11 May 2025 19:23:11 -0700 Subject: [PATCH 12/18] overlay poc --- go.mod | 5 +- go.sum | 2 + pkg/tui/drew/model_env_selection.go | 142 ++++++++++++++++++++-------- pkg/tui/drew/model_main.go | 3 - 4 files changed, 110 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index 1dc99b1f..332a5806 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/brevdev/brev-cli -go 1.22.6 +go 1.23.4 + +toolchain go1.24.2 require ( github.com/alessio/shellescape v1.4.1 @@ -108,6 +110,7 @@ require ( github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/rmhubbert/bubbletea-overlay v0.3.2 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.0 // indirect diff --git a/go.sum b/go.sum index a6040c90..2463405e 100644 --- a/go.sum +++ b/go.sum @@ -433,6 +433,8 @@ github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rmhubbert/bubbletea-overlay v0.3.2 h1:IvlwNFwcgx4gWQ1P8mXXZxFTzxbw1t6gAm/qvidCw7I= +github.com/rmhubbert/bubbletea-overlay v0.3.2/go.mod h1:eGY/M6yyUP6IRildHOhDMHBscFm816Im2oSB1nLZMoo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go index 98cee88f..1a05e55c 100644 --- a/pkg/tui/drew/model_env_selection.go +++ b/pkg/tui/drew/model_env_selection.go @@ -6,6 +6,7 @@ import ( "strings" "time" + overlay "github.com/rmhubbert/bubbletea-overlay" "github.com/zhengkyl/pearls/scrollbar" "github.com/charmbracelet/bubbles/key" @@ -89,22 +90,46 @@ func NewEnvSelection() *EnvSelection { ) envSelection.loadingSpinner = envSpinner + envDetailsModal := overlay.New( + nil, + nil, + overlay.Center, + overlay.Center, + 0, + 0, + ) + envSelection.modal = envDetailsModal + return envSelection } +type envSelectionState int + +const ( + envLoadingState envSelectionState = iota + envListState + envModalState +) + // EnvSelection is a model that represents the environment pick list. Note that this is not a complete // charmbracelet/bubbles/list.Model, but rather a wrapper around it that adds some additional functionality // while allowing for simplified use of the wrapped list. type EnvSelection struct { + // The primary "list" envList list.Model envSelectedViewport viewport.Model // A spinner model to use when rendering containers or environments statusSpinner spinner.Model + // An overlay modal to display the environment details + modal *overlay.Model + // A spinner model to use when fetching environments - showLoadingSpinner bool - loadingSpinner spinner.Model + loadingSpinner spinner.Model + + // The current state of the environment selection model + state envSelectionState } func (e *EnvSelection) HelpTextEntries() [][]string { @@ -178,20 +203,47 @@ func envSelectionErrorCmd(err error) tea.Cmd { } func (e *EnvSelection) View() string { - if e.showLoadingSpinner { - spinner := fmt.Sprintf("Loading environments %s", e.loadingSpinner.View()) - - // Create a vertically centered spinner box with full height - loadingBox := lipgloss.NewStyle(). - Height(e.envList.Height()). // Match the table height - Width(e.envList.Width()). // Match the table width - Align(lipgloss.Center). - AlignVertical(lipgloss.Center). - Render(spinner) - - return loadingBox + if e.state == envLoadingState { + return e.loadingView() + } else if e.state == envModalState { + return e.modalView() + } else { + return e.listView() } +} + +func (e *EnvSelection) loadingView() string { + spinner := fmt.Sprintf("Loading environments %s", e.loadingSpinner.View()) + + // Create a vertically centered spinner box with full height + loadingBox := lipgloss.NewStyle(). + Height(e.envList.Height()). // Match the table height + Width(e.envList.Width()). // Match the table width + Align(lipgloss.Center). + AlignVertical(lipgloss.Center). + Render(spinner) + + return loadingBox +} + +func (e *EnvSelection) modalView() string { + e.modal.Background = StringModel{content: e.listView()} + foreStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder(), true). + BorderForeground(lipgloss.Color("6")). + Padding(0, 1) + + boldStyle := lipgloss.NewStyle().Bold(true) + title := boldStyle.Render("Bubble Tea Overlay") + content := "Hello! I'm in a modal window.\n\nPress to close the window." + layout := lipgloss.JoinVertical(lipgloss.Left, title, content) + + e.modal.Foreground = StringModel{content: foreStyle.Render(layout)} + return e.modal.View() +} + +func (e *EnvSelection) listView() string { var selected *Environment if e.envList.SelectedItem() == nil { selected = nil @@ -206,7 +258,7 @@ func (e *EnvSelection) View() string { // The list view should represent 40% of the total width. envListViewWidth := int(float64(e.envList.Width()) * 0.4) - // The details view should represent 60% (59% because of rounding) of the total width. + // The details view should represent 60% (59% because of rounding) of the total width. // Why the "-4"? The scrollbar has limited capabilities for width rendering, so we // save 4 columns for it... hacky! envDetailsViewWidth := int(float64(e.envList.Width()-4) * 0.59) @@ -366,8 +418,8 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { case fetchEnvsMsg: // The orgs have been fetched, so we need to update the org pick list model - // Disable the loading spinner - e.showLoadingSpinner = false + // Display the main list state + e.state = envListState if msg.err != nil { return envSelectionErrorCmd(msg.err) @@ -388,37 +440,51 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { return nil case tea.KeyMsg: - // We need to know if the user has changed the selection in the env list. If they have, we then need - // to scroll to the top of the viewport, otherwise the viewport will remember the previous scroll position. - previousSelection := e.envList.SelectedItem().(envListItem).environment.ID - - // Pass the key event to the env pick list model to allow for environment selection - var keyCmds []tea.Cmd - e.envList, cmd = e.envList.Update(msg) - keyCmds = append(keyCmds, cmd) - - // If the selection has changed, scroll to the top of the viewport - if previousSelection != e.envList.SelectedItem().(envListItem).environment.ID { - e.envSelectedViewport.SetYOffset(0) + switch msg.String() { + case " ": + if e.state == envListState { + e.state = envModalState + } else if e.state == envModalState { + e.state = envListState + } + return nil } - - // Pass the key event to the env selection viewport to allow for viewport navigation - e.envSelectedViewport, cmd = e.envSelectedViewport.Update(msg) - keyCmds = append(keyCmds, cmd) - - return tea.Batch(keyCmds...) - case spinner.TickMsg: if msg.ID == e.statusSpinner.ID() { e.statusSpinner, cmd = e.statusSpinner.Update(msg) return cmd } - if msg.ID == e.loadingSpinner.ID() && e.showLoadingSpinner { + if msg.ID == e.loadingSpinner.ID() && e.state == envLoadingState { e.loadingSpinner, cmd = e.loadingSpinner.Update(msg) return cmd } } + if e.state == envModalState { + e.modal.Foreground, cmd = e.modal.Foreground.Update(msg) + return cmd + } + + // We need to know if the user has changed the selection in the env list. If they have, we then need + // to scroll to the top of the viewport, otherwise the viewport will remember the previous scroll position. + previousSelection := e.envList.SelectedItem().(envListItem).environment.ID + + // Pass the key event to the env pick list model to allow for environment selection + var keyCmds []tea.Cmd + e.envList, cmd = e.envList.Update(msg) + keyCmds = append(keyCmds, cmd) + + // If the selection has changed, scroll to the top of the viewport + if previousSelection != e.envList.SelectedItem().(envListItem).environment.ID { + e.envSelectedViewport.SetYOffset(0) + } + + // Pass the key event to the env selection viewport to allow for viewport navigation + e.envSelectedViewport, cmd = e.envSelectedViewport.Update(msg) + keyCmds = append(keyCmds, cmd) + + return tea.Batch(keyCmds...) + return cmd } @@ -433,7 +499,7 @@ func (e *EnvSelection) FetchEnvs(organizationID string) tea.Cmd { fetchEnvsCmd := cmdFetchEnvs(organizationID) // Start the spinner - e.showLoadingSpinner = true + e.state = envLoadingState loadingSpinnerCmd := e.loadingSpinner.Tick // Start the env status spinner diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index 8a77efa9..20636888 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -42,9 +42,6 @@ type MainModel struct { quitting bool suspending bool - // General viewport - // viewport viewport.Model - // Org list Modal renderOrgPickList bool orgSelection *OrgSelection From 3ca81390d61189a27be4eaf5faa2fbccab6ebb5a Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 12 May 2025 11:03:52 -0700 Subject: [PATCH 13/18] action modal --- pkg/tui/drew/model_env_modal.go | 129 ++++++++++++++++++++++++++++ pkg/tui/drew/model_env_selection.go | 73 +++++++++------- pkg/tui/drew/model_org_selection.go | 8 +- 3 files changed, 175 insertions(+), 35 deletions(-) create mode 100644 pkg/tui/drew/model_env_modal.go diff --git a/pkg/tui/drew/model_env_modal.go b/pkg/tui/drew/model_env_modal.go new file mode 100644 index 00000000..3363902e --- /dev/null +++ b/pkg/tui/drew/model_env_modal.go @@ -0,0 +1,129 @@ +package drew + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + lipgloss "github.com/charmbracelet/lipgloss" +) + +type EnvModal struct { + environment *Environment + commands list.Model +} + +func NewEnvModal() *EnvModal { + commands := list.New(nil, list.NewDefaultDelegate(), 0, 0) + commands.SetFilteringEnabled(false) + commands.SetShowHelp(false) + commands.SetShowStatusBar(false) + commands.SetShowPagination(false) + commands.SetShowTitle(false) + + return &EnvModal{ + commands: commands, + } +} + +func (m EnvModal) Init() tea.Cmd { + return nil +} + +func (m EnvModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + if msg, ok := msg.(tea.KeyMsg); ok { + if msg.String() == "enter" { + return m, cmdEnvCommand(m.commands.SelectedItem().(commandItem).title) + } + } + + m.commands, cmd = m.commands.Update(msg) + return &m, cmd +} + +func (m EnvModal) View() string { + foreStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder(), true). + BorderForeground(lipgloss.Color("6")). + Padding(0, 1) + + boldStyle := lipgloss.NewStyle().Bold(true) + + title := boldStyle.Render(m.environment.Name) + directive := "Select an action to perform on the environment" + + header := fmt.Sprintf("%s\n%s\n\n", title, directive) + + content := lipgloss.JoinVertical(lipgloss.Left, header, m.commands.View()) + + return foreStyle.Render(content) +} + +func (m *EnvModal) SetEnvironment(environment *Environment) { + items := []list.Item{} + if environment.Status == EnvironmentStatusRunning { + items = append(items, commandItem{title: "Stop"}) + } + if environment.Status == EnvironmentStatusStopped { + items = append(items, commandItem{title: "Start"}) + } + items = append(items, commandItem{title: "Terminate"}) + + m.commands.SetItems(items) + m.environment = environment +} + +func (m *EnvModal) SetWidth(width int) { + m.commands.SetWidth(min(width, 30)) +} + +func (m *EnvModal) SetHeight(height int) { + m.commands.SetHeight(min(height, 10)) +} + +type envCommandMsg struct { + command string + err error +} + +func cmdEnvCommand(command string) tea.Cmd { + return func() tea.Msg { + return envCommandMsg{command: command, err: nil} + } +} + +type commandItem struct { + title string +} + +func (i commandItem) Title() string { return i.title } +func (i commandItem) Description() string { return "" } +func (i commandItem) FilterValue() string { return i.title } + +type PassthroughModel struct { + content string +} + +func NewPassthroughModel() *PassthroughModel { + return &PassthroughModel{ + content: "", + } +} + +func (m *PassthroughModel) SetContent(content string) { + m.content = content +} + +func (m PassthroughModel) Init() tea.Cmd { + return nil +} + +func (m PassthroughModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return &m, nil +} + +func (m PassthroughModel) View() string { + return m.content +} diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go index 1a05e55c..22abe8dc 100644 --- a/pkg/tui/drew/model_env_selection.go +++ b/pkg/tui/drew/model_env_selection.go @@ -91,8 +91,8 @@ func NewEnvSelection() *EnvSelection { envSelection.loadingSpinner = envSpinner envDetailsModal := overlay.New( - nil, - nil, + NewEnvModal(), + NewPassthroughModel(), overlay.Center, overlay.Center, 0, @@ -115,7 +115,7 @@ const ( // charmbracelet/bubbles/list.Model, but rather a wrapper around it that adds some additional functionality // while allowing for simplified use of the wrapped list. type EnvSelection struct { - // The primary "list" + // The primary view envList list.Model envSelectedViewport viewport.Model @@ -153,6 +153,8 @@ func (e *EnvSelection) Width() int { func (e *EnvSelection) SetWidth(width int) { e.envList.SetWidth(width) e.envSelectedViewport.Width = width + + e.modal.Foreground.(*EnvModal).SetWidth(width) } // Height returns the height of the organization pick list. @@ -163,6 +165,8 @@ func (e *EnvSelection) Height() int { func (e *EnvSelection) SetHeight(height int) { e.envList.SetHeight(height + 4) e.envSelectedViewport.Height = height + + e.modal.Foreground.(*EnvModal).SetHeight(height) } type envListItem struct { @@ -227,33 +231,11 @@ func (e *EnvSelection) loadingView() string { } func (e *EnvSelection) modalView() string { - e.modal.Background = StringModel{content: e.listView()} - - foreStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder(), true). - BorderForeground(lipgloss.Color("6")). - Padding(0, 1) - - boldStyle := lipgloss.NewStyle().Bold(true) - title := boldStyle.Render("Bubble Tea Overlay") - content := "Hello! I'm in a modal window.\n\nPress to close the window." - layout := lipgloss.JoinVertical(lipgloss.Left, title, content) - - e.modal.Foreground = StringModel{content: foreStyle.Render(layout)} return e.modal.View() } func (e *EnvSelection) listView() string { - var selected *Environment - if e.envList.SelectedItem() == nil { - selected = nil - } else { - if selectedItem, ok := e.envList.SelectedItem().(envListItem); ok { - selected = &selectedItem.environment - } else { - selected = nil - } - } + environment := e.getSelectedEnvironment() // The list view should represent 40% of the total width. envListViewWidth := int(float64(e.envList.Width()) * 0.4) @@ -264,7 +246,7 @@ func (e *EnvSelection) listView() string { envDetailsViewWidth := int(float64(e.envList.Width()-4) * 0.59) // Fill the details view with the selected environment details - e.envSelectedViewport.SetContent(e.renderEnvDetails(selected, envDetailsViewWidth)) + e.envSelectedViewport.SetContent(e.renderEnvDetails(environment, envDetailsViewWidth)) // Render the list view envListView := lipgloss.NewStyle(). @@ -292,6 +274,20 @@ func (e *EnvSelection) listView() string { return lipgloss.JoinHorizontal(lipgloss.Top, envListView, lipgloss.JoinHorizontal(lipgloss.Right, envDetailsView, scrollbar.View())) } +func (e *EnvSelection) getSelectedEnvironment() *Environment { + var selected *Environment + if e.envList.SelectedItem() == nil { + selected = nil + } else { + if selectedItem, ok := e.envList.SelectedItem().(envListItem); ok { + selected = &selectedItem.environment + } else { + selected = nil + } + } + return selected +} + func (e *EnvSelection) renderEnvDetails(environment *Environment, width int) string { if environment == nil { return "" @@ -420,7 +416,7 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { // Display the main list state e.state = envListState - + if msg.err != nil { return envSelectionErrorCmd(msg.err) } @@ -439,10 +435,24 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { return nil + case envCommandMsg: + // The environment command has been completed. Exit the modal. + if msg.err != nil { + return envSelectionErrorCmd(msg.err) + } + + e.state = envListState + return nil + case tea.KeyMsg: switch msg.String() { case " ": if e.state == envListState { + // TODO: this actually shouldn't be the way the content is set. Intead of passing a string (which cannot be updated / can only be replaced) + // we need to pass in a Model that references all of the internal models (like spinners, lists, etc.). This way we can have Update() calls + // update the state of various objects, and have a later View() call on the modal that references the updated state. + e.modal.Background.(*PassthroughModel).SetContent(e.listView()) + e.modal.Foreground.(*EnvModal).SetEnvironment(e.getSelectedEnvironment()) e.state = envModalState } else if e.state == envModalState { e.state = envListState @@ -461,7 +471,10 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { } if e.state == envModalState { - e.modal.Foreground, cmd = e.modal.Foreground.Update(msg) + foreground, cmd := e.modal.Foreground.Update(msg) + if envModal, ok := foreground.(*EnvModal); ok { + e.modal.Foreground = envModal + } return cmd } @@ -484,8 +497,6 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { keyCmds = append(keyCmds, cmd) return tea.Batch(keyCmds...) - - return cmd } // FetchEnvs fetches the environments and updates the env pick list model. This function automatically diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go index 284c4ae7..cc2c1bda 100644 --- a/pkg/tui/drew/model_org_selection.go +++ b/pkg/tui/drew/model_org_selection.go @@ -219,10 +219,10 @@ func fetchOrgs() []organization { time.Sleep(time.Second * 1) return []organization{ - {ID: "1", Name: "Organization 1", Description: "First organization"}, - {ID: "2", Name: "Organization 2", Description: "Second organization"}, - {ID: "3", Name: "Organization 3", Description: "Third organization"}, - {ID: "4", Name: "Organization 4", Description: "Fourth organization"}, + {ID: "1", Name: "org 1", Description: "First organization"}, + {ID: "2", Name: "brev-internal", Description: "Second organization"}, + {ID: "3", Name: "my-cool-org", Description: "Third organization"}, + {ID: "4", Name: "brev-load-testing", Description: "Fourth organization"}, {ID: "5", Name: "Organization 5", Description: "Fifth organization"}, {ID: "6", Name: "Organization 6", Description: "Sixth organization"}, {ID: "7", Name: "Organization 7", Description: "Seventh organization"}, From 508e33d01e222ad468768d4f96b341065498d953 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 13 May 2025 09:31:13 -0700 Subject: [PATCH 14/18] doc --- pkg/tui/drew/model_env_modal.go | 29 ++-- pkg/tui/drew/model_env_selection.go | 210 +++++++++++++++++----------- pkg/tui/drew/model_main.go | 1 - 3 files changed, 147 insertions(+), 93 deletions(-) diff --git a/pkg/tui/drew/model_env_modal.go b/pkg/tui/drew/model_env_modal.go index 3363902e..8a98d232 100644 --- a/pkg/tui/drew/model_env_modal.go +++ b/pkg/tui/drew/model_env_modal.go @@ -35,7 +35,7 @@ func (m EnvModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { if msg.String() == "enter" { - return m, cmdEnvCommand(m.commands.SelectedItem().(commandItem).title) + return &m, cmdEnvCommand(m.commands.SelectedItem().(commandItem).title) } } @@ -102,28 +102,33 @@ func (i commandItem) Title() string { return i.title } func (i commandItem) Description() string { return "" } func (i commandItem) FilterValue() string { return i.title } -type PassthroughModel struct { - content string +// ContentFuncModel is a model that simply returns the result of a function call when its View() +// method is invoked. +type ContentFuncModel struct { + contentFunc func() string } -func NewPassthroughModel() *PassthroughModel { - return &PassthroughModel{ - content: "", +func NewPassthroughModel(contentFunc func() string) *ContentFuncModel { + return &ContentFuncModel{ + contentFunc: contentFunc, } } -func (m *PassthroughModel) SetContent(content string) { - m.content = content +func (m *ContentFuncModel) SetContentFunc(contentFunc func() string) { + m.contentFunc = contentFunc } -func (m PassthroughModel) Init() tea.Cmd { +func (m ContentFuncModel) Init() tea.Cmd { return nil } -func (m PassthroughModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m ContentFuncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return &m, nil } -func (m PassthroughModel) View() string { - return m.content +func (m ContentFuncModel) View() string { + if m.contentFunc != nil { + return m.contentFunc() + } + return "" } diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go index 22abe8dc..40671e0c 100644 --- a/pkg/tui/drew/model_env_selection.go +++ b/pkg/tui/drew/model_env_selection.go @@ -18,15 +18,14 @@ import ( lipgloss_table "github.com/charmbracelet/lipgloss/table" ) -const ( - envListWidthPercentage = 40.0 - envDetailsWidthPercentage = 60.0 -) - -// NewEnvSelection creates a new environment pick list model. +// NewEnvSelection creates a new environment selection model. This model displays a list of environments +// on the left side of the screen, and a viewport of the selected environment's details on the right side. func NewEnvSelection() *EnvSelection { + // The model to return envSelection := &EnvSelection{} + // The delegate for the environment list -- a list delegate allows for custom rendering of the list items. + // In this case we customize the various colors and styles of items based on whether or not they are selected. delegate := list.NewDefaultDelegate() delegate.Styles.NormalTitle = lipgloss.NewStyle(). Foreground(textColorNormalTitle). @@ -53,6 +52,9 @@ func NewEnvSelection() *EnvSelection { delegate.Styles.FilterMatch = lipgloss.NewStyle().Underline(true) + // The environment list model, which consumes the above delegate. Note here that the list is provided + // an empty slice of items, and "0" values for the width and height. This is because the list will be + // the state of this model will be updated dynamically (within the Update() method of this model). list := list.New([]list.Item{}, delegate, 0, 0) list.SetShowStatusBar(false) list.SetShowTitle(false) @@ -62,6 +64,8 @@ func NewEnvSelection() *EnvSelection { list.DisableQuitKeybindings() envSelection.envList = list + // The viewport model, which consumes the selected environment's details. Viewports deal with key + // bindings slightly differently than other models, so we must set them here at instantiation time. envSelectedViewport := viewport.New(100, 50) envSelectedViewport.KeyMap = viewport.KeyMap{ Up: key.NewBinding( @@ -79,26 +83,31 @@ func NewEnvSelection() *EnvSelection { } envSelection.envSelectedViewport = envSelectedViewport + // The status spinner model, which is shared amongst any environment which displays a non-terminal + // status. envStatusSpinner := spinner.New( spinner.WithSpinner(spinner.MiniDot), ) envSelection.statusSpinner = envStatusSpinner - envSpinner := spinner.New( + // The loading spinner model, which is used when fetching environments. + envLoadingSpinner := spinner.New( spinner.WithSpinner(spinner.Points), spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#64748b"))), ) - envSelection.loadingSpinner = envSpinner + envSelection.loadingSpinner = envLoadingSpinner - envDetailsModal := overlay.New( + // The env actions modal, which is displayed as an overlay and contains additional actions to + // take on the selected environment. + envActionsModal := overlay.New( NewEnvModal(), - NewPassthroughModel(), + NewPassthroughModel(func() string { return envSelection.listView() }), overlay.Center, overlay.Center, 0, 0, ) - envSelection.modal = envDetailsModal + envSelection.modal = envActionsModal return envSelection } @@ -132,6 +141,9 @@ type EnvSelection struct { state envSelectionState } +// HelpTextEntries returns the help text entries for the environment selection model. +// TODO: this should be made more dynamic, as the help text should change based on the current state +// of the model (e.g. when the modal is open, the help text should change to reflect the available actions). func (e *EnvSelection) HelpTextEntries() [][]string { return [][]string{ {"q/esc", "exit"}, @@ -150,10 +162,11 @@ func (e *EnvSelection) Width() int { return e.envList.Width() } +// SetWidth sets the width of the environment selection model. func (e *EnvSelection) SetWidth(width int) { e.envList.SetWidth(width) e.envSelectedViewport.Width = width - + e.modal.Foreground.(*EnvModal).SetWidth(width) } @@ -162,6 +175,7 @@ func (e *EnvSelection) Height() int { return e.envList.Height() } +// SetHeight sets the height of the environment selection model. func (e *EnvSelection) SetHeight(height int) { e.envList.SetHeight(height + 4) e.envSelectedViewport.Height = height @@ -217,7 +231,7 @@ func (e *EnvSelection) View() string { } func (e *EnvSelection) loadingView() string { - spinner := fmt.Sprintf("Loading environments %s", e.loadingSpinner.View()) + spinner := fmt.Sprintf("%s\n\nLoading environments %s", nvidiaLogoLarge, e.loadingSpinner.View()) // Create a vertically centered spinner box with full height loadingBox := lipgloss.NewStyle(). @@ -406,97 +420,140 @@ func dataTable() *lipgloss_table.Table { BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))) } -func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { - var cmd tea.Cmd +func (e *EnvSelection) updateEnvList(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + // If the user presses the spacebar, open the modal with the selected environment in context + case " ": + e.modal.Foreground.(*EnvModal).SetEnvironment(e.getSelectedEnvironment()) + e.state = envModalState + return nil + + // If the user presses any other key, prepare for navigation + default: + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + // We need to know if the user has changed the selection in the env list. If they have, we then need + // to scroll to the top of the viewport, otherwise the viewport will remember the previous scroll position. + previousSelection := e.envList.SelectedItem().(envListItem).environment.ID + + // Pass the key event to the env pick list model to allow for environment selection + e.envList, cmd = e.envList.Update(msg) + cmds = append(cmds, cmd) + + // If the selection has changed, scroll to the top of the viewport + if previousSelection != e.envList.SelectedItem().(envListItem).environment.ID { + e.envSelectedViewport.SetYOffset(0) + } + + // Pass the key event to the env selection viewport to allow for viewport navigation + e.envSelectedViewport, cmd = e.envSelectedViewport.Update(msg) + cmds = append(cmds, cmd) + + return tea.Batch(cmds...) + } + } + + // Nothing more to do + return nil +} +func (e *EnvSelection) updateEnvModal(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { - case fetchEnvsMsg: - // The orgs have been fetched, so we need to update the org pick list model + // The environment command has been completed + case envCommandMsg: + if msg.err != nil { + return envSelectionErrorCmd(msg.err) + } - // Display the main list state + // Move back to the list state e.state = envListState - + return nil + + // A key has been pressed within the context of the modal + case tea.KeyMsg: + switch msg.String() { + // If the user presses the spacebar, move back to the list state + case " ": + // Move back to the list state + e.state = envListState + // Nothing more to do + return nil + } + } + + // For all other messages, pass them to the modal and update its model + foreground, cmd := e.modal.Foreground.Update(msg) + if envModal, ok := foreground.(*EnvModal); ok { + e.modal.Foreground = envModal + return cmd + } else { + return envSelectionErrorCmd(fmt.Errorf("unknown modal message: %T", msg)) + } +} + +func (e *EnvSelection) updateLoadingState(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + // The orgs have been fetched, so we need to update the org pick list model + case fetchEnvsMsg: if msg.err != nil { return envSelectionErrorCmd(msg.err) } - // Insert the orgs into the org pick list model + // Insert the fetched environments into the env pick list model envListItems := make([]list.Item, len(msg.environments)) for i, env := range msg.environments { envListItems[i] = envListItem{envSelection: e, environment: env} } + // If there are any environments, show the status bar if len(envListItems) > 0 { e.envList.SetShowStatusBar(true) } + // Update the env pick list model with the new items e.envList.SetItems(envListItems) - return nil - - case envCommandMsg: - // The environment command has been completed. Exit the modal. - if msg.err != nil { - return envSelectionErrorCmd(msg.err) - } - + // Move from the loading state to the list state e.state = envListState - return nil + } - case tea.KeyMsg: - switch msg.String() { - case " ": - if e.state == envListState { - // TODO: this actually shouldn't be the way the content is set. Intead of passing a string (which cannot be updated / can only be replaced) - // we need to pass in a Model that references all of the internal models (like spinners, lists, etc.). This way we can have Update() calls - // update the state of various objects, and have a later View() call on the modal that references the updated state. - e.modal.Background.(*PassthroughModel).SetContent(e.listView()) - e.modal.Foreground.(*EnvModal).SetEnvironment(e.getSelectedEnvironment()) - e.state = envModalState - } else if e.state == envModalState { - e.state = envListState - } - return nil - } + // Nothing more to do + return nil +} + +func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { + // Handle tick events for the status and loading spinners + switch msg := msg.(type) { case spinner.TickMsg: if msg.ID == e.statusSpinner.ID() { + var cmd tea.Cmd e.statusSpinner, cmd = e.statusSpinner.Update(msg) return cmd } - if msg.ID == e.loadingSpinner.ID() && e.state == envLoadingState { + if msg.ID == e.loadingSpinner.ID() { + var cmd tea.Cmd e.loadingSpinner, cmd = e.loadingSpinner.Update(msg) return cmd } } - if e.state == envModalState { - foreground, cmd := e.modal.Foreground.Update(msg) - if envModal, ok := foreground.(*EnvModal); ok { - e.modal.Foreground = envModal - } - return cmd - } - - // We need to know if the user has changed the selection in the env list. If they have, we then need - // to scroll to the top of the viewport, otherwise the viewport will remember the previous scroll position. - previousSelection := e.envList.SelectedItem().(envListItem).environment.ID - - // Pass the key event to the env pick list model to allow for environment selection - var keyCmds []tea.Cmd - e.envList, cmd = e.envList.Update(msg) - keyCmds = append(keyCmds, cmd) - - // If the selection has changed, scroll to the top of the viewport - if previousSelection != e.envList.SelectedItem().(envListItem).environment.ID { - e.envSelectedViewport.SetYOffset(0) + // Handle other state-specific messages + switch e.state { + case envListState: + return e.updateEnvList(msg) + case envModalState: + return e.updateEnvModal(msg) + case envLoadingState: + return e.updateLoadingState(msg) + default: + return envSelectionErrorCmd(fmt.Errorf("unknown state: %d", e.state)) } - - // Pass the key event to the env selection viewport to allow for viewport navigation - e.envSelectedViewport, cmd = e.envSelectedViewport.Update(msg) - keyCmds = append(keyCmds, cmd) - - return tea.Batch(keyCmds...) } // FetchEnvs fetches the environments and updates the env pick list model. This function automatically @@ -539,7 +596,7 @@ func cmdFetchEnvs(organizationID string) tea.Cmd { func fetchEnvs(organizationID string) []Environment { // simulate loading - time.Sleep(time.Second * 1) + time.Sleep(time.Second * 2) return []Environment{ {ID: "1", Name: "my-cool-env", InstanceType: Crusoe_1x_a100_40gb, Status: EnvironmentStatusRunning, PortMappings: []PortMapping{{"22", "22"}, {"8080", "80"}}, Tunnels: []Tunnel{{"443", "https://foo.bar.com"}}}, @@ -557,14 +614,7 @@ func fetchEnvs(organizationID string) []Environment { } } -var logoSmall = `β–žβ–˜β–—β–žβ–—β–—β–€▐β–€β–€β––β––β–—β–—β–˜β–€β–—β–€▝β–ž -β––β–—β–€β–€β–€β–€β–€β–žβ–€β–€β–€β–žβ–€β–€β––▝β––β–€▝β–— -β–˜β–˜β–—β–—β–€β–—β–€β–—β–€β––▐▐▐▐▐▐▐β–—β––β–ž -▝β––▝β––β–€β–žβ–€▐β–€β–—β–€β–€β–€β–˜β–—β–€β–€β–€β––β–€ -β––β–žβ–€β–€▝β–€β–žβ–žβ–€β–€▝β–€β–€β–žβ–€β–€β–€β–˜β–˜β–ž -▝β–˜β–€β–žβ––β–€β–€β–žβ–€β–€β–€β–€β–˜β–˜β–€▝β–€β–€▝▝` - -var logoLarge = `β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ +var nvidiaLogoLarge = `β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index 20636888..9f2f95fb 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -21,7 +21,6 @@ var ( infoStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() - // b.Left = "─" return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) }() From 8e3b20bfbaed49691b2d4f19b0a70c05d8570ca3 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 13 May 2025 16:45:16 -0700 Subject: [PATCH 15/18] logging --- .gitignore | 1 + pkg/tui/drew/model_main.go | 2 ++ pkg/tui/tui.go | 13 +++++++++++++ 3 files changed, 16 insertions(+) diff --git a/.gitignore b/.gitignore index 8f1541e0..de88d0d2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ dist/ # binary brev-cli brev +brev.log # golang executable go1.* diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index 9f2f95fb..fa1f7e35 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -2,6 +2,7 @@ package drew import ( "fmt" + "log" "strings" tea "github.com/charmbracelet/bubbletea" @@ -166,6 +167,7 @@ func (m *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case initMsg: + log.Default().Println("initMsg received") // If the program is being initialized, render the org pick list modal and fetch the orgs m.renderOrgPickList = true diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 2e876404..cde4e6ac 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -2,6 +2,8 @@ package tui import ( "fmt" + "log" + "os" "strings" "time" @@ -320,6 +322,17 @@ func RunMainTUI(s *store.AuthHTTPStore, t *terminal.Terminal) error { // tea.WithAltScreen(), // tea.WithMouseCellMotion(), // ) + + if len(os.Getenv("BREV_DEBUG_LOG")) > 0 { + f, err := tea.LogToFile("brev.log", "") + if err != nil { + panic(err) + } + defer f.Close() + + log.SetFlags(log.LstdFlags | log.Lshortfile) + } + p := tea.NewProgram(&drew.MainModel{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) _, err := p.Run() return err From 7c3dc2828609714d574545be7344be34b1748899 Mon Sep 17 00:00:00 2001 From: naderlikeladder Date: Tue, 13 May 2025 19:01:48 -0700 Subject: [PATCH 16/18] org selection pulls live org data --- pkg/tui/drew/model_main.go | 12 +++- pkg/tui/drew/model_org_selection.go | 85 ++++++++++++----------------- pkg/tui/tui.go | 2 +- 3 files changed, 47 insertions(+), 52 deletions(-) diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go index fa1f7e35..496fe93c 100644 --- a/pkg/tui/drew/model_main.go +++ b/pkg/tui/drew/model_main.go @@ -46,6 +46,16 @@ type MainModel struct { renderOrgPickList bool orgSelection *OrgSelection envSelection *EnvSelection + + // Store for API calls + store OrgStore +} + +// NewMainModel creates a new main model. +func NewMainModel(store OrgStore) *MainModel { + return &MainModel{ + store: store, + } } func (m *MainModel) View() string { @@ -128,7 +138,7 @@ func (m *MainModel) initCmd() tea.Cmd { } func (m *MainModel) Init() tea.Cmd { - m.orgSelection = NewOrgSelection() + m.orgSelection = NewOrgSelection(m.store) m.envSelection = NewEnvSelection() // TODO: if not default org is found (read from ~/.brev), submit the init commandΓΈ diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go index cc2c1bda..1ccbaab3 100644 --- a/pkg/tui/drew/model_org_selection.go +++ b/pkg/tui/drew/model_org_selection.go @@ -2,17 +2,24 @@ package drew import ( "sort" - "time" + "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/store" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) +type OrgStore interface { + GetOrganizations(options *store.GetOrganizationsOptions) ([]entity.Organization, error) +} + // NewOrgSelection creates a new organization pick list model. -func NewOrgSelection() *OrgSelection { - orgSelection := &OrgSelection{} +func NewOrgSelection(store OrgStore) *OrgSelection { + orgSelection := &OrgSelection{ + store: store, + } delegate := list.NewDefaultDelegate() delegate.Styles.NormalTitle = lipgloss.NewStyle(). @@ -67,6 +74,7 @@ func NewOrgSelection() *OrgSelection { type OrgSelection struct { orgPickListModel list.Model orgSelected *orgListItem + store OrgStore } func (o *OrgSelection) SetHeight(height int) { @@ -99,11 +107,11 @@ func (e *OrgSelection) HelpTextEntries() [][]string { } type orgListItem struct { - Organization organization + Organization entity.Organization } func (i orgListItem) Title() string { return i.Organization.Name } -func (i orgListItem) Description() string { return i.Organization.Description } +func (i orgListItem) Description() string { return i.Organization.UserNetworkID } func (i orgListItem) FilterValue() string { return i.Organization.Name } type ( @@ -112,6 +120,11 @@ type ( // CloseOrgSelectionMsg is a message that indicates the organization pick list should be closed. CloseOrgSelectionMsg struct{} + + fetchOrgsMsg struct { + organizations []entity.Organization + err error + } ) func orgSelectionErrorCmd(err error) tea.Cmd { @@ -176,6 +189,22 @@ func (o *OrgSelection) Update(msg tea.Msg) tea.Cmd { return cmd } +func cmdFetchOrgs(store OrgStore) tea.Cmd { + return func() tea.Msg { + organizations, err := store.GetOrganizations(nil) + if err != nil { + return fetchOrgsMsg{err: err} + } + + // Sort the organizations by ID + sort.Slice(organizations, func(i, j int) bool { + return organizations[i].ID < organizations[j].ID + }) + + return fetchOrgsMsg{organizations: organizations} + } +} + // FetchOrgs fetches the organizations and updates the org pick list model. This function automatically // starts the spinner and returns a command that will update the org pick list model when the organizations // are fetched. The returned command should be used to render the next frame for the spinner, and should @@ -185,51 +214,7 @@ func (o *OrgSelection) FetchOrgs() tea.Cmd { startSpinnerCmd := o.orgPickListModel.StartSpinner() // Fetch the organizations - fetchOrgsCmd := cmdFetchOrgs() + fetchOrgsCmd := cmdFetchOrgs(o.store) return tea.Batch(startSpinnerCmd, fetchOrgsCmd) } - -type organization struct { - ID string - Name string - Description string -} - -type fetchOrgsMsg struct { - organizations []organization - err error -} - -func cmdFetchOrgs() tea.Cmd { - return func() tea.Msg { - organizations := fetchOrgs() - - // Sort the organizations by ID - sort.Slice(organizations, func(i, j int) bool { - return organizations[i].ID < organizations[j].ID - }) - - return fetchOrgsMsg{organizations: organizations, err: nil} - } -} - -func fetchOrgs() []organization { - // simulate loading - time.Sleep(time.Second * 1) - - return []organization{ - {ID: "1", Name: "org 1", Description: "First organization"}, - {ID: "2", Name: "brev-internal", Description: "Second organization"}, - {ID: "3", Name: "my-cool-org", Description: "Third organization"}, - {ID: "4", Name: "brev-load-testing", Description: "Fourth organization"}, - {ID: "5", Name: "Organization 5", Description: "Fifth organization"}, - {ID: "6", Name: "Organization 6", Description: "Sixth organization"}, - {ID: "7", Name: "Organization 7", Description: "Seventh organization"}, - {ID: "8", Name: "Organization 8", Description: "Eighth organization"}, - {ID: "9", Name: "Organization 9", Description: "Ninth organization"}, - {ID: "10", Name: "Organization 10", Description: "Tenth organization"}, - {ID: "11", Name: "Organization 11", Description: "Eleventh organization"}, - {ID: "12", Name: "Organization 12", Description: "Twelfth organization"}, - } -} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index cde4e6ac..9e48c65a 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -333,7 +333,7 @@ func RunMainTUI(s *store.AuthHTTPStore, t *terminal.Terminal) error { log.SetFlags(log.LstdFlags | log.Lshortfile) } - p := tea.NewProgram(&drew.MainModel{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(drew.NewMainModel(s), tea.WithAltScreen(), tea.WithMouseCellMotion()) _, err := p.Run() return err } From b0c3103441a77e579cd383225cc2e55350636945 Mon Sep 17 00:00:00 2001 From: naderlikeladder Date: Wed, 14 May 2025 09:35:38 -0700 Subject: [PATCH 17/18] update the brev active org when org is selected --- pkg/tui/drew/model_org_selection.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go index 1ccbaab3..ed91df3e 100644 --- a/pkg/tui/drew/model_org_selection.go +++ b/pkg/tui/drew/model_org_selection.go @@ -13,6 +13,7 @@ import ( type OrgStore interface { GetOrganizations(options *store.GetOrganizationsOptions) ([]entity.Organization, error) + SetDefaultOrganization(org *entity.Organization) error } // NewOrgSelection creates a new organization pick list model. @@ -177,6 +178,11 @@ func (o *OrgSelection) Update(msg tea.Msg) tea.Cmd { case "enter": if selected, ok := o.orgPickListModel.SelectedItem().(orgListItem); ok { o.orgSelected = &selected + // Save the selected org as the default + err := o.store.SetDefaultOrganization(&selected.Organization) + if err != nil { + return orgSelectionErrorCmd(err) + } return orgSelectionCloseCmd() } From b27ca05b3156bc1e500fb71514521113de14018f Mon Sep 17 00:00:00 2001 From: naderlikeladder Date: Wed, 14 May 2025 14:47:03 -0700 Subject: [PATCH 18/18] file transfer modal --- pkg/tui/drew/model_env_dragdrop_modal.go | 65 ++++++++++++++++++++++++ pkg/tui/drew/model_env_selection.go | 47 +++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 pkg/tui/drew/model_env_dragdrop_modal.go diff --git a/pkg/tui/drew/model_env_dragdrop_modal.go b/pkg/tui/drew/model_env_dragdrop_modal.go new file mode 100644 index 00000000..88108d4a --- /dev/null +++ b/pkg/tui/drew/model_env_dragdrop_modal.go @@ -0,0 +1,65 @@ +package drew + +import ( + tea "github.com/charmbracelet/bubbletea" + lipgloss "github.com/charmbracelet/lipgloss" +) + +type EnvDragDropModal struct { + environment *Environment + width int + height int +} + +func NewEnvDragDropModal() *EnvDragDropModal { + return &EnvDragDropModal{} +} + +func (m EnvDragDropModal) Init() tea.Cmd { + return nil +} + +func (m EnvDragDropModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + if msg.String() == " " { + // Close modal on space + return &m, nil + } + } + return &m, nil +} + +func (m EnvDragDropModal) View() string { + foreStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder(), true). + BorderForeground(lipgloss.Color("6")). + Padding(1, 2) + + boldStyle := lipgloss.NewStyle().Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + + title := boldStyle.Render("File Transfer") + subtitle := dimStyle.Render("Drag and drop files here to SCP them to " + m.environment.Name) + + content := lipgloss.JoinVertical(lipgloss.Center, + title, + "", + subtitle, + "", + dimStyle.Render("Press SPACE to close"), + ) + + return foreStyle.Render(content) +} + +func (m *EnvDragDropModal) SetEnvironment(environment *Environment) { + m.environment = environment +} + +func (m *EnvDragDropModal) SetWidth(width int) { + m.width = width +} + +func (m *EnvDragDropModal) SetHeight(height int) { + m.height = height +} \ No newline at end of file diff --git a/pkg/tui/drew/model_env_selection.go b/pkg/tui/drew/model_env_selection.go index 40671e0c..73458761 100644 --- a/pkg/tui/drew/model_env_selection.go +++ b/pkg/tui/drew/model_env_selection.go @@ -109,6 +109,17 @@ func NewEnvSelection() *EnvSelection { ) envSelection.modal = envActionsModal + // The drag-drop modal for file transfer + dragDropModal := overlay.New( + NewEnvDragDropModal(), + NewPassthroughModel(func() string { return envSelection.listView() }), + overlay.Center, + overlay.Center, + 0, + 0, + ) + envSelection.dragDropModal = dragDropModal + return envSelection } @@ -118,6 +129,7 @@ const ( envLoadingState envSelectionState = iota envListState envModalState + envDragDropState ) // EnvSelection is a model that represents the environment pick list. Note that this is not a complete @@ -134,6 +146,9 @@ type EnvSelection struct { // An overlay modal to display the environment details modal *overlay.Model + // An overlay modal for drag-and-drop file transfer + dragDropModal *overlay.Model + // A spinner model to use when fetching environments loadingSpinner spinner.Model @@ -168,6 +183,7 @@ func (e *EnvSelection) SetWidth(width int) { e.envSelectedViewport.Width = width e.modal.Foreground.(*EnvModal).SetWidth(width) + e.dragDropModal.Foreground.(*EnvDragDropModal).SetWidth(width) } // Height returns the height of the organization pick list. @@ -181,6 +197,7 @@ func (e *EnvSelection) SetHeight(height int) { e.envSelectedViewport.Height = height e.modal.Foreground.(*EnvModal).SetHeight(height) + e.dragDropModal.Foreground.(*EnvDragDropModal).SetHeight(height) } type envListItem struct { @@ -225,6 +242,8 @@ func (e *EnvSelection) View() string { return e.loadingView() } else if e.state == envModalState { return e.modalView() + } else if e.state == envDragDropState { + return e.dragDropModal.View() } else { return e.listView() } @@ -429,6 +448,11 @@ func (e *EnvSelection) updateEnvList(msg tea.Msg) tea.Cmd { e.modal.Foreground.(*EnvModal).SetEnvironment(e.getSelectedEnvironment()) e.state = envModalState return nil + // If the user presses enter, open the drag-drop modal + case "enter": + e.dragDropModal.Foreground.(*EnvDragDropModal).SetEnvironment(e.getSelectedEnvironment()) + e.state = envDragDropState + return nil // If the user presses any other key, prepare for navigation default: @@ -527,6 +551,27 @@ func (e *EnvSelection) updateLoadingState(msg tea.Msg) tea.Cmd { return nil } +func (e *EnvSelection) updateDragDropModal(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case " ": + // Move back to the list state + e.state = envListState + return nil + } + } + + // For all other messages, pass them to the modal and update its model + foreground, cmd := e.dragDropModal.Foreground.Update(msg) + if dragDropModal, ok := foreground.(*EnvDragDropModal); ok { + e.dragDropModal.Foreground = dragDropModal + return cmd + } else { + return envSelectionErrorCmd(fmt.Errorf("unknown modal message: %T", msg)) + } +} + func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { // Handle tick events for the status and loading spinners switch msg := msg.(type) { @@ -549,6 +594,8 @@ func (e *EnvSelection) Update(msg tea.Msg) tea.Cmd { return e.updateEnvList(msg) case envModalState: return e.updateEnvModal(msg) + case envDragDropState: + return e.updateDragDropModal(msg) case envLoadingState: return e.updateLoadingState(msg) default: