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/.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/go.mod b/go.mod index c0d74dea..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 @@ -121,6 +124,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..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= @@ -521,6 +523,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/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", + } +) diff --git a/pkg/tui/drew/model_auth.go b/pkg/tui/drew/model_auth.go new file mode 100644 index 00000000..940825e0 --- /dev/null +++ b/pkg/tui/drew/model_auth.go @@ -0,0 +1,3 @@ +package drew + + 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_modal.go b/pkg/tui/drew/model_env_modal.go new file mode 100644 index 00000000..8a98d232 --- /dev/null +++ b/pkg/tui/drew/model_env_modal.go @@ -0,0 +1,134 @@ +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 } + +// 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(contentFunc func() string) *ContentFuncModel { + return &ContentFuncModel{ + contentFunc: contentFunc, + } +} + +func (m *ContentFuncModel) SetContentFunc(contentFunc func() string) { + m.contentFunc = contentFunc +} + +func (m ContentFuncModel) Init() tea.Cmd { + return nil +} + +func (m ContentFuncModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return &m, nil +} + +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 new file mode 100644 index 00000000..73458761 --- /dev/null +++ b/pkg/tui/drew/model_env_selection.go @@ -0,0 +1,679 @@ +package drew + +import ( + "fmt" + "sort" + "strings" + "time" + + overlay "github.com/rmhubbert/bubbletea-overlay" + "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" +) + +// 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). + 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) + + // 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) + list.SetStatusBarItemName("environment", "environments") + list.SetFilteringEnabled(false) + list.SetShowHelp(false) + 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( + 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 + + // 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 + + // 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 = envLoadingSpinner + + // 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(func() string { return envSelection.listView() }), + overlay.Center, + overlay.Center, + 0, + 0, + ) + 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 +} + +type envSelectionState int + +const ( + envLoadingState envSelectionState = iota + envListState + envModalState + envDragDropState +) + +// 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 view + 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 + + // An overlay modal for drag-and-drop file transfer + dragDropModal *overlay.Model + + // A spinner model to use when fetching environments + loadingSpinner spinner.Model + + // The current state of the environment selection model + 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"}, + {"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. +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) + e.dragDropModal.Foreground.(*EnvDragDropModal).SetWidth(width) +} + +// Height returns the height of the organization pick list. +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 + + e.modal.Foreground.(*EnvModal).SetHeight(height) + e.dragDropModal.Foreground.(*EnvDragDropModal).SetHeight(height) +} + +type envListItem struct { + 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) Description() string { + 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 ( + // 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.state == envLoadingState { + return e.loadingView() + } else if e.state == envModalState { + return e.modalView() + } else if e.state == envDragDropState { + return e.dragDropModal.View() + } else { + return e.listView() + } +} + +func (e *EnvSelection) loadingView() string { + spinner := fmt.Sprintf("%s\n\nLoading environments %s", nvidiaLogoLarge, 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 { + return e.modal.View() +} + +func (e *EnvSelection) listView() string { + environment := e.getSelectedEnvironment() + + // 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. + // 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(environment, 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.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) + } + + // 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) 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 "" + } + + 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 (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 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: + 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) { + + // The environment command has been completed + case envCommandMsg: + if msg.err != nil { + return envSelectionErrorCmd(msg.err) + } + + // 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 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) + + // Move from the loading state to the list state + e.state = envListState + } + + // Nothing more to do + 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) { + 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() { + var cmd tea.Cmd + e.loadingSpinner, cmd = e.loadingSpinner.Update(msg) + return cmd + } + } + + // Handle other state-specific messages + switch e.state { + case envListState: + return e.updateEnvList(msg) + case envModalState: + return e.updateEnvModal(msg) + case envDragDropState: + return e.updateDragDropModal(msg) + case envLoadingState: + return e.updateLoadingState(msg) + default: + return envSelectionErrorCmd(fmt.Errorf("unknown state: %d", e.state)) + } +} + +// 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.envList.SetItems([]list.Item{}) + + // Fetch the organizations + fetchEnvsCmd := cmdFetchEnvs(organizationID) + + // Start the spinner + e.state = envLoadingState + loadingSpinnerCmd := e.loadingSpinner.Tick + + // Start the env status spinner + statusSpinnerCmd := e.statusSpinner.Tick + + return tea.Batch(fetchEnvsCmd, loadingSpinnerCmd, statusSpinnerCmd) +} + +type fetchEnvsMsg struct { + environments []Environment + err error +} + +func cmdFetchEnvs(organizationID string) tea.Cmd { + return func() tea.Msg { + 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 * 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"}}}, + {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 nvidiaLogoLarge = `▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀` diff --git a/pkg/tui/drew/model_main.go b/pkg/tui/drew/model_main.go new file mode 100644 index 00000000..496fe93c --- /dev/null +++ b/pkg/tui/drew/model_main.go @@ -0,0 +1,244 @@ +package drew + +import ( + "fmt" + "log" + "strings" + + tea "github.com/charmbracelet/bubbletea" + lipgloss "github.com/charmbracelet/lipgloss" +) + +var ( + 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() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + 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 + + // Org list Modal + 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 { + 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.envSelection.Height() / 2) - (h / 2) + // marginLeft := (m.envSelection.Width() / 2) - (w / 2) + // marginBottom := m.envSelection.Height() - marginTop - h - 2 + content = lipgloss.NewStyle(). + 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.envSelection.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.envSelection.Width()-lipgloss.Width(title))) + return lipgloss.JoinHorizontal(lipgloss.Center, title, line) +} + +func (m *MainModel) footerView() string { + helpTextEntries := []string{} + if m.renderOrgPickList { + 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])) + } + } 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.store) + m.envSelection = NewEnvSelection() + + // 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) { + // 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) + 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 + cmd := m.orgSelection.FetchOrgs() + return m, 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) + return m, cmd + } + } + + // 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 + } + } + + // 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()) + 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, 30)) +} diff --git a/pkg/tui/drew/model_org_selection.go b/pkg/tui/drew/model_org_selection.go new file mode 100644 index 00000000..ed91df3e --- /dev/null +++ b/pkg/tui/drew/model_org_selection.go @@ -0,0 +1,226 @@ +package drew + +import ( + "sort" + + "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) + SetDefaultOrganization(org *entity.Organization) error +} + +// NewOrgSelection creates a new organization pick list model. +func NewOrgSelection(store OrgStore) *OrgSelection { + orgSelection := &OrgSelection{ + store: store, + } + + 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 + list := list.New([]list.Item{}, delegate, 0, 0) + + // Style the organization pick list + 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 +} + +// 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 + store OrgStore +} + +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. +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() +} + +func (e *OrgSelection) HelpTextEntries() [][]string { + return [][]string{ + {"o/q/esc", "close window"}, + } +} + +type orgListItem struct { + Organization entity.Organization +} + +func (i orgListItem) Title() string { return i.Organization.Name } +func (i orgListItem) Description() string { return i.Organization.UserNetworkID } +func (i orgListItem) FilterValue() string { return i.Organization.Name } + +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{} + + fetchOrgsMsg struct { + organizations []entity.Organization + err error + } +) + +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() +} + +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 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{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 + + case tea.KeyMsg: + switch msg.String() { + + // Close the org list + case "esc", "o", "q": + return orgSelectionCloseCmd() + + // Select an org + 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() + } + + // For all other key events, pass them to the org pick list model + default: + o.orgPickListModel, cmd = o.orgPickListModel.Update(msg) + } + } + + 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 +// 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(o.store) + + return tea.Batch(startSpinnerCmd, fetchOrgsCmd) +} 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"} +) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index e7b9f64b..9e48c65a 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -2,12 +2,15 @@ package tui import ( "fmt" + "log" + "os" "strings" "time" "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 +30,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 +61,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 +73,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 +252,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 +318,22 @@ 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(), + // ) + + 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.NewMainModel(s), tea.WithAltScreen(), tea.WithMouseCellMotion()) _, err := p.Run() return err -} \ No newline at end of file +}