From fb6cbe435c41beefedf227b4b4d79e4aee628b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Suszy=C5=84ski?= Date: Mon, 3 Apr 2023 10:37:36 +0200 Subject: [PATCH] Tui with bubbletea --- .golangci.yaml | 4 + go.mod | 18 ++- go.sum | 50 ++++++++ go.work.sum | 7 +- pkg/ghet/download/action.go | 2 + pkg/ghet/download/download.go | 42 ++++-- pkg/ghet/download/plan.go | 74 ++++++++--- pkg/github/assets.go | 3 + pkg/output/context.go | 2 +- pkg/output/logger.go | 2 +- pkg/output/tui/print.go | 64 +++++++++ pkg/output/tui/progress.go | 235 ++++++++++++++++++++++++++++++++++ pkg/output/tui/runnable.go | 5 + pkg/output/tui/spinner.go | 85 ++++++++++++ pkg/output/tui/widgets.go | 34 +++++ 15 files changed, 595 insertions(+), 32 deletions(-) create mode 100644 pkg/output/tui/print.go create mode 100644 pkg/output/tui/progress.go create mode 100644 pkg/output/tui/runnable.go create mode 100644 pkg/output/tui/spinner.go create mode 100644 pkg/output/tui/widgets.go diff --git a/.golangci.yaml b/.golangci.yaml index 2ee4675..343aa51 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -29,6 +29,7 @@ linters: - ireturn - varnamelen - exhaustruct + - contextcheck issues: exclude-rules: @@ -37,6 +38,9 @@ issues: - wrapcheck linters-settings: + wrapcheck: + ignorePackageGlobs: + - "github.com/cardil/ghet/*" gomoddirectives: # List of allowed `replace` directives. Default is empty. replace-allow-list: [] diff --git a/go.mod b/go.mod index 9151e6c..96661f8 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.19 require ( github.com/1set/gut v0.0.0-20201117175203-a82363231997 + github.com/charmbracelet/bubbles v0.15.0 github.com/go-eden/slf4go v1.1.2 github.com/go-eden/slf4go-zap v0.0.0-20230228024123-c74fa79d28f0 github.com/google/go-github/v48 v48.1.0 @@ -15,12 +16,16 @@ require ( github.com/wavesoftware/go-commandline v1.0.0 go.uber.org/zap v1.24.0 golang.org/x/oauth2 v0.2.0 - golang.org/x/sys v0.2.0 + golang.org/x/term v0.2.0 sigs.k8s.io/yaml v1.3.0 ) require ( + github.com/aymanbagabas/go-osc52 v1.0.3 // indirect github.com/benbjohnson/clock v1.3.0 // indirect + github.com/charmbracelet/bubbletea v0.23.1 // indirect + github.com/charmbracelet/lipgloss v0.6.0 // indirect + github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-eden/common v0.1.14 // indirect github.com/go-eden/routine v1.0.2 // indirect @@ -28,13 +33,24 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/kr/pretty v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.13.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/wavesoftware/go-retcode v1.0.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect golang.org/x/net v0.2.0 // indirect + golang.org/x/sys v0.2.0 // indirect + golang.org/x/text v0.4.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect diff --git a/go.sum b/go.sum index 5766a77..9fc3cfa 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,20 @@ github.com/1set/gut v0.0.0-20201117175203-a82363231997 h1:za2jSkE1Rx56hTzBko3ZZ4gA/nq+rA/jVovWuAF4jyo= github.com/1set/gut v0.0.0-20201117175203-a82363231997/go.mod h1:DpCCAL0dgBMQdiqPUIIRpdU9zNcIZwJjW+L/8Mb30mw= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= +github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= +github.com/charmbracelet/bubbletea v0.23.1 h1:CYdteX1wCiCzKNUlwm25ZHBIc1GXlYFyUIte8WPvhck= +github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= +github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -39,12 +51,39 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= +github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -94,14 +133,25 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/go.work.sum b/go.work.sum index eecef19..e6f02c4 100644 --- a/go.work.sum +++ b/go.work.sum @@ -357,6 +357,7 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a h1:pv34s756C4pEXnjgPfGYgdhg/ZdajGhyOvzx8k+23nw= github.com/ashanbrown/forbidigo v1.2.0 h1:RMlEFupPCxQ1IogYOQUnIQwGEUGK8g5vAPMRyJoSxbc= github.com/ashanbrown/makezero v0.0.0-20210520155254-b6261585ddde h1:YOsoVXsZQPA9aOTy1g0lAJv5VzZUvwQuZqug8XPeqfM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/aws/aws-lambda-go v1.13.3 h1:SuCy7H3NLyp+1Mrfp+m80jcbi9KYWAs9/BXwppwRDzY= github.com/aws/aws-sdk-go v1.44.76 h1:5e8yGO/XeNYKckOjpBKUd5wStf0So3CrQIiOMCVLpOI= github.com/aws/aws-sdk-go v1.44.114 h1:plIkWc/RsHr3DXBj4MEw9sEW4CcL/e2ryokc+CKyq1I= @@ -382,7 +383,6 @@ github.com/bazelbuild/bazelisk v1.13.2 h1:SpigbUorngcfDULmft0WkdrYVCqqqsFPEW4hm8 github.com/bazelbuild/rules_go v0.34.0 h1:cmObMtgIOaEU944SqXtJ9DnlS8IPGGa7pdRnsrpQzXM= github.com/beeker1121/goque v1.0.3-0.20191103205551-d618510128af h1:XbgLdZvVbWsK9HAhAYOp6rksTAdOVYDBQtGSVOLlJrw= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= github.com/bits-and-blooms/bitset v1.2.0 h1:Kn4yilvwNtMACtf1eYDlG8H77R07mZSPbMjLyS07ChA= @@ -415,6 +415,7 @@ github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8 github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk= github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054 h1:uH66TXeswKn5PW5zdZ39xEwfS9an067BirqA+P4QaLI= github.com/charithe/durationcheck v0.0.9 h1:mPP4ucLrf/rKZiIG/a9IPXHGlh8p4CzgpyTy6EEutYk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/chavacava/garif v0.0.0-20210405164556-e8a0a408d6af h1:spmv8nSH9h5oCQf40jt/ufBCt9j0/58u4G+rkeMqXGI= github.com/checkpoint-restore/go-criu/v4 v4.1.0 h1:WW2B2uxx9KWF6bGlHqhm8Okiafwwx7Y2kcpn8lCpjgo= github.com/checkpoint-restore/go-criu/v5 v5.3.0 h1:wpFFOoomK3389ue2lAb0Boag6XPht5QYpipxmSNL4d8= @@ -440,7 +441,6 @@ github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL github.com/containerd/aufs v1.0.0 h1:2oeJiwX5HstO7shSrPZjrohJZLzK36wvpdmzDRkL/LY= github.com/containerd/btrfs v1.0.0 h1:osn1exbzdub9L5SouXO5swW4ea/xVdJZ3wokxN5GrnA= github.com/containerd/cgroups v1.0.3 h1:ADZftAkglvCiD44c77s5YmMqaP2pzVCFZvBmAlBdAP4= -github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/containerd v1.6.6 h1:xJNPhbrmz8xAMDNoVjHy9YHtWwEQNS+CDkcIRh7t8Y0= github.com/containerd/continuity v0.2.2 h1:QSqfxcn8c+12slxwu00AtzXrsami0MJb/MQs9lOLHLA= github.com/containerd/fifo v1.0.0 h1:6PirWBr9/L7GDamKr+XM0IeUFXu5mf3M/BPpH9gaLBU= @@ -918,7 +918,6 @@ github.com/quasilyte/go-ruleguard/rules v0.0.0-20210428214800-545e0d2e0bf7 h1:cR github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY= github.com/qur/ar v0.0.0-20130629153254-282534b91770 h1:A6sXY4zAECrW5Obx41PVMGr4kOw1rd1kmwcHa5M0dTg= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rs/cors v1.8.2 h1:KCooALfAYGs415Cwu5ABvv9n9509fSiG5SQJn/AQo4U= github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= @@ -932,6 +931,7 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1 h1:ZFfeKAhIQiiOrQaI3/znw0gOmYpO28Tcu1YaqMa/jtQ= github.com/sagikazarmark/crypt v0.6.0 h1:REOEXCs/NFY/1jOCEouMuT4zEniE5YoXbvpC5X/TLF8= github.com/sagikazarmark/crypt v0.8.0 h1:xtk0uUHVWVsRBdEUGYBym4CXbcllXky2M7Qlwsf8C0Y= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da h1:p3Vo3i64TCLY7gIfzeQaUJ+kppEO5WQG3cL8iE8tGHU= github.com/sanposhiho/wastedassign/v2 v2.0.6 h1:+6/hQIHKNJAUixEj6EmOngGIisyeI+T3335lYTyxRoA= github.com/sassoftware/go-rpmutils v0.1.1 h1:ZHMXpGoMHL/cpQJ1byyhEBW73TDtpNq4inYa9M4FxyY= @@ -1125,7 +1125,6 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= diff --git a/pkg/ghet/download/action.go b/pkg/ghet/download/action.go index 23d86eb..405c46d 100644 --- a/pkg/ghet/download/action.go +++ b/pkg/ghet/download/action.go @@ -4,6 +4,7 @@ import ( "context" "github.com/cardil/ghet/pkg/output" + "github.com/cardil/ghet/pkg/output/tui" slog "github.com/go-eden/slf4go" "github.com/pkg/errors" ) @@ -13,6 +14,7 @@ func Action(ctx context.Context, args Args) error { "owner": args.Owner, "repo": args.Repo, }) + ctx = tui.EnsureWidgets(ctx) pl, err := CreatePlan(ctx, args) if err != nil { return errors.WithStack(err) diff --git a/pkg/ghet/download/download.go b/pkg/ghet/download/download.go index 3f35d7e..9f3c9e3 100644 --- a/pkg/ghet/download/download.go +++ b/pkg/ghet/download/download.go @@ -7,18 +7,29 @@ import ( "net/http" "os" "path" + "strings" "github.com/1set/gut/yos" "github.com/cardil/ghet/pkg/metadata" "github.com/cardil/ghet/pkg/output" + "github.com/cardil/ghet/pkg/output/tui" slog "github.com/go-eden/slf4go" "github.com/kirsle/configdir" "github.com/pkg/errors" ) -const executableMode = 0o755 +const ( + executableMode = 0o750 +) + +type assetInfo struct { + Asset + number int + total int + longestName int +} -func downloadAsset(ctx context.Context, asset Asset, args Args) error { +func downloadAsset(ctx context.Context, asset assetInfo, args Args) error { l := output.LoggerFrom(ctx).WithFields(slog.Fields{ "asset": asset.Name, }) @@ -31,7 +42,7 @@ func downloadAsset(ctx context.Context, asset Asset, args Args) error { if fileExists(l, cachePath, asset.Size) { l.WithFields(slog.Fields{"cachePath": cachePath}). Debug("Asset already downloaded") - return copyFile(cachePath, asset, args) + return copyFile(cachePath, asset.Asset, args) } l.Debug("Downloading asset") @@ -53,17 +64,32 @@ func downloadAsset(ctx context.Context, asset Asset, args Args) error { out, err := os.Create(cachePath) if err != nil { - return err + return errors.WithStack(err) } defer out.Close() - _, err = io.Copy(out, resp.Body) - if err != nil { - return errors.WithStack(err) + + format := "📥 %d/%d %s" + progress := tui.WidgetsFrom(ctx).NewProgress(ctx, asset.Size, tui.Message{ + Text: fmt.Sprintf(format, asset.number, asset.total, asset.Name), + Size: len(fmt.Sprintf(format, asset.total, asset.total, strings.Repeat("x", asset.longestName))), + }) + if perr := progress.With(func(pc tui.ProgressControl) error { + _, err = io.Copy(out, io.TeeReader(resp.Body, pc)) + if err != nil { + pc.Error(err) + return errors.WithStack(err) + } + return nil + }); perr != nil { + return perr } - return copyFile(cachePath, asset, args) + return copyFile(cachePath, asset.Asset, args) } func copyFile(cachePath string, asset Asset, args Args) error { + if err := os.MkdirAll(args.Destination, executableMode); err != nil { + return errors.WithStack(err) + } bin := path.Join(args.Destination, args.Asset.FileName.ToString()) if err := yos.MoveFile(cachePath, bin); err != nil { return errors.WithStack(err) diff --git a/pkg/ghet/download/plan.go b/pkg/ghet/download/plan.go index 43627d4..cab82c8 100644 --- a/pkg/ghet/download/plan.go +++ b/pkg/ghet/download/plan.go @@ -2,13 +2,16 @@ package download import ( "context" + "fmt" "strings" pkggithub "github.com/cardil/ghet/pkg/github" githubapi "github.com/cardil/ghet/pkg/github/api" "github.com/cardil/ghet/pkg/output" + "github.com/cardil/ghet/pkg/output/tui" slog "github.com/go-eden/slf4go" "github.com/google/go-github/v48/github" + "github.com/gookit/color" "github.com/pkg/errors" ) @@ -38,22 +41,18 @@ func CreatePlan(ctx context.Context, args Args) (*Plan, error) { r *github.Response err error ) - if args.Tag == pkggithub.LatestTag { - log.Debug("Getting latest release") - rr, r, err = client.Repositories.GetLatestRelease(ctx, args.Owner, args.Repo) - if err != nil { - return nil, errors.WithStack(err) - } - args.Tag = rr.GetTagName() - } else { - log.WithFields(slog.Fields{"tag": args.Tag}). - Debug("Getting release") - rr, r, err = client.Repositories.GetReleaseByTag(ctx, - args.Owner, args.Repo, args.Tag) - if err != nil { - return nil, errors.WithStack(err) - } + widgets := tui.WidgetsFrom(ctx) + spin := widgets.NewSpinner(ctx, + fmt.Sprintf("⛳️ Getting information about %s release", + color.Cyan.Sprintf(args.Tag)), + ) + if err = spin.With(func(spinner tui.Spinner) error { + rr, r, err = fetchRelease(ctx, args, client) + return err + }); err != nil { + return nil, err } + log.WithFields(slog.Fields{ "response": r, "release": rr, @@ -78,6 +77,7 @@ func CreatePlan(ctx context.Context, args Args) (*Plan, error) { } log.WithFields(slog.Fields{"assets": len(assets)}). Debug("Plan created") + widgets.Printf(ctx, "🎉 Found %s matching assets", color.Cyan.Sprint(len(assets))) return &Plan{Assets: assets}, nil } @@ -86,14 +86,54 @@ func (p Plan) Download(ctx context.Context, args Args) error { "owner": args.Owner, "repo": args.Repo, }) + longestName := 0 for _, asset := range p.Assets { - if err := downloadAsset(ctx, asset, args); err != nil { - return errors.WithStack(err) + nameLen := len(asset.Name) + if nameLen > longestName { + longestName = nameLen + } + } + for i, asset := range p.Assets { + ai := assetInfo{ + Asset: asset, + number: i + 1, + total: len(p.Assets), + longestName: longestName, + } + if err := downloadAsset(ctx, ai, args); err != nil { + return err } } return nil } +func fetchRelease( + ctx context.Context, args Args, + client *github.Client, +) (*github.RepositoryRelease, *github.Response, error) { + var ( + err error + rr *github.RepositoryRelease + r *github.Response + ) + log := output.LoggerFrom(ctx) + if args.Tag == pkggithub.LatestTag { + log.Debug("Getting latest release") + if rr, r, err = client.Repositories.GetLatestRelease(ctx, args.Owner, args.Repo); err != nil { + return nil, nil, errors.WithStack(err) + } + args.Tag = rr.GetTagName() + } else { + log.WithFields(slog.Fields{"tag": args.Tag}). + Debug("Getting release") + if rr, r, err = client.Repositories.GetReleaseByTag(ctx, + args.Owner, args.Repo, args.Tag); err != nil { + return nil, nil, errors.WithStack(err) + } + } + return rr, r, nil +} + func assetMatches(asset *github.ReleaseAsset, args Args) bool { name := asset.GetName() return (name == args.Checksums.ToString()) || diff --git a/pkg/github/assets.go b/pkg/github/assets.go index e2e343e..a9f2750 100644 --- a/pkg/github/assets.go +++ b/pkg/github/assets.go @@ -11,6 +11,9 @@ type FileName struct { } func (n FileName) ToString() string { + if n.Extension == "" { + return n.BaseName + } joiner := "." if strings.HasPrefix(n.Extension, ".") { joiner = "" diff --git a/pkg/output/context.go b/pkg/output/context.go index 9c8b5e6..22f85d9 100644 --- a/pkg/output/context.go +++ b/pkg/output/context.go @@ -4,7 +4,7 @@ import "context" type printerKey struct{} -func FromContext(ctx context.Context) Printer { +func PrinterFrom(ctx context.Context) Printer { p, ok := ctx.Value(printerKey{}).(Printer) if !ok { return defaultPrinter() diff --git a/pkg/output/logger.go b/pkg/output/logger.go index 979d074..663abd1 100644 --- a/pkg/output/logger.go +++ b/pkg/output/logger.go @@ -76,7 +76,7 @@ func createFileLogger(ctx context.Context) *zap.Logger { } func createDefaultLogger(ctx context.Context) *zap.Logger { - prtr := FromContext(ctx) + prtr := PrinterFrom(ctx) errout := prtr.ErrOrStderr() ec := zap.NewDevelopmentEncoderConfig() ec.EncodeLevel = zapcore.CapitalColorLevelEncoder diff --git a/pkg/output/tui/print.go b/pkg/output/tui/print.go new file mode 100644 index 0000000..2ddada1 --- /dev/null +++ b/pkg/output/tui/print.go @@ -0,0 +1,64 @@ +package tui + +import ( + "context" + "strings" + + "github.com/cardil/ghet/pkg/output" + "github.com/charmbracelet/lipgloss" +) + +type PrintfFunc func(ctx context.Context, format string, a ...any) + +func FmtPrintfFunc(ctx context.Context, format string, a ...any) { + printer := output.PrinterFrom(ctx) + if !strings.HasSuffix(format, "\n") { + format += "\n" + } + printer.Printf(format, a...) +} + +var _ PrintfFunc = FmtPrintfFunc + +type Message struct { + Text string + Size int +} + +func (m Message) BoundingBoxSize() int { + mSize := m.TextSize() + if mSize < m.Size { + mSize = m.Size + } + return mSize +} + +func (m Message) TextSize() int { + return len(m.Text) +} + +func helpStyle(str string) string { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render(str) +} + +type humanByteSize struct { + num float64 + unit string +} + +func humanizeBytes(bytes float64, unitSuffix string) humanByteSize { + num := bytes + units := []string{ + "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", + } + i := 0 + const kilo = 1024 + for num > kilo && i < len(units)-1 { + num /= kilo + i++ + } + return humanByteSize{ + num: num, + unit: units[i] + unitSuffix, + } +} diff --git a/pkg/output/tui/progress.go b/pkg/output/tui/progress.go new file mode 100644 index 0000000..f6ea47a --- /dev/null +++ b/pkg/output/tui/progress.go @@ -0,0 +1,235 @@ +package tui + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/cardil/ghet/pkg/output" + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" +) + +const speedInterval = time.Second / 5 + +type NewProgressFunc func(ctx context.Context, totalSize int, message Message) Progress + +type Progress interface { + Runnable[ProgressControl] +} + +type ProgressControl interface { + io.Writer + Error(err error) +} + +func NewBubbleProgress(ctx context.Context, totalSize int, message Message) Progress { + return &BubbleProgress{ + InputOutput: output.PrinterFrom(ctx), + TotalSize: totalSize, + Message: message, + } +} + +var _ NewProgressFunc = NewBubbleProgress + +type BubbleProgress struct { + output.InputOutput + Message + TotalSize int + + prog progress.Model + tea *tea.Program + downloaded int + speed int + prevSpeed []int + err error + ended chan struct{} +} + +func (b *BubbleProgress) With(fn func(ProgressControl) error) error { + b.start() + defer b.stop() + return fn(b) +} + +func (b *BubbleProgress) Error(err error) { + b.err = err + b.tea.Send(tea.Quit()) +} + +func (b *BubbleProgress) Write(bytes []byte) (int, error) { + if b.err != nil { + return 0, b.err + } + noOfBytes := len(bytes) + b.downloaded += noOfBytes + b.speed += noOfBytes + if b.TotalSize > 0 { + percent := float64(b.downloaded) / float64(b.TotalSize) + b.onProgress(percent) + } + return noOfBytes, nil +} + +func (b *BubbleProgress) Init() tea.Cmd { + return b.tickSpeed() +} + +func (b *BubbleProgress) View() string { + return b.display(b.prog.View()) + + "\n" + helpStyle("Press Ctrl+C to cancel") +} + +func (b *BubbleProgress) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + handle := bubbleProgressHandler{b} + switch event := msg.(type) { + case tea.WindowSizeMsg: + return handle.windowSize(event) + + case tea.KeyMsg: + return handle.keyPressed(event) + + case speedChange: + return handle.speedChange() + + case percentChange: + return handle.percentChange(event) + + // FrameMsg is sent when the progress bar wants to animate itself + case progress.FrameMsg: + return handle.progressFrame(event) + + default: + return b, nil + } +} + +type bubbleProgressHandler struct { + *BubbleProgress +} + +func (b bubbleProgressHandler) windowSize(event tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + const percentLen = 4 + b.prog.Width = event.Width - len(b.display("")) + percentLen + return b, nil +} + +func (b bubbleProgressHandler) keyPressed(event tea.KeyMsg) (tea.Model, tea.Cmd) { + if event.Type == tea.KeyCtrlC { + b.err = context.Canceled + return b, tea.Quit + } + return b, nil +} + +func (b bubbleProgressHandler) speedChange() (tea.Model, tea.Cmd) { + b.prevSpeed = append(b.prevSpeed, b.speed) + const speedAvgAmount = 4 + if len(b.prevSpeed) > speedAvgAmount { + b.prevSpeed = b.prevSpeed[1:] + } + b.speed = 0 + if b.downloaded < b.TotalSize { + return b, b.tickSpeed() + } + return b, nil +} + +func (b bubbleProgressHandler) percentChange(event percentChange) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + cmds = append(cmds, b.prog.SetPercent(float64(event))) + + if event >= 1.0 { + cmds = append(cmds, tea.Sequence(finalPause(), tea.Quit)) + } + + return b, tea.Batch(cmds...) +} + +func (b bubbleProgressHandler) progressFrame(event progress.FrameMsg) (tea.Model, tea.Cmd) { + progressModel, cmd := b.prog.Update(event) + if m, ok := progressModel.(progress.Model); ok { + b.prog = m + } + return b, cmd +} + +func (b *BubbleProgress) display(bar string) string { + const padding = 2 + const pad = " ⋮ " + paddingLen := padding + b.Message.BoundingBoxSize() - b.Message.TextSize() + titlePad := strings.Repeat(" ", paddingLen) + total := humanizeBytes(float64(b.TotalSize), "") + totalFmt := fmt.Sprintf("%6.2f %-3s", total.num, total.unit) + return b.Message.Text + titlePad + bar + pad + b.speedFormatted() + + pad + totalFmt +} + +func (b *BubbleProgress) speedFormatted() string { + s := humanizeBytes(b.speedPerSecond(), "/s") + return fmt.Sprintf("%6.2f %-5s", s.num, s.unit) +} + +func (b *BubbleProgress) speedPerSecond() float64 { + speed := 0. + for _, s := range b.prevSpeed { + speed += float64(s) + } + if len(b.prevSpeed) > 0 { + speed /= float64(len(b.prevSpeed)) + } + return speed / float64(speedInterval.Microseconds()) * + float64(time.Second.Microseconds()) +} + +func (b *BubbleProgress) tickSpeed() tea.Cmd { + return tea.Every(speedInterval, func(ti time.Time) tea.Msg { + return speedChange{} + }) +} + +func (b *BubbleProgress) start() { + b.prog = progress.New(progress.WithDefaultGradient()) + b.tea = tea.NewProgram(b, + tea.WithInput(b.InOrStdin()), + tea.WithOutput(b.OutOrStdout()), + ) + b.ended = make(chan struct{}) + go func() { + t := b.tea + _, _ = t.Run() + b.ended <- struct{}{} + if err := t.ReleaseTerminal(); err != nil { + panic(err) + } + }() +} + +func (b *BubbleProgress) stop() { + if b.tea == nil { + return + } + + <-b.ended + b.tea = nil + b.ended = nil +} + +func (b *BubbleProgress) onProgress(percent float64) { + b.tea.Send(percentChange(percent)) +} + +func finalPause() tea.Cmd { + const pause = 500 * time.Millisecond + return tea.Tick(pause, func(_ time.Time) tea.Msg { + return nil + }) +} + +type percentChange float64 + +type speedChange struct{} diff --git a/pkg/output/tui/runnable.go b/pkg/output/tui/runnable.go new file mode 100644 index 0000000..83c5a22 --- /dev/null +++ b/pkg/output/tui/runnable.go @@ -0,0 +1,5 @@ +package tui + +type Runnable[T any] interface { + With(fn func(T) error) error +} diff --git a/pkg/output/tui/spinner.go b/pkg/output/tui/spinner.go new file mode 100644 index 0000000..238a79d --- /dev/null +++ b/pkg/output/tui/spinner.go @@ -0,0 +1,85 @@ +package tui + +import ( + "context" + "fmt" + + "github.com/cardil/ghet/pkg/output" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const spinnerColor = lipgloss.Color("205") + +var spinnerStyle = lipgloss.NewStyle().Foreground(spinnerColor) + +type NewSpinnerFunc func(ctx context.Context, message string) Spinner + +type Spinner interface { + Runnable[Spinner] +} + +func NewBubbleSpinner(ctx context.Context, message string) Spinner { + return &BubbleSpinner{ + InputOutput: output.PrinterFrom(ctx), + Message: message, + } +} + +var _ NewSpinnerFunc = NewBubbleSpinner + +type BubbleSpinner struct { + output.InputOutput + Message string + + spin spinner.Model + tea *tea.Program +} + +func (b *BubbleSpinner) With(fn func(Spinner) error) error { + b.start() + defer b.stop() + return fn(b) +} + +func (b *BubbleSpinner) Init() tea.Cmd { + return b.spin.Tick +} + +func (b *BubbleSpinner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m, c := b.spin.Update(msg) + b.spin = m + return b, c +} + +func (b *BubbleSpinner) View() string { + return fmt.Sprintf("%s %s", b.Message, b.spin.View()) +} + +func (b *BubbleSpinner) start() { + b.spin = spinner.New( + spinner.WithSpinner(spinner.Meter), + spinner.WithStyle(spinnerStyle), + ) + b.tea = tea.NewProgram(b, + tea.WithInput(b.InOrStdin()), + tea.WithOutput(b.OutOrStdout()), + ) + go func() { + t := b.tea + _, _ = t.Run() + _ = t.ReleaseTerminal() + }() +} + +func (b *BubbleSpinner) stop() { + if b.tea == nil { + return + } + b.tea.Quit() + b.tea = nil + b.spin = spinner.Model{} + endMsg := fmt.Sprintf("%s %s\n", b.Message, spinnerStyle.Render("Done")) + _, _ = b.OutOrStdout().Write([]byte(endMsg)) +} diff --git a/pkg/output/tui/widgets.go b/pkg/output/tui/widgets.go new file mode 100644 index 0000000..1c96e1d --- /dev/null +++ b/pkg/output/tui/widgets.go @@ -0,0 +1,34 @@ +package tui + +import "context" + +type Widgets struct { + NewSpinner NewSpinnerFunc + NewProgress NewProgressFunc + Printf PrintfFunc +} + +type widgetsKey struct{} + +func EnsureWidgets(ctx context.Context) context.Context { + return WithWidgets(ctx, defaultWidgets()) +} + +func WithWidgets(ctx context.Context, w *Widgets) context.Context { + return context.WithValue(ctx, widgetsKey{}, w) +} + +func WidgetsFrom(ctx context.Context) *Widgets { + if w, ok := ctx.Value(widgetsKey{}).(*Widgets); ok { + return w + } + return nil +} + +func defaultWidgets() *Widgets { + return &Widgets{ + NewSpinner: NewBubbleSpinner, + NewProgress: NewBubbleProgress, + Printf: FmtPrintfFunc, + } +}