Skip to content

Commit feed5b8

Browse files
authored
Merge pull request #426 from lets-cli/agent-add-self-upgrade-pre
Keep self-upgrade progress interruptible
2 parents 2a7303c + 174f565 commit feed5b8

4 files changed

Lines changed: 27 additions & 266 deletions

File tree

docs/docs/changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ title: Changelog
88
* `[Added]` Add `checksum.files`, `checksum.sh`, and `checksum.persist` command checksum syntax while keeping the old checksum format compatible.
99
* `[Added]` Add `lets self upgrade --pre` to opt into upgrading to the latest prerelease.
1010
* `[Added]` Add `lets self fix` config migration command with `--dry-run` preview output for deprecated checksum syntax.
11-
* `[Fixed]` Allow `lets self upgrade` downloads to respond to Ctrl-C.
11+
* `[Fixed]` Keep download progress indicators from intercepting Ctrl-C during `lets self upgrade`.
1212
* `[Fixed]` Make checksum calculation respect command-level `work_dir` overrides.
1313
* `[Fixed]` Restore the release checkout after the GoReleaser dry run so prerelease publishing does not fail on a dirty `go.mod`.
1414

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ toolchain go1.26.0
66

77
require (
88
charm.land/bubbles/v2 v2.1.0
9-
charm.land/bubbletea/v2 v2.0.2
109
charm.land/lipgloss/v2 v2.0.2
1110
github.com/charmbracelet/colorprofile v0.4.2
1211
github.com/charmbracelet/x/ansi v0.11.6
@@ -28,6 +27,7 @@ require (
2827
)
2928

3029
require (
30+
charm.land/bubbletea/v2 v2.0.2 // indirect
3131
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
3232
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
3333
github.com/charmbracelet/harmonica v0.2.0 // indirect
@@ -69,7 +69,7 @@ require (
6969
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
7070
github.com/lithammer/dedent v1.1.0
7171
github.com/spf13/pflag v1.0.9
72-
golang.org/x/sys v0.45.0 // indirect
72+
golang.org/x/sys v0.45.0
7373
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
7474
gopkg.in/yaml.v3 v3.0.1
7575
)

internal/progressbar/progress.go

Lines changed: 5 additions & 242 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@ import (
88
"net/url"
99
"path"
1010
"strings"
11-
"sync"
1211
"time"
1312

1413
bubblesprogress "charm.land/bubbles/v2/progress"
15-
tea "charm.land/bubbletea/v2"
1614
"charm.land/lipgloss/v2"
1715
"github.com/charmbracelet/x/term"
1816
"github.com/lets-cli/lets/internal/fetch"
@@ -30,9 +28,7 @@ type Observer struct {
3028
writer io.Writer
3129
width int
3230
noColor bool
33-
animate bool
3431
throttle time.Duration
35-
finalPause time.Duration
3632
fillColor color.Color
3733
emptyColor color.Color
3834
now func() time.Time
@@ -73,12 +69,6 @@ func WithThrottle(throttle time.Duration) Option {
7369
}
7470
}
7571

76-
func WithFinalPause(finalPause time.Duration) Option {
77-
return func(observer *Observer) {
78-
observer.finalPause = finalPause
79-
}
80-
}
81-
8272
func WithNow(now func() time.Time) Option {
8373
return func(observer *Observer) {
8474
observer.now = now
@@ -87,12 +77,10 @@ func WithNow(now func() time.Time) Option {
8777

8878
func New(writer io.Writer, options ...Option) *Observer {
8979
observer := &Observer{
90-
writer: writer,
91-
width: detectWidth(writer),
92-
throttle: 100 * time.Millisecond,
93-
finalPause: 750 * time.Millisecond,
94-
animate: isTerminal(writer),
95-
now: time.Now,
80+
writer: writer,
81+
width: detectWidth(writer),
82+
throttle: 100 * time.Millisecond,
83+
now: time.Now,
9684
}
9785

9886
for _, option := range options {
@@ -111,10 +99,6 @@ func New(writer io.Writer, options ...Option) *Observer {
11199
}
112100

113101
func (o *Observer) Start(info fetch.ProgressInfo) fetch.ProgressTracker { //nolint:ireturn // Implements fetch.ProgressObserver.
114-
if info.TotalBytes > 0 && o.animate {
115-
return newAnimatedTracker(o, info)
116-
}
117-
118102
tracker := &manualTracker{
119103
observer: o,
120104
info: info,
@@ -243,226 +227,9 @@ func (t *manualTracker) progressModel(width int) bubblesprogress.Model {
243227
return model
244228
}
245229

246-
type animatedTracker struct {
247-
observer *Observer
248-
program *tea.Program
249-
done chan struct{}
250-
label string
251-
read int64
252-
total int64
253-
lastUpdate time.Time
254-
}
255-
256-
func newAnimatedTracker(observer *Observer, info fetch.ProgressInfo) *animatedTracker {
257-
ready := make(chan struct{})
258-
done := make(chan struct{})
259-
label := downloadLabel(info.URL)
260-
_, _ = fmt.Fprintln(observer.writer, labelLine("Downloading", label, observer.width))
261-
model := newProgressModel(observer, label, info.TotalBytes, ready)
262-
program := tea.NewProgram(
263-
model,
264-
tea.WithInput(nil),
265-
tea.WithOutput(observer.writer),
266-
tea.WithoutSignals(),
267-
)
268-
269-
tracker := &animatedTracker{
270-
observer: observer,
271-
program: program,
272-
done: done,
273-
label: label,
274-
total: info.TotalBytes,
275-
}
276-
277-
go func() {
278-
_, _ = program.Run()
279-
280-
close(done)
281-
}()
282-
283-
<-ready
284-
285-
return tracker
286-
}
287-
288-
func (t *animatedTracker) Add(n int64) {
289-
t.read += n
290-
291-
now := t.observer.now()
292-
if t.observer.throttle > 0 && !t.lastUpdate.IsZero() && now.Sub(t.lastUpdate) < t.observer.throttle {
293-
return
294-
}
295-
296-
t.program.Send(progressMsg{read: t.read, total: t.total})
297-
t.lastUpdate = now
298-
}
299-
300-
func (t *animatedTracker) Done(err error) {
301-
if err != nil {
302-
t.program.Send(progressErrMsg{})
303-
} else {
304-
t.program.Send(progressDoneMsg{read: t.read, total: t.total})
305-
}
306-
307-
<-t.done
308-
309-
if err == nil {
310-
_, _ = fmt.Fprintf(t.observer.writer, "%s\n", t.progressLine())
311-
}
312-
}
313-
314-
func (t *animatedTracker) progressLine() string {
315-
bar := t.progressModel().ViewAs(1)
316-
return fmt.Sprintf("%s 100%% %s/%s", bar, formatBytes(t.total), formatBytes(t.total))
317-
}
318-
319-
func (t *animatedTracker) progressModel() bubblesprogress.Model {
320-
model := bubblesprogress.New(
321-
bubblesprogress.WithWidth(barWidthForTerminal(t.observer.width)),
322-
bubblesprogress.WithoutPercentage(),
323-
bubblesprogress.WithFillCharacters('#', '-'),
324-
)
325-
if t.observer.noColor {
326-
model.FullColor = nil
327-
model.EmptyColor = nil
328-
} else {
329-
applyProgressColors(&model, t.observer.fillColor, t.observer.emptyColor)
330-
}
331-
332-
return model
333-
}
334-
335-
type progressMsg struct {
336-
read int64
337-
total int64
338-
}
339-
340-
type progressDoneMsg struct {
341-
read int64
342-
total int64
343-
}
344-
345-
type progressErrMsg struct{}
346-
347-
type progressQuitMsg struct{}
348-
349-
type progressModel struct {
350-
label string
351-
read int64
352-
total int64
353-
width int
354-
finalPause time.Duration
355-
ready chan struct{}
356-
readyOnce *sync.Once
357-
progress bubblesprogress.Model
358-
}
359-
360-
func newProgressModel(observer *Observer, label string, total int64, ready chan struct{}) progressModel {
361-
model := bubblesprogress.New(
362-
bubblesprogress.WithWidth(barWidthForTerminal(observer.width)),
363-
bubblesprogress.WithoutPercentage(),
364-
bubblesprogress.WithFillCharacters('#', '-'),
365-
)
366-
if observer.noColor {
367-
model.FullColor = nil
368-
model.EmptyColor = nil
369-
} else {
370-
applyProgressColors(&model, observer.fillColor, observer.emptyColor)
371-
}
372-
373-
return progressModel{
374-
label: label,
375-
total: total,
376-
width: observer.width,
377-
finalPause: observer.finalPause,
378-
ready: ready,
379-
readyOnce: &sync.Once{},
380-
progress: model,
381-
}
382-
}
383-
384-
func (m progressModel) Init() tea.Cmd {
385-
m.readyOnce.Do(func() {
386-
close(m.ready)
387-
})
388-
389-
return nil
390-
}
391-
392-
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:ireturn // Required by Bubble Tea's model interface.
393-
switch msg := msg.(type) {
394-
case tea.WindowSizeMsg:
395-
m.width = msg.Width
396-
m.progress.SetWidth(barWidthForTerminal(msg.Width))
397-
398-
return m, nil
399-
400-
case progressMsg:
401-
m.read = msg.read
402-
m.total = msg.total
403-
404-
return m, m.progress.SetPercent(m.percent())
405-
406-
case progressDoneMsg:
407-
m.read = msg.read
408-
m.total = msg.total
409-
410-
return m, tea.Batch(m.progress.SetPercent(1), m.quitAfterFinalPause())
411-
412-
case progressErrMsg:
413-
return m, tea.Quit
414-
415-
case progressQuitMsg:
416-
return m, tea.Quit
417-
418-
case bubblesprogress.FrameMsg:
419-
var cmd tea.Cmd
420-
421-
m.progress, cmd = m.progress.Update(msg)
422-
423-
return m, cmd
424-
425-
default:
426-
return m, nil
427-
}
428-
}
429-
430-
func (m progressModel) View() tea.View {
431-
return tea.NewView(m.progressLine())
432-
}
433-
434-
func (m progressModel) progressLine() string {
435-
return fmt.Sprintf("%s %3.0f%% %s/%s", m.progress.View(), m.percent()*100, formatBytes(m.read), formatBytes(m.total))
436-
}
437-
438-
func (m progressModel) percent() float64 {
439-
if m.total <= 0 {
440-
return 0
441-
}
442-
443-
return clamp(float64(m.read)/float64(m.total), 0, 1)
444-
}
445-
446-
func (m progressModel) quitAfterFinalPause() tea.Cmd {
447-
return tea.Tick(m.finalPause, func(time.Time) tea.Msg {
448-
return progressQuitMsg{}
449-
})
450-
}
451-
452-
func barWidthForTerminal(width int) int {
453-
suffixWidth := lipgloss.Width(" 100% 1023.9 KiB/1023.9 KiB")
454-
455-
spaceForBar := width - 1 - suffixWidth
456-
if spaceForBar < minBarWidth {
457-
return minBarWidth
458-
}
459-
460-
return min(maxBarWidth, max(minBarWidth, spaceForBar))
461-
}
462-
463230
func detectWidth(writer io.Writer) int {
464231
file, ok := writer.(term.File)
465-
if !ok || !isTerminal(writer) {
232+
if !ok || !util.IsTerminalWriter(writer) {
466233
return defaultWidth
467234
}
468235

@@ -474,10 +241,6 @@ func detectWidth(writer io.Writer) int {
474241
return width
475242
}
476243

477-
func isTerminal(writer io.Writer) bool {
478-
return util.IsTerminalWriter(writer)
479-
}
480-
481244
func applyProgressColors(model *bubblesprogress.Model, fill, empty color.Color) {
482245
if fill != nil {
483246
model.FullColor = fill

internal/progressbar/progress_test.go

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
func TestObserver(t *testing.T) {
1313
t.Run("renders unchanged downloading label for known size", func(t *testing.T) {
1414
var out bytes.Buffer
15-
observer := New(&out, WithWidth(120), WithNoColor(true), WithThrottle(0), WithFinalPause(0))
15+
observer := New(&out, WithWidth(120), WithNoColor(true), WithThrottle(0))
1616

1717
tracker := observer.Start(fetch.ProgressInfo{
1818
Kind: fetch.SourceRemoteConfig,
@@ -87,6 +87,24 @@ func TestObserver(t *testing.T) {
8787
t.Fatalf("expected add at throttle boundary to render")
8888
}
8989
})
90+
91+
t.Run("does not enable terminal keyboard modes", func(t *testing.T) {
92+
var out bytes.Buffer
93+
observer := New(&out, WithWidth(120), WithNoColor(true), WithThrottle(0))
94+
95+
tracker := observer.Start(fetch.ProgressInfo{
96+
Kind: fetch.SourceSelfUpdate,
97+
URL: "https://example.com/lets_Darwin_arm64.tar.gz",
98+
TotalBytes: 4,
99+
})
100+
tracker.Add(4)
101+
tracker.Done(nil)
102+
103+
got := out.String()
104+
if strings.Contains(got, "\033[=1;1u") || strings.Contains(got, "\033[>4;2m") {
105+
t.Fatalf("progress output must not change terminal keyboard modes, got %q", got)
106+
}
107+
})
90108
}
91109

92110
func TestFormatBytes(t *testing.T) {
@@ -110,23 +128,3 @@ func TestDownloadLabel(t *testing.T) {
110128
t.Fatalf("expected filename label, got %q", got)
111129
}
112130
}
113-
114-
func TestProgressModel(t *testing.T) {
115-
observer := New(&bytes.Buffer{}, WithWidth(120), WithNoColor(true), WithFinalPause(0))
116-
model := newProgressModel(observer, "lets.yaml", 1107, make(chan struct{}))
117-
118-
_, cmd := model.Update(progressMsg{read: 512, total: 1107})
119-
if cmd == nil {
120-
t.Fatal("expected progress update to return animation command")
121-
}
122-
123-
updated, _ := model.Update(progressDoneMsg{read: 1107, total: 1107})
124-
finalModel := updated.(progressModel)
125-
got := finalModel.View().Content
126-
if strings.Contains(got, "Downloaded") {
127-
t.Fatalf("did not expect label to change to Downloaded, got %q", got)
128-
}
129-
if !strings.Contains(got, "100% 1.1 KiB/1.1 KiB") {
130-
t.Fatalf("expected final progress status, got %q", got)
131-
}
132-
}

0 commit comments

Comments
 (0)