forked from cashapp/hermit
-
Notifications
You must be signed in to change notification settings - Fork 0
/
ui.go
345 lines (315 loc) · 8.31 KB
/
ui.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
// Package ui provides the terminal UI formatter for Hermit.
//
// This encapsulates both logging and progress.
//
// Output will be cleared if the higher level Hermit operation is
// successful.
//
// Hermit progress is conveyed via a single progress bar at the bottom of
// its output, ala modern Ubuntu apt progress. The line below the progress
// bar will be the list of concurrent actions being run. The capacity of
// the progress bar will dynamically adjust as new tasks are added.
//
// The progress bar will use partial Unicode blocks:
// https://en.wikipedia.org/wiki/Block_Elements#Character_table
//
// Log output appears above the progress bar.
package ui
import (
"bytes"
"fmt"
"hash/fnv"
"io"
"os"
"os/signal"
"strings"
"syscall"
"golang.org/x/term"
"github.com/cashapp/hermit/errors"
)
// SyncWriter is an io.Writer that can be Sync()ed.
type SyncWriter interface {
io.Writer
Sync() error
}
// UI controls the display of logs and task progress.
type UI struct {
*loggingMixin
tty *os.File
width int
stdout SyncWriter
stderr SyncWriter
stdoutIsTTY bool
stderrIsTTY bool
haveProgress bool
size int64 // Total size of the progress bar. This dynamically changes as new operations are registered.
operations []*Task
state uint64
minlevel Level
progressBarEnabled bool
}
var _ Logger = &UI{}
// NewForTesting returns a new UI that writes all output to the returned bytes.Buffer.
func NewForTesting() (*UI, *bytes.Buffer) {
b := &bytes.Buffer{}
w := nopSyncer{b}
ui := New(LevelTrace, w, w, true, true)
return ui, b
}
// New creates a new UI.
func New(level Level, stdout, stderr SyncWriter, stdoutIsTTY, stderrIsTTY bool) *UI {
w := &UI{
tty: os.Stdout,
stdout: stdout,
stdoutIsTTY: stdoutIsTTY,
stderr: stderr,
stderrIsTTY: stderrIsTTY,
minlevel: level,
progressBarEnabled: true,
}
w.loggingMixin = &loggingMixin{
logWriter: logWriter{
level: LevelDebug,
logf: func(level Level, format string, args ...interface{}) {
w.logf(level, "", format, args...)
},
},
logf: w.logf,
}
w.updateWidth()
winch := make(chan os.Signal, 1)
go func() {
for range winch {
w.updateWidth()
}
}()
signal.Notify(winch, syscall.SIGWINCH)
return w
}
// SetLevel sets the UI's minimum log level.
func (w *UI) SetLevel(level Level) {
w.lock.Lock()
defer w.lock.Unlock()
w.minlevel = level
}
// SetProgressBarEnabled defines if we want to show the progress bar to the user
func (w *UI) SetProgressBarEnabled(enabled bool) {
w.lock.Lock()
defer w.lock.Unlock()
w.progressBarEnabled = enabled
}
// WillLog returns true if "level" will be logged.
func (w *UI) WillLog(level Level) bool {
w.lock.Lock()
defer w.lock.Unlock()
return w.minlevel.Visible(level)
}
// Task creates a new unstarted task.
//
// The resulting Task can be used as a ui.Logger.
//
// Task progress can be modified later.
func (w *UI) Task(task string) *Task {
return w.operation(task, "", 0, true)
}
// Progress creates a new task with progress indicator of size.
//
// The resulting Task can be used as a ui.Logger.
func (w *UI) Progress(task string, size int) *Task {
return w.operation(task, "", size, false)
}
// If "lazy" is true, the returned Task will not contribute to the progress indicator or task list.
func (w *UI) operation(task, subtask string, size int, lazy bool) *Task {
w.lock.Lock()
w.size += int64(size)
op := &Task{
loggingMixin: loggingMixin{
logWriter: logWriter{
level: w.level,
logf: func(level Level, format string, args ...interface{}) {
w.logf(level, "", format, args...)
},
},
task: task,
subtask: subtask,
logf: w.logf,
},
size: size,
started: !lazy,
w: w,
}
w.operations = append(w.operations, op)
w.lock.Unlock()
w.redrawProgress()
return op
}
// Clear the progress indicator.
func (w *UI) Clear() {
w.lock.Lock()
w.clearProgress()
_ = w.stdout.Sync()
w.lock.Unlock()
}
// Change the size of a Task.
func (w *UI) swapSize(oldSize, newSize int) {
w.lock.Lock()
defer w.lock.Unlock()
w.size += int64(newSize - oldSize)
}
func (w *UI) logf(level Level, label string, format string, args ...interface{}) {
if !w.minlevel.Visible(level) {
return
}
w.lock.Lock()
defer w.lock.Unlock()
// Whether to ANSI format the output.
ansi := w.stdoutIsTTY && level < LevelWarn || w.stderrIsTTY && level >= LevelWarn
// Format the message.
var msg string
if ansi {
msg += fmt.Sprintf("\033[1m%s", levelColor[level])
}
msg += level.String() + ":"
if label != "" {
msg += label + ":"
}
msg += " "
if ansi {
msg += fmt.Sprintf("\033[0m%s", levelColor[level])
msg += fmt.Sprintf(format, args...)
msg += "\033[0m\033[0K"
} else {
msg += fmt.Sprintf(format, args...)
}
w.clearProgress()
switch {
case w.stdoutIsTTY && level < LevelWarn:
fmt.Fprintf(w.stdout, "%s\n", msg)
case level >= LevelWarn:
fmt.Fprintf(w.stderr, "%s\n", msg)
}
if level == LevelFatal {
defer func() {
_ = w.stderr.Sync()
os.Exit(1)
}()
} else {
w.writeProgress(w.width)
}
}
func (w *UI) redrawProgress() {
w.lock.Lock()
defer w.lock.Unlock()
if state := w.operationState(); state == w.state {
return
} else { // nolint
w.state = state
}
w.clearProgress()
w.writeProgress(w.width)
}
// Internal only, does not acquire lock.
func (w *UI) clearProgress() {
if !w.progressBarEnabled || !w.stdoutIsTTY || !w.haveProgress {
return
}
// Clear previous progress indicator.
for i := 0; i < 2; i++ {
fmt.Fprintf(w.stdout, "\033[0A\033[2K\r") // Move up and clear line
}
}
// Internal only, does not acquire lock.
func (w *UI) writeProgress(width int) {
if !w.progressBarEnabled {
return
}
liveOperations := w.liveOperations()
w.haveProgress = len(liveOperations) > 0
if !w.haveProgress || !w.stdoutIsTTY {
return
}
// Collect progress status.
progress := 0
size := 0
complete := 1
for _, op := range liveOperations {
opprogress, opsize, _ := op.status()
if opprogress >= opsize {
complete++
}
progress += opprogress
size += opsize
}
// We want to have the count of tasks as 1/2 rather than 0/2; this avoids 3/2
if complete > len(liveOperations) {
complete = len(liveOperations)
}
// Format progress bar.
percent := float64(progress) / float64(size)
barsn := len(theme.bars)
columns := int(float64(width-15) * float64(barsn) * percent)
nofm := fmt.Sprintf("%d/%d", complete, len(liveOperations))
percentstr := fmt.Sprintf("%.1f%%", percent*100)
spaces := width - columns/barsn - 15
if spaces < 0 {
spaces = 0
}
fmt.Fprintf(w.stdout, "%s%s%s %-7s%6s\n", strings.Repeat(theme.fill, columns/barsn), theme.bars[columns%barsn], strings.Repeat(theme.blank, spaces), nofm, percentstr)
// Write operations bar.
for _, op := range liveOperations {
opprogress, opsize, _ := op.status()
if opprogress < opsize {
fmt.Fprintf(w.stdout, "\033[0m%s ", op.label())
}
}
fmt.Fprintf(w.stdout, "\033[0m\033[0K\n")
_ = w.stdout.Sync()
}
// Internal only, does not acquire lock.
func (w *UI) liveOperations() []*Task {
liveOperations := make([]*Task, 0, len(w.operations))
for _, op := range w.operations {
_, _, started := op.status()
if started {
liveOperations = append(liveOperations, op)
}
}
return liveOperations
}
// Internal only, does not acquire lock.
func (w *UI) operationState() uint64 {
h := fnv.New64a()
for _, op := range w.liveOperations() {
progress, size, started := op.status()
fmt.Fprintf(h, "%v:%v:%v\n", progress, size, started)
}
return h.Sum64()
}
func (w *UI) updateWidth() {
w.lock.Lock()
var err error
w.width, _, err = term.GetSize(int(w.tty.Fd()))
if err != nil || w.width < 20 { // Assume it's borked.
w.width = 80
}
w.lock.Unlock()
}
// Printf prints directly to the stdout without log formatting
func (w *UI) Printf(format string, args ...interface{}) {
w.lock.Lock()
fmt.Fprintf(w.stdout, format, args...)
w.lock.Unlock()
}
// Confirmation from the user with y/N options to proceed
func (w *UI) Confirmation(message string, args ...interface{}) (bool, error) {
w.lock.Lock()
defer w.lock.Unlock()
fmt.Fprintf(w.stdout, "hermit: "+message+"\n", args...)
s := ""
if _, err := fmt.Scan(&s); err != nil {
return false, errors.WithStack(err)
}
s = strings.TrimSpace(s)
s = strings.ToLower(s)
return s == "y" || s == "yes", nil
}