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  }