├── stages.gif ├── README.md ├── go.mod ├── go.sum └── main.go /stages.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/bubbletea-stages/HEAD/stages.gif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | ![Bubbbletea State Machine pattern](./stages.gif) 4 | 5 | This example repository demonstrates the Bubbletea State Machine pattern, which is ideal for building 6 | command line tools and other Terminal User Interfaces (TUI) that can orchestrate complex deployments for your users. 7 | 8 | 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module z/z 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.15.0 7 | github.com/charmbracelet/bubbletea v0.23.2 8 | github.com/charmbracelet/lipgloss v0.7.0 9 | ) 10 | 11 | require ( 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 13 | github.com/containerd/console v1.0.3 // indirect 14 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 15 | github.com/mattn/go-isatty v0.0.17 // indirect 16 | github.com/mattn/go-localereader v0.0.1 // indirect 17 | github.com/mattn/go-runewidth v0.0.14 // indirect 18 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 19 | github.com/muesli/cancelreader v0.2.2 // indirect 20 | github.com/muesli/reflow v0.3.0 // indirect 21 | github.com/muesli/termenv v0.15.0 // indirect 22 | github.com/rivo/uniseg v0.2.0 // indirect 23 | golang.org/x/sync v0.1.0 // indirect 24 | golang.org/x/sys v0.6.0 // indirect 25 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 26 | golang.org/x/text v0.3.7 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 2 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 3 | github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 6 | github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= 7 | github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= 8 | github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= 9 | github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps= 10 | github.com/charmbracelet/bubbletea v0.23.2/go.mod h1:FaP3WUivcTM0xOKNmhciz60M6I+weYLF76mr1JyI7sM= 11 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 12 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 13 | github.com/charmbracelet/lipgloss v0.7.0 h1:cezqy7Ca4XaO4xWQ+uRmsFKyitFnC88GFwce+yCNWos= 14 | github.com/charmbracelet/lipgloss v0.7.0/go.mod h1:uLUJKOkkcdPmrrE60+ZVpe3Fiz0aekJ02eqL2NrpOTs= 15 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 16 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 17 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 18 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 19 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 20 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 21 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 22 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 23 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 24 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 25 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 26 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 27 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 28 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 29 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 30 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 31 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 32 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 33 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 34 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 35 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 36 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 37 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 38 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 39 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 40 | github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= 41 | github.com/muesli/termenv v0.15.0 h1:ZYfCF4CZGhAA4meilZ5pd7tfUX4QLH4zB7OBie4RMS8= 42 | github.com/muesli/termenv v0.15.0/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 43 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 44 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 45 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 46 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 47 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 48 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 49 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 55 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 57 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 58 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 59 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 60 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 61 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/charmbracelet/bubbles/spinner" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | ) 14 | 15 | // Stage is a single step in a deployment process. Only one stage can be running at one time, 16 | // And the entire process exits if any stage fails along the way 17 | 18 | // The Action is the function that is run to complete the stage's work 19 | // IsComplete 20 | type Stage struct { 21 | Name string 22 | Action func() error 23 | Error error 24 | IsComplete bool 25 | IsCompleteFunc func() bool 26 | Reset func() error 27 | } 28 | 29 | var stageIndex = 0 30 | 31 | var stages = []Stage{ 32 | { 33 | Name: "One", 34 | Action: func() error { 35 | time.Sleep(3 * time.Second) 36 | return nil 37 | }, 38 | IsCompleteFunc: func() bool { return false }, 39 | IsComplete: false, 40 | }, 41 | { 42 | Name: "Two", 43 | Action: func() error { 44 | time.Sleep(3 * time.Second) 45 | return errors.New("This one errored") 46 | }, 47 | IsCompleteFunc: func() bool { return false }, 48 | IsComplete: false, 49 | }, 50 | { 51 | Name: "Three", 52 | Action: func() error { 53 | time.Sleep(3 * time.Second) 54 | return nil 55 | }, 56 | IsCompleteFunc: func() bool { return false }, 57 | IsComplete: false, 58 | }, 59 | } 60 | 61 | type model struct { 62 | status int 63 | Error error 64 | spinner spinner.Model 65 | } 66 | 67 | type startDeployMsg struct{} 68 | 69 | func startDeployCmd() tea.Msg { 70 | return startDeployMsg{} 71 | } 72 | 73 | func runStage() tea.Msg { 74 | if !stages[stageIndex].IsCompleteFunc() { 75 | // Run the current stage, and record its result status 76 | stages[stageIndex].Error = stages[stageIndex].Action() 77 | } 78 | return stageCompleteMsg{} 79 | } 80 | 81 | type stageCompleteMsg struct{} 82 | 83 | type errMsg struct{ err error } 84 | 85 | // For messages that contain errors it's often handy to also implement the 86 | // error interface on the message. 87 | func (e errMsg) Error() string { return e.err.Error() } 88 | 89 | func initialModel() model { 90 | s := spinner.New() 91 | s.Spinner = spinner.Dot 92 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 93 | return model{ 94 | spinner: s, 95 | } 96 | } 97 | 98 | func (m model) Init() tea.Cmd { 99 | return tea.Batch(m.spinner.Tick, startDeployCmd) 100 | } 101 | 102 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 103 | switch msg := msg.(type) { 104 | case stageCompleteMsg: 105 | // If we have an error, then set the error so that the views can properly update 106 | if stages[stageIndex].Error != nil { 107 | m.Error = stages[stageIndex].Error 108 | writeCommandLogFile() 109 | return m, tea.Quit 110 | } 111 | // Otherwise, mark the current stage as complete and move to the next stage 112 | stages[stageIndex].IsComplete = true 113 | // If we've reached the end of the defined stages, we're done 114 | if stageIndex+1 >= len(stages) { 115 | return m, tea.Quit 116 | } 117 | stageIndex++ 118 | return m, runStage 119 | 120 | case errMsg: 121 | m.Error = msg 122 | return m, tea.Quit 123 | 124 | case tea.KeyMsg: 125 | if msg.Type == tea.KeyCtrlC { 126 | return m, tea.Quit 127 | } 128 | 129 | case startDeployMsg: 130 | return m, runStage 131 | } 132 | 133 | var spinnerCmd tea.Cmd 134 | m.spinner, spinnerCmd = m.spinner.Update(msg) 135 | return m, spinnerCmd 136 | } 137 | 138 | func renderCheckbox(s Stage) string { 139 | sb := strings.Builder{} 140 | if s.Error != nil { 141 | sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(" ❌ ")) 142 | } else if s.IsComplete { 143 | sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("170")).Render(" ✅ ")) 144 | } else { 145 | sb.WriteString(" 🔲 ") 146 | } 147 | return sb.String() 148 | } 149 | 150 | func renderWorkingStatus(m model, s Stage) string { 151 | sb := strings.Builder{} 152 | if !s.IsComplete { 153 | sb.WriteString(m.spinner.View()) 154 | } else { 155 | sb.WriteString(" ") 156 | } 157 | sb.WriteString(" ") 158 | sb.WriteString(s.Name) 159 | return sb.String() 160 | } 161 | 162 | func (m model) View() string { 163 | sb := strings.Builder{} 164 | 165 | sb.WriteString(fmt.Sprintf("Current stage: %s\n", stages[stageIndex].Name)) 166 | 167 | for _, stage := range stages { 168 | sb.WriteString(renderCheckbox(stage) + " " + renderWorkingStatus(m, stage) + "\n") 169 | } 170 | return sb.String() 171 | } 172 | 173 | // commandLog is rendered when the deployment encounters an error. It retains a log of all the "commands" that were run in the course of deploying the example 174 | // "commands" are intentionally in air-quotes here because this also includes things like checking for the existence of environment variables, and is not yet 175 | // implemented in a truly re-windable cross-platform way, but it's a start, and it's better than asking someone over an email what failed 176 | var commandLog = []string{} 177 | 178 | func logCommand(s string) { 179 | commandLog = append(commandLog, s) 180 | } 181 | 182 | func writeCommandLogFile() { 183 | //Write the entire command log to a file on the filesystem so that the user has the option of sending it to Gruntwork for debugging purposes 184 | // We currently write the file to ./gruntwork-examples-debug.log in the same directory as the executable was run in 185 | 186 | // Create the file 187 | f, err := os.Create("bubbletea-debug.log") 188 | if err != nil { 189 | fmt.Println(err) 190 | return 191 | } 192 | // Write to the file, first writing the UTC timestamp as the first line, then looping through the command log to write each command on a new line 193 | f.WriteString("Ran at: " + time.Now().UTC().String() + "\n") 194 | f.WriteString("******************************************************************************\n") 195 | f.WriteString("Human legible log of steps taken and commands run up to the point of failure:\n") 196 | f.WriteString("******************************************************************************\n") 197 | for _, cmd := range commandLog { 198 | f.WriteString(cmd + "\n") 199 | } 200 | f.WriteString("^ The above command is likely the one that caused the error!\n") 201 | f.WriteString("\n\n") 202 | f.WriteString("******************************************************************************\n") 203 | f.WriteString("Complete log of the error that halted the deployment:\n") 204 | f.WriteString("******************************************************************************\n") 205 | f.WriteString("\n\n") 206 | f.WriteString(stages[stageIndex].Error.Error() + "\n") 207 | } 208 | 209 | func main() { 210 | if _, err := tea.NewProgram(initialModel()).Run(); err != nil { 211 | fmt.Printf("Uh oh, there was an error: %v\n", err) 212 | os.Exit(1) 213 | } 214 | } 215 | --------------------------------------------------------------------------------