diff --git a/go.mod b/go.mod index 77db02b41..e0aeaba24 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 github.com/creack/pty v1.1.18 github.com/gabriel-vasile/mimetype v1.4.1 + github.com/gdamore/tcell/v2 v2.5.3 github.com/google/go-cmp v0.5.9 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/websocket v1.4.2 @@ -28,6 +29,7 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 github.com/opentracing/opentracing-go v1.1.0 + github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00 github.com/sourcegraph/jsonrpc2 v0.1.0 github.com/spf13/cobra v1.5.0 @@ -50,6 +52,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/fatih/color v1.7.0 // indirect + github.com/gdamore/encoding v1.0.0 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect @@ -63,7 +66,7 @@ require ( github.com/muesli/termenv v0.12.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect github.com/stretchr/objx v0.4.0 // indirect diff --git a/go.sum b/go.sum index 2b1ba41e2..6e9ec1689 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,10 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0= +github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -218,9 +222,12 @@ github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFSt 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0= +github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d/go.mod h1:YX2wUZOcJGOIycErz2s9KvDaP0jnWwRCirQMPLPpQ+Y= 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/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= +github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -370,12 +377,14 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/internal/codespaces/grpc/generate.md b/internal/codespaces/grpc/generate.md new file mode 100644 index 000000000..7ae1dcc1a --- /dev/null +++ b/internal/codespaces/grpc/generate.md @@ -0,0 +1,16 @@ +# Protocol Buffers for Codespaces + +Instructions for generating and adding gRPC protocol buffers. + +## Generate Protocol Buffers + +1. [Download `protoc`](https://grpc.io/docs/protoc-installation/) +2. [Download protocol compiler plugins for Go](https://grpc.io/docs/languages/go/quickstart/) +3. Run `./generate.sh` from the `internal/codespaces/grpc` directory + +## Add New Protocol Buffers + +1. Download a `.proto` contract from the service repo +2. Create a new directory and copy the `.proto` to it +3. Update `generate.sh` to include the include the new `.proto` +4. Follow the instructions to [Generate Protocol Buffers](#generate-protocol-buffers) diff --git a/internal/codespaces/grpc/generate.sh b/internal/codespaces/grpc/generate.sh new file mode 100755 index 000000000..0aff3868a --- /dev/null +++ b/internal/codespaces/grpc/generate.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +set -e + +if ! protoc --version; then + echo 'ERROR: protoc is not on your PATH' + exit 1 +fi +if ! protoc-gen-go --version; then + echo 'ERROR: protoc-gen-go is not on your PATH' + exit 1 +fi +if ! protoc-gen-go-grpc --version; then + echo 'ERROR: protoc-gen-go-grpc is not on your PATH' +fi + +function generate { + local contract="$1" + + protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract" + echo "Generated protocol buffers for $contract" +} + +generate jupyter/JupyterServerHostService.v1.proto + +echo 'Done!' diff --git a/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go index c48f3d0cf..88b33a50d 100644 --- a/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go +++ b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go @@ -1,8 +1,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.28.0 -// protoc v3.21.3 -// source: JupyterServerHostService.v1.proto +// protoc-gen-go v1.28.1 +// protoc v3.12.4 +// source: jupyter/JupyterServerHostService.v1.proto package jupyter @@ -29,7 +29,7 @@ type GetRunningServerRequest struct { func (x *GetRunningServerRequest) Reset() { *x = GetRunningServerRequest{} if protoimpl.UnsafeEnabled { - mi := &file_JupyterServerHostService_v1_proto_msgTypes[0] + mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -42,7 +42,7 @@ func (x *GetRunningServerRequest) String() string { func (*GetRunningServerRequest) ProtoMessage() {} func (x *GetRunningServerRequest) ProtoReflect() protoreflect.Message { - mi := &file_JupyterServerHostService_v1_proto_msgTypes[0] + mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -55,7 +55,7 @@ func (x *GetRunningServerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetRunningServerRequest.ProtoReflect.Descriptor instead. func (*GetRunningServerRequest) Descriptor() ([]byte, []int) { - return file_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{0} + return file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{0} } type GetRunningServerResponse struct { @@ -72,7 +72,7 @@ type GetRunningServerResponse struct { func (x *GetRunningServerResponse) Reset() { *x = GetRunningServerResponse{} if protoimpl.UnsafeEnabled { - mi := &file_JupyterServerHostService_v1_proto_msgTypes[1] + mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -85,7 +85,7 @@ func (x *GetRunningServerResponse) String() string { func (*GetRunningServerResponse) ProtoMessage() {} func (x *GetRunningServerResponse) ProtoReflect() protoreflect.Message { - mi := &file_JupyterServerHostService_v1_proto_msgTypes[1] + mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -98,7 +98,7 @@ func (x *GetRunningServerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetRunningServerResponse.ProtoReflect.Descriptor instead. func (*GetRunningServerResponse) Descriptor() ([]byte, []int) { - return file_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{1} + return file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{1} } func (x *GetRunningServerResponse) GetResult() bool { @@ -129,57 +129,57 @@ func (x *GetRunningServerResponse) GetServerUrl() string { return "" } -var File_JupyterServerHostService_v1_proto protoreflect.FileDescriptor +var File_jupyter_JupyterServerHostService_v1_proto protoreflect.FileDescriptor -var file_JupyterServerHostService_v1_proto_rawDesc = []byte{ - 0x0a, 0x21, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, - 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x12, 0x2b, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, - 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, - 0x22, 0x19, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x7e, 0x0a, 0x18, 0x47, - 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, - 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, - 0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x0a, - 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x32, 0xb5, 0x01, 0x0a, 0x11, - 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, - 0x74, 0x12, 0x9f, 0x01, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x44, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, - 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x45, 0x2e, 0x43, - 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, - 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, - 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +var file_jupyter_JupyterServerHostService_v1_proto_rawDesc = []byte{ + 0x0a, 0x29, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x2f, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, + 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x2b, 0x43, 0x6f, 0x64, + 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, + 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x22, 0x19, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x52, + 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x22, 0x7e, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, + 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x16, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, + 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x55, 0x72, 0x6c, 0x32, 0xb5, 0x01, 0x0a, 0x11, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x12, 0x9f, 0x01, 0x0a, 0x10, 0x47, 0x65, + 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x44, + 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, + 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, + 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, + 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x45, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, + 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, + 0x2f, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( - file_JupyterServerHostService_v1_proto_rawDescOnce sync.Once - file_JupyterServerHostService_v1_proto_rawDescData = file_JupyterServerHostService_v1_proto_rawDesc + file_jupyter_JupyterServerHostService_v1_proto_rawDescOnce sync.Once + file_jupyter_JupyterServerHostService_v1_proto_rawDescData = file_jupyter_JupyterServerHostService_v1_proto_rawDesc ) -func file_JupyterServerHostService_v1_proto_rawDescGZIP() []byte { - file_JupyterServerHostService_v1_proto_rawDescOnce.Do(func() { - file_JupyterServerHostService_v1_proto_rawDescData = protoimpl.X.CompressGZIP(file_JupyterServerHostService_v1_proto_rawDescData) +func file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP() []byte { + file_jupyter_JupyterServerHostService_v1_proto_rawDescOnce.Do(func() { + file_jupyter_JupyterServerHostService_v1_proto_rawDescData = protoimpl.X.CompressGZIP(file_jupyter_JupyterServerHostService_v1_proto_rawDescData) }) - return file_JupyterServerHostService_v1_proto_rawDescData + return file_jupyter_JupyterServerHostService_v1_proto_rawDescData } -var file_JupyterServerHostService_v1_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_JupyterServerHostService_v1_proto_goTypes = []interface{}{ +var file_jupyter_JupyterServerHostService_v1_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_jupyter_JupyterServerHostService_v1_proto_goTypes = []interface{}{ (*GetRunningServerRequest)(nil), // 0: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest (*GetRunningServerResponse)(nil), // 1: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse } -var file_JupyterServerHostService_v1_proto_depIdxs = []int32{ +var file_jupyter_JupyterServerHostService_v1_proto_depIdxs = []int32{ 0, // 0: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:input_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest 1, // 1: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:output_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse 1, // [1:2] is the sub-list for method output_type @@ -189,13 +189,13 @@ var file_JupyterServerHostService_v1_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for field type_name } -func init() { file_JupyterServerHostService_v1_proto_init() } -func file_JupyterServerHostService_v1_proto_init() { - if File_JupyterServerHostService_v1_proto != nil { +func init() { file_jupyter_JupyterServerHostService_v1_proto_init() } +func file_jupyter_JupyterServerHostService_v1_proto_init() { + if File_jupyter_JupyterServerHostService_v1_proto != nil { return } if !protoimpl.UnsafeEnabled { - file_JupyterServerHostService_v1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetRunningServerRequest); i { case 0: return &v.state @@ -207,7 +207,7 @@ func file_JupyterServerHostService_v1_proto_init() { return nil } } - file_JupyterServerHostService_v1_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*GetRunningServerResponse); i { case 0: return &v.state @@ -224,18 +224,18 @@ func file_JupyterServerHostService_v1_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_JupyterServerHostService_v1_proto_rawDesc, + RawDescriptor: file_jupyter_JupyterServerHostService_v1_proto_rawDesc, NumEnums: 0, NumMessages: 2, NumExtensions: 0, NumServices: 1, }, - GoTypes: file_JupyterServerHostService_v1_proto_goTypes, - DependencyIndexes: file_JupyterServerHostService_v1_proto_depIdxs, - MessageInfos: file_JupyterServerHostService_v1_proto_msgTypes, + GoTypes: file_jupyter_JupyterServerHostService_v1_proto_goTypes, + DependencyIndexes: file_jupyter_JupyterServerHostService_v1_proto_depIdxs, + MessageInfos: file_jupyter_JupyterServerHostService_v1_proto_msgTypes, }.Build() - File_JupyterServerHostService_v1_proto = out.File - file_JupyterServerHostService_v1_proto_rawDesc = nil - file_JupyterServerHostService_v1_proto_goTypes = nil - file_JupyterServerHostService_v1_proto_depIdxs = nil + File_jupyter_JupyterServerHostService_v1_proto = out.File + file_jupyter_JupyterServerHostService_v1_proto_rawDesc = nil + file_jupyter_JupyterServerHostService_v1_proto_goTypes = nil + file_jupyter_JupyterServerHostService_v1_proto_depIdxs = nil } diff --git a/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go index 8ba53014d..789a9df91 100644 --- a/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go +++ b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go @@ -1,8 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.2.0 -// - protoc v3.21.3 -// source: JupyterServerHostService.v1.proto +// - protoc v3.12.4 +// source: jupyter/JupyterServerHostService.v1.proto package jupyter @@ -101,5 +101,5 @@ var JupyterServerHost_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "JupyterServerHostService.v1.proto", + Metadata: "jupyter/JupyterServerHostService.v1.proto", } diff --git a/pkg/cmd/extension/browse/browse.go b/pkg/cmd/extension/browse/browse.go new file mode 100644 index 000000000..be605ef9c --- /dev/null +++ b/pkg/cmd/extension/browse/browse.go @@ -0,0 +1,527 @@ +package browse + +import ( + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/charmbracelet/glamour" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/search" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/spf13/cobra" +) + +const pagingOffset = 24 + +type ExtBrowseOpts struct { + Cmd *cobra.Command + Browser ibrowser + IO *iostreams.IOStreams + Searcher search.Searcher + Em extensions.ExtensionManager + Client *http.Client + Logger *log.Logger + Cfg config.Config + Rg *readmeGetter + Debug bool +} + +type ibrowser interface { + Browse(string) error +} + +type uiRegistry struct { + // references to some of the heavily cross-referenced tview primitives. Not + // everything is in here because most things are just used once in one place + // and don't need to be easy to look up like this. + App *tview.Application + Outerflex *tview.Flex + List *tview.List + Readme *tview.TextView +} + +type extEntry struct { + URL string + Name string + FullName string + Installed bool + Official bool + description string +} + +func (e extEntry) Title() string { + var installed string + var official string + + if e.Installed { + installed = " [green](installed)" + } + + if e.Official { + official = " [yellow](official)" + } + + return fmt.Sprintf("%s%s%s", e.FullName, official, installed) +} + +func (e extEntry) Description() string { + if e.description == "" { + return "no description provided" + } + return e.description +} + +type extList struct { + ui uiRegistry + extEntries []extEntry + app *tview.Application + filter string + opts ExtBrowseOpts +} + +func newExtList(opts ExtBrowseOpts, ui uiRegistry, extEntries []extEntry) *extList { + ui.List.SetTitleColor(tcell.ColorWhite) + ui.List.SetSelectedTextColor(tcell.ColorBlack) + ui.List.SetSelectedBackgroundColor(tcell.ColorWhite) + ui.List.SetWrapAround(false) + ui.List.SetBorderPadding(1, 1, 1, 1) + + el := &extList{ + ui: ui, + extEntries: extEntries, + app: ui.App, + opts: opts, + } + + el.Reset() + return el +} + +func (el *extList) createModal() *tview.Modal { + m := tview.NewModal() + m.SetBackgroundColor(tcell.ColorPurple) + m.SetDoneFunc(func(_ int, _ string) { + el.ui.App.SetRoot(el.ui.Outerflex, true) + el.Refresh() + }) + + return m +} + +func (el *extList) InstallSelected() { + ee, ix := el.FindSelected() + if ix < 0 { + el.opts.Logger.Println("failed to find selected entry") + return + } + repo, err := ghrepo.FromFullName(ee.FullName) + if err != nil { + el.opts.Logger.Println(fmt.Errorf("failed to install '%s't: %w", ee.FullName, err)) + return + } + + modal := el.createModal() + + modal.SetText(fmt.Sprintf("Installing %s...", ee.FullName)) + el.ui.App.SetRoot(modal, true) + // I could eliminate this with a goroutine but it seems to be working fine + el.app.ForceDraw() + err = el.opts.Em.Install(repo, "") + if err != nil { + modal.SetText(fmt.Sprintf("Failed to install %s: %s", ee.FullName, err.Error())) + } else { + modal.SetText(fmt.Sprintf("Installed %s!", ee.FullName)) + modal.AddButtons([]string{"ok"}) + el.ui.App.SetFocus(modal) + } + + el.toggleInstalled(ix) +} + +func (el *extList) RemoveSelected() { + ee, ix := el.FindSelected() + if ix < 0 { + el.opts.Logger.Println("failed to find selected extension") + return + } + + modal := el.createModal() + + modal.SetText(fmt.Sprintf("Removing %s...", ee.FullName)) + el.ui.App.SetRoot(modal, true) + // I could eliminate this with a goroutine but it seems to be working fine + el.ui.App.ForceDraw() + + err := el.opts.Em.Remove(strings.TrimPrefix(ee.Name, "gh-")) + if err != nil { + modal.SetText(fmt.Sprintf("Failed to remove %s: %s", ee.FullName, err.Error())) + } else { + modal.SetText(fmt.Sprintf("Removed %s.", ee.FullName)) + modal.AddButtons([]string{"ok"}) + el.ui.App.SetFocus(modal) + } + el.toggleInstalled(ix) +} + +func (el *extList) toggleInstalled(ix int) { + ee := el.extEntries[ix] + ee.Installed = !ee.Installed + el.extEntries[ix] = ee +} + +func (el *extList) Focus() { + el.app.SetFocus(el.ui.List) +} + +func (el *extList) Refresh() { + el.Reset() + el.Filter(el.filter) +} + +func (el *extList) Reset() { + el.ui.List.Clear() + for _, ee := range el.extEntries { + el.ui.List.AddItem(ee.Title(), ee.Description(), rune(0), func() {}) + } +} + +func (el *extList) PageDown() { + el.ui.List.SetCurrentItem(el.ui.List.GetCurrentItem() + pagingOffset) +} + +func (el *extList) PageUp() { + i := el.ui.List.GetCurrentItem() - pagingOffset + if i < 0 { + i = 0 + } + el.ui.List.SetCurrentItem(i) +} + +func (el *extList) ScrollDown() { + el.ui.List.SetCurrentItem(el.ui.List.GetCurrentItem() + 1) +} + +func (el *extList) ScrollUp() { + i := el.ui.List.GetCurrentItem() - 1 + if i < 0 { + i = 0 + } + el.ui.List.SetCurrentItem(i) +} + +func (el *extList) FindSelected() (extEntry, int) { + if el.ui.List.GetItemCount() == 0 { + return extEntry{}, -1 + } + title, desc := el.ui.List.GetItemText(el.ui.List.GetCurrentItem()) + for x, e := range el.extEntries { + if e.Title() == title && e.Description() == desc { + return e, x + } + } + return extEntry{}, -1 +} + +func (el *extList) Filter(text string) { + el.filter = text + if text == "" { + return + } + el.ui.List.Clear() + for _, ee := range el.extEntries { + if strings.Contains(ee.Title()+ee.Description(), text) { + el.ui.List.AddItem(ee.Title(), ee.Description(), rune(0), func() {}) + } + } +} + +func getSelectedReadme(opts ExtBrowseOpts, readme *tview.TextView, el *extList) (string, error) { + ee, ix := el.FindSelected() + if ix < 0 { + return "", errors.New("failed to find selected entry") + } + fullName := ee.FullName + rm, err := opts.Rg.Get(fullName) + if err != nil { + return "", err + } + + _, _, wrap, _ := readme.GetInnerRect() + + // using glamour directly because if I don't horrible things happen + renderer, err := glamour.NewTermRenderer( + glamour.WithStylePath("dark"), + glamour.WithWordWrap(wrap)) + if err != nil { + return "", err + } + rendered, err := renderer.Render(rm) + if err != nil { + return "", err + } + + return rendered, nil +} + +func getExtensions(opts ExtBrowseOpts) ([]extEntry, error) { + extEntries := []extEntry{} + + installed := opts.Em.List() + + result, err := opts.Searcher.Repositories(search.Query{ + Kind: search.KindRepositories, + Limit: 1000, + Qualifiers: search.Qualifiers{ + Topic: []string{"gh-extension"}, + }, + }) + if err != nil { + return extEntries, fmt.Errorf("failed to search for extensions: %w", err) + } + + host, _ := opts.Cfg.DefaultHost() + + for _, repo := range result.Items { + if !strings.HasPrefix(repo.Name, "gh-") { + continue + } + ee := extEntry{ + URL: "https://" + host + "/" + repo.FullName, + FullName: repo.FullName, + Name: repo.Name, + description: repo.Description, + } + for _, v := range installed { + // TODO consider a Repo() on Extension interface + var installedRepo string + if u, err := git.ParseURL(v.URL()); err == nil { + if r, err := ghrepo.FromURL(u); err == nil { + installedRepo = ghrepo.FullName(r) + } + } + if repo.FullName == installedRepo { + ee.Installed = true + } + } + if repo.Owner.Login == "cli" || repo.Owner.Login == "github" { + ee.Official = true + } + + extEntries = append(extEntries, ee) + } + + return extEntries, nil +} + +func ExtBrowse(opts ExtBrowseOpts) error { + if opts.Debug { + f, err := os.CreateTemp("", "extBrowse-*.txt") + if err != nil { + return err + } + defer os.Remove(f.Name()) + + opts.Logger = log.New(f, "", log.Lshortfile) + } else { + opts.Logger = log.New(io.Discard, "", 0) + } + + opts.IO.StartProgressIndicator() + extEntries, err := getExtensions(opts) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + opts.Rg = newReadmeGetter(opts.Client, time.Hour*24) + + app := tview.NewApplication() + + outerFlex := tview.NewFlex() + innerFlex := tview.NewFlex() + + header := tview.NewTextView().SetText(fmt.Sprintf("browsing %d gh extensions", len(extEntries))) + header.SetTextAlign(tview.AlignCenter).SetTextColor(tcell.ColorWhite) + + filter := tview.NewInputField().SetLabel("filter: ") + filter.SetFieldBackgroundColor(tcell.ColorGray) + filter.SetBorderPadding(0, 0, 20, 20) + + list := tview.NewList() + + readme := tview.NewTextView() + readme.SetBorderPadding(1, 1, 0, 1) + readme.SetBorder(true).SetBorderColor(tcell.ColorPurple) + + help := tview.NewTextView() + help.SetText( + "/: filter i/r: install/remove w: open in browser pgup/pgdn: scroll readme q: quit") + help.SetTextAlign(tview.AlignCenter) + + ui := uiRegistry{ + App: app, + Outerflex: outerFlex, + List: list, + } + + extList := newExtList(opts, ui, extEntries) + + loadSelectedReadme := func() { + rendered, err := getSelectedReadme(opts, readme, extList) + if err != nil { + opts.Logger.Println(err.Error()) + readme.SetText("unable to fetch readme :(") + return + } + + app.QueueUpdateDraw(func() { + readme.SetText("") + readme.SetDynamicColors(true) + + w := tview.ANSIWriter(readme) + _, _ = w.Write([]byte(rendered)) + + readme.ScrollToBeginning() + }) + } + + filter.SetChangedFunc(func(text string) { + extList.Filter(text) + go loadSelectedReadme() + }) + + filter.SetDoneFunc(func(key tcell.Key) { + switch key { + case tcell.KeyEnter: + extList.Focus() + case tcell.KeyEscape: + filter.SetText("") + extList.Reset() + extList.Focus() + } + }) + + innerFlex.SetDirection(tview.FlexColumn) + innerFlex.AddItem(list, 0, 1, true) + innerFlex.AddItem(readme, 0, 1, false) + + outerFlex.SetDirection(tview.FlexRow) + outerFlex.AddItem(header, 1, -1, false) + outerFlex.AddItem(filter, 1, -1, false) + outerFlex.AddItem(innerFlex, 0, 1, true) + outerFlex.AddItem(help, 1, -1, false) + + app.SetRoot(outerFlex, true) + + // Force fetching of initial readme by loading it just prior to the first + // draw. The callback is removed immediately after draw. + app.SetBeforeDrawFunc(func(_ tcell.Screen) bool { + go loadSelectedReadme() + return false // returning true would halt drawing which we do not want + }) + + app.SetAfterDrawFunc(func(_ tcell.Screen) { + app.SetBeforeDrawFunc(nil) + app.SetAfterDrawFunc(nil) + }) + + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if filter.HasFocus() { + return event + } + + switch event.Rune() { + case 'q': + app.Stop() + case 'k': + extList.ScrollUp() + readme.SetText("...fetching readme...") + go loadSelectedReadme() + case 'j': + extList.ScrollDown() + readme.SetText("...fetching readme...") + go loadSelectedReadme() + case 'w': + ee, ix := extList.FindSelected() + if ix < 0 { + opts.Logger.Println("failed to find selected entry") + return nil + } + err = opts.Browser.Browse(ee.URL) + if err != nil { + opts.Logger.Println(fmt.Errorf("could not open browser for '%s': %w", ee.URL, err)) + } + case 'i': + extList.InstallSelected() + case 'r': + extList.RemoveSelected() + case ' ': + // The shift check works on windows and not linux/mac: + if event.Modifiers()&tcell.ModShift != 0 { + extList.PageUp() + } else { + extList.PageDown() + } + go loadSelectedReadme() + case '/': + app.SetFocus(filter) + return nil + } + switch event.Key() { + case tcell.KeyUp: + extList.ScrollUp() + go loadSelectedReadme() + return nil + case tcell.KeyDown: + extList.ScrollDown() + go loadSelectedReadme() + return nil + case tcell.KeyEscape: + filter.SetText("") + extList.Reset() + case tcell.KeyCtrlSpace: + // The ctrl check works on windows/mac and not windows: + extList.PageUp() + go loadSelectedReadme() + case tcell.KeyCtrlJ: + extList.PageDown() + go loadSelectedReadme() + case tcell.KeyCtrlK: + extList.PageUp() + go loadSelectedReadme() + case tcell.KeyPgUp: + row, col := readme.GetScrollOffset() + if row > 0 { + readme.ScrollTo(row-2, col) + } + return nil + case tcell.KeyPgDn: + row, col := readme.GetScrollOffset() + readme.ScrollTo(row+2, col) + return nil + } + + return event + }) + + // Without this redirection, the git client inside of the extension manager + // will dump git output to the terminal. + opts.IO.ErrOut = io.Discard + + if err := app.Run(); err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/extension/browse/browse_test.go b/pkg/cmd/extension/browse/browse_test.go new file mode 100644 index 000000000..8cec46607 --- /dev/null +++ b/pkg/cmd/extension/browse/browse_test.go @@ -0,0 +1,366 @@ +package browse + +import ( + "encoding/base64" + "io" + "log" + "net/http" + "net/url" + "testing" + "time" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/repo/view" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/search" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func Test_getSelectedReadme(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + content := base64.StdEncoding.EncodeToString([]byte("lol")) + + reg.Register( + httpmock.REST("GET", "repos/cli/gh-cool/readme"), + httpmock.JSONResponse(view.RepoReadme{Content: content})) + + client := &http.Client{Transport: ®} + + rg := newReadmeGetter(client, time.Second) + opts := ExtBrowseOpts{ + Rg: rg, + } + readme := tview.NewTextView() + ui := uiRegistry{ + List: tview.NewList(), + } + extEntries := []extEntry{ + { + Name: "gh-cool", + FullName: "cli/gh-cool", + Installed: false, + Official: true, + description: "it's just cool ok", + }, + { + Name: "gh-screensaver", + FullName: "vilmibm/gh-screensaver", + Installed: true, + Official: false, + description: "animations in your terminal", + }, + } + el := newExtList(opts, ui, extEntries) + + content, err := getSelectedReadme(opts, readme, el) + assert.NoError(t, err) + assert.Contains(t, content, "lol") +} + +func Test_getExtensionRepos(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + client := &http.Client{Transport: ®} + + values := url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "q": []string{"topic:gh-extension"}, + } + cfg := config.NewBlankConfig() + + cfg.DefaultHostFunc = func() (string, string) { return "github.com", "" } + + reg.Register( + httpmock.QueryMatcher("GET", "search/repositories", values), + httpmock.JSONResponse(search.RepositoriesResult{ + IncompleteResults: false, + Items: []search.Repository{ + { + FullName: "vilmibm/gh-screensaver", + Name: "gh-screensaver", + Description: "terminal animations", + Owner: search.User{ + Login: "vilmibm", + }, + }, + { + FullName: "cli/gh-cool", + Name: "gh-cool", + Description: "it's just cool ok", + Owner: search.User{ + Login: "cli", + }, + }, + { + FullName: "samcoe/gh-triage", + Name: "gh-triage", + Description: "helps with triage", + Owner: search.User{ + Login: "samcoe", + }, + }, + { + FullName: "github/gh-gei", + Name: "gh-gei", + Description: "something something enterprise", + Owner: search.User{ + Login: "github", + }, + }, + }, + Total: 4, + }), + ) + + searcher := search.NewSearcher(client, "github.com") + emMock := &extensions.ExtensionManagerMock{} + emMock.ListFunc = func() []extensions.Extension { + return []extensions.Extension{ + &extensions.ExtensionMock{ + URLFunc: func() string { + return "https://github.com/vilmibm/gh-screensaver" + }, + }, + &extensions.ExtensionMock{ + URLFunc: func() string { + return "https://github.com/github/gh-gei" + }, + }, + } + } + + opts := ExtBrowseOpts{ + Searcher: searcher, + Em: emMock, + Cfg: cfg, + } + + extEntries, err := getExtensions(opts) + assert.NoError(t, err) + + expectedEntries := []extEntry{ + { + URL: "https://github.com/vilmibm/gh-screensaver", + Name: "gh-screensaver", + FullName: "vilmibm/gh-screensaver", + Installed: true, + Official: false, + description: "terminal animations", + }, + { + URL: "https://github.com/cli/gh-cool", + Name: "gh-cool", + FullName: "cli/gh-cool", + Installed: false, + Official: true, + description: "it's just cool ok", + }, + { + URL: "https://github.com/samcoe/gh-triage", + Name: "gh-triage", + FullName: "samcoe/gh-triage", + Installed: false, + Official: false, + description: "helps with triage", + }, + { + URL: "https://github.com/github/gh-gei", + Name: "gh-gei", + FullName: "github/gh-gei", + Installed: true, + Official: true, + description: "something something enterprise", + }, + } + + assert.Equal(t, expectedEntries, extEntries) +} + +func Test_extEntry(t *testing.T) { + cases := []struct { + name string + ee extEntry + expectedTitle string + expectedDesc string + }{ + { + name: "official", + ee: extEntry{ + Name: "gh-cool", + FullName: "cli/gh-cool", + Installed: false, + Official: true, + description: "it's just cool ok", + }, + expectedTitle: "cli/gh-cool [yellow](official)", + expectedDesc: "it's just cool ok", + }, + { + name: "no description", + ee: extEntry{ + Name: "gh-nodesc", + FullName: "barryburton/gh-nodesc", + Installed: false, + Official: false, + description: "", + }, + expectedTitle: "barryburton/gh-nodesc", + expectedDesc: "no description provided", + }, + { + name: "installed", + ee: extEntry{ + Name: "gh-screensaver", + FullName: "vilmibm/gh-screensaver", + Installed: true, + Official: false, + description: "animations in your terminal", + }, + expectedTitle: "vilmibm/gh-screensaver [green](installed)", + expectedDesc: "animations in your terminal", + }, + { + name: "neither", + ee: extEntry{ + Name: "gh-triage", + FullName: "samcoe/gh-triage", + Installed: false, + Official: false, + description: "help with triage", + }, + expectedTitle: "samcoe/gh-triage", + expectedDesc: "help with triage", + }, + { + name: "both", + ee: extEntry{ + Name: "gh-gei", + FullName: "github/gh-gei", + Installed: true, + Official: true, + description: "something something enterprise", + }, + expectedTitle: "github/gh-gei [yellow](official) [green](installed)", + expectedDesc: "something something enterprise", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedTitle, tt.ee.Title()) + assert.Equal(t, tt.expectedDesc, tt.ee.Description()) + }) + } +} + +func Test_extList(t *testing.T) { + opts := ExtBrowseOpts{ + Logger: log.New(io.Discard, "", 0), + Em: &extensions.ExtensionManagerMock{ + InstallFunc: func(repo ghrepo.Interface, _ string) error { + assert.Equal(t, "cli/gh-cool", ghrepo.FullName(repo)) + return nil + }, + RemoveFunc: func(name string) error { + assert.Equal(t, "cool", name) + return nil + }, + }, + } + app := tview.NewApplication() + list := tview.NewList() + ui := uiRegistry{ + List: list, + App: app, + } + extEntries := []extEntry{ + { + Name: "gh-cool", + FullName: "cli/gh-cool", + Installed: false, + Official: true, + description: "it's just cool ok", + }, + { + Name: "gh-screensaver", + FullName: "vilmibm/gh-screensaver", + Installed: true, + Official: false, + description: "animations in your terminal", + }, + { + Name: "gh-triage", + FullName: "samcoe/gh-triage", + Installed: false, + Official: false, + description: "help with triage", + }, + { + Name: "gh-gei", + FullName: "github/gh-gei", + Installed: true, + Official: true, + description: "something something enterprise", + }, + } + + extList := newExtList(opts, ui, extEntries) + + extList.Filter("cool") + assert.Equal(t, 1, extList.ui.List.GetItemCount()) + + title, _ := extList.ui.List.GetItemText(0) + assert.Equal(t, "cli/gh-cool [yellow](official)", title) + + extList.InstallSelected() + assert.True(t, extList.extEntries[0].Installed) + + extList.Refresh() + assert.Equal(t, 1, extList.ui.List.GetItemCount()) + + title, _ = extList.ui.List.GetItemText(0) + assert.Equal(t, "cli/gh-cool [yellow](official) [green](installed)", title) + + extList.RemoveSelected() + assert.False(t, extList.extEntries[0].Installed) + + extList.Refresh() + assert.Equal(t, 1, extList.ui.List.GetItemCount()) + + title, _ = extList.ui.List.GetItemText(0) + assert.Equal(t, "cli/gh-cool [yellow](official)", title) + + extList.Reset() + assert.Equal(t, 4, extList.ui.List.GetItemCount()) + + ee, ix := extList.FindSelected() + assert.Equal(t, 0, ix) + assert.Equal(t, "cli/gh-cool [yellow](official)", ee.Title()) + + extList.ScrollDown() + ee, ix = extList.FindSelected() + assert.Equal(t, 1, ix) + assert.Equal(t, "vilmibm/gh-screensaver [green](installed)", ee.Title()) + + extList.ScrollUp() + ee, ix = extList.FindSelected() + assert.Equal(t, 0, ix) + assert.Equal(t, "cli/gh-cool [yellow](official)", ee.Title()) + + extList.PageDown() + ee, ix = extList.FindSelected() + assert.Equal(t, 3, ix) + assert.Equal(t, "github/gh-gei [yellow](official) [green](installed)", ee.Title()) + + extList.PageUp() + ee, ix = extList.FindSelected() + assert.Equal(t, 0, ix) + assert.Equal(t, "cli/gh-cool [yellow](official)", ee.Title()) +} diff --git a/pkg/cmd/extension/browse/rg.go b/pkg/cmd/extension/browse/rg.go new file mode 100644 index 000000000..4884b1779 --- /dev/null +++ b/pkg/cmd/extension/browse/rg.go @@ -0,0 +1,33 @@ +package browse + +import ( + "net/http" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/repo/view" +) + +type readmeGetter struct { + client *http.Client +} + +func newReadmeGetter(client *http.Client, cacheTTL time.Duration) *readmeGetter { + cachingClient := api.NewCachedHTTPClient(client, cacheTTL) + return &readmeGetter{ + client: cachingClient, + } +} + +func (g *readmeGetter) Get(repoFullName string) (string, error) { + repo, err := ghrepo.FromFullName(repoFullName) + if err != nil { + return "", err + } + readme, err := view.RepositoryReadme(g.client, repo, "") + if err != nil { + return "", err + } + return readme.Content, nil +} diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 1ea3b5530..2c65836c2 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -5,12 +5,15 @@ import ( "fmt" "os" "strings" + "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/extension/browse" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/search" @@ -239,6 +242,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter t := utils.NewTablePrinter(io) for _, c := range cmds { + // TODO consider a Repo() on Extension interface var repo string if u, err := git.ParseURL(c.URL()); err == nil { if r, err := ghrepo.FromURL(u); err == nil { @@ -406,6 +410,74 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return nil }, }, + func() *cobra.Command { + var debug bool + cmd := &cobra.Command{ + Use: "browse", + Short: "Enter a UI for browsing, adding, and removing extensions", + Long: heredoc.Doc(` + This command will take over your terminal and run a fully interactive + interface for browsing, adding, and removing gh extensions. + + The extension list is navigated with the arrow keys or with j/k. + Space and control+space (or control + j/k) page the list up and down. + Extension readmes can be scrolled with page up/page down keys + (fn + arrow up/down on a mac keyboard). + + For highlighted extensions, you can press: + + - w to open the extension in your web browser + - i to install the extension + - r to remove the extension + + Press / to focus the filter input. Press enter to scroll the results. + Press Escape to clear the filter and return to the full list. + + Press q to quit. + + The output of this command may be difficult to navigate for screen reader + users, users operating at high zoom and other users of assistive technology. It + is also not advised for automation scripts. We advise those users to use the + alternative command: + + gh ext search + + along with gh ext install, gh ext remove, and gh repo view. + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if !io.CanPrompt() { + return errors.New("this command runs an interactive UI and needs to be run in a terminal") + } + cfg, err := config() + if err != nil { + return err + } + host, _ := cfg.DefaultHost() + client, err := f.HttpClient() + if err != nil { + return err + } + + searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host) + + opts := browse.ExtBrowseOpts{ + Cmd: cmd, + IO: io, + Browser: browser, + Searcher: searcher, + Em: m, + Client: client, + Cfg: cfg, + Debug: debug, + } + + return browse.ExtBrowse(opts) + }, + } + cmd.Flags().BoolVar(&debug, "debug", false, "log to /tmp/extBrowse-*") + return cmd + }(), &cobra.Command{ Use: "exec [args]", Short: "Execute an installed extension", diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 7b2533da3..d093c764c 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -756,6 +756,12 @@ func TestNewCmdExtension(t *testing.T) { }, wantStdout: "test output", }, + { + name: "browse", + args: []string{"browse"}, + wantErr: true, + errMsg: "this command runs an interactive UI and needs to be run in a terminal", + }, } for _, tt := range tests {