github.com/omnigres/cli@v0.1.4/tui/download_progress.go (about) 1 package tui 2 3 import ( 4 "encoding/json" 5 "errors" 6 "io" 7 "strings" 8 "time" 9 10 "github.com/charmbracelet/bubbles/progress" 11 tea "github.com/charmbracelet/bubbletea" 12 ) 13 14 // DownloadStatus is the top-level structure of the JSON on each line of the docker output 15 // 16 // This can be used to define onProgress callbacks when creating a new DownloadProgress TUI 17 type DownloadStatus struct { 18 Status string `json:"status"` 19 ProgressDetail progressDetail `json:"progressDetail"` 20 ID string `json:"id"` 21 } 22 type progressDetail struct { 23 Current *int `json:"current"` 24 Total *int `json:"total"` 25 } 26 27 type dockerProgressWriter struct { 28 reader io.ReadCloser 29 layers int 30 downloaded int 31 downloads map[string]float64 32 onProgress func(int, int, float64, DownloadStatus) 33 onError func(error) 34 onJsonParse func(DownloadStatus) 35 onFinish func() 36 } 37 38 type quitMsg struct{} 39 type progressMsg float64 40 type progressErrMsg struct{ err error } 41 type debugMsg string 42 type Model struct { 43 header string 44 progress progress.Model 45 writer *dockerProgressWriter 46 Err error 47 debug string 48 } 49 50 var teaProgram *tea.Program 51 52 // Start will copy the reader output to the DockerProgressWriter. 53 // 54 // This function is called in a goroutine so we don't block the TUI. 55 func (pw *dockerProgressWriter) Start() { 56 _, err := io.Copy(pw, pw.reader) 57 if err != nil { 58 pw.onError(err) 59 } 60 } 61 62 // NewDownloadProgress returns a bubbletea TUI that tracks the progress of the given reader. 63 // 64 // It assumes the reader outputs the docker progress JSON messages. 65 func NewDownloadProgress(initialHeader string, reader io.ReadCloser) *tea.Program { 66 writer := newDockerProgressWriter( 67 reader, 68 func(err error) { teaProgram.Send(progressErrMsg{err}) }, 69 func(slice int, totalSlices int, downloadsInFlight float64, details DownloadStatus) { 70 teaProgram.Send(progressMsg((float64(slice) + downloadsInFlight) / float64(totalSlices))) 71 }, 72 func(details DownloadStatus) { 73 // Uncomment to see each output line below the progres bar 74 // encodedDetails, _ := json.Marshal(details) 75 // teaProgram.Send(debugMsg(string(encodedDetails))) 76 }, 77 func() { 78 teaProgram.Send(quitMsg{}) 79 }, 80 ) 81 teaProgram = tea.NewProgram( 82 Model{ 83 header: initialHeader, 84 progress: progress.New(progress.WithDefaultGradient()), 85 writer: writer, 86 }, 87 ) 88 go writer.Start() 89 return teaProgram 90 } 91 92 func newDockerProgressWriter( 93 reader io.ReadCloser, 94 onError func(error), 95 onProgress func(int, int, float64, DownloadStatus), 96 onJsonParse func(DownloadStatus), 97 onFinish func(), 98 ) *dockerProgressWriter { 99 return &dockerProgressWriter{ 100 reader: reader, 101 layers: 0, 102 downloaded: 0, 103 downloads: map[string]float64{}, 104 onError: onError, 105 onProgress: onProgress, 106 onJsonParse: onJsonParse, 107 onFinish: onFinish, 108 } 109 } 110 111 func (cw *dockerProgressWriter) Write(payload []byte) (n int, jsonError error) { 112 lines := strings.Split(string(payload), "\n") 113 for _, line := range lines { 114 var status DownloadStatus 115 progressParseError := json.Unmarshal([]byte(line), &status) 116 if progressParseError == nil { 117 // useful for debugging 118 cw.onJsonParse(status) 119 120 switch status.Status { 121 case "Downloading": 122 var downloadProgress float64 123 if status.ProgressDetail.Current != nil && status.ProgressDetail.Total != nil && *status.ProgressDetail.Total > 0 { 124 downloadProgress = float64(*status.ProgressDetail.Current) / float64(*status.ProgressDetail.Total) 125 } else { 126 downloadProgress = 0 127 } 128 cw.downloads[status.ID] = downloadProgress 129 130 // Sums all download progress in flight for finer grained progress 131 totalDownload := 0.0 132 for _, value := range cw.downloads { 133 totalDownload += value 134 } 135 cw.onProgress(cw.downloaded, cw.layers, totalDownload, status) 136 break 137 case "Pulling fs layer": 138 cw.layers++ 139 break 140 case "Download complete": 141 delete(cw.downloads, status.ID) 142 cw.downloaded++ 143 break 144 case "Already exists": 145 cw.layers++ 146 cw.downloaded++ 147 break 148 default: 149 if strings.Contains(status.Status, "Status: Downloaded newer image") { 150 cw.onProgress(cw.layers, cw.layers, 0.0, status) 151 // we need to sleep to ensure the TUI has time to animate the progress bar 152 // specially in cases where we have only one small image to download 153 time.Sleep(time.Second) 154 cw.onFinish() 155 } 156 } 157 } 158 } 159 return len(payload), nil 160 } 161 162 func (m Model) Init() tea.Cmd { 163 return func() tea.Msg { 164 return nil 165 } 166 } 167 168 const ( 169 padding = 2 170 maxWidth = 80 171 ) 172 173 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 174 switch msg := msg.(type) { 175 case tea.KeyMsg: 176 if msg.Type == tea.KeyEsc { 177 return m, func() tea.Msg { return progressErrMsg{errors.New("ESC was pressed")} } 178 } 179 return m, nil 180 181 case tea.WindowSizeMsg: 182 m.progress.Width = msg.Width - padding*2 - 4 183 if m.progress.Width > maxWidth { 184 m.progress.Width = maxWidth 185 } 186 return m, nil 187 188 case progressErrMsg: 189 m.Err = msg.err 190 return m, tea.Quit 191 192 case debugMsg: 193 m.debug = string(msg) 194 return m, nil 195 196 case quitMsg: 197 return m, tea.Quit 198 199 case progressMsg: 200 cmd := m.progress.SetPercent(float64(msg)) 201 return m, cmd 202 203 // FrameMsg is sent when the progress bar wants to animate itself 204 case progress.FrameMsg: 205 progressModel, cmd := m.progress.Update(msg) 206 m.progress = progressModel.(progress.Model) 207 return m, cmd 208 209 default: 210 return m, nil 211 } 212 } 213 214 func (m Model) View() string { 215 if m.Err != nil { 216 return "Error downloading: " + m.Err.Error() + "\n" 217 } 218 pad := strings.Repeat(" ", padding) 219 220 almostThere := "" 221 if m.progress.Percent() == 1.0 { 222 almostThere = pad + "Finishing download verification. " 223 } 224 return "\n" + 225 pad + m.header + "\n\n" + 226 pad + m.progress.View() + "\n\n" + 227 almostThere + 228 pad + "Press ESC to abort download.\n\n" + 229 pad + m.debug 230 }