diff --git a/go.mod b/go.mod index 46d1a20b..03405103 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,21 @@ go 1.19 require ( github.com/AlecAivazis/survey/v2 v2.3.6 + github.com/DopplerHQ/gocui v0.1.0 github.com/atotto/clipboard v0.1.4 github.com/google/uuid v1.3.0 github.com/hashicorp/go-version v1.6.0 github.com/jedib0t/go-pretty v4.3.0+incompatible + github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 github.com/mattn/go-isatty v0.0.16 + github.com/sasha-s/go-deadlock v0.3.1 + github.com/sirupsen/logrus v1.9.0 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.1 github.com/zalando/go-keyring v0.2.1 golang.org/x/crypto v0.1.0 + golang.org/x/sync v0.1.0 gopkg.in/gookit/color.v1 v1.1.6 gopkg.in/yaml.v3 v3.0.1 ) @@ -23,21 +28,28 @@ require ( github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/gdamore/tcell/v2 v2.4.0 // indirect + github.com/go-errors/errors v1.0.2 // indirect github.com/go-openapi/errors v0.20.3 // indirect github.com/go-openapi/strfmt v0.21.3 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/lucasb-eyer/go-colorful v1.0.3 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid v1.3.1 // indirect + github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.2 // indirect + github.com/samber/lo v1.31.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.mongodb.org/mongo-driver v1.10.3 // indirect + golang.org/x/exp v0.0.0-20220317015231-48e79f11773a // indirect golang.org/x/sys v0.1.0 // indirect golang.org/x/term v0.1.0 // indirect golang.org/x/text v0.4.0 // indirect diff --git a/go.sum b/go.sum index 796c3e1a..42ac419a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw= github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= +github.com/DopplerHQ/gocui v0.1.0 h1:koC9KoJsJCLrhmU7kd3APEzyeteU4h+3+rxogvjtLHk= +github.com/DopplerHQ/gocui v0.1.0/go.mod h1:sh6LfDRF5KYZbKXdyTgZ62eVhx1dIVTTKxsTzD9Qmg4= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= @@ -19,6 +21,12 @@ github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnG 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= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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.4.0 h1:W6dxJEmaxYvhICFoTY3WrLLEXsQ11SaFnKGVEXW57KM= +github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= +github.com/go-errors/errors v1.0.2 h1:xMxH9j2fNg/L4hLn/4y3M0IUsn0M6Wbu/Uh9QlOfBh4= +github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= github.com/go-openapi/errors v0.20.3 h1:rz6kiC84sqNQoqrtulzaL/VERgkoCyB6WdEkc2ujzUc= github.com/go-openapi/errors v0.20.3/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk= @@ -44,6 +52,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= +github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY= +github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= @@ -52,12 +62,15 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 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-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 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/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -71,13 +84,22 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= +github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= 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/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.31.0 h1:Sfa+/064Tdo4SvlohQUQzBhgSer9v/coGvKQI/XLWAM= +github.com/samber/lo v1.31.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8= +github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= +github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI= @@ -97,6 +119,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= @@ -111,21 +134,28 @@ go.mongodb.org/mongo-driver v1.10.3/go.mod h1:z4XpeoU6w+9Vht+jAFyLgVrD+jGSQQe0+C golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/exp v0.0.0-20220317015231-48e79f11773a h1:DAzrdbxsb5tXNOhMCSwF7ZdfMbW46hE9fSVO6BsmUZM= +golang.org/x/exp v0.0.0-20220317015231-48e79f11773a/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.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-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/pkg/cmd/secrets.go b/pkg/cmd/secrets.go index ccab379d..d892677b 100644 --- a/pkg/cmd/secrets.go +++ b/pkg/cmd/secrets.go @@ -327,7 +327,7 @@ func setSecrets(cmd *cobra.Command, args []string) { } } - response, err := http.SetSecrets(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value, secrets) + response, err := http.SetSecrets(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value, secrets, nil) if !err.IsNil() { utils.HandleError(err.Unwrap(), err.Message) } @@ -383,7 +383,7 @@ func deleteSecrets(cmd *cobra.Command, args []string) { secrets[arg] = nil } - response, err := http.SetSecrets(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value, secrets) + response, err := http.SetSecrets(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value, secrets, nil) if !err.IsNil() { utils.HandleError(err.Unwrap(), err.Message) } diff --git a/pkg/cmd/tui.go b/pkg/cmd/tui.go new file mode 100644 index 00000000..1fe7ac3f --- /dev/null +++ b/pkg/cmd/tui.go @@ -0,0 +1,42 @@ +/* +Copyright © 2020 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package cmd + +import ( + "github.com/DopplerHQ/cli/pkg/configuration" + tuiApp "github.com/DopplerHQ/cli/pkg/tui" + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/spf13/cobra" +) + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "Launch TUI (BETA)", + Args: cobra.NoArgs, + Run: tui, +} + +func tui(cmd *cobra.Command, args []string) { + localConfig := configuration.LocalConfig(cmd) + tuiApp.Start(localConfig) +} + +func init() { + tuiCmd.Flags().StringP("project", "p", "", "project (e.g. backend)") + tuiCmd.Flags().StringP("config", "c", "", "config (e.g. dev)") + tuiCmd.Flags().BoolVar(&utils.DebugTUI, "debug-tui", utils.DebugTUI, "log TUI messages to file") + rootCmd.AddCommand(tuiCmd) +} diff --git a/pkg/configuration/tui.go b/pkg/configuration/tui.go new file mode 100644 index 00000000..81be76de --- /dev/null +++ b/pkg/configuration/tui.go @@ -0,0 +1,27 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package configuration + +var CURRENT_INTRO_VERSION = 1 + +func TUIShouldShowIntro() bool { + return configContents.TUI.IntroVersionSeen != CURRENT_INTRO_VERSION +} + +func TUIMarkIntroSeen() { + configContents.TUI.IntroVersionSeen = CURRENT_INTRO_VERSION + writeConfig(configContents) +} diff --git a/pkg/controllers/secrets.go b/pkg/controllers/secrets.go index 0f2ff1d9..05359896 100644 --- a/pkg/controllers/secrets.go +++ b/pkg/controllers/secrets.go @@ -59,6 +59,32 @@ var dangerousSecretNames = [...]string{ "NODE_OPTIONS", } +func GetSecrets(config models.ScopedOptions) (map[string]models.ComputedSecret, Error) { + utils.RequireValue("token", config.Token.Value) + + response, err := http.GetSecrets(config.APIHost.Value, utils.GetBool(config.VerifyTLS.Value, true), config.Token.Value, config.EnclaveProject.Value, config.EnclaveConfig.Value, nil, false, 0) + if !err.IsNil() { + return nil, Error{Err: err.Unwrap(), Message: err.Message} + } + secrets, parseErr := models.ParseSecrets(response) + if parseErr != nil { + return nil, Error{Err: parseErr, Message: "Unable to parse API response"} + } + + return secrets, Error{} +} + +func SetSecrets(config models.ScopedOptions, changeRequests []models.ChangeRequest) (map[string]models.ComputedSecret, Error) { + utils.RequireValue("token", config.Token.Value) + + secrets, err := http.SetSecrets(config.APIHost.Value, utils.GetBool(config.VerifyTLS.Value, true), config.Token.Value, config.EnclaveProject.Value, config.EnclaveConfig.Value, nil, changeRequests) + if !err.IsNil() { + return nil, Error{Err: err.Unwrap(), Message: err.Message} + } + + return secrets, Error{} +} + func GetSecretNames(config models.ScopedOptions) ([]string, Error) { utils.RequireValue("token", config.Token.Value) diff --git a/pkg/http/api.go b/pkg/http/api.go index 847cd119..cadff434 100644 --- a/pkg/http/api.go +++ b/pkg/http/api.go @@ -225,9 +225,13 @@ func GetSecrets(host string, verifyTLS bool, apiKey string, project string, conf } // SetSecrets for specified project and config -func SetSecrets(host string, verifyTLS bool, apiKey string, project string, config string, secrets map[string]interface{}) (map[string]models.ComputedSecret, Error) { +func SetSecrets(host string, verifyTLS bool, apiKey string, project string, config string, secrets map[string]interface{}, changeRequests []models.ChangeRequest) (map[string]models.ComputedSecret, Error) { reqBody := map[string]interface{}{} - reqBody["secrets"] = secrets + if changeRequests != nil { + reqBody["change_requests"] = changeRequests + } else { + reqBody["secrets"] = secrets + } body, err := json.Marshal(reqBody) if err != nil { return nil, Error{Err: err, Message: "Invalid secrets"} diff --git a/pkg/models/api.go b/pkg/models/api.go index f5856ade..51966624 100644 --- a/pkg/models/api.go +++ b/pkg/models/api.go @@ -25,6 +25,15 @@ type ComputedSecret struct { Note string `json:"note"` } +// ChangeRequest can be used to smartly update secrets +type ChangeRequest struct { + OriginalName interface{} `json:"originalName"` + OriginalValue interface{} `json:"originalValue,omitempty"` + Name string `json:"name"` + Value string `json:"value"` + ShouldDelete bool `json:"shouldDelete"` +} + // SecretNote contains a secret and its note type SecretNote struct { Secret string `json:"secret"` diff --git a/pkg/models/config.go b/pkg/models/config.go index 23064bdd..c7d21b28 100644 --- a/pkg/models/config.go +++ b/pkg/models/config.go @@ -24,6 +24,7 @@ type ConfigFile struct { Scoped map[string]FileScopedOptions `yaml:"scoped"` VersionCheck VersionCheck `yaml:"version-check"` Analytics AnalyticsOptions `yaml:"analytics"` + TUI TUIOptions `yaml:"tui"` } // FileScopedOptions config options @@ -48,6 +49,10 @@ type AnalyticsOptions struct { Disable bool `yaml:"disable"` } +type TUIOptions struct { + IntroVersionSeen int `yaml:"introVersionSeen"` +} + // ScopedOptions options with their scope type ScopedOptions struct { Token ScopedOption `json:"token,omitempty" yaml:"token,omitempty"` diff --git a/pkg/tui/common/common.go b/pkg/tui/common/common.go new file mode 100644 index 00000000..c196d1af --- /dev/null +++ b/pkg/tui/common/common.go @@ -0,0 +1,34 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package common + +import ( + "github.com/DopplerHQ/cli/pkg/models" + "github.com/sirupsen/logrus" +) + +// Commonly used things wrapped into one struct for convenience when passing it around +type Common struct { + Log *logrus.Entry + Opts models.ScopedOptions +} + +func NewCommon(opts models.ScopedOptions) (*Common, error) { + return &Common{ + Log: newLogger(), + Opts: opts, + }, nil +} diff --git a/pkg/tui/common/logging.go b/pkg/tui/common/logging.go new file mode 100644 index 00000000..573b25be --- /dev/null +++ b/pkg/tui/common/logging.go @@ -0,0 +1,63 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package common + +import ( + "io/ioutil" + "log" + "os" + "path/filepath" + + "github.com/DopplerHQ/cli/pkg/configuration" + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/sirupsen/logrus" +) + +func newLogger() *logrus.Entry { + var log *logrus.Logger + if utils.DebugTUI { + log = newDebugLogger() + } else { + log = newDiscardedLogger() + } + + return log.WithFields(logrus.Fields{}) +} + +func newDebugLogger() *logrus.Logger { + logger := logrus.New() + logger.SetLevel(logrus.DebugLevel) + logPath, err := LogPath() + if err != nil { + log.Fatal(err) + } + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + if err != nil { + log.Fatalf("Unable to log to log file: %v", err) + } + logger.SetOutput(file) + return logger +} + +func newDiscardedLogger() *logrus.Logger { + logger := logrus.New() + logger.SetOutput(ioutil.Discard) + return logger +} + +func LogPath() (string, error) { + return filepath.Join(configuration.UserConfigDir, "tui.log"), nil +} diff --git a/pkg/tui/gui/cmp_base.go b/pkg/tui/gui/cmp_base.go new file mode 100644 index 00000000..9dd5891f --- /dev/null +++ b/pkg/tui/gui/cmp_base.go @@ -0,0 +1,70 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import "github.com/DopplerHQ/gocui" + +type Component interface { + GetViewName() string + GetView() *gocui.View + GetTitle() string + GetFocusable() bool + + // Full re-render from State + Render() error + + OnFocus() + OnBlur() +} + +type BaseComponent struct { + gui *Gui + view *gocui.View +} + +func CreateBaseComponent(gui *Gui, cmp Component) (*BaseComponent, error) { + var err error + + baseCmp := &BaseComponent{gui: gui} + + view, err := gui.createView(cmp) + if err != nil { + return nil, err + } + baseCmp.view = view + + return baseCmp, nil +} + +func (self *BaseComponent) GetView() *gocui.View { + return self.view +} + +func (self *BaseComponent) GetFocusable() bool { + return true +} + +func (self *BaseComponent) OnFocus() { + +} + +func (self *BaseComponent) OnBlur() { + +} + +func (self *BaseComponent) Render() error { + return nil +} diff --git a/pkg/tui/gui/cmp_configs.go b/pkg/tui/gui/cmp_configs.go new file mode 100644 index 00000000..301207e2 --- /dev/null +++ b/pkg/tui/gui/cmp_configs.go @@ -0,0 +1,92 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "github.com/DopplerHQ/cli/pkg/tui/gui/state" + "github.com/DopplerHQ/gocui" +) + +type ConfigsComponent struct { + *BaseComponent + + selectedIdx int +} + +var _ Component = &ConfigsComponent{} + +func CreateConfigsComponent(gui *Gui) (*ConfigsComponent, error) { + cmp := &ConfigsComponent{} + + baseCmp, err := CreateBaseComponent(gui, cmp) + if err != nil { + return nil, err + } + cmp.BaseComponent = baseCmp + + cmp.view.Highlight = true + cmp.view.SelFgColor = gocui.ColorMagenta + cmp.view.SelBgColor = gocui.ColorBlack + + gui.bindKey("Configs", 'j', gocui.ModNone, func(v *gocui.View) error { + return cmp.SelectIdx(cmp.selectedIdx + 1) + }) + gui.bindKey("Configs", 'k', gocui.ModNone, func(v *gocui.View) error { + return cmp.SelectIdx(cmp.selectedIdx - 1) + }) + gui.bindKey("Configs", gocui.KeyEnter, gocui.ModNone, func(v *gocui.View) error { + go gui.selectConfig(cmp.selectedIdx) + return nil + }) + + return cmp, nil +} + +func (self *ConfigsComponent) SelectIdx(idx int) error { + maxIdx := len(state.Configs()) - 1 + newIdx, err := SelectIdx(self, idx, maxIdx) + if err != nil { + return err + } + self.selectedIdx = newIdx + return nil +} + +func (self *ConfigsComponent) GetViewName() string { return "Configs" } +func (self *ConfigsComponent) GetTitle() string { return "Configs (1)" } + +func (self *ConfigsComponent) OnFocus() { + if self.selectedIdx >= len(state.Configs()) { + self.SelectIdx(0) + } +} + +func (self *ConfigsComponent) Render() error { + text := "" + + _, activeConf := state.Active() + for _, conf := range state.Configs() { + if conf.Name == activeConf { + text += "* " + } + text += conf.Name + "\n" + } + + self.GetView().Clear() + self.GetView().WriteString(text) + + return nil +} diff --git a/pkg/tui/gui/cmp_projects.go b/pkg/tui/gui/cmp_projects.go new file mode 100644 index 00000000..d521af5c --- /dev/null +++ b/pkg/tui/gui/cmp_projects.go @@ -0,0 +1,86 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "github.com/DopplerHQ/cli/pkg/tui/gui/state" + "github.com/DopplerHQ/gocui" +) + +type ProjectsComponent struct { + *BaseComponent + + selectedIdx int +} + +var _ Component = &ProjectsComponent{} + +func CreateProjectsComponent(gui *Gui) (*ProjectsComponent, error) { + cmp := &ProjectsComponent{} + + baseCmp, err := CreateBaseComponent(gui, cmp) + if err != nil { + return nil, err + } + cmp.BaseComponent = baseCmp + + cmp.view.Highlight = true + cmp.view.SelFgColor = gocui.ColorMagenta + cmp.view.SelBgColor = gocui.ColorBlack + + gui.bindKey("Projects", 'j', gocui.ModNone, func(v *gocui.View) error { + return cmp.SelectIdx(cmp.selectedIdx + 1) + }) + gui.bindKey("Projects", 'k', gocui.ModNone, func(v *gocui.View) error { + return cmp.SelectIdx(cmp.selectedIdx - 1) + }) + gui.bindKey("Projects", gocui.KeyEnter, gocui.ModNone, func(v *gocui.View) error { + go gui.selectProject(cmp.selectedIdx) + return nil + }) + + return cmp, nil +} + +func (self *ProjectsComponent) SelectIdx(idx int) error { + maxIdx := len(state.Projects()) - 1 + newIdx, err := SelectIdx(self, idx, maxIdx) + if err != nil { + return err + } + self.selectedIdx = newIdx + return nil +} + +func (self *ProjectsComponent) GetViewName() string { return "Projects" } +func (self *ProjectsComponent) GetTitle() string { return "Projects (2)" } + +func (self *ProjectsComponent) Render() error { + text := "" + + activeProj, _ := state.Active() + for _, proj := range state.Projects() { + if proj.Name == activeProj { + text += "* " + } + text += proj.Name + "\n" + } + + self.GetView().Clear() + self.GetView().WriteString(text) + + return nil +} diff --git a/pkg/tui/gui/cmp_prompt_help.go b/pkg/tui/gui/cmp_prompt_help.go new file mode 100644 index 00000000..e6282b78 --- /dev/null +++ b/pkg/tui/gui/cmp_prompt_help.go @@ -0,0 +1,115 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "github.com/DopplerHQ/gocui" +) + +type PromptHelpComponent struct { + *BaseComponent +} + +var _ Component = &PromptHelpComponent{} + +func CreatePromptHelpComponent(gui *Gui) (*PromptHelpComponent, error) { + cmp := &PromptHelpComponent{} + + baseCmp, err := CreateBaseComponent(gui, cmp) + if err != nil { + return nil, err + } + cmp.BaseComponent = baseCmp + + gui.bindKey("PromptHelp", gocui.KeyEnter, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptHelp.Close() + }) + gui.bindKey("PromptHelp", gocui.KeyEsc, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptHelp.Close() + }) + gui.bindKey("PromptHelp", 'q', gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptHelp.Close() + }) + + cmp.GetView().Visible = false + + return cmp, nil +} + +func (self *PromptHelpComponent) GetViewName() string { return "PromptHelp" } +func (self *PromptHelpComponent) GetTitle() string { return "Help" } +func (self *PromptHelpComponent) GetFocusable() bool { return true } + +func (self *PromptHelpComponent) OnFocus() { + self.Render() + self.gui.g.SetViewOnTop(self.GetViewName()) + self.GetView().Visible = true +} + +func (self *PromptHelpComponent) OnBlur() { + self.GetView().Visible = false +} + +func (self *PromptHelpComponent) Close() error { + return self.gui.focusComponent(self.gui.cmps.secrets) +} + +func (self *PromptHelpComponent) Render() error { + text := `Global Keybinds: + 1 Focus Configs + 2 Focus Projects + 3 Focus Secrets + / Focus Filter + q Exit + +Configs / Projects List Keybinds: + j Move cursor down + k Move cursor up + Enter Select + +Secrets Keybinds: + j Move cursor down + k Move cursor up + h / l Toggle between name and value + J Scroll current selection down + K Scroll current selection up + e Enter edit mode + s Open save prompt + a Add new secret + d Delete current secret + u Undo changes + y Copy current selection to clipboard + +Secrets Editing Mode Keybinds: + Esc Exit editing mode + Tab Toggle between name and value + +Save Prompt Keybinds: + Enter Confirm + Esc / q Cancel + +Save Prompt Keybinds: + Enter Confirm + Esc / q Cancel + +Filter Keybinds: + Enter / Esc Stop filtering` + + self.GetView().Clear() + self.GetView().WriteString(text) + + return nil +} diff --git a/pkg/tui/gui/cmp_prompt_intro.go b/pkg/tui/gui/cmp_prompt_intro.go new file mode 100644 index 00000000..72bf364f --- /dev/null +++ b/pkg/tui/gui/cmp_prompt_intro.go @@ -0,0 +1,89 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "github.com/DopplerHQ/cli/pkg/configuration" + "github.com/DopplerHQ/gocui" +) + +type PromptIntroComponent struct { + *BaseComponent +} + +var _ Component = &PromptIntroComponent{} + +func CreatePromptIntroComponent(gui *Gui) (*PromptIntroComponent, error) { + cmp := &PromptIntroComponent{} + + baseCmp, err := CreateBaseComponent(gui, cmp) + if err != nil { + return nil, err + } + cmp.BaseComponent = baseCmp + + gui.bindKey("PromptIntro", gocui.KeyEnter, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptIntro.Close() + }) + gui.bindKey("PromptIntro", gocui.KeyEsc, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptIntro.Close() + }) + gui.bindKey("PromptIntro", 'q', gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptIntro.Close() + }) + + cmp.GetView().Visible = false + + return cmp, nil +} + +func (self *PromptIntroComponent) GetViewName() string { return "PromptIntro" } +func (self *PromptIntroComponent) GetTitle() string { return "Welcome" } +func (self *PromptIntroComponent) GetFocusable() bool { return true } + +func (self *PromptIntroComponent) OnFocus() { + self.Render() + self.gui.g.SetViewOnTop(self.GetViewName()) + self.GetView().Visible = true +} + +func (self *PromptIntroComponent) OnBlur() { + self.GetView().Visible = false +} + +func (self *PromptIntroComponent) Close() error { + self.gui.Log.Debug("write") + configuration.TUIMarkIntroSeen() + self.gui.Log.Debug("wrote") + return self.gui.focusComponent(self.gui.cmps.secrets) +} + +func (self *PromptIntroComponent) Render() error { + text := `Welcome to the beta version of the Doppler TUI! + +To get started, close this window with Escape and then +press ? to view a list of keybindings and supported operations. + +We'd love your feedback! Please report any bugs and feature +requests to our CLI repository at: + +https://github.com/DopplerHQ/cli` + + self.GetView().Clear() + self.GetView().WriteString(text) + + return nil +} diff --git a/pkg/tui/gui/cmp_prompt_save.go b/pkg/tui/gui/cmp_prompt_save.go new file mode 100644 index 00000000..8c7f6127 --- /dev/null +++ b/pkg/tui/gui/cmp_prompt_save.go @@ -0,0 +1,95 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "github.com/DopplerHQ/cli/pkg/tui/gui/state" + "github.com/DopplerHQ/gocui" +) + +type PromptSaveComponent struct { + *BaseComponent +} + +var _ Component = &PromptSaveComponent{} + +func CreatePromptSaveComponent(gui *Gui) (*PromptSaveComponent, error) { + cmp := &PromptSaveComponent{} + + baseCmp, err := CreateBaseComponent(gui, cmp) + if err != nil { + return nil, err + } + cmp.BaseComponent = baseCmp + + gui.bindKey("PromptSave", gocui.KeyEnter, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptSave.ConfirmSave() + }) + gui.bindKey("PromptSave", gocui.KeyEsc, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptSave.CancelSave() + }) + gui.bindKey("PromptSave", 'q', gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.promptSave.CancelSave() + }) + + cmp.GetView().Visible = false + + return cmp, nil +} + +func (self *PromptSaveComponent) GetViewName() string { return "PromptSave" } +func (self *PromptSaveComponent) GetTitle() string { return "Confirm Changes" } +func (self *PromptSaveComponent) GetFocusable() bool { return true } + +func (self *PromptSaveComponent) OnFocus() { + self.Render() + self.gui.g.SetViewOnTop(self.GetViewName()) + self.GetView().Visible = true +} + +func (self *PromptSaveComponent) OnBlur() { + self.GetView().Visible = false +} + +func (self *PromptSaveComponent) ConfirmSave() error { + if len(state.Changes()) == 0 { + return self.CancelSave() + } + go self.gui.saveSecrets(state.Changes()) + return nil +} + +func (self *PromptSaveComponent) CancelSave() error { + return self.gui.focusComponent(self.gui.cmps.secrets) +} + +func (self *PromptSaveComponent) Render() error { + text := "" + + if len(state.Changes()) > 0 { + text = "The following secrets will be updated: \n\n" + for _, change := range state.Changes() { + text += "● " + change.Name + "\n" + } + } else { + text = "There are no changes to save" + } + + self.GetView().Clear() + self.GetView().WriteString(text) + + return nil +} diff --git a/pkg/tui/gui/cmp_secret_view.go b/pkg/tui/gui/cmp_secret_view.go new file mode 100644 index 00000000..2419510d --- /dev/null +++ b/pkg/tui/gui/cmp_secret_view.go @@ -0,0 +1,179 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "fmt" + "strings" + + "github.com/DopplerHQ/cli/pkg/models" + "github.com/DopplerHQ/cli/pkg/tui/gui/state" + "github.com/DopplerHQ/gocui" +) + +type SecretViewModel struct { + originalName interface{} + originalValue interface{} + originalVisibility string + nameView *gocui.View + valueView *gocui.View + isTouched bool + isDirty bool + shouldDelete bool +} + +var generateId func() string + +func init() { + curId := 0 + generateId = func() string { + curId++ + return fmt.Sprint(curId) + } +} + +func (svm *SecretViewModel) ToChangeRequest() models.ChangeRequest { + cr := models.ChangeRequest{ + OriginalName: svm.originalName, + Name: svm.nameView.TextArea.GetContent(), + Value: svm.valueView.TextArea.GetContent(), + ShouldDelete: svm.shouldDelete, + } + + if svm.originalVisibility != "restricted" { + cr.OriginalValue = svm.originalValue + } + + return cr +} + +func (svm *SecretViewModel) ShouldSubmit() bool { + return svm.isDirty && (len(svm.nameView.TextArea.GetContent()) > 0 || (svm.originalName != nil && len(svm.originalName.(string)) > 0)) +} + +func CreateSecretViewModel(gui *Gui, secret *state.Secret) (*SecretViewModel, error) { + id := generateId() + + nameView, err := gui.g.SetView("SVM:Name:"+id, 0, 1, 1000, 1, 0) + nameView.ParentView = gui.cmps.secrets.view + nameView.ConstrainContentsToParent = true + if !gocui.IsUnknownView(err) { + return nil, err + } + + valueView, err := gui.g.SetView("SVM:Value:"+id, 0, 1, 1000, 1, 0) + valueView.ParentView = gui.cmps.secrets.view + valueView.ConstrainContentsToParent = true + if !gocui.IsUnknownView(err) { + return nil, err + } + + nameView.Editor = gocui.EditorFunc(gui.SecretNameEditor) + nameView.TextArea.Clear() + nameView.TextArea.TypeString(secret.Name) + nameView.TextArea.SetCursor2D(0, 0) + nameView.RenderTextArea() + + valueView.Editor = gocui.EditorFunc(gui.SecretValueEditor) + valueView.TextArea.Clear() + if secret.Visibility == "restricted" { + valueView.TextArea.TypeString("[RESTRICTED]") + } else { + valueView.TextArea.TypeString(secret.Value) + } + valueView.TextArea.SetCursor2D(0, 0) + valueView.RenderTextArea() + + svm := &SecretViewModel{ + originalName: secret.Name, + originalValue: secret.Value, + originalVisibility: secret.Visibility, + nameView: nameView, + valueView: valueView, + } + + svm.UpdateViewState() + svm.ApplyFilter() + + return svm, nil +} + +func (gui *Gui) SecretNameEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { + switch { + case key == gocui.KeyEnter: + gui.cmps.secrets.FinishEditingCurrentField() + return false + case key == gocui.KeySpace: + key = '_' + ch = '_' + case (ch > 64 && ch < 91) || (ch > 47 && ch < 58): + break + case ch > 96 && ch < 123: + ch = ch - 32 + default: + ch = '_' + } + + rendered := gocui.DefaultEditor.Edit(v, key, ch, mod) + if rendered { + gui.cmps.secrets.OnCurrentSVMChanged() + } + return rendered +} + +func (gui *Gui) SecretValueEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { + switch { + case key == gocui.KeyLF: + key = gocui.KeyEnter + } + + rendered := gocui.DefaultEditor.Edit(v, key, ch, mod) + if rendered { + gui.cmps.secrets.OnCurrentSVMChanged() + } + return rendered +} + +func (self *SecretViewModel) UpdateViewState() { + nameChanged := self.nameView.TextArea.GetContent() != self.originalName + valueChanged := (self.originalVisibility != "restricted" && self.valueView.TextArea.GetContent() != self.originalValue) || (self.originalVisibility == "restricted" && self.isTouched) + + self.isDirty = self.originalName == "" || nameChanged || valueChanged || self.shouldDelete + + if self.shouldDelete { + self.nameView.FrameColor = gocui.ColorRed + self.valueView.FrameColor = gocui.ColorRed + self.nameView.FgColor = gocui.ColorRed + self.valueView.FgColor = gocui.ColorRed + } else if self.isDirty { + self.nameView.FrameColor = gocui.ColorYellow + self.valueView.FrameColor = gocui.ColorYellow + self.nameView.FgColor = gocui.ColorYellow + self.valueView.FgColor = gocui.ColorYellow + } else { + self.nameView.FrameColor = gocui.ColorDefault + self.valueView.FrameColor = gocui.ColorDefault + self.nameView.FgColor = gocui.ColorDefault + self.valueView.FgColor = gocui.ColorDefault + } +} + +func (self *SecretViewModel) ApplyFilter() { + filter := state.Filter() + shouldShow := self.isDirty || len(filter) == 0 || strings.Index(self.nameView.TextArea.GetContent(), strings.ToUpper(filter)) >= 0 + self.nameView.Visible = shouldShow + self.valueView.Visible = shouldShow +} diff --git a/pkg/tui/gui/cmp_secrets.go b/pkg/tui/gui/cmp_secrets.go new file mode 100644 index 00000000..37cb8ebf --- /dev/null +++ b/pkg/tui/gui/cmp_secrets.go @@ -0,0 +1,428 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "errors" + "fmt" + "strings" + + "github.com/DopplerHQ/cli/pkg/models" + "github.com/DopplerHQ/cli/pkg/tui/gui/state" + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/DopplerHQ/gocui" +) + +type SecretsComponent struct { + *BaseComponent + + scrollDelta int + activeSVM *SecretViewModel + svmsForSecretsSetAt int64 + secretVMs []*SecretViewModel +} + +var _ Component = &SecretsComponent{} + +func CreateSecretsComponent(gui *Gui) (*SecretsComponent, error) { + cmp := &SecretsComponent{} + + baseCmp, err := CreateBaseComponent(gui, cmp) + if err != nil { + return nil, err + } + cmp.BaseComponent = baseCmp + + gui.bindKey("Secrets", 'j', gocui.ModNone, func(v *gocui.View) error { + return cmp.SelectDelta(1) + }) + gui.bindKey("Secrets", 'k', gocui.ModNone, func(v *gocui.View) error { + return cmp.SelectDelta(-1) + }) + gui.bindKey("Secrets", 'h', gocui.ModNone, func(v *gocui.View) error { + return cmp.ToggleNameValue() + }) + gui.bindKey("Secrets", 'l', gocui.ModNone, func(v *gocui.View) error { + return cmp.ToggleNameValue() + }) + + gui.bindKey("Secrets", gocui.KeyArrowDown, gocui.ModNone, func(v *gocui.View) error { + return cmp.SelectDelta(1) + }) + gui.bindKey("Secrets", gocui.KeyArrowUp, gocui.ModNone, func(v *gocui.View) error { + return cmp.SelectDelta(-1) + }) + gui.bindKey("Secrets", gocui.KeyArrowLeft, gocui.ModNone, func(v *gocui.View) error { + return cmp.ToggleNameValue() + }) + gui.bindKey("Secrets", gocui.KeyArrowRight, gocui.ModNone, func(v *gocui.View) error { + return cmp.ToggleNameValue() + }) + + gui.bindKey("Secrets", gocui.KeyTab, gocui.ModNone, func(v *gocui.View) error { + return cmp.ToggleNameValue() + }) + gui.bindKey("Secrets", gocui.KeyBacktab, gocui.ModNone, func(v *gocui.View) error { + return cmp.ToggleNameValue() + }) + gui.bindKey("Secrets", 'J', gocui.ModNone, func(v *gocui.View) error { + gui.g.CurrentView().TextArea.MoveCursorDown() + gui.g.CurrentView().ScrollDown(1) + return nil + }) + gui.bindKey("Secrets", 'K', gocui.ModNone, func(v *gocui.View) error { + gui.g.CurrentView().TextArea.MoveCursorUp() + gui.g.CurrentView().ScrollDown(-1) + return nil + }) + gui.bindKey("Secrets", 'e', gocui.ModNone, func(v *gocui.View) error { + cmp.EditCurrentField() + return nil + }) + gui.bindKey("Secrets", 'y', gocui.ModNone, func(v *gocui.View) error { + cmp.YankCurrentField() + return nil + }) + gui.bindKey("Secrets", 'a', gocui.ModNone, func(v *gocui.View) error { + return cmp.AppendSVM() + }) + gui.bindKey("Secrets", 'd', gocui.ModNone, func(v *gocui.View) error { + return cmp.DeleteSVM() + }) + gui.bindKey("Secrets", 'u', gocui.ModNone, func(v *gocui.View) error { + cmp.UndoChanges() + return nil + }) + gui.bindKey("Secrets", 's', gocui.ModNone, func(v *gocui.View) error { + return cmp.PromptSave() + }) + gui.bindKey("Secrets", gocui.KeyEsc, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.secrets.FinishEditingCurrentField() + }) + + return cmp, nil +} + +func (self *SecretsComponent) GetViewName() string { return "Secrets" } +func (self *SecretsComponent) GetTitle() string { return "Secrets (3)" } + +func (self *SecretsComponent) visibleSVMs() []*SecretViewModel { + var vis []*SecretViewModel + for _, svm := range self.secretVMs { + if svm.nameView.Visible { + vis = append(vis, svm) + } + } + return vis +} + +func (self *SecretsComponent) SetActiveSVM(idx int) { + visibleSVMs := self.visibleSVMs() + if idx < len(visibleSVMs) { + self.activeSVM = visibleSVMs[idx] + } +} + +func (self *SecretsComponent) SelectSVM(idx int, forceFocusName bool) error { + focusName := forceFocusName || strings.Index(self.gui.g.CurrentView().Name(), "SVM:Name:") == 0 + visibleSVMs := self.visibleSVMs() + if idx >= len(visibleSVMs) { + self.activeSVM = nil + return nil + } + svmToFocus := visibleSVMs[idx] + + var viewToFocus string + if focusName { + viewToFocus = svmToFocus.nameView.Name() + } else { + viewToFocus = svmToFocus.valueView.Name() + } + + if _, err := self.gui.g.SetCurrentView(viewToFocus); err != nil { + return err + } + + self.activeSVM = svmToFocus + return nil +} + +func (self *SecretsComponent) SelectDelta(delta int) error { + curIdx := -1 + visibleSVMs := self.visibleSVMs() + for idx, svm := range visibleSVMs { + if self.activeSVM == svm { + curIdx = idx + break + } + } + + if curIdx == -1 { + return nil + } + + idxToFocus := utils.Clamp(curIdx+delta, 0, len(visibleSVMs)-1) + return self.SelectSVM(idxToFocus, false) +} + +func (self *SecretsComponent) OnBlur() { + self.view.TitleColor = gocui.ColorWhite +} + +func (self *SecretsComponent) OnFocus() { + self.view.TitleColor = gocui.ColorMagenta + + if self.activeSVM != nil { + toFocus := self.activeSVM.nameView + self.gui.g.SetCurrentView(toFocus.Name()) + } +} + +func (self *SecretsComponent) createSVMs() error { + if state.SecretsSetAt() == self.svmsForSecretsSetAt { + return nil + } + + self.gui.mutexes.SecretViewsMutex.Lock() + defer self.gui.mutexes.SecretViewsMutex.Unlock() + + self.svmsForSecretsSetAt = state.SecretsSetAt() + + for _, oldView := range self.secretVMs { + self.gui.g.DeleteView(oldView.nameView.Name()) + self.gui.g.DeleteView(oldView.valueView.Name()) + } + + self.scrollDelta = 0 + self.activeSVM = nil + self.secretVMs = make([]*SecretViewModel, len(state.Secrets())) + + for idx, secret := range state.Secrets() { + secret := secret + var err error + if self.secretVMs[idx], err = CreateSecretViewModel(self.gui, &secret); err != nil { + return err + } + } + + if len(self.secretVMs) > 0 { + self.activeSVM = self.secretVMs[0] + } + + curViewName := self.gui.g.CurrentView().Name() + isSVMFocused := strings.Index(curViewName, "SVM:") == 0 + if isSVMFocused || curViewName == self.GetViewName() { + // We want to focus on the newly created SVM if we were focused on the secrets component + self.OnFocus() + } + + return nil +} + +func (self *SecretsComponent) ToggleNameValue() error { + isNameFocused := strings.Index(self.gui.g.CurrentView().Name(), "SVM:Name:") == 0 + isEditing := self.gui.g.CurrentView().Editable + + if isEditing { + if err := self.FinishEditingCurrentField(); err != nil { + return err + } + } + + var err error + if isNameFocused { + _, err = self.gui.g.SetCurrentView(self.activeSVM.valueView.Name()) + } else { + _, err = self.gui.g.SetCurrentView(self.activeSVM.nameView.Name()) + } + + if err != nil { + return err + } + + if isEditing { + self.EditCurrentField() + } + + return nil +} + +func (self *SecretsComponent) AppendSVM() error { + newSVM, err := CreateSecretViewModel(self.gui, &state.Secret{}) + if err != nil { + return err + } + newSVM.originalName = nil + + self.gui.mutexes.SecretViewsMutex.Lock() + self.secretVMs = append(self.secretVMs, newSVM) + self.gui.mutexes.SecretViewsMutex.Unlock() + + // We need to position the new SVM before we can select it + self.gui.layout(self.gui.g) + + visibleSVMs := self.visibleSVMs() + if err = self.SelectSVM(len(visibleSVMs)-1, true); err != nil { + return err + } + + self.EditCurrentField() + return nil +} + +func (self *SecretsComponent) DeleteSVM() error { + if self.activeSVM == nil { + return nil + } + + if self.activeSVM.originalName == nil { + self.gui.mutexes.SecretViewsMutex.Lock() + defer self.gui.mutexes.SecretViewsMutex.Unlock() + + curIdx := -1 + for idx, svm := range self.secretVMs { + if self.activeSVM == svm { + curIdx = idx + break + } + } + + curVisibleIdx := -1 + for idx, svm := range self.visibleSVMs() { + if self.activeSVM == svm { + curVisibleIdx = idx + break + } + } + + if curIdx == -1 || curVisibleIdx == -1 { + return errors.New("Attempted to delete but couldn't find active SVM") + } + + self.secretVMs = append(self.secretVMs[:curIdx], self.secretVMs[curIdx+1:]...) + if err := self.gui.g.DeleteView(self.activeSVM.nameView.Name()); err != nil { + return err + } + if err := self.gui.g.DeleteView(self.activeSVM.valueView.Name()); err != nil { + return err + } + + idxToFocus := utils.Max(curVisibleIdx-1, 0) + if err := self.SelectSVM(idxToFocus, true); err != nil { + return err + } + } else { + self.activeSVM.shouldDelete = true + self.OnCurrentSVMChanged() + } + + return nil +} + +func (self *SecretsComponent) UndoChanges() { + if self.activeSVM == nil { + return + } + + self.activeSVM.shouldDelete = false + self.activeSVM.isTouched = false + + self.activeSVM.nameView.TextArea.Clear() + self.activeSVM.nameView.TextArea.TypeString(fmt.Sprint(self.activeSVM.originalName)) + self.activeSVM.nameView.TextArea.SetCursor2D(0, 0) + self.activeSVM.nameView.RenderTextArea() + + self.activeSVM.valueView.TextArea.Clear() + if self.activeSVM.originalVisibility == "restricted" { + self.activeSVM.valueView.TextArea.TypeString("[RESTRICTED]") + } else { + self.activeSVM.valueView.TextArea.TypeString(fmt.Sprint(self.activeSVM.originalValue)) + } + self.activeSVM.valueView.TextArea.SetCursor2D(0, 0) + self.activeSVM.valueView.RenderTextArea() + + self.OnCurrentSVMChanged() +} + +func (self *SecretsComponent) OnCurrentSVMChanged() { + self.activeSVM.UpdateViewState() +} + +func (self *SecretsComponent) EditCurrentField() { + if self.activeSVM == nil { + return + } + + self.activeSVM.isTouched = true + + isValueFocused := strings.Index(self.gui.g.CurrentView().Name(), "SVM:Value:") == 0 + if isValueFocused && self.activeSVM.originalVisibility == "restricted" { + self.gui.g.CurrentView().TextArea.Clear() + self.gui.cmps.secrets.OnCurrentSVMChanged() + } + + self.gui.g.Cursor = true + self.gui.g.CurrentView().Editable = true + self.gui.g.CurrentView().ParentView.Editable = true + self.gui.g.CurrentView().TextArea.SetCursorAtEnd() + self.gui.g.CurrentView().RenderTextArea() +} + +func (self *SecretsComponent) YankCurrentField() error { + if self.activeSVM == nil { + return nil + } + + return utils.CopyToClipboard(self.gui.g.CurrentView().TextArea.GetContent()) +} + +func (self *SecretsComponent) FinishEditingCurrentField() error { + if self.activeSVM == nil { + return errors.New("Attempted to finish editing but no active SVM exists") + } + + self.gui.g.Cursor = false + self.gui.g.CurrentView().Editable = false + self.gui.g.CurrentView().ParentView.Editable = false + self.gui.g.CurrentView().SetCursor(0, 0) + + return nil +} + +func (self *SecretsComponent) PromptSave() error { + var changes []models.ChangeRequest + for _, svm := range self.secretVMs { + if svm.ShouldSubmit() { + changes = append(changes, svm.ToChangeRequest()) + } + } + + state.SetChanges(changes) + return self.gui.focusComponent(self.gui.cmps.promptSave) +} + +func (self *SecretsComponent) Render() error { + if err := self.createSVMs(); err != nil { + return err + } + + if len(state.Projects()) > 0 && len(state.Configs()) > 0 { + curProj, curConf := state.Active() + self.view.Title = fmt.Sprintf("Secrets (3) [%s / %s]", curProj, curConf) + } + + return nil +} diff --git a/pkg/tui/gui/cmp_secrets_filter.go b/pkg/tui/gui/cmp_secrets_filter.go new file mode 100644 index 00000000..3375e839 --- /dev/null +++ b/pkg/tui/gui/cmp_secrets_filter.go @@ -0,0 +1,79 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "math" + + "github.com/DopplerHQ/cli/pkg/tui/gui/state" + "github.com/DopplerHQ/gocui" +) + +type SecretsFilterComponent struct { + *BaseComponent +} + +var _ Component = &SecretsFilterComponent{} + +func CreateSecretsFilterComponent(gui *Gui) (*SecretsFilterComponent, error) { + cmp := &SecretsFilterComponent{} + + baseCmp, err := CreateBaseComponent(gui, cmp) + if err != nil { + return nil, err + } + cmp.BaseComponent = baseCmp + + gui.bindKey("SecretsFilter", gocui.KeyEnter, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.secretsFilter.Finish() + }) + gui.bindKey("SecretsFilter", gocui.KeyEsc, gocui.ModNone, func(v *gocui.View) error { + return gui.cmps.secretsFilter.Finish() + }) + + cmp.GetView().Editable = true + cmp.GetView().Editor = gocui.EditorFunc(gui.SecretsFilterEditor) + + return cmp, nil +} + +func (self *SecretsFilterComponent) GetViewName() string { return "SecretsFilter" } +func (self *SecretsFilterComponent) GetTitle() string { return "Filter (/)" } +func (self *SecretsFilterComponent) GetFocusable() bool { return true } + +func (self *SecretsFilterComponent) Finish() error { + if err := self.gui.focusComponent(self.gui.cmps.secrets); err != nil { + return err + } + + return self.gui.cmps.secrets.SelectSVM(0, true) +} + +func (gui *Gui) SecretsFilterEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool { + rendered := gocui.DefaultEditor.Edit(v, key, ch, mod) + if rendered { + state.SetFilter(gui.cmps.secretsFilter.GetView().Buffer()) + for _, svm := range gui.cmps.secrets.secretVMs { + svm.ApplyFilter() + } + + // As we filter, we want to make sure that we're pinning to the top of the secrets view + gui.cmps.secrets.scrollDelta = math.MaxInt + gui.layout(gui.g) + gui.cmps.secrets.SetActiveSVM(0) + } + return rendered +} diff --git a/pkg/tui/gui/cmp_status.go b/pkg/tui/gui/cmp_status.go new file mode 100644 index 00000000..989ea570 --- /dev/null +++ b/pkg/tui/gui/cmp_status.go @@ -0,0 +1,59 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import "github.com/DopplerHQ/gocui" + +type StatusComponent struct { + *BaseComponent +} + +var _ Component = &StatusComponent{} + +func CreateStatusComponent(gui *Gui) (*StatusComponent, error) { + cmp := &StatusComponent{} + + baseCmp, err := CreateBaseComponent(gui, cmp) + if err != nil { + return nil, err + } + cmp.BaseComponent = baseCmp + + return cmp, nil +} + +func (self *StatusComponent) GetViewName() string { return "Status" } +func (self *StatusComponent) GetTitle() string { return "Status" } +func (self *StatusComponent) GetFocusable() bool { return false } + +func (self *StatusComponent) Render() error { + self.GetView().Clear() + + self.GetView().HasLoader = self.gui.isFetching + if self.gui.isFetching { + self.GetView().WriteString("Fetching...") + } else { + if len(self.gui.statusMessage) > 0 { + self.GetView().FgColor = gocui.ColorRed + self.GetView().WriteString(self.gui.statusMessage) + } else { + self.GetView().FgColor = gocui.ColorWhite + self.GetView().WriteString("Ready") + } + } + + return nil +} diff --git a/pkg/tui/gui/components.go b/pkg/tui/gui/components.go new file mode 100644 index 00000000..51cd4cb3 --- /dev/null +++ b/pkg/tui/gui/components.go @@ -0,0 +1,159 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "github.com/DopplerHQ/gocui" +) + +type Components struct { + configs *ConfigsComponent + projects *ProjectsComponent + secrets *SecretsComponent + status *StatusComponent + promptSave *PromptSaveComponent + promptHelp *PromptHelpComponent + promptIntro *PromptIntroComponent + secretsFilter *SecretsFilterComponent + + focusable []Component +} + +func (gui *Gui) createView(cmp Component) (*gocui.View, error) { + view, err := gui.g.SetView(cmp.GetViewName(), 0, 0, 10, 10, 0) + + // Unknown view is thrown when a view is created, which is what we're doing here, so + // we can safely ignore that error. + if !gocui.IsUnknownView(err) { + return nil, err + } + + if cmp.GetFocusable() { + gui.cmps.focusable = append(gui.cmps.focusable, cmp) + } + + view.Title = cmp.GetTitle() + + return view, nil +} + +func (gui *Gui) createAllComponents() error { + if configsCmp, err := CreateConfigsComponent(gui); err == nil { + gui.cmps.configs = configsCmp + } else { + return err + } + + if cmp, err := CreateProjectsComponent(gui); err == nil { + gui.cmps.projects = cmp + } else { + return err + } + + if cmp, err := CreateSecretsComponent(gui); err == nil { + gui.cmps.secrets = cmp + } else { + return err + } + + if cmp, err := CreateStatusComponent(gui); err == nil { + gui.cmps.status = cmp + } else { + return err + } + + if cmp, err := CreatePromptSaveComponent(gui); err == nil { + gui.cmps.promptSave = cmp + } else { + return err + } + + if cmp, err := CreatePromptHelpComponent(gui); err == nil { + gui.cmps.promptHelp = cmp + } else { + return err + } + + if cmp, err := CreatePromptIntroComponent(gui); err == nil { + gui.cmps.promptIntro = cmp + } else { + return err + } + + if cmp, err := CreateSecretsFilterComponent(gui); err == nil { + gui.cmps.secretsFilter = cmp + } else { + return err + } + + return nil +} + +func (gui *Gui) renderAllStateBasedComponents() { + gui.g.Update(func(*gocui.Gui) error { + cmps := []Component{ + gui.cmps.configs, + gui.cmps.projects, + gui.cmps.secrets, + gui.cmps.status, + } + for _, cmp := range cmps { + if err := cmp.Render(); err != nil { + return err + } + } + return nil + }) +} + +func (gui *Gui) getCurComponentIdx() (int, bool) { + curView := gui.g.CurrentView() + if curView == nil { + return 0, false + } + + if curView.ParentView != nil { + curView = curView.ParentView + } + curCmpIdx := -1 + + for idx, cmp := range gui.cmps.focusable { + if cmp.GetView() == curView { + curCmpIdx = idx + } + } + + if curCmpIdx == -1 { + return 0, false + } + + return curCmpIdx, true +} + +func (gui *Gui) focusComponent(cmp Component) error { + curCmpIdx, ok := gui.getCurComponentIdx() + if ok { + curCmp := gui.cmps.focusable[curCmpIdx] + curCmp.OnBlur() + } + + if _, err := gui.g.SetCurrentView(cmp.GetViewName()); err != nil { + return err + } + + cmp.OnFocus() + return nil +} diff --git a/pkg/tui/gui/dispatch.go b/pkg/tui/gui/dispatch.go new file mode 100644 index 00000000..b078f4e9 --- /dev/null +++ b/pkg/tui/gui/dispatch.go @@ -0,0 +1,232 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "context" + "sort" + + "github.com/DopplerHQ/cli/pkg/controllers" + "github.com/DopplerHQ/cli/pkg/models" + "github.com/DopplerHQ/cli/pkg/tui/gui/state" + "golang.org/x/sync/errgroup" +) + +// Helper functions for fetching ----------------------------------------------- +// TODO: Should these keep track of the context for pending requests and cancel previous ones as new ones come in? + +func (gui *Gui) fetchConfigs(projectName string) ([]models.ConfigInfo, controllers.Error) { + fetchOpts := gui.Opts + fetchOpts.EnclaveProject = models.ScopedOption{Scope: "", Source: "tui", Value: projectName} + return controllers.GetConfigs(fetchOpts) +} + +func (gui *Gui) fetchSecrets(projectName string, configName string) (map[string]models.ComputedSecret, controllers.Error) { + fetchOpts := gui.Opts + fetchOpts.EnclaveProject = models.ScopedOption{Scope: "", Source: "tui", Value: projectName} + fetchOpts.EnclaveConfig = models.ScopedOption{Scope: "", Source: "tui", Value: configName} + return controllers.GetSecrets(fetchOpts) +} + +func (gui *Gui) postSecrets(projectName string, configName string, changeRequests []models.ChangeRequest) (map[string]models.ComputedSecret, controllers.Error) { + fetchOpts := gui.Opts + fetchOpts.EnclaveProject = models.ScopedOption{Scope: "", Source: "tui", Value: projectName} + fetchOpts.EnclaveConfig = models.ScopedOption{Scope: "", Source: "tui", Value: configName} + return controllers.SetSecrets(fetchOpts, changeRequests) +} + +// Helper functions for converting models -------------------------------------- + +func createSecrets(computedSecrets map[string]models.ComputedSecret) []state.Secret { + var secrets []state.Secret + for _, cs := range computedSecrets { + if cs.Name == "DOPPLER_CONFIG" || cs.Name == "DOPPLER_ENVIRONMENT" || cs.Name == "DOPPLER_PROJECT" { + continue + } + var value string + if cs.RawValue == nil { + value = "" + } else { + value = *cs.RawValue + } + secrets = append(secrets, state.Secret{ + Name: cs.Name, + Value: value, + Visibility: cs.RawVisibility, + }) + } + + sort.Sort(state.ByName(secrets)) + return secrets +} + +func (gui *Gui) handleError(err error) { + gui.setIsFetching(false) + gui.statusMessage = err.Error() + gui.renderAllStateBasedComponents() +} + +// Dispatchable actions -------------------------------------------------------- + +func (gui *Gui) load() { + defer recoverScreenOnCrash() + gui.setIsFetching(true) + + var projectIds []string + var configInfos []models.ConfigInfo + var computedSecrets map[string]models.ComputedSecret + + var selectedProjectIdx int + var selectedConfigIdx int + + g, _ := errgroup.WithContext(context.Background()) + g.Go(func() error { + defer recoverScreenOnCrash() + var err controllers.Error + projectIds, err = controllers.GetProjectIDs(gui.Opts) + return err.Unwrap() + }) + g.Go(func() error { + defer recoverScreenOnCrash() + var err controllers.Error + configInfos, err = gui.fetchConfigs(gui.Opts.EnclaveProject.Value) + return err.Unwrap() + }) + g.Go(func() error { + defer recoverScreenOnCrash() + var err controllers.Error + computedSecrets, err = gui.fetchSecrets(gui.Opts.EnclaveProject.Value, gui.Opts.EnclaveConfig.Value) + return err.Unwrap() + }) + if err := g.Wait(); err != nil { + gui.handleError(err) + return + } + + projects := make([]state.Project, len(projectIds)) + for idx, id := range projectIds { + projects[idx] = state.Project{Name: id} + if id == gui.Opts.EnclaveProject.Value { + selectedProjectIdx = idx + } + } + + configs := make([]state.Config, len(configInfos)) + for idx, configInfo := range configInfos { + configs[idx] = state.Config{Name: configInfo.Name} + if configInfo.Name == gui.Opts.EnclaveConfig.Value { + selectedConfigIdx = idx + } + } + + secrets := createSecrets(computedSecrets) + + state.SetProjects(projects) + state.SetConfigs(configs) + state.SetSecrets(secrets, gui.Opts.EnclaveProject.Value, gui.Opts.EnclaveConfig.Value) + + gui.cmps.projects.SelectIdx(selectedProjectIdx) + gui.cmps.configs.SelectIdx(selectedConfigIdx) + + gui.setIsFetching(false) +} + +func (gui *Gui) selectProject(projectIdx int) { + defer recoverScreenOnCrash() + gui.setIsFetching(true) + + var configInfos []models.ConfigInfo + + g, _ := errgroup.WithContext(context.Background()) + g.Go(func() error { + defer recoverScreenOnCrash() + var err controllers.Error + configInfos, err = gui.fetchConfigs(state.Projects()[gui.cmps.projects.selectedIdx].Name) + return err.Unwrap() + }) + if err := g.Wait(); err != nil { + gui.handleError(err) + return + } + + configs := make([]state.Config, len(configInfos)) + for idx, configInfo := range configInfos { + configs[idx] = state.Config{Name: configInfo.Name} + } + + state.SetConfigs(configs) + state.SetSecrets(make([]state.Secret, 0), "", "") + + gui.setIsFetching(false) + gui.focusComponent(gui.cmps.configs) +} + +func (gui *Gui) selectConfig(configIdx int) { + defer recoverScreenOnCrash() + gui.setIsFetching(true) + + var computedSecrets map[string]models.ComputedSecret + + curProj := state.Projects()[gui.cmps.projects.selectedIdx].Name + curConf := state.Configs()[gui.cmps.configs.selectedIdx].Name + + g, _ := errgroup.WithContext(context.Background()) + g.Go(func() error { + defer recoverScreenOnCrash() + var err controllers.Error + computedSecrets, err = gui.fetchSecrets(curProj, curConf) + return err.Unwrap() + }) + if err := g.Wait(); err != nil { + gui.handleError(err) + return + } + + secrets := createSecrets(computedSecrets) + state.SetSecrets(secrets, curProj, curConf) + + gui.setIsFetching(false) + gui.focusComponent(gui.cmps.secrets) +} + +func (gui *Gui) saveSecrets(changeRequests []models.ChangeRequest) { + defer recoverScreenOnCrash() + gui.setIsFetching(true) + + var computedSecrets map[string]models.ComputedSecret + + curProj := state.Projects()[gui.cmps.projects.selectedIdx].Name + curConf := state.Configs()[gui.cmps.configs.selectedIdx].Name + + g, _ := errgroup.WithContext(context.Background()) + g.Go(func() error { + defer recoverScreenOnCrash() + var err controllers.Error + computedSecrets, err = gui.postSecrets(curProj, curConf, changeRequests) + return err.Unwrap() + }) + if err := g.Wait(); err != nil { + gui.handleError(err) + gui.focusComponent(gui.cmps.secrets) + return + } + + secrets := createSecrets(computedSecrets) + state.SetSecrets(secrets, curProj, curConf) + + gui.setIsFetching(false) + gui.focusComponent(gui.cmps.secrets) +} diff --git a/pkg/tui/gui/gui.go b/pkg/tui/gui/gui.go new file mode 100644 index 00000000..3d6fa6ce --- /dev/null +++ b/pkg/tui/gui/gui.go @@ -0,0 +1,173 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "context" + + "github.com/DopplerHQ/cli/pkg/configuration" + "github.com/DopplerHQ/cli/pkg/tui/common" + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/DopplerHQ/gocui" + "github.com/sasha-s/go-deadlock" +) + +type Gui struct { + *common.Common + + g *gocui.Gui + cmps Components + mutexes Mutexes + + isFetching bool + statusMessage string +} + +type Mutexes struct { + SecretViewsMutex *deadlock.Mutex +} + +func NewGui(cmn *common.Common) (*Gui, error) { + gui := &Gui{ + Common: cmn, + mutexes: Mutexes{ + SecretViewsMutex: &deadlock.Mutex{}, + }, + } + + return gui, nil +} + +func (gui *Gui) run() error { + g, err := gocui.NewGui(gocui.OutputTrue, false, gocui.NORMAL, false, nil) + if err != nil { + return err + } + + gui.g = g + defer gui.g.Close() + + // Managers are run on every render cycle + gui.g.SetManager(gocui.ManagerFunc(gui.layout)) + + if err := gui.createAllComponents(); err != nil { + return err + } + + gui.bindKey("", gocui.KeyCtrlC, gocui.ModNone, func(v *gocui.View) error { + return gocui.ErrQuit + }) + gui.bindKey("", 'q', gocui.ModNone, func(v *gocui.View) error { + return gocui.ErrQuit + }) + gui.bindKey("", '1', gocui.ModNone, func(v *gocui.View) error { + return gui.focusComponent(gui.cmps.configs) + }) + gui.bindKey("", '2', gocui.ModNone, func(v *gocui.View) error { + return gui.focusComponent(gui.cmps.projects) + }) + gui.bindKey("", '3', gocui.ModNone, func(v *gocui.View) error { + return gui.focusComponent(gui.cmps.secrets) + }) + gui.bindKey("", '/', gocui.ModNone, func(v *gocui.View) error { + return gui.focusComponent(gui.cmps.secretsFilter) + }) + gui.bindKey("", '?', gocui.ModNone, func(v *gocui.View) error { + return gui.focusComponent(gui.cmps.promptHelp) + }) + + g.Highlight = true + g.SelFgColor = gocui.ColorMagenta + g.SelFrameColor = gocui.ColorMagenta + + // Set the default component focus + if configuration.TUIShouldShowIntro() { + gui.focusComponent(gui.cmps.promptIntro) + } else { + gui.focusComponent(gui.cmps.secrets) + } + + // Fetch the data needed for the initial state of the app + go gui.load() + + return gui.g.MainLoop() +} + +// bindKey must bind a key or panics +func (gui *Gui) bindKey(viewname string, key interface{}, mod gocui.Modifier, handler func(*gocui.View) error) { + err := gui.g.SetKeybinding(viewname, key, mod, func(g *gocui.Gui, v *gocui.View) error { + if gui.isFetching && key != gocui.KeyCtrlC { + return nil + } + return handler(v) + }) + if err != nil { + panic(err) + } +} + +func (gui *Gui) setIsFetching(isFetching bool) { + gui.statusMessage = "" + gui.isFetching = isFetching + gui.renderAllStateBasedComponents() + + ctx := context.Background() + if isFetching { + gui.g.StartTicking(ctx) + } else { + ctx.Done() + } +} + +func SafeWithError(f func() error) error { + panicking := true + defer func() { + if panicking && gocui.Screen != nil { + gocui.Screen.Fini() + } + }() + + err := f() + + panicking = false + + return err +} + +// recoverScreenOnCrash MUST be deferred in EVERY goroutine that the TUI spawns to ensure that a +// panic doesn't leave the user's terminal in a broken state. +func recoverScreenOnCrash() { + if r := recover(); r != nil { + gocui.Screen.Fini() + panic(r) + } +} + +func (gui *Gui) RunAndHandleError() error { + return SafeWithError(func() error { + if err := gui.run(); err != nil { + switch err { + case gocui.ErrQuit: + return nil + default: + utils.Print(err.Error()) + return err + } + } + + return nil + }) +} diff --git a/pkg/tui/gui/layout.go b/pkg/tui/gui/layout.go new file mode 100644 index 00000000..35f9e301 --- /dev/null +++ b/pkg/tui/gui/layout.go @@ -0,0 +1,220 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +import ( + "fmt" + "math" + "strings" + + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/DopplerHQ/gocui" + "github.com/jesseduffield/lazycore/pkg/boxlayout" +) + +func (gui *Gui) getWindowDimensions() map[string]boxlayout.Dimensions { + width, height := gui.g.Size() + + sideSectionWeight, mainSectionWeight := gui.getSectionWeights() + + root := &boxlayout.Box{ + Direction: boxlayout.COLUMN, + Weight: 1, + Children: []*boxlayout.Box{ + {Direction: boxlayout.ROW, Weight: sideSectionWeight, Children: gui.sideSectionChildren()}, + {Direction: boxlayout.ROW, Weight: mainSectionWeight, Children: gui.mainSectionChildren()}, + }, + } + + return boxlayout.ArrangeWindows(root, 0, 0, width, height) +} + +func (gui *Gui) mainSectionChildren() []*boxlayout.Box { + return []*boxlayout.Box{ + {Window: "Secrets", Weight: 1}, + {Size: 3, Direction: boxlayout.COLUMN, Children: []*boxlayout.Box{ + {Window: "Status", Weight: 2}, + {Window: "SecretsFilter", Weight: 1}, + }}, + } +} + +func (gui *Gui) getSectionWeights() (int, int) { + sidePanelWidthRatio := 0.2 + mainSectionWeight := int(1/sidePanelWidthRatio) - 1 + sideSectionWeight := 1 + return sideSectionWeight, mainSectionWeight +} + +func (gui *Gui) sideSectionChildren() []*boxlayout.Box { + return []*boxlayout.Box{ + {Window: "Configs", Weight: 1}, + {Window: "Projects", Weight: 1}, + } +} + +func (gui *Gui) layout(g *gocui.Gui) error { + viewDimensions := gui.getWindowDimensions() + + // We assume that the view has already been created. + setViewFromDimensions := func(viewName string) (*gocui.View, error) { + dimensionsObj, ok := viewDimensions[viewName] + if !ok { + return nil, fmt.Errorf("could not find dimensions for view %s", viewName) + } + + view, err := g.View(viewName) + if err != nil { + return nil, err + } + + frameOffset := 1 + if view.Frame { + frameOffset = 0 + } + _, err = g.SetView( + viewName, + dimensionsObj.X0-frameOffset, + dimensionsObj.Y0-frameOffset, + dimensionsObj.X1+frameOffset, + dimensionsObj.Y1+frameOffset, + 0, + ) + + return view, err + } + + setSecretViewSizes := func() error { + gui.mutexes.SecretViewsMutex.Lock() + defer gui.mutexes.SecretViewsMutex.Unlock() + + secX0, secY0, secX1, secY1 := gui.cmps.secrets.view.Dimensions() + secWidth := gui.cmps.secrets.view.InnerWidth() + + svmNameX := secX0 + 1 + svmNameWidth := int(math.Floor(float64(secWidth) * 0.3)) + svmValueX := secX0 + svmNameWidth + 2 + + asvm := gui.cmps.secrets.activeSVM + + // The Y positions of the secret views are dependent on how much we need to offset to ensure + // that the active SVM is always fully within the bounds of the secrets view + if asvm != nil { + // We use the value view because the name view will always be one line tall + _, asvmY0, _, asvmY1 := asvm.valueView.Dimensions() + if asvmY1 >= secY1 { + gui.cmps.secrets.scrollDelta += (asvmY1 - secY1) + 1 + } else if asvmY0 <= secY0 { + gui.cmps.secrets.scrollDelta += (asvmY0 - secY0) - 1 + } + } + curY := secY0 - gui.cmps.secrets.scrollDelta + 1 + + for _, svm := range gui.cmps.secrets.visibleSVMs() { + numLines := len(strings.Split(svm.valueView.TextArea.GetContent(), "\n")) + valueHeight := utils.Clamp(numLines, 1, 8) + + _, err := g.SetView(svm.nameView.Name(), svmNameX, curY, svmNameX+svmNameWidth, curY+2, 0) + if err != nil { + return err + } + + _, err = g.SetView(svm.valueView.Name(), svmValueX, curY, secX1-1, curY+1+valueHeight, 0) + if err != nil { + return err + } + + if svm == asvm { + // If possible, we want to pin the Y origin at the top (which improves the behavior of the + // textarea as it's resizing). gocui is smart enough to adjust the origin down if the cursor + // is out of bounds of the view size. + svm.valueView.SetOriginY(0) + svm.valueView.RenderTextArea() + } + + curY += valueHeight + 2 + } + + return nil + } + + centerPrompt := func(viewName string, width int, height int) error { + winWidth, winHeight := gui.g.Size() + + _, err := g.SetView( + viewName, + winWidth/2-width/2, + winHeight/2-height/2, + winWidth/2+width/2, + winHeight/2+int(math.Ceil(float64(height)/2.0)), + 0, + ) + if err != nil { + return err + } + return nil + } + + setPromptSaveSize := func() error { + width := 80 + height := utils.Clamp(gui.cmps.promptSave.GetView().LinesHeight(), 2, 10) + return centerPrompt(gui.cmps.promptSave.GetView().Name(), width, height) + } + + setPromptHelpSize := func() error { + width := 80 + height := gui.cmps.promptHelp.GetView().LinesHeight() + 1 + return centerPrompt(gui.cmps.promptHelp.GetView().Name(), width, height) + } + + setPromptIntroSize := func() error { + width := 80 + height := gui.cmps.promptIntro.GetView().LinesHeight() + 1 + return centerPrompt(gui.cmps.promptIntro.GetView().Name(), width, height) + } + + if _, err := setViewFromDimensions("Configs"); err != nil { + return err + } + if _, err := setViewFromDimensions("Projects"); err != nil { + return err + } + if _, err := setViewFromDimensions("Secrets"); err != nil { + return err + } + if _, err := setViewFromDimensions("Status"); err != nil { + return err + } + if _, err := setViewFromDimensions("SecretsFilter"); err != nil { + return err + } + + if err := setSecretViewSizes(); err != nil { + return err + } + + if err := setPromptSaveSize(); err != nil { + return err + } + if err := setPromptHelpSize(); err != nil { + return err + } + if err := setPromptIntroSize(); err != nil { + return err + } + + return nil +} diff --git a/pkg/tui/gui/list_utils.go b/pkg/tui/gui/list_utils.go new file mode 100644 index 00000000..7baaef32 --- /dev/null +++ b/pkg/tui/gui/list_utils.go @@ -0,0 +1,53 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package gui + +func SelectIdx(cmp Component, idx int, maxIdx int) (int, error) { + if idx < 0 { + return 0, nil + } + + if idx > maxIdx { + return maxIdx, nil + } + + view := cmp.GetView() + + minVisible := view.OriginY() + maxVisible := minVisible + view.InnerHeight() + + if idx >= minVisible && idx <= maxVisible { + if err := view.SetCursor(0, (idx - minVisible)); err != nil { + return 0, err + } + } else if idx < minVisible { + if err := view.SetOriginY(idx); err != nil { + return 0, err + } + if err := view.SetCursor(0, 0); err != nil { + return 0, err + } + } else { + if err := view.SetOriginY(idx - view.InnerHeight()); err != nil { + return 0, err + } + if err := view.SetCursor(0, view.InnerHeight()); err != nil { + return 0, err + } + } + + return idx, nil +} diff --git a/pkg/tui/gui/state/state.go b/pkg/tui/gui/state/state.go new file mode 100644 index 00000000..fed054b6 --- /dev/null +++ b/pkg/tui/gui/state/state.go @@ -0,0 +1,96 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package state + +import ( + "time" + + "github.com/DopplerHQ/cli/pkg/models" +) + +type Project struct { + Name string +} + +type Config struct { + Name string + Environment string + Root bool +} + +type Secret struct { + Name string + Value string + Visibility string +} + +type ByName []Secret + +func (x ByName) Len() int { return len(x) } +func (x ByName) Less(i, j int) bool { return x[i].Name < x[j].Name } +func (x ByName) Swap(i, j int) { x[i], x[j] = x[j], x[i] } + +type State struct { + projects []Project + configs []Config + secrets []Secret + secretsSetAt int64 + + active struct { + project string + config string + } + + filter string + changes []models.ChangeRequest +} + +var state *State + +func init() { + projects := make([]Project, 0) + configs := make([]Config, 0) + secrets := make([]Secret, 0) + + state = &State{ + projects: projects, + configs: configs, + secrets: secrets, + } +} + +func Projects() []Project { return state.projects } +func SetProjects(projects []Project) { state.projects = projects } + +func Configs() []Config { return state.configs } +func SetConfigs(configs []Config) { state.configs = configs } + +func Secrets() []Secret { return state.secrets } +func SecretsSetAt() int64 { return state.secretsSetAt } +func SetSecrets(secrets []Secret, projectName string, configName string) { + state.secrets = secrets + state.secretsSetAt = time.Now().Unix() + state.active.project = projectName + state.active.config = configName +} + +func Active() (string, string) { return state.active.project, state.active.config } + +func Filter() string { return state.filter } +func SetFilter(filter string) { state.filter = filter } + +func Changes() []models.ChangeRequest { return state.changes } +func SetChanges(changes []models.ChangeRequest) { state.changes = changes } diff --git a/pkg/tui/tui_app.go b/pkg/tui/tui_app.go new file mode 100644 index 00000000..485fe27c --- /dev/null +++ b/pkg/tui/tui_app.go @@ -0,0 +1,48 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tui + +import ( + "log" + + "github.com/DopplerHQ/cli/pkg/models" + "github.com/DopplerHQ/cli/pkg/tui/common" + "github.com/DopplerHQ/cli/pkg/tui/gui" +) + +type App struct { + *common.Common + gui *gui.Gui +} + +func Start(opts models.ScopedOptions) { + cmn, err := common.NewCommon(opts) + if err != nil { + log.Fatal(err) + } + + gui, err := gui.NewGui(cmn) + if err != nil { + log.Fatal(err) + } + + app := &App{ + Common: cmn, + gui: gui, + } + + app.gui.RunAndHandleError() +} diff --git a/pkg/utils/config.go b/pkg/utils/config.go index 090dfd50..f048f347 100644 --- a/pkg/utils/config.go +++ b/pkg/utils/config.go @@ -18,6 +18,9 @@ package utils // Debug whether we're running in debug mode var Debug = false +// DebugTUI whether to log TUI debug messages to $config_dir/tui.log +var DebugTUI = false + // Silent whether we should display Info messages var Silent = false diff --git a/pkg/utils/number.go b/pkg/utils/number.go new file mode 100644 index 00000000..89185d3f --- /dev/null +++ b/pkg/utils/number.go @@ -0,0 +1,39 @@ +/* +Copyright © 2023 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package utils + +func Min(x, y int) int { + if x < y { + return x + } + return y +} + +func Max(x, y int) int { + if x > y { + return x + } + return y +} + +func Clamp(x int, min int, max int) int { + if x < min { + return min + } else if x > max { + return max + } + return x +}